Skip to content

[Bug]: Matrix outbound sends use lowercased room ID, triggering 403 M_FORBIDDEN against rooms the bot is actually joined to #78206

@lukeboyett

Description

@lukeboyett

Bug type

Behavior bug (incorrect output/state without crash)

Beta release blocker

No

Summary

Matrix outbound sends regularly target a lowercased variant of the canonical mixed-case room ID, causing Synapse to return 403 M_FORBIDDEN: User not in room <lowercased-id> even though the bot is a member of the canonical mixed-case room.

Steps to reproduce

  1. Have a bot account that is a member of a Matrix room whose room ID contains uppercase letters in the local-part (e.g. !ExampleMixedCase:matrix.example.org). Synapse-generated room IDs are always mixed-case, so this is the default.
  2. From the bot account, exercise an outbound code path that goes through reply routing, cron delivery, queue retry, or mirror/group send (any code path that derives the outbound to from session metadata rather than from a freshly-resolved Matrix join).
  3. Observe in gateway.err.log:
    [tools] message failed: User @bot-a:matrix.example.org not in room
      !examplemixedcase:matrix.example.org, and room previews are disabled
    
  4. Inspect the bot's local Matrix sync storage at <config-root>/matrix/accounts/<bot>/.../bot-storage.json. The joined-rooms list contains !ExampleMixedCase:matrix.example.org, not !examplemixedcase:matrix.example.org.
  5. Inspect the corresponding session-store entry (<config-root>/agents/<bot>/sessions/sessions.json):
    • groupId: !examplemixedcase:matrix.example.org ← lowercased
    • deliveryContext.to: room:!ExampleMixedCase:matrix.example.org ← mixed-case (correct)
    • lastTo: room:!ExampleMixedCase:matrix.example.org ← mixed-case (correct)
  6. The failed delivery-queue item (<config-root>/delivery-queue/failed/<uuid>.json) shows to: !examplemixedcase:matrix.example.org, i.e. the outbound payload used the lowercased identity even though the session preserved the mixed-case destination.

Expected behavior

Outbound Matrix sends use the provider-exact room ID — the same string the bot received when it joined the room — preserved through the entire outbound path including queue recovery, retry, and reply/mirror plumbing. The room ID Matrix issued for the room is the only valid <roomId> for POST /_matrix/client/v3/rooms/<roomId>/send/m.room.message/<txnId>.

Actual behavior

Outbound payload to field gets a lowercased identity that matches the canonicalized session/group key, not the joined room. Synapse correctly returns 403 because no room with the lowercased ID exists in that bot account's membership. The mixed-case destination is preserved in deliveryContext.to and lastTo in the session entry but is discarded somewhere in the outbound path.

OpenClaw version

2026.4.23-beta.5

Operating system

macOS 15.x (Darwin 23.x)

Install method

pnpm dev (from a moltbot checkout)

Model

n/a — this is a Matrix-routing bug; reproduces against any model.

Provider / routing chain

openclaw -> matrix-synapse (self-hosted)

Additional provider/model setup details

n/a — bug is independent of LLM provider or model. Reproduces on multiple bot accounts and multiple rooms in the same deployment.

Logs, screenshots, and evidence

7-day window, single deployment, multiple bot accounts, classified by a log scanner that splits "lowercased target" 403s from "out-of-scope target" 403s:

Class                                  Count
case-bug (lowercased mixed-case room)  357
out-of-scope (other not-in-room)        53

The 357 lowercased-target events are distributed across 5 distinct rooms and 2 distinct bot accounts. Every lowercased-target event has a matching mixed-case room in the bot's bot-storage.json joined-rooms list. The lowercased forms are not present anywhere in the bot's joined-rooms list.

Representative log line (bot/room redacted; case-loss pattern preserved):

2026-05-05T13:14:31Z [tools] message failed:
  User @bot-a:matrix.example.org not in room !examplemixedcase:matrix.example.org,
  and room previews are disabled
  raw_params={"action":"read","channel":"matrix",
             "target":"room:!examplemixedcase:matrix.example.org","limit":12}

Same case-loss pattern observed across read and send actions, and across at least these outbound contexts: cron-delivered isolated agent runs, reply-routing mirror sends, and queue-retry of previously failed sends.

Impact and severity

  • Affected: any deployment with mixed-case Matrix room IDs (i.e. essentially every Synapse-hosted deployment, since Synapse generates mixed-case room IDs).
  • Severity: behavior bug — failed deliveries surface as [tools] message failed to the agent and as queued failures in delivery-queue/failed/. No data corruption observed, no auth/security exposure. Agents experience silent message-drop in any code path that goes through the affected routing.
  • Frequency: in a deployment with normal traffic, ~50 events/day spread across multiple rooms and bots.
  • Consequence: legitimate agent-to-room messages are dropped (read attempts fail to return history; send attempts never deliver). Operators see recurring 403 noise in gateway.err.log and recurring entries in delivery-queue/failed/. Agents can also be misled into believing membership has been revoked.

Additional information

Files already audited as not-the-bug

  • extensions/matrix/src/matrix/target-ids.tsnormalizeMatrixResolvableTarget and normalizeMatrixMessagingTarget strip prefixes (matrix:, room:, channel:, user:) but do not lowercase room IDs.
  • extensions/matrix/src/matrix/send/targets.tsresolveMatrixRoomId resolves aliases and direct rooms; does not lowercase room IDs.
  • src/utils/delivery-context.shared.tsnormalizeDeliveryContext trims to via normalizeOptionalString; does not lowercase.
  • src/config/sessions/delivery-info.tsextractDeliveryInfo already has mixed-case regression coverage in its test file (preserves room:!MixedCase:example.org from stored last-route metadata and from thread-fallback).

Where the case loss is intentional

  • src/channels/session.tsrecordInboundSession() calls normalizeLowercaseStringOrEmpty(sessionKey). The lowercased session key is intentional for indexing and matching. The bug is when this lowercased identity leaks into the outbound to field.

Suspect paths to audit

The outbound path appears to mix two concepts: the lowercased canonical session/group identity (used for indexing) and the provider-exact target (used for real Matrix sends). The most suspect locations:

  • src/auto-reply/reply/route-reply.ts — passes mirror.sessionKey and groupId into outbound plumbing.
  • src/infra/outbound/message.ts
  • src/infra/outbound/deliver.ts
  • src/infra/outbound/outbound-send-service.ts
  • src/infra/outbound/delivery-queue.* — queue recovery / retry is a strong candidate (the failed queue item has to: <lowercased> while the producing session has deliveryContext.to: <mixed-case>).
  • src/infra/outbound/targets.ts
  • src/infra/outbound/targets-session.ts
  • src/cron/isolated-agent/delivery-target.ts

Suggested fix direction

Design principle for Matrix room sends: never derive the outbound room ID from a lowercased session key, groupId, or canonical identifier when a preserved exact to / nativeChannelId exists.

Field-precedence rules for outbound Matrix targets:

  1. explicit deliveryContext.to
  2. lastTo
  3. exact origin.to
  4. exact origin.nativeChannelId (re-wrap with room: prefix if needed)

Lowercased session keys, groupId, and other canonical identifiers are valid lookup keys but must not become the provider-exact outbound target.

Suggested test coverage

  1. Mixed-case Matrix room preserved through reply routing — given session key lowercased and deliveryContext.to = room:!MixedCase:example.org, the final outbound target is !MixedCase:example.org.
  2. Mixed-case room preserved through cron delivery — queued payload and recovery/retry both keep mixed-case.
  3. Mirrored / group reply — mirror.sessionKey lowercased, deliveryContext.to mixed-case, send target stays mixed-case.
  4. Queue recovery — failed queued Matrix item originally targeting !MixedCase:example.org retries against !MixedCase:example.org.
  5. Negative regression — given the joined room is !MixedCase:example.org and the lowercased variant is absent from membership, no code path may send to !mixedcase:example.org.

Why this isn't a Synapse / membership issue

The send call:

POST /_matrix/client/v3/rooms/<roomId>/send/m.room.message/<txnId>

Synapse correctly checks membership against the exact <roomId> string. Matrix room IDs are case-sensitive. The 403 is correct: the bot really isn't a member of the lowercased ID, because that's not the room ID the bot ever joined. The bug is on the OpenClaw side, in how the outbound to is derived.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions