mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
fix(heartbeat): block dm targets and internalize blocked prompts
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user