Skip to content

Commit df069f7

Browse files
authored
fix(imessage): surface silent group-allowlist drops at default log level (#79190)
Merged via squash. Prepared head SHA: 6454366 Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Reviewed-by: @omarshahine
1 parent 5ae385b commit df069f7

6 files changed

Lines changed: 275 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,7 @@ Docs: https://docs.openclaw.ai
590590
- WhatsApp: send captioned `MEDIA:` directive auto-replies once instead of emitting an empty media message before the captioned media reply. (#78770) Thanks @ai-hpc.
591591
- Hooks/cron: log returned `/hooks/agent` isolated-run errors and failed cron jobs with cron diagnostic summaries, so rejected `payload.model` values are visible instead of looking like accepted-but-missing runs. Fixes #78597. (#78655) Thanks @kevinslin.
592592
- Managed proxy/security: classify raw socket callsites and proxy runtime mutations in boundary checks so new direct egress or unmanaged proxy-state changes cannot land without explicit review. (#77126) Thanks @jesse-merhi.
593+
- Channels/iMessage: surface the silent group-allowlist drop at default log level by emitting a one-time `warn` per account at monitor startup when `channels.imessage.groupPolicy: "allowlist"` is set without a `channels.imessage.groups` block, plus a one-time `warn` per `chat_id` when the runtime gate drops a specific group, naming the exact `channels.imessage.groups[...]` key to add to allow it. Fixes #78749. (#79190) Thanks @omarshahine.
593594

594595
## 2026.5.3-1
595596

docs/channels/imessage-from-bluebubbles.md

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,12 @@ The bundled iMessage plugin runs **two** separate group allowlist gates back-to-
8585
- a `groups: { "*": { ... } }` wildcard entry (sets `allowAll = true`), or
8686
- an explicit per-`chat_id` entry under `groups`.
8787

88-
If gate 1 passes but gate 2 fails, the message is dropped and the rejection logs **only at `verbose`/`debug` level** (`inbound-processing.ts:337`). At the default `info` log level every group message vanishes silently — DMs continue to work because they take a different code path.
88+
If gate 1 passes but gate 2 fails, the message is dropped. The plugin emits two `warn`-level signals so this is no longer silent at default log level:
89+
90+
- A one-time startup `warn` per account when `groupPolicy: "allowlist"` is set but `channels.imessage.groups` is empty (no `"*"` wildcard, no per-`chat_id` entries) — fired before any messages land.
91+
- A one-time per-`chat_id` `warn` the first time a specific group is dropped at runtime, naming the chat_id and the exact key to add to `groups` to allow it.
92+
93+
DMs continue to work because they take a different code path.
8994

9095
This is the most common BlueBubbles → bundled-iMessage migration failure mode: operators copy `groupAllowFrom` and `groupPolicy` but skip the `groups` block, because BlueBubbles' `groups: { "*": { "requireMention": true } }` looks like an unrelated mention setting. It's actually load-bearing for the registry gate.
9196

@@ -107,15 +112,7 @@ The minimum config to keep group messages flowing after `groupPolicy: "allowlist
107112

108113
`requireMention: true` under `*` is harmless when no mention patterns are configured: the runtime sets `canDetectMention = false` and short-circuits the mention drop at `inbound-processing.ts:512`. With mention patterns configured (`agents.list[].groupChat.mentionPatterns`), it works as expected.
109114

110-
To debug a suspected silent drop, raise log level temporarily:
111-
112-
```bash
113-
OPENCLAW_LOG_LEVEL=debug openclaw gateway
114-
```
115-
116-
Then look for `imessage: skipping group message (<chat_id>) not in allowlist`. If you see that line, gate 2 is dropping — add the `groups` block.
117-
118-
Tracker for raising the drop's log severity so this is no longer silent at `info`: [openclaw#78749](https://github.com/openclaw/openclaw/issues/78749).
115+
If the gateway logs `imessage: dropping group message from chat_id=<id>` or the startup line `imessage: groupPolicy="allowlist" but channels.imessage.groups is empty`, gate 2 is dropping — add the `groups` block.
119116

120117
## Step-by-step
121118

@@ -174,7 +171,7 @@ Tracker for raising the drop's log severity so this is no longer silent at `info
174171

175172
4. **Verify DMs.** Send the agent a direct message; confirm the reply lands.
176173

177-
5. **Verify groups separately.** DMs and groups take different code paths — DM success does not prove groups are routing. Send the agent a message in a paired group chat and confirm the reply lands. If the group goes silent (no agent reply, no error), restart the gateway with `OPENCLAW_LOG_LEVEL=debug openclaw gateway` and look for `imessage: skipping group message (...) not in allowlist`. If that line appears, your `groups` block is missing or empty — see "Group registry footgun" above.
174+
5. **Verify groups separately.** DMs and groups take different code paths — DM success does not prove groups are routing. Send the agent a message in a paired group chat and confirm the reply lands. If the group goes silent (no agent reply, no error), check the gateway log for `imessage: dropping group message from chat_id=<id>` or the startup `imessage: groupPolicy="allowlist" but channels.imessage.groups is empty` line — both fire at the default log level. If either appears, your `groups` block is missing or empty — see "Group registry footgun" above.
178175

179176
6. **Verify the action surface** — from a paired DM, ask the agent to react, edit, unsend, reply, send a photo, and (in a group) rename the group / add or remove a participant. Each action should land natively in Messages.app. If any throws "iMessage `<action>` requires the imsg private API bridge", run `imsg launch` again and refresh `channels status --probe`.
180177

docs/channels/imessage.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,12 @@ If SIP-disabled isn't acceptable for your threat model:
239239
1. **Sender / chat-target allowlist** (`channels.imessage.groupAllowFrom`) — handle, `chat_guid`, `chat_identifier`, or `chat_id`.
240240
2. **Group registry** (`channels.imessage.groups`) — with `groupPolicy: "allowlist"`, this gate requires either a `groups: { "*": { ... } }` wildcard entry (sets `allowAll = true`), or an explicit per-`chat_id` entry under `groups`.
241241

242-
If gate 2 has nothing in it, every group message is dropped — and the rejection logs only at `verbose`/`debug` level, so the drops are silent at the default `info` log level. DMs continue to work because they take a different code path.
242+
If gate 2 has nothing in it, every group message is dropped. The plugin emits two `warn`-level signals at the default log level:
243+
244+
- one-time per account at startup: `imessage: groupPolicy="allowlist" but channels.imessage.groups is empty for account "<id>"`
245+
- one-time per `chat_id` at runtime: `imessage: dropping group message from chat_id=<id> ...`
246+
247+
DMs continue to work because they take a different code path.
243248

244249
Minimum config to keep groups flowing under `groupPolicy: "allowlist"`:
245250

@@ -255,7 +260,7 @@ If SIP-disabled isn't acceptable for your threat model:
255260
}
256261
```
257262

258-
To debug a suspected silent drop, run `OPENCLAW_LOG_LEVEL=debug openclaw gateway` and look for `imessage: skipping group message (<chat_id>) not in allowlist`. If that line appears, gate 2 is dropping — add the `groups` block.
263+
If those `warn` lines appear in the gateway log, gate 2 is dropping — add the `groups` block.
259264
</Warning>
260265

261266
Mention gating for groups:
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { beforeEach, describe, expect, it } from "vitest";
2+
import {
3+
resetGroupAllowlistWarningsForTesting,
4+
warnGroupAllowlistDropPerChatOnce,
5+
warnGroupAllowlistMisconfigOnce,
6+
} from "./group-allowlist-warnings.js";
7+
8+
beforeEach(() => {
9+
resetGroupAllowlistWarningsForTesting();
10+
});
11+
12+
describe("warnGroupAllowlistMisconfigOnce", () => {
13+
it("fires when groupPolicy=allowlist and groups is undefined", () => {
14+
const messages: string[] = [];
15+
const fired = warnGroupAllowlistMisconfigOnce({
16+
groupPolicy: "allowlist",
17+
groups: undefined,
18+
accountId: "default",
19+
log: (m) => messages.push(m),
20+
});
21+
expect(fired).toBe(true);
22+
expect(messages).toHaveLength(1);
23+
expect(messages[0]).toContain('groupPolicy="allowlist"');
24+
expect(messages[0]).toContain("channels.imessage.groups is empty");
25+
expect(messages[0]).toContain("default");
26+
});
27+
28+
it("fires when groupPolicy=allowlist and groups is empty object", () => {
29+
const messages: string[] = [];
30+
const fired = warnGroupAllowlistMisconfigOnce({
31+
groupPolicy: "allowlist",
32+
groups: {},
33+
accountId: "default",
34+
log: (m) => messages.push(m),
35+
});
36+
expect(fired).toBe(true);
37+
expect(messages).toHaveLength(1);
38+
});
39+
40+
it("does not fire when groupPolicy is not allowlist", () => {
41+
const messages: string[] = [];
42+
const fired = warnGroupAllowlistMisconfigOnce({
43+
groupPolicy: "open",
44+
groups: undefined,
45+
accountId: "default",
46+
log: (m) => messages.push(m),
47+
});
48+
expect(fired).toBe(false);
49+
expect(messages).toHaveLength(0);
50+
});
51+
52+
it("does not fire when groups has a wildcard entry", () => {
53+
const messages: string[] = [];
54+
const fired = warnGroupAllowlistMisconfigOnce({
55+
groupPolicy: "allowlist",
56+
groups: { "*": { requireMention: true } },
57+
accountId: "default",
58+
log: (m) => messages.push(m),
59+
});
60+
expect(fired).toBe(false);
61+
expect(messages).toHaveLength(0);
62+
});
63+
64+
it("does not fire when groups has explicit chat_id entries", () => {
65+
const messages: string[] = [];
66+
const fired = warnGroupAllowlistMisconfigOnce({
67+
groupPolicy: "allowlist",
68+
groups: { "12345": {} },
69+
accountId: "default",
70+
log: (m) => messages.push(m),
71+
});
72+
expect(fired).toBe(false);
73+
expect(messages).toHaveLength(0);
74+
});
75+
76+
it("only fires once per accountId", () => {
77+
const messages: string[] = [];
78+
const log = (m: string) => messages.push(m);
79+
expect(
80+
warnGroupAllowlistMisconfigOnce({
81+
groupPolicy: "allowlist",
82+
groups: undefined,
83+
accountId: "default",
84+
log,
85+
}),
86+
).toBe(true);
87+
expect(
88+
warnGroupAllowlistMisconfigOnce({
89+
groupPolicy: "allowlist",
90+
groups: undefined,
91+
accountId: "default",
92+
log,
93+
}),
94+
).toBe(false);
95+
expect(messages).toHaveLength(1);
96+
});
97+
98+
it("fires separately for distinct accountIds", () => {
99+
const messages: string[] = [];
100+
const log = (m: string) => messages.push(m);
101+
warnGroupAllowlistMisconfigOnce({
102+
groupPolicy: "allowlist",
103+
groups: undefined,
104+
accountId: "primary",
105+
log,
106+
});
107+
warnGroupAllowlistMisconfigOnce({
108+
groupPolicy: "allowlist",
109+
groups: undefined,
110+
accountId: "secondary",
111+
log,
112+
});
113+
expect(messages).toHaveLength(2);
114+
});
115+
});
116+
117+
describe("warnGroupAllowlistDropPerChatOnce", () => {
118+
it("fires once per accountId:chat_id pair", () => {
119+
const messages: string[] = [];
120+
const log = (m: string) => messages.push(m);
121+
expect(warnGroupAllowlistDropPerChatOnce({ accountId: "default", chatId: 42, log })).toBe(true);
122+
expect(warnGroupAllowlistDropPerChatOnce({ accountId: "default", chatId: 42, log })).toBe(
123+
false,
124+
);
125+
expect(messages).toHaveLength(1);
126+
expect(messages[0]).toContain("chat_id=42");
127+
expect(messages[0]).toContain("default");
128+
expect(messages[0]).toContain('channels.imessage.groups["42"]');
129+
});
130+
131+
it("fires separately for distinct chat_ids on the same account", () => {
132+
const messages: string[] = [];
133+
const log = (m: string) => messages.push(m);
134+
warnGroupAllowlistDropPerChatOnce({ accountId: "default", chatId: 1, log });
135+
warnGroupAllowlistDropPerChatOnce({ accountId: "default", chatId: 2, log });
136+
warnGroupAllowlistDropPerChatOnce({ accountId: "default", chatId: 2, log });
137+
expect(messages).toHaveLength(2);
138+
});
139+
140+
it("treats numeric and string chat_ids as the same key", () => {
141+
const messages: string[] = [];
142+
const log = (m: string) => messages.push(m);
143+
warnGroupAllowlistDropPerChatOnce({ accountId: "default", chatId: 42, log });
144+
warnGroupAllowlistDropPerChatOnce({ accountId: "default", chatId: "42", log });
145+
expect(messages).toHaveLength(1);
146+
});
147+
148+
it("skips when chat_id is undefined or empty", () => {
149+
const messages: string[] = [];
150+
const log = (m: string) => messages.push(m);
151+
expect(
152+
warnGroupAllowlistDropPerChatOnce({ accountId: "default", chatId: undefined, log }),
153+
).toBe(false);
154+
expect(warnGroupAllowlistDropPerChatOnce({ accountId: "default", chatId: "", log })).toBe(
155+
false,
156+
);
157+
expect(messages).toHaveLength(0);
158+
});
159+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Group-allowlist visibility helpers. The runtime gate at line ~336 of
2+
// inbound-processing.ts drops every group message when groupPolicy="allowlist"
3+
// and channels.imessage.groups is missing. Without these warnings the drop is
4+
// invisible at default log level — the most common BlueBubbles → bundled-iMessage
5+
// migration footgun. See https://github.com/openclaw/openclaw/issues/78749.
6+
7+
type GroupsConfig = Record<
8+
string,
9+
{ requireMention?: boolean; tools?: unknown; toolsBySender?: unknown }
10+
>;
11+
12+
const startupWarned = new Set<string>();
13+
const perChatWarned = new Set<string>();
14+
15+
/**
16+
* Fires once per `accountId` at monitor startup when `groupPolicy === "allowlist"`
17+
* but `channels.imessage.groups` is empty (no `"*"` wildcard, no explicit
18+
* `chat_id` entries). Without one of those, every group message is dropped at
19+
* the second gate even when the sender passes `groupAllowFrom`.
20+
*/
21+
export function warnGroupAllowlistMisconfigOnce(params: {
22+
groupPolicy: string;
23+
groups: GroupsConfig | undefined;
24+
accountId: string;
25+
log: (message: string) => void;
26+
}): boolean {
27+
if (params.groupPolicy !== "allowlist") {
28+
return false;
29+
}
30+
const entries = params.groups ? Object.keys(params.groups) : [];
31+
if (entries.length > 0) {
32+
return false;
33+
}
34+
const key = `imessage:${params.accountId}`;
35+
if (startupWarned.has(key)) {
36+
return false;
37+
}
38+
startupWarned.add(key);
39+
params.log(
40+
`imessage: groupPolicy="allowlist" but channels.imessage.groups is empty for account "${params.accountId}". ` +
41+
`Every inbound group message will be dropped. ` +
42+
`Add channels.imessage.groups["*"] = { requireMention: true } to allow all groups, ` +
43+
`or explicit per-chat_id entries to allow specific groups.`,
44+
);
45+
return true;
46+
}
47+
48+
/**
49+
* Fires once per `accountId:chat_id` when the runtime allowlist gate drops a
50+
* group message because that chat_id is not in `channels.imessage.groups`.
51+
* Bounded by the number of distinct group chats the gateway sees.
52+
*/
53+
export function warnGroupAllowlistDropPerChatOnce(params: {
54+
accountId: string;
55+
chatId: string | number | undefined;
56+
log: (message: string) => void;
57+
}): boolean {
58+
const chat = params.chatId == null ? "" : String(params.chatId).trim();
59+
if (!chat) {
60+
return false;
61+
}
62+
const key = `imessage:${params.accountId}:${chat}`;
63+
if (perChatWarned.has(key)) {
64+
return false;
65+
}
66+
perChatWarned.add(key);
67+
params.log(
68+
`imessage: dropping group message from chat_id=${chat} (account "${params.accountId}") — ` +
69+
`not in channels.imessage.groups allowlist. ` +
70+
`Add channels.imessage.groups["${chat}"] or channels.imessage.groups["*"] to allow it.`,
71+
);
72+
return true;
73+
}
74+
75+
/** Test helper. Keeps warning-cache state deterministic across test files. */
76+
export function resetGroupAllowlistWarningsForTesting(): void {
77+
startupWarned.clear();
78+
perChatWarned.clear();
79+
}

extensions/imessage/src/monitor/monitor-provider.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ import { attachIMessageMonitorAbortHandler } from "./abort-handler.js";
5353
import { combineIMessagePayloads } from "./coalesce.js";
5454
import { createIMessageEchoCachingSend, deliverReplies } from "./deliver.js";
5555
import { createSentMessageCache } from "./echo-cache.js";
56+
import {
57+
warnGroupAllowlistDropPerChatOnce,
58+
warnGroupAllowlistMisconfigOnce,
59+
} from "./group-allowlist-warnings.js";
5660
import {
5761
buildIMessageInboundContext,
5862
resolveIMessageInboundDecision,
@@ -195,6 +199,12 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
195199
accountId: accountInfo.accountId,
196200
log: (message) => runtime.log?.(warn(message)),
197201
});
202+
warnGroupAllowlistMisconfigOnce({
203+
groupPolicy,
204+
groups: imessageCfg.groups,
205+
accountId: accountInfo.accountId,
206+
log: (message) => runtime.log?.(warn(message)),
207+
});
198208
const dmPolicy = imessageCfg.dmPolicy ?? "pairing";
199209
const includeAttachments = opts.includeAttachments ?? imessageCfg.includeAttachments ?? false;
200210
const mediaMaxBytes = (opts.mediaMaxMb ?? imessageCfg.mediaMaxMb ?? 16) * 1024 * 1024;
@@ -399,6 +409,17 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
399409
if (isLoopDrop) {
400410
loopRateLimiter.record(rateLimitKey);
401411
}
412+
// Surface the silent-allowlist drop once per chat. Without this, operators
413+
// who migrate from BlueBubbles and copy groupPolicy="allowlist" without
414+
// populating channels.imessage.groups see every group message vanish at
415+
// default log level. See issue #78749.
416+
if (decision.reason === "group id not in allowlist") {
417+
warnGroupAllowlistDropPerChatOnce({
418+
accountId: accountInfo.accountId,
419+
chatId: message.chat_id ?? undefined,
420+
log: (msg) => runtime.log?.(warn(msg)),
421+
});
422+
}
402423
return;
403424
}
404425

0 commit comments

Comments
 (0)