Skip to content

fix(bluebubbles): tighten DM-vs-group routing across session route, reactions, chatGuid fallback, and short message ids#73235

Merged
steipete merged 16 commits intoopenclaw:mainfrom
zqchris:fix/bluebubbles-routing-guards
Apr 28, 2026
Merged

fix(bluebubbles): tighten DM-vs-group routing across session route, reactions, chatGuid fallback, and short message ids#73235
steipete merged 16 commits intoopenclaw:mainfrom
zqchris:fix/bluebubbles-routing-guards

Conversation

@zqchris
Copy link
Copy Markdown
Contributor

@zqchris zqchris commented Apr 28, 2026

Summary

Five interlocking BlueBubbles routing fixes for symptoms that all stem from the same family of problems: ambiguity between DM and group chat shapes leaking into peer-id / session-key resolution. Each commit is independently reviewable and tested; bundled because the cross-chat guard fixes (commits 4 and 5) are direct follow-ups to the short-id fix and only matter together.

The user-visible failure mode I hit repeatedly: reactions / tapbacks and quoted replies authored inside a group would intermittently land in a DM, targeting an old message the user could no longer see. The tool call looks successful; the chat archive shows a group reaction appearing in the DM transcript.

1. fix(bluebubbles): scope short message id resolution to the caller's chat

resolveBlueBubblesMessageId accepts requireKnownShortId but never validates that the resolved GUID is actually in the chat the caller is acting on. Short numeric ids ("1", "5") are allocated from a single global counter across every account and every chat, so reusing or mis-remembering one — common after a long group conversation — silently routes the action to a different chat.

Adds an optional chatContext parameter (chatGuid / chatIdentifier / chatId). When provided, looks up the resolved GUID's chat membership and rejects mismatches with a remediation hint pointing at the chat target rather than the id format.

2. fix(bluebubbles): apply cross-chat guard to full message GUIDs as well

Direct follow-up to #1. The first version of the guard only ran on numeric short ids. Once short-id mismatches started rejecting cross-chat reuses, agents would retry with the full GUID copied from history or a previous tool result, and that path bypassed the guard entirely. Apply the same isCrossChatMismatch check to full GUID input. Cache misses still fall through (callers may legitimately supply a fresh-from-the-wire GUID), but cache hits with a chat mismatch throw with the same remediation hint.

3. fix(bluebubbles): refuse sender-DM fallback when resolving group inbound chatGuid

When a BlueBubbles inbound webhook arrives without chatGuid, processMessage falls back to resolveChatGuidForTarget. The previous fallback target was:

isGroup && (chatId || chatIdentifier)
  ? <chat_id or chat_identifier>
  : { kind: "handle", address: message.senderId }

The else branch quietly covered two very different cases:

  1. DM with no chatGuid — resolving via sender handle is correct.
  2. Group with no chatGuid AND no chatId AND no chatIdentifier — resolving via sender handle returns that sender's DM chatGuid, then the rest of processMessage uses it for ack reactions, mark-read, outbound reply cache, etc. — every subsequent action lands in a DM with the sender instead of the group.

Split the fallback so the group-without-any-chat-hint case fails closed with a diagnostic instead of silently rerouting.

4. fix(bluebubbles): drop group reactions that arrive without any chat identifier

processReaction peer id calculation:

const peerId = reaction.isGroup
  ? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group"))
  : reaction.senderId;

If a message-reaction event arrives with isGroup: true and no chatGuid/chatId/chatIdentifier, peerId becomes the literal string "group" and resolveBlueBubblesConversationRoute synthesizes a session key unrelated to any real binding. The reaction surfaces in whatever session the binding fallback picks.

Drop these events with a debug log instead of routing them somewhere wrong.

5. fix(bluebubbles): distinguish DM vs group chat_guid in outbound session route

resolveBlueBubblesOutboundSessionRoute classified all chat_guid: prefixed targets as groups:

const isGroup =
  parsed.kind === "chat_id" ||
  parsed.kind === "chat_guid" ||
  parsed.kind === "chat_identifier";

But BlueBubbles encodes DM chatGuids in the same chat_guid: form — they look like iMessage;-;+15551234567 (the ;-; separator is the DM marker; groups use ;+;). Treating those as groups gave the same DM two different sessionKeys depending on whether the caller addressed it as a handle (bluebubbles:imessage:+15551234567) or as a chat_guid (bluebubbles:chat_guid:iMessage;-;+15551234567). Use the separator to classify so DM and group chat_guids end up on the right session shape.

Test plan

  • pnpm test extensions/bluebubbles — 545/545 passing on this branch
  • New regression tests added for each commit (monitor-reply-cache.test.ts, monitor-processing-chat-resolve.test.ts, session-route.test.ts)
  • pnpm check:changed — clean
  • Manual verification recommended: a group reaction routed via short id "5" after a long group conversation no longer lands in a DM (pre-fix repro on patch/chris)

Notes for reviewers

  • All five commits authored against patch/chris over multiple weeks, then cherry-picked clean onto current main. No conflicts.
  • Each commit is atomic and individually testable. Happy to split into multiple PRs if a reviewer prefers; the routing-guard family naturally clusters but I have no strong opinion on packaging.
  • Commits 1+2 and commits 3+4+5 are the two natural sub-clusters if we want to land them in stages.

@openclaw-barnacle openclaw-barnacle Bot added channel: bluebubbles Channel integration: bluebubbles size: L labels Apr 28, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 28, 2026

Greptile Summary

This PR implements five interlocking fixes for BlueBubbles DM-vs-group routing bugs: a global cross-chat guard on short message IDs and full GUIDs, fail-closed behavior for group inbounds missing all chat identifiers, a drop rule for unroutable group reactions, and DM-chatGuid classification in the outbound session route.

  • Incomplete session key convergence (session-route.ts): The test "DM via chat_guid and DM via handle land on the same session key" only asserts peer.kind equality. The peerId for chat_guid:iMessage;-;+15551234567 is the full chatGuid string, while the handle form uses +15551234567. Since the comment acknowledges that sessionKey derives from peer.id, these two addressing forms still produce different session keys — the core problem the PR describes ("same DM getting two different sessionKeys") is only partially addressed by the classification fix.

Confidence Score: 3/5

Hold for author clarification on session key convergence before merging.

The cross-chat guard, fail-closed group inbound fallback, and reaction drop fixes are all well-implemented and tested. However, the session route fix in commit 5 addresses the isGroup classification correctly but does not normalize peerId between handle form and chatGuid form — meaning the two addressing forms for the same DM still derive different session keys. The test that claims to verify convergence only checks peer.kind, not actual sessionKey equality. This is a P1 gap in the most user-visible fix of the PR.

extensions/bluebubbles/src/session-route.ts and session-route.test.ts need attention for the session key convergence gap.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/bluebubbles/src/session-route.test.ts
Line: 71-80

Comment:
**Session key convergence not actually asserted**

The test name and the comment on line 72-73 both state that the same DM addressed two ways "must converge on the same sessionKey so existing bindings keep matching." But the actual assertion only checks `peer.kind` equality — it never asserts `handleRoute?.sessionKey === chatGuidRoute?.sessionKey`.

Looking at the `peerId` derivation in `session-route.ts`:
- Handle form (`imessage:+15551234567`) → `peerId = "+15551234567"` (from `parsed.to`)
- chatGuid form (`chat_guid:iMessage;-;+15551234567`) → `peerId = "iMessage;-;+15551234567"` (from `parsed.chatGuid`)

Since the comment on line 78 explicitly acknowledges that "sessionKey base derives from peer.id," these two different `peerId` values produce two different session keys even after this fix. The fix prevents a *group* session being synthesized for DM chatGuids, but it does not make the two addressing forms land on the *same* session key. If existing bindings were created via the handle form and an outbound is then routed via chatGuid form, the sessions still diverge. The test should either assert `expect(handleRoute?.sessionKey).toBe(chatGuidRoute?.sessionKey)` (and fix `peerId` so it passes), or be renamed to accurately reflect only the shape classification (`peer.kind`) it verifies.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "docs(changelog): note BlueBubbles routin..." | Re-trigger Greptile

Comment on lines +71 to +80
it("DM via chat_guid and DM via handle land on the same session key", () => {
// The point of disambiguation: a DM addressed two different ways must
// converge on the same sessionKey so existing bindings keep matching.
const handleRoute = call("bluebubbles:imessage:+15551234567");
const chatGuidRoute = call("bluebubbles:chat_guid:iMessage;-;+15551234567");
expect(handleRoute?.sessionKey).toBeDefined();
expect(chatGuidRoute?.sessionKey).toBeDefined();
// Both are direct now; sessionKey base derives from peer.id.
expect(handleRoute?.peer.kind).toBe(chatGuidRoute?.peer.kind);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Session key convergence not actually asserted

The test name and the comment on line 72-73 both state that the same DM addressed two ways "must converge on the same sessionKey so existing bindings keep matching." But the actual assertion only checks peer.kind equality — it never asserts handleRoute?.sessionKey === chatGuidRoute?.sessionKey.

Looking at the peerId derivation in session-route.ts:

  • Handle form (imessage:+15551234567) → peerId = "+15551234567" (from parsed.to)
  • chatGuid form (chat_guid:iMessage;-;+15551234567) → peerId = "iMessage;-;+15551234567" (from parsed.chatGuid)

Since the comment on line 78 explicitly acknowledges that "sessionKey base derives from peer.id," these two different peerId values produce two different session keys even after this fix. The fix prevents a group session being synthesized for DM chatGuids, but it does not make the two addressing forms land on the same session key. If existing bindings were created via the handle form and an outbound is then routed via chatGuid form, the sessions still diverge. The test should either assert expect(handleRoute?.sessionKey).toBe(chatGuidRoute?.sessionKey) (and fix peerId so it passes), or be renamed to accurately reflect only the shape classification (peer.kind) it verifies.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/bluebubbles/src/session-route.test.ts
Line: 71-80

Comment:
**Session key convergence not actually asserted**

The test name and the comment on line 72-73 both state that the same DM addressed two ways "must converge on the same sessionKey so existing bindings keep matching." But the actual assertion only checks `peer.kind` equality — it never asserts `handleRoute?.sessionKey === chatGuidRoute?.sessionKey`.

Looking at the `peerId` derivation in `session-route.ts`:
- Handle form (`imessage:+15551234567`) → `peerId = "+15551234567"` (from `parsed.to`)
- chatGuid form (`chat_guid:iMessage;-;+15551234567`) → `peerId = "iMessage;-;+15551234567"` (from `parsed.chatGuid`)

Since the comment on line 78 explicitly acknowledges that "sessionKey base derives from peer.id," these two different `peerId` values produce two different session keys even after this fix. The fix prevents a *group* session being synthesized for DM chatGuids, but it does not make the two addressing forms land on the *same* session key. If existing bindings were created via the handle form and an outbound is then routed via chatGuid form, the sessions still diverge. The test should either assert `expect(handleRoute?.sessionKey).toBe(chatGuidRoute?.sessionKey)` (and fix `peerId` so it passes), or be renamed to accurately reflect only the shape classification (`peer.kind`) it verifies.

How can I resolve this? If you propose a fix, please make it concise.

@clawsweeper
Copy link
Copy Markdown
Contributor

clawsweeper Bot commented Apr 28, 2026

Codex review: keeping this open for maintainer follow-up; there is still a little grit to resolve.

Keep this PR open. Current main still lacks the BlueBubbles routing guards the PR is meant to add, while the provided GitHub context shows this PR is open and unmerged with a current implementation candidate plus regression tests.

Best possible solution:

Keep this PR open as the active implementation candidate. Maintainers should review the current head, especially the BlueBubbles regression coverage plus the plugin discovery/doctor side changes, then either land it after focused tests and changed-gate proof or request targeted changes.

What I checked:

  • Current main still classifies all BlueBubbles chat_guid routes as groups: On current main, resolveBlueBubblesOutboundSessionRoute sets isGroup true for every parsed chat_guid and uses the raw chatGuid as peer.id, so DM-shaped chat_guid:iMessage;-;... targets still diverge from handle-form DM routes. (extensions/bluebubbles/src/session-route.ts:14, 0f3a9d812b40)
  • Current main short-id resolver is still global and unscoped: resolveBlueBubblesMessageId only accepts requireKnownShortId; it resolves numeric IDs from global maps and has no chatContext or cross-chat mismatch check for short IDs or full GUIDs. (extensions/bluebubbles/src/monitor-reply-cache.ts:88, 0f3a9d812b40)
  • Current main action callers do not pass chat context: BlueBubbles reaction/edit/unsend/reply action handling calls resolveBlueBubblesMessageId with only requireKnownShortId, so the caller chat is not available to reject cross-chat reuse. (extensions/bluebubbles/src/actions.ts:204, 0f3a9d812b40)
  • Current main still has the group inbound sender-DM fallback: When an inbound group message lacks chatGuid and lacks both chatId and chatIdentifier, current main falls through to { kind: "handle", address: message.senderId }, which is the fallback this PR says can resolve the sender's DM chatGuid. (extensions/bluebubbles/src/monitor-processing.ts:1298, 0f3a9d812b40)
  • Current main still routes identifier-less group reactions through a literal group peer: processReaction still computes peerId as the literal string "group" when reaction.isGroup is true and no chatGuid/chatIdentifier/chatId is present. (extensions/bluebubbles/src/monitor-processing.ts:1883, 0f3a9d812b40)
  • Regression tests from the PR are not on main: The dedicated test files named in the PR body, including session-route.test.ts, monitor-reply-cache.test.ts, and monitor-processing-chat-resolve.test.ts, are absent from current main. (0f3a9d812b40)

Remaining risk / open question:

  • Closing this PR now would leave the reported BlueBubbles short-id, full-GUID, inbound fallback, reaction, and outbound session-route bugs unfixed on current main.
  • The PR diff also contains non-BlueBubbles plugin discovery, doctor, runtime-deps, and test-environment changes. Those are security-sensitive/code-execution-adjacent surfaces and should receive maintainer review before landing, even if they were added to unblock checks.
  • The provided context shows an Aisle security review had started but had not posted final results yet, so maintainers should account for that before merge.

Codex review notes: model gpt-5.5, reasoning high; reviewed against 0f3a9d812b40.

@steipete steipete force-pushed the fix/bluebubbles-routing-guards branch from 1406960 to a958ffb Compare April 28, 2026 18:15
@openclaw-barnacle openclaw-barnacle Bot added channel: signal Channel integration: signal scripts Repository scripts commands Command implementations labels Apr 28, 2026
@steipete steipete force-pushed the fix/bluebubbles-routing-guards branch from fc5c517 to 357aeda Compare April 28, 2026 18:26
@openclaw-barnacle openclaw-barnacle Bot removed channel: signal Channel integration: signal scripts Repository scripts labels Apr 28, 2026
@steipete steipete force-pushed the fix/bluebubbles-routing-guards branch from 6dd9cd9 to c4fc326 Compare April 28, 2026 18:51
@steipete steipete force-pushed the fix/bluebubbles-routing-guards branch from 11d708d to d190d30 Compare April 28, 2026 19:51
@openclaw-barnacle openclaw-barnacle Bot added the channel: googlechat Channel integration: googlechat label Apr 28, 2026
Chris Zhang and others added 16 commits April 28, 2026 21:02
BlueBubbles short message ids (numeric aliases like "1", "5" that agents
use instead of full GUIDs to save tokens) are allocated from a single
global counter across every account and every chat. Nothing in
resolveBlueBubblesMessageId verified that the resolved GUID was actually
in the chat the caller was acting on, so any time an agent reused or
mis-remembered a short id — especially common after a long group
conversation — the id could silently point at a different chat entirely.

Symptom Chris observed: reactions/tapbacks and quoted replies authored
inside a group would intermittently land in a DM, targeting an old
message the user could no longer see. Tool call looks successful, chat
archive shows a group reaction appearing in the DM transcript.

Add an optional chatContext parameter to resolveBlueBubblesMessageId
(chatGuid / chatIdentifier / chatId). When provided, look up the
cached reply entry for the resolved GUID and compare. A clear mismatch
(same identifier present on both sides, different values) throws with a
message that lists both chats and points at "use the full GUID", so the
agent fails fast and retries with a disambiguated id. Ambiguous cases
(either side missing all identifiers) pass through to preserve existing
behavior for callers that cannot supply chat hints. The comparison
mirrors resolveReplyContextFromCache so outbound and inbound paths agree
on scope.

Update every call site that resolves a short id for outbound BB traffic
to pass chatContext:
- extensions/bluebubbles/src/actions.ts: react, edit, unsend, reply
  (build context from chat* params, then to/target, then the tool's
  currentChannelId)
- extensions/bluebubbles/src/channel.ts sendText: derive context from
  the `to` target
- extensions/bluebubbles/src/media-send.ts: same
- extensions/bluebubbles/src/monitor-processing.ts deliver path: pass
  the chat already resolved for routing

Add buildBlueBubblesChatContextFromTarget to targets.ts so callers can
project a raw target string (`chat_guid:...`, `chat_id:42`,
`imessage:+1...`, bare handle) into the context shape.

Tests:
- extensions/bluebubbles/src/monitor-reply-cache.test.ts (new, 8 cases):
  same-chat resolves, cross-chatGuid throws, ambiguous passes,
  chatIdentifier fallback, chatId fallback, full GUID input bypasses,
  error message identifies both chats, unknown short id still errors.
- extensions/bluebubbles/src/actions.test.ts: update the react short-id
  assertion to verify chatContext now flows through.

Local patch for upstream consideration — same root cause affects every
BB user; plan is to open a separate upstream PR once this bakes locally.
…und chatGuid

When a BlueBubbles inbound webhook arrives without `chatGuid`, processMessage
falls back to `resolveChatGuidForTarget` to look it up. The previous fallback
target was:

    isGroup && (chatId || chatIdentifier)
      ? <chat_id or chat_identifier>
      : { kind: "handle", address: message.senderId }

That `else` branch quietly covered two very different cases:

1. DM with no chatGuid — resolving via sender handle is correct, the chat
   IS the conversation with that handle.
2. **Group with no chatGuid AND no chatId AND no chatIdentifier** — resolving
   via sender handle yields *that sender's DM chatGuid*, then the rest of
   processMessage uses it for ack reactions, mark-read, outbound reply cache,
   typing indicators, and outboundTarget.

Case 2 is reachable: `monitor.webhook.test-helpers.ts` ships a default
`createMessageReactionPayloadForTest` payload with no chatGuid/chatId/
chatIdentifier and `isGroup` defaulted to `false`, mirroring real BlueBubbles
reaction/tapback webhooks. When a group reaction or tapback arrives in that
shape and isGroup is later corrected to true (or the message takes the same
poisoned path), `chatGuidForActions` becomes the sender's DM chatGuid. The
poisoned chatGuid then writes the outbound reply cache (line ~1395) with the
wrong chat, defeating the cross-chat short-id guard added in
9912472289 — a later short id resolved against that cache cannot detect the
mismatch and the agent's reaction/reply silently lands in the DM.

Symptom Chris observed (recurring after 9912472289 baked): group messages
getting reacted to from the agent's side show up in a DM transcript with
that sender, attached to a message GUID the user can no longer locate in
the DM.

Extract the fallback target construction into
`buildBlueBubblesInboundChatResolveTarget` so the rule is testable in
isolation and the wrong fallback can never be reached again:

- Group inbound + chatId present → `chat_id`
- Group inbound + chatIdentifier present → `chat_identifier`
- **Group inbound + neither → return null (caller skips chatGuid-dependent actions)**
- DM inbound → `handle` (unchanged: the conversation IS that sender)

processMessage now logs at verbose when the group case returns null instead
of silently degrading to the sender's DM.

Tests: extensions/bluebubbles/src/monitor-processing-chat-resolve.test.ts
covers the eight branches (group with id, group with identifier, group
preferring id, group with neither, blank/non-finite/null variants, DM, DM
with chat_id present, DM with empty sender).

Local patch for upstream consideration — pairs with the short-id chat guard
landed in the previous commit.
The cross-chat guard added in the prior commit (resolveBlueBubblesMessageId
with chatContext) only ran on numeric short ids — `if (/^\d+$/.test(trimmed))`.
Full GUID input fell through to `return trimmed` with no chat check.

Once the short-id guard started rejecting cross-chat reuses, agents would
retry the same call with the full GUID copied from history or a previous
tool result. That second attempt bypassed the guard entirely and the
group reaction landed in the DM anyway — exactly the symptom the prior
commit was meant to close.

Apply the same `isCrossChatMismatch` check to full GUID input. Cache miss
still falls through (callers may legitimately supply a fresh-from-the-wire
GUID the cache hasn't observed yet), but cache hits with a chat mismatch
throw with a remediation hint pointed at the chat target rather than at
the id format — telling an agent to "retry with the full GUID" makes no
sense when it already supplied one.

Tests (extensions/bluebubbles/src/monitor-reply-cache.test.ts):
- UUID + same chat → resolves
- UUID + different chat → throws (this is the regression)
- UUID + cache miss → passes through (preserves behavior for fresh GUIDs)
- UUID + empty chatContext → passes through (preserves prior behavior)
- UUID error message hints at the chat target, not the id format
- chatIdentifier fallback applies to UUID input too

Local patch for upstream consideration — completes the cross-chat guard
started in the prior commit so both id forms are protected symmetrically.
…dentifier

processReaction's peerId calculation:

    const peerId = reaction.isGroup
      ? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group"))
      : reaction.senderId;

reads as "if it's a group with at least one chat hint, use that hint;
otherwise fall through to either the literal string 'group' (group case)
or the sender id (DM case)". Two failure modes hide here:

1. BlueBubbles fires a `message-reaction` event with `isGroup: true` but
   omits chatGuid AND chatId AND chatIdentifier — peerId becomes the
   literal "group" and resolveBlueBubblesConversationRoute synthesizes
   a session key unrelated to any real binding. The reaction surfaces in
   whatever session the binding fallback picks, never the right one.

2. The same payload arrives with isGroup misclassified as false (BB's
   group-flag inference relies on chatGuid, explicit isGroup, or
   participants > 2 — none of which are guaranteed for reaction events;
   monitor.webhook.test-helpers.ts even ships a default reaction fixture
   with no chatGuid and isGroup defaulted to false). peerId then becomes
   reaction.senderId and the event is enqueued into the sender's DM
   session — the group tapback shows up inside an unrelated 1:1
   transcript Chris was looking at.

Neither outcome is recoverable without a chat hint — without chatGuid,
chatId, or chatIdentifier we cannot identify which group the reaction
belongs to. Drop the event with a verbose-log and let the agent miss
that reaction rather than route it incorrectly. DM reactions (which
legitimately may arrive with no chat hint and only a sender) keep
working because the guard is gated on `reaction.isGroup === true`.

A latent risk remains: if BB ever sends an isGroup-misclassified-as-false
payload, this guard does not catch it. That would require teaching
normalize to surface group-flag confidence, which is a larger change
left for follow-up.

Tests (extensions/bluebubbles/src/monitor.test.ts):
- Group reaction with no chat identifiers → not enqueued
- Group reaction with at least one chat identifier → still enqueued
  (regression sentinel for the new guard)

Local patch for upstream consideration.
…on route

resolveBlueBubblesOutboundSessionRoute classified all `chat_guid:`
prefixed targets as groups:

    const isGroup =
      parsed.kind === "chat_id" ||
      parsed.kind === "chat_guid" ||
      parsed.kind === "chat_identifier";

But BlueBubbles also encodes DM chatGuids in the same `chat_guid:`
form — they look like `iMessage;-;+15551234567` (the `;-;` separator
is the DM marker; groups use `;+;`). Treating those as groups gave
the same DM two different sessionKeys depending on how the caller
addressed it:

- handle form (`bluebubbles:imessage:+15551234567`)
  → peer.kind = "direct", from = `bluebubbles:+15551234567`
- chat_guid form (`bluebubbles:chat_guid:iMessage;-;+15551234567`)
  → peer.kind = "group", from = `group:iMessage;-;+15551234567`

When a bound DM session was looked up against the second form, no
binding matched and the outbound landed in a freshly-synthesized
"group" sessionKey — a degenerate session that the next inbound
message also failed to find, surfacing the conversation in the
wrong place.

Use resolveGroupFlagFromChatGuid (already used by monitor-normalize
to read the same marker for inbound webhooks) so both directions
agree on what counts as a group. Unknown chatGuid shapes still
fall back to "group" to preserve prior behavior — we never
silently downgrade a real group to direct.

Tests: extensions/bluebubbles/src/session-route.test.ts (new)
- chat_guid `;-;` → direct
- chat_guid `;+;` → group
- chat_guid with no recognizable marker → group (back-compat)
- handle target → direct
- chat_id / chat_identifier → group (unchanged)
- DM addressed two ways converges on the same peer kind

Local patch for upstream consideration. Latent bug introduced by
0f7cd59 (BlueBubbles: move outbound session routing behind plugin
boundary), not commonly hit because most outbound DM call sites use
the handle form, but a real foot-gun for callers that pass the
chat_guid form.
Four findings on this PR, all addressed in this commit:

1. **Cross-chat guard bypass when ctx.chatGuid present but cached lacks chatGuid**
   (CWE-697). Earlier `isCrossChatMismatch` gated chatIdentifier and chatId
   fallback comparisons on `!ctxChatGuid`, which let any non-empty
   ctx.chatGuid suppress the fallback checks when the cached entry happened
   to lack chatGuid — letting a short id from chat A be reused while acting
   in chat B. Rewrite the function so chatIdentifier/chatId comparisons
   run independently based on availability on each side, not on whether
   ctx.chatGuid happens to be present.

2. **Sensitive chat identifiers exposed via thrown cross-chat error**
   (CWE-200). `describeChatForError` interpolated raw chatGuid /
   chatIdentifier / chatId into the error message — these can leak phone
   numbers / email addresses / chat GUIDs into agent transcripts, tool
   results, remote channel deliveries, or third-party log aggregators.
   Surface only the *shape* of the chat target with `=<redacted>` values.

3. **Group reaction drop-guard bypass via whitespace chatIdentifier**.
   Earlier guard treated "" as missing but accepted " " / "\t". Trim
   chatGuid/chatIdentifier before the missing-check so a webhook sender
   supplying whitespace cannot satisfy the guard and have peerId degrade
   to the literal "group".

4. **Log injection via webhook senderId/messageId in verbose log lines**
   (CWE-117). Untrusted webhook fields were interpolated directly into
   `logVerbose` calls without sanitization, allowing log forging if a
   sender carried CR/LF/control bytes. Wrap with the existing
   `sanitizeForLog()` helper at all such sites.

Test updates: monitor-reply-cache.test.ts cross-chat error assertions
now expect `chatGuid=<redacted>` instead of raw values.
Three findings from the second pass:

1. **MEDIUM — Cross-chat short message ID guard bypassed on empty chat
   context (CWE-285).** When `requireKnownShortId=true` and `chatContext`
   was missing or `{}`, `resolveBlueBubblesMessageId` would still resolve
   the short id. Short ids are allocated from a single global counter
   across every account and chat, so an action call without a chat
   scope could silently apply to the wrong conversation. Throw "requires
   a chat scope" instead. The previous behavior was an explicit
   "fail-open" choice with a comment acknowledging the risk; the
   underlying assumption (downstream call carries chatGuid) does not
   hold for every action handler. Test rewritten to expect fail-closed.

2. **LOW — Unsanitized messageId reflected in cross-chat guard error
   (CWE-117 / CWE-200).** The thrown error embedded the raw inputId
   (and the raw chatGuid / chatIdentifier from the cached entry until
   the previous pass). Replace the inputId with a shape descriptor
   (`<short:N-digit>` or `<uuid:prefix…>`) so cross-chat errors no
   longer leak any concrete identifier. Combined with the chat
   identifier redaction in describeChatForError (already in place),
   the error is fully redacted.

3. **LOW — PII exposure via verbose logs (CWE-532).** Untrusted webhook
   identifiers (senderId / messageId / action) were already passed
   through `sanitizeForLog`, but the helper only stripped control
   characters — it did not redact secrets such as `?password=` query
   strings or `Authorization: Bearer …` headers that occasionally
   bleed into error chains. Extend `sanitizeForLog` to redact those
   patterns. All call sites benefit immediately.
@steipete steipete force-pushed the fix/bluebubbles-routing-guards branch from ec75fa3 to 85c3676 Compare April 28, 2026 20:03
@steipete steipete merged commit 7ee85a1 into openclaw:main Apr 28, 2026
66 of 67 checks passed
@steipete
Copy link
Copy Markdown
Contributor

Landed via rebase onto main.

Local proof:

  • pnpm test src/plugins/loader.test.ts src/auto-reply/reply/session-reset-prompt.test.ts
  • pnpm exec oxfmt --check --threads=1 src/plugins/loader.test.ts src/auto-reply/reply/session-reset-prompt.ts
  • git diff --check
  • pnpm lint --threads=8

CI:

  • Required/touched checks green on 85c367690ff81064fc4af124b248e934b979224c.
  • The OpenAI / Opus parity gate remained red; it is not a touched-surface gate for this BlueBubbles/plugin-loader PR.

Source head: 85c3676
Landed as: 7ee85a1

Thanks @zqchris!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: bluebubbles Channel integration: bluebubbles channel: googlechat Channel integration: googlechat commands Command implementations gateway Gateway runtime size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants