mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
4970ed2357
* fix(matrix): recognize room version 12 room IDs (no :server suffix) Room version 12 (MSC4291) dropped the trailing ":server" from room IDs -- they are now a hash of the create event. Every place in the Matrix plugin that treated "!" + ":" as the signature of an already-resolved room ID silently discarded valid v12 room IDs as unresolved instead of using them directly: - channels.matrix.rooms config resolution (config.ts) dropped the entry entirely, so group rooms could never pass the groupPolicy "allowlist" gate on a v12 homeserver -- messages were dropped with no reply and no default-level log line, since the only trace is a verbose-only debug log. - The invite auto-join allowlist validator and the interactive group-room setup resolver in onboarding.ts had the same check duplicated, so a user typing a v12 room ID during setup would be told it was invalid. - session-route.ts's per-room DM recipientSessionExact check had the same gap for room-kind sends. Runtime auto-join (auto-join.ts) already only checked for the "!" sigil, which is why joining a v12 room worked while responding in it did not -- this made the bug hard to spot from the join path alone. Fix: add a single canonical isMatrixRoomId predicate next to the existing isMatrixQualifiedUserId in target-ids.ts (user IDs and aliases still require ":server" per spec; only room IDs changed) and reuse it at all four sites instead of repeating the stale check. Confirmed live against a real Room v12 homeserver (Conduit): the server's own /joined_rooms response returns bare "!<hash>" room IDs with no colon. * docs(matrix): document suffixless room version 12 room IDs Room version 12 (MSC4291) dropped the ":server" suffix from room IDs. Document that the suffixless "!room" form is accepted anywhere the docs previously only showed "!room:server", matching the target-ids.ts fix landed in this same PR. * fix(matrix): update stale Room v12 guidance text * docs(matrix): accept suffixless Room v12 IDs in the group-policy guide docs/channels/matrix.md already documents that channels.matrix.groups accepts the suffixless !room form on room version 12+, but the group-policy guide (docs/channels/groups.md) still only listed !room:server, contradicting the channel doc an operator on a v12 homeserver would actually be following. Addresses the ClawSweeper P2 finding on PR #123931. * fix(matrix): advertise suffixless Room v12 IDs in onboarding placeholders The invite auto-join and group-room setup prompts' placeholder text still showed only `!roomId:server`, even though the retry note, validation, and unresolved-room diagnostic already accept and describe the suffixless `!roomId` form on room version 12+. An operator on a v12 homeserver would see their homeserver's own room IDs contradicted by the very placeholder guiding them through setup. Updated both placeholders to list the suffixless form alongside the existing examples, matching the phrasing already used in docs/channels/matrix.md and the invite retry note. Added configureRoomsAccess/roomsAllowlist options to the shared createMatrixUpdateKeepCredentialsPrompter test harness (mirroring the existing inviteAutoJoin option) so the group-room setup flow can be exercised without duplicating the base prompter setup, then added focused tests asserting the exact placeholder text for both prompts. Verified both new tests fail against the pre-fix placeholders and pass after. Addresses the two ClawSweeper P2 findings on PR #123931. * test(matrix): restore only allowlisted environment keys * fix(matrix): reject empty room identifiers --------- Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
421 lines
14 KiB
TypeScript
421 lines
14 KiB
TypeScript
// Matrix tests cover session route plugin behavior.
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import {
|
|
normalizeSessionDeliveryState,
|
|
upsertSessionEntry,
|
|
} from "openclaw/plugin-sdk/session-store-runtime";
|
|
import type { SessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import type { OpenClawConfig } from "./runtime-api.js";
|
|
import { resolveMatrixOutboundSessionRoute } from "./session-route.js";
|
|
|
|
const tempDirs = new Set<string>();
|
|
const currentDmSessionKey = "agent:main:matrix:channel:!dm:example.org";
|
|
type MatrixChannelConfig = NonNullable<NonNullable<OpenClawConfig["channels"]>["matrix"]>;
|
|
|
|
const perRoomDmMatrixConfig = {
|
|
dm: {
|
|
sessionScope: "per-room",
|
|
},
|
|
} satisfies MatrixChannelConfig;
|
|
|
|
const defaultAccountPerRoomDmMatrixConfig = {
|
|
defaultAccount: "ops",
|
|
accounts: {
|
|
ops: {
|
|
dm: {
|
|
sessionScope: "per-room",
|
|
},
|
|
},
|
|
},
|
|
} satisfies MatrixChannelConfig;
|
|
|
|
async function createTempStore(entries: Record<string, SessionEntry>): Promise<string> {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-session-route-"));
|
|
tempDirs.add(tempDir);
|
|
const storePath = path.join(tempDir, "sessions.json");
|
|
for (const [sessionKey, entry] of Object.entries(entries)) {
|
|
await upsertSessionEntry({ sessionKey, storePath, entry });
|
|
}
|
|
return storePath;
|
|
}
|
|
|
|
async function createMatrixRouteConfig(
|
|
entries: Record<string, SessionEntry>,
|
|
matrix: MatrixChannelConfig = perRoomDmMatrixConfig,
|
|
): Promise<OpenClawConfig> {
|
|
return {
|
|
session: {
|
|
store: await createTempStore(entries),
|
|
},
|
|
channels: {
|
|
matrix,
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
}
|
|
|
|
function createStoredDirectDmSession(
|
|
params: {
|
|
from?: string;
|
|
to?: string;
|
|
accountId?: string | null;
|
|
nativeChannelId?: string;
|
|
nativeDirectUserId?: string;
|
|
lastTo?: string;
|
|
lastAccountId?: string;
|
|
} = {},
|
|
): SessionEntry {
|
|
const accountId = params.accountId === null ? undefined : (params.accountId ?? "ops");
|
|
const to = params.to ?? "room:!dm:example.org";
|
|
const accountMetadata = accountId ? { accountId } : {};
|
|
const nativeMetadata = {
|
|
...(params.nativeChannelId ? { nativeChannelId: params.nativeChannelId } : {}),
|
|
...(params.nativeDirectUserId ? { nativeDirectUserId: params.nativeDirectUserId } : {}),
|
|
};
|
|
return {
|
|
sessionId: "sess-1",
|
|
updatedAt: Date.now(),
|
|
chatType: "direct",
|
|
delivery: normalizeSessionDeliveryState({
|
|
origin: {
|
|
chatType: "direct",
|
|
from: params.from ?? "matrix:@alice:example.org",
|
|
to,
|
|
...nativeMetadata,
|
|
...accountMetadata,
|
|
},
|
|
context: { channel: "matrix", to, ...accountMetadata },
|
|
}),
|
|
};
|
|
}
|
|
|
|
function createStoredChannelSession(): SessionEntry {
|
|
return {
|
|
sessionId: "sess-1",
|
|
updatedAt: Date.now(),
|
|
chatType: "channel",
|
|
delivery: normalizeSessionDeliveryState({
|
|
origin: {
|
|
chatType: "channel",
|
|
from: "matrix:channel:!ops:example.org",
|
|
to: "room:!ops:example.org",
|
|
nativeChannelId: "!ops:example.org",
|
|
nativeDirectUserId: "@alice:example.org",
|
|
accountId: "ops",
|
|
},
|
|
context: {
|
|
channel: "matrix",
|
|
to: "room:!ops:example.org",
|
|
accountId: "ops",
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
|
|
function resolveUserRoute(params: { cfg: OpenClawConfig; accountId?: string; target?: string }) {
|
|
const target = params.target ?? "@alice:example.org";
|
|
return resolveMatrixOutboundSessionRoute({
|
|
cfg: params.cfg,
|
|
agentId: "main",
|
|
...(params.accountId ? { accountId: params.accountId } : {}),
|
|
currentSessionKey: currentDmSessionKey,
|
|
target,
|
|
resolvedTarget: {
|
|
to: target,
|
|
kind: "user",
|
|
source: "normalized",
|
|
},
|
|
});
|
|
}
|
|
|
|
async function resolveUserRouteForCurrentSession(params: {
|
|
storedSession: SessionEntry;
|
|
accountId?: string;
|
|
target?: string;
|
|
matrix?: MatrixChannelConfig;
|
|
}) {
|
|
return resolveUserRoute({
|
|
cfg: await createMatrixRouteConfig(
|
|
{
|
|
[currentDmSessionKey]: params.storedSession,
|
|
},
|
|
params.matrix ?? perRoomDmMatrixConfig,
|
|
),
|
|
...(params.accountId ? { accountId: params.accountId } : {}),
|
|
...(params.target ? { target: params.target } : {}),
|
|
});
|
|
}
|
|
|
|
function expectCurrentDmRoomRoute(route: ReturnType<typeof resolveMatrixOutboundSessionRoute>) {
|
|
const currentRoute = expectRoute(route);
|
|
expect(currentRoute.sessionKey).toBe(currentDmSessionKey);
|
|
expect(currentRoute.baseSessionKey).toBe(currentDmSessionKey);
|
|
expect(currentRoute.peer.kind).toBe("channel");
|
|
expect(currentRoute.peer.id).toBe("!dm:example.org");
|
|
expect(currentRoute.chatType).toBe("direct");
|
|
expect(currentRoute.from).toBe("matrix:@alice:example.org");
|
|
expect(currentRoute.to).toBe("room:!dm:example.org");
|
|
expect(currentRoute.recipientSessionExact).toBe(true);
|
|
}
|
|
|
|
function expectFallbackUserRoute(
|
|
route: ReturnType<typeof resolveMatrixOutboundSessionRoute>,
|
|
params?: {
|
|
userId?: string;
|
|
},
|
|
) {
|
|
const userId = params?.userId ?? "@alice:example.org";
|
|
const fallbackRoute = expectRoute(route);
|
|
expect(fallbackRoute.sessionKey).toBe("agent:main:main");
|
|
expect(fallbackRoute.baseSessionKey).toBe("agent:main:main");
|
|
expect(fallbackRoute.peer.kind).toBe("direct");
|
|
expect(fallbackRoute.peer.id).toBe(userId);
|
|
expect(fallbackRoute.chatType).toBe("direct");
|
|
expect(fallbackRoute.from).toBe(`matrix:${userId}`);
|
|
expect(fallbackRoute.to).toBe(`room:${userId}`);
|
|
expect(fallbackRoute.recipientSessionExact).toBe(false);
|
|
}
|
|
|
|
function expectRoute(route: ReturnType<typeof resolveMatrixOutboundSessionRoute>) {
|
|
if (!route) {
|
|
throw new Error("Expected Matrix route");
|
|
}
|
|
return route;
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const tempDir of tempDirs) {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
tempDirs.clear();
|
|
});
|
|
|
|
describe("resolveMatrixOutboundSessionRoute", () => {
|
|
it("reuses the current DM room session for same-user sends when Matrix DMs are per-room", async () => {
|
|
const route = await resolveUserRouteForCurrentSession({
|
|
storedSession: createStoredDirectDmSession(),
|
|
accountId: "ops",
|
|
});
|
|
|
|
expectCurrentDmRoomRoute(route);
|
|
});
|
|
|
|
it("falls back to user-scoped routing when the current session is for another DM peer", async () => {
|
|
const route = await resolveUserRouteForCurrentSession({
|
|
storedSession: createStoredDirectDmSession({ from: "matrix:@bob:example.org" }),
|
|
accountId: "ops",
|
|
});
|
|
|
|
expectFallbackUserRoute(route);
|
|
});
|
|
|
|
it("falls back to user-scoped routing when the current session belongs to another Matrix account", async () => {
|
|
const route = await resolveUserRouteForCurrentSession({
|
|
storedSession: createStoredDirectDmSession(),
|
|
accountId: "support",
|
|
});
|
|
|
|
expectFallbackUserRoute(route);
|
|
});
|
|
|
|
it("reuses the canonical DM room after user-target outbound metadata overwrites latest to fields", async () => {
|
|
const route = await resolveUserRouteForCurrentSession({
|
|
storedSession: createStoredDirectDmSession({
|
|
from: "matrix:@bob:example.org",
|
|
to: "room:@bob:example.org",
|
|
nativeChannelId: "!dm:example.org",
|
|
nativeDirectUserId: "@alice:example.org",
|
|
lastTo: "room:@bob:example.org",
|
|
lastAccountId: "ops",
|
|
}),
|
|
accountId: "ops",
|
|
});
|
|
|
|
expectCurrentDmRoomRoute(route);
|
|
});
|
|
|
|
it("does not reuse the canonical DM room for a different Matrix user after latest metadata drift", async () => {
|
|
const route = await resolveUserRouteForCurrentSession({
|
|
storedSession: createStoredDirectDmSession({
|
|
from: "matrix:@bob:example.org",
|
|
to: "room:@bob:example.org",
|
|
nativeChannelId: "!dm:example.org",
|
|
nativeDirectUserId: "@alice:example.org",
|
|
lastTo: "room:@bob:example.org",
|
|
lastAccountId: "ops",
|
|
}),
|
|
accountId: "ops",
|
|
target: "@bob:example.org",
|
|
});
|
|
|
|
expectFallbackUserRoute(route, { userId: "@bob:example.org" });
|
|
});
|
|
|
|
it("does not reuse a room after the session metadata was overwritten by a non-DM Matrix send", async () => {
|
|
const route = await resolveUserRouteForCurrentSession({
|
|
storedSession: createStoredChannelSession(),
|
|
accountId: "ops",
|
|
});
|
|
|
|
expectFallbackUserRoute(route);
|
|
});
|
|
|
|
it("uses the effective default Matrix account when accountId is omitted", async () => {
|
|
const route = await resolveUserRouteForCurrentSession({
|
|
storedSession: createStoredDirectDmSession(),
|
|
matrix: defaultAccountPerRoomDmMatrixConfig,
|
|
});
|
|
|
|
expectCurrentDmRoomRoute(route);
|
|
});
|
|
|
|
it("reuses the current DM room when stored account metadata is missing", async () => {
|
|
const route = await resolveUserRouteForCurrentSession({
|
|
storedSession: createStoredDirectDmSession({ accountId: null }),
|
|
matrix: defaultAccountPerRoomDmMatrixConfig,
|
|
});
|
|
|
|
expectCurrentDmRoomRoute(route);
|
|
});
|
|
|
|
it("recovers channel thread routes from currentSessionKey and preserves Matrix event-id case", () => {
|
|
const route = resolveMatrixOutboundSessionRoute({
|
|
cfg: {},
|
|
agentId: "main",
|
|
target: "room:!ops:example.org",
|
|
currentSessionKey: "agent:main:matrix:channel:!ops:example.org:thread:$RootEvent:Example.Org",
|
|
});
|
|
|
|
const channelRoute = expectRoute(route);
|
|
expect(channelRoute.sessionKey).toBe(
|
|
"agent:main:matrix:channel:!ops:example.org:thread:$RootEvent:Example.Org",
|
|
);
|
|
expect(channelRoute.baseSessionKey).toBe("agent:main:matrix:channel:!ops:example.org");
|
|
expect(channelRoute.threadId).toBe("$RootEvent:Example.Org");
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "uses the Matrix thread root when replying to a child event",
|
|
threadId: "$ThreadRoot:Example.Org",
|
|
replyToId: "$ReplyChild:Example.Org",
|
|
expectedThreadId: "$ThreadRoot:Example.Org",
|
|
},
|
|
{
|
|
name: "keeps reply-only session routing when no Matrix thread exists",
|
|
threadId: undefined,
|
|
replyToId: "$ReplyChild:Example.Org",
|
|
expectedThreadId: "$ReplyChild:Example.Org",
|
|
},
|
|
])("$name", ({ threadId, replyToId, expectedThreadId }) => {
|
|
const route = expectRoute(
|
|
resolveMatrixOutboundSessionRoute({
|
|
cfg: {},
|
|
agentId: "main",
|
|
target: "room:!ops:example.org",
|
|
threadId,
|
|
replyToId,
|
|
}),
|
|
);
|
|
|
|
expect(route.threadId).toBe(expectedThreadId);
|
|
expect(route.sessionKey).toBe(
|
|
`agent:main:matrix:channel:!ops:example.org:thread:${expectedThreadId}`,
|
|
);
|
|
});
|
|
|
|
it("does not claim room aliases as canonical inbound session ids", () => {
|
|
const route = resolveMatrixOutboundSessionRoute({
|
|
cfg: {},
|
|
agentId: "main",
|
|
target: "#ops:example.org",
|
|
});
|
|
|
|
expect(route?.recipientSessionExact).toBe(false);
|
|
});
|
|
|
|
it("does not claim room ids when DMs are keyed by user identity", () => {
|
|
const route = resolveMatrixOutboundSessionRoute({
|
|
cfg: {},
|
|
agentId: "main",
|
|
target: "!ops:example.org",
|
|
});
|
|
|
|
expect(route?.recipientSessionExact).toBe(false);
|
|
});
|
|
|
|
it("claims a room id as canonical when DMs are room-scoped", () => {
|
|
const route = resolveMatrixOutboundSessionRoute({
|
|
cfg: { channels: { matrix: perRoomDmMatrixConfig } },
|
|
agentId: "main",
|
|
target: "room:!ops:example.org",
|
|
});
|
|
|
|
expect(route?.recipientSessionExact).toBe(true);
|
|
});
|
|
|
|
it("claims a room version 12 room id (no :server suffix) as canonical when DMs are room-scoped", () => {
|
|
// Room version 12 (MSC4291) dropped the trailing ":server" from room IDs.
|
|
const route = resolveMatrixOutboundSessionRoute({
|
|
cfg: { channels: { matrix: perRoomDmMatrixConfig } },
|
|
agentId: "main",
|
|
target: "room:!UIZ0YzC99dC1AyEM6mGl0_XNP8u8xeCCt_Zk8Uhkp70",
|
|
});
|
|
|
|
expect(route?.recipientSessionExact).toBe(true);
|
|
});
|
|
|
|
it("resolves per-room DM metadata from the base key when currentSessionKey has a thread suffix", async () => {
|
|
const storedSession = createStoredDirectDmSession();
|
|
const route = resolveUserRoute({
|
|
cfg: await createMatrixRouteConfig({
|
|
[currentDmSessionKey]: storedSession,
|
|
}),
|
|
accountId: "ops",
|
|
target: "@alice:example.org",
|
|
});
|
|
const threadedRoute = resolveMatrixOutboundSessionRoute({
|
|
cfg: await createMatrixRouteConfig({
|
|
[route?.baseSessionKey ?? currentDmSessionKey]: storedSession,
|
|
}),
|
|
agentId: "main",
|
|
accountId: "ops",
|
|
target: "@alice:example.org",
|
|
resolvedTarget: {
|
|
to: "@alice:example.org",
|
|
kind: "user",
|
|
source: "normalized",
|
|
},
|
|
currentSessionKey: `${route?.baseSessionKey}:thread:$DmRoot:Example.Org`,
|
|
});
|
|
|
|
const dmThreadRoute = expectRoute(threadedRoute);
|
|
expect(dmThreadRoute.sessionKey).toBe(`${route?.baseSessionKey}:thread:$DmRoot:Example.Org`);
|
|
expect(dmThreadRoute.baseSessionKey).toBe(route?.baseSessionKey);
|
|
expect(dmThreadRoute.to).toBe("room:!dm:example.org");
|
|
expect(dmThreadRoute.threadId).toBe("$DmRoot:Example.Org");
|
|
});
|
|
|
|
it('does not recover currentSessionKey threads for shared dmScope "main" DMs', () => {
|
|
const route = resolveMatrixOutboundSessionRoute({
|
|
cfg: {},
|
|
agentId: "main",
|
|
target: "@alice:example.org",
|
|
currentSessionKey: "agent:main:main:thread:$DmRoot:Example.Org",
|
|
resolvedTarget: {
|
|
to: "@alice:example.org",
|
|
kind: "user",
|
|
source: "normalized",
|
|
},
|
|
});
|
|
|
|
const dmRoute = expectRoute(route);
|
|
expect(dmRoute.sessionKey).toBe("agent:main:main");
|
|
expect(dmRoute.baseSessionKey).toBe("agent:main:main");
|
|
expect(dmRoute.threadId).toBeUndefined();
|
|
expect(dmRoute.recipientSessionExact).toBe(true);
|
|
});
|
|
});
|