diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b047d4ab53..47a03922e2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,6 +140,7 @@ Docs: https://docs.openclaw.ai - xAI/Responses: normalize image-bearing tool results for xAI responses payloads, including OpenResponses-style `input_image.source` parts, so image tool replays no longer 422 on the follow-up turn. (#58017) Thanks @neeravmakwana. - Cron/isolated sessions: carry the full live-session provider, model, and auth-profile selection across retry restarts so cron jobs with model overrides no longer fail or loop on mid-run model-switch requests. (#57972) Thanks @issaba1. - Matrix/direct rooms: stop trusting remote `is_direct`, honor explicit local `is_direct: false` for discovered DM candidates, and avoid extra member-state lookups for shared rooms so DM routing and repair stay aligned. (#57124) Thanks @w-sss. +- Matrix/DM threads: only promote fresh invite rooms when Matrix marks the invite as direct, keep failed local DM repairs stable across later replies, and keep thread-isolated Matrix sessions reporting the correct route policy. - Agents/sandbox: make remote FS bridge reads pin the parent path and open the file atomically in the helper so read access cannot race path resolution. Thanks @AntAISecurityLab and @vincentkoc. - Tools/web_fetch: add an explicit trusted env-proxy path for proxy-only installs while keeping strict SSRF fetches on the pinned direct path, so trusted proxy routing does not weaken strict destination binding. (#50650) Thanks @kkav004. - Exec/env: block Python package index override variables from request-scoped host exec environment sanitization so package fetches cannot be redirected through a caller-supplied index. Thanks @nexrin and @vincentkoc. diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index 7dd3c962fcc..f9e3357eabb 100644 --- a/extensions/matrix/src/config-schema.ts +++ b/extensions/matrix/src/config-schema.ts @@ -1,6 +1,6 @@ import { AllowFromListSchema, - DmPolicySchema, + buildNestedDmConfigSchema, GroupPolicySchema, MarkdownConfigSchema, ToolPolicySchema, @@ -83,14 +83,9 @@ export const MatrixConfigSchema = z.object({ autoJoin: z.enum(["always", "allowlist", "off"]).optional(), autoJoinAllowlist: AllowFromListSchema, groupAllowFrom: AllowFromListSchema, - dm: z - .object({ - enabled: z.boolean().optional(), - policy: DmPolicySchema.optional(), - allowFrom: AllowFromListSchema, - threadReplies: z.enum(["off", "inbound", "always"]).optional(), - }) - .optional(), + dm: buildNestedDmConfigSchema({ + threadReplies: z.enum(["off", "inbound", "always"]).optional(), + }), groups: z.object({}).catchall(matrixRoomSchema).optional(), rooms: z.object({}).catchall(matrixRoomSchema).optional(), actions: matrixActionSchema, diff --git a/extensions/matrix/src/matrix/monitor/direct.test.ts b/extensions/matrix/src/matrix/monitor/direct.test.ts index 8fb05b2d98c..4a92c651dfb 100644 --- a/extensions/matrix/src/matrix/monitor/direct.test.ts +++ b/extensions/matrix/src/matrix/monitor/direct.test.ts @@ -323,6 +323,34 @@ describe("createDirectRoomTracker", () => { ).resolves.toBe(true); }); + it("keeps locally promoted direct rooms stable after repair failures", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-30T23:00:00Z")); + const client = createMockClient({ + isDm: false, + dmCacheAvailable: true, + setAccountDataError: new Error("account data unavailable"), + }); + const tracker = createDirectRoomTracker(client); + tracker.rememberInvite("!room:example.org", "@alice:example.org"); + + await expect( + tracker.isDirectMessage({ + roomId: "!room:example.org", + senderId: "@alice:example.org", + }), + ).resolves.toBe(true); + + vi.setSystemTime(new Date("2026-03-30T23:01:00Z")); + + await expect( + tracker.isDirectMessage({ + roomId: "!room:example.org", + senderId: "@alice:example.org", + }), + ).resolves.toBe(true); + }); + it("does not classify 2-member rooms whose sender is not a joined member when falling back", async () => { const client = createMockClient({ isDm: false, diff --git a/extensions/matrix/src/matrix/monitor/direct.ts b/extensions/matrix/src/matrix/monitor/direct.ts index 04c8eae4359..5e9ee3fc7f8 100644 --- a/extensions/matrix/src/matrix/monitor/direct.ts +++ b/extensions/matrix/src/matrix/monitor/direct.ts @@ -42,6 +42,7 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr const joinedMembersCache = new Map(); const directMemberFlagCache = new Map(); const recentInviteCandidates = new Map(); + const locallyPromotedDirectRooms = new Map(); const ensureSelfUserId = async (): Promise => { if (cachedSelfUserId) { @@ -127,6 +128,24 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr } }; + const hasLocallyPromotedDirectRoom = (roomId: string, remoteUserId?: string | null): boolean => { + const normalizedRemoteUserId = remoteUserId?.trim(); + if (!normalizedRemoteUserId) { + return false; + } + return locallyPromotedDirectRooms.get(roomId)?.remoteUserId === normalizedRemoteUserId; + }; + + const rememberLocallyPromotedDirectRoom = (roomId: string, remoteUserId: string): void => { + const normalizedRemoteUserId = remoteUserId.trim(); + if (!normalizedRemoteUserId) { + return; + } + rememberBounded(locallyPromotedDirectRooms, roomId, { + remoteUserId: normalizedRemoteUserId, + }); + }; + return { invalidateRoom: (roomId: string): void => { joinedMembersCache.delete(roomId); @@ -135,6 +154,7 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr directMemberFlagCache.delete(key); } } + locallyPromotedDirectRooms.delete(roomId); lastDmUpdateMs = 0; log(`matrix: invalidated dm cache room=${roomId}`); }, @@ -191,6 +211,11 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr return true; } + if (hasLocallyPromotedDirectRoom(roomId, senderId)) { + log(`matrix: dm detected via local promotion room=${roomId}`); + return true; + } + if (hasRecentInviteCandidate(roomId, senderId) && (await canPromoteRecentInvite(roomId))) { const promotion = await promoteMatrixDirectRoomCandidate({ client, @@ -199,6 +224,7 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr selfUserId, }); if (promotion.classifyAsDirect) { + rememberLocallyPromotedDirectRoom(roomId, senderId ?? ""); log( `matrix: dm detected via recent invite room=${roomId} reason=${promotion.reason} repaired=${String(promotion.repaired)}`, ); diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index 625cc890192..f034687af79 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -306,6 +306,27 @@ describe("registerMatrixMonitorEvents verification routing", () => { expect(rememberInvite).not.toHaveBeenCalled(); }); + it("does not remember invite provenance when Matrix does not mark the invite as direct", async () => { + const { invalidateRoom, rememberInvite, roomInviteListener } = createHarness(); + if (!roomInviteListener) { + throw new Error("room.invite listener was not registered"); + } + + roomInviteListener("!room:example.org", { + event_id: "$invite-group", + sender: "@alice:example.org", + type: EventType.RoomMember, + origin_server_ts: Date.now(), + content: { + membership: "invite", + }, + state_key: "@bot:example.org", + }); + + expect(invalidateRoom).toHaveBeenCalledWith("!room:example.org"); + expect(rememberInvite).not.toHaveBeenCalled(); + }); + it("does not synthesize invite provenance from room joins", async () => { const { invalidateRoom, rememberInvite, roomJoinListener } = createHarness(); if (!roomJoinListener) { diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts index 048ecd3cb7f..c81b3807c5f 100644 --- a/extensions/matrix/src/matrix/monitor/events.ts +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -130,10 +130,10 @@ export function registerMatrixMonitorEvents(params: { const invitee = typeof event?.state_key === "string" ? event.state_key.trim() : ""; const senderIsInvitee = typeof event?.sender === "string" && invitee && event.sender.trim() === invitee; - if (typeof event?.sender === "string" && event.sender.trim() && !senderIsInvitee) { + const isDirect = (event?.content as { is_direct?: boolean } | undefined)?.is_direct === true; + if (isDirect && typeof event?.sender === "string" && event.sender.trim() && !senderIsInvitee) { directTracker?.rememberInvite?.(roomId, event.sender); } - const isDirect = (event?.content as { is_direct?: boolean } | undefined)?.is_direct === true; logVerboseMessage( `matrix: invite room=${roomId} sender=${sender} direct=${String(isDirect)} id=${eventId}`, ); diff --git a/extensions/matrix/src/matrix/monitor/route.test.ts b/extensions/matrix/src/matrix/monitor/route.test.ts index ad8a69dc39e..b11d4aa956d 100644 --- a/extensions/matrix/src/matrix/monitor/route.test.ts +++ b/extensions/matrix/src/matrix/monitor/route.test.ts @@ -208,6 +208,7 @@ describe("resolveMatrixInboundRoute thread-isolated sessions", () => { expect(route.sessionKey).toContain(":thread:$thread-root"); expect(route.mainSessionKey).not.toContain(":thread:"); + expect(route.lastRoutePolicy).toBe("session"); }); it("preserves mixed-case matrix thread ids in session keys", () => { diff --git a/extensions/matrix/src/matrix/monitor/route.ts b/extensions/matrix/src/matrix/monitor/route.ts index 203dc486ae0..611c6addc01 100644 --- a/extensions/matrix/src/matrix/monitor/route.ts +++ b/extensions/matrix/src/matrix/monitor/route.ts @@ -1,3 +1,4 @@ +import { deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing"; import { getSessionBindingService, resolveAgentIdFromSessionKey, @@ -101,6 +102,10 @@ export function resolveMatrixInboundRoute(params: { ...effectiveRoute, sessionKey: threadKeys.sessionKey, mainSessionKey: threadKeys.parentSessionKey ?? effectiveRoute.sessionKey, + lastRoutePolicy: deriveLastRoutePolicy({ + sessionKey: threadKeys.sessionKey, + mainSessionKey: threadKeys.parentSessionKey ?? effectiveRoute.sessionKey, + }), }, configuredBinding, runtimeBindingId: null, diff --git a/extensions/matrix/src/matrix/monitor/threads.ts b/extensions/matrix/src/matrix/monitor/threads.ts index 01e91b56680..3c2554bd763 100644 --- a/extensions/matrix/src/matrix/monitor/threads.ts +++ b/extensions/matrix/src/matrix/monitor/threads.ts @@ -43,7 +43,6 @@ export function resolveMatrixThreadRouting(params: { dmThreadReplies?: MatrixThreadReplies; messageId: string; threadRootId?: string; - isThreadRoot?: boolean; }): MatrixThreadRouting { const effectiveThreadReplies = params.isDirectMessage && params.dmThreadReplies !== undefined @@ -51,9 +50,7 @@ export function resolveMatrixThreadRouting(params: { : params.threadReplies; const messageId = params.messageId.trim(); const threadRootId = params.threadRootId?.trim(); - const isThreadRoot = params.isThreadRoot === true; - const inboundThreadId = - threadRootId && threadRootId !== messageId && !isThreadRoot ? threadRootId : undefined; + const inboundThreadId = threadRootId && threadRootId !== messageId ? threadRootId : undefined; const threadId = effectiveThreadReplies === "off" ? undefined diff --git a/src/channels/plugins/config-schema.ts b/src/channels/plugins/config-schema.ts index 04ce3fbdfb0..971f50abea9 100644 --- a/src/channels/plugins/config-schema.ts +++ b/src/channels/plugins/config-schema.ts @@ -1,4 +1,4 @@ -import { z, type ZodTypeAny } from "zod"; +import { z, type ZodRawShape, type ZodTypeAny } from "zod"; import { DmPolicySchema } from "../../config/zod-schema.core.js"; import type { ChannelConfigRuntimeIssue, @@ -18,14 +18,15 @@ type ExtendableZodObject = ZodTypeAny & { export const AllowFromEntrySchema = z.union([z.string(), z.number()]); export const AllowFromListSchema = z.array(AllowFromEntrySchema).optional(); -export function buildNestedDmConfigSchema() { - return z - .object({ - enabled: z.boolean().optional(), - policy: DmPolicySchema.optional(), - allowFrom: AllowFromListSchema, - }) - .optional(); +export function buildNestedDmConfigSchema( + extraShape?: TExtraShape, +) { + const baseShape = { + enabled: z.boolean().optional(), + policy: DmPolicySchema.optional(), + allowFrom: AllowFromListSchema, + }; + return z.object(extraShape ? { ...baseShape, ...extraShape } : baseShape).optional(); } export function buildCatchallMultiAccountChannelSchema(