diff --git a/CHANGELOG.md b/CHANGELOG.md index 350acd09e9f..c0b47107134 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Heartbeat/Cron: restore interval heartbeat behavior so missing `HEARTBEAT.md` no longer suppresses runs (only effectively empty files skip), preserving prompt-driven and tagged-cron execution paths. +- WhatsApp/Cron/Heartbeat: enforce allowlisted routing for implicit scheduled/system delivery by merging pairing-store + configured `allowFrom` recipients, selecting authorized recipients when last-route context points to a non-allowlisted chat, and preventing heartbeat fan-out to recent unauthorized chats. - Heartbeat/Active hours: constrain active-hours `24` sentinel parsing to `24:00` in time validation so invalid values like `24:30` are rejected early. (#21410) thanks @adhitShet. - Heartbeat: treat `activeHours` windows with identical `start`/`end` times as zero-width (always outside the window) instead of always-active. (#21408) thanks @adhitShet. - Gateway/Pairing: tolerate legacy paired devices missing `roles`/`scopes` metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant. diff --git a/src/channels/plugins/whatsapp-heartbeat.test.ts b/src/channels/plugins/whatsapp-heartbeat.test.ts new file mode 100644 index 00000000000..6d430ccf8dd --- /dev/null +++ b/src/channels/plugins/whatsapp-heartbeat.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../config/sessions.js", () => ({ + loadSessionStore: vi.fn(), + resolveStorePath: vi.fn(() => "/tmp/test-sessions.json"), +})); + +vi.mock("../../pairing/pairing-store.js", () => ({ + readChannelAllowFromStoreSync: vi.fn(() => []), +})); + +import type { OpenClawConfig } from "../../config/config.js"; +import { loadSessionStore } from "../../config/sessions.js"; +import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js"; +import { resolveWhatsAppHeartbeatRecipients } from "./whatsapp-heartbeat.js"; + +function makeCfg(overrides?: Partial): OpenClawConfig { + return { + bindings: [], + channels: {}, + ...overrides, + } as OpenClawConfig; +} + +describe("resolveWhatsAppHeartbeatRecipients", () => { + beforeEach(() => { + vi.mocked(loadSessionStore).mockReset(); + vi.mocked(readChannelAllowFromStoreSync).mockReset(); + vi.mocked(readChannelAllowFromStoreSync).mockReturnValue([]); + }); + + it("uses allowFrom store recipients when session recipients are ambiguous", () => { + vi.mocked(loadSessionStore).mockReturnValue({ + a: { lastChannel: "whatsapp", lastTo: "+15550000001", updatedAt: 2, sessionId: "a" }, + b: { lastChannel: "whatsapp", lastTo: "+15550000002", updatedAt: 1, sessionId: "b" }, + }); + vi.mocked(readChannelAllowFromStoreSync).mockReturnValue(["+15550000001"]); + + const cfg = makeCfg(); + const result = resolveWhatsAppHeartbeatRecipients(cfg); + + expect(result).toEqual({ recipients: ["+15550000001"], source: "session-single" }); + }); + + it("falls back to allowFrom when no session recipient is authorized", () => { + vi.mocked(loadSessionStore).mockReturnValue({ + a: { lastChannel: "whatsapp", lastTo: "+15550000099", updatedAt: 2, sessionId: "a" }, + }); + vi.mocked(readChannelAllowFromStoreSync).mockReturnValue(["+15550000001"]); + + const cfg = makeCfg(); + const result = resolveWhatsAppHeartbeatRecipients(cfg); + + expect(result).toEqual({ recipients: ["+15550000001"], source: "allowFrom" }); + }); + + it("includes both session and allowFrom recipients when --all is set", () => { + vi.mocked(loadSessionStore).mockReturnValue({ + a: { lastChannel: "whatsapp", lastTo: "+15550000099", updatedAt: 2, sessionId: "a" }, + }); + vi.mocked(readChannelAllowFromStoreSync).mockReturnValue(["+15550000001"]); + + const cfg = makeCfg(); + const result = resolveWhatsAppHeartbeatRecipients(cfg, { all: true }); + + expect(result).toEqual({ + recipients: ["+15550000099", "+15550000001"], + source: "all", + }); + }); +}); diff --git a/src/channels/plugins/whatsapp-heartbeat.ts b/src/channels/plugins/whatsapp-heartbeat.ts index 9d6e4a48a5b..d91e5dd25c1 100644 --- a/src/channels/plugins/whatsapp-heartbeat.ts +++ b/src/channels/plugins/whatsapp-heartbeat.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../../config/config.js"; import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; +import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js"; import { normalizeE164 } from "../../utils.js"; import { normalizeChatChannelId } from "../registry.js"; @@ -51,18 +52,34 @@ export function resolveWhatsAppHeartbeatRecipients( } const sessionRecipients = getSessionRecipients(cfg); - const allowFrom = + const configuredAllowFrom = Array.isArray(cfg.channels?.whatsapp?.allowFrom) && cfg.channels.whatsapp.allowFrom.length > 0 ? cfg.channels.whatsapp.allowFrom.filter((v) => v !== "*").map(normalizeE164) : []; + const storeAllowFrom = readChannelAllowFromStoreSync("whatsapp").map(normalizeE164); const unique = (list: string[]) => [...new Set(list.filter(Boolean))]; + const allowFrom = unique([...configuredAllowFrom, ...storeAllowFrom]); if (opts.all) { const all = unique([...sessionRecipients.map((s) => s.to), ...allowFrom]); return { recipients: all, source: "all" }; } + if (allowFrom.length > 0) { + const allowSet = new Set(allowFrom); + const authorizedSessionRecipients = sessionRecipients + .map((entry) => entry.to) + .filter((recipient) => allowSet.has(recipient)); + if (authorizedSessionRecipients.length === 1) { + return { recipients: [authorizedSessionRecipients[0]], source: "session-single" }; + } + if (authorizedSessionRecipients.length > 1) { + return { recipients: authorizedSessionRecipients, source: "session-ambiguous" }; + } + return { recipients: allowFrom, source: "allowFrom" }; + } + if (sessionRecipients.length === 1) { return { recipients: [sessionRecipients[0].to], source: "session-single" }; } diff --git a/src/cron/isolated-agent/delivery-target.test.ts b/src/cron/isolated-agent/delivery-target.test.ts index 1b61407f4e4..15acbd36834 100644 --- a/src/cron/isolated-agent/delivery-target.test.ts +++ b/src/cron/isolated-agent/delivery-target.test.ts @@ -12,8 +12,18 @@ vi.mock("../../infra/outbound/channel-selection.js", () => ({ resolveMessageChannelSelection: vi.fn().mockResolvedValue({ channel: "telegram" }), })); +vi.mock("../../pairing/pairing-store.js", () => ({ + readChannelAllowFromStoreSync: vi.fn(() => []), +})); + +vi.mock("../../web/accounts.js", () => ({ + resolveWhatsAppAccount: vi.fn(() => ({ allowFrom: [] })), +})); + import { loadSessionStore } from "../../config/sessions.js"; import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js"; +import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js"; +import { resolveWhatsAppAccount } from "../../web/accounts.js"; import { resolveDeliveryTarget } from "./delivery-target.js"; function makeCfg(overrides?: Partial): OpenClawConfig { @@ -50,6 +60,46 @@ async function resolveForAgent(params: { } describe("resolveDeliveryTarget", () => { + it("reroutes implicit whatsapp delivery to authorized allowFrom recipient", async () => { + setMainSessionEntry({ + sessionId: "sess-w1", + updatedAt: 1000, + lastChannel: "whatsapp", + lastTo: "+15550000099", + }); + vi.mocked(resolveWhatsAppAccount).mockReturnValue({ + allowFrom: [], + } as unknown as ReturnType); + vi.mocked(readChannelAllowFromStoreSync).mockReturnValue(["+15550000001"]); + + const cfg = makeCfg({ bindings: [] }); + const result = await resolveDeliveryTarget(cfg, AGENT_ID, { channel: "last", to: undefined }); + + expect(result.channel).toBe("whatsapp"); + expect(result.to).toBe("+15550000001"); + }); + + it("keeps explicit whatsapp target unchanged", async () => { + setMainSessionEntry({ + sessionId: "sess-w2", + updatedAt: 1000, + lastChannel: "whatsapp", + lastTo: "+15550000099", + }); + vi.mocked(resolveWhatsAppAccount).mockReturnValue({ + allowFrom: [], + } as unknown as ReturnType); + vi.mocked(readChannelAllowFromStoreSync).mockReturnValue(["+15550000001"]); + + const cfg = makeCfg({ bindings: [] }); + const result = await resolveDeliveryTarget(cfg, AGENT_ID, { + channel: "whatsapp", + to: "+15550000099", + }); + + expect(result.to).toBe("+15550000099"); + }); + it("falls back to bound accountId when session has no lastAccountId", async () => { setMainSessionEntry(undefined); diff --git a/src/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts index b7516778e63..b13e4a40c6f 100644 --- a/src/cron/isolated-agent/delivery-target.ts +++ b/src/cron/isolated-agent/delivery-target.ts @@ -12,8 +12,11 @@ import { resolveOutboundTarget, resolveSessionDeliveryTarget, } from "../../infra/outbound/targets.js"; +import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js"; import { buildChannelAccountBindings } from "../../routing/bindings.js"; import { normalizeAgentId } from "../../routing/session-key.js"; +import { resolveWhatsAppAccount } from "../../web/accounts.js"; +import { normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; export async function resolveDeliveryTarget( cfg: OpenClawConfig, @@ -76,7 +79,7 @@ export async function resolveDeliveryTarget( const channel = resolved.channel ?? fallbackChannel ?? DEFAULT_CHAT_CHANNEL; const mode = resolved.mode as "explicit" | "implicit"; - const toCandidate = resolved.to; + let toCandidate = resolved.to; // When the session has no lastAccountId (e.g. first-run isolated cron // session), fall back to the agent's bound account from bindings config. @@ -112,12 +115,34 @@ export async function resolveDeliveryTarget( }; } + let allowFromOverride: string[] | undefined; + if (channel === "whatsapp") { + const configuredAllowFromRaw = resolveWhatsAppAccount({ cfg, accountId }).allowFrom ?? []; + const configuredAllowFrom = configuredAllowFromRaw + .map((entry) => String(entry).trim()) + .filter((entry) => entry && entry !== "*") + .map((entry) => normalizeWhatsAppTarget(entry)) + .filter((entry): entry is string => Boolean(entry)); + const storeAllowFrom = readChannelAllowFromStoreSync("whatsapp", process.env, accountId) + .map((entry) => normalizeWhatsAppTarget(entry)) + .filter((entry): entry is string => Boolean(entry)); + allowFromOverride = [...new Set([...configuredAllowFrom, ...storeAllowFrom])]; + + if (mode === "implicit" && allowFromOverride.length > 0) { + const normalizedCurrentTarget = normalizeWhatsAppTarget(toCandidate); + if (!normalizedCurrentTarget || !allowFromOverride.includes(normalizedCurrentTarget)) { + toCandidate = allowFromOverride[0]; + } + } + } + const docked = resolveOutboundTarget({ channel, to: toCandidate, cfg, accountId, mode, + allowFrom: allowFromOverride, }); return { channel, diff --git a/src/pairing/pairing-store.ts b/src/pairing/pairing-store.ts index fcf9a4f0ce1..758ffa5961a 100644 --- a/src/pairing/pairing-store.ts +++ b/src/pairing/pairing-store.ts @@ -269,6 +269,16 @@ async function readAllowFromStateForPath( return normalizeAllowFromList(channel, value); } +function readAllowFromStateForPathSync(channel: PairingChannel, filePath: string): string[] { + try { + const raw = fs.readFileSync(filePath, "utf8"); + const parsed = JSON.parse(raw) as AllowFromStore; + return normalizeAllowFromList(channel, parsed); + } catch { + return []; + } +} + async function readAllowFromState(params: { channel: PairingChannel; entry: string | number; @@ -341,6 +351,24 @@ export async function readChannelAllowFromStore( return dedupePreserveOrder([...scopedEntries, ...legacyEntries]); } +export function readChannelAllowFromStoreSync( + channel: PairingChannel, + env: NodeJS.ProcessEnv = process.env, + accountId?: string, +): string[] { + const normalizedAccountId = accountId?.trim().toLowerCase() ?? ""; + if (!normalizedAccountId) { + const filePath = resolveAllowFromPath(channel, env); + return readAllowFromStateForPathSync(channel, filePath); + } + + const scopedPath = resolveAllowFromPath(channel, env, accountId); + const scopedEntries = readAllowFromStateForPathSync(channel, scopedPath); + const legacyPath = resolveAllowFromPath(channel, env); + const legacyEntries = readAllowFromStateForPathSync(channel, legacyPath); + return dedupePreserveOrder([...scopedEntries, ...legacyEntries]); +} + type AllowFromStoreEntryUpdateParams = { channel: PairingChannel; entry: string | number;