diff --git a/CHANGELOG.md b/CHANGELOG.md index f3979c921f1..e65c260e832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Infer/media: report missing image-understanding and audio-transcription provider configuration for `image describe`, `image describe-many`, and `audio transcribe` instead of blaming the input path when no provider is available. Fixes #73569 and supersedes #73593, #74288, and #74495. Thanks @bittoby, @tmimmanuel, @Linux2010, and @vyctorbrzezowski. - Docs/health: clarify that session listing surfaces stored conversation rows rather than Discord/channel socket liveness, and point connectivity checks at channel status and health probes. Fixes #70420. Thanks @ashersoutherncities-art and @martingarramon. +- WhatsApp/Cron: keep DM pairing-store approvals out of implicit cron and heartbeat recipient fallback, so scheduled automation only uses explicit targets, active configured recipients, or configured `allowFrom` entries. Fixes #62339. Thanks @kelvinisly-collab. - Active Memory: use the configured recall timeout as the blocking prompt-build hook budget by default and move cold-start setup grace behind explicit `setupGraceTimeoutMs` config, so the plugin no longer silently extends 15000 ms configs to 45000 ms on the main lane. Fixes #75843. Thanks @vishutdhar. - Plugins/web-provider: reuse the active gateway plugin registry for runtime web provider resolution after deriving the same candidate plugin ids as the loader path, avoiding a redundant `loadOpenClawPlugins` call on every request while preserving origin and scope filters. Fixes #75513. Thanks @jochen. - Crestodian/CLI: exit non-zero when interactive Crestodian is invoked without a TTY, so scripts and CI no longer treat the setup error as success. Fixes #73646 and supersedes #73928 and #74059. Thanks @bittoby, @luyao618, and @Linux2010. diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 9956325a808..9255ae69479 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -164,6 +164,8 @@ For isolated jobs, chat delivery is shared. If a chat route is available, the ag When an agent creates an isolated reminder from an active chat, OpenClaw stores the preserved live delivery target for the fallback announce route. Internal session keys may be lowercase; provider delivery targets are not reconstructed from those keys when current chat context is available. +Implicit announce delivery uses configured channel allowlists to validate and reroute stale targets. DM pairing-store approvals are not fallback automation recipients; set `delivery.to` or configure the channel `allowFrom` entry when a scheduled job should proactively send to a DM. + Failure notifications follow a separate destination path: - `cron.failureDestination` sets a global default for failure notifications. diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index 79f72a59d4f..5bc9bab9b26 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -219,6 +219,7 @@ content and identifiers. Runtime behavior details: - pairings are persisted in channel allow-store and merged with configured `allowFrom` + - scheduled automation and heartbeat recipient fallback use explicit delivery targets or configured `allowFrom`; DM pairing approvals are not implicit cron or heartbeat recipients - if no allowlist is configured, the linked self number is allowed by default - OpenClaw never auto-pairs outbound `fromMe` DMs (messages you send to yourself from the linked device) diff --git a/extensions/whatsapp/src/heartbeat-recipients.runtime.ts b/extensions/whatsapp/src/heartbeat-recipients.runtime.ts index ba58e4a79cd..8d1a83a9a2a 100644 --- a/extensions/whatsapp/src/heartbeat-recipients.runtime.ts +++ b/extensions/whatsapp/src/heartbeat-recipients.runtime.ts @@ -1,6 +1,5 @@ export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; export { normalizeE164 } from "openclaw/plugin-sdk/account-resolution"; -export { readChannelAllowFromStoreSync } from "openclaw/plugin-sdk/channel-pairing"; export { normalizeChannelId } from "openclaw/plugin-sdk/channel-targets"; export { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; export type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; diff --git a/extensions/whatsapp/src/heartbeat-recipients.test.ts b/extensions/whatsapp/src/heartbeat-recipients.test.ts index e9a44610b3a..2e522d47460 100644 --- a/extensions/whatsapp/src/heartbeat-recipients.test.ts +++ b/extensions/whatsapp/src/heartbeat-recipients.test.ts @@ -3,12 +3,10 @@ import { resolveWhatsAppHeartbeatRecipients } from "./heartbeat-recipients.js"; import type { OpenClawConfig } from "./runtime-api.js"; const loadSessionStoreMock = vi.hoisted(() => vi.fn()); -const readChannelAllowFromStoreSyncMock = vi.hoisted(() => vi.fn<() => string[]>(() => [])); vi.mock("./heartbeat-recipients.runtime.js", () => ({ DEFAULT_ACCOUNT_ID: "default", loadSessionStore: loadSessionStoreMock, - readChannelAllowFromStoreSync: readChannelAllowFromStoreSyncMock, resolveStorePath: vi.fn(() => "/tmp/test-sessions.json"), normalizeChannelId: (value?: string | null) => { const trimmed = value?.trim().toLowerCase(); @@ -36,10 +34,6 @@ describe("resolveWhatsAppHeartbeatRecipients", () => { loadSessionStoreMock.mockReturnValue(store); } - function setAllowFromStore(entries: string[]) { - readChannelAllowFromStoreSyncMock.mockReturnValue(entries); - } - function resolveWith( cfgOverrides: Partial = {}, opts?: Parameters[1], @@ -51,24 +45,22 @@ describe("resolveWhatsAppHeartbeatRecipients", () => { setSessionStore({ a: { lastChannel: "whatsapp", lastTo: "+15550000099", updatedAt: 2, sessionId: "a" }, }); - setAllowFromStore(["+15550000001"]); } beforeEach(() => { loadSessionStoreMock.mockReset(); - readChannelAllowFromStoreSyncMock.mockReset(); loadSessionStoreMock.mockReturnValue({}); - setAllowFromStore([]); }); - it("uses allowFrom store recipients when session recipients are ambiguous", () => { + it("uses configured allowFrom recipients when session recipients are ambiguous", () => { setSessionStore({ a: { lastChannel: "whatsapp", lastTo: "+15550000001", updatedAt: 2, sessionId: "a" }, b: { lastChannel: "whatsapp", lastTo: "+15550000002", updatedAt: 1, sessionId: "b" }, }); - setAllowFromStore(["+15550000001"]); - const result = resolveWith(); + const result = resolveWith({ + channels: { whatsapp: { allowFrom: ["+15550000001"] } as never }, + }); expect(result).toEqual({ recipients: ["+15550000001"], source: "session-single" }); }); @@ -76,7 +68,9 @@ describe("resolveWhatsAppHeartbeatRecipients", () => { it("falls back to allowFrom when no session recipient is authorized", () => { setSingleUnauthorizedSessionWithAllowFrom(); - const result = resolveWith(); + const result = resolveWith({ + channels: { whatsapp: { allowFrom: ["+15550000001"] } as never }, + }); expect(result).toEqual({ recipients: ["+15550000001"], source: "allowFrom" }); }); @@ -84,7 +78,10 @@ describe("resolveWhatsAppHeartbeatRecipients", () => { it("includes both session and allowFrom recipients when --all is set", () => { setSingleUnauthorizedSessionWithAllowFrom(); - const result = resolveWith({}, { all: true }); + const result = resolveWith( + { channels: { whatsapp: { allowFrom: ["+15550000001"] } as never } }, + { all: true }, + ); expect(result).toEqual({ recipients: ["+15550000099", "+15550000001"], @@ -126,8 +123,9 @@ describe("resolveWhatsAppHeartbeatRecipients", () => { b: { lastChannel: "whatsapp", lastTo: "+15550000002", updatedAt: 1, sessionId: "b" }, c: { lastChannel: "whatsapp", lastTo: "+15550000003", updatedAt: 0, sessionId: "c" }, }); - setAllowFromStore(["+15550000001", "+15550000002"]); - const result = resolveWith(); + const result = resolveWith({ + channels: { whatsapp: { allowFrom: ["+15550000001", "+15550000002"] } as never }, + }); expect(result).toEqual({ recipients: ["+15550000001", "+15550000002"], source: "session-ambiguous", @@ -145,11 +143,10 @@ describe("resolveWhatsAppHeartbeatRecipients", () => { expect(result).toEqual({ recipients: ["+15550000009"], source: "allowFrom" }); }); - it("uses the requested account allowFrom config and pairing store", () => { + it("uses the requested account allowFrom config without pairing-store recipients", () => { setSessionStore({ a: { lastChannel: "whatsapp", lastTo: "+15550000077", updatedAt: 2, sessionId: "a" }, }); - setAllowFromStore(["+15550000002"]); const result = resolveWith( { @@ -167,18 +164,16 @@ describe("resolveWhatsAppHeartbeatRecipients", () => { { accountId: "work" }, ); - expect(readChannelAllowFromStoreSyncMock).toHaveBeenCalledWith("whatsapp", process.env, "work"); expect(result).toEqual({ - recipients: ["+15550000003", "+15550000002"], + recipients: ["+15550000003"], source: "allowFrom", }); }); - it("uses configured defaultAccount allowFrom config and pairing store when accountId is omitted", () => { + it("uses configured defaultAccount allowFrom config when accountId is omitted", () => { setSessionStore({ a: { lastChannel: "whatsapp", lastTo: "+15550000077", updatedAt: 2, sessionId: "a" }, }); - setAllowFromStore(["+15550000002"]); const result = resolveWith({ channels: { @@ -194,9 +189,8 @@ describe("resolveWhatsAppHeartbeatRecipients", () => { }, }); - expect(readChannelAllowFromStoreSyncMock).toHaveBeenCalledWith("whatsapp", process.env, "work"); expect(result).toEqual({ - recipients: ["+15550000003", "+15550000002"], + recipients: ["+15550000003"], source: "allowFrom", }); }); diff --git a/extensions/whatsapp/src/heartbeat-recipients.ts b/extensions/whatsapp/src/heartbeat-recipients.ts index 8811935968d..961ba358ddb 100644 --- a/extensions/whatsapp/src/heartbeat-recipients.ts +++ b/extensions/whatsapp/src/heartbeat-recipients.ts @@ -4,7 +4,6 @@ import { loadSessionStore, normalizeChannelId, normalizeE164, - readChannelAllowFromStoreSync, resolveStorePath, type OpenClawConfig, } from "./heartbeat-recipients.runtime.js"; @@ -63,14 +62,9 @@ export function resolveWhatsAppHeartbeatRecipients( ) .filter((value) => value !== "*") .map(normalizeE164); - const storeAllowFrom = readChannelAllowFromStoreSync( - "whatsapp", - process.env, - resolvedAccountId, - ).map(normalizeE164); const unique = (list: string[]) => [...new Set(list.filter(Boolean))]; - const allowFrom = unique([...configuredAllowFrom, ...storeAllowFrom]); + const allowFrom = unique(configuredAllowFrom); if (opts.all) { return { diff --git a/src/cron/isolated-agent/delivery-target.runtime.ts b/src/cron/isolated-agent/delivery-target.runtime.ts index d22ffaa3d35..bc57fcca898 100644 --- a/src/cron/isolated-agent/delivery-target.runtime.ts +++ b/src/cron/isolated-agent/delivery-target.runtime.ts @@ -1,4 +1,3 @@ export { getLoadedChannelPluginForRead } from "../../channels/plugins/registry-loaded-read.js"; -export { readChannelAllowFromStoreEntriesSync } from "../../pairing/allow-from-store-read.js"; export { mapAllowFromEntries } from "../../plugin-sdk/channel-config-helpers.js"; export { resolveFirstBoundAccountId } from "../../routing/bound-account-read.js"; diff --git a/src/cron/isolated-agent/delivery-target.test.ts b/src/cron/isolated-agent/delivery-target.test.ts index 0d5bd53c4b0..33c6cb1c640 100644 --- a/src/cron/isolated-agent/delivery-target.test.ts +++ b/src/cron/isolated-agent/delivery-target.test.ts @@ -103,6 +103,8 @@ const normalizeTelegramTargetForDeliveryTest = vi.fn((raw: string): string | und beforeEach(() => { resetPluginRuntimeStateForTest(); normalizeTelegramTargetForDeliveryTest.mockClear(); + vi.mocked(readChannelAllowFromStoreEntriesSync).mockReset(); + vi.mocked(readChannelAllowFromStoreEntriesSync).mockReturnValue([]); vi.mocked(resolveOutboundTarget).mockReset(); setActivePluginRegistry( createTestRegistry([ @@ -234,9 +236,8 @@ describe("resolveDeliveryTarget", () => { lastChannel: "alpha", lastTo: "room-denied", }); - setStoredAlphaAllowFrom(["room-allowed"]); - const cfg = makeCfg({ bindings: [], channels: { alpha: { allowFrom: [] } } }); + const cfg = makeCfg({ bindings: [], channels: { alpha: { allowFrom: ["room-allowed"] } } }); const result = await resolveLastTarget(cfg); expect(result.channel).toBe("alpha"); @@ -249,9 +250,8 @@ describe("resolveDeliveryTarget", () => { lastChannel: "alpha", lastTo: "room-denied", }); - setStoredAlphaAllowFrom(["room-allowed"]); - const cfg = makeCfg({ bindings: [], channels: { alpha: { allowFrom: [] } } }); + const cfg = makeCfg({ bindings: [], channels: { alpha: { allowFrom: ["room-allowed"] } } }); const result = await resolveDeliveryTarget( cfg, AGENT_ID, @@ -283,6 +283,19 @@ describe("resolveDeliveryTarget", () => { expect(result.to).toBe("room-denied"); }); + it("does not use pairing-store entries as implicit automation recipients", async () => { + setMainSessionEntry(undefined); + setStoredAlphaAllowFrom(["room-paired"]); + + const cfg = makeCfg({ bindings: [], channels: { alpha: { allowFrom: [] } } }); + const result = await resolveLastTarget(cfg); + + expect(result.ok).toBe(false); + expect(result.channel).toBe("alpha"); + expect(result.to).toBeUndefined(); + expect(readChannelAllowFromStoreEntriesSync).not.toHaveBeenCalled(); + }); + it("falls back to bound accountId when session has no lastAccountId", async () => { setMainSessionEntry(undefined); const cfg = makeForumBoundCfg(); diff --git a/src/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts index fcafa2f8787..7fd704cfb17 100644 --- a/src/cron/isolated-agent/delivery-target.ts +++ b/src/cron/isolated-agent/delivery-target.ts @@ -241,11 +241,8 @@ export async function resolveDeliveryTarget( let effectiveAllowFrom: string[] | undefined; if (mode === "implicit") { - const { - getLoadedChannelPluginForRead, - mapAllowFromEntries, - readChannelAllowFromStoreEntriesSync, - } = await loadDeliveryTargetRuntime(); + const { getLoadedChannelPluginForRead, mapAllowFromEntries } = + await loadDeliveryTargetRuntime(); const channelPlugin = getLoadedChannelPluginForRead(channel); const resolvedAccountId = normalizeAccountId(accountId); const configuredAllowFromRaw = channelPlugin?.config.resolveAllowFrom?.({ @@ -255,12 +252,7 @@ export async function resolveDeliveryTarget( const configuredAllowFrom = configuredAllowFromRaw ? mapAllowFromEntries(configuredAllowFromRaw) : []; - const storeAllowFrom = readChannelAllowFromStoreEntriesSync( - channel, - process.env, - resolvedAccountId, - ); - const allowFromOverride = [...new Set([...configuredAllowFrom, ...storeAllowFrom])]; + const allowFromOverride = [...new Set(configuredAllowFrom)]; effectiveAllowFrom = allowFromOverride; if (toCandidate && allowFromOverride.length > 0) {