mirror of https://github.com/openclaw/openclaw.git
Matrix: tighten DM invite promotion state
This commit is contained in:
parent
1243e2c0b6
commit
a06ec156fc
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
Loading…
Reference in New Issue