From 87834490e809d74ee922b0ceaeac78191a10db80 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 9 Mar 2026 12:59:12 -0400 Subject: [PATCH] Matrix: thread account-scoped target resolution --- extensions/matrix/src/channel.resolve.test.ts | 41 ++++ .../matrix/src/matrix/monitor/config.test.ts | 7 + .../matrix/src/matrix/monitor/config.ts | 11 ++ extensions/matrix/src/matrix/monitor/index.ts | 1 + .../matrix/src/matrix/monitor/route.test.ts | 180 ++++++++++++++++++ extensions/matrix/src/matrix/monitor/route.ts | 8 + .../matrix/src/onboarding.resolve.test.ts | 112 +++++++++++ extensions/matrix/src/onboarding.ts | 1 + extensions/matrix/src/resolve-targets.test.ts | 47 +++++ extensions/matrix/src/resolve-targets.ts | 3 + 10 files changed, 411 insertions(+) create mode 100644 extensions/matrix/src/channel.resolve.test.ts create mode 100644 extensions/matrix/src/matrix/monitor/route.test.ts create mode 100644 extensions/matrix/src/onboarding.resolve.test.ts diff --git a/extensions/matrix/src/channel.resolve.test.ts b/extensions/matrix/src/channel.resolve.test.ts new file mode 100644 index 00000000000..aff3b30119f --- /dev/null +++ b/extensions/matrix/src/channel.resolve.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const resolveMatrixTargetsMock = vi.hoisted(() => vi.fn(async () => [])); + +vi.mock("./resolve-targets.js", () => ({ + resolveMatrixTargets: resolveMatrixTargetsMock, +})); + +import { matrixPlugin } from "./channel.js"; + +describe("matrix resolver adapter", () => { + beforeEach(() => { + resolveMatrixTargetsMock.mockClear(); + }); + + it("forwards accountId into Matrix target resolution", async () => { + await matrixPlugin.resolver?.resolveTargets({ + cfg: { channels: { matrix: {} } }, + accountId: "ops", + inputs: ["Alice"], + kind: "user", + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }, + }); + + expect(resolveMatrixTargetsMock).toHaveBeenCalledWith({ + cfg: { channels: { matrix: {} } }, + accountId: "ops", + inputs: ["Alice"], + kind: "user", + runtime: expect.objectContaining({ + log: expect.any(Function), + error: expect.any(Function), + exit: expect.any(Function), + }), + }); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/config.test.ts b/extensions/matrix/src/matrix/monitor/config.test.ts index 259c0f9e99a..21d96a0abe4 100644 --- a/extensions/matrix/src/matrix/monitor/config.test.ts +++ b/extensions/matrix/src/matrix/monitor/config.test.ts @@ -51,6 +51,7 @@ describe("resolveMatrixMonitorConfig", () => { const result = await resolveMatrixMonitorConfig({ cfg: {} as CoreConfig, + accountId: "ops", allowFrom: ["matrix:@Alice:Example.org", "Bob"], groupAllowFrom: ["user:@Carol:Example.org"], roomsConfig, @@ -74,6 +75,7 @@ describe("resolveMatrixMonitorConfig", () => { expect(resolveTargets).toHaveBeenNthCalledWith( 1, expect.objectContaining({ + accountId: "ops", kind: "user", inputs: ["Bob"], }), @@ -81,6 +83,7 @@ describe("resolveMatrixMonitorConfig", () => { expect(resolveTargets).toHaveBeenNthCalledWith( 2, expect.objectContaining({ + accountId: "ops", kind: "group", inputs: ["General"], }), @@ -88,6 +91,7 @@ describe("resolveMatrixMonitorConfig", () => { expect(resolveTargets).toHaveBeenNthCalledWith( 3, expect.objectContaining({ + accountId: "ops", kind: "user", inputs: ["Dana"], }), @@ -107,6 +111,7 @@ describe("resolveMatrixMonitorConfig", () => { const result = await resolveMatrixMonitorConfig({ cfg: {} as CoreConfig, + accountId: "ops", allowFrom: ["user:Ghost"], groupAllowFrom: ["matrix:@known:example.org"], roomsConfig: { @@ -125,6 +130,7 @@ describe("resolveMatrixMonitorConfig", () => { expect(resolveTargets).toHaveBeenNthCalledWith( 1, expect.objectContaining({ + accountId: "ops", kind: "user", inputs: ["Ghost"], }), @@ -132,6 +138,7 @@ describe("resolveMatrixMonitorConfig", () => { expect(resolveTargets).toHaveBeenNthCalledWith( 2, expect.objectContaining({ + accountId: "ops", kind: "group", inputs: ["Project X"], }), diff --git a/extensions/matrix/src/matrix/monitor/config.ts b/extensions/matrix/src/matrix/monitor/config.ts index bc711b7fa51..247c71e5e25 100644 --- a/extensions/matrix/src/matrix/monitor/config.ts +++ b/extensions/matrix/src/matrix/monitor/config.ts @@ -61,6 +61,7 @@ function sanitizeMatrixRoomUserAllowlists(entries: MatrixRoomsConfig): MatrixRoo async function resolveMatrixMonitorUserEntries(params: { cfg: CoreConfig; + accountId?: string | null; entries: Array; runtime: RuntimeEnv; resolveTargets: ResolveMatrixTargetsFn; @@ -93,6 +94,7 @@ async function resolveMatrixMonitorUserEntries(params: { ? [] : await params.resolveTargets({ cfg: params.cfg, + accountId: params.accountId, inputs: pending.map((entry) => entry.query), kind: "user", runtime: params.runtime, @@ -115,6 +117,7 @@ async function resolveMatrixMonitorUserEntries(params: { async function resolveMatrixMonitorUserAllowlist(params: { cfg: CoreConfig; + accountId?: string | null; label: string; list?: Array; runtime: RuntimeEnv; @@ -127,6 +130,7 @@ async function resolveMatrixMonitorUserAllowlist(params: { const resolution = await resolveMatrixMonitorUserEntries({ cfg: params.cfg, + accountId: params.accountId, entries: allowList, runtime: params.runtime, resolveTargets: params.resolveTargets, @@ -148,6 +152,7 @@ async function resolveMatrixMonitorUserAllowlist(params: { async function resolveMatrixMonitorRoomsConfig(params: { cfg: CoreConfig; + accountId?: string | null; roomsConfig?: MatrixRoomsConfig; runtime: RuntimeEnv; resolveTargets: ResolveMatrixTargetsFn; @@ -193,6 +198,7 @@ async function resolveMatrixMonitorRoomsConfig(params: { if (pending.length > 0) { const resolved = await params.resolveTargets({ cfg: params.cfg, + accountId: params.accountId, inputs: pending.map((entry) => entry.query), kind: "group", runtime: params.runtime, @@ -231,6 +237,7 @@ async function resolveMatrixMonitorRoomsConfig(params: { const resolution = await resolveMatrixMonitorUserEntries({ cfg: params.cfg, + accountId: params.accountId, entries: Array.from(roomUsers), runtime: params.runtime, resolveTargets: params.resolveTargets, @@ -252,6 +259,7 @@ async function resolveMatrixMonitorRoomsConfig(params: { export async function resolveMatrixMonitorConfig(params: { cfg: CoreConfig; + accountId?: string | null; allowFrom?: Array; groupAllowFrom?: Array; roomsConfig?: MatrixRoomsConfig; @@ -267,6 +275,7 @@ export async function resolveMatrixMonitorConfig(params: { const [allowFrom, groupAllowFrom, roomsConfig] = await Promise.all([ resolveMatrixMonitorUserAllowlist({ cfg: params.cfg, + accountId: params.accountId, label: "matrix dm allowlist", list: params.allowFrom, runtime: params.runtime, @@ -274,6 +283,7 @@ export async function resolveMatrixMonitorConfig(params: { }), resolveMatrixMonitorUserAllowlist({ cfg: params.cfg, + accountId: params.accountId, label: "matrix group allowlist", list: params.groupAllowFrom, runtime: params.runtime, @@ -281,6 +291,7 @@ export async function resolveMatrixMonitorConfig(params: { }), resolveMatrixMonitorRoomsConfig({ cfg: params.cfg, + accountId: params.accountId, roomsConfig: params.roomsConfig, runtime: params.runtime, resolveTargets, diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 3f7bff38f40..e50f62aac2e 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -86,6 +86,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi ({ allowFrom, groupAllowFrom, roomsConfig } = await resolveMatrixMonitorConfig({ cfg, + accountId: effectiveAccountId, allowFrom, groupAllowFrom, roomsConfig, diff --git a/extensions/matrix/src/matrix/monitor/route.test.ts b/extensions/matrix/src/matrix/monitor/route.test.ts new file mode 100644 index 00000000000..b645c5c39c4 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/route.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../../../src/config/config.js"; +import { + __testing as sessionBindingTesting, + registerSessionBindingAdapter, +} from "../../../../../src/infra/outbound/session-binding-service.js"; +import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js"; +import { resolveMatrixInboundRoute } from "./route.js"; + +const baseCfg = { + session: { mainKey: "main" }, + agents: { + list: [{ id: "main" }, { id: "sender-agent" }, { id: "room-agent" }, { id: "acp-agent" }], + }, +} satisfies OpenClawConfig; + +function resolveDmRoute(cfg: OpenClawConfig) { + return resolveMatrixInboundRoute({ + cfg, + accountId: "ops", + roomId: "!dm:example.org", + senderId: "@alice:example.org", + isDirectMessage: true, + messageId: "$msg1", + resolveAgentRoute, + }); +} + +describe("resolveMatrixInboundRoute", () => { + beforeEach(() => { + sessionBindingTesting.resetSessionBindingAdaptersForTests(); + }); + + it("prefers sender-bound DM routing over DM room fallback bindings", () => { + const cfg = { + ...baseCfg, + bindings: [ + { + agentId: "room-agent", + match: { + channel: "matrix", + accountId: "ops", + peer: { kind: "channel", id: "!dm:example.org" }, + }, + }, + { + agentId: "sender-agent", + match: { + channel: "matrix", + accountId: "ops", + peer: { kind: "direct", id: "@alice:example.org" }, + }, + }, + ], + } satisfies OpenClawConfig; + + const { route, configuredBinding } = resolveDmRoute(cfg); + + expect(configuredBinding).toBeNull(); + expect(route.agentId).toBe("sender-agent"); + expect(route.matchedBy).toBe("binding.peer"); + expect(route.sessionKey).toBe("agent:sender-agent:main"); + }); + + it("uses the DM room as a parent-peer fallback before account-level bindings", () => { + const cfg = { + ...baseCfg, + bindings: [ + { + agentId: "acp-agent", + match: { + channel: "matrix", + accountId: "ops", + }, + }, + { + agentId: "room-agent", + match: { + channel: "matrix", + accountId: "ops", + peer: { kind: "channel", id: "!dm:example.org" }, + }, + }, + ], + } satisfies OpenClawConfig; + + const { route, configuredBinding } = resolveDmRoute(cfg); + + expect(configuredBinding).toBeNull(); + expect(route.agentId).toBe("room-agent"); + expect(route.matchedBy).toBe("binding.peer.parent"); + expect(route.sessionKey).toBe("agent:room-agent:main"); + }); + + it("lets configured ACP room bindings override DM parent-peer routing", () => { + const cfg = { + ...baseCfg, + bindings: [ + { + agentId: "room-agent", + match: { + channel: "matrix", + accountId: "ops", + peer: { kind: "channel", id: "!dm:example.org" }, + }, + }, + { + type: "acp", + agentId: "acp-agent", + match: { + channel: "matrix", + accountId: "ops", + peer: { kind: "channel", id: "!dm:example.org" }, + }, + }, + ], + } satisfies OpenClawConfig; + + const { route, configuredBinding } = resolveDmRoute(cfg); + + expect(configuredBinding?.spec.agentId).toBe("acp-agent"); + expect(route.agentId).toBe("acp-agent"); + expect(route.matchedBy).toBe("binding.channel"); + expect(route.sessionKey).toContain("agent:acp-agent:acp:binding:matrix:ops:"); + }); + + it("lets runtime conversation bindings override both sender and room route matches", () => { + registerSessionBindingAdapter({ + channel: "matrix", + accountId: "ops", + listBySession: () => [], + resolveByConversation: (ref) => + ref.conversationId === "!dm:example.org" + ? { + bindingId: "ops:!dm:example.org", + targetSessionKey: "agent:bound:session-1", + targetKind: "session", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "!dm:example.org", + }, + status: "active", + boundAt: Date.now(), + metadata: { boundBy: "user-1" }, + } + : null, + touch: vi.fn(), + }); + + const cfg = { + ...baseCfg, + bindings: [ + { + agentId: "sender-agent", + match: { + channel: "matrix", + accountId: "ops", + peer: { kind: "direct", id: "@alice:example.org" }, + }, + }, + { + agentId: "room-agent", + match: { + channel: "matrix", + accountId: "ops", + peer: { kind: "channel", id: "!dm:example.org" }, + }, + }, + ], + } satisfies OpenClawConfig; + + const { route, configuredBinding } = resolveDmRoute(cfg); + + expect(configuredBinding).toBeNull(); + expect(route.agentId).toBe("bound"); + expect(route.matchedBy).toBe("binding.channel"); + expect(route.sessionKey).toBe("agent:bound:session-1"); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/route.ts b/extensions/matrix/src/matrix/monitor/route.ts index 11eb5077ecc..e3a16b61ebd 100644 --- a/extensions/matrix/src/matrix/monitor/route.ts +++ b/extensions/matrix/src/matrix/monitor/route.ts @@ -30,6 +30,14 @@ export function resolveMatrixInboundRoute(params: { kind: params.isDirectMessage ? "direct" : "channel", id: params.isDirectMessage ? params.senderId : params.roomId, }, + // Matrix DMs are still sender-addressed first, but the room ID remains a + // useful fallback binding key for generic route matching. + parentPeer: params.isDirectMessage + ? { + kind: "channel", + id: params.roomId, + } + : undefined, }); const bindingConversationId = params.threadRootId && params.threadRootId !== params.messageId diff --git a/extensions/matrix/src/onboarding.resolve.test.ts b/extensions/matrix/src/onboarding.resolve.test.ts new file mode 100644 index 00000000000..f1d610aa5d4 --- /dev/null +++ b/extensions/matrix/src/onboarding.resolve.test.ts @@ -0,0 +1,112 @@ +import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/matrix"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CoreConfig } from "./types.js"; + +const resolveMatrixTargetsMock = vi.hoisted(() => + vi.fn(async () => [{ input: "Alice", resolved: true, id: "@alice:example.org" }]), +); + +vi.mock("./resolve-targets.js", () => ({ + resolveMatrixTargets: resolveMatrixTargetsMock, +})); + +import { matrixOnboardingAdapter } from "./onboarding.js"; +import { setMatrixRuntime } from "./runtime.js"; + +describe("matrix onboarding account-scoped resolution", () => { + beforeEach(() => { + setMatrixRuntime({ + state: { + resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) => + (homeDir ?? (() => "/tmp"))(), + }, + config: { + loadConfig: () => ({}), + }, + } as never); + resolveMatrixTargetsMock.mockClear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("passes accountId into Matrix allowlist target resolution during onboarding", async () => { + const prompter = { + note: vi.fn(async () => {}), + select: vi.fn(async ({ message }: { message: string }) => { + if (message === "Matrix already configured. What do you want to do?") { + return "add-account"; + } + if (message === "Matrix auth method") { + return "token"; + } + if (message === "Matrix rooms access") { + return "allowlist"; + } + throw new Error(`unexpected select prompt: ${message}`); + }), + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Matrix account name") { + return "ops"; + } + if (message === "Matrix homeserver URL") { + return "https://matrix.ops.example.org"; + } + if (message === "Matrix access token") { + return "ops-token"; + } + if (message === "Matrix device name (optional)") { + return ""; + } + if (message === "Matrix allowFrom (full @user:server; display name only if unique)") { + return "Alice"; + } + if (message === "Matrix rooms allowlist (comma-separated)") { + return ""; + } + throw new Error(`unexpected text prompt: ${message}`); + }), + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Enable end-to-end encryption (E2EE)?") { + return false; + } + if (message === "Configure Matrix rooms access?") { + return true; + } + return false; + }), + } as unknown as WizardPrompter; + + const result = await matrixOnboardingAdapter.configureInteractive!({ + cfg: { + channels: { + matrix: { + accounts: { + default: { + homeserver: "https://matrix.main.example.org", + accessToken: "main-token", + }, + }, + }, + }, + } as CoreConfig, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv, + prompter, + options: undefined, + accountOverrides: {}, + shouldPromptAccountIds: true, + forceAllowFrom: true, + configured: true, + label: "Matrix", + }); + + expect(result).not.toBe("skip"); + expect(resolveMatrixTargetsMock).toHaveBeenCalledWith({ + cfg: expect.any(Object), + accountId: "ops", + inputs: ["Alice"], + kind: "user", + }); + }); +}); diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts index f3a0c9b11c8..00e6e952fc0 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/onboarding.ts @@ -119,6 +119,7 @@ async function promptMatrixAllowFrom(params: { if (pending.length > 0) { const results = await resolveMatrixTargets({ cfg, + accountId, inputs: pending, kind: "user", }).catch(() => []); diff --git a/extensions/matrix/src/resolve-targets.test.ts b/extensions/matrix/src/resolve-targets.test.ts index 61ca1306fb7..22f5cddabdc 100644 --- a/extensions/matrix/src/resolve-targets.test.ts +++ b/extensions/matrix/src/resolve-targets.test.ts @@ -28,6 +28,12 @@ describe("resolveMatrixTargets (users)", () => { expect(result?.resolved).toBe(true); expect(result?.id).toBe("@alice:example.org"); + expect(listMatrixDirectoryPeersLive).toHaveBeenCalledWith({ + cfg: {}, + accountId: undefined, + query: "Alice", + limit: 5, + }); }); it("does not resolve ambiguous or non-exact matches", async () => { @@ -63,6 +69,47 @@ describe("resolveMatrixTargets (users)", () => { expect(result?.resolved).toBe(true); expect(result?.id).toBe("!two:example.org"); expect(result?.note).toBeUndefined(); + expect(listMatrixDirectoryGroupsLive).toHaveBeenCalledWith({ + cfg: {}, + accountId: undefined, + query: "#team", + limit: 5, + }); + }); + + it("threads accountId into live Matrix target lookups", async () => { + vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue([ + { kind: "user", id: "@alice:example.org", name: "Alice" }, + ]); + vi.mocked(listMatrixDirectoryGroupsLive).mockResolvedValue([ + { kind: "group", id: "!team:example.org", name: "Team", handle: "#team" }, + ]); + + await resolveMatrixTargets({ + cfg: {}, + accountId: "ops", + inputs: ["Alice"], + kind: "user", + }); + await resolveMatrixTargets({ + cfg: {}, + accountId: "ops", + inputs: ["#team"], + kind: "group", + }); + + expect(listMatrixDirectoryPeersLive).toHaveBeenCalledWith({ + cfg: {}, + accountId: "ops", + query: "Alice", + limit: 5, + }); + expect(listMatrixDirectoryGroupsLive).toHaveBeenCalledWith({ + cfg: {}, + accountId: "ops", + query: "#team", + limit: 5, + }); }); it("reuses directory lookups for normalized duplicate inputs", async () => { diff --git a/extensions/matrix/src/resolve-targets.ts b/extensions/matrix/src/resolve-targets.ts index 823afb09f1d..471d9e7f33a 100644 --- a/extensions/matrix/src/resolve-targets.ts +++ b/extensions/matrix/src/resolve-targets.ts @@ -99,6 +99,7 @@ async function readCachedMatches( export async function resolveMatrixTargets(params: { cfg: unknown; + accountId?: string | null; inputs: string[]; kind: ChannelResolveKind; runtime?: RuntimeEnv; @@ -123,6 +124,7 @@ export async function resolveMatrixTargets(params: { const matches = await readCachedMatches(userLookupCache, trimmed, (query) => listMatrixDirectoryPeersLive({ cfg: params.cfg, + accountId: params.accountId, query, limit: 5, }), @@ -150,6 +152,7 @@ export async function resolveMatrixTargets(params: { const matches = await readCachedMatches(groupLookupCache, trimmed, (query) => listMatrixDirectoryGroupsLive({ cfg: params.cfg, + accountId: params.accountId, query, limit: 5, }),