fix: harden matrix multi-account routing (#7286) (thanks @emonty)

This commit is contained in:
Peter Steinberger
2026-02-13 20:34:53 +01:00
parent a76ac1344e
commit 2b685b08c2
10 changed files with 188 additions and 35 deletions

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ import {
} from "./group-mentions.js";
import {
listMatrixAccountIds,
resolveMatrixAccountConfig,
resolveDefaultMatrixAccountId,
resolveMatrixAccount,
type ResolvedMatrixAccount,
@@ -146,8 +147,8 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
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<ResolvedMatrixAccount> = {
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<ResolvedMatrixAccount> = {
.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 }) =>

View File

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

View File

@@ -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<ChannelDirectoryEntry[]> {
@@ -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<MatrixUserDirectoryResponse>({
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<ChannelDirectoryEntry[]> {
@@ -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("#")) {

View File

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

View File

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

View File

@@ -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<string, Promise<SharedMatrixClientState>>()
const sharedClientStartPromises = new Map<string, Promise<void>>();
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<MatrixClient> {
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);
}

View File

@@ -218,7 +218,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
...cfg.channels?.matrix?.dm,
allowFrom,
},
...(groupAllowFrom.length > 0 ? { groupAllowFrom } : {}),
groupAllowFrom,
...(roomsConfig ? { groups: roomsConfig } : {}),
},
},

View File

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