diff --git a/CHANGELOG.md b/CHANGELOG.md index 7601b9997b2..f01eed45ffa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,11 @@ Docs: https://docs.openclaw.ai ### Breaking - **BREAKING:** Security/Sandbox: block Docker `network: "container:"` namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). Thanks @tdjackey for reporting. +- **BREAKING:** Heartbeat delivery now blocks DM-style `user:` targets. Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages. ### Fixes +- Heartbeat routing: prevent heartbeat leakage/spam into Discord DMs by blocking DM-style heartbeat delivery targets and keeping blocked-delivery cron/exec prompts internal-only. (#25871) - iMessage/Reasoning safety: harden iMessage echo suppression with outbound `messageId` matching (plus scoped text fallback), and enforce reasoning-payload suppression on routed outbound delivery paths to prevent hidden thinking text from being sent as user-visible channel messages. (#25897, #1649, #25757) Thanks @rmarr and @Iranb. - Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 `dev=0` stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false `Local media path is not safe to read` drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng. - Windows/Exec shell selection: prefer PowerShell 7 (`pwsh`) discovery (Program Files, ProgramW6432, PATH) before falling back to Windows PowerShell 5.1, fixing `&&` command chaining failures on Windows hosts with PS7 installed. (#25684, #25638) Thanks @zerone0x. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 58c1d6fd504..050106968f4 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -812,6 +812,7 @@ Periodic heartbeat runs. - `every`: duration string (ms/s/m/h). Default: `30m`. - `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs. +- Heartbeats never deliver to DM-style `user:` targets; those runs still execute, but outbound delivery is skipped. - Per-agent: set `agents.list[].heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats. - Heartbeats run full agent turns — shorter intervals burn more tokens. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index f4fea3b5a35..3f7403d4647 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -239,7 +239,7 @@ When validation fails: ``` - `every`: duration string (`30m`, `2h`). Set `0m` to disable. - - `target`: `last` | `whatsapp` | `telegram` | `discord` | `none` + - `target`: `last` | `whatsapp` | `telegram` | `discord` | `none` (DM-style `user:` heartbeat delivery is blocked) - See [Heartbeat](/gateway/heartbeat) for the full guide. diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index e22d0990612..c2a762bb6a0 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -215,6 +215,7 @@ Use `accountId` to target a specific account on multi-account channels like Tele - `last`: deliver to the last used external channel. - explicit channel: `whatsapp` / `telegram` / `discord` / `googlechat` / `slack` / `msteams` / `signal` / `imessage`. - `none` (default): run the heartbeat but **do not deliver** externally. +- DM-style heartbeat destinations are blocked (`user:` targets resolve to no-delivery). - `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id). For Telegram topics/threads, use `:topic:`. - `accountId`: optional account id for multi-account channels. When `target: "last"`, the account id applies to the resolved last channel if it supports accounts; otherwise it is ignored. If the account id does not match a configured account for the resolved channel, delivery is skipped. - `prompt`: overrides the default prompt body (not merged). @@ -235,6 +236,7 @@ Use `accountId` to target a specific account on multi-account channels like Tele - `session` only affects the run context; delivery is controlled by `target` and `to`. - To deliver to a specific channel/recipient, set `target` + `to`. With `target: "last"`, delivery uses the last external channel for that session. +- Heartbeat deliveries never send to DM-style `user:` targets; those runs still execute, but outbound delivery is skipped. - If the main queue is busy, the heartbeat is skipped and retried later. - If `target` resolves to no external destination, the run still happens but no outbound message is sent. diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index d3bb0ad9e41..69bf0c450d7 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -174,6 +174,7 @@ Common signatures: - `cron: timer tick failed` → scheduler tick failed; check file/log/runtime errors. - `heartbeat skipped` with `reason=quiet-hours` → outside active hours window. - `heartbeat: unknown accountId` → invalid account id for heartbeat delivery target. +- `heartbeat skipped` with `reason=dm-blocked` → heartbeat target resolved to a DM-style `user:` destination (blocked by design). Related: diff --git a/docs/start/openclaw.md b/docs/start/openclaw.md index fec776bb8f6..058f2fa67fe 100644 --- a/docs/start/openclaw.md +++ b/docs/start/openclaw.md @@ -164,6 +164,7 @@ Set `agents.defaults.heartbeat.every: "0m"` to disable. - If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), OpenClaw skips the heartbeat run to save API calls. - If the file is missing, the heartbeat still runs and the model decides what to do. - If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), OpenClaw suppresses outbound delivery for that heartbeat. +- Heartbeat delivery to DM-style `user:` targets is blocked; those runs still execute but skip outbound delivery. - Heartbeats run full agent turns — shorter intervals burn more tokens. ```json5 diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index b835df8863d..2696d4bdb03 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -37,6 +37,7 @@ describe("Ghost reminder bug (issue #13317)", () => { const createConfig = async (params: { tmpDir: string; storePath: string; + target?: "telegram" | "none"; }): Promise<{ cfg: OpenClawConfig; sessionKey: string }> => { const cfg: OpenClawConfig = { agents: { @@ -44,7 +45,7 @@ describe("Ghost reminder bug (issue #13317)", () => { workspace: params.tmpDir, heartbeat: { every: "5m", - target: "telegram", + target: params.target ?? "telegram", }, }, }, @@ -96,6 +97,7 @@ describe("Ghost reminder bug (issue #13317)", () => { replyText: string; reason: string; enqueue: (sessionKey: string) => void; + target?: "telegram" | "none"; }): Promise<{ result: Awaited>; sendTelegram: ReturnType; @@ -105,7 +107,11 @@ describe("Ghost reminder bug (issue #13317)", () => { return withTempHeartbeatSandbox( async ({ tmpDir, storePath }) => { const { sendTelegram, getReplySpy } = createHeartbeatDeps(params.replyText); - const { cfg, sessionKey } = await createConfig({ tmpDir, storePath }); + const { cfg, sessionKey } = await createConfig({ + tmpDir, + storePath, + target: params.target, + }); params.enqueue(sessionKey); const result = await runHeartbeatOnce({ cfg, @@ -192,4 +198,38 @@ describe("Ghost reminder bug (issue #13317)", () => { expect(calledCtx?.Body).not.toContain("Read HEARTBEAT.md"); expect(sendTelegram).toHaveBeenCalled(); }); + + it("uses an internal-only cron prompt when delivery target is none", async () => { + const { result, sendTelegram, calledCtx } = await runHeartbeatCase({ + tmpPrefix: "openclaw-cron-internal-", + replyText: "Handled internally", + reason: "cron:reminder-job", + target: "none", + enqueue: (sessionKey) => { + enqueueSystemEvent("Reminder: Rotate API keys", { sessionKey }); + }, + }); + + expect(result.status).toBe("ran"); + expect(calledCtx?.Provider).toBe("cron-event"); + expect(calledCtx?.Body).toContain("Handle this reminder internally"); + expect(sendTelegram).not.toHaveBeenCalled(); + }); + + it("uses an internal-only exec prompt when delivery target is none", async () => { + const { result, sendTelegram, calledCtx } = await runHeartbeatCase({ + tmpPrefix: "openclaw-exec-internal-", + replyText: "Handled internally", + reason: "exec-event", + target: "none", + enqueue: (sessionKey) => { + enqueueSystemEvent("exec finished: deploy succeeded", { sessionKey }); + }, + }); + + expect(result.status).toBe("ran"); + expect(calledCtx?.Provider).toBe("exec-event"); + expect(calledCtx?.Body).toContain("Handle the result internally"); + expect(sendTelegram).not.toHaveBeenCalled(); + }); }); diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 52d82043756..be698c3c5ef 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -301,7 +301,7 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.to).toBe("63448508"); }); - it("does not return inherited threadId from resolveHeartbeatDeliveryTarget", () => { + it("blocks heartbeat delivery to Slack DMs and avoids inherited threadId", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ cfg, @@ -317,11 +317,49 @@ describe("resolveSessionDeliveryTarget", () => { }, }); - expect(resolved.channel).toBe("slack"); - expect(resolved.to).toBe("user:U123"); + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("dm-blocked"); expect(resolved.threadId).toBeUndefined(); }); + it("blocks heartbeat delivery to Discord DMs", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-discord-dm", + updatedAt: 1, + lastChannel: "discord", + lastTo: "user:12345", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("dm-blocked"); + }); + + it("keeps heartbeat delivery to Discord channels", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-discord-channel", + updatedAt: 1, + lastChannel: "discord", + lastTo: "channel:999", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("discord"); + expect(resolved.to).toBe("channel:999"); + }); + it("keeps explicit threadId in heartbeat mode", () => { const resolved = resolveSessionDeliveryTarget({ entry: { diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 6df0ecee6d2..9f1770f88fe 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -1,10 +1,13 @@ +import type { ChatType } from "../../channels/chat-type.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js"; import { formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js"; +import { parseDiscordTarget } from "../../discord/targets.js"; import { normalizeAccountId } from "../../routing/session-key.js"; +import { parseSlackTarget } from "../../slack/targets.js"; import { parseTelegramTarget } from "../../telegram/targets.js"; import { deliveryContextFromSession } from "../../utils/delivery-context.js"; import type { @@ -319,6 +322,20 @@ export function resolveHeartbeatDeliveryTarget(params: { }; } + const deliveryChatType = resolveHeartbeatDeliveryChatType({ + channel: resolvedTarget.channel, + to: resolved.to, + }); + if (deliveryChatType === "direct") { + return { + channel: "none", + reason: "dm-blocked", + accountId: effectiveAccountId, + lastChannel: resolvedTarget.lastChannel, + lastAccountId: resolvedTarget.lastAccountId, + }; + } + let reason: string | undefined; const plugin = getChannelPlugin(resolvedTarget.channel); if (plugin?.config.resolveAllowFrom) { @@ -345,6 +362,59 @@ export function resolveHeartbeatDeliveryTarget(params: { }; } +function inferChatTypeFromTarget(params: { + channel: DeliverableMessageChannel; + to: string; +}): ChatType | undefined { + const to = params.to.trim(); + if (!to) { + return undefined; + } + + if (/^user:/i.test(to)) { + return "direct"; + } + if (/^(channel:|thread:)/i.test(to)) { + return "channel"; + } + if (/^group:/i.test(to)) { + return "group"; + } + + switch (params.channel) { + case "discord": { + try { + const target = parseDiscordTarget(to, { defaultKind: "channel" }); + if (!target) { + return undefined; + } + return target.kind === "user" ? "direct" : "channel"; + } catch { + return undefined; + } + } + case "slack": { + const target = parseSlackTarget(to, { defaultKind: "channel" }); + if (!target) { + return undefined; + } + return target.kind === "user" ? "direct" : "channel"; + } + default: + return undefined; + } +} + +function resolveHeartbeatDeliveryChatType(params: { + channel: DeliverableMessageChannel; + to: string; +}): ChatType | undefined { + return inferChatTypeFromTarget({ + channel: params.channel, + to: params.to, + }); +} + function resolveHeartbeatSenderId(params: { allowFrom: Array; deliveryTo?: string;