From 2b685b08c28ae8c8f09da71803417519117e57d9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Feb 2026 20:34:53 +0100 Subject: [PATCH] fix: harden matrix multi-account routing (#7286) (thanks @emonty) --- CHANGELOG.md | 2 +- .../matrix/src/channel.directory.test.ts | 82 ++++++++++++++++++- extensions/matrix/src/channel.ts | 16 ++-- extensions/matrix/src/directory-live.test.ts | 54 ++++++++++++ extensions/matrix/src/directory-live.ts | 6 +- extensions/matrix/src/group-mentions.ts | 7 +- extensions/matrix/src/matrix/accounts.ts | 26 +++--- extensions/matrix/src/matrix/client/shared.ts | 14 ++-- extensions/matrix/src/matrix/monitor/index.ts | 2 +- extensions/matrix/src/matrix/send/client.ts | 14 ++-- 10 files changed, 188 insertions(+), 35 deletions(-) create mode 100644 extensions/matrix/src/directory-live.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a70f2946f..4898aa7e40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -239,7 +239,7 @@ Docs: https://docs.openclaw.ai - Doctor/State dir: suppress repeated legacy migration warnings only for valid symlink mirrors, while keeping warnings for empty or invalid legacy trees. (#11709) Thanks @gumadeiras. - Tests: harden flaky hotspots by removing timer sleeps, consolidating onboarding provider-auth coverage, and improving memory test realism. (#11598) Thanks @gumadeiras. - macOS: honor Nix-managed defaults suite (`ai.openclaw.mac`) for nixMode to prevent onboarding from reappearing after bundle-id churn. (#12205) Thanks @joshp123. -- Matrix: add multi-account support via `channels.matrix.accounts`; use per-account config for dm policy, allowFrom, groups, and other settings; serialize account startup to avoid race condition. (#3165, #3085) Thanks @emonty. +- Matrix: add multi-account support via `channels.matrix.accounts`; use per-account config for dm policy, allowFrom, groups, and other settings; serialize account startup to avoid race condition. (#7286, #3165, #3085) Thanks @emonty. ## 2026.2.6 diff --git a/extensions/matrix/src/channel.directory.test.ts b/extensions/matrix/src/channel.directory.test.ts index eb2aeacac7..a58bd76e94 100644 --- a/extensions/matrix/src/channel.directory.test.ts +++ b/extensions/matrix/src/channel.directory.test.ts @@ -1,9 +1,28 @@ import type { PluginRuntime } from "openclaw/plugin-sdk"; -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CoreConfig } from "./types.js"; import { matrixPlugin } from "./channel.js"; import { setMatrixRuntime } from "./runtime.js"; +vi.mock("@vector-im/matrix-bot-sdk", () => ({ + ConsoleLogger: class { + trace = vi.fn(); + debug = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + error = vi.fn(); + }, + MatrixClient: class {}, + LogService: { + setLogger: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, + SimpleFsStorageProvider: class {}, + RustSdkCryptoStorageProvider: class {}, +})); + describe("matrix directory", () => { beforeEach(() => { setMatrixRuntime({ @@ -61,4 +80,65 @@ describe("matrix directory", () => { ]), ); }); + + it("resolves replyToMode from account config", () => { + const cfg = { + channels: { + matrix: { + replyToMode: "off", + accounts: { + Assistant: { + replyToMode: "all", + }, + }, + }, + }, + } as unknown as CoreConfig; + + expect(matrixPlugin.threading?.resolveReplyToMode).toBeTruthy(); + expect( + matrixPlugin.threading?.resolveReplyToMode?.({ + cfg, + accountId: "assistant", + chatType: "direct", + }), + ).toBe("all"); + expect( + matrixPlugin.threading?.resolveReplyToMode?.({ + cfg, + accountId: "default", + chatType: "direct", + }), + ).toBe("off"); + }); + + it("resolves group mention policy from account config", () => { + const cfg = { + channels: { + matrix: { + groups: { + "!room:example.org": { requireMention: true }, + }, + accounts: { + Assistant: { + groups: { + "!room:example.org": { requireMention: false }, + }, + }, + }, + }, + }, + } as unknown as CoreConfig; + + expect(matrixPlugin.groups.resolveRequireMention({ cfg, groupId: "!room:example.org" })).toBe( + true, + ); + expect( + matrixPlugin.groups.resolveRequireMention({ + cfg, + accountId: "assistant", + groupId: "!room:example.org", + }), + ).toBe(false); + }); }); diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 0924a24154..dc2ff62284 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -19,6 +19,7 @@ import { } from "./group-mentions.js"; import { listMatrixAccountIds, + resolveMatrixAccountConfig, resolveDefaultMatrixAccountId, resolveMatrixAccount, type ResolvedMatrixAccount, @@ -146,8 +147,8 @@ export const matrixPlugin: ChannelPlugin = { baseUrl: account.homeserver, }), resolveAllowFrom: ({ cfg, accountId }) => { - const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }); - return (account.config.dm?.allowFrom ?? []).map((entry: string | number) => String(entry)); + const matrixConfig = resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }); + return (matrixConfig.dm?.allowFrom ?? []).map((entry: string | number) => String(entry)); }, formatAllowFrom: ({ allowFrom }) => normalizeMatrixAllowList(allowFrom), }, @@ -183,7 +184,8 @@ export const matrixPlugin: ChannelPlugin = { resolveToolPolicy: resolveMatrixGroupToolPolicy, }, threading: { - resolveReplyToMode: ({ cfg }) => (cfg as CoreConfig).channels?.matrix?.replyToMode ?? "off", + resolveReplyToMode: ({ cfg, accountId }) => + resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }).replyToMode ?? "off", buildToolContext: ({ context, hasRepliedRef }) => { const currentTarget = context.To; return { @@ -290,10 +292,10 @@ export const matrixPlugin: ChannelPlugin = { .map((id) => ({ kind: "group", id }) as const); return ids; }, - listPeersLive: async ({ cfg, query, limit }) => - listMatrixDirectoryPeersLive({ cfg, query, limit }), - listGroupsLive: async ({ cfg, query, limit }) => - listMatrixDirectoryGroupsLive({ cfg, query, limit }), + listPeersLive: async ({ cfg, accountId, query, limit }) => + listMatrixDirectoryPeersLive({ cfg, accountId, query, limit }), + listGroupsLive: async ({ cfg, accountId, query, limit }) => + listMatrixDirectoryGroupsLive({ cfg, accountId, query, limit }), }, resolver: { resolveTargets: async ({ cfg, inputs, kind, runtime }) => diff --git a/extensions/matrix/src/directory-live.test.ts b/extensions/matrix/src/directory-live.test.ts new file mode 100644 index 0000000000..3949c7565e --- /dev/null +++ b/extensions/matrix/src/directory-live.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js"; +import { resolveMatrixAuth } from "./matrix/client.js"; + +vi.mock("./matrix/client.js", () => ({ + resolveMatrixAuth: vi.fn(), +})); + +describe("matrix directory live", () => { + const cfg = { channels: { matrix: {} } }; + + beforeEach(() => { + vi.mocked(resolveMatrixAuth).mockReset(); + vi.mocked(resolveMatrixAuth).mockResolvedValue({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "test-token", + }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ results: [] }), + text: async () => "", + }), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("passes accountId to peer directory auth resolution", async () => { + await listMatrixDirectoryPeersLive({ + cfg, + accountId: "assistant", + query: "alice", + limit: 10, + }); + + expect(resolveMatrixAuth).toHaveBeenCalledWith({ cfg, accountId: "assistant" }); + }); + + it("passes accountId to group directory auth resolution", async () => { + await listMatrixDirectoryGroupsLive({ + cfg, + accountId: "assistant", + query: "!room:example.org", + limit: 10, + }); + + expect(resolveMatrixAuth).toHaveBeenCalledWith({ cfg, accountId: "assistant" }); + }); +}); diff --git a/extensions/matrix/src/directory-live.ts b/extensions/matrix/src/directory-live.ts index e43a7c099a..f06eb0be25 100644 --- a/extensions/matrix/src/directory-live.ts +++ b/extensions/matrix/src/directory-live.ts @@ -50,6 +50,7 @@ function normalizeQuery(value?: string | null): string { export async function listMatrixDirectoryPeersLive(params: { cfg: unknown; + accountId?: string | null; query?: string | null; limit?: number | null; }): Promise { @@ -57,7 +58,7 @@ export async function listMatrixDirectoryPeersLive(params: { if (!query) { return []; } - const auth = await resolveMatrixAuth({ cfg: params.cfg as never }); + const auth = await resolveMatrixAuth({ cfg: params.cfg as never, accountId: params.accountId }); const res = await fetchMatrixJson({ homeserver: auth.homeserver, accessToken: auth.accessToken, @@ -122,6 +123,7 @@ async function fetchMatrixRoomName( export async function listMatrixDirectoryGroupsLive(params: { cfg: unknown; + accountId?: string | null; query?: string | null; limit?: number | null; }): Promise { @@ -129,7 +131,7 @@ export async function listMatrixDirectoryGroupsLive(params: { if (!query) { return []; } - const auth = await resolveMatrixAuth({ cfg: params.cfg as never }); + const auth = await resolveMatrixAuth({ cfg: params.cfg as never, accountId: params.accountId }); const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20; if (query.startsWith("#")) { diff --git a/extensions/matrix/src/group-mentions.ts b/extensions/matrix/src/group-mentions.ts index d5b970021b..dd8c2bb7e7 100644 --- a/extensions/matrix/src/group-mentions.ts +++ b/extensions/matrix/src/group-mentions.ts @@ -1,5 +1,6 @@ import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk"; import type { CoreConfig } from "./types.js"; +import { resolveMatrixAccountConfig } from "./matrix/accounts.js"; import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js"; export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean { @@ -18,8 +19,9 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b const groupChannel = params.groupChannel?.trim() ?? ""; const aliases = groupChannel ? [groupChannel] : []; const cfg = params.cfg as CoreConfig; + const matrixConfig = resolveMatrixAccountConfig({ cfg, accountId: params.accountId }); const resolved = resolveMatrixRoomConfig({ - rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms, + rooms: matrixConfig.groups ?? matrixConfig.rooms, roomId, aliases, name: groupChannel || undefined, @@ -56,8 +58,9 @@ export function resolveMatrixGroupToolPolicy( const groupChannel = params.groupChannel?.trim() ?? ""; const aliases = groupChannel ? [groupChannel] : []; const cfg = params.cfg as CoreConfig; + const matrixConfig = resolveMatrixAccountConfig({ cfg, accountId: params.accountId }); const resolved = resolveMatrixRoomConfig({ - rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms, + rooms: matrixConfig.groups ?? matrixConfig.rooms, roomId, aliases, name: groupChannel || undefined, diff --git a/extensions/matrix/src/matrix/accounts.ts b/extensions/matrix/src/matrix/accounts.ts index 66cf2d903c..6fd3f2763f 100644 --- a/extensions/matrix/src/matrix/accounts.ts +++ b/extensions/matrix/src/matrix/accounts.ts @@ -86,16 +86,7 @@ export function resolveMatrixAccount(params: { }): ResolvedMatrixAccount { const accountId = normalizeAccountId(params.accountId); const matrixBase = params.cfg.channels?.matrix ?? {}; - - // Check if this account exists in accounts structure - const accountConfig = resolveAccountConfig(params.cfg, accountId); - - // Merge account-specific config with top-level defaults so settings like - // blockStreaming, groupPolicy, etc. inherit from channels.matrix when not - // overridden per account. - const base: MatrixConfig = accountConfig - ? mergeAccountConfig(matrixBase, accountConfig) - : matrixBase; + const base = resolveMatrixAccountConfig({ cfg: params.cfg, accountId }); const enabled = base.enabled !== false && matrixBase.enabled !== false; const resolved = resolveMatrixConfigForAccount(params.cfg, accountId, process.env); @@ -124,6 +115,21 @@ export function resolveMatrixAccount(params: { }; } +export function resolveMatrixAccountConfig(params: { + cfg: CoreConfig; + accountId?: string | null; +}): MatrixConfig { + const accountId = normalizeAccountId(params.accountId); + const matrixBase = params.cfg.channels?.matrix ?? {}; + const accountConfig = resolveAccountConfig(params.cfg, accountId); + if (!accountConfig) { + return matrixBase; + } + // Merge account-specific config with top-level defaults so settings like + // groupPolicy and blockStreaming inherit when not overridden. + return mergeAccountConfig(matrixBase, accountConfig); +} + export function listEnabledMatrixAccounts(cfg: CoreConfig): ResolvedMatrixAccount[] { return listMatrixAccountIds(cfg) .map((accountId) => resolveMatrixAccount({ cfg, accountId })) diff --git a/extensions/matrix/src/matrix/client/shared.ts b/extensions/matrix/src/matrix/client/shared.ts index 5c9a8a8df7..7134f754da 100644 --- a/extensions/matrix/src/matrix/client/shared.ts +++ b/extensions/matrix/src/matrix/client/shared.ts @@ -1,5 +1,6 @@ import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import { LogService } from "@vector-im/matrix-bot-sdk"; +import { normalizeAccountId } from "openclaw/plugin-sdk"; import type { CoreConfig } from "../../types.js"; import type { MatrixAuth } from "./types.js"; import { resolveMatrixAuth } from "./config.js"; @@ -19,12 +20,13 @@ const sharedClientPromises = new Map>() const sharedClientStartPromises = new Map>(); function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string { + const normalizedAccountId = normalizeAccountId(accountId); return [ auth.homeserver, auth.userId, auth.accessToken, auth.encryption ? "e2ee" : "plain", - accountId ?? DEFAULT_ACCOUNT_KEY, + normalizedAccountId || DEFAULT_ACCOUNT_KEY, ].join("|"); } @@ -103,10 +105,10 @@ export async function resolveSharedMatrixClient( accountId?: string | null; } = {}, ): Promise { + const accountId = normalizeAccountId(params.accountId); const auth = - params.auth ?? - (await resolveMatrixAuth({ cfg: params.cfg, env: params.env, accountId: params.accountId })); - const key = buildSharedClientKey(auth, params.accountId); + params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env, accountId })); + const key = buildSharedClientKey(auth, accountId); const shouldStart = params.startClient !== false; // Check if we already have a client for this key @@ -142,7 +144,7 @@ export async function resolveSharedMatrixClient( const createPromise = createSharedMatrixClient({ auth, timeoutMs: params.timeoutMs, - accountId: params.accountId, + accountId, }); sharedClientPromises.set(key, createPromise); try { @@ -194,6 +196,6 @@ export function stopSharedClient(key?: string): void { * to avoid stopping all accounts. */ export function stopSharedClientForAccount(auth: MatrixAuth, accountId?: string | null): void { - const key = buildSharedClientKey(auth, accountId); + const key = buildSharedClientKey(auth, normalizeAccountId(accountId)); stopSharedClient(key); } diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 03d8c1a95f..37c441bbe3 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -218,7 +218,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi ...cfg.channels?.matrix?.dm, allowFrom, }, - ...(groupAllowFrom.length > 0 ? { groupAllowFrom } : {}), + groupAllowFrom, ...(roomsConfig ? { groups: roomsConfig } : {}), }, }, diff --git a/extensions/matrix/src/matrix/send/client.ts b/extensions/matrix/src/matrix/send/client.ts index e37f557c6d..3564859b48 100644 --- a/extensions/matrix/src/matrix/send/client.ts +++ b/extensions/matrix/src/matrix/send/client.ts @@ -62,14 +62,18 @@ export async function resolveMatrixClient(opts: { if (opts.client) { return { client: opts.client, stopOnDone: false }; } + const accountId = + typeof opts.accountId === "string" && opts.accountId.trim().length > 0 + ? normalizeAccountId(opts.accountId) + : undefined; // Try to get the client for the specific account - const active = getActiveMatrixClient(opts.accountId); + const active = getActiveMatrixClient(accountId); if (active) { return { client: active, stopOnDone: false }; } // When no account is specified, try the default account first; only fall back to // any active client as a last resort (prevents sending from an arbitrary account). - if (!opts.accountId) { + if (!accountId) { const defaultClient = getActiveMatrixClient(DEFAULT_ACCOUNT_ID); if (defaultClient) { return { client: defaultClient, stopOnDone: false }; @@ -83,18 +87,18 @@ export async function resolveMatrixClient(opts: { if (shouldShareClient) { const client = await resolveSharedMatrixClient({ timeoutMs: opts.timeoutMs, - accountId: opts.accountId, + accountId, }); return { client, stopOnDone: false }; } - const auth = await resolveMatrixAuth({ accountId: opts.accountId }); + const auth = await resolveMatrixAuth({ accountId }); const client = await createMatrixClient({ homeserver: auth.homeserver, userId: auth.userId, accessToken: auth.accessToken, encryption: auth.encryption, localTimeoutMs: opts.timeoutMs, - accountId: opts.accountId, + accountId, }); if (auth.encryption && client.crypto) { try {