fix(heartbeat): block dm targets and internalize blocked prompts

This commit is contained in:
Peter Steinberger
2026-02-25 02:02:26 +00:00
parent e0201c2774
commit a805d6b439
9 changed files with 161 additions and 6 deletions

View File

@@ -13,9 +13,11 @@ Docs: https://docs.openclaw.ai
### Breaking
- **BREAKING:** Security/Sandbox: block Docker `network: "container:<id>"` 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:<id>` 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.

View File

@@ -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:<id>` 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.

View File

@@ -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:<id>` heartbeat delivery is blocked)
- See [Heartbeat](/gateway/heartbeat) for the full guide.
</Accordion>

View File

@@ -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:<id>` 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 `<chatId>:topic:<messageThreadId>`.
- `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:<id>` 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.

View File

@@ -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:<id>` destination (blocked by design).
Related:

View File

@@ -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:<id>` targets is blocked; those runs still execute but skip outbound delivery.
- Heartbeats run full agent turns — shorter intervals burn more tokens.
```json5

View File

@@ -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<ReturnType<typeof runHeartbeatOnce>>;
sendTelegram: ReturnType<typeof vi.fn>;
@@ -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();
});
});

View File

@@ -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: {

View File

@@ -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<string | number>;
deliveryTo?: string;