From 1f9ebb9dda36ee0217f44fe5bb0177b992d4d1fb Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Thu, 21 May 2026 17:40:17 -0700 Subject: [PATCH] Fix Matrix configured two-person room routing (#85137) * Fix Matrix configured room DM routing * Add Matrix room routing changelog --- CHANGELOG.md | 1 + .../matrix/src/matrix/direct-room.test.ts | 2 +- .../matrix/src/matrix/monitor/direct.test.ts | 34 ++++++++++ .../matrix/src/matrix/monitor/direct.ts | 14 +++++ .../matrix/src/matrix/monitor/index.test.ts | 63 ++++++++++++++++++- extensions/matrix/src/matrix/monitor/index.ts | 17 +++++ 6 files changed, 129 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5249f39dbd..3f2abe8fb87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai - Providers/Ollama: preserve native Ollama tool-call IDs across assistant replay so Gemini over Ollama Cloud can keep its hidden function-call thought-signature handle. - Discord: keep session recovery and `/stop` abort ownership on the source dispatch lane while bound ACP turns continue routing to their target session, so stalled pre-run work and late replies are cleared instead of leaking after stop. Fixes #84477. (#85100) Thanks @joshavant. - Codex app-server: mark missing turn completion after observed execution as replay-unsafe and release the session so follow-up turns can run. Fixes #84076. (#85107) Thanks @joshavant. +- Matrix: keep explicitly configured two-person rooms on the room route before stale `m.direct` or strict two-member DM fallback can bypass mention gating. Fixes #85017. (#85137) Thanks @joshavant. - PDF tool: time out idle remote PDF body reads after 120 seconds so stalled remote documents return an error instead of wedging the session. Fixes #68649. (#84768) Thanks @luoyanglang. - Diagnostics/OpenTelemetry plugin: suppress handled OTLP exporter promise rejections so collector shutdowns no longer crash the Gateway. (#81085) Thanks @luoyanglang. - Media/audio: skip empty structured sherpa-onnx transcripts instead of treating the raw JSON payload as spoken text. (#84667) Thanks @TurboTheTurtle. diff --git a/extensions/matrix/src/matrix/direct-room.test.ts b/extensions/matrix/src/matrix/direct-room.test.ts index 0179d2a1e65..2d4cc677662 100644 --- a/extensions/matrix/src/matrix/direct-room.test.ts +++ b/extensions/matrix/src/matrix/direct-room.test.ts @@ -41,7 +41,7 @@ describe("inspectMatrixDirectRoomEvidence", () => { expect(result.strict).toBe(true); }); - it("records only the local member-state direct flag", async () => { + it("preserves strict evidence when local is_direct=false provides a promotion veto reason", async () => { const client = createClient({ getRoomStateEvent: vi.fn(async (_roomId: string, _eventType: string, stateKey: string) => stateKey === "@bot:example.org" ? { is_direct: false } : { is_direct: true }, diff --git a/extensions/matrix/src/matrix/monitor/direct.test.ts b/extensions/matrix/src/matrix/monitor/direct.test.ts index 915955319ba..8d70390da6d 100644 --- a/extensions/matrix/src/matrix/monitor/direct.test.ts +++ b/extensions/matrix/src/matrix/monitor/direct.test.ts @@ -97,6 +97,40 @@ describe("createDirectRoomTracker", () => { expect(client.getJoinedRoomMembers).toHaveBeenCalledWith("!room:example.org"); }); + it("lets explicit room config veto stale m.direct classifications", async () => { + const client = createMockClient({ isDm: true }); + const tracker = createDirectRoomTracker(client, { + isExplicitlyConfiguredRoom: (roomId) => roomId === "!room:example.org", + }); + + await expect( + tracker.isDirectMessage({ + roomId: "!room:example.org", + senderId: "@alice:example.org", + }), + ).resolves.toBe(false); + + expect(client.dms.update).not.toHaveBeenCalled(); + expect(client.getJoinedRoomMembers).not.toHaveBeenCalled(); + }); + + it("lets explicit room config veto strict two-member fallback before dm cache seed", async () => { + const client = createMockClient({ isDm: false, dmCacheAvailable: false }); + const tracker = createDirectRoomTracker(client, { + isExplicitlyConfiguredRoom: (roomId) => roomId === "!room:example.org", + }); + + await expect( + tracker.isDirectMessage({ + roomId: "!room:example.org", + senderId: "@alice:example.org", + }), + ).resolves.toBe(false); + + expect(client.dms.update).not.toHaveBeenCalled(); + expect(client.getJoinedRoomMembers).not.toHaveBeenCalled(); + }); + it("does not trust stale m.direct classifications for shared rooms", async () => { const client = createMockClient({ isDm: true, diff --git a/extensions/matrix/src/matrix/monitor/direct.ts b/extensions/matrix/src/matrix/monitor/direct.ts index bd520c4a6a6..06161afd41e 100644 --- a/extensions/matrix/src/matrix/monitor/direct.ts +++ b/extensions/matrix/src/matrix/monitor/direct.ts @@ -14,6 +14,7 @@ type DirectMessageCheck = { type DirectRoomTrackerOptions = { log?: (message: string) => void; + isExplicitlyConfiguredRoom?: (roomId: string) => boolean | Promise; canPromoteRecentInvite?: (roomId: string) => boolean | Promise; canPromoteUnmappedStrictRoom?: (roomId: string) => boolean | Promise; shouldKeepLocallyPromotedDirectRoom?: @@ -162,6 +163,15 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr } }; + const isExplicitlyConfiguredRoom = async (roomId: string): Promise => { + try { + return (await opts.isExplicitlyConfiguredRoom?.(roomId)) ?? false; + } catch (err) { + log(`matrix: configured room check failed room=${roomId} (${String(err)})`); + return true; + } + }; + const hasLocallyPromotedDirectRoom = (roomId: string, remoteUserId?: string | null): boolean => { const normalizedRemoteUserId = remoteUserId?.trim(); if (!normalizedRemoteUserId) { @@ -204,6 +214,10 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr }, isDirectMessage: async (params: DirectMessageCheck): Promise => { const { roomId, senderId } = params; + if (await isExplicitlyConfiguredRoom(roomId)) { + log(`matrix: dm rejected via explicit room config room=${roomId}`); + return false; + } const selfUserId = params.selfUserId ?? (await ensureSelfUserId()); const joinedMembers = await resolveJoinedMembers(roomId); const strictDirectMembership = isStrictDirectMembership({ diff --git a/extensions/matrix/src/matrix/monitor/index.test.ts b/extensions/matrix/src/matrix/monitor/index.test.ts index c754a04f9dc..69035d5af73 100644 --- a/extensions/matrix/src/matrix/monitor/index.test.ts +++ b/extensions/matrix/src/matrix/monitor/index.test.ts @@ -4,6 +4,7 @@ import type { MatrixConfig, MatrixStreamingMode } from "../../types.js"; import type { MatrixRoomInfo } from "./room-info.js"; type DirectRoomTrackerOptions = { + isExplicitlyConfiguredRoom?: (roomId: string) => boolean | Promise; canPromoteRecentInvite?: (roomId: string) => boolean | Promise; canPromoteUnmappedStrictRoom?: (roomId: string) => boolean | Promise; shouldKeepLocallyPromotedDirectRoom?: @@ -145,7 +146,18 @@ vi.mock("../../runtime-api.js", () => { ToolPolicySchema: z.any().optional(), addAllowlistUserEntriesFromConfigEntry: vi.fn(), buildChannelConfigSchema: (schema: unknown) => schema, - buildChannelKeyCandidates: () => [], + buildChannelKeyCandidates: (...keys: Array) => { + const seen = new Set(); + return keys + .map((key) => (typeof key === "string" ? key.trim() : "")) + .filter((key) => { + if (!key || seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + }, buildProbeChannelStatusSummary: ( snapshot: Record, extra?: Record, @@ -961,6 +973,55 @@ describe("monitorMatrixProvider", () => { await expect(trackerOpts.canPromoteRecentInvite("!room:example.org")).resolves.toBe(false); }); + it("wires exact room config as a direct-room classifier veto", async () => { + (hoisted.accountConfig as { rooms?: Record }).rooms = { + "!room:example.org": { requireMention: true }, + "*": { requireMention: false }, + }; + + await startMonitorAndAbortAfterStartup(); + + const trackerOpts = directRoomTrackerOptions(); + if (!trackerOpts?.isExplicitlyConfiguredRoom) { + throw new Error("explicit room config callback was not wired"); + } + + expect(await trackerOpts.isExplicitlyConfiguredRoom("!room:example.org")).toBe(true); + expect(await trackerOpts.isExplicitlyConfiguredRoom("!other:example.org")).toBe(false); + expect(hoisted.getRoomInfo).not.toHaveBeenCalled(); + }); + + it("wires alias room config as a direct-room classifier veto", async () => { + (hoisted.accountConfig as { rooms?: Record }).rooms = { + "#ops:example.org": { requireMention: true }, + "*": { requireMention: false }, + }; + const { resolveMatrixTargets } = await import("../../resolve-targets.js"); + vi.mocked(resolveMatrixTargets).mockResolvedValueOnce([ + { + input: "#ops:example.org", + resolved: true, + id: "!room:example.org", + }, + ]); + + await startMonitorAndAbortAfterStartup(); + + const trackerOpts = directRoomTrackerOptions(); + if (!trackerOpts?.isExplicitlyConfiguredRoom) { + throw new Error("explicit room config callback was not wired"); + } + + hoisted.getRoomInfo.mockResolvedValueOnce({ + canonicalAlias: "#ops:example.org", + altAliases: [], + nameResolved: true, + aliasesResolved: true, + }); + + expect(await trackerOpts.isExplicitlyConfiguredRoom("!room:example.org")).toBe(true); + }); + it("wires recent-invite promotion to reject named rooms", async () => { await startMonitorAndAbortAfterStartup(); diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 22baf79c5bb..d7967e30aea 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -50,6 +50,7 @@ import { } from "./inbound-dedupe.js"; import { shouldPromoteRecentInviteRoom } from "./recent-invite.js"; import { createMatrixRoomInfoResolver } from "./room-info.js"; +import { resolveMatrixRoomConfig } from "./rooms.js"; import { runMatrixStartupMaintenance } from "./startup.js"; import { createMatrixMonitorStatusController } from "./status.js"; import { createMatrixMonitorSyncLifecycle } from "./sync-lifecycle.js"; @@ -345,8 +346,24 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi // /sync cursor we want restart backlogs to replay just like other channels. const dropPreStartupMessages = !client.hasPersistedSyncState(); const { getRoomInfo, getMemberDisplayName } = createMatrixRoomInfoResolver(client); + const isExplicitlyConfiguredRoom = async (roomId: string): Promise => { + const roomInfoForConfig = needsRoomAliasesForConfig + ? await getRoomInfo(roomId, { includeAliases: true }) + : undefined; + const aliases = roomInfoForConfig + ? [roomInfoForConfig.canonicalAlias ?? "", ...roomInfoForConfig.altAliases].filter(Boolean) + : []; + return ( + resolveMatrixRoomConfig({ + rooms: roomsConfig, + roomId, + aliases, + }).matchSource === "direct" + ); + }; const directTracker = createDirectRoomTracker(client, { log: logVerboseMessage, + isExplicitlyConfiguredRoom, canPromoteRecentInvite: async (roomId) => shouldPromoteRecentInviteRoom({ roomId,