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
+1 -1
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. - 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. - 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. - 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 ## 2026.2.6
@@ -1,9 +1,28 @@
import type { PluginRuntime } from "openclaw/plugin-sdk"; 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 type { CoreConfig } from "./types.js";
import { matrixPlugin } from "./channel.js"; import { matrixPlugin } from "./channel.js";
import { setMatrixRuntime } from "./runtime.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", () => { describe("matrix directory", () => {
beforeEach(() => { beforeEach(() => {
setMatrixRuntime({ 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);
});
}); });
+9 -7
View File
@@ -19,6 +19,7 @@ import {
} from "./group-mentions.js"; } from "./group-mentions.js";
import { import {
listMatrixAccountIds, listMatrixAccountIds,
resolveMatrixAccountConfig,
resolveDefaultMatrixAccountId, resolveDefaultMatrixAccountId,
resolveMatrixAccount, resolveMatrixAccount,
type ResolvedMatrixAccount, type ResolvedMatrixAccount,
@@ -146,8 +147,8 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
baseUrl: account.homeserver, baseUrl: account.homeserver,
}), }),
resolveAllowFrom: ({ cfg, accountId }) => { resolveAllowFrom: ({ cfg, accountId }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }); const matrixConfig = resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId });
return (account.config.dm?.allowFrom ?? []).map((entry: string | number) => String(entry)); return (matrixConfig.dm?.allowFrom ?? []).map((entry: string | number) => String(entry));
}, },
formatAllowFrom: ({ allowFrom }) => normalizeMatrixAllowList(allowFrom), formatAllowFrom: ({ allowFrom }) => normalizeMatrixAllowList(allowFrom),
}, },
@@ -183,7 +184,8 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
resolveToolPolicy: resolveMatrixGroupToolPolicy, resolveToolPolicy: resolveMatrixGroupToolPolicy,
}, },
threading: { threading: {
resolveReplyToMode: ({ cfg }) => (cfg as CoreConfig).channels?.matrix?.replyToMode ?? "off", resolveReplyToMode: ({ cfg, accountId }) =>
resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }).replyToMode ?? "off",
buildToolContext: ({ context, hasRepliedRef }) => { buildToolContext: ({ context, hasRepliedRef }) => {
const currentTarget = context.To; const currentTarget = context.To;
return { return {
@@ -290,10 +292,10 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
.map((id) => ({ kind: "group", id }) as const); .map((id) => ({ kind: "group", id }) as const);
return ids; return ids;
}, },
listPeersLive: async ({ cfg, query, limit }) => listPeersLive: async ({ cfg, accountId, query, limit }) =>
listMatrixDirectoryPeersLive({ cfg, query, limit }), listMatrixDirectoryPeersLive({ cfg, accountId, query, limit }),
listGroupsLive: async ({ cfg, query, limit }) => listGroupsLive: async ({ cfg, accountId, query, limit }) =>
listMatrixDirectoryGroupsLive({ cfg, query, limit }), listMatrixDirectoryGroupsLive({ cfg, accountId, query, limit }),
}, },
resolver: { resolver: {
resolveTargets: async ({ cfg, inputs, kind, runtime }) => resolveTargets: async ({ cfg, inputs, kind, runtime }) =>
@@ -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" });
});
});
+4 -2
View File
@@ -50,6 +50,7 @@ function normalizeQuery(value?: string | null): string {
export async function listMatrixDirectoryPeersLive(params: { export async function listMatrixDirectoryPeersLive(params: {
cfg: unknown; cfg: unknown;
accountId?: string | null;
query?: string | null; query?: string | null;
limit?: number | null; limit?: number | null;
}): Promise<ChannelDirectoryEntry[]> { }): Promise<ChannelDirectoryEntry[]> {
@@ -57,7 +58,7 @@ export async function listMatrixDirectoryPeersLive(params: {
if (!query) { if (!query) {
return []; 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>({ const res = await fetchMatrixJson<MatrixUserDirectoryResponse>({
homeserver: auth.homeserver, homeserver: auth.homeserver,
accessToken: auth.accessToken, accessToken: auth.accessToken,
@@ -122,6 +123,7 @@ async function fetchMatrixRoomName(
export async function listMatrixDirectoryGroupsLive(params: { export async function listMatrixDirectoryGroupsLive(params: {
cfg: unknown; cfg: unknown;
accountId?: string | null;
query?: string | null; query?: string | null;
limit?: number | null; limit?: number | null;
}): Promise<ChannelDirectoryEntry[]> { }): Promise<ChannelDirectoryEntry[]> {
@@ -129,7 +131,7 @@ export async function listMatrixDirectoryGroupsLive(params: {
if (!query) { if (!query) {
return []; 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; const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
if (query.startsWith("#")) { if (query.startsWith("#")) {
+5 -2
View File
@@ -1,5 +1,6 @@
import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk"; import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk";
import type { CoreConfig } from "./types.js"; import type { CoreConfig } from "./types.js";
import { resolveMatrixAccountConfig } from "./matrix/accounts.js";
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js"; import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean { export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean {
@@ -18,8 +19,9 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
const groupChannel = params.groupChannel?.trim() ?? ""; const groupChannel = params.groupChannel?.trim() ?? "";
const aliases = groupChannel ? [groupChannel] : []; const aliases = groupChannel ? [groupChannel] : [];
const cfg = params.cfg as CoreConfig; const cfg = params.cfg as CoreConfig;
const matrixConfig = resolveMatrixAccountConfig({ cfg, accountId: params.accountId });
const resolved = resolveMatrixRoomConfig({ const resolved = resolveMatrixRoomConfig({
rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms, rooms: matrixConfig.groups ?? matrixConfig.rooms,
roomId, roomId,
aliases, aliases,
name: groupChannel || undefined, name: groupChannel || undefined,
@@ -56,8 +58,9 @@ export function resolveMatrixGroupToolPolicy(
const groupChannel = params.groupChannel?.trim() ?? ""; const groupChannel = params.groupChannel?.trim() ?? "";
const aliases = groupChannel ? [groupChannel] : []; const aliases = groupChannel ? [groupChannel] : [];
const cfg = params.cfg as CoreConfig; const cfg = params.cfg as CoreConfig;
const matrixConfig = resolveMatrixAccountConfig({ cfg, accountId: params.accountId });
const resolved = resolveMatrixRoomConfig({ const resolved = resolveMatrixRoomConfig({
rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms, rooms: matrixConfig.groups ?? matrixConfig.rooms,
roomId, roomId,
aliases, aliases,
name: groupChannel || undefined, name: groupChannel || undefined,
+16 -10
View File
@@ -86,16 +86,7 @@ export function resolveMatrixAccount(params: {
}): ResolvedMatrixAccount { }): ResolvedMatrixAccount {
const accountId = normalizeAccountId(params.accountId); const accountId = normalizeAccountId(params.accountId);
const matrixBase = params.cfg.channels?.matrix ?? {}; const matrixBase = params.cfg.channels?.matrix ?? {};
const base = resolveMatrixAccountConfig({ cfg: params.cfg, accountId });
// 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 enabled = base.enabled !== false && matrixBase.enabled !== false; const enabled = base.enabled !== false && matrixBase.enabled !== false;
const resolved = resolveMatrixConfigForAccount(params.cfg, accountId, process.env); 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[] { export function listEnabledMatrixAccounts(cfg: CoreConfig): ResolvedMatrixAccount[] {
return listMatrixAccountIds(cfg) return listMatrixAccountIds(cfg)
.map((accountId) => resolveMatrixAccount({ cfg, accountId })) .map((accountId) => resolveMatrixAccount({ cfg, accountId }))
@@ -1,5 +1,6 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { LogService } 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 { CoreConfig } from "../../types.js";
import type { MatrixAuth } from "./types.js"; import type { MatrixAuth } from "./types.js";
import { resolveMatrixAuth } from "./config.js"; import { resolveMatrixAuth } from "./config.js";
@@ -19,12 +20,13 @@ const sharedClientPromises = new Map<string, Promise<SharedMatrixClientState>>()
const sharedClientStartPromises = new Map<string, Promise<void>>(); const sharedClientStartPromises = new Map<string, Promise<void>>();
function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string { function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string {
const normalizedAccountId = normalizeAccountId(accountId);
return [ return [
auth.homeserver, auth.homeserver,
auth.userId, auth.userId,
auth.accessToken, auth.accessToken,
auth.encryption ? "e2ee" : "plain", auth.encryption ? "e2ee" : "plain",
accountId ?? DEFAULT_ACCOUNT_KEY, normalizedAccountId || DEFAULT_ACCOUNT_KEY,
].join("|"); ].join("|");
} }
@@ -103,10 +105,10 @@ export async function resolveSharedMatrixClient(
accountId?: string | null; accountId?: string | null;
} = {}, } = {},
): Promise<MatrixClient> { ): Promise<MatrixClient> {
const accountId = normalizeAccountId(params.accountId);
const auth = const auth =
params.auth ?? params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env, accountId }));
(await resolveMatrixAuth({ cfg: params.cfg, env: params.env, accountId: params.accountId })); const key = buildSharedClientKey(auth, accountId);
const key = buildSharedClientKey(auth, params.accountId);
const shouldStart = params.startClient !== false; const shouldStart = params.startClient !== false;
// Check if we already have a client for this key // Check if we already have a client for this key
@@ -142,7 +144,7 @@ export async function resolveSharedMatrixClient(
const createPromise = createSharedMatrixClient({ const createPromise = createSharedMatrixClient({
auth, auth,
timeoutMs: params.timeoutMs, timeoutMs: params.timeoutMs,
accountId: params.accountId, accountId,
}); });
sharedClientPromises.set(key, createPromise); sharedClientPromises.set(key, createPromise);
try { try {
@@ -194,6 +196,6 @@ export function stopSharedClient(key?: string): void {
* to avoid stopping all accounts. * to avoid stopping all accounts.
*/ */
export function stopSharedClientForAccount(auth: MatrixAuth, accountId?: string | null): void { export function stopSharedClientForAccount(auth: MatrixAuth, accountId?: string | null): void {
const key = buildSharedClientKey(auth, accountId); const key = buildSharedClientKey(auth, normalizeAccountId(accountId));
stopSharedClient(key); stopSharedClient(key);
} }
@@ -218,7 +218,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
...cfg.channels?.matrix?.dm, ...cfg.channels?.matrix?.dm,
allowFrom, allowFrom,
}, },
...(groupAllowFrom.length > 0 ? { groupAllowFrom } : {}), groupAllowFrom,
...(roomsConfig ? { groups: roomsConfig } : {}), ...(roomsConfig ? { groups: roomsConfig } : {}),
}, },
}, },
+9 -5
View File
@@ -62,14 +62,18 @@ export async function resolveMatrixClient(opts: {
if (opts.client) { if (opts.client) {
return { client: opts.client, stopOnDone: false }; 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 // Try to get the client for the specific account
const active = getActiveMatrixClient(opts.accountId); const active = getActiveMatrixClient(accountId);
if (active) { if (active) {
return { client: active, stopOnDone: false }; return { client: active, stopOnDone: false };
} }
// When no account is specified, try the default account first; only fall back to // 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). // any active client as a last resort (prevents sending from an arbitrary account).
if (!opts.accountId) { if (!accountId) {
const defaultClient = getActiveMatrixClient(DEFAULT_ACCOUNT_ID); const defaultClient = getActiveMatrixClient(DEFAULT_ACCOUNT_ID);
if (defaultClient) { if (defaultClient) {
return { client: defaultClient, stopOnDone: false }; return { client: defaultClient, stopOnDone: false };
@@ -83,18 +87,18 @@ export async function resolveMatrixClient(opts: {
if (shouldShareClient) { if (shouldShareClient) {
const client = await resolveSharedMatrixClient({ const client = await resolveSharedMatrixClient({
timeoutMs: opts.timeoutMs, timeoutMs: opts.timeoutMs,
accountId: opts.accountId, accountId,
}); });
return { client, stopOnDone: false }; return { client, stopOnDone: false };
} }
const auth = await resolveMatrixAuth({ accountId: opts.accountId }); const auth = await resolveMatrixAuth({ accountId });
const client = await createMatrixClient({ const client = await createMatrixClient({
homeserver: auth.homeserver, homeserver: auth.homeserver,
userId: auth.userId, userId: auth.userId,
accessToken: auth.accessToken, accessToken: auth.accessToken,
encryption: auth.encryption, encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs, localTimeoutMs: opts.timeoutMs,
accountId: opts.accountId, accountId,
}); });
if (auth.encryption && client.crypto) { if (auth.encryption && client.crypto) {
try { try {