diff --git a/extensions/matrix/src/matrix/actions/verification.test.ts b/extensions/matrix/src/matrix/actions/verification.test.ts new file mode 100644 index 00000000000..2d1eb954cb1 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/verification.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const withResolvedActionClientMock = vi.fn(); +const loadConfigMock = vi.fn(() => ({ + channels: { + matrix: {}, + }, +})); + +vi.mock("../../runtime.js", () => ({ + getMatrixRuntime: () => ({ + config: { + loadConfig: loadConfigMock, + }, + }), +})); + +vi.mock("./client.js", () => ({ + withResolvedActionClient: (...args: unknown[]) => withResolvedActionClientMock(...args), +})); + +let listMatrixVerifications: typeof import("./verification.js").listMatrixVerifications; + +describe("matrix verification actions", () => { + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + loadConfigMock.mockReturnValue({ + channels: { + matrix: {}, + }, + }); + ({ listMatrixVerifications } = await import("./verification.js")); + }); + + it("points encryption guidance at the selected Matrix account", async () => { + loadConfigMock.mockReturnValue({ + channels: { + matrix: { + accounts: { + ops: { + encryption: false, + }, + }, + }, + }, + }); + withResolvedActionClientMock.mockImplementation(async (_opts, run) => { + return await run({ crypto: null }); + }); + + await expect(listMatrixVerifications({ accountId: "ops" })).rejects.toThrow( + "Matrix encryption is not available (enable channels.matrix.accounts.ops.encryption=true)", + ); + }); + + it("uses the resolved default Matrix account when accountId is omitted", async () => { + loadConfigMock.mockReturnValue({ + channels: { + matrix: { + defaultAccount: "ops", + accounts: { + ops: { + encryption: false, + }, + }, + }, + }, + }); + withResolvedActionClientMock.mockImplementation(async (_opts, run) => { + return await run({ crypto: null }); + }); + + await expect(listMatrixVerifications()).rejects.toThrow( + "Matrix encryption is not available (enable channels.matrix.accounts.ops.encryption=true)", + ); + }); +}); diff --git a/extensions/matrix/src/matrix/actions/verification.ts b/extensions/matrix/src/matrix/actions/verification.ts index 937b5992bb7..adad96e954d 100644 --- a/extensions/matrix/src/matrix/actions/verification.ts +++ b/extensions/matrix/src/matrix/actions/verification.ts @@ -1,11 +1,16 @@ +import { getMatrixRuntime } from "../../runtime.js"; +import type { CoreConfig } from "../../types.js"; +import { formatMatrixEncryptionUnavailableError } from "../encryption-guidance.js"; import { withResolvedActionClient } from "./client.js"; import type { MatrixActionClientOpts } from "./types.js"; function requireCrypto( client: import("../sdk.js").MatrixClient, + opts: MatrixActionClientOpts, ): NonNullable { if (!client.crypto) { - throw new Error("Matrix encryption is not available (enable channels.matrix.encryption=true)"); + const cfg = getMatrixRuntime().config.loadConfig() as CoreConfig; + throw new Error(formatMatrixEncryptionUnavailableError(cfg, opts.accountId)); } return client.crypto; } @@ -22,7 +27,7 @@ export async function listMatrixVerifications(opts: MatrixActionClientOpts = {}) return await withResolvedActionClient( { ...opts, readiness: "started" }, async (client) => { - const crypto = requireCrypto(client); + const crypto = requireCrypto(client, opts); return await crypto.listVerifications(); }, "persist", @@ -40,7 +45,7 @@ export async function requestMatrixVerification( return await withResolvedActionClient( { ...params, readiness: "started" }, async (client) => { - const crypto = requireCrypto(client); + const crypto = requireCrypto(client, params); const ownUser = params.ownUser ?? (!params.userId && !params.deviceId && !params.roomId); return await crypto.requestVerification({ ownUser, @@ -60,7 +65,7 @@ export async function acceptMatrixVerification( return await withResolvedActionClient( { ...opts, readiness: "started" }, async (client) => { - const crypto = requireCrypto(client); + const crypto = requireCrypto(client, opts); return await crypto.acceptVerification(resolveVerificationId(requestId)); }, "persist", @@ -74,7 +79,7 @@ export async function cancelMatrixVerification( return await withResolvedActionClient( { ...opts, readiness: "started" }, async (client) => { - const crypto = requireCrypto(client); + const crypto = requireCrypto(client, opts); return await crypto.cancelVerification(resolveVerificationId(requestId), { reason: opts.reason?.trim() || undefined, code: opts.code?.trim() || undefined, @@ -91,7 +96,7 @@ export async function startMatrixVerification( return await withResolvedActionClient( { ...opts, readiness: "started" }, async (client) => { - const crypto = requireCrypto(client); + const crypto = requireCrypto(client, opts); return await crypto.startVerification(resolveVerificationId(requestId), opts.method ?? "sas"); }, "persist", @@ -105,7 +110,7 @@ export async function generateMatrixVerificationQr( return await withResolvedActionClient( { ...opts, readiness: "started" }, async (client) => { - const crypto = requireCrypto(client); + const crypto = requireCrypto(client, opts); return await crypto.generateVerificationQr(resolveVerificationId(requestId)); }, "persist", @@ -120,7 +125,7 @@ export async function scanMatrixVerificationQr( return await withResolvedActionClient( { ...opts, readiness: "started" }, async (client) => { - const crypto = requireCrypto(client); + const crypto = requireCrypto(client, opts); const payload = qrDataBase64.trim(); if (!payload) { throw new Error("Matrix QR data is required"); @@ -138,7 +143,7 @@ export async function getMatrixVerificationSas( return await withResolvedActionClient( { ...opts, readiness: "started" }, async (client) => { - const crypto = requireCrypto(client); + const crypto = requireCrypto(client, opts); return await crypto.getVerificationSas(resolveVerificationId(requestId)); }, "persist", @@ -152,7 +157,7 @@ export async function confirmMatrixVerificationSas( return await withResolvedActionClient( { ...opts, readiness: "started" }, async (client) => { - const crypto = requireCrypto(client); + const crypto = requireCrypto(client, opts); return await crypto.confirmVerificationSas(resolveVerificationId(requestId)); }, "persist", @@ -166,7 +171,7 @@ export async function mismatchMatrixVerificationSas( return await withResolvedActionClient( { ...opts, readiness: "started" }, async (client) => { - const crypto = requireCrypto(client); + const crypto = requireCrypto(client, opts); return await crypto.mismatchVerificationSas(resolveVerificationId(requestId)); }, "persist", @@ -180,7 +185,7 @@ export async function confirmMatrixVerificationReciprocateQr( return await withResolvedActionClient( { ...opts, readiness: "started" }, async (client) => { - const crypto = requireCrypto(client); + const crypto = requireCrypto(client, opts); return await crypto.confirmVerificationReciprocateQr(resolveVerificationId(requestId)); }, "persist", @@ -193,7 +198,7 @@ export async function getMatrixEncryptionStatus( return await withResolvedActionClient( { ...opts, readiness: "started" }, async (client) => { - const crypto = requireCrypto(client); + const crypto = requireCrypto(client, opts); const recoveryKey = await crypto.getRecoveryKey(); return { encryptionEnabled: true, diff --git a/extensions/matrix/src/matrix/encryption-guidance.ts b/extensions/matrix/src/matrix/encryption-guidance.ts new file mode 100644 index 00000000000..ce6132cefd8 --- /dev/null +++ b/extensions/matrix/src/matrix/encryption-guidance.ts @@ -0,0 +1,27 @@ +import { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id"; +import type { CoreConfig } from "../types.js"; +import { resolveDefaultMatrixAccountId } from "./accounts.js"; +import { resolveMatrixConfigPath } from "./config-update.js"; + +export function resolveMatrixEncryptionConfigPath( + cfg: CoreConfig, + accountId?: string | null, +): string { + const effectiveAccountId = + normalizeOptionalAccountId(accountId) ?? resolveDefaultMatrixAccountId(cfg); + return `${resolveMatrixConfigPath(cfg, effectiveAccountId)}.encryption`; +} + +export function formatMatrixEncryptionUnavailableError( + cfg: CoreConfig, + accountId?: string | null, +): string { + return `Matrix encryption is not available (enable ${resolveMatrixEncryptionConfigPath(cfg, accountId)}=true)`; +} + +export function formatMatrixEncryptedEventDisabledWarning( + cfg: CoreConfig, + accountId?: string | null, +): string { + return `matrix: encrypted event received without encryption enabled; set ${resolveMatrixEncryptionConfigPath(cfg, accountId)}=true and verify the device to decrypt`; +} diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index 1287c9fd6e2..5e669a6c11e 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import type { CoreConfig } from "../../types.js"; import type { MatrixAuth } from "../client.js"; import type { MatrixClient } from "../sdk.js"; import { registerMatrixMonitorEvents } from "./events.js"; @@ -14,6 +15,8 @@ function getSentNoticeBody(sendMessage: ReturnType, index = 0): st } function createHarness(params?: { + cfg?: CoreConfig; + accountId?: string; authEncryption?: boolean; cryptoAvailable?: boolean; verifications?: Array<{ @@ -50,9 +53,10 @@ function createHarness(params?: { } as unknown as MatrixClient; registerMatrixMonitorEvents({ + cfg: params?.cfg ?? { channels: { matrix: {} } }, client, auth: { - accountId: "default", + accountId: params?.accountId ?? "default", encryption: params?.authEncryption ?? true, } as MatrixAuth, logVerboseMessage: vi.fn(), @@ -268,6 +272,35 @@ describe("registerMatrixMonitorEvents verification routing", () => { ); }); + it("uses the active Matrix account path in encrypted-event warnings", () => { + const { logger, roomEventListener } = createHarness({ + accountId: "ops", + authEncryption: false, + cfg: { + channels: { + matrix: { + accounts: { + ops: {}, + }, + }, + }, + }, + }); + + roomEventListener("!room:example.org", { + event_id: "$enc1", + sender: "@alice:example.org", + type: EventType.RoomMessageEncrypted, + origin_server_ts: Date.now(), + content: {}, + }); + + expect(logger.warn).toHaveBeenCalledWith( + "matrix: encrypted event received without encryption enabled; set channels.matrix.accounts.ops.encryption=true and verify the device to decrypt", + { roomId: "!room:example.org" }, + ); + }); + it("warns once when crypto bindings are unavailable for encrypted rooms", () => { const { formatNativeDependencyHint, logger, roomEventListener } = createHarness({ authEncryption: true, diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts index d61916be60d..4020b5f7dcb 100644 --- a/extensions/matrix/src/matrix/monitor/events.ts +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -1,11 +1,14 @@ import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/matrix"; +import type { CoreConfig } from "../../types.js"; import type { MatrixAuth } from "../client.js"; +import { formatMatrixEncryptedEventDisabledWarning } from "../encryption-guidance.js"; import type { MatrixClient } from "../sdk.js"; import type { MatrixRawEvent } from "./types.js"; import { EventType } from "./types.js"; import { createMatrixVerificationEventRouter } from "./verification-events.js"; export function registerMatrixMonitorEvents(params: { + cfg: CoreConfig; client: MatrixClient; auth: MatrixAuth; logVerboseMessage: (message: string) => void; @@ -16,6 +19,7 @@ export function registerMatrixMonitorEvents(params: { onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise; }): void { const { + cfg, client, auth, logVerboseMessage, @@ -85,8 +89,7 @@ export function registerMatrixMonitorEvents(params: { ); if (auth.encryption !== true && !warnedEncryptedRooms.has(roomId)) { warnedEncryptedRooms.add(roomId); - const warning = - "matrix: encrypted event received without encryption enabled; set channels.matrix.encryption=true and verify the device to decrypt"; + const warning = formatMatrixEncryptedEventDisabledWarning(cfg, auth.accountId); logger.warn(warning, { roomId }); } if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) { diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 4379025ef09..3f7bff38f40 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -208,6 +208,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi ); registerMatrixMonitorEvents({ + cfg, client, auth, logVerboseMessage,