fix(whatsapp): stabilize auth state and reconcile local runtime after CLI login (#67815)

* WhatsApp: harden auth persistence and backup recovery

* WhatsApp: model unstable auth state across runtime and setup

* WhatsApp: recover login and monitor startup from unstable auth

* Channels: surface auth stabilizing in status and health

* Gateway protocol: add channels.start surface

* Gateway: reconcile local channel runtime after CLI login

* Channels UI: reflect recovered login start state

* Changelog: note WhatsApp auth stabilization

* Gateway: fix lint in call test
This commit is contained in:
Marcus Castro
2026-04-19 14:20:46 -03:00
committed by GitHub
parent 1d4e4314dd
commit aa76cf43f0
53 changed files with 2191 additions and 366 deletions

View File

@@ -77,6 +77,7 @@ Docs: https://docs.openclaw.ai
- macOS/remote SSH: require an already-trusted host key on the macOS remote command, gateway probe, port tunnel, and pairing probe paths by switching `StrictHostKeyChecking=accept-new` to `StrictHostKeyChecking=yes` and centralizing the shared SSH option fragments in `CommandResolver`, so first-time macOS remote connections no longer silently accept an unknown host key and must be trusted ahead of time via `~/.ssh/known_hosts`. (#68199)
- CLI/configure: show the channel picker before probing statuses and let remove mode delete configured channel blocks directly from config. (#68007) Thanks @gumadeiras.
- Control UI/settings: reset scroll position when switching settings pages and align details headers. (#68150) Thanks @BunsDev.
- WhatsApp/gateway: harden WhatsApp auth persistence and backup recovery, model unstable auth state explicitly in setup/status/health, recover backup-backed login without forcing a fresh QR, and keep local gateway handoff and channel restarts truthful after login. Thanks @mcaxtr.
- OpenAI Codex/OAuth: keep OpenClaw as the canonical owner for imported Codex CLI OAuth sessions, stop writing refreshed credentials back into `.codex`, and prefer fresher OpenClaw credentials over stale imported CLI state so refresh recovery stays stable. Thanks @vincentkoc.
- OpenAI Codex/OAuth: treat the OpenAI TLS prerequisites probe as advisory instead of a hard blocker, so Codex sign-in can still proceed when the speculative Node/OpenSSL precheck fails but the real OAuth flow still works. Thanks @vincentkoc.
- Models status/OAuth health: align OAuth health reporting with the same effective credential view runtime uses, so expired refreshable sessions stop showing healthy by default and fresher imported Codex CLI credentials surface correctly in `models status`, doctor, and gateway auth status. Thanks @vincentkoc.

View File

@@ -60,7 +60,7 @@ extension ChannelsStore {
timeoutMs: 35000)
self.whatsappLoginMessage = result.message
self.whatsappLoginQrDataUrl = result.qrDataUrl
self.whatsappLoginConnected = nil
self.whatsappLoginConnected = result.connected
shouldAutoWait = autoWait && result.qrDataUrl != nil
} catch {
self.whatsappLoginMessage = error.localizedDescription
@@ -148,6 +148,7 @@ extension ChannelsStore {
private struct WhatsAppLoginStartResult: Codable {
let qrDataUrl: String?
let message: String
let connected: Bool?
}
private struct WhatsAppLoginWaitResult: Codable {

View File

@@ -2481,6 +2481,24 @@ public struct ChannelsStatusResult: Codable, Sendable {
}
}
public struct ChannelsStartParams: Codable, Sendable {
public let channel: String
public let accountid: String?
public init(
channel: String,
accountid: String?)
{
self.channel = channel
self.accountid = accountid
}
private enum CodingKeys: String, CodingKey {
case channel
case accountid = "accountId"
}
}
public struct ChannelsLogoutParams: Codable, Sendable {
public let channel: String
public let accountid: String?

View File

@@ -2481,6 +2481,24 @@ public struct ChannelsStatusResult: Codable, Sendable {
}
}
public struct ChannelsStartParams: Codable, Sendable {
public let channel: String
public let accountid: String?
public init(
channel: String,
accountid: String?)
{
self.channel = channel
self.accountid = accountid
}
private enum CodingKeys: String, CodingKey {
case channel
case accountid = "accountId"
}
}
public struct ChannelsLogoutParams: Codable, Sendable {
public let channel: String
public let accountid: String?

View File

@@ -0,0 +1,203 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
logoutWeb,
pickWebChannel,
readWebAuthSnapshot,
readWebAuthState,
restoreCredsFromBackupIfNeeded,
webAuthExists,
WhatsAppAuthUnstableError,
WHATSAPP_AUTH_UNSTABLE_CODE,
} from "./auth-store.js";
import type { CredsQueueWaitResult } from "./creds-persistence.js";
const hoisted = vi.hoisted(() => ({
waitForCredsSaveQueueWithTimeout: vi.fn<() => Promise<CredsQueueWaitResult>>(
async () => "drained",
),
}));
vi.mock("./creds-persistence.js", async () => {
const actual =
await vi.importActual<typeof import("./creds-persistence.js")>("./creds-persistence.js");
return {
...actual,
waitForCredsSaveQueueWithTimeout: hoisted.waitForCredsSaveQueueWithTimeout,
};
});
function createTempAuthDir(prefix: string) {
return fsSync.mkdtempSync(
path.join((process.env.TMPDIR ?? "/tmp").replace(/\/+$/, ""), `${prefix}-`),
);
}
describe("auth-store", () => {
beforeEach(() => {
hoisted.waitForCredsSaveQueueWithTimeout.mockReset().mockResolvedValue("drained");
});
it("does not restore creds from backup on ordinary reads", async () => {
const authDir = createTempAuthDir("openclaw-wa-auth-read");
const credsPath = path.join(authDir, "creds.json");
const backupPath = path.join(authDir, "creds.json.bak");
fsSync.writeFileSync(backupPath, JSON.stringify({ me: { id: "123@s.whatsapp.net" } }), "utf-8");
await expect(webAuthExists(authDir)).resolves.toBe(false);
expect(fsSync.existsSync(credsPath)).toBe(false);
});
it("restores creds from a regular backup file", async () => {
const authDir = createTempAuthDir("openclaw-wa-auth-restore");
const credsPath = path.join(authDir, "creds.json");
fsSync.writeFileSync(credsPath, "{", "utf-8");
fsSync.writeFileSync(
path.join(authDir, "creds.json.bak"),
JSON.stringify({ me: { id: "123@s.whatsapp.net" } }),
"utf-8",
);
await expect(restoreCredsFromBackupIfNeeded(authDir)).resolves.toBe(true);
expect(JSON.parse(fsSync.readFileSync(credsPath, "utf-8"))).toEqual({
me: { id: "123@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");
const backupPath = path.join(authDir, "creds.json.bak");
const credsPath = path.join(authDir, "creds.json");
fsSync.writeFileSync(targetPath, JSON.stringify({ me: { id: "123@s.whatsapp.net" } }), "utf-8");
fsSync.symlinkSync(targetPath, backupPath);
fsSync.writeFileSync(credsPath, "{", "utf-8");
await expect(restoreCredsFromBackupIfNeeded(authDir)).resolves.toBe(false);
expect(fsSync.readFileSync(credsPath, "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(
path.join(authDir, "creds.json"),
JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }),
"utf-8",
);
await expect(readWebAuthState(authDir)).resolves.toBe("linked");
await expect(readWebAuthSnapshot(authDir)).resolves.toMatchObject({
state: "linked",
authAgeMs: expect.any(Number),
selfId: expect.objectContaining({ e164: "+15551234567" }),
});
});
it("reports unstable auth state when the shared barrier read times out", async () => {
const authDir = createTempAuthDir("openclaw-wa-auth-unstable-state");
fsSync.writeFileSync(
path.join(authDir, "creds.json"),
JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }),
"utf-8",
);
hoisted.waitForCredsSaveQueueWithTimeout
.mockResolvedValueOnce("timed_out")
.mockResolvedValueOnce("timed_out");
await expect(readWebAuthState(authDir)).resolves.toBe("unstable");
await expect(readWebAuthSnapshot(authDir)).resolves.toEqual({
state: "unstable",
authAgeMs: null,
selfId: { e164: null, jid: null, lid: null },
});
});
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",
);
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);
});
it("does not delete the whole legacy auth root when targeted cleanup fails", async () => {
const authDir = createTempAuthDir("openclaw-wa-auth-legacy-failure");
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");
const originalRm = fs.rm;
const rmSpy = vi.spyOn(fs, "rm").mockImplementation(async (target, options) => {
if (String(target).endsWith("creds.json")) {
throw Object.assign(new Error("EACCES"), { code: "EACCES" });
}
return await originalRm.call(fs, target, options as never);
});
const runtime = {
log: vi.fn(),
error: vi.fn(),
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();
});
it("clears auth state even when directory enumeration fails", async () => {
const authDir = createTempAuthDir("openclaw-wa-auth-readdir");
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 unrelated non-empty directories on logout", async () => {
const authDir = createTempAuthDir("openclaw-wa-auth-unrelated");
fsSync.writeFileSync(path.join(authDir, "notes.txt"), "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(false);
expect(fsSync.existsSync(authDir)).toBe(true);
expect(fsSync.existsSync(path.join(authDir, "notes.txt"))).toBe(true);
});
it("throws a typed unstable-auth error when channel selection times out", async () => {
hoisted.waitForCredsSaveQueueWithTimeout.mockResolvedValueOnce("timed_out");
await expect(pickWebChannel("auto", "/tmp/openclaw-wa-auth-unstable")).rejects.toEqual(
expect.objectContaining({
code: WHATSAPP_AUTH_UNSTABLE_CODE,
name: WhatsAppAuthUnstableError.name,
}),
);
});
});

View File

@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
@@ -8,10 +9,29 @@ import { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
import { defaultRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { resolveOAuthDir } from "./auth-store.runtime.js";
import { hasWebCredsSync, resolveWebCredsBackupPath, resolveWebCredsPath } from "./creds-files.js";
import {
waitForCredsSaveQueueWithTimeout,
type CredsQueueWaitResult,
} from "./creds-persistence.js";
import { resolveComparableIdentity, type WhatsAppSelfIdentity } from "./identity.js";
import { resolveUserPath, type WebChannel } from "./text-runtime.js";
export { hasWebCredsSync, resolveWebCredsBackupPath, resolveWebCredsPath };
export const WHATSAPP_AUTH_UNSTABLE_CODE = "whatsapp-auth-unstable";
const authStoreLogger = getChildLogger({ module: "web-auth-store" });
const emptyWebSelfId = () => ({ e164: null, jid: null, lid: null }) as const;
export type WhatsAppWebAuthState = "linked" | "not-linked" | "unstable";
export class WhatsAppAuthUnstableError extends Error {
readonly code = WHATSAPP_AUTH_UNSTABLE_CODE;
constructor(message = "WhatsApp auth state is still stabilizing; retry shortly.") {
super(message);
this.name = "WhatsAppAuthUnstableError";
}
}
export function resolveDefaultWebAuthDir(): string {
return path.join(resolveOAuthDir(), "whatsapp", DEFAULT_ACCOUNT_ID);
}
@@ -33,8 +53,26 @@ export function readCredsJsonRaw(filePath: string): string | null {
}
}
export function maybeRestoreCredsFromBackup(authDir: string): void {
async function waitForWebAuthBarrier(
authDir: string,
context: string,
): Promise<CredsQueueWaitResult> {
const result = await waitForCredsSaveQueueWithTimeout(authDir);
if (result === "timed_out") {
authStoreLogger.warn(
{
authDir,
context,
},
"timed out waiting for queued WhatsApp creds save before auth read",
);
}
return result;
}
export async function restoreCredsFromBackupIfNeeded(authDir: string): Promise<boolean> {
const logger = getChildLogger({ module: "web-session" });
let tempRestorePath: string | null = null;
try {
const credsPath = resolveWebCredsPath(authDir);
const backupPath = resolveWebCredsBackupPath(authDir);
@@ -42,31 +80,44 @@ export function maybeRestoreCredsFromBackup(authDir: string): void {
if (raw) {
// Validate that creds.json is parseable.
JSON.parse(raw);
return;
return false;
}
const backupRaw = readCredsJsonRaw(backupPath);
if (!backupRaw) {
return;
return false;
}
const backupStats = await fs.lstat(backupPath).catch(() => null);
if (!backupStats?.isFile()) {
return false;
}
// Ensure backup is parseable before restoring.
JSON.parse(backupRaw);
fsSync.copyFileSync(backupPath, credsPath);
try {
fsSync.chmodSync(credsPath, 0o600);
} catch {
// best-effort on platforms that support it
}
tempRestorePath = path.join(authDir, `.creds.restore-${randomUUID()}.tmp`);
await fs.writeFile(tempRestorePath, backupRaw, {
encoding: "utf-8",
mode: 0o600,
flag: "wx",
});
await fs.rename(tempRestorePath, credsPath);
tempRestorePath = null;
logger.warn({ credsPath }, "restored corrupted WhatsApp creds.json from backup");
return true;
} catch {
// ignore
} finally {
if (tempRestorePath) {
await fs.rm(tempRestorePath, { force: true }).catch(() => {
// best-effort temp cleanup
});
}
}
return false;
}
export async function webAuthExists(authDir: string = resolveDefaultWebAuthDir()) {
const resolvedAuthDir = resolveUserPath(authDir);
maybeRestoreCredsFromBackup(resolvedAuthDir);
const credsPath = resolveWebCredsPath(resolvedAuthDir);
try {
await fs.access(resolvedAuthDir);
@@ -86,6 +137,89 @@ export async function webAuthExists(authDir: string = resolveDefaultWebAuthDir()
}
}
function resolveWebAuthState(params: {
linked: boolean;
barrierResult: CredsQueueWaitResult;
}): WhatsAppWebAuthState {
if (params.barrierResult === "timed_out") {
return "unstable";
}
return params.linked ? "linked" : "not-linked";
}
async function readWebAuthStateCore(
authDir: string,
context: string,
): Promise<{ authDir: string; linked: boolean; state: WhatsAppWebAuthState }> {
const resolvedAuthDir = resolveUserPath(authDir);
const barrierResult = await waitForWebAuthBarrier(resolvedAuthDir, context);
const linked = await webAuthExists(resolvedAuthDir);
return {
authDir: resolvedAuthDir,
linked,
state: resolveWebAuthState({ linked, barrierResult }),
};
}
export function formatWhatsAppWebAuthStatusState(state: WhatsAppWebAuthState): string {
switch (state) {
case "linked":
return "linked";
case "not-linked":
return "not linked";
case "unstable":
return "auth stabilizing";
}
const exhaustive: never = state;
return exhaustive;
}
export async function readWebAuthState(
authDir: string = resolveDefaultWebAuthDir(),
): Promise<WhatsAppWebAuthState> {
return (await readWebAuthStateCore(authDir, "readWebAuthState")).state;
}
export async function readWebAuthSnapshot(authDir: string = resolveDefaultWebAuthDir()) {
const auth = await readWebAuthStateCore(authDir, "readWebAuthSnapshot");
return {
state: auth.state,
authAgeMs: auth.state === "linked" ? getWebAuthAgeMs(auth.authDir) : null,
selfId: auth.state === "linked" ? readWebSelfId(auth.authDir) : emptyWebSelfId(),
} as const;
}
export async function readWebAuthExistsBestEffort(authDir: string = resolveDefaultWebAuthDir()) {
const state = await readWebAuthState(authDir);
return {
exists: state === "linked",
timedOut: state === "unstable",
} as const;
}
export async function readWebAuthExistsForDecision(
authDir: string = resolveDefaultWebAuthDir(),
): Promise<{ outcome: "stable"; exists: boolean } | { outcome: "unstable" }> {
const state = await readWebAuthState(authDir);
if (state === "unstable") {
return { outcome: "unstable" };
}
return {
outcome: "stable",
exists: state === "linked",
};
}
export async function readWebAuthSnapshotBestEffort(authDir: string = resolveDefaultWebAuthDir()) {
const snapshot = await readWebAuthSnapshot(authDir);
return {
linked: snapshot.state === "linked",
timedOut: snapshot.state === "unstable",
authAgeMs: snapshot.authAgeMs,
selfId: snapshot.selfId,
} as const;
}
async function clearLegacyBaileysAuthState(authDir: string) {
const entries = await fs.readdir(authDir, { withFileTypes: true });
const shouldDelete = (name: string) => {
@@ -113,6 +247,45 @@ 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;
}
if (isLegacyAuthDir) {
const entries = await fs.readdir(authDir, { withFileTypes: true });
return entries.some((entry) => {
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;
});
}
const credsStats = await fs.stat(resolveWebCredsPath(authDir)).catch(() => null);
if (credsStats?.isFile()) {
return true;
}
const backupStats = await fs.stat(resolveWebCredsBackupPath(authDir)).catch(() => null);
return backupStats?.isFile() === true;
} catch (error) {
const codeValue =
error && typeof error === "object" && "code" in error
? (error as { code?: unknown }).code
: undefined;
const code = typeof codeValue === "string" ? codeValue : "";
return code !== "ENOENT";
}
}
export async function logoutWeb(params: {
authDir?: string;
isLegacyAuthDir?: boolean;
@@ -120,8 +293,13 @@ export async function logoutWeb(params: {
}) {
const runtime = params.runtime ?? defaultRuntime;
const resolvedAuthDir = resolveUserPath(params.authDir ?? resolveDefaultWebAuthDir());
const exists = await webAuthExists(resolvedAuthDir);
if (!exists) {
const barrierResult = await waitForWebAuthBarrier(resolvedAuthDir, "logoutWeb");
if (barrierResult === "timed_out") {
runtime.log(
info("WhatsApp auth state is still stabilizing; clearing cached credentials anyway."),
);
}
if (!(await shouldClearOnLogout(resolvedAuthDir, Boolean(params.isLegacyAuthDir)))) {
runtime.log(info("No WhatsApp Web session found; nothing to delete."));
return false;
}
@@ -139,7 +317,7 @@ export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) {
try {
const credsPath = resolveWebCredsPath(resolveUserPath(authDir));
if (!fsSync.existsSync(credsPath)) {
return { e164: null, jid: null, lid: null } as const;
return emptyWebSelfId();
}
const raw = fsSync.readFileSync(credsPath, "utf-8");
const parsed = JSON.parse(raw) as { me?: { id?: string; lid?: string } } | undefined;
@@ -156,7 +334,7 @@ export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) {
lid: identity.lid ?? null,
} as const;
} catch {
return { e164: null, jid: null, lid: null } as const;
return emptyWebSelfId();
}
}
@@ -165,7 +343,6 @@ export async function readWebSelfIdentity(
fallback?: { id?: string | null; lid?: string | null } | null,
): Promise<WhatsAppSelfIdentity> {
const resolvedAuthDir = resolveUserPath(authDir);
maybeRestoreCredsFromBackup(resolvedAuthDir);
try {
const raw = await fs.readFile(resolveWebCredsPath(resolvedAuthDir), "utf-8");
const parsed = JSON.parse(raw) as { me?: { id?: string; lid?: string } } | undefined;
@@ -187,6 +364,21 @@ export async function readWebSelfIdentity(
}
}
export async function readWebSelfIdentityForDecision(
authDir: string = resolveDefaultWebAuthDir(),
fallback?: { id?: string | null; lid?: string | null } | null,
): Promise<{ outcome: "stable"; identity: WhatsAppSelfIdentity } | { outcome: "unstable" }> {
const resolvedAuthDir = resolveUserPath(authDir);
const result = await waitForWebAuthBarrier(resolvedAuthDir, "readWebSelfIdentityForDecision");
if (result === "timed_out") {
return { outcome: "unstable" };
}
return {
outcome: "stable",
identity: await readWebSelfIdentity(resolvedAuthDir, fallback),
};
}
/**
* Return the age (in milliseconds) of the cached WhatsApp web auth state, or null when missing.
* Helpful for heartbeats/observability to spot stale credentials.
@@ -223,8 +415,11 @@ export async function pickWebChannel(
authDir: string = resolveDefaultWebAuthDir(),
): Promise<WebChannel> {
const choice: WebChannel = pref === "auto" ? "web" : pref;
const hasWeb = await webAuthExists(authDir);
if (!hasWeb) {
const auth = await readWebAuthExistsForDecision(authDir);
if (auth.outcome === "unstable") {
throw new WhatsAppAuthUnstableError();
}
if (!auth.exists) {
throw new Error(
`No WhatsApp Web session found. Run \`${formatCliCommand("openclaw channels login --channel whatsapp --verbose")}\` to link.`,
);

View File

@@ -6,6 +6,7 @@ import { setLoggerOverride } from "openclaw/plugin-sdk/runtime-env";
import { withEnvAsync } from "openclaw/plugin-sdk/testing";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js";
import { WhatsAppAuthUnstableError } from "./auth-store.js";
import {
createWebInboundDeliverySpies,
createMockWebListener,
@@ -176,6 +177,59 @@ describe("web auto-reply connection", () => {
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Stopping web monitoring"));
});
it("retries inbox attach when auth state is still stabilizing", async () => {
const sleep = vi.fn(async () => {});
const listenerFactory = vi.fn(async () => {
if (listenerFactory.mock.calls.length === 1) {
throw new WhatsAppAuthUnstableError(
"WhatsApp auth state is still stabilizing; retrying inbox attach.",
);
}
return createMockWebListener();
});
const { runtime, controller, run } = startWebAutoReplyMonitor({
monitorWebChannelFn: monitorWebChannel as never,
listenerFactory,
sleep,
reconnect: { initialMs: 5, maxMs: 5, maxAttempts: 3, factor: 1.1 },
});
await vi.waitFor(
() => {
expect(listenerFactory).toHaveBeenCalledTimes(2);
},
{ timeout: 250, interval: 2 },
);
controller.abort();
await run;
expect(sleep).toHaveBeenCalledWith(expect.any(Number), expect.any(AbortSignal));
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("inbox attach"));
});
it("stops retrying inbox attach when auth stays unstable past max attempts", async () => {
const sleep = vi.fn(async () => {});
const listenerFactory = vi.fn(async () => {
throw new WhatsAppAuthUnstableError(
"WhatsApp auth state is still stabilizing; retrying inbox attach.",
);
});
const { runtime, run } = startWebAutoReplyMonitor({
monitorWebChannelFn: monitorWebChannel as never,
listenerFactory,
sleep,
reconnect: { initialMs: 5, maxMs: 5, maxAttempts: 2, factor: 1.1 },
});
await run;
expect(listenerFactory).toHaveBeenCalledTimes(2);
expect(sleep).toHaveBeenCalledTimes(1);
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Retry 1/2"));
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Stopping web monitoring"));
});
it("forces reconnect when watchdog closes without onClose", async () => {
vi.useFakeTimers();
try {
@@ -508,7 +562,8 @@ describe("web auto-reply connection", () => {
const sendComposing = vi.fn().mockResolvedValue(undefined);
const sendMedia = vi.fn().mockResolvedValue(undefined);
const replyResolver = vi.fn().mockImplementation(async (_ctx, opts) => {
const replyResolver = vi.fn().mockImplementation(async (ctx, opts) => {
void ctx;
opts?.onTypingController?.(typingMock);
return { text: "final reply" };
});

View File

@@ -15,6 +15,7 @@ import {
type RuntimeEnv,
} from "openclaw/plugin-sdk/runtime-env";
import { resolveWhatsAppAccount, resolveWhatsAppMediaMaxBytes } from "../accounts.js";
import { WHATSAPP_AUTH_UNSTABLE_CODE, WhatsAppAuthUnstableError } from "../auth-store.js";
import {
WhatsAppConnectionController,
type ManagedWhatsAppListener,
@@ -85,6 +86,16 @@ function resolveExplicitWhatsAppDebounceOverride(params: {
return channel.debounceMs;
}
function isRetryableAuthUnstableError(error: unknown): error is WhatsAppAuthUnstableError {
return (
error instanceof WhatsAppAuthUnstableError ||
(typeof error === "object" &&
error !== null &&
"code" in error &&
(error as { code?: unknown }).code === WHATSAPP_AUTH_UNSTABLE_CODE)
);
}
export async function monitorWebChannel(
verbose: boolean,
listenerFactory: typeof attachWebInboxToSocket | undefined = attachWebInboxToSocket,
@@ -101,7 +112,6 @@ export async function monitorWebChannel(
const heartbeatLogger = getChildLogger({ module: "web-heartbeat", runId });
const reconnectLogger = getChildLogger({ module: "web-reconnect", runId });
const statusController = createWebChannelStatusController(tuning.statusSink);
const _status = statusController.snapshot();
statusController.emit();
const baseCfg = loadConfig();
@@ -215,89 +225,138 @@ export async function monitorWebChannel(
return !hasControlCommand(msg.body, cfg);
};
const connection = await controller.openConnection({
connectionId,
createListener: async ({ sock, connection }) => {
const onMessage = createWebOnMessageHandler({
cfg,
verbose,
connectionId,
maxMediaBytes,
groupHistoryLimit,
groupHistories,
groupMemberNames,
echoTracker,
backgroundTasks: connection.backgroundTasks,
replyResolver: activeReplyResolver,
replyLogger,
baseMentionConfig,
account,
});
let connection;
try {
connection = await controller.openConnection({
connectionId,
createListener: async ({ sock, connection }) => {
const onMessage = createWebOnMessageHandler({
cfg,
verbose,
connectionId,
maxMediaBytes,
groupHistoryLimit,
groupHistories,
groupMemberNames,
echoTracker,
backgroundTasks: connection.backgroundTasks,
replyResolver: activeReplyResolver,
replyLogger,
baseMentionConfig,
account,
});
return (await (listenerFactory ?? attachWebInboxToSocket)({
verbose,
accountId: account.accountId,
authDir: account.authDir,
mediaMaxMb: account.mediaMaxMb,
selfChatMode: account.selfChatMode,
sendReadReceipts: account.sendReadReceipts,
debounceMs: inboundDebounceMs,
shouldDebounce,
socketRef: controller.socketRef,
shouldRetryDisconnect: () => !sigintStop && controller.shouldRetryDisconnect(),
disconnectRetryPolicy: reconnectPolicy,
disconnectRetryAbortSignal: controller.getDisconnectRetryAbortSignal(),
onMessage: async (msg: WebInboundMsg) => {
const inboundAt = Date.now();
controller.noteInbound(inboundAt);
statusController.noteInbound(inboundAt);
await onMessage(msg);
},
sock,
})) as ManagedWhatsAppListener;
},
onHeartbeat: (snapshot) => {
const authAgeMs = getWebAuthAgeMs(account.authDir);
const minutesSinceLastMessage = snapshot.lastInboundAt
? Math.floor((Date.now() - snapshot.lastInboundAt) / 60000)
: null;
return (await (listenerFactory ?? attachWebInboxToSocket)({
verbose,
accountId: account.accountId,
authDir: account.authDir,
mediaMaxMb: account.mediaMaxMb,
selfChatMode: account.selfChatMode,
sendReadReceipts: account.sendReadReceipts,
debounceMs: inboundDebounceMs,
shouldDebounce,
socketRef: controller.socketRef,
shouldRetryDisconnect: () => !sigintStop && controller.shouldRetryDisconnect(),
disconnectRetryPolicy: reconnectPolicy,
disconnectRetryAbortSignal: controller.getDisconnectRetryAbortSignal(),
onMessage: async (msg: WebInboundMsg) => {
const inboundAt = Date.now();
controller.noteInbound(inboundAt);
statusController.noteInbound(inboundAt);
await onMessage(msg);
},
sock,
})) as ManagedWhatsAppListener;
},
onHeartbeat: (snapshot) => {
const authAgeMs = getWebAuthAgeMs(account.authDir);
const minutesSinceLastMessage = snapshot.lastInboundAt
? Math.floor((Date.now() - snapshot.lastInboundAt) / 60000)
: null;
const logData = {
connectionId: snapshot.connectionId,
reconnectAttempts: snapshot.reconnectAttempts,
messagesHandled: snapshot.handledMessages,
lastInboundAt: snapshot.lastInboundAt,
authAgeMs,
uptimeMs: snapshot.uptimeMs,
...(minutesSinceLastMessage !== null && minutesSinceLastMessage > 30
? { minutesSinceLastMessage }
: {}),
};
if (minutesSinceLastMessage && minutesSinceLastMessage > 30) {
heartbeatLogger.warn(logData, "⚠️ web gateway heartbeat - no messages in 30+ minutes");
} else {
heartbeatLogger.info(logData, "web gateway heartbeat");
}
},
onWatchdogTimeout: (snapshot) => {
const watchdogBaselineAt = snapshot.lastInboundAt ?? snapshot.startedAt;
const minutesSinceLastMessage = Math.floor((Date.now() - watchdogBaselineAt) / 60000);
statusController.noteWatchdogStale();
heartbeatLogger.warn(
{
const logData = {
connectionId: snapshot.connectionId,
minutesSinceLastMessage,
lastInboundAt: snapshot.lastInboundAt ? new Date(snapshot.lastInboundAt) : null,
reconnectAttempts: snapshot.reconnectAttempts,
messagesHandled: snapshot.handledMessages,
lastInboundAt: snapshot.lastInboundAt,
authAgeMs,
uptimeMs: snapshot.uptimeMs,
...(minutesSinceLastMessage !== null && minutesSinceLastMessage > 30
? { minutesSinceLastMessage }
: {}),
};
if (minutesSinceLastMessage && minutesSinceLastMessage > 30) {
heartbeatLogger.warn(
logData,
"⚠️ web gateway heartbeat - no messages in 30+ minutes",
);
} else {
heartbeatLogger.info(logData, "web gateway heartbeat");
}
},
onWatchdogTimeout: (snapshot) => {
const watchdogBaselineAt = snapshot.lastInboundAt ?? snapshot.startedAt;
const minutesSinceLastMessage = Math.floor((Date.now() - watchdogBaselineAt) / 60000);
statusController.noteWatchdogStale();
heartbeatLogger.warn(
{
connectionId: snapshot.connectionId,
minutesSinceLastMessage,
lastInboundAt: snapshot.lastInboundAt ? new Date(snapshot.lastInboundAt) : null,
messagesHandled: snapshot.handledMessages,
},
"Message timeout detected - forcing reconnect",
);
whatsappHeartbeatLog.warn(
`No messages received in ${minutesSinceLastMessage}m - restarting connection`,
);
},
});
} catch (error) {
if (!isRetryableAuthUnstableError(error)) {
throw error;
}
const retryDecision = controller.consumeReconnectAttempt();
statusController.noteReconnectAttempts(retryDecision.reconnectAttempts);
statusController.noteClose({
error: error.message,
reconnectAttempts: retryDecision.reconnectAttempts,
healthState: retryDecision.healthState,
});
if (retryDecision.action === "stop") {
reconnectLogger.warn(
{
connectionId,
reconnectAttempts: retryDecision.reconnectAttempts,
maxAttempts: reconnectPolicy.maxAttempts,
},
"Message timeout detected - forcing reconnect",
"web reconnect: auth state stayed unstable; max attempts reached",
);
whatsappHeartbeatLog.warn(
`No messages received in ${minutesSinceLastMessage}m - restarting connection`,
runtime.error(
`WhatsApp auth state is still stabilizing after ${retryDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts} attempts. Stopping web monitoring.`,
);
},
});
await controller.shutdown();
break;
}
reconnectLogger.info(
{
connectionId,
reconnectAttempts: retryDecision.reconnectAttempts,
delayMs: retryDecision.delayMs,
},
"web reconnect: auth state still stabilizing during inbox attach; retrying",
);
runtime.error(
`WhatsApp auth state is still stabilizing. Retry ${retryDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} for inbox attach in ${formatDurationPrecise(retryDecision.delayMs ?? 0)}.`,
);
try {
await controller.waitBeforeRetry(retryDecision.delayMs ?? 0);
} catch {
break;
}
continue;
}
statusController.noteConnected();
controller.setUnhandledRejectionCleanup(

View File

@@ -7,6 +7,11 @@ import {
getWebAuthAgeMs as getWebAuthAgeMsImpl,
logWebSelfId as logWebSelfIdImpl,
logoutWeb as logoutWebImpl,
readWebAuthSnapshot as readWebAuthSnapshotImpl,
readWebAuthState as readWebAuthStateImpl,
readWebAuthExistsBestEffort as readWebAuthExistsBestEffortImpl,
readWebAuthExistsForDecision as readWebAuthExistsForDecisionImpl,
readWebAuthSnapshotBestEffort as readWebAuthSnapshotBestEffortImpl,
readWebSelfId as readWebSelfIdImpl,
webAuthExists as webAuthExistsImpl,
} from "./auth-store.js";
@@ -18,6 +23,11 @@ type GetActiveWebListener = typeof import("./active-listener.js").getActiveWebLi
type GetWebAuthAgeMs = typeof import("./auth-store.js").getWebAuthAgeMs;
type LogWebSelfId = typeof import("./auth-store.js").logWebSelfId;
type LogoutWeb = typeof import("./auth-store.js").logoutWeb;
type ReadWebAuthSnapshot = typeof import("./auth-store.js").readWebAuthSnapshot;
type ReadWebAuthState = typeof import("./auth-store.js").readWebAuthState;
type ReadWebAuthExistsBestEffort = typeof import("./auth-store.js").readWebAuthExistsBestEffort;
type ReadWebAuthExistsForDecision = typeof import("./auth-store.js").readWebAuthExistsForDecision;
type ReadWebAuthSnapshotBestEffort = typeof import("./auth-store.js").readWebAuthSnapshotBestEffort;
type ReadWebSelfId = typeof import("./auth-store.js").readWebSelfId;
type WebAuthExists = typeof import("./auth-store.js").webAuthExists;
type LoginWeb = typeof import("./login.js").loginWeb;
@@ -44,6 +54,36 @@ export function logoutWeb(...args: Parameters<LogoutWeb>): ReturnType<LogoutWeb>
return logoutWebImpl(...args);
}
export function readWebAuthSnapshot(
...args: Parameters<ReadWebAuthSnapshot>
): ReturnType<ReadWebAuthSnapshot> {
return readWebAuthSnapshotImpl(...args);
}
export function readWebAuthState(
...args: Parameters<ReadWebAuthState>
): ReturnType<ReadWebAuthState> {
return readWebAuthStateImpl(...args);
}
export function readWebAuthExistsBestEffort(
...args: Parameters<ReadWebAuthExistsBestEffort>
): ReturnType<ReadWebAuthExistsBestEffort> {
return readWebAuthExistsBestEffortImpl(...args);
}
export function readWebAuthExistsForDecision(
...args: Parameters<ReadWebAuthExistsForDecision>
): ReturnType<ReadWebAuthExistsForDecision> {
return readWebAuthExistsForDecisionImpl(...args);
}
export function readWebAuthSnapshotBestEffort(
...args: Parameters<ReadWebAuthSnapshotBestEffort>
): ReturnType<ReadWebAuthSnapshotBestEffort> {
return readWebAuthSnapshotBestEffortImpl(...args);
}
export function readWebSelfId(...args: Parameters<ReadWebSelfId>): ReturnType<ReadWebSelfId> {
return readWebSelfIdImpl(...args);
}

View File

@@ -3,6 +3,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createQueuedWizardPrompter } from "../../../test/helpers/plugins/setup-wizard.js";
import { whatsappApprovalAuth } from "./approval-auth.js";
import { WHATSAPP_AUTH_UNSTABLE_CODE } from "./auth-store.js";
import { whatsappPlugin } from "./channel.js";
import { checkWhatsAppHeartbeatReady } from "./heartbeat.js";
import type { OpenClawConfig } from "./runtime-api.js";
@@ -26,6 +27,13 @@ import {
const hoisted = vi.hoisted(() => ({
loginWeb: vi.fn(async () => {}),
pathExists: vi.fn(async () => false),
readWebAuthState: vi.fn(async (): Promise<"linked" | "not-linked" | "unstable"> => "not-linked"),
readWebAuthExistsForDecision: vi.fn(
async (): Promise<{ outcome: "stable"; exists: boolean } | { outcome: "unstable" }> => ({
outcome: "stable",
exists: false,
}),
),
resolveWhatsAppAuthDir: vi.fn(() => ({
authDir: "/tmp/openclaw-whatsapp-test",
})),
@@ -82,6 +90,15 @@ vi.mock("./accounts.js", async () => {
};
});
vi.mock("./auth-store.js", async () => {
const actual = await vi.importActual<typeof import("./auth-store.js")>("./auth-store.js");
return {
...actual,
readWebAuthState: hoisted.readWebAuthState,
readWebAuthExistsForDecision: hoisted.readWebAuthExistsForDecision,
};
});
function createRuntime(): RuntimeEnv {
return {
error: vi.fn(),
@@ -132,6 +149,13 @@ describe("whatsapp setup wizard", () => {
hoisted.loginWeb.mockReset();
hoisted.pathExists.mockReset();
hoisted.pathExists.mockResolvedValue(false);
hoisted.readWebAuthState.mockReset();
hoisted.readWebAuthState.mockResolvedValue("not-linked");
hoisted.readWebAuthExistsForDecision.mockReset();
hoisted.readWebAuthExistsForDecision.mockResolvedValue({
outcome: "stable",
exists: false,
});
hoisted.resolveWhatsAppAuthDir.mockReset();
hoisted.resolveWhatsAppAuthDir.mockReturnValue({ authDir: "/tmp/openclaw-whatsapp-test" });
});
@@ -397,11 +421,49 @@ describe("whatsapp setup wizard", () => {
},
} as OpenClawConfig,
deps: {
webAuthExists: async () => true,
readWebAuthExistsForDecision: async () => ({
outcome: "stable" as const,
exists: true,
}),
hasActiveWebListener: (accountId?: string) => accountId === "work",
},
});
expect(result).toEqual({ ok: true, reason: "ok" });
});
it("heartbeat readiness returns unstable when auth state timing is unresolved", async () => {
const result = await checkWhatsAppHeartbeatReady({
cfg: {
channels: {
whatsapp: {
accounts: {
default: {
authDir: "/tmp/default",
},
},
},
},
} as OpenClawConfig,
deps: {
readWebAuthExistsForDecision: async () => ({ outcome: "unstable" as const }),
hasActiveWebListener: () => true,
},
});
expect(result).toEqual({ ok: false, reason: WHATSAPP_AUTH_UNSTABLE_CODE });
});
it("does not treat unstable auth as configured in generic plugin config checks", async () => {
hoisted.readWebAuthState.mockResolvedValueOnce("unstable");
await expect(
whatsappPlugin.config.isConfigured?.(
{
authDir: "/tmp/work",
} as never,
{} as never,
),
).resolves.toBe(false);
});
});

View File

@@ -1,6 +1,6 @@
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import { type ResolvedWhatsAppAccount } from "./accounts.js";
import { webAuthExists } from "./auth-store.js";
import { readWebAuthState } from "./auth-store.js";
import { resolveWhatsAppGroupIntroHint } from "./group-intro.js";
import {
resolveWhatsAppGroupRequireMention,
@@ -19,7 +19,7 @@ export const whatsappSetupPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
},
setupWizard: whatsappSetupWizardProxy,
setup: whatsappSetupAdapter,
isConfigured: async (account) => await webAuthExists(account.authDir),
isConfigured: async (account) => (await readWebAuthState(account.authDir)) === "linked",
}),
lifecycle: {
detectLegacyStateMigrations: ({ oauthDir }) =>

View File

@@ -75,8 +75,10 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
},
setupWizard: whatsappSetupWizardProxy,
setup: whatsappSetupAdapter,
isConfigured: async (account) =>
await (await loadWhatsAppChannelRuntime()).webAuthExists(account.authDir),
isConfigured: async (account) => {
const channelRuntime = await loadWhatsAppChannelRuntime();
return (await channelRuntime.readWebAuthState(account.authDir)) === "linked";
},
}),
agentTools: () => [createWhatsAppLoginTool()],
allowlist: buildDmGroupAccountAllowlistAdapter({
@@ -182,24 +184,47 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
}),
collectStatusIssues: collectWhatsAppStatusIssues,
buildChannelSummary: async ({ account, snapshot }) => {
const channelRuntime = await loadWhatsAppChannelRuntime();
const authDir = account.authDir;
const auth = authDir
? await channelRuntime.readWebAuthSnapshot(authDir)
: {
state: "not-linked" as const,
authAgeMs: null,
selfId: { e164: null, jid: null, lid: null },
};
const linked =
typeof snapshot.linked === "boolean"
? snapshot.linked
: authDir
? await (await loadWhatsAppChannelRuntime()).webAuthExists(authDir)
: false;
const authAgeMs =
linked && authDir
? (await loadWhatsAppChannelRuntime()).getWebAuthAgeMs(authDir)
: null;
: auth.state === "unstable"
? undefined
: auth.state === "linked";
const summaryAuthState =
auth.state === "unstable"
? auth.state
: linked === true
? "linked"
: linked === false
? "not-linked"
: undefined;
const statusState = summaryAuthState === undefined ? undefined : summaryAuthState;
const configured =
auth.state === "unstable"
? typeof snapshot.configured === "boolean"
? snapshot.configured
: true
: typeof linked === "boolean"
? linked
: auth.state === "linked";
const authAgeMs = typeof linked === "boolean" && linked ? auth.authAgeMs : null;
const self =
linked && authDir
? (await loadWhatsAppChannelRuntime()).readWebSelfId(authDir)
: { e164: null, jid: null };
typeof linked === "boolean" && linked
? auth.selfId
: { e164: null, jid: null, lid: null };
return {
configured: linked,
linked,
configured,
...(statusState ? { statusState } : {}),
...(typeof linked === "boolean" ? { linked } : {}),
authAgeMs,
self,
running: snapshot.running ?? false,
@@ -215,14 +240,20 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
};
},
resolveAccountSnapshot: async ({ account, runtime }) => {
const linked = await (await loadWhatsAppChannelRuntime()).webAuthExists(account.authDir);
const channelRuntime = await loadWhatsAppChannelRuntime();
const authState = await channelRuntime.readWebAuthState(account.authDir);
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: true,
extra: {
linked,
statusState: authState,
...(authState === "linked"
? { linked: true }
: authState === "not-linked"
? { linked: false }
: {}),
connected: runtime?.connected ?? false,
reconnectAttempts: runtime?.reconnectAttempts,
lastConnectedAt: runtime?.lastConnectedAt ?? null,

View File

@@ -1,24 +1,18 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
import { WhatsAppConnectionController } from "./connection-controller.js";
import {
createWaSocket,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection,
} from "./session.js";
import { createWaSocket, waitForWaConnection } from "./session.js";
vi.mock("./session.js", async () => {
const actual = await vi.importActual<typeof import("./session.js")>("./session.js");
return {
...actual,
createWaSocket: vi.fn(),
waitForCredsSaveQueueWithTimeout: vi.fn(async () => {}),
waitForWaConnection: vi.fn(),
};
});
const createWaSocketMock = vi.mocked(createWaSocket);
const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout);
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
function createListenerStub(messageId = "ok") {
@@ -81,24 +75,22 @@ describe("WhatsAppConnectionController", () => {
expect(controller.getActiveListener()).toBeNull();
});
it("flushes pending creds saves before opening a socket", async () => {
it("lets createWaSocket own the auth barrier before opening a socket", async () => {
const callOrder: string[] = [];
waitForCredsSaveQueueWithTimeoutMock.mockImplementationOnce(async () => {
callOrder.push("wait");
});
createWaSocketMock.mockImplementationOnce(async () => {
callOrder.push("create");
return { ws: { close: vi.fn() } } as never;
});
waitForWaConnectionMock.mockResolvedValueOnce(undefined);
waitForWaConnectionMock.mockImplementationOnce(async () => {
callOrder.push("wait-for-connection");
});
await controller.openConnection({
connectionId: "conn-flush-first",
createListener: async () => createListenerStub() as never,
});
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith("/tmp/wa-auth");
expect(callOrder).toEqual(["wait", "create"]);
expect(callOrder).toEqual(["create", "wait-for-connection"]);
});
it("keeps the previous registered controller until a replacement listener is ready", async () => {

View File

@@ -12,7 +12,6 @@ import {
formatError,
getStatusCode,
logoutWeb,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection,
} from "./session.js";
@@ -73,6 +72,13 @@ export type WhatsAppConnectionCloseDecision = {
normalized: NormalizedConnectionCloseReason;
};
export type WhatsAppReconnectAttemptDecision = {
action: "stop" | "retry";
delayMs?: number;
reconnectAttempts: number;
healthState: "stopped" | "reconnecting";
};
function createNeverResolvePromise<T>(): Promise<T> {
return new Promise<T>(() => {});
}
@@ -175,7 +181,6 @@ export async function waitForWhatsAppLoginResult(params: {
restarted = true;
params.runtime.log(info(WHATSAPP_LOGIN_RESTART_MESSAGE));
closeWaSocket(currentSock);
await waitForCredsSaveQueueWithTimeout(params.authDir);
try {
currentSock = await createSocket(false, params.verbose, {
authDir: params.authDir,
@@ -347,7 +352,6 @@ export class WhatsAppConnectionController {
let sock: WaSocket | null = null;
let connection: WhatsAppLiveConnection | null = null;
try {
await waitForCredsSaveQueueWithTimeout(this.authDir);
sock = await createWaSocket(false, this.verbose, {
authDir: this.authDir,
});
@@ -449,6 +453,26 @@ export class WhatsAppConnectionController {
};
}
const retryDecision = this.consumeReconnectAttempt();
if (retryDecision.action === "stop") {
return {
action: "stop",
reconnectAttempts: retryDecision.reconnectAttempts,
healthState: retryDecision.healthState,
normalized,
};
}
return {
action: "retry",
delayMs: retryDecision.delayMs,
reconnectAttempts: retryDecision.reconnectAttempts,
healthState: retryDecision.healthState,
normalized,
};
}
consumeReconnectAttempt(): WhatsAppReconnectAttemptDecision {
this.reconnectAttempts += 1;
if (
this.reconnectPolicy.maxAttempts > 0 &&
@@ -458,7 +482,6 @@ export class WhatsAppConnectionController {
action: "stop",
reconnectAttempts: this.reconnectAttempts,
healthState: "stopped",
normalized,
};
}
@@ -467,7 +490,6 @@ export class WhatsAppConnectionController {
delayMs: computeBackoff(this.reconnectPolicy, this.reconnectAttempts),
reconnectAttempts: this.reconnectAttempts,
healthState: "reconnecting",
normalized,
};
}

View File

@@ -0,0 +1,98 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveWebCredsPath } from "./creds-files.js";
import { BufferJSON } from "./session.runtime.js";
const CREDS_FILE_MODE = 0o600;
const CREDS_SAVE_FLUSH_TIMEOUT_MS = 15_000;
const credsSaveQueues = new Map<string, Promise<void>>();
export type CredsQueueWaitResult = "drained" | "timed_out";
async function syncDirectory(dirPath: string): Promise<void> {
let handle: Awaited<ReturnType<typeof fs.open>> | undefined;
try {
handle = await fs.open(dirPath, "r");
await handle.sync();
} catch {
// best-effort on platforms that do not support directory fsync
} finally {
await handle?.close().catch(() => {
// best-effort close
});
}
}
export async function writeCredsJsonAtomically(authDir: string, creds: unknown): Promise<void> {
const credsPath = resolveWebCredsPath(authDir);
const tempPath = path.join(authDir, `.creds.${process.pid}.${randomUUID()}.tmp`);
const json = JSON.stringify(creds, BufferJSON.replacer);
let handle: Awaited<ReturnType<typeof fs.open>> | undefined;
try {
handle = await fs.open(tempPath, "w", CREDS_FILE_MODE);
await handle.writeFile(json, { encoding: "utf-8" });
await handle.sync();
await handle.close();
handle = undefined;
await fs.rename(tempPath, credsPath);
await fs.chmod(credsPath, CREDS_FILE_MODE).catch(() => {
// best-effort on platforms that support it
});
await syncDirectory(path.dirname(credsPath));
} catch (error) {
await handle?.close().catch(() => {
// best-effort close
});
await fs.rm(tempPath, { force: true }).catch(() => {
// best-effort cleanup
});
throw error;
}
}
export function enqueueCredsSave(
authDir: string,
saveCreds: () => Promise<void> | void,
onError: (error: unknown) => void,
): void {
const previous = credsSaveQueues.get(authDir) ?? Promise.resolve();
const next = previous
.then(() => saveCreds())
.catch((error) => {
onError(error);
})
.finally(() => {
if (credsSaveQueues.get(authDir) === next) {
credsSaveQueues.delete(authDir);
}
});
credsSaveQueues.set(authDir, next);
}
export function waitForCredsSaveQueue(authDir?: string): Promise<void> {
if (authDir) {
return credsSaveQueues.get(authDir) ?? Promise.resolve();
}
return Promise.all(credsSaveQueues.values()).then(() => {});
}
export async function waitForCredsSaveQueueWithTimeout(
authDir: string,
timeoutMs = CREDS_SAVE_FLUSH_TIMEOUT_MS,
): Promise<CredsQueueWaitResult> {
let flushTimeout: ReturnType<typeof setTimeout> | undefined;
return await Promise.race([
waitForCredsSaveQueue(authDir).then(() => "drained" as const),
new Promise<CredsQueueWaitResult>((resolve) => {
flushTimeout = setTimeout(() => resolve("timed_out"), timeoutMs);
}),
]).finally(() => {
if (flushTimeout) {
clearTimeout(flushTimeout);
}
});
}

View File

@@ -1,4 +1,5 @@
import { resolveWhatsAppAccount } from "./accounts.js";
import { readWebAuthExistsForDecision, WHATSAPP_AUTH_UNSTABLE_CODE } from "./auth-store.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { loadWhatsAppChannelRuntime } from "./shared.js";
@@ -6,7 +7,7 @@ export async function checkWhatsAppHeartbeatReady(params: {
cfg: OpenClawConfig;
accountId?: string;
deps?: {
webAuthExists?: (authDir: string) => Promise<boolean>;
readWebAuthExistsForDecision?: typeof readWebAuthExistsForDecision;
hasActiveWebListener?: (accountId?: string) => boolean;
};
}) {
@@ -14,10 +15,13 @@ export async function checkWhatsAppHeartbeatReady(params: {
return { ok: false as const, reason: "whatsapp-disabled" as const };
}
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId });
const authExists = await (
params.deps?.webAuthExists ?? (await loadWhatsAppChannelRuntime()).webAuthExists
const authState = await (
params.deps?.readWebAuthExistsForDecision ?? readWebAuthExistsForDecision
)(account.authDir);
if (!authExists) {
if (authState.outcome === "unstable") {
return { ok: false as const, reason: WHATSAPP_AUTH_UNSTABLE_CODE };
}
if (!authState.exists) {
return { ok: false as const, reason: "whatsapp-not-linked" as const };
}
const listenerActive = params.deps?.hasActiveWebListener

View File

@@ -4,7 +4,7 @@ import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime";
import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { getChildLogger } from "openclaw/plugin-sdk/text-runtime";
import { readWebSelfIdentity } from "../auth-store.js";
import { readWebSelfIdentityForDecision, WhatsAppAuthUnstableError } from "../auth-store.js";
import { getPrimaryIdentityId, resolveComparableIdentity } from "../identity.js";
import { DEFAULT_RECONNECT_POLICY, computeBackoff, sleepWithAbort } from "../reconnect.js";
import { createWaSocket, formatError, getStatusCode, waitForWaConnection } from "../session.js";
@@ -131,10 +131,16 @@ export async function attachWebInboxToSocket(
);
}
const self = await readWebSelfIdentity(
const selfIdentity = await readWebSelfIdentityForDecision(
options.authDir,
sock.user as { id?: string | null; lid?: string | null } | undefined,
);
if (selfIdentity.outcome === "unstable") {
throw new WhatsAppAuthUnstableError(
"WhatsApp auth state is still stabilizing; retrying inbox attach.",
);
}
const self = selfIdentity.identity;
type QueuedInboundMessage = WebInboundMessage & {
dedupeKey?: string;
};

View File

@@ -3,19 +3,25 @@ import { startWebLoginWithQr, waitForWebLogin } from "./login-qr.js";
import {
createWaSocket,
logoutWeb,
waitForCredsSaveQueueWithTimeout,
readWebAuthExistsForDecision,
readWebSelfId,
WHATSAPP_AUTH_UNSTABLE_CODE,
waitForWaConnection,
} from "./session.js";
vi.mock("./session.js", async () => {
const actual = await vi.importActual<typeof import("./session.js")>("./session.js");
const createWaSocket = vi.fn(
async (_printQr: boolean, _verbose: boolean, opts?: { onQr?: (qr: string) => void }) => {
async (
_printQr: boolean,
_verbose: boolean,
opts?: { authDir?: string; onQr?: (qr: string) => void },
) => {
const sock = { ws: { close: vi.fn() } };
if (opts?.onQr) {
setImmediate(() => opts.onQr?.("qr-data"));
}
return sock;
return sock as never;
},
);
const waitForWaConnection = vi.fn();
@@ -26,20 +32,21 @@ vi.mock("./session.js", async () => {
(err as { status?: number })?.status ??
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode,
);
const webAuthExists = vi.fn(async () => false);
const readWebSelfId = vi.fn(() => ({ e164: null, jid: null }));
const readWebAuthExistsForDecision = vi.fn(async () => ({
outcome: "stable" as const,
exists: false,
}));
const readWebSelfId = vi.fn(() => ({ e164: null, jid: null, lid: null }));
const logoutWeb = vi.fn(async () => true);
const waitForCredsSaveQueueWithTimeout = vi.fn(async () => {});
return {
...actual,
createWaSocket,
waitForWaConnection,
formatError,
getStatusCode,
webAuthExists,
readWebAuthExistsForDecision,
readWebSelfId,
logoutWeb,
waitForCredsSaveQueueWithTimeout,
};
});
@@ -48,8 +55,9 @@ vi.mock("./qr-image.js", () => ({
}));
const createWaSocketMock = vi.mocked(createWaSocket);
const readWebAuthExistsForDecisionMock = vi.mocked(readWebAuthExistsForDecision);
const readWebSelfIdMock = vi.mocked(readWebSelfId);
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout);
const logoutWebMock = vi.mocked(logoutWeb);
async function flushTasks() {
@@ -60,18 +68,35 @@ async function flushTasks() {
describe("login-qr", () => {
beforeEach(() => {
vi.clearAllMocks();
createWaSocketMock
.mockReset()
.mockImplementation(
async (
_printQr: boolean,
_verbose: boolean,
opts?: { authDir?: string; onQr?: (qr: string) => void },
) => {
const sock = { ws: { close: vi.fn() } };
if (opts?.onQr) {
setImmediate(() => opts.onQr?.("qr-data"));
}
return sock as never;
},
);
waitForWaConnectionMock.mockReset();
readWebAuthExistsForDecisionMock.mockReset().mockResolvedValue({
outcome: "stable",
exists: false,
});
readWebSelfIdMock.mockReset().mockReturnValue({ e164: null, jid: null, lid: null });
logoutWebMock.mockReset().mockResolvedValue(true);
});
it("restarts login once on status 515 and completes", async () => {
let releaseCredsFlush: (() => void) | undefined;
const credsFlushGate = new Promise<void>((resolve) => {
releaseCredsFlush = resolve;
});
waitForWaConnectionMock
// Baileys v7 wraps the error: { error: BoomError(515) }
.mockRejectedValueOnce({ error: { output: { statusCode: 515 } } })
.mockResolvedValueOnce(undefined);
waitForCredsSaveQueueWithTimeoutMock.mockReturnValueOnce(credsFlushGate);
const start = await startWebLoginWithQr({ timeoutMs: 5000 });
expect(start.qrDataUrl).toBe("data:image/png;base64,base64");
@@ -80,11 +105,7 @@ describe("login-qr", () => {
await flushTasks();
await flushTasks();
expect(createWaSocketMock).toHaveBeenCalledTimes(1);
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledOnce();
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith(expect.any(String));
releaseCredsFlush?.();
expect(createWaSocketMock).toHaveBeenCalledTimes(2);
const result = await resultPromise;
expect(result.connected).toBe(true);
@@ -126,4 +147,43 @@ describe("login-qr", () => {
message: "WhatsApp login failed: cleanup failed",
});
});
it("returns an unstable-auth result when creds flush does not settle", async () => {
readWebAuthExistsForDecisionMock.mockResolvedValueOnce({ outcome: "unstable" });
const result = await startWebLoginWithQr({ timeoutMs: 5000 });
expect(result).toEqual({
code: WHATSAPP_AUTH_UNSTABLE_CODE,
message: "WhatsApp auth state is still stabilizing. Retry login in a moment.",
});
expect(createWaSocketMock).not.toHaveBeenCalled();
});
it("reports a recovered linked session when socket bootstrap restores auth without a QR", async () => {
createWaSocketMock.mockImplementationOnce(
async (
_printQr: boolean,
_verbose: boolean,
_opts?: { authDir?: string; onQr?: (qr: string) => void },
) =>
({
ws: { close: vi.fn() },
}) as never,
);
waitForWaConnectionMock.mockResolvedValueOnce(undefined);
readWebSelfIdMock.mockReturnValueOnce({ e164: "+5511977000000", jid: null, lid: null });
const result = await startWebLoginWithQr({ timeoutMs: 5000 });
expect(result).toEqual({
connected: true,
message: "WhatsApp recovered the existing linked session (+5511977000000).",
});
expect(createWaSocketMock).toHaveBeenCalledOnce();
await expect(waitForWebLogin({ timeoutMs: 1000 })).resolves.toEqual({
connected: false,
message: "No active WhatsApp login in progress.",
});
});
});

View File

@@ -10,9 +10,20 @@ import {
WHATSAPP_LOGGED_OUT_QR_MESSAGE,
} from "./connection-controller.js";
import { renderQrPngBase64 } from "./qr-image.js";
import { createWaSocket, readWebSelfId, webAuthExists } from "./session.js";
import {
createWaSocket,
readWebAuthExistsForDecision,
readWebSelfId,
WHATSAPP_AUTH_UNSTABLE_CODE,
} from "./session.js";
type WaSocket = Awaited<ReturnType<typeof createWaSocket>>;
export type StartWebLoginWithQrResult = {
qrDataUrl?: string;
message: string;
connected?: boolean;
code?: typeof WHATSAPP_AUTH_UNSTABLE_CODE;
};
type ActiveLogin = {
accountId: string;
@@ -31,6 +42,15 @@ type ActiveLogin = {
runtime: RuntimeEnv;
};
type LoginQrRaceResult =
| { outcome: "qr"; qr: string }
| { outcome: "connected" }
| { outcome: "failed"; message: string };
function waitForNextTask(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}
const ACTIVE_LOGIN_TTL_MS = 3 * 60_000;
const activeLogins = new Map<string, ActiveLogin>();
@@ -93,6 +113,52 @@ function attachLoginWaiter(accountId: string, login: ActiveLogin) {
});
}
async function waitForQrOrRecoveredLogin(params: {
accountId: string;
login: ActiveLogin;
qrPromise: Promise<string>;
}): Promise<LoginQrRaceResult> {
const qrResult = params.qrPromise.then(
(qr) => ({ outcome: "qr", qr }) as const,
(err) =>
({
outcome: "failed",
message: `Failed to get QR: ${String(err)}`,
}) as const,
);
const loginResult = params.login.waitPromise.then(async () => {
const current = activeLogins.get(params.accountId);
if (current?.id !== params.login.id) {
return {
outcome: "failed",
message: "WhatsApp login was replaced by a newer request.",
} as const;
}
// A QR may already be queued for the next task even if the login waiter won first.
await waitForNextTask();
const latest = activeLogins.get(params.accountId);
if (latest?.id !== params.login.id) {
return {
outcome: "failed",
message: "WhatsApp login was replaced by a newer request.",
} as const;
}
if (latest.qr) {
return { outcome: "qr", qr: latest.qr } as const;
}
if (latest.connected) {
return { outcome: "connected" } as const;
}
return {
outcome: "failed",
message: latest.error ? `WhatsApp login failed: ${latest.error}` : "WhatsApp login failed.",
} as const;
});
return await Promise.race([qrResult, loginResult]);
}
export async function startWebLoginWithQr(
opts: {
verbose?: boolean;
@@ -101,13 +167,19 @@ export async function startWebLoginWithQr(
accountId?: string;
runtime?: RuntimeEnv;
} = {},
): Promise<{ qrDataUrl?: string; message: string }> {
): Promise<StartWebLoginWithQrResult> {
const runtime = opts.runtime ?? defaultRuntime;
const cfg = loadConfig();
const account = resolveWhatsAppAccount({ cfg, accountId: opts.accountId });
const hasWeb = await webAuthExists(account.authDir);
const selfId = readWebSelfId(account.authDir);
if (hasWeb && !opts.force) {
const authState = await readWebAuthExistsForDecision(account.authDir);
if (authState.outcome === "unstable") {
return {
code: WHATSAPP_AUTH_UNSTABLE_CODE,
message: "WhatsApp auth state is still stabilizing. Retry login in a moment.",
};
}
if (authState.exists && !opts.force) {
const selfId = readWebSelfId(account.authDir);
const who = selfId.e164 ?? selfId.jid ?? "unknown";
return {
message: `WhatsApp is already linked (${who}). Say “relink” if you want a fresh QR.`,
@@ -182,18 +254,31 @@ export async function startWebLoginWithQr(
}
attachLoginWaiter(account.accountId, login);
let qr: string;
try {
qr = await qrPromise;
} catch (err) {
clearTimeout(qrTimer);
const loginStartResult = await waitForQrOrRecoveredLogin({
accountId: account.accountId,
login,
qrPromise,
});
clearTimeout(qrTimer);
if (loginStartResult.outcome === "connected") {
const selfId = readWebSelfId(account.authDir);
const who = selfId.e164 ?? selfId.jid ?? "unknown";
await resetActiveLogin(account.accountId);
return {
message: `Failed to get QR: ${String(err)}`,
message: `WhatsApp recovered the existing linked session (${who}).`,
connected: true,
};
}
const base64 = await renderQrPngBase64(qr);
if (loginStartResult.outcome === "failed") {
await resetActiveLogin(account.accountId);
return {
message: loginStartResult.message,
};
}
const base64 = await renderQrPngBase64(loginStartResult.qr);
login.qrDataUrl = `data:image/png;base64,${base64}`;
return {
qrDataUrl: login.qrDataUrl,

View File

@@ -2,12 +2,7 @@ import { rmSync } from "node:fs";
import fs from "node:fs/promises";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loginWeb } from "./login.js";
import {
createWaSocket,
formatError,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection,
} from "./session.js";
import { createWaSocket, formatError, waitForWaConnection } from "./session.js";
const rmMock = vi.spyOn(fs, "rm");
const testState = vi.hoisted(() => ({
@@ -51,14 +46,12 @@ vi.mock("./session.js", async () => {
(err as { status?: number })?.status ??
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode,
);
const waitForCredsSaveQueueWithTimeout = vi.fn(async () => {});
return {
...actual,
createWaSocket,
waitForWaConnection,
formatError,
getStatusCode,
waitForCredsSaveQueueWithTimeout,
WA_WEB_AUTH_DIR: authDir,
logoutWeb: vi.fn(async (params: { authDir?: string }) => {
await fs.rm(params.authDir ?? authDir, {
@@ -72,7 +65,6 @@ vi.mock("./session.js", async () => {
const createWaSocketMock = vi.mocked(createWaSocket);
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout);
const formatErrorMock = vi.mocked(formatError);
async function flushTasks() {
@@ -86,7 +78,6 @@ describe("loginWeb coverage", () => {
vi.clearAllMocks();
createWaSocketMock.mockClear();
waitForWaConnectionMock.mockReset().mockResolvedValue(undefined);
waitForCredsSaveQueueWithTimeoutMock.mockReset().mockResolvedValue(undefined);
formatErrorMock.mockReset().mockImplementation((err: unknown) => `formatted:${String(err)}`);
rmMock.mockClear();
});
@@ -99,24 +90,15 @@ describe("loginWeb coverage", () => {
});
it("restarts once when WhatsApp requests code 515", async () => {
let releaseCredsFlush: (() => void) | undefined;
const credsFlushGate = new Promise<void>((resolve) => {
releaseCredsFlush = resolve;
});
waitForWaConnectionMock
.mockRejectedValueOnce({ error: { output: { statusCode: 515 } } })
.mockResolvedValueOnce(undefined);
waitForCredsSaveQueueWithTimeoutMock.mockReturnValueOnce(credsFlushGate);
const runtime = { log: vi.fn(), error: vi.fn() } as never;
const pendingLogin = loginWeb(false, waitForWaConnectionMock as never, runtime);
await flushTasks();
expect(createWaSocketMock).toHaveBeenCalledTimes(1);
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledOnce();
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith(testState.authDir);
releaseCredsFlush?.();
expect(createWaSocketMock).toHaveBeenCalledTimes(2);
await pendingLogin;
expect(createWaSocketMock).toHaveBeenCalledTimes(2);

View File

@@ -21,14 +21,24 @@ vi.mock("./session.js", async () => {
};
});
vi.mock("./auth-store.js", async () => {
const actual = await vi.importActual<typeof import("./auth-store.js")>("./auth-store.js");
return {
...actual,
restoreCredsFromBackupIfNeeded: vi.fn(async () => false),
};
});
import type { waitForWaConnection } from "./session.js";
let loginWeb: typeof import("./login.js").loginWeb;
let createWaSocket: typeof import("./session.js").createWaSocket;
let restoreCredsFromBackupIfNeeded: typeof import("./auth-store.js").restoreCredsFromBackupIfNeeded;
describe("web login", () => {
beforeAll(async () => {
({ loginWeb } = await import("./login.js"));
({ createWaSocket } = await import("./session.js"));
({ restoreCredsFromBackupIfNeeded } = await import("./auth-store.js"));
});
beforeEach(() => {
@@ -57,6 +67,19 @@ describe("web login", () => {
await vi.advanceTimersByTimeAsync(1);
expect(close).toHaveBeenCalledTimes(1);
});
it("prints a backup recovery success message when creds are restored from backup", async () => {
const waiter: typeof waitForWaConnection = vi.fn().mockResolvedValue(undefined);
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
vi.mocked(restoreCredsFromBackupIfNeeded).mockResolvedValueOnce(true);
await loginWeb(false, waiter);
expect(consoleLog).toHaveBeenCalledWith(
expect.stringContaining("✅ Recovered from creds.json.bak; web session ready."),
);
consoleLog.mockRestore();
});
});
describe("renderQrPngBase64", () => {

View File

@@ -4,6 +4,7 @@ import { danger, success } from "openclaw/plugin-sdk/runtime-env";
import { defaultRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { logInfo } from "openclaw/plugin-sdk/text-runtime";
import { resolveWhatsAppAccount } from "./accounts.js";
import { restoreCredsFromBackupIfNeeded } from "./auth-store.js";
import { closeWaSocketSoon, waitForWhatsAppLoginResult } from "./connection-controller.js";
import { createWaSocket, waitForWaConnection } from "./session.js";
@@ -15,6 +16,7 @@ export async function loginWeb(
) {
const cfg = loadConfig();
const account = resolveWhatsAppAccount({ cfg, accountId });
const restoredFromBackup = await restoreCredsFromBackupIfNeeded(account.authDir);
let sock = await createWaSocket(true, verbose, {
authDir: account.authDir,
});
@@ -36,7 +38,9 @@ export async function loginWeb(
success(
result.restarted
? "✅ Linked after restart; web session ready."
: "✅ Linked! Credentials saved for future sends.",
: restoredFromBackup
? "✅ Recovered from creds.json.bak; web session ready."
: "✅ Linked! Credentials saved for future sends.",
),
);
return;

View File

@@ -34,6 +34,55 @@ function createTempAuthDir(prefix: string) {
);
}
function mockFsOpenForCredsWrites(params?: {
onTempWrite?: (filePath: string) => Promise<void> | void;
}) {
const open = fs.open.bind(fs);
const tempHandles: Array<{
filePath: string;
writeFile: ReturnType<typeof vi.fn>;
sync: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
}> = [];
const dirHandles: Array<{
filePath: string;
sync: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
}> = [];
const openSpy = vi.spyOn(fs, "open").mockImplementation(async (filePath, flags, mode) => {
if (typeof filePath === "string" && flags === "w" && filePath.includes(".creds.")) {
const handle = {
filePath,
writeFile: vi.fn(async () => {
await params?.onTempWrite?.(filePath);
}),
sync: vi.fn(async () => {}),
close: vi.fn(async () => {}),
};
tempHandles.push(handle);
return handle as never;
}
if (typeof filePath === "string" && flags === "r") {
const handle = {
filePath,
sync: vi.fn(async () => {}),
close: vi.fn(async () => {}),
};
dirHandles.push(handle);
return handle as never;
}
return open(filePath as never, flags as never, mode as never);
});
return {
openSpy,
tempHandles,
dirHandles,
restore() {
openSpy.mockRestore();
},
};
}
function mockCredsJsonSpies(readContents: string) {
const credsSuffix = path.join("/tmp", "openclaw-oauth", "whatsapp", "default", "creds.json");
const copySpy = vi.spyOn(fsSync, "copyFileSync").mockImplementation(() => {});
@@ -116,7 +165,7 @@ describe("web session", () => {
it("creates WA socket with QR handler", async () => {
const authDir = createTempAuthDir("openclaw-wa-creds-test");
const writeFileSpy = vi.spyOn(fs, "writeFile");
const openMock = mockFsOpenForCredsWrites();
await createWaSocket(true, false, { authDir });
const makeWASocket = baileys.makeWASocket as ReturnType<typeof vi.fn>;
@@ -129,12 +178,12 @@ describe("web session", () => {
expect(typeof passedLogger?.trace).toBe("function");
await emitCredsUpdate(authDir);
expect(writeFileSpy).toHaveBeenCalledWith(
expect(openMock.openSpy).toHaveBeenCalledWith(
expect.stringContaining(path.join(authDir, ".creds.")),
expect.any(String),
expect.objectContaining({ mode: 0o600 }),
"w",
0o600,
);
writeFileSpy.mockRestore();
openMock.restore();
});
it("uses ambient env proxy agent when HTTPS_PROXY is configured", async () => {
@@ -253,16 +302,16 @@ describe("web session", () => {
it("does not clobber creds backup when creds.json is corrupted", async () => {
const creds = mockCredsJsonSpies("{");
const writeFileSpy = vi.spyOn(fs, "writeFile");
const openMock = mockFsOpenForCredsWrites();
await createWaSocket(false, false);
await emitCredsUpdate();
expect(creds.copySpy).not.toHaveBeenCalled();
expect(writeFileSpy).toHaveBeenCalled();
expect(openMock.tempHandles).toHaveLength(1);
creds.restore();
writeFileSpy.mockRestore();
openMock.restore();
});
it("serializes creds.update saves to avoid overlapping writes", async () => {
@@ -274,19 +323,16 @@ describe("web session", () => {
});
const authDir = createTempAuthDir("openclaw-wa-queue");
const writeFile = fs.writeFile.bind(fs);
const writeFileSpy = vi
.spyOn(fs, "writeFile")
.mockImplementation(async (file, data, options) => {
if (typeof file === "string" && file.startsWith(authDir) && file.includes(".creds.")) {
const openMock = mockFsOpenForCredsWrites({
onTempWrite: async (filePath) => {
if (filePath.startsWith(authDir)) {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await gate;
inFlight -= 1;
return;
}
return writeFile(file, data, options as never);
});
},
});
await createWaSocket(false, false, { authDir });
const sock = getLastSocket();
@@ -301,10 +347,10 @@ describe("web session", () => {
await waitForCredsSaveQueue(authDir);
expect(writeFileSpy).toHaveBeenCalledTimes(2);
expect(openMock.tempHandles).toHaveLength(2);
expect(maxInFlight).toBe(1);
expect(inFlight).toBe(0);
writeFileSpy.mockRestore();
openMock.restore();
});
it("lets different authDir queues flush independently", async () => {
@@ -321,24 +367,21 @@ describe("web session", () => {
const authDirA = createTempAuthDir("openclaw-wa-a");
const authDirB = createTempAuthDir("openclaw-wa-b");
const writeFile = fs.writeFile.bind(fs);
const writeFileSpy = vi
.spyOn(fs, "writeFile")
.mockImplementation(async (file, data, options) => {
if (typeof file === "string" && file.startsWith(authDirA) && file.includes(".creds.")) {
const openMock = mockFsOpenForCredsWrites({
onTempWrite: async (filePath) => {
if (filePath.startsWith(authDirA)) {
inFlightA += 1;
await gateA;
inFlightA -= 1;
return;
}
if (typeof file === "string" && file.startsWith(authDirB) && file.includes(".creds.")) {
if (filePath.startsWith(authDirB)) {
inFlightB += 1;
await gateB;
inFlightB -= 1;
return;
}
return writeFile(file, data, options as never);
});
},
});
await createWaSocket(false, false, { authDir: authDirA });
const sockA = getLastSocket();
@@ -350,7 +393,7 @@ describe("web session", () => {
await flushCredsUpdate();
expect(writeFileSpy).toHaveBeenCalledTimes(2);
expect(openMock.tempHandles).toHaveLength(2);
expect(inFlightA).toBe(1);
expect(inFlightB).toBe(1);
@@ -360,12 +403,12 @@ describe("web session", () => {
expect(inFlightA).toBe(0);
expect(inFlightB).toBe(0);
writeFileSpy.mockRestore();
openMock.restore();
});
it("rotates creds backup when creds.json is valid JSON", async () => {
const creds = mockCredsJsonSpies("{}");
const writeFileSpy = vi.spyOn(fs, "writeFile");
const openMock = mockFsOpenForCredsWrites();
const backupSuffix = path.join(
"/tmp",
"openclaw-oauth",
@@ -381,27 +424,32 @@ describe("web session", () => {
const args = creds.copySpy.mock.calls[0] ?? [];
expect(String(args[0] ?? "")).toContain(creds.credsSuffix);
expect(String(args[1] ?? "")).toContain(backupSuffix);
expect(writeFileSpy).toHaveBeenCalled();
expect(openMock.tempHandles).toHaveLength(1);
creds.restore();
writeFileSpy.mockRestore();
openMock.restore();
});
it("writes creds.json atomically via temp file and rename", async () => {
const writeFileSpy = vi.spyOn(fs, "writeFile").mockResolvedValue(undefined);
const openMock = mockFsOpenForCredsWrites();
const renameSpy = vi.spyOn(fs, "rename").mockResolvedValue(undefined);
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
const chmodSpy = vi.spyOn(fsSync, "chmodSync").mockImplementation(() => {});
const chmodSpy = vi.spyOn(fs, "chmod").mockResolvedValue(undefined);
await writeCredsJsonAtomically("/tmp/openclaw-oauth/whatsapp/default", {
me: { id: "123@s.whatsapp.net" },
});
expect(writeFileSpy).toHaveBeenCalledTimes(1);
expect(openMock.tempHandles).toHaveLength(1);
expect(openMock.tempHandles[0]?.writeFile).toHaveBeenCalledTimes(1);
expect(openMock.tempHandles[0]?.sync).toHaveBeenCalledTimes(1);
expect(openMock.tempHandles[0]?.close).toHaveBeenCalledTimes(1);
expect(renameSpy).toHaveBeenCalledTimes(1);
expect(rmSpy).not.toHaveBeenCalled();
expect(chmodSpy).not.toHaveBeenCalled();
const writePath = writeFileSpy.mock.calls[0]?.[0];
expect(chmodSpy).toHaveBeenCalledOnce();
expect(openMock.dirHandles).toHaveLength(1);
expect(openMock.dirHandles[0]?.sync).toHaveBeenCalledTimes(1);
const writePath = openMock.tempHandles[0]?.filePath;
const renameArgs = renameSpy.mock.calls[0] ?? [];
expect(typeof writePath).toBe("string");
expect(writePath).toContain(".creds.");
@@ -409,7 +457,7 @@ describe("web session", () => {
path.join("/tmp", "openclaw-oauth", "whatsapp", "default", "creds.json"),
);
writeFileSpy.mockRestore();
openMock.restore();
renameSpy.mockRestore();
rmSpy.mockRestore();
chmodSpy.mockRestore();

View File

@@ -1,8 +1,6 @@
import { randomUUID } from "node:crypto";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import type { Agent } from "node:https";
import path from "node:path";
import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime";
import { VERSION } from "openclaw/plugin-sdk/cli-runtime";
import { resolveAmbientNodeProxyAgent } from "openclaw/plugin-sdk/extension-shared";
@@ -10,15 +8,21 @@ import { danger, success } from "openclaw/plugin-sdk/runtime-env";
import { getChildLogger, toPinoLikeLogger } from "openclaw/plugin-sdk/runtime-env";
import { ensureDir, resolveUserPath } from "openclaw/plugin-sdk/text-runtime";
import {
maybeRestoreCredsFromBackup,
readCredsJsonRaw,
restoreCredsFromBackupIfNeeded,
resolveDefaultWebAuthDir,
resolveWebCredsBackupPath,
resolveWebCredsPath,
} from "./auth-store.js";
import {
enqueueCredsSave,
waitForCredsSaveQueue,
waitForCredsSaveQueueWithTimeout,
writeCredsJsonAtomically,
type CredsQueueWaitResult,
} from "./creds-persistence.js";
import { formatError, getStatusCode } from "./session-errors.js";
import {
BufferJSON,
DisconnectReason,
fetchLatestBaileysVersion,
makeCacheableSignalKeyStore,
@@ -32,54 +36,47 @@ export {
logoutWeb,
logWebSelfId,
pickWebChannel,
readWebAuthSnapshot,
readWebAuthState,
readWebAuthExistsBestEffort,
readWebAuthExistsForDecision,
readWebAuthSnapshotBestEffort,
readWebSelfIdentityForDecision,
readWebSelfId,
WHATSAPP_AUTH_UNSTABLE_CODE,
WhatsAppAuthUnstableError,
type WhatsAppWebAuthState,
WA_WEB_AUTH_DIR,
webAuthExists,
} from "./auth-store.js";
export {
waitForCredsSaveQueue,
waitForCredsSaveQueueWithTimeout,
writeCredsJsonAtomically,
} from "./creds-persistence.js";
export type { CredsQueueWaitResult } from "./creds-persistence.js";
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
const CREDS_FLUSH_TIMEOUT_MESSAGE =
"Queued WhatsApp creds save did not finish before auth bootstrap; skipping repair and continuing with primary creds.";
async function loadQrTerminal() {
const mod = await import("qrcode-terminal");
return mod.default ?? mod;
}
export async function writeCredsJsonAtomically(authDir: string, creds: unknown): Promise<void> {
const credsPath = resolveWebCredsPath(authDir);
const tempPath = path.join(authDir, `.creds.${process.pid}.${Date.now()}.tmp`);
try {
await fs.writeFile(tempPath, JSON.stringify(creds, BufferJSON.replacer), { mode: 0o600 });
await fs.rename(tempPath, credsPath);
} catch (err) {
try {
await fs.rm(tempPath, { force: true });
} catch {
// best-effort cleanup
}
throw err;
}
}
// Per-authDir queues so multi-account creds saves don't block each other.
const credsSaveQueues = new Map<string, Promise<void>>();
const CREDS_SAVE_FLUSH_TIMEOUT_MS = 15_000;
function enqueueSaveCreds(
authDir: string,
saveCreds: () => Promise<void> | void,
logger: ReturnType<typeof getChildLogger>,
): void {
const prev = credsSaveQueues.get(authDir) ?? Promise.resolve();
const next = prev
.then(() => safeSaveCreds(authDir, saveCreds, logger))
.catch((err) => {
enqueueCredsSave(
authDir,
() => safeSaveCreds(authDir, saveCreds, logger),
(err) => {
logger.warn({ error: String(err) }, "WhatsApp creds save queue error");
})
.finally(() => {
if (credsSaveQueues.get(authDir) === next) {
credsSaveQueues.delete(authDir);
}
});
credsSaveQueues.set(authDir, next);
},
);
}
async function safeSaveCreds(
@@ -135,7 +132,12 @@ export async function createWaSocket(
const authDir = resolveUserPath(opts.authDir ?? resolveDefaultWebAuthDir());
await ensureDir(authDir);
const sessionLogger = getChildLogger({ module: "web-session" });
maybeRestoreCredsFromBackup(authDir);
const queueResult = await waitForCredsSaveQueueWithTimeout(authDir);
if (queueResult === "timed_out") {
sessionLogger.warn({ authDir }, CREDS_FLUSH_TIMEOUT_MESSAGE);
} else {
await restoreCredsFromBackupIfNeeded(authDir);
}
const { state } = await useMultiFileAuthState(authDir);
const saveCreds = async () => {
await writeCredsJsonAtomically(authDir, state.creds);
@@ -219,18 +221,6 @@ async function resolveEnvProxyAgent(
});
}
type UndiciProxyAgentsModule = Pick<typeof import("undici"), "EnvHttpProxyAgent" | "ProxyAgent">;
let undiciProxyAgentsModulePromise: Promise<UndiciProxyAgentsModule> | null = null;
async function loadUndiciProxyAgents(): Promise<UndiciProxyAgentsModule> {
undiciProxyAgentsModulePromise ??= import("undici").then(({ EnvHttpProxyAgent, ProxyAgent }) => ({
EnvHttpProxyAgent,
ProxyAgent,
}));
return undiciProxyAgentsModulePromise;
}
async function resolveEnvFetchDispatcher(
logger: ReturnType<typeof getChildLogger>,
agent?: unknown,
@@ -241,7 +231,7 @@ async function resolveEnvFetchDispatcher(
return undefined;
}
try {
const { EnvHttpProxyAgent, ProxyAgent } = await loadUndiciProxyAgents();
const { EnvHttpProxyAgent, ProxyAgent } = await import("undici");
return proxyUrl
? new ProxyAgent({ allowH2: false, uri: proxyUrl })
: new EnvHttpProxyAgent({ allowH2: false });
@@ -306,32 +296,6 @@ export async function waitForWaConnection(sock: ReturnType<typeof makeWASocket>)
});
}
/** Await pending credential saves — scoped to one authDir, or all if omitted. */
export function waitForCredsSaveQueue(authDir?: string): Promise<void> {
if (authDir) {
return credsSaveQueues.get(authDir) ?? Promise.resolve();
}
return Promise.all(credsSaveQueues.values()).then(() => {});
}
/** Await pending credential saves, but don't hang forever on stalled I/O. */
export async function waitForCredsSaveQueueWithTimeout(
authDir: string,
timeoutMs = CREDS_SAVE_FLUSH_TIMEOUT_MS,
): Promise<void> {
let flushTimeout: ReturnType<typeof setTimeout> | undefined;
await Promise.race([
waitForCredsSaveQueue(authDir),
new Promise<void>((resolve) => {
flushTimeout = setTimeout(resolve, timeoutMs);
}),
]).finally(() => {
if (flushTimeout) {
clearTimeout(flushTimeout);
}
});
}
export function newConnectionId() {
return randomUUID();
}

View File

@@ -31,7 +31,12 @@ const hoisted = vi.hoisted(() => ({
),
loginWeb: vi.fn(async () => {}),
pathExists: vi.fn(async () => false),
resolveWhatsAppAuthDir: vi.fn(() => ({
readWebAuthState: vi.fn<(authDir: string) => Promise<"linked" | "not-linked" | "unstable">>(
async () => "not-linked",
),
resolveWhatsAppAuthDir: vi.fn<
(params: { cfg: OpenClawConfig; accountId: string }) => { authDir: string }
>(() => ({
authDir: "/tmp/openclaw-whatsapp-test",
})),
}));
@@ -66,6 +71,14 @@ vi.mock("./accounts.js", async () => {
};
});
vi.mock("./auth-store.js", async () => {
const actual = await vi.importActual<typeof import("./auth-store.js")>("./auth-store.js");
return {
...actual,
readWebAuthState: hoisted.readWebAuthState,
};
});
function createRuntime(): RuntimeEnv {
return {
error: vi.fn(),
@@ -136,6 +149,8 @@ describe("whatsapp setup wizard", () => {
hoisted.loginWeb.mockReset();
hoisted.pathExists.mockReset();
hoisted.pathExists.mockResolvedValue(false);
hoisted.readWebAuthState.mockReset();
hoisted.readWebAuthState.mockResolvedValue("not-linked");
hoisted.resolveWhatsAppAuthDir.mockReset();
hoisted.resolveWhatsAppAuthDir.mockReturnValue({ authDir: "/tmp/openclaw-whatsapp-test" });
});
@@ -203,8 +218,11 @@ describe("whatsapp setup wizard", () => {
});
it("uses configured defaultAccount for omitted-account setup status", async () => {
hoisted.detectWhatsAppLinked.mockImplementation(
async (_cfg: OpenClawConfig, accountId: string) => accountId === "work",
hoisted.resolveWhatsAppAuthDir.mockImplementation(({ accountId }: { accountId: string }) => ({
authDir: accountId === "work" ? "/tmp/work" : "/tmp/default",
}));
hoisted.readWebAuthState.mockImplementation(async (authDir: string) =>
authDir === "/tmp/work" ? "linked" : "not-linked",
);
const status = await whatsappGetStatus({
@@ -228,11 +246,33 @@ describe("whatsapp setup wizard", () => {
expect(status.configured).toBe(true);
expect(status.statusLines).toEqual(["WhatsApp (work): linked"]);
expect(hoisted.detectWhatsAppLinked).toHaveBeenCalledWith(
expect.any(Object),
DEFAULT_ACCOUNT_ID,
);
expect(hoisted.detectWhatsAppLinked).toHaveBeenCalledWith(expect.any(Object), "work");
expect(hoisted.readWebAuthState).toHaveBeenCalledWith("/tmp/default");
expect(hoisted.readWebAuthState).toHaveBeenCalledWith("/tmp/work");
});
it("shows auth stabilizing when auth reads time out", async () => {
hoisted.resolveWhatsAppAuthDir.mockReturnValue({ authDir: "/tmp/work" });
hoisted.readWebAuthState.mockResolvedValue("unstable");
const status = await whatsappGetStatus({
cfg: {
channels: {
whatsapp: {
accounts: {
work: {
authDir: "/tmp/work",
},
},
},
},
} as OpenClawConfig,
accountOverrides: {
whatsapp: "work",
},
});
expect(status.configured).toBe(false);
expect(status.statusLines).toEqual(["WhatsApp (work): auth stabilizing"]);
});
it("uses configured defaultAccount for omitted-account finalize writes", async () => {

View File

@@ -1,10 +1,25 @@
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
import { DEFAULT_ACCOUNT_ID, setSetupChannelEnabled } from "openclaw/plugin-sdk/setup";
import { listWhatsAppAccountIds } from "./accounts.js";
import { detectWhatsAppLinked, finalizeWhatsAppSetup } from "./setup-finalize.js";
import {
DEFAULT_ACCOUNT_ID,
setSetupChannelEnabled,
type OpenClawConfig,
} from "openclaw/plugin-sdk/setup";
import { listWhatsAppAccountIds, resolveWhatsAppAuthDir } from "./accounts.js";
import { formatWhatsAppWebAuthStatusState, readWebAuthState } from "./auth-store.js";
import { finalizeWhatsAppSetup } from "./setup-finalize.js";
const channel = "whatsapp" as const;
type WhatsAppSetupLinkState = "linked" | "not-linked" | "unstable";
async function readWhatsAppSetupLinkState(
cfg: OpenClawConfig,
accountId: string,
): Promise<WhatsAppSetupLinkState> {
const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId });
return await readWebAuthState(authDir);
}
export const whatsappSetupWizard: ChannelSetupWizard = {
channel,
status: {
@@ -16,7 +31,7 @@ export const whatsappSetupWizard: ChannelSetupWizard = {
unconfiguredScore: 4,
resolveConfigured: async ({ cfg, accountId }) => {
for (const resolvedAccountId of accountId ? [accountId] : listWhatsAppAccountIds(cfg)) {
if (await detectWhatsAppLinked(cfg, resolvedAccountId)) {
if ((await readWhatsAppSetupLinkState(cfg, resolvedAccountId)) === "linked") {
return true;
}
}
@@ -28,16 +43,19 @@ export const whatsappSetupWizard: ChannelSetupWizard = {
(accountId ? [accountId] : listWhatsAppAccountIds(cfg)).map(
async (resolvedAccountId) => ({
accountId: resolvedAccountId,
linked: await detectWhatsAppLinked(cfg, resolvedAccountId),
state: await readWhatsAppSetupLinkState(cfg, resolvedAccountId),
}),
),
)
).find((entry) => entry.linked)?.accountId;
const labelAccountId = accountId ?? linkedAccountId;
).find((entry) => entry.state === "linked" || entry.state === "unstable");
const labelAccountId = accountId ?? linkedAccountId?.accountId;
const label = labelAccountId
? `WhatsApp (${labelAccountId === DEFAULT_ACCOUNT_ID ? "default" : labelAccountId})`
: "WhatsApp";
return [`${label}: ${configured ? "linked" : "not linked"}`];
const stateLabel = configured
? formatWhatsAppWebAuthStatusState("linked")
: formatWhatsAppWebAuthStatusState(linkedAccountId?.state ?? "not-linked");
return [`${label}: ${stateLabel}`];
},
},
resolveShouldPromptAccountIds: ({ shouldPromptAccountIds }) => shouldPromptAccountIds,

View File

@@ -20,6 +20,25 @@ describe("collectWhatsAppStatusIssues", () => {
]);
});
it("reports auth reads that are still stabilizing", () => {
const issues = collectWhatsAppStatusIssues([
{
accountId: "default",
enabled: true,
statusState: "unstable",
},
]);
expect(issues).toEqual([
expect.objectContaining({
channel: "whatsapp",
accountId: "default",
kind: "auth",
message: "Auth state is still stabilizing.",
}),
]);
});
it("reports linked but disconnected runtime state", () => {
const issues = collectWhatsAppStatusIssues([
{

View File

@@ -11,6 +11,7 @@ import {
type WhatsAppAccountStatus = {
accountId?: unknown;
statusState?: unknown;
enabled?: unknown;
linked?: unknown;
connected?: unknown;
@@ -27,6 +28,7 @@ function readWhatsAppAccountStatus(value: ChannelAccountSnapshot): WhatsAppAccou
}
return {
accountId: value.accountId,
statusState: value.statusState,
enabled: value.enabled,
linked: value.linked,
connected: value.connected,
@@ -46,6 +48,7 @@ export function collectWhatsAppStatusIssues(
readAccount: readWhatsAppAccountStatus,
collectIssues: ({ account, accountId, issues }) => {
const linked = account.linked === true;
const statusState = asString(account.statusState);
const running = account.running === true;
const connected = account.connected === true;
const reconnectAttempts =
@@ -55,6 +58,17 @@ export function collectWhatsAppStatusIssues(
const lastError = asString(account.lastError);
const healthState = asString(account.healthState);
if (statusState === "unstable") {
issues.push({
channel: "whatsapp",
accountId,
kind: "auth",
message: "Auth state is still stabilizing.",
fix: "Wait a moment for queued credential writes to finish, then retry the command or rerun health.",
});
return;
}
if (!linked) {
issues.push({
channel: "whatsapp",

View File

@@ -0,0 +1,12 @@
export function formatChannelStatusState(statusState: string): string {
switch (statusState) {
case "linked":
return "linked";
case "not-linked":
return "not linked";
case "unstable":
return "auth stabilizing";
default:
return statusState;
}
}

View File

@@ -317,6 +317,7 @@ export type ChannelLogoutResult = {
export type ChannelLoginWithQrStartResult = {
qrDataUrl?: string;
message: string;
connected?: boolean;
};
export type ChannelLoginWithQrWaitResult = {

View File

@@ -180,6 +180,7 @@ export type ChannelAccountSnapshot = {
name?: string;
enabled?: boolean;
configured?: boolean;
statusState?: string;
linked?: boolean;
running?: boolean;
connected?: boolean;

View File

@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
applyPluginAutoEnable: vi.fn(),
replaceConfigFile: vi.fn(),
setVerbose: vi.fn(),
callGateway: vi.fn(),
createClackPrompter: vi.fn(),
ensureChannelSetupPluginInstalled: vi.fn(),
loadChannelSetupPluginRegistrySnapshotForChannel: vi.fn(),
@@ -57,6 +58,10 @@ vi.mock("../globals.js", () => ({
setVerbose: mocks.setVerbose,
}));
vi.mock("../gateway/call.js", () => ({
callGateway: mocks.callGateway,
}));
vi.mock("../wizard/clack-prompter.js", () => ({
createClackPrompter: mocks.createClackPrompter,
}));
@@ -72,7 +77,7 @@ describe("channel-auth", () => {
const plugin = {
id: "whatsapp",
auth: { login: mocks.login },
gateway: { logoutAccount: mocks.logoutAccount },
gateway: { startAccount: vi.fn(), logoutAccount: mocks.logoutAccount },
config: {
listAccountIds: vi.fn().mockReturnValue(["default"]),
resolveAccount: mocks.resolveAccount,
@@ -89,6 +94,7 @@ describe("channel-auth", () => {
mocks.readConfigFileSnapshot.mockResolvedValue({ hash: "config-1" });
mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({ config, changes: [] }));
mocks.replaceConfigFile.mockResolvedValue(undefined);
mocks.callGateway.mockResolvedValue({ ok: true });
mocks.listChannelPlugins.mockReturnValue([plugin]);
mocks.resolveDefaultAgentId.mockReturnValue("main");
mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/workspace");
@@ -122,6 +128,41 @@ describe("channel-auth", () => {
channelInput: "wa",
}),
);
expect(mocks.callGateway).toHaveBeenCalledWith({
config: { channels: { whatsapp: {} } },
method: "channels.start",
params: {
channel: "whatsapp",
accountId: "acct-1",
},
mode: "backend",
clientName: "gateway-client",
deviceIdentity: null,
});
});
it("skips gateway runtime reconcile in remote mode and warns without failing login", async () => {
mocks.loadConfig.mockReturnValue({
gateway: { mode: "remote" },
channels: { whatsapp: {} },
});
await runChannelLogin({ channel: "whatsapp", account: "acct-1" }, runtime);
expect(mocks.callGateway).not.toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Gateway is in remote mode"));
});
it("keeps login successful when local gateway runtime reconcile fails", async () => {
mocks.callGateway.mockRejectedValue(new Error("gateway unreachable"));
await expect(
runChannelLogin({ channel: "whatsapp", account: "acct-1" }, runtime),
).resolves.toBeUndefined();
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("running gateway did not restart it: gateway unreachable"),
);
});
it("auto-picks the single configured channel that supports login when opts are empty", async () => {

View File

@@ -12,11 +12,14 @@ import {
type OpenClawConfig,
} from "../config/config.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { callGateway } from "../gateway/call.js";
import { setVerbose } from "../globals.js";
import { formatErrorMessage } from "../infra/errors.js";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { sanitizeForLog } from "../terminal/ansi.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
type ChannelAuthOptions = {
channel?: string;
@@ -134,6 +137,41 @@ function resolveAccountContext(
return { accountId };
}
async function reconcileGatewayRuntimeAfterLocalLogin(params: {
cfg: OpenClawConfig;
plugin: ChannelPlugin;
channelId: string;
accountId: string;
runtime: RuntimeEnv;
}) {
if (!params.plugin.gateway?.startAccount) {
return;
}
if (params.cfg.gateway?.mode === "remote") {
params.runtime.log(
`Gateway is in remote mode; local login saved auth for ${params.channelId}/${params.accountId} but did not start the remote runtime.`,
);
return;
}
try {
await callGateway({
config: params.cfg,
method: "channels.start",
params: {
channel: params.channelId,
accountId: params.accountId,
},
mode: GATEWAY_CLIENT_MODES.BACKEND,
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
deviceIdentity: null,
});
} catch (error) {
params.runtime.log(
`Local login saved auth for ${params.channelId}/${params.accountId}, but the running gateway did not restart it: ${formatErrorMessage(error)}`,
);
}
}
export async function runChannelLogin(
opts: ChannelAuthOptions,
runtime: RuntimeEnv = defaultRuntime,
@@ -170,6 +208,13 @@ export async function runChannelLogin(
verbose: Boolean(opts.verbose),
channelInput,
});
await reconcileGatewayRuntimeAfterLocalLogin({
cfg,
plugin,
channelId: plugin.id,
accountId,
runtime,
});
}
export async function runChannelLogout(

View File

@@ -1,4 +1,5 @@
import { getChannelPlugin } from "../channels/plugins/index.js";
import { formatChannelStatusState } from "../channels/plugins/status-state.js";
import { asNullableRecord } from "../shared/record-coerce.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import type { ChannelAccountHealthSummary, HealthSummary } from "./health.types.js";
@@ -171,6 +172,19 @@ export const formatHealthChannelLines = (
})
.filter((value): value is string => Boolean(value))
: [];
const statusState =
typeof baseSummary.statusState === "string" ? baseSummary.statusState : null;
if (statusState) {
if (statusState === "linked") {
const authAgeMs = typeof baseSummary.authAgeMs === "number" ? baseSummary.authAgeMs : null;
const authLabel = authAgeMs != null ? ` (auth age ${Math.round(authAgeMs / 60000)}m)` : "";
lines.push(`${label}: ${formatChannelStatusState(statusState)}${authLabel}`);
} else {
lines.push(`${label}: ${formatChannelStatusState(statusState)}`);
}
continue;
}
const linked = typeof baseSummary.linked === "boolean" ? baseSummary.linked : null;
if (linked !== null) {
if (linked) {

View File

@@ -132,6 +132,23 @@ describe("healthCommand", () => {
"Telegram: ok (@pinguini_ugi_bot:main:196ms, @flurry_ugi_bot:flurry:190ms, @poe_ugi_bot:poe:188ms)",
);
});
it("formats statusState without inferring from linked", () => {
const summary = createHealthSummary({
channels: {
whatsapp: {
accountId: "default",
statusState: "unstable",
configured: true,
},
},
channelOrder: ["whatsapp"],
channelLabels: { whatsapp: "WhatsApp" },
});
const lines = formatHealthChannelLines(summary, { accountMode: "default" });
expect(lines).toContain("WhatsApp: auth stabilizing");
});
});
describe("formatHealthCheckFailure", () => {

View File

@@ -11,6 +11,7 @@ import {
} from "../../channels/account-summary.js";
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { formatChannelStatusState } from "../../channels/plugins/status-state.js";
import type {
ChannelAccountSnapshot,
ChannelId,
@@ -188,16 +189,18 @@ const buildAccountNotes = (params: {
};
function resolveLinkFields(summary: unknown): {
statusState: string | null;
linked: boolean | null;
authAgeMs: number | null;
selfE164: string | null;
} {
const rec = asRecord(summary);
const statusState = typeof rec.statusState === "string" ? rec.statusState : null;
const linked = typeof rec.linked === "boolean" ? rec.linked : null;
const authAgeMs = typeof rec.authAgeMs === "number" ? rec.authAgeMs : null;
const self = asRecord(rec.self);
const selfE164 = typeof self.e164 === "string" && self.e164.trim() ? self.e164.trim() : null;
return { linked, authAgeMs, selfE164 };
return { statusState, linked, authAgeMs, selfE164 };
}
function collectMissingPaths(accounts: ChannelAccountRow[]): string[] {
@@ -311,6 +314,9 @@ export async function buildChannelsTable(
if (unavailableConfiguredAccounts.length > 0) {
return "warn";
}
if (link.statusState === "unstable") {
return "warn";
}
if (link.linked === false) {
return "setup";
}
@@ -339,6 +345,24 @@ export async function buildChannelsTable(
if (issues.length > 0) {
return issues[0]?.message ?? "misconfigured";
}
if (link.statusState) {
if (link.statusState === "linked") {
const extra: string[] = [];
if (link.selfE164) {
extra.push(link.selfE164);
}
if (link.authAgeMs != null && link.authAgeMs >= 0) {
extra.push(`auth ${formatTimeAgo(link.authAgeMs)}`);
}
if (accounts.length > 1 || plugin.meta.forceAccountBinding) {
extra.push(`accounts ${accounts.length || 1}`);
}
return extra.length > 0
? `${formatChannelStatusState(link.statusState)} · ${extra.join(" · ")}`
: formatChannelStatusState(link.statusState);
}
return formatChannelStatusState(link.statusState);
}
if (link.linked !== null) {
const base = link.linked ? "linked" : "not linked";

View File

@@ -330,6 +330,20 @@ describe("callGateway url resolution", () => {
expect(lastRequestOptions?.method).toBe("health");
});
it("honors an explicit null device identity override", async () => {
setLocalLoopbackGatewayConfig();
await callGateway({
method: "health",
token: "explicit-token",
deviceIdentity: null,
});
expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789");
expect(lastClientOptions?.token).toBe("explicit-token");
expect(lastClientOptions?.deviceIdentity).toBeNull();
});
it("uses OPENCLAW_GATEWAY_URL env override in remote mode when remote URL is missing", async () => {
loadConfig.mockReturnValue({
gateway: { mode: "remote", bind: "loopback", remote: {} },

View File

@@ -6,7 +6,7 @@ import {
resolveStateDir as resolveStateDirFromPaths,
} from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { loadOrCreateDeviceIdentity, type DeviceIdentity } from "../infra/device-identity.js";
import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import {
@@ -54,6 +54,7 @@ type CallGatewayBaseOptions = {
clientVersion?: string;
platform?: string;
mode?: GatewayClientMode;
deviceIdentity?: DeviceIdentity | null;
instanceId?: string;
minProtocol?: number;
maxProtocol?: number;
@@ -491,7 +492,10 @@ async function executeGatewayRequestWithScopes<T>(params: {
mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI,
role: "operator",
scopes,
deviceIdentity: resolveDeviceIdentityForGatewayCall(),
deviceIdentity:
opts.deviceIdentity === undefined
? resolveDeviceIdentityForGatewayCall()
: opts.deviceIdentity,
minProtocol: opts.minProtocol ?? PROTOCOL_VERSION,
maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION,
onHelloOk: async (hello) => {

View File

@@ -147,6 +147,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
"node.pending.enqueue",
],
[ADMIN_SCOPE]: [
"channels.start",
"channels.logout",
"agents.create",
"agents.update",

View File

@@ -44,6 +44,8 @@ import {
AgentsListResultSchema,
type AgentWaitParams,
AgentWaitParamsSchema,
type ChannelsStartParams,
ChannelsStartParamsSchema,
type ChannelsLogoutParams,
ChannelsLogoutParamsSchema,
type TalkConfigParams,
@@ -431,6 +433,8 @@ export const validateTalkSpeakResult = ajv.compile<TalkSpeakResult>(TalkSpeakRes
export const validateChannelsStatusParams = ajv.compile<ChannelsStatusParams>(
ChannelsStatusParamsSchema,
);
export const validateChannelsStartParams =
ajv.compile<ChannelsStartParams>(ChannelsStartParamsSchema);
export const validateChannelsLogoutParams = ajv.compile<ChannelsLogoutParams>(
ChannelsLogoutParamsSchema,
);
@@ -616,6 +620,7 @@ export {
TalkSpeakResultSchema,
ChannelsStatusParamsSchema,
ChannelsStatusResultSchema,
ChannelsStartParamsSchema,
ChannelsLogoutParamsSchema,
WebLoginStartParamsSchema,
WebLoginWaitParamsSchema,
@@ -720,6 +725,7 @@ export type {
TalkModeParams,
ChannelsStatusParams,
ChannelsStatusResult,
ChannelsStartParams,
ChannelsLogoutParams,
WebLoginStartParams,
WebLoginWaitParams,

View File

@@ -185,6 +185,14 @@ export const ChannelsLogoutParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const ChannelsStartParamsSchema = Type.Object(
{
channel: NonEmptyString,
accountId: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export const WebLoginStartParamsSchema = Type.Object(
{
force: Type.Optional(Type.Boolean()),

View File

@@ -50,6 +50,7 @@ import {
ToolsEffectiveResultSchema,
} from "./agents-models-skills.js";
import {
ChannelsStartParamsSchema,
ChannelsLogoutParamsSchema,
TalkConfigParamsSchema,
TalkConfigResultSchema,
@@ -282,6 +283,7 @@ export const ProtocolSchemas = {
TalkSpeakResult: TalkSpeakResultSchema,
ChannelsStatusParams: ChannelsStatusParamsSchema,
ChannelsStatusResult: ChannelsStatusResultSchema,
ChannelsStartParams: ChannelsStartParamsSchema,
ChannelsLogoutParams: ChannelsLogoutParamsSchema,
WebLoginStartParams: WebLoginStartParamsSchema,
WebLoginWaitParams: WebLoginWaitParamsSchema,

View File

@@ -84,6 +84,7 @@ export type TalkSpeakParams = SchemaType<"TalkSpeakParams">;
export type TalkSpeakResult = SchemaType<"TalkSpeakResult">;
export type ChannelsStatusParams = SchemaType<"ChannelsStatusParams">;
export type ChannelsStatusResult = SchemaType<"ChannelsStatusResult">;
export type ChannelsStartParams = SchemaType<"ChannelsStartParams">;
export type ChannelsLogoutParams = SchemaType<"ChannelsLogoutParams">;
export type WebLoginStartParams = SchemaType<"WebLoginStartParams">;
export type WebLoginWaitParams = SchemaType<"WebLoginWaitParams">;

View File

@@ -12,6 +12,7 @@ const BASE_METHODS = [
"doctor.memory.dedupeDreamDiary",
"logs.tail",
"channels.status",
"channels.start",
"channels.logout",
"status",
"usage.status",

View File

@@ -0,0 +1,174 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelRuntimeSnapshot } from "../server-channel-runtime.types.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
const mocks = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({})),
applyPluginAutoEnable: vi.fn(),
getChannelPlugin: vi.fn(),
}));
vi.mock("../../config/config.js", () => ({
loadConfig: mocks.loadConfig,
readConfigFileSnapshot: vi.fn(),
}));
vi.mock("../../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: mocks.applyPluginAutoEnable,
}));
vi.mock("../../channels/plugins/index.js", () => ({
listChannelPlugins: vi.fn(),
getChannelPlugin: mocks.getChannelPlugin,
normalizeChannelId: (value: string) => value,
}));
import { channelsHandlers } from "./channels.js";
function createOptions(
params: Record<string, unknown>,
overrides?: Partial<GatewayRequestHandlerOptions>,
): GatewayRequestHandlerOptions {
return {
req: { type: "req", id: "req-1", method: "channels.start", params },
params,
client: null,
isWebchatConnect: () => false,
respond: vi.fn(),
context: {
startChannel: vi.fn(),
getRuntimeSnapshot: vi.fn(
(): ChannelRuntimeSnapshot => ({
channels: {
whatsapp: {
accountId: "default-account",
running: true,
},
},
channelAccounts: {
whatsapp: {
"default-account": {
accountId: "default-account",
running: true,
},
},
},
}),
),
},
...overrides,
} as unknown as GatewayRequestHandlerOptions;
}
describe("channelsHandlers channels.start", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.loadConfig.mockReturnValue({});
mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({ config, changes: [] }));
mocks.getChannelPlugin.mockReturnValue({
id: "whatsapp",
gateway: { startAccount: vi.fn() },
config: {
defaultAccountId: () => "default-account",
listAccountIds: () => ["default-account"],
resolveAccount: () => ({}),
},
});
});
it("resolves the default account and starts the channel runtime", async () => {
const startChannel = vi.fn();
const respond = vi.fn();
await channelsHandlers["channels.start"](
createOptions(
{ channel: "whatsapp" },
{
respond,
context: {
startChannel,
getRuntimeSnapshot: vi.fn(
(): ChannelRuntimeSnapshot => ({
channels: {
whatsapp: {
accountId: "default-account",
running: true,
},
},
channelAccounts: {
whatsapp: {
"default-account": {
accountId: "default-account",
running: true,
},
},
},
}),
),
} as unknown as GatewayRequestHandlerOptions["context"],
},
),
);
expect(mocks.applyPluginAutoEnable).toHaveBeenCalledWith({
config: {},
env: process.env,
});
expect(startChannel).toHaveBeenCalledWith("whatsapp", "default-account");
expect(respond).toHaveBeenCalledWith(
true,
{
channel: "whatsapp",
accountId: "default-account",
started: true,
},
undefined,
);
});
it("reports started=false when the channel runtime remains stopped", async () => {
const startChannel = vi.fn();
const respond = vi.fn();
await channelsHandlers["channels.start"](
createOptions(
{ channel: "whatsapp" },
{
respond,
context: {
startChannel,
getRuntimeSnapshot: vi.fn(
(): ChannelRuntimeSnapshot => ({
channels: {
whatsapp: {
accountId: "default-account",
running: false,
},
},
channelAccounts: {
whatsapp: {
"default-account": {
accountId: "default-account",
running: false,
},
},
},
}),
),
} as unknown as GatewayRequestHandlerOptions["context"],
},
),
);
expect(startChannel).toHaveBeenCalledWith("whatsapp", "default-account");
expect(respond).toHaveBeenCalledWith(
true,
{
channel: "whatsapp",
accountId: "default-account",
started: false,
},
undefined,
);
});
});

View File

@@ -20,9 +20,11 @@ import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateChannelsStartParams,
validateChannelsLogoutParams,
validateChannelsStatusParams,
} from "../protocol/index.js";
import type { ChannelRuntimeSnapshot } from "../server-channel-runtime.types.js";
import { formatForLog } from "../ws-log.js";
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
@@ -33,6 +35,39 @@ type ChannelLogoutPayload = {
[key: string]: unknown;
};
type ChannelStartPayload = {
channel: ChannelId;
accountId: string;
started: boolean;
};
function resolveRuntimeAccountSnapshot(params: {
runtime: ChannelRuntimeSnapshot;
channelId: ChannelId;
accountId: string;
}): ChannelAccountSnapshot | undefined {
const accounts = params.runtime.channelAccounts[params.channelId];
const direct = accounts?.[params.accountId];
if (direct) {
return direct;
}
const fallback = params.runtime.channels[params.channelId];
return fallback?.accountId === params.accountId ? fallback : undefined;
}
function resolveChannelGatewayAccountId(params: {
plugin: ChannelPlugin;
cfg: OpenClawConfig;
accountId?: string | null;
}): string {
return (
normalizeOptionalString(params.accountId) ||
params.plugin.config.defaultAccountId?.(params.cfg) ||
params.plugin.config.listAccountIds(params.cfg)[0] ||
DEFAULT_ACCOUNT_ID
);
}
export async function logoutChannelAccount(params: {
channelId: ChannelId;
accountId?: string | null;
@@ -40,11 +75,7 @@ export async function logoutChannelAccount(params: {
context: GatewayRequestContext;
plugin: ChannelPlugin;
}): Promise<ChannelLogoutPayload> {
const resolvedAccountId =
normalizeOptionalString(params.accountId) ||
params.plugin.config.defaultAccountId?.(params.cfg) ||
params.plugin.config.listAccountIds(params.cfg)[0] ||
DEFAULT_ACCOUNT_ID;
const resolvedAccountId = resolveChannelGatewayAccountId(params);
const account = params.plugin.config.resolveAccount(params.cfg, resolvedAccountId);
await params.context.stopChannel(params.channelId, resolvedAccountId);
const result = await params.plugin.gateway?.logoutAccount?.({
@@ -69,6 +100,32 @@ export async function logoutChannelAccount(params: {
};
}
export async function startChannelAccount(params: {
channelId: ChannelId;
accountId?: string | null;
cfg: OpenClawConfig;
context: GatewayRequestContext;
plugin: ChannelPlugin;
}): Promise<ChannelStartPayload> {
if (!params.plugin.gateway?.startAccount) {
throw new Error(`Channel ${params.channelId} does not support runtime start`);
}
const resolvedAccountId = resolveChannelGatewayAccountId(params);
await params.context.startChannel(params.channelId, resolvedAccountId);
const runtime = params.context.getRuntimeSnapshot();
const started =
resolveRuntimeAccountSnapshot({
runtime,
channelId: params.channelId,
accountId: resolvedAccountId,
})?.running === true;
return {
channel: params.channelId,
accountId: resolvedAccountId,
started,
};
}
export const channelsHandlers: GatewayRequestHandlers = {
"channels.status": async ({ params, respond, context }) => {
if (!validateChannelsStatusParams(params)) {
@@ -240,6 +297,62 @@ export const channelsHandlers: GatewayRequestHandlers = {
respond(true, payload, undefined);
},
"channels.start": async ({ params, respond, context }) => {
if (!validateChannelsStartParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid channels.start params: ${formatValidationErrors(validateChannelsStartParams.errors)}`,
),
);
return;
}
const rawChannel = (params as { channel?: unknown }).channel;
const channelId = typeof rawChannel === "string" ? normalizeChannelId(rawChannel) : null;
if (!channelId) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "invalid channels.start channel"),
);
return;
}
const plugin = getChannelPlugin(channelId);
if (!plugin) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unknown channel: ${formatForLog(rawChannel)}`),
);
return;
}
if (!plugin.gateway?.startAccount) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `channel ${channelId} does not support start`),
);
return;
}
try {
const cfg = applyPluginAutoEnable({
config: loadConfig(),
env: process.env,
}).config;
const payload = await startChannelAccount({
channelId,
accountId: (params as { accountId?: string | null }).accountId,
cfg,
context,
plugin,
});
respond(true, payload, undefined);
} catch (error) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(error)));
}
},
"channels.logout": async ({ params, respond, context }) => {
if (!validateChannelsLogoutParams(params)) {
respond(

View File

@@ -0,0 +1,163 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelRuntimeSnapshot } from "../server-channel-runtime.types.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
const mocks = vi.hoisted(() => ({
listChannelPlugins: vi.fn(),
}));
vi.mock("../../channels/plugins/index.js", () => ({
listChannelPlugins: mocks.listChannelPlugins,
}));
import { webHandlers } from "./web.js";
function createOptions(
params: Record<string, unknown>,
overrides?: Partial<GatewayRequestHandlerOptions>,
): GatewayRequestHandlerOptions {
return {
req: { type: "req", id: "req-1", method: "web.login.start", params },
params,
client: null,
isWebchatConnect: () => false,
respond: vi.fn(),
context: {
stopChannel: vi.fn(),
startChannel: vi.fn(),
getRuntimeSnapshot: vi.fn(
(): ChannelRuntimeSnapshot => ({
channels: {
whatsapp: {
accountId: "default",
running: true,
},
},
channelAccounts: {
whatsapp: {
default: {
accountId: "default",
running: true,
},
},
},
}),
),
},
...overrides,
} as unknown as GatewayRequestHandlerOptions;
}
describe("webHandlers web.login.start", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("restarts a previously running channel when login start exits early without a QR", async () => {
const loginWithQrStart = vi.fn().mockResolvedValue({
code: "whatsapp-auth-unstable",
message: "retry later",
});
mocks.listChannelPlugins.mockReturnValue([
{
id: "whatsapp",
gatewayMethods: ["web.login.start"],
gateway: { loginWithQrStart },
},
]);
const startChannel = vi.fn();
const stopChannel = vi.fn();
const respond = vi.fn();
await webHandlers["web.login.start"](
createOptions(
{ accountId: "default" },
{
respond,
context: {
stopChannel,
startChannel,
getRuntimeSnapshot: vi.fn(
(): ChannelRuntimeSnapshot => ({
channels: {
whatsapp: {
accountId: "default",
running: true,
},
},
channelAccounts: {
whatsapp: {
default: {
accountId: "default",
running: true,
},
},
},
}),
),
} as unknown as GatewayRequestHandlerOptions["context"],
},
),
);
expect(stopChannel).toHaveBeenCalledWith("whatsapp", "default");
expect(startChannel).toHaveBeenCalledWith("whatsapp", "default");
expect(respond).toHaveBeenCalledWith(
true,
{
code: "whatsapp-auth-unstable",
message: "retry later",
},
undefined,
);
});
it("keeps the channel stopped when login start has taken over with a QR flow", async () => {
const loginWithQrStart = vi.fn().mockResolvedValue({
qrDataUrl: "data:image/png;base64,qr",
message: "scan qr",
});
mocks.listChannelPlugins.mockReturnValue([
{
id: "whatsapp",
gatewayMethods: ["web.login.start"],
gateway: { loginWithQrStart },
},
]);
const startChannel = vi.fn();
const stopChannel = vi.fn();
await webHandlers["web.login.start"](
createOptions(
{ accountId: "default" },
{
context: {
stopChannel,
startChannel,
getRuntimeSnapshot: vi.fn(
(): ChannelRuntimeSnapshot => ({
channels: {
whatsapp: {
accountId: "default",
running: true,
},
},
channelAccounts: {
whatsapp: {
default: {
accountId: "default",
running: true,
},
},
},
}),
),
} as unknown as GatewayRequestHandlerOptions["context"],
},
),
);
expect(stopChannel).toHaveBeenCalledWith("whatsapp", "default");
expect(startChannel).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,5 @@
import { listChannelPlugins } from "../../channels/plugins/index.js";
import type { ChannelId } from "../../channels/plugins/types.public.js";
import {
ErrorCodes,
errorShape,
@@ -38,6 +39,25 @@ function respondProviderUnsupported(respond: RespondFn, providerId: string) {
);
}
function wasChannelRunning(params: {
context: Parameters<GatewayRequestHandlers["web.login.start"]>[0]["context"];
channelId: ChannelId;
accountId?: string;
}): boolean {
const runtime = params.context.getRuntimeSnapshot();
if (params.accountId) {
const accountRuntime = runtime.channelAccounts[params.channelId]?.[params.accountId];
if (accountRuntime) {
return accountRuntime.running === true;
}
}
if (!params.accountId) {
return runtime.channels[params.channelId]?.running === true;
}
const defaultRuntime = runtime.channels[params.channelId];
return defaultRuntime?.accountId === params.accountId && defaultRuntime.running === true;
}
export const webHandlers: GatewayRequestHandlers = {
"web.login.start": async ({ params, respond, context }) => {
if (!validateWebLoginStartParams(params)) {
@@ -58,11 +78,16 @@ export const webHandlers: GatewayRequestHandlers = {
respondProviderUnavailable(respond);
return;
}
await context.stopChannel(provider.id, accountId);
if (!provider.gateway?.loginWithQrStart) {
respondProviderUnsupported(respond, provider.id);
return;
}
const wasRunning = wasChannelRunning({
context,
channelId: provider.id,
accountId,
});
await context.stopChannel(provider.id, accountId);
const result = await provider.gateway.loginWithQrStart({
force: Boolean((params as { force?: boolean }).force),
timeoutMs:
@@ -72,6 +97,11 @@ export const webHandlers: GatewayRequestHandlers = {
verbose: Boolean((params as { verbose?: boolean }).verbose),
accountId,
});
if (result.connected) {
await context.startChannel(provider.id, accountId);
} else if (wasRunning && !result.qrDataUrl) {
await context.startChannel(provider.id, accountId);
}
respond(true, result, undefined);
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));

View File

@@ -61,6 +61,36 @@ async function expectSharedOperatorScopesCleared(
}
}
async function expectLocalBackendGatewayClientScopesPreserved(
port: number,
auth: { token?: string; password?: string },
) {
const ws = await openWs(port);
try {
const res = await connectReq(ws, {
...auth,
client: { ...BACKEND_GATEWAY_CLIENT },
scopes: ["operator.admin"],
device: null,
});
expect(res.ok).toBe(true);
const helloOk = res.payload as
| {
auth?: {
scopes?: unknown;
};
}
| undefined;
expect(helloOk?.auth?.scopes).toEqual(["operator.admin"]);
const adminRes = await rpcReq(ws, "set-heartbeats", { enabled: false });
expect(adminRes.ok).toBe(true);
} finally {
ws.close();
}
}
describe("gateway auth compatibility baseline", () => {
describe("token mode", () => {
let server: Awaited<ReturnType<typeof startGatewayServer>>;
@@ -94,6 +124,10 @@ describe("gateway auth compatibility baseline", () => {
await expectSharedOperatorScopesCleared(port, { token: "secret" });
});
test("preserves scopes for direct-local backend shared-token connects without device identity", async () => {
await expectLocalBackendGatewayClientScopesPreserved(port, { token: "secret" });
});
test("returns stable token-missing details for control ui without token", async () => {
const ws = await openWs(port, { origin: originForPort(port) });
try {
@@ -262,6 +296,10 @@ describe("gateway auth compatibility baseline", () => {
test("clears requested scopes for shared-password operator connects without device identity", async () => {
await expectSharedOperatorScopesCleared(port, { password: "secret" });
});
test("preserves scopes for direct-local backend shared-password connects without device identity", async () => {
await expectLocalBackendGatewayClientScopesPreserved(port, { password: "secret" });
});
});
describe("none mode", () => {

View File

@@ -575,6 +575,24 @@ export function attachGatewayWsMessageHandler(params: {
connectParams.scopes = scopes;
}
};
let pairingLocality = resolvePairingLocality({
connectParams,
isLocalClient,
requestHost,
requestOrigin,
remoteAddress: remoteAddr,
hasProxyHeaders,
hasBrowserOriginHeader,
sharedAuthOk,
authMethod,
});
let skipLocalBackendSelfPairing = shouldSkipLocalBackendSelfPairing({
connectParams,
locality: pairingLocality,
hasBrowserOriginHeader,
sharedAuthOk,
authMethod,
});
const handleMissingDeviceIdentity = (): boolean => {
const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
isControlUi,
@@ -600,10 +618,12 @@ export function attachGatewayWsMessageHandler(params: {
isLocalClient,
});
// Shared token/password auth can bypass pairing for trusted operators.
// Device-less clients only keep self-declared scopes on the explicit
// allow path, including trusted token-authenticated backend operators.
// Device-less clients still clear self-declared scopes by default, with
// one narrow exception: the direct-local backend gateway-client shared-
// auth handoff used for in-process control-plane coordination.
if (
!device &&
!skipLocalBackendSelfPairing &&
shouldClearUnboundScopesForMissingDeviceIdentity({
decision,
controlUiAuthPolicy,
@@ -739,6 +759,24 @@ export function attachGatewayWsMessageHandler(params: {
}),
verifyDeviceToken,
}));
pairingLocality = resolvePairingLocality({
connectParams,
isLocalClient,
requestHost,
requestOrigin,
remoteAddress: remoteAddr,
hasProxyHeaders,
hasBrowserOriginHeader,
sharedAuthOk,
authMethod,
});
skipLocalBackendSelfPairing = shouldSkipLocalBackendSelfPairing({
connectParams,
locality: pairingLocality,
hasBrowserOriginHeader,
sharedAuthOk,
authMethod,
});
if (!authOk) {
rejectUnauthorized(authResult);
return;
@@ -773,24 +811,6 @@ export function attachGatewayWsMessageHandler(params: {
authOk,
authMethod,
});
const pairingLocality = resolvePairingLocality({
connectParams,
isLocalClient,
requestHost,
requestOrigin,
remoteAddress: remoteAddr,
hasProxyHeaders,
hasBrowserOriginHeader,
sharedAuthOk,
authMethod,
});
const skipLocalBackendSelfPairing = shouldSkipLocalBackendSelfPairing({
connectParams,
locality: pairingLocality,
hasBrowserOriginHeader,
sharedAuthOk,
authMethod,
});
const skipControlUiPairingForDevice = shouldSkipControlUiPairing(
controlUiAuthPolicy,
role,

View File

@@ -72,6 +72,7 @@ function makeTelegramSummaryPlugin(params: {
enabled: boolean;
configured: boolean;
linked?: boolean;
statusState?: string;
authAgeMs?: number;
allowFrom?: string[];
}): ChannelPlugin {
@@ -107,6 +108,7 @@ function makeTelegramSummaryPlugin(params: {
},
status: {
buildChannelSummary: async () => ({
statusState: params.statusState,
linked: params.linked,
configured: params.configured,
authAgeMs: params.authAgeMs,
@@ -279,6 +281,29 @@ describe("buildChannelSummary", () => {
expect(lines).toContain(" - primary (Main Bot) (dm:mutuals, token:env)");
});
it("prefers plugin statusState when provided", async () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
plugin: makeTelegramSummaryPlugin({
enabled: true,
configured: true,
statusState: "unstable",
}),
source: "test",
},
]),
);
const lines = await buildChannelSummary({ channels: {} } as never, {
colorize: false,
includeAllowFrom: false,
});
expect(lines).toContain("Telegram: auth stabilizing +15551234567");
});
it("renders non-slack account detail fields for configured accounts", async () => {
setActivePluginRegistry(
createTestRegistry([

View File

@@ -9,6 +9,7 @@ import {
resolveChannelAccountEnabled,
} from "../channels/account-summary.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { formatChannelStatusState } from "../channels/plugins/status-state.js";
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
import type { ChannelAccountSnapshot } from "../channels/plugins/types.public.js";
import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js";
@@ -199,6 +200,10 @@ export async function buildChannelSummary(
: undefined;
const summaryRecord = summary;
const statusState =
summaryRecord && typeof summaryRecord.statusState === "string"
? summaryRecord.statusState
: null;
const linked =
summaryRecord && typeof summaryRecord.linked === "boolean" ? summaryRecord.linked : null;
const configured =
@@ -208,18 +213,20 @@ export async function buildChannelSummary(
const status = !anyEnabled
? "disabled"
: linked !== null
? linked
? "linked"
: "not linked"
: configured
? "configured"
: "not configured";
: statusState
? formatChannelStatusState(statusState)
: linked !== null
? linked
? "linked"
: "not linked"
: configured
? "configured"
: "not configured";
const statusColor =
status === "linked" || status === "configured"
? theme.success
: status === "not linked"
: status === "not linked" || status === "auth stabilizing"
? theme.error
: theme.muted;
const baseLabel = plugin.meta.label ?? plugin.id;

View File

@@ -41,16 +41,17 @@ export async function startWhatsAppLogin(state: ChannelsState, force: boolean) {
}
state.whatsappBusy = true;
try {
const res = await state.client.request<{ message?: string; qrDataUrl?: string }>(
"web.login.start",
{
force,
timeoutMs: 30000,
},
);
const res = await state.client.request<{
message?: string;
qrDataUrl?: string;
connected?: boolean;
}>("web.login.start", {
force,
timeoutMs: 30000,
});
state.whatsappLoginMessage = res.message ?? null;
state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null;
state.whatsappLoginConnected = null;
state.whatsappLoginConnected = typeof res.connected === "boolean" ? res.connected : null;
} catch (err) {
state.whatsappLoginMessage = String(err);
state.whatsappLoginQrDataUrl = null;