From 6f78fe27d60286374fa387d46af2bc97d8bd8c51 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 9 Mar 2026 10:06:26 -0400 Subject: [PATCH] Matrix: add backup reset and fix migration targeting --- docs/channels/matrix.md | 9 ++ docs/install/migrating-matrix.md | 10 +- extensions/matrix/src/cli.test.ts | 102 +++++++++++++++++ extensions/matrix/src/cli.ts | 48 ++++++++ extensions/matrix/src/matrix/actions.ts | 1 + .../matrix/src/matrix/actions/verification.ts | 4 + extensions/matrix/src/matrix/sdk.test.ts | 84 ++++++++++++++ extensions/matrix/src/matrix/sdk.ts | 107 ++++++++++++++++++ src/infra/matrix-account-selection.ts | 58 ++++++++++ src/infra/matrix-legacy-crypto.test.ts | 76 +++++++++++++ src/infra/matrix-legacy-crypto.ts | 37 ++---- src/infra/matrix-legacy-state.test.ts | 47 ++++++++ src/infra/matrix-legacy-state.ts | 29 +---- 13 files changed, 560 insertions(+), 52 deletions(-) create mode 100644 src/infra/matrix-account-selection.ts diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 3bfea5a448a..18c1ff3c0ec 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -251,6 +251,12 @@ Verbose restore diagnostics: openclaw matrix verify backup restore --verbose ``` +Delete the current server backup and create a fresh backup baseline: + +```bash +openclaw matrix verify backup reset --yes +``` + All `verify` commands are concise by default (including quiet internal SDK logging) and show detailed diagnostics only with `--verbose`. Use `--json` for full machine-readable output when scripting. @@ -291,6 +297,9 @@ If the homeserver requires interactive auth to upload cross-signing keys, OpenCl Use `--force-reset-cross-signing` only when you intentionally want to discard the current cross-signing identity and create a new one. +If you intentionally want to discard the current room-key backup and start a new backup baseline for future messages, use `openclaw matrix verify backup reset --yes`. +Do this only when you accept that unrecoverable old encrypted history will stay unavailable. + ### Startup behavior When `encryption: true`, Matrix defaults `startupVerification` to `"if-unverified"`. diff --git a/docs/install/migrating-matrix.md b/docs/install/migrating-matrix.md index 70a8935213e..fb61c6bd9b8 100644 --- a/docs/install/migrating-matrix.md +++ b/docs/install/migrating-matrix.md @@ -93,7 +93,13 @@ If your old installation had local-only encrypted history that was never backed openclaw matrix verify device "" ``` -7. If no server-side key backup exists yet, create one for future recoveries: +7. If you are intentionally abandoning unrecoverable old history and want a fresh backup baseline for future messages, run: + + ```bash + openclaw matrix verify backup reset --yes + ``` + +8. If no server-side key backup exists yet, create one for future recoveries: ```bash openclaw matrix verify bootstrap @@ -202,6 +208,8 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins - Meaning: the stored key does not match the active Matrix backup. - What to do: rerun `openclaw matrix verify device ""` with the correct key. +If you accept losing unrecoverable old encrypted history, you can instead reset the current backup baseline with `openclaw matrix verify backup reset --yes`. + `Backup trust chain is not verified on this device. Re-run 'openclaw matrix verify device '.` - Meaning: the backup exists, but this device does not trust the cross-signing chain strongly enough yet. diff --git a/extensions/matrix/src/cli.test.ts b/extensions/matrix/src/cli.test.ts index bbcf2024f96..529635b79c5 100644 --- a/extensions/matrix/src/cli.test.ts +++ b/extensions/matrix/src/cli.test.ts @@ -14,6 +14,7 @@ const matrixSetupApplyAccountConfigMock = vi.fn(); const matrixSetupValidateInputMock = vi.fn(); const matrixRuntimeLoadConfigMock = vi.fn(); const matrixRuntimeWriteConfigFileMock = vi.fn(); +const resetMatrixRoomKeyBackupMock = vi.fn(); const restoreMatrixRoomKeyBackupMock = vi.fn(); const setMatrixSdkConsoleLoggingMock = vi.fn(); const setMatrixSdkLogModeMock = vi.fn(); @@ -24,6 +25,7 @@ vi.mock("./matrix/actions/verification.js", () => ({ bootstrapMatrixVerification: (...args: unknown[]) => bootstrapMatrixVerificationMock(...args), getMatrixRoomKeyBackupStatus: (...args: unknown[]) => getMatrixRoomKeyBackupStatusMock(...args), getMatrixVerificationStatus: (...args: unknown[]) => getMatrixVerificationStatusMock(...args), + resetMatrixRoomKeyBackup: (...args: unknown[]) => resetMatrixRoomKeyBackupMock(...args), restoreMatrixRoomKeyBackup: (...args: unknown[]) => restoreMatrixRoomKeyBackupMock(...args), verifyMatrixRecoveryKey: (...args: unknown[]) => verifyMatrixRecoveryKeyMock(...args), })); @@ -118,6 +120,21 @@ describe("matrix CLI verification commands", () => { pendingVerifications: 0, cryptoBootstrap: {}, }); + resetMatrixRoomKeyBackupMock.mockResolvedValue({ + success: true, + previousVersion: "1", + deletedVersion: "1", + createdVersion: "2", + backup: { + serverVersion: "2", + activeVersion: "2", + trusted: true, + matchesDecryptionKey: true, + decryptionKeyCached: true, + keyLoadAttempted: false, + keyLoadError: null, + }, + }); updateMatrixOwnProfileMock.mockResolvedValue({ skipped: false, displayNameUpdated: true, @@ -195,6 +212,32 @@ describe("matrix CLI verification commands", () => { expect(process.exitCode).toBe(1); }); + it("sets non-zero exit code for backup reset failures in JSON mode", async () => { + resetMatrixRoomKeyBackupMock.mockResolvedValue({ + success: false, + error: "reset failed", + previousVersion: "1", + deletedVersion: "1", + createdVersion: null, + backup: { + serverVersion: null, + activeVersion: null, + trusted: null, + matchesDecryptionKey: null, + decryptionKeyCached: null, + keyLoadAttempted: false, + keyLoadError: null, + }, + }); + const program = buildProgram(); + + await program.parseAsync(["matrix", "verify", "backup", "reset", "--yes", "--json"], { + from: "user", + }); + + expect(process.exitCode).toBe(1); + }); + it("lists matrix devices", async () => { listMatrixOwnDevicesMock.mockResolvedValue([ { @@ -732,6 +775,65 @@ describe("matrix CLI verification commands", () => { ); }); + it("includes backup reset guidance when the backup key does not match this device", async () => { + getMatrixVerificationStatusMock.mockResolvedValue({ + encryptionEnabled: true, + verified: true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + userId: "@bot:example.org", + deviceId: "DEVICE123", + backupVersion: "21868", + backup: { + serverVersion: "21868", + activeVersion: "21868", + trusted: true, + matchesDecryptionKey: false, + decryptionKeyCached: true, + keyLoadAttempted: false, + keyLoadError: null, + }, + recoveryKeyStored: true, + recoveryKeyCreatedAt: "2026-03-09T14:40:00.000Z", + pendingVerifications: 0, + }); + const program = buildProgram(); + + await program.parseAsync(["matrix", "verify", "status"], { from: "user" }); + + expect(console.log).toHaveBeenCalledWith( + "- If you want a fresh backup baseline and accept losing unrecoverable history, run 'openclaw matrix verify backup reset --yes'.", + ); + }); + + it("requires --yes before resetting the Matrix room-key backup", async () => { + const program = buildProgram(); + + await program.parseAsync(["matrix", "verify", "backup", "reset"], { from: "user" }); + + expect(process.exitCode).toBe(1); + expect(resetMatrixRoomKeyBackupMock).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + "Backup reset failed: Refusing to reset Matrix room-key backup without --yes", + ); + }); + + it("resets the Matrix room-key backup when confirmed", async () => { + const program = buildProgram(); + + await program.parseAsync(["matrix", "verify", "backup", "reset", "--yes"], { + from: "user", + }); + + expect(resetMatrixRoomKeyBackupMock).toHaveBeenCalledWith({ accountId: "default" }); + expect(console.log).toHaveBeenCalledWith("Reset success: yes"); + expect(console.log).toHaveBeenCalledWith("Previous backup version: 1"); + expect(console.log).toHaveBeenCalledWith("Deleted backup version: 1"); + expect(console.log).toHaveBeenCalledWith("Current backup version: 2"); + expect(console.log).toHaveBeenCalledWith("Backup: active and trusted on this device"); + }); + it("prints resolved account-aware guidance when a named Matrix account is selected implicitly", async () => { resolveMatrixAuthContextMock.mockImplementation( ({ cfg, accountId }: { cfg: unknown; accountId?: string | null }) => ({ diff --git a/extensions/matrix/src/cli.ts b/extensions/matrix/src/cli.ts index 191b5929382..504f4725976 100644 --- a/extensions/matrix/src/cli.ts +++ b/extensions/matrix/src/cli.ts @@ -12,6 +12,7 @@ import { bootstrapMatrixVerification, getMatrixRoomKeyBackupStatus, getMatrixVerificationStatus, + resetMatrixRoomKeyBackup, restoreMatrixRoomKeyBackup, verifyMatrixRecoveryKey, } from "./matrix/actions/verification.js"; @@ -572,10 +573,16 @@ function buildVerificationGuidance( nextSteps.add( `Backup key mismatch on this device. Re-run '${formatMatrixCliCommand("verify device ", accountId)}' with the matching recovery key.`, ); + nextSteps.add( + `If you want a fresh backup baseline and accept losing unrecoverable history, run '${formatMatrixCliCommand("verify backup reset --yes", accountId)}'.`, + ); } else if (backupIssue.code === "untrusted-signature") { nextSteps.add( `Backup trust chain is not verified on this device. Re-run '${formatMatrixCliCommand("verify device ", accountId)}'.`, ); + nextSteps.add( + `If you want a fresh backup baseline and accept losing unrecoverable history, run '${formatMatrixCliCommand("verify backup reset --yes", accountId)}'.`, + ); } else if (backupIssue.code === "indeterminate") { nextSteps.add( `Run '${formatMatrixCliCommand("verify status --verbose", accountId)}' to inspect backup trust diagnostics.`, @@ -828,6 +835,47 @@ export function registerMatrixCli(params: { program: Command }): void { }); }); + backup + .command("reset") + .description("Delete the current server backup and create a fresh room-key backup baseline") + .option("--account ", "Account ID (for multi-account setups)") + .option("--yes", "Confirm destructive backup reset", false) + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action( + async (options: { account?: string; yes?: boolean; verbose?: boolean; json?: boolean }) => { + const accountId = resolveMatrixCliAccountId(options.account); + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => { + if (options.yes !== true) { + throw new Error("Refusing to reset Matrix room-key backup without --yes"); + } + return await resetMatrixRoomKeyBackup({ accountId }); + }, + onText: (result, verbose) => { + printAccountLabel(accountId); + console.log(`Reset success: ${result.success ? "yes" : "no"}`); + if (result.error) { + console.log(`Error: ${result.error}`); + } + console.log(`Previous backup version: ${result.previousVersion ?? "none"}`); + console.log(`Deleted backup version: ${result.deletedVersion ?? "none"}`); + console.log(`Current backup version: ${result.createdVersion ?? "none"}`); + printBackupSummary(result.backup); + if (verbose) { + printTimestamp("Reset at", result.resetAt); + printBackupStatus(result.backup); + } + }, + shouldFail: (result) => !result.success, + errorPrefix: "Backup reset failed", + onJsonError: (message) => ({ success: false, error: message }), + }); + }, + ); + backup .command("restore") .description("Restore encrypted room keys from server backup") diff --git a/extensions/matrix/src/matrix/actions.ts b/extensions/matrix/src/matrix/actions.ts index b6661351864..d0d8b8810b3 100644 --- a/extensions/matrix/src/matrix/actions.ts +++ b/extensions/matrix/src/matrix/actions.ts @@ -28,6 +28,7 @@ export { listMatrixVerifications, mismatchMatrixVerificationSas, requestMatrixVerification, + resetMatrixRoomKeyBackup, restoreMatrixRoomKeyBackup, scanMatrixVerificationQr, startMatrixVerification, diff --git a/extensions/matrix/src/matrix/actions/verification.ts b/extensions/matrix/src/matrix/actions/verification.ts index f017d495930..2131a8ffe5f 100644 --- a/extensions/matrix/src/matrix/actions/verification.ts +++ b/extensions/matrix/src/matrix/actions/verification.ts @@ -215,6 +215,10 @@ export async function restoreMatrixRoomKeyBackup( ); } +export async function resetMatrixRoomKeyBackup(opts: MatrixActionClientOpts = {}) { + return await withStartedActionClient(opts, async (client) => await client.resetRoomKeyBackup()); +} + export async function bootstrapMatrixVerification( opts: MatrixActionClientOpts & { recoveryKey?: string; diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index 0c914c85db6..4dc22e9ce46 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -1342,6 +1342,90 @@ describe("MatrixClient crypto bootstrapping", () => { expect(result.backup.matchesDecryptionKey).toBe(false); }); + it("resets the current room-key backup and creates a fresh trusted version", async () => { + const checkKeyBackupAndEnable = vi.fn(async () => {}); + const bootstrapSecretStorage = vi.fn(async () => {}); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapSecretStorage, + checkKeyBackupAndEnable, + getActiveSessionBackupVersion: vi.fn(async () => "21869"), + getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "21869", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "doRequest").mockImplementation(async (method, endpoint) => { + if (method === "GET" && String(endpoint).includes("/room_keys/version")) { + return { version: "21868" }; + } + if (method === "DELETE" && String(endpoint).includes("/room_keys/version/21868")) { + return {}; + } + return {}; + }); + + const result = await client.resetRoomKeyBackup(); + + expect(result.success).toBe(true); + expect(result.previousVersion).toBe("21868"); + expect(result.deletedVersion).toBe("21868"); + expect(result.createdVersion).toBe("21869"); + expect(bootstrapSecretStorage).toHaveBeenCalledWith( + expect.objectContaining({ setupNewKeyBackup: true }), + ); + expect(checkKeyBackupAndEnable).toHaveBeenCalledTimes(1); + }); + + it("fails reset when the recreated backup still does not match the local decryption key", async () => { + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapSecretStorage: vi.fn(async () => {}), + checkKeyBackupAndEnable: vi.fn(async () => {}), + getActiveSessionBackupVersion: vi.fn(async () => "21868"), + getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "21868", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: false, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "doRequest").mockImplementation(async (method, endpoint) => { + if (method === "GET" && String(endpoint).includes("/room_keys/version")) { + return { version: "21868" }; + } + if (method === "DELETE" && String(endpoint).includes("/room_keys/version/21868")) { + return {}; + } + return {}; + }); + + const result = await client.resetRoomKeyBackup(); + + expect(result.success).toBe(false); + expect(result.error).toContain("does not have the matching backup decryption key"); + expect(result.createdVersion).toBe("21868"); + expect(result.backup.matchesDecryptionKey).toBe(false); + }); + it("reports bootstrap failure when cross-signing keys are not published", async () => { matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index a0ef5ef7159..160ab488961 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -84,6 +84,16 @@ export type MatrixRoomKeyBackupRestoreResult = { backup: MatrixRoomKeyBackupStatus; }; +export type MatrixRoomKeyBackupResetResult = { + success: boolean; + error?: string; + previousVersion: string | null; + deletedVersion: string | null; + createdVersion: string | null; + resetAt?: string; + backup: MatrixRoomKeyBackupStatus; +}; + export type MatrixRecoveryKeyVerificationResult = MatrixOwnDeviceVerificationStatus & { success: boolean; verifiedAt?: string; @@ -126,6 +136,17 @@ function normalizeOptionalString(value: string | null | undefined): string | nul return normalized ? normalized : null; } +function isMatrixNotFoundError(err: unknown): boolean { + const errObj = err as { statusCode?: number; body?: { errcode?: string } }; + if (errObj?.statusCode === 404 || errObj?.body?.errcode === "M_NOT_FOUND") { + return true; + } + const message = (err instanceof Error ? err.message : String(err)).toLowerCase(); + return ( + message.includes("m_not_found") || message.includes("[404]") || message.includes("not found") + ); +} + export class MatrixClient { private readonly client: MatrixJsClient; private readonly emitter = new EventEmitter(); @@ -900,6 +921,92 @@ export class MatrixClient { } } + async resetRoomKeyBackup(): Promise { + let previousVersion: string | null = null; + let deletedVersion: string | null = null; + const fail = async (error: string): Promise => { + const backup = await this.getRoomKeyBackupStatus(); + return { + success: false, + error, + previousVersion, + deletedVersion, + createdVersion: backup.serverVersion, + backup, + }; + }; + + if (!this.encryptionEnabled) { + return await fail("Matrix encryption is disabled for this client"); + } + + await this.ensureStartedForCryptoControlPlane(); + const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; + if (!crypto) { + return await fail("Matrix crypto is not available (start client with encryption enabled)"); + } + + previousVersion = await this.resolveRoomKeyBackupVersion(); + + try { + if (previousVersion) { + try { + await this.doRequest( + "DELETE", + `/_matrix/client/v3/room_keys/version/${encodeURIComponent(previousVersion)}`, + ); + } catch (err) { + if (!isMatrixNotFoundError(err)) { + throw err; + } + } + deletedVersion = previousVersion; + } + + await this.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto, { + setupNewKeyBackup: true, + }); + await this.enableTrustedRoomKeyBackupIfPossible(crypto); + + const backup = await this.getRoomKeyBackupStatus(); + const createdVersion = backup.serverVersion; + if (!createdVersion) { + return await fail("Matrix room key backup is still missing after reset."); + } + if (backup.activeVersion !== createdVersion) { + return await fail( + "Matrix room key backup was recreated on the server but is not active on this device.", + ); + } + if (backup.decryptionKeyCached === false) { + return await fail( + "Matrix room key backup was recreated but its decryption key is not cached on this device.", + ); + } + if (backup.matchesDecryptionKey === false) { + return await fail( + "Matrix room key backup was recreated but this device does not have the matching backup decryption key.", + ); + } + if (backup.trusted === false) { + return await fail( + "Matrix room key backup was recreated but is not trusted on this device.", + ); + } + + return { + success: true, + previousVersion, + deletedVersion, + createdVersion, + resetAt: new Date().toISOString(), + backup, + }; + } catch (err) { + return await fail(err instanceof Error ? err.message : String(err)); + } + } + async getOwnCrossSigningPublicationStatus(): Promise { const userId = this.client.getUserId() ?? this.selfUserId ?? null; if (!userId) { diff --git a/src/infra/matrix-account-selection.ts b/src/infra/matrix-account-selection.ts new file mode 100644 index 00000000000..548eb6edeaa --- /dev/null +++ b/src/infra/matrix-account-selection.ts @@ -0,0 +1,58 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + normalizeOptionalAccountId, +} from "../routing/session-key.js"; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record | null { + return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null; +} + +export function resolveConfiguredMatrixAccountIds(cfg: OpenClawConfig): string[] { + const channel = resolveMatrixChannelConfig(cfg); + if (!channel) { + return []; + } + + const accounts = isRecord(channel.accounts) ? channel.accounts : null; + if (!accounts) { + return [DEFAULT_ACCOUNT_ID]; + } + + const ids = Object.keys(accounts) + .map((accountId) => normalizeAccountId(accountId)) + .filter((accountId) => accountId.length > 0 && isRecord(accounts[accountId])); + + return Array.from(new Set(ids.length > 0 ? ids : [DEFAULT_ACCOUNT_ID])).toSorted((a, b) => + a.localeCompare(b), + ); +} + +export function resolveMatrixDefaultOrOnlyAccountId(cfg: OpenClawConfig): string { + const channel = resolveMatrixChannelConfig(cfg); + if (!channel) { + return DEFAULT_ACCOUNT_ID; + } + + const accounts = isRecord(channel.accounts) ? channel.accounts : null; + const configuredDefault = normalizeOptionalAccountId( + typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined, + ); + if (configuredDefault && accounts && isRecord(accounts[configuredDefault])) { + return configuredDefault; + } + if (accounts && isRecord(accounts[DEFAULT_ACCOUNT_ID])) { + return DEFAULT_ACCOUNT_ID; + } + + const configuredAccountIds = resolveConfiguredMatrixAccountIds(cfg); + if (configuredAccountIds.length === 1) { + return configuredAccountIds[0] ?? DEFAULT_ACCOUNT_ID; + } + return DEFAULT_ACCOUNT_ID; +} diff --git a/src/infra/matrix-legacy-crypto.test.ts b/src/infra/matrix-legacy-crypto.test.ts index bbaa20510f2..db6fbbbd244 100644 --- a/src/infra/matrix-legacy-crypto.test.ts +++ b/src/infra/matrix-legacy-crypto.test.ts @@ -119,4 +119,80 @@ describe("matrix legacy encrypted-state migration", () => { expect(state.restoreStatus).toBe("manual-action-required"); }); }); + + it("prepares flat legacy crypto for the only configured non-default Matrix account", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeFile( + path.join(stateDir, "matrix", "crypto", "bot-sdk.json"), + JSON.stringify({ deviceId: "DEVICEOPS" }), + ); + writeFile( + path.join(stateDir, "credentials", "matrix", "credentials-ops.json"), + JSON.stringify( + { + homeserver: "https://matrix.example.org", + userId: "@ops-bot:example.org", + accessToken: "tok-ops", + deviceId: "DEVICEOPS", + }, + null, + 2, + ), + ); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + ops: { + homeserver: "https://matrix.example.org", + userId: "@ops-bot:example.org", + }, + }, + }, + }, + }; + const { rootDir } = resolveMatrixAccountStorageRoot({ + stateDir, + homeserver: "https://matrix.example.org", + userId: "@ops-bot:example.org", + accessToken: "tok-ops", + accountId: "ops", + }); + + const detection = detectLegacyMatrixCrypto({ cfg, env: process.env }); + expect(detection.warnings).toEqual([]); + expect(detection.plans).toHaveLength(1); + expect(detection.plans[0]?.accountId).toBe("ops"); + + const result = await autoPrepareLegacyMatrixCrypto({ + cfg, + env: process.env, + deps: { + inspectLegacyStore: async () => ({ + deviceId: "DEVICEOPS", + roomKeyCounts: { total: 6, backedUp: 6 }, + backupVersion: "21868", + decryptionKeyBase64: "YWJjZA==", + }), + }, + }); + + expect(result.migrated).toBe(true); + expect(result.warnings).toEqual([]); + const recovery = JSON.parse( + fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"), + ) as { + privateKeyBase64: string; + }; + expect(recovery.privateKeyBase64).toBe("YWJjZA=="); + const state = JSON.parse( + fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"), + ) as { + accountId: string; + }; + expect(state.accountId).toBe("ops"); + }); + }); }); diff --git a/src/infra/matrix-legacy-crypto.ts b/src/infra/matrix-legacy-crypto.ts index 7f401b609d6..efbed986a6d 100644 --- a/src/infra/matrix-legacy-crypto.ts +++ b/src/infra/matrix-legacy-crypto.ts @@ -5,11 +5,12 @@ import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { writeJsonFileAtomically } from "../plugin-sdk/json-store.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import { - DEFAULT_ACCOUNT_ID, - normalizeAccountId, - normalizeOptionalAccountId, -} from "../routing/session-key.js"; + resolveConfiguredMatrixAccountIds, + resolveMatrixChannelConfig, + resolveMatrixDefaultOrOnlyAccountId, +} from "./matrix-account-selection.js"; import { resolveMatrixAccountStorageRoot, resolveMatrixCredentialsPath, @@ -150,23 +151,12 @@ function loadStoredMatrixCredentials( } } -function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record | null { - return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null; +function resolveMatrixAccountIds(cfg: OpenClawConfig): string[] { + return resolveConfiguredMatrixAccountIds(cfg); } -function resolveMatrixAccountIds(cfg: OpenClawConfig): string[] { - const channel = resolveMatrixChannelConfig(cfg); - if (!channel) { - return []; - } - const accounts = isRecord(channel.accounts) ? channel.accounts : null; - if (!accounts) { - return [DEFAULT_ACCOUNT_ID]; - } - const ids = Object.keys(accounts).map((accountId) => normalizeAccountId(accountId)); - return Array.from(new Set(ids.length > 0 ? ids : [DEFAULT_ACCOUNT_ID])).toSorted((a, b) => - a.localeCompare(b), - ); +function resolveMatrixFlatStoreTargetAccountId(cfg: OpenClawConfig): string { + return resolveMatrixDefaultOrOnlyAccountId(cfg); } function resolveMatrixAccountConfig( @@ -205,14 +195,7 @@ function resolveLegacyMatrixFlatStorePlan(params: { }; } - const accounts = isRecord(channel.accounts) ? channel.accounts : null; - const configuredDefault = normalizeOptionalAccountId( - typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined, - ); - const accountId = - configuredDefault && accounts && isRecord(accounts[configuredDefault]) - ? configuredDefault - : DEFAULT_ACCOUNT_ID; + const accountId = resolveMatrixFlatStoreTargetAccountId(params.cfg); const stored = loadStoredMatrixCredentials(params.env, accountId); const account = resolveMatrixAccountConfig(params.cfg, accountId); const homeserver = typeof account.homeserver === "string" ? account.homeserver.trim() : ""; diff --git a/src/infra/matrix-legacy-state.test.ts b/src/infra/matrix-legacy-state.test.ts index 096f2752a57..15eddd03132 100644 --- a/src/infra/matrix-legacy-state.test.ts +++ b/src/infra/matrix-legacy-state.test.ts @@ -119,4 +119,51 @@ describe("matrix legacy state migration", () => { expect(detection.selectionNote).toContain('account "work"'); }); }); + + it("migrates flat legacy Matrix state into the only configured non-default account", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}'); + writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto"); + writeFile( + path.join(stateDir, "credentials", "matrix", "credentials-ops.json"), + JSON.stringify( + { + homeserver: "https://matrix.example.org", + userId: "@ops-bot:example.org", + accessToken: "tok-ops", + }, + null, + 2, + ), + ); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + ops: { + homeserver: "https://matrix.example.org", + userId: "@ops-bot:example.org", + }, + }, + }, + }, + }; + + const detection = detectLegacyMatrixState({ cfg, env: process.env }); + expect(detection && "warning" in detection).toBe(false); + if (!detection || "warning" in detection) { + throw new Error("expected a migratable Matrix legacy state plan"); + } + + expect(detection.accountId).toBe("ops"); + + const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env }); + expect(result.migrated).toBe(true); + expect(result.warnings).toEqual([]); + expect(fs.existsSync(detection.targetStoragePath)).toBe(true); + expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true); + }); + }); }); diff --git a/src/infra/matrix-legacy-state.ts b/src/infra/matrix-legacy-state.ts index 80f61f869ea..7bce4070e74 100644 --- a/src/infra/matrix-legacy-state.ts +++ b/src/infra/matrix-legacy-state.ts @@ -3,11 +3,11 @@ import os from "node:os"; import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import { - DEFAULT_ACCOUNT_ID, - normalizeAccountId, - normalizeOptionalAccountId, -} from "../routing/session-key.js"; + resolveMatrixChannelConfig, + resolveMatrixDefaultOrOnlyAccountId, +} from "./matrix-account-selection.js"; import { resolveMatrixAccountStorageRoot, resolveMatrixCredentialsPath as resolveSharedMatrixCredentialsPath, @@ -86,10 +86,6 @@ function loadStoredMatrixCredentials( } } -function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record | null { - return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null; -} - function resolveMatrixAccountConfig( cfg: OpenClawConfig, accountId: string, @@ -111,22 +107,7 @@ function resolveMatrixAccountConfig( } function resolveMatrixTargetAccountId(cfg: OpenClawConfig): string { - const channel = resolveMatrixChannelConfig(cfg); - if (!channel) { - return DEFAULT_ACCOUNT_ID; - } - - const accounts = isRecord(channel.accounts) ? channel.accounts : null; - const configuredDefault = normalizeOptionalAccountId( - typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined, - ); - if (configuredDefault && accounts && isRecord(accounts[configuredDefault])) { - return configuredDefault; - } - if (accounts && isRecord(accounts[DEFAULT_ACCOUNT_ID])) { - return DEFAULT_ACCOUNT_ID; - } - return DEFAULT_ACCOUNT_ID; + return resolveMatrixDefaultOrOnlyAccountId(cfg); } function resolveMatrixFlatStoreSelectionNote(params: {