Matrix: tighten DM invite promotion state

This commit is contained in:
Gustavo Madeira Santana 2026-03-30 23:13:11 -04:00
parent 1243e2c0b6
commit a06ec156fc
No known key found for this signature in database
10 changed files with 99 additions and 24 deletions

View File

@ -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.

View File

@ -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,

View File

@ -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,

View File

@ -42,6 +42,7 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
const joinedMembersCache = new Map<string, { members: string[]; ts: number }>();
const directMemberFlagCache = new Map<string, { isDirect: boolean | null; ts: number }>();
const recentInviteCandidates = new Map<string, { remoteUserId: string; ts: number }>();
const locallyPromotedDirectRooms = new Map<string, { remoteUserId: string }>();
const ensureSelfUserId = async (): Promise<string | null> => {
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)}`,
);

View File

@ -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) {

View File

@ -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}`,
);

View File

@ -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", () => {

View File

@ -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,

View File

@ -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

View File

@ -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<TExtraShape extends ZodRawShape = {}>(
extraShape?: TExtraShape,
) {
const baseShape = {
enabled: z.boolean().optional(),
policy: DmPolicySchema.optional(),
allowFrom: AllowFromListSchema,
};
return z.object(extraShape ? { ...baseShape, ...extraShape } : baseShape).optional();
}
export function buildCatchallMultiAccountChannelSchema<T extends ExtendableZodObject>(