From efe987d2c23744f9874f43efa2b4341e412cd92f Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 9 Mar 2026 11:46:56 -0400 Subject: [PATCH] Matrix: honor env-backed legacy migration config --- src/infra/matrix-legacy-crypto.test.ts | 63 +++++++++++ src/infra/matrix-legacy-crypto.ts | 118 ++++++-------------- src/infra/matrix-legacy-state.test.ts | 42 +++++++ src/infra/matrix-legacy-state.ts | 100 ++++------------- src/infra/matrix-migration-config.ts | 149 +++++++++++++++++++++++++ 5 files changed, 307 insertions(+), 165 deletions(-) create mode 100644 src/infra/matrix-migration-config.ts diff --git a/src/infra/matrix-legacy-crypto.test.ts b/src/infra/matrix-legacy-crypto.test.ts index db6fbbbd244..5b45fa41587 100644 --- a/src/infra/matrix-legacy-crypto.test.ts +++ b/src/infra/matrix-legacy-crypto.test.ts @@ -195,4 +195,67 @@ describe("matrix legacy encrypted-state migration", () => { expect(state.accountId).toBe("ops"); }); }); + + it("uses scoped Matrix env vars when resolving flat legacy crypto migration", async () => { + await withTempHome( + async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeFile( + path.join(stateDir, "matrix", "crypto", "bot-sdk.json"), + JSON.stringify({ deviceId: "DEVICEOPS" }), + ); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + ops: {}, + }, + }, + }, + }; + const { rootDir } = resolveMatrixAccountStorageRoot({ + stateDir, + homeserver: "https://matrix.example.org", + userId: "@ops-bot:example.org", + accessToken: "tok-ops-env", + 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: 4, backedUp: 4 }, + backupVersion: "9001", + 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=="); + }, + { + env: { + MATRIX_OPS_HOMESERVER: "https://matrix.example.org", + MATRIX_OPS_USER_ID: "@ops-bot:example.org", + MATRIX_OPS_ACCESS_TOKEN: "tok-ops-env", + }, + }, + ); + }); }); diff --git a/src/infra/matrix-legacy-crypto.ts b/src/infra/matrix-legacy-crypto.ts index efbed986a6d..1e3a87f3528 100644 --- a/src/infra/matrix-legacy-crypto.ts +++ b/src/infra/matrix-legacy-crypto.ts @@ -5,25 +5,22 @@ 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 } from "../routing/session-key.js"; import { resolveConfiguredMatrixAccountIds, resolveMatrixChannelConfig, resolveMatrixDefaultOrOnlyAccountId, } from "./matrix-account-selection.js"; +import { + credentialsMatchResolvedIdentity, + loadStoredMatrixCredentials, + resolveMatrixMigrationConfigFields, +} from "./matrix-migration-config.js"; import { resolveMatrixAccountStorageRoot, - resolveMatrixCredentialsPath, resolveMatrixLegacyFlatStoragePaths, } from "./matrix-storage-paths.js"; -type MatrixStoredCredentials = { - homeserver: string; - userId: string; - accessToken: string; - deviceId?: string; -}; - type MatrixLegacyCryptoCounts = { total: number; backedUp: number; @@ -99,10 +96,6 @@ type MatrixStoredRecoveryKey = { }; }; -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function isLegacyBotSdkCryptoStore(cryptoRootDir: string): boolean { return ( fs.existsSync(path.join(cryptoRootDir, "bot-sdk.json")) || @@ -117,40 +110,6 @@ function isLegacyBotSdkCryptoStore(cryptoRootDir: string): boolean { ); } -function loadStoredMatrixCredentials( - env: NodeJS.ProcessEnv, - accountId: string, -): MatrixStoredCredentials | null { - const stateDir = resolveStateDir(env, os.homedir); - const credentialsPath = resolveMatrixCredentialsPath({ - stateDir, - accountId: normalizeAccountId(accountId), - }); - try { - if (!fs.existsSync(credentialsPath)) { - return null; - } - const parsed = JSON.parse( - fs.readFileSync(credentialsPath, "utf8"), - ) as Partial; - if ( - typeof parsed.homeserver !== "string" || - typeof parsed.userId !== "string" || - typeof parsed.accessToken !== "string" - ) { - return null; - } - return { - homeserver: parsed.homeserver, - userId: parsed.userId, - accessToken: parsed.accessToken, - deviceId: typeof parsed.deviceId === "string" ? parsed.deviceId : undefined, - }; - } catch { - return null; - } -} - function resolveMatrixAccountIds(cfg: OpenClawConfig): string[] { return resolveConfiguredMatrixAccountIds(cfg); } @@ -159,24 +118,6 @@ function resolveMatrixFlatStoreTargetAccountId(cfg: OpenClawConfig): string { return resolveMatrixDefaultOrOnlyAccountId(cfg); } -function resolveMatrixAccountConfig( - cfg: OpenClawConfig, - accountId: string, -): Record { - const channel = resolveMatrixChannelConfig(cfg); - if (!channel) { - return {}; - } - const accounts = isRecord(channel.accounts) ? channel.accounts : null; - const accountEntry = accounts && isRecord(accounts[accountId]) ? accounts[accountId] : null; - const merged = { - ...channel, - ...accountEntry, - }; - delete merged.accounts; - return merged; -} - function resolveLegacyMatrixFlatStorePlan(params: { cfg: OpenClawConfig; env: NodeJS.ProcessEnv; @@ -197,14 +138,20 @@ function resolveLegacyMatrixFlatStorePlan(params: { 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() : ""; - const userId = - (typeof account.userId === "string" ? account.userId.trim() : "") || stored?.userId || ""; - const accessToken = - (typeof account.accessToken === "string" ? account.accessToken.trim() : "") || - stored?.accessToken || - ""; + const resolved = resolveMatrixMigrationConfigFields({ + cfg: params.cfg, + env: params.env, + accountId, + }); + const matchingStored = credentialsMatchResolvedIdentity(stored, { + homeserver: resolved.homeserver, + userId: resolved.userId, + }) + ? stored + : null; + const homeserver = resolved.homeserver; + const userId = resolved.userId || matchingStored?.userId || ""; + const accessToken = resolved.accessToken || matchingStored?.accessToken || ""; if (!homeserver || !userId || !accessToken) { return { @@ -274,18 +221,21 @@ function resolveMatrixLegacyCryptoPlans(params: { const stateDir = resolveStateDir(params.env, os.homedir); for (const accountId of resolveMatrixAccountIds(params.cfg)) { - const account = resolveMatrixAccountConfig(params.cfg, accountId); const stored = loadStoredMatrixCredentials(params.env, accountId); - const homeserver = - (typeof account.homeserver === "string" ? account.homeserver.trim() : "") || - stored?.homeserver || - ""; - const userId = - (typeof account.userId === "string" ? account.userId.trim() : "") || stored?.userId || ""; - const accessToken = - (typeof account.accessToken === "string" ? account.accessToken.trim() : "") || - stored?.accessToken || - ""; + const resolved = resolveMatrixMigrationConfigFields({ + cfg: params.cfg, + env: params.env, + accountId, + }); + const matchingStored = credentialsMatchResolvedIdentity(stored, { + homeserver: resolved.homeserver, + userId: resolved.userId, + }) + ? stored + : null; + const homeserver = resolved.homeserver; + const userId = resolved.userId || matchingStored?.userId || ""; + const accessToken = resolved.accessToken || matchingStored?.accessToken || ""; if (!homeserver || !userId || !accessToken) { continue; } diff --git a/src/infra/matrix-legacy-state.test.ts b/src/infra/matrix-legacy-state.test.ts index 15eddd03132..fa5230af353 100644 --- a/src/infra/matrix-legacy-state.test.ts +++ b/src/infra/matrix-legacy-state.test.ts @@ -120,6 +120,48 @@ describe("matrix legacy state migration", () => { }); }); + it("uses scoped Matrix env vars when resolving a flat-store migration target", 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"); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + ops: {}, + }, + }, + }, + }; + + const detection = detectLegacyMatrixState({ cfg, env: process.env }); + expect(detection && "warning" in detection).toBe(false); + if (!detection || "warning" in detection) { + throw new Error("expected scoped Matrix env vars to resolve a legacy state plan"); + } + + expect(detection.accountId).toBe("ops"); + expect(detection.targetRootDir).toContain("matrix.example.org__ops-bot_example.org"); + + 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); + }, + { + env: { + MATRIX_OPS_HOMESERVER: "https://matrix.example.org", + MATRIX_OPS_USER_ID: "@ops-bot:example.org", + MATRIX_OPS_ACCESS_TOKEN: "tok-ops-env", + }, + }, + ); + }); + it("migrates flat legacy Matrix state into the only configured non-default account", async () => { await withTempHome(async (home) => { const stateDir = path.join(home, ".openclaw"); diff --git a/src/infra/matrix-legacy-state.ts b/src/infra/matrix-legacy-state.ts index 7bce4070e74..4e7005c871f 100644 --- a/src/infra/matrix-legacy-state.ts +++ b/src/infra/matrix-legacy-state.ts @@ -8,18 +8,16 @@ import { resolveMatrixChannelConfig, resolveMatrixDefaultOrOnlyAccountId, } from "./matrix-account-selection.js"; +import { + credentialsMatchResolvedIdentity, + loadStoredMatrixCredentials, + resolveMatrixMigrationConfigFields, +} from "./matrix-migration-config.js"; import { resolveMatrixAccountStorageRoot, - resolveMatrixCredentialsPath as resolveSharedMatrixCredentialsPath, resolveMatrixLegacyFlatStoragePaths, } from "./matrix-storage-paths.js"; -type MatrixStoredCredentials = { - homeserver: string; - userId: string; - accessToken: string; -}; - export type MatrixLegacyStateMigrationResult = { migrated: boolean; changes: string[]; @@ -49,63 +47,6 @@ function resolveLegacyMatrixPaths(env: NodeJS.ProcessEnv): { return resolveMatrixLegacyFlatStoragePaths(stateDir); } -function resolveMatrixCredentialsPath(env: NodeJS.ProcessEnv, accountId: string): string { - const stateDir = resolveStateDir(env, os.homedir); - return resolveSharedMatrixCredentialsPath({ - stateDir, - accountId: normalizeAccountId(accountId), - }); -} - -function loadStoredMatrixCredentials( - env: NodeJS.ProcessEnv, - accountId: string, -): MatrixStoredCredentials | null { - const credentialsPath = resolveMatrixCredentialsPath(env, accountId); - try { - if (!fs.existsSync(credentialsPath)) { - return null; - } - const parsed = JSON.parse( - fs.readFileSync(credentialsPath, "utf-8"), - ) as Partial; - if ( - typeof parsed.homeserver !== "string" || - typeof parsed.userId !== "string" || - typeof parsed.accessToken !== "string" - ) { - return null; - } - return { - homeserver: parsed.homeserver, - userId: parsed.userId, - accessToken: parsed.accessToken, - }; - } catch { - return null; - } -} - -function resolveMatrixAccountConfig( - cfg: OpenClawConfig, - accountId: string, -): Record { - const channel = resolveMatrixChannelConfig(cfg); - if (!channel) { - return {}; - } - - const accounts = isRecord(channel.accounts) ? channel.accounts : null; - const accountEntry = accounts && isRecord(accounts[accountId]) ? accounts[accountId] : null; - - const merged = { - ...channel, - ...accountEntry, - }; - delete merged.accounts; - return merged; -} - function resolveMatrixTargetAccountId(cfg: OpenClawConfig): string { return resolveMatrixDefaultOrOnlyAccountId(cfg); } @@ -155,25 +96,22 @@ function resolveMatrixMigrationPlan(params: { } const accountId = resolveMatrixTargetAccountId(params.cfg); - const account = resolveMatrixAccountConfig(params.cfg, accountId); const stored = loadStoredMatrixCredentials(params.env, accountId); const selectionNote = resolveMatrixFlatStoreSelectionNote({ channel, accountId }); - - const homeserver = typeof account.homeserver === "string" ? account.homeserver.trim() : ""; - const configUserId = typeof account.userId === "string" ? account.userId.trim() : ""; - const configAccessToken = - typeof account.accessToken === "string" ? account.accessToken.trim() : ""; - - const storedMatchesHomeserver = - stored && homeserver ? stored.homeserver === homeserver : Boolean(stored); - const storedMatchesUser = - stored && configUserId ? stored.userId === configUserId : Boolean(stored); - - const userId = - configUserId || (storedMatchesHomeserver && storedMatchesUser ? (stored?.userId ?? "") : ""); - const accessToken = - configAccessToken || - (storedMatchesHomeserver && storedMatchesUser ? (stored?.accessToken ?? "") : ""); + const resolved = resolveMatrixMigrationConfigFields({ + cfg: params.cfg, + env: params.env, + accountId, + }); + const matchingStored = credentialsMatchResolvedIdentity(stored, { + homeserver: resolved.homeserver, + userId: resolved.userId, + }) + ? stored + : null; + const homeserver = resolved.homeserver; + const userId = resolved.userId || matchingStored?.userId || ""; + const accessToken = resolved.accessToken || matchingStored?.accessToken || ""; if (!homeserver || !userId || !accessToken) { return { diff --git a/src/infra/matrix-migration-config.ts b/src/infra/matrix-migration-config.ts new file mode 100644 index 00000000000..f5f3d469987 --- /dev/null +++ b/src/infra/matrix-migration-config.ts @@ -0,0 +1,149 @@ +import fs from "node:fs"; +import os from "node:os"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; +import { normalizeAccountId } from "../routing/session-key.js"; +import { resolveMatrixChannelConfig } from "./matrix-account-selection.js"; +import { resolveMatrixCredentialsPath } from "./matrix-storage-paths.js"; + +export type MatrixStoredCredentials = { + homeserver: string; + userId: string; + accessToken: string; + deviceId?: string; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function clean(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function resolveMatrixEnvAccountToken(accountId: string): string { + return normalizeAccountId(accountId) + .replace(/[^a-z0-9]+/gi, "_") + .replace(/^_+|_+$/g, "") + .toUpperCase(); +} + +function resolveScopedMatrixEnvConfig( + accountId: string, + env: NodeJS.ProcessEnv, +): { + homeserver: string; + userId: string; + accessToken: string; +} { + const token = resolveMatrixEnvAccountToken(accountId); + return { + homeserver: clean(env[`MATRIX_${token}_HOMESERVER`]), + userId: clean(env[`MATRIX_${token}_USER_ID`]), + accessToken: clean(env[`MATRIX_${token}_ACCESS_TOKEN`]), + }; +} + +function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): { + homeserver: string; + userId: string; + accessToken: string; +} { + return { + homeserver: clean(env.MATRIX_HOMESERVER), + userId: clean(env.MATRIX_USER_ID), + accessToken: clean(env.MATRIX_ACCESS_TOKEN), + }; +} + +function resolveMatrixAccountConfigEntry( + cfg: OpenClawConfig, + accountId: string, +): Record | null { + const channel = resolveMatrixChannelConfig(cfg); + if (!channel) { + return null; + } + const accounts = isRecord(channel.accounts) ? channel.accounts : null; + return accounts && isRecord(accounts[accountId]) ? accounts[accountId] : null; +} + +export function resolveMatrixMigrationConfigFields(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + accountId: string; +}): { + homeserver: string; + userId: string; + accessToken: string; +} { + const channel = resolveMatrixChannelConfig(params.cfg); + const account = resolveMatrixAccountConfigEntry(params.cfg, params.accountId); + const scopedEnv = resolveScopedMatrixEnvConfig(params.accountId, params.env); + const globalEnv = resolveGlobalMatrixEnvConfig(params.env); + + return { + homeserver: + clean(account?.homeserver) || + scopedEnv.homeserver || + clean(channel?.homeserver) || + globalEnv.homeserver, + userId: + clean(account?.userId) || scopedEnv.userId || clean(channel?.userId) || globalEnv.userId, + accessToken: + clean(account?.accessToken) || + scopedEnv.accessToken || + clean(channel?.accessToken) || + globalEnv.accessToken, + }; +} + +export function loadStoredMatrixCredentials( + env: NodeJS.ProcessEnv, + accountId: string, +): MatrixStoredCredentials | null { + const stateDir = resolveStateDir(env, os.homedir); + const credentialsPath = resolveMatrixCredentialsPath({ + stateDir, + accountId: normalizeAccountId(accountId), + }); + try { + if (!fs.existsSync(credentialsPath)) { + return null; + } + const parsed = JSON.parse( + fs.readFileSync(credentialsPath, "utf8"), + ) as Partial; + if ( + typeof parsed.homeserver !== "string" || + typeof parsed.userId !== "string" || + typeof parsed.accessToken !== "string" + ) { + return null; + } + return { + homeserver: parsed.homeserver, + userId: parsed.userId, + accessToken: parsed.accessToken, + deviceId: typeof parsed.deviceId === "string" ? parsed.deviceId : undefined, + }; + } catch { + return null; + } +} + +export function credentialsMatchResolvedIdentity( + stored: MatrixStoredCredentials | null, + identity: { + homeserver: string; + userId: string; + }, +): stored is MatrixStoredCredentials { + if (!stored || !identity.homeserver) { + return false; + } + if (!identity.userId) { + return stored.homeserver === identity.homeserver; + } + return stored.homeserver === identity.homeserver && stored.userId === identity.userId; +}