Files
openclaw/extensions/matrix/src/session-route.test.ts
Markus Hartung 4970ed2357 fix(matrix): accept Room v12 IDs without server suffix (#123931)
* 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>
2026-08-19 16:12:05 -07:00

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);
});
});