From 7db294ff439dbd9b83bc275b41a1da5c93c72e12 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 9 Mar 2026 04:38:54 -0400 Subject: [PATCH] Matrix: extract startup maintenance flow --- extensions/matrix/src/matrix/monitor/index.ts | 140 ++----------- .../matrix/src/matrix/monitor/startup.test.ts | 188 ++++++++++++++++++ .../matrix/src/matrix/monitor/startup.ts | 159 +++++++++++++++ 3 files changed, 361 insertions(+), 126 deletions(-) create mode 100644 extensions/matrix/src/matrix/monitor/startup.test.ts create mode 100644 extensions/matrix/src/matrix/monitor/startup.ts diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index da0d8c99547..4379025ef09 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -19,18 +19,14 @@ import { resolveSharedMatrixClient, stopSharedClientForAccount, } from "../client.js"; -import { updateMatrixAccountConfig } from "../config-update.js"; -import { summarizeMatrixDeviceHealth } from "../device-health.js"; -import { syncMatrixOwnProfile } from "../profile.js"; import { createMatrixThreadBindingManager } from "../thread-bindings.js"; import { registerMatrixAutoJoin } from "./auto-join.js"; import { resolveMatrixMonitorConfig } from "./config.js"; import { createDirectRoomTracker } from "./direct.js"; import { registerMatrixMonitorEvents } from "./events.js"; import { createMatrixRoomMessageHandler } from "./handler.js"; -import { maybeRestoreLegacyMatrixBackup } from "./legacy-crypto-restore.js"; import { createMatrixRoomInfoResolver } from "./room-info.js"; -import { ensureMatrixStartupVerification } from "./startup-verification.js"; +import { runMatrixStartupMaintenance } from "./startup.js"; export type MonitorMatrixOpts = { runtime?: RuntimeEnv; @@ -235,127 +231,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi // Shared client is already started via resolveSharedMatrixClient. logger.info(`matrix: logged in as ${auth.userId}`); - try { - const profileSync = await syncMatrixOwnProfile({ - client, - userId: auth.userId, - displayName: accountConfig.name, - avatarUrl: accountConfig.avatarUrl, - loadAvatarFromUrl: async (url, maxBytes) => await core.media.loadWebMedia(url, maxBytes), - }); - if (profileSync.displayNameUpdated) { - logger.info(`matrix: profile display name updated for ${auth.userId}`); - } - if (profileSync.avatarUpdated) { - logger.info(`matrix: profile avatar updated for ${auth.userId}`); - } - if ( - profileSync.convertedAvatarFromHttp && - profileSync.resolvedAvatarUrl && - accountConfig.avatarUrl !== profileSync.resolvedAvatarUrl - ) { - const latestCfg = core.config.loadConfig() as CoreConfig; - const updatedCfg = updateMatrixAccountConfig(latestCfg, account.accountId, { - avatarUrl: profileSync.resolvedAvatarUrl, - }); - await core.config.writeConfigFile(updatedCfg as never); - logVerboseMessage( - `matrix: persisted converted avatar URL for account ${account.accountId} (${profileSync.resolvedAvatarUrl})`, - ); - } - } catch (err) { - logger.warn("matrix: failed to sync profile from config", { error: String(err) }); - } - - // If E2EE is enabled, report device verification status and request self-verification - // when configured and the device is still unverified. - if (auth.encryption && client.crypto) { - try { - const deviceHealth = summarizeMatrixDeviceHealth(await client.listOwnDevices()); - if (deviceHealth.staleOpenClawDevices.length > 0) { - logger.warn( - `matrix: stale OpenClaw devices detected for ${auth.userId}: ${deviceHealth.staleOpenClawDevices.map((device) => device.deviceId).join(", ")}. Run 'openclaw matrix devices prune-stale --account ${effectiveAccountId}' to keep encrypted-room trust healthy.`, - ); - } - } catch (err) { - logger.debug?.("Failed to inspect matrix device hygiene (non-fatal)", { - error: String(err), - }); - } - - try { - const startupVerification = await ensureMatrixStartupVerification({ - client, - auth, - accountConfig, - env: process.env, - }); - if (startupVerification.kind === "verified") { - logger.info("matrix: device is verified by its owner and ready for encrypted rooms"); - } else if ( - startupVerification.kind === "disabled" || - startupVerification.kind === "cooldown" || - startupVerification.kind === "pending" || - startupVerification.kind === "request-failed" - ) { - logger.info( - "matrix: device not verified — run 'openclaw matrix verify device ' to enable E2EE", - ); - if (startupVerification.kind === "pending") { - logger.info( - "matrix: startup verification request is already pending; finish it in another Matrix client", - ); - } else if (startupVerification.kind === "cooldown") { - logVerboseMessage( - `matrix: skipped startup verification request due to cooldown (retryAfterMs=${startupVerification.retryAfterMs ?? 0})`, - ); - } else if (startupVerification.kind === "request-failed") { - logger.debug?.("Matrix startup verification request failed (non-fatal)", { - error: startupVerification.error ?? "unknown", - }); - } - } else if (startupVerification.kind === "requested") { - logger.info( - "matrix: device not verified — requested verification in another Matrix client", - ); - } - } catch (err) { - logger.debug?.("Failed to resolve matrix verification status (non-fatal)", { - error: String(err), - }); - } - - try { - const legacyCryptoRestore = await maybeRestoreLegacyMatrixBackup({ - client, - auth, - env: process.env, - }); - if (legacyCryptoRestore.kind === "restored") { - logger.info( - `matrix: restored ${legacyCryptoRestore.imported}/${legacyCryptoRestore.total} room key(s) from legacy encrypted-state backup`, - ); - if (legacyCryptoRestore.localOnlyKeys > 0) { - logger.warn( - `matrix: ${legacyCryptoRestore.localOnlyKeys} legacy local-only room key(s) were never backed up and could not be restored automatically`, - ); - } - } else if (legacyCryptoRestore.kind === "failed") { - logger.warn( - `matrix: failed restoring room keys from legacy encrypted-state backup: ${legacyCryptoRestore.error}`, - ); - if (legacyCryptoRestore.localOnlyKeys > 0) { - logger.warn( - `matrix: ${legacyCryptoRestore.localOnlyKeys} legacy local-only room key(s) were never backed up and may remain unavailable until manually recovered`, - ); - } - } - } catch (err) { - logger.warn("matrix: failed restoring legacy encrypted-state backup", { - error: String(err), - }); - } - } + await runMatrixStartupMaintenance({ + client, + auth, + accountId: account.accountId, + effectiveAccountId, + accountConfig, + logger, + logVerboseMessage, + loadConfig: () => core.config.loadConfig() as CoreConfig, + writeConfigFile: async (nextCfg) => await core.config.writeConfigFile(nextCfg), + loadWebMedia: async (url, maxBytes) => await core.media.loadWebMedia(url, maxBytes), + env: process.env, + }); await new Promise((resolve) => { const onAbort = () => { diff --git a/extensions/matrix/src/matrix/monitor/startup.test.ts b/extensions/matrix/src/matrix/monitor/startup.test.ts new file mode 100644 index 00000000000..d093dfff121 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/startup.test.ts @@ -0,0 +1,188 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { runMatrixStartupMaintenance } from "./startup.js"; + +const hoisted = vi.hoisted(() => ({ + maybeRestoreLegacyMatrixBackup: vi.fn(async () => ({ kind: "skipped" as const })), + summarizeMatrixDeviceHealth: vi.fn(() => ({ + staleOpenClawDevices: [] as Array<{ deviceId: string }>, + })), + syncMatrixOwnProfile: vi.fn(async () => ({ + skipped: false, + displayNameUpdated: false, + avatarUpdated: false, + resolvedAvatarUrl: null, + uploadedAvatarSource: null, + convertedAvatarFromHttp: false, + })), + ensureMatrixStartupVerification: vi.fn(async () => ({ kind: "verified" as const })), + updateMatrixAccountConfig: vi.fn((cfg: unknown) => cfg), +})); + +vi.mock("../config-update.js", () => ({ + updateMatrixAccountConfig: (...args: unknown[]) => hoisted.updateMatrixAccountConfig(...args), +})); + +vi.mock("../device-health.js", () => ({ + summarizeMatrixDeviceHealth: (...args: unknown[]) => hoisted.summarizeMatrixDeviceHealth(...args), +})); + +vi.mock("../profile.js", () => ({ + syncMatrixOwnProfile: (...args: unknown[]) => hoisted.syncMatrixOwnProfile(...args), +})); + +vi.mock("./legacy-crypto-restore.js", () => ({ + maybeRestoreLegacyMatrixBackup: (...args: unknown[]) => + hoisted.maybeRestoreLegacyMatrixBackup(...args), +})); + +vi.mock("./startup-verification.js", () => ({ + ensureMatrixStartupVerification: (...args: unknown[]) => + hoisted.ensureMatrixStartupVerification(...args), +})); + +describe("runMatrixStartupMaintenance", () => { + beforeEach(() => { + hoisted.maybeRestoreLegacyMatrixBackup.mockClear().mockResolvedValue({ kind: "skipped" }); + hoisted.summarizeMatrixDeviceHealth.mockClear().mockReturnValue({ staleOpenClawDevices: [] }); + hoisted.syncMatrixOwnProfile.mockClear().mockResolvedValue({ + skipped: false, + displayNameUpdated: false, + avatarUpdated: false, + resolvedAvatarUrl: null, + uploadedAvatarSource: null, + convertedAvatarFromHttp: false, + }); + hoisted.ensureMatrixStartupVerification.mockClear().mockResolvedValue({ kind: "verified" }); + hoisted.updateMatrixAccountConfig.mockClear().mockImplementation((cfg: unknown) => cfg); + }); + + function createParams() { + return { + client: { + crypto: {}, + listOwnDevices: vi.fn(async () => []), + } as never, + auth: { + accountId: "ops", + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", + encryption: false, + }, + accountId: "ops", + effectiveAccountId: "ops", + accountConfig: { + name: "Ops Bot", + avatarUrl: "https://example.org/avatar.png", + }, + logger: { + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }, + logVerboseMessage: vi.fn(), + loadConfig: vi.fn(() => ({ channels: { matrix: {} } })), + writeConfigFile: vi.fn(async () => {}), + loadWebMedia: vi.fn(async () => ({ + buffer: Buffer.from("avatar"), + contentType: "image/png", + fileName: "avatar.png", + })), + env: {}, + } as const; + } + + it("persists converted avatar URLs after profile sync", async () => { + const params = createParams(); + const updatedCfg = { channels: { matrix: { avatarUrl: "mxc://avatar" } } }; + hoisted.syncMatrixOwnProfile.mockResolvedValue({ + skipped: false, + displayNameUpdated: false, + avatarUpdated: true, + resolvedAvatarUrl: "mxc://avatar", + uploadedAvatarSource: "http", + convertedAvatarFromHttp: true, + }); + hoisted.updateMatrixAccountConfig.mockReturnValue(updatedCfg); + + await runMatrixStartupMaintenance(params); + + expect(hoisted.syncMatrixOwnProfile).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "@bot:example.org", + displayName: "Ops Bot", + avatarUrl: "https://example.org/avatar.png", + }), + ); + expect(hoisted.updateMatrixAccountConfig).toHaveBeenCalledWith( + { channels: { matrix: {} } }, + "ops", + { avatarUrl: "mxc://avatar" }, + ); + expect(params.writeConfigFile).toHaveBeenCalledWith(updatedCfg as never); + expect(params.logVerboseMessage).toHaveBeenCalledWith( + "matrix: persisted converted avatar URL for account ops (mxc://avatar)", + ); + }); + + it("reports stale devices, pending verification, and restored legacy backups", async () => { + const params = createParams(); + params.auth.encryption = true; + hoisted.summarizeMatrixDeviceHealth.mockReturnValue({ + staleOpenClawDevices: [{ deviceId: "DEV123" }], + }); + hoisted.ensureMatrixStartupVerification.mockResolvedValue({ kind: "pending" }); + hoisted.maybeRestoreLegacyMatrixBackup.mockResolvedValue({ + kind: "restored", + imported: 2, + total: 3, + localOnlyKeys: 1, + }); + + await runMatrixStartupMaintenance(params); + + expect(params.logger.warn).toHaveBeenCalledWith( + "matrix: stale OpenClaw devices detected for @bot:example.org: DEV123. Run 'openclaw matrix devices prune-stale --account ops' to keep encrypted-room trust healthy.", + ); + expect(params.logger.info).toHaveBeenCalledWith( + "matrix: device not verified — run 'openclaw matrix verify device ' to enable E2EE", + ); + expect(params.logger.info).toHaveBeenCalledWith( + "matrix: startup verification request is already pending; finish it in another Matrix client", + ); + expect(params.logger.info).toHaveBeenCalledWith( + "matrix: restored 2/3 room key(s) from legacy encrypted-state backup", + ); + expect(params.logger.warn).toHaveBeenCalledWith( + "matrix: 1 legacy local-only room key(s) were never backed up and could not be restored automatically", + ); + }); + + it("logs cooldown and request-failure verification outcomes without throwing", async () => { + const params = createParams(); + params.auth.encryption = true; + hoisted.ensureMatrixStartupVerification.mockResolvedValueOnce({ + kind: "cooldown", + retryAfterMs: 321, + }); + + await runMatrixStartupMaintenance(params); + + expect(params.logVerboseMessage).toHaveBeenCalledWith( + "matrix: skipped startup verification request due to cooldown (retryAfterMs=321)", + ); + + hoisted.ensureMatrixStartupVerification.mockResolvedValueOnce({ + kind: "request-failed", + error: "boom", + }); + + await runMatrixStartupMaintenance(params); + + expect(params.logger.debug).toHaveBeenCalledWith( + "Matrix startup verification request failed (non-fatal)", + { error: "boom" }, + ); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/startup.ts b/extensions/matrix/src/matrix/monitor/startup.ts new file mode 100644 index 00000000000..7aec135c3b1 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/startup.ts @@ -0,0 +1,159 @@ +import type { RuntimeLogger } from "openclaw/plugin-sdk/matrix"; +import type { CoreConfig, MatrixConfig } from "../../types.js"; +import type { MatrixAuth } from "../client.js"; +import { updateMatrixAccountConfig } from "../config-update.js"; +import { summarizeMatrixDeviceHealth } from "../device-health.js"; +import { syncMatrixOwnProfile } from "../profile.js"; +import type { MatrixClient } from "../sdk.js"; +import { maybeRestoreLegacyMatrixBackup } from "./legacy-crypto-restore.js"; +import { ensureMatrixStartupVerification } from "./startup-verification.js"; + +type MatrixStartupClient = Pick< + MatrixClient, + | "crypto" + | "getUserProfile" + | "listOwnDevices" + | "restoreRoomKeyBackup" + | "setAvatarUrl" + | "setDisplayName" + | "uploadContent" +>; + +export async function runMatrixStartupMaintenance(params: { + client: MatrixStartupClient; + auth: MatrixAuth; + accountId: string; + effectiveAccountId: string; + accountConfig: MatrixConfig; + logger: RuntimeLogger; + logVerboseMessage: (message: string) => void; + loadConfig: () => CoreConfig; + writeConfigFile: (cfg: never) => Promise; + loadWebMedia: ( + url: string, + maxBytes: number, + ) => Promise<{ buffer: Buffer; contentType?: string; fileName?: string }>; + env?: NodeJS.ProcessEnv; +}): Promise { + try { + const profileSync = await syncMatrixOwnProfile({ + client: params.client, + userId: params.auth.userId, + displayName: params.accountConfig.name, + avatarUrl: params.accountConfig.avatarUrl, + loadAvatarFromUrl: async (url, maxBytes) => await params.loadWebMedia(url, maxBytes), + }); + if (profileSync.displayNameUpdated) { + params.logger.info(`matrix: profile display name updated for ${params.auth.userId}`); + } + if (profileSync.avatarUpdated) { + params.logger.info(`matrix: profile avatar updated for ${params.auth.userId}`); + } + if ( + profileSync.convertedAvatarFromHttp && + profileSync.resolvedAvatarUrl && + params.accountConfig.avatarUrl !== profileSync.resolvedAvatarUrl + ) { + const latestCfg = params.loadConfig(); + const updatedCfg = updateMatrixAccountConfig(latestCfg, params.accountId, { + avatarUrl: profileSync.resolvedAvatarUrl, + }); + await params.writeConfigFile(updatedCfg as never); + params.logVerboseMessage( + `matrix: persisted converted avatar URL for account ${params.accountId} (${profileSync.resolvedAvatarUrl})`, + ); + } + } catch (err) { + params.logger.warn("matrix: failed to sync profile from config", { error: String(err) }); + } + + if (!(params.auth.encryption && params.client.crypto)) { + return; + } + + try { + const deviceHealth = summarizeMatrixDeviceHealth(await params.client.listOwnDevices()); + if (deviceHealth.staleOpenClawDevices.length > 0) { + params.logger.warn( + `matrix: stale OpenClaw devices detected for ${params.auth.userId}: ${deviceHealth.staleOpenClawDevices.map((device) => device.deviceId).join(", ")}. Run 'openclaw matrix devices prune-stale --account ${params.effectiveAccountId}' to keep encrypted-room trust healthy.`, + ); + } + } catch (err) { + params.logger.debug?.("Failed to inspect matrix device hygiene (non-fatal)", { + error: String(err), + }); + } + + try { + const startupVerification = await ensureMatrixStartupVerification({ + client: params.client, + auth: params.auth, + accountConfig: params.accountConfig, + env: params.env, + }); + if (startupVerification.kind === "verified") { + params.logger.info("matrix: device is verified by its owner and ready for encrypted rooms"); + } else if ( + startupVerification.kind === "disabled" || + startupVerification.kind === "cooldown" || + startupVerification.kind === "pending" || + startupVerification.kind === "request-failed" + ) { + params.logger.info( + "matrix: device not verified — run 'openclaw matrix verify device ' to enable E2EE", + ); + if (startupVerification.kind === "pending") { + params.logger.info( + "matrix: startup verification request is already pending; finish it in another Matrix client", + ); + } else if (startupVerification.kind === "cooldown") { + params.logVerboseMessage( + `matrix: skipped startup verification request due to cooldown (retryAfterMs=${startupVerification.retryAfterMs ?? 0})`, + ); + } else if (startupVerification.kind === "request-failed") { + params.logger.debug?.("Matrix startup verification request failed (non-fatal)", { + error: startupVerification.error ?? "unknown", + }); + } + } else if (startupVerification.kind === "requested") { + params.logger.info( + "matrix: device not verified — requested verification in another Matrix client", + ); + } + } catch (err) { + params.logger.debug?.("Failed to resolve matrix verification status (non-fatal)", { + error: String(err), + }); + } + + try { + const legacyCryptoRestore = await maybeRestoreLegacyMatrixBackup({ + client: params.client, + auth: params.auth, + env: params.env, + }); + if (legacyCryptoRestore.kind === "restored") { + params.logger.info( + `matrix: restored ${legacyCryptoRestore.imported}/${legacyCryptoRestore.total} room key(s) from legacy encrypted-state backup`, + ); + if (legacyCryptoRestore.localOnlyKeys > 0) { + params.logger.warn( + `matrix: ${legacyCryptoRestore.localOnlyKeys} legacy local-only room key(s) were never backed up and could not be restored automatically`, + ); + } + } else if (legacyCryptoRestore.kind === "failed") { + params.logger.warn( + `matrix: failed restoring room keys from legacy encrypted-state backup: ${legacyCryptoRestore.error}`, + ); + if (legacyCryptoRestore.localOnlyKeys > 0) { + params.logger.warn( + `matrix: ${legacyCryptoRestore.localOnlyKeys} legacy local-only room key(s) were never backed up and may remain unavailable until manually recovered`, + ); + } + } + } catch (err) { + params.logger.warn("matrix: failed restoring legacy encrypted-state backup", { + error: String(err), + }); + } +}