diff --git a/extensions/matrix/src/matrix/client/create-client.ts b/extensions/matrix/src/matrix/client/create-client.ts index 1c66dbbc302..37fd64e5e87 100644 --- a/extensions/matrix/src/matrix/client/create-client.ts +++ b/extensions/matrix/src/matrix/client/create-client.ts @@ -31,6 +31,7 @@ export async function createMatrixClient(params: { userId, accessToken: params.accessToken, accountId: params.accountId, + deviceId: params.deviceId, env, }); await maybeMigrateLegacyStorage({ @@ -44,6 +45,7 @@ export async function createMatrixClient(params: { homeserver, userId, accountId: params.accountId, + deviceId: params.deviceId, }); const cryptoDatabasePrefix = `openclaw-matrix-${storagePaths.accountKey}-${storagePaths.tokenHash}`; diff --git a/extensions/matrix/src/matrix/client/storage.test.ts b/extensions/matrix/src/matrix/client/storage.test.ts index 1d368c90fce..0f4b8c24262 100644 --- a/extensions/matrix/src/matrix/client/storage.test.ts +++ b/extensions/matrix/src/matrix/client/storage.test.ts @@ -316,4 +316,54 @@ describe("matrix client storage paths", () => { expect(resolvedPaths.rootDir).toBe(oldStoragePaths.rootDir); expect(resolvedPaths.tokenHash).toBe(oldStoragePaths.tokenHash); }); + + it("does not reuse a populated sibling storage root from a different device", () => { + const stateDir = setupStateDir(); + const oldStoragePaths = resolveMatrixStoragePaths({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "secret-token-old", + deviceId: "OLDDEVICE", + env: {}, + }); + fs.mkdirSync(oldStoragePaths.rootDir, { recursive: true }); + fs.writeFileSync(oldStoragePaths.storagePath, '{"legacy":true}'); + fs.writeFileSync( + path.join(oldStoragePaths.rootDir, "startup-verification.json"), + JSON.stringify({ deviceId: "OLDDEVICE" }, null, 2), + ); + + const newerCanonicalPaths = resolveMatrixAccountStorageRoot({ + stateDir, + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "secret-token-new", + }); + fs.mkdirSync(newerCanonicalPaths.rootDir, { recursive: true }); + fs.writeFileSync( + path.join(newerCanonicalPaths.rootDir, "storage-meta.json"), + JSON.stringify( + { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accountId: "default", + accessTokenHash: newerCanonicalPaths.tokenHash, + deviceId: "NEWDEVICE", + }, + null, + 2, + ), + ); + + const resolvedPaths = resolveMatrixStoragePaths({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "secret-token-new", + deviceId: "NEWDEVICE", + env: {}, + }); + + expect(resolvedPaths.rootDir).toBe(newerCanonicalPaths.rootDir); + expect(resolvedPaths.tokenHash).toBe(newerCanonicalPaths.tokenHash); + }); }); diff --git a/extensions/matrix/src/matrix/client/storage.ts b/extensions/matrix/src/matrix/client/storage.ts index 447f66443b0..2d29be0587c 100644 --- a/extensions/matrix/src/matrix/client/storage.ts +++ b/extensions/matrix/src/matrix/client/storage.ts @@ -18,6 +18,7 @@ const THREAD_BINDINGS_FILENAME = "thread-bindings.json"; const LEGACY_CRYPTO_MIGRATION_FILENAME = "legacy-crypto-migration.json"; const RECOVERY_KEY_FILENAME = "recovery-key.json"; const IDB_SNAPSHOT_FILENAME = "crypto-idb-snapshot.json"; +const STARTUP_VERIFICATION_FILENAME = "startup-verification.json"; type LegacyMoveRecord = { sourcePath: string; @@ -25,6 +26,14 @@ type LegacyMoveRecord = { label: string; }; +type StoredRootMetadata = { + homeserver?: string; + userId?: string; + accountId?: string; + accessTokenHash?: string; + deviceId?: string | null; +}; + function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): { storagePath: string; cryptoPath: string; @@ -88,9 +97,84 @@ function resolveStorageRootMtimeMs(rootDir: string): number { } } +function readStoredRootMetadata(rootDir: string): StoredRootMetadata { + const metadata: StoredRootMetadata = {}; + + try { + const parsed = JSON.parse( + fs.readFileSync(path.join(rootDir, STORAGE_META_FILENAME), "utf8"), + ) as Partial; + if (typeof parsed.homeserver === "string" && parsed.homeserver.trim()) { + metadata.homeserver = parsed.homeserver.trim(); + } + if (typeof parsed.userId === "string" && parsed.userId.trim()) { + metadata.userId = parsed.userId.trim(); + } + if (typeof parsed.accountId === "string" && parsed.accountId.trim()) { + metadata.accountId = parsed.accountId.trim(); + } + if (typeof parsed.accessTokenHash === "string" && parsed.accessTokenHash.trim()) { + metadata.accessTokenHash = parsed.accessTokenHash.trim(); + } + if (typeof parsed.deviceId === "string" && parsed.deviceId.trim()) { + metadata.deviceId = parsed.deviceId.trim(); + } + } catch { + // ignore missing or malformed storage metadata + } + + try { + const parsed = JSON.parse( + fs.readFileSync(path.join(rootDir, STARTUP_VERIFICATION_FILENAME), "utf8"), + ) as { deviceId?: unknown }; + if (!metadata.deviceId && typeof parsed.deviceId === "string" && parsed.deviceId.trim()) { + metadata.deviceId = parsed.deviceId.trim(); + } + } catch { + // ignore missing or malformed verification state + } + + return metadata; +} + +function isCompatibleStorageRoot(params: { + candidateRootDir: string; + homeserver: string; + userId: string; + accountKey: string; + deviceId?: string | null; +}): boolean { + const metadata = readStoredRootMetadata(params.candidateRootDir); + if (metadata.homeserver && metadata.homeserver !== params.homeserver) { + return false; + } + if (metadata.userId && metadata.userId !== params.userId) { + return false; + } + if ( + metadata.accountId && + normalizeAccountId(metadata.accountId) !== normalizeAccountId(params.accountKey) + ) { + return false; + } + if ( + params.deviceId && + metadata.deviceId && + metadata.deviceId.trim() && + metadata.deviceId.trim() !== params.deviceId.trim() + ) { + return false; + } + return true; +} + function resolvePreferredMatrixStorageRoot(params: { canonicalRootDir: string; canonicalTokenHash: string; + homeserver: string; + userId: string; + accountKey: string; + deviceId?: string | null; }): { rootDir: string; tokenHash: string; @@ -122,6 +206,17 @@ function resolvePreferredMatrixStorageRoot(params: { continue; } const candidateRootDir = path.join(parentDir, entry.name); + if ( + !isCompatibleStorageRoot({ + candidateRootDir, + homeserver: params.homeserver, + userId: params.userId, + accountKey: params.accountKey, + deviceId: params.deviceId, + }) + ) { + continue; + } const candidateScore = scoreStorageRoot(candidateRootDir); if (candidateScore <= 0) { continue; @@ -153,6 +248,7 @@ export function resolveMatrixStoragePaths(params: { userId: string; accessToken: string; accountId?: string | null; + deviceId?: string | null; env?: NodeJS.ProcessEnv; stateDir?: string; }): MatrixStoragePaths { @@ -168,6 +264,10 @@ export function resolveMatrixStoragePaths(params: { const { rootDir, tokenHash } = resolvePreferredMatrixStorageRoot({ canonicalRootDir: canonical.rootDir, canonicalTokenHash: canonical.tokenHash, + homeserver: params.homeserver, + userId: params.userId, + accountKey: canonical.accountKey, + deviceId: params.deviceId, }); return { rootDir, @@ -301,6 +401,7 @@ export function writeStorageMeta(params: { homeserver: string; userId: string; accountId?: string | null; + deviceId?: string | null; }): void { try { const payload = { @@ -308,6 +409,7 @@ export function writeStorageMeta(params: { userId: params.userId, accountId: params.accountId ?? DEFAULT_ACCOUNT_KEY, accessTokenHash: params.storagePaths.tokenHash, + deviceId: params.deviceId ?? null, createdAt: new Date().toISOString(), }; fs.mkdirSync(params.storagePaths.rootDir, { recursive: true }); diff --git a/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.ts b/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.ts index 5804ab8adae..f4d17f400a1 100644 --- a/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.ts +++ b/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.ts @@ -43,7 +43,7 @@ function isMigrationState(value: unknown): value is MatrixLegacyCryptoMigrationS async function resolvePendingMigrationStatePath(params: { stateDir: string; - auth: Pick; + auth: Pick; }): Promise<{ statePath: string; value: MatrixLegacyCryptoMigrationState | null; @@ -53,6 +53,7 @@ async function resolvePendingMigrationStatePath(params: { userId: params.auth.userId, accessToken: params.auth.accessToken, accountId: params.auth.accountId, + deviceId: params.auth.deviceId, stateDir: params.stateDir, }); const directStatePath = path.join(rootDir, "legacy-crypto-migration.json"); @@ -89,7 +90,7 @@ async function resolvePendingMigrationStatePath(params: { export async function maybeRestoreLegacyMatrixBackup(params: { client: Pick; - auth: Pick; + auth: Pick; env?: NodeJS.ProcessEnv; stateDir?: string; }): Promise { diff --git a/extensions/matrix/src/matrix/monitor/startup-verification.ts b/extensions/matrix/src/matrix/monitor/startup-verification.ts index e91fcdbf6eb..6bc34136674 100644 --- a/extensions/matrix/src/matrix/monitor/startup-verification.ts +++ b/extensions/matrix/src/matrix/monitor/startup-verification.ts @@ -51,6 +51,7 @@ function resolveStartupVerificationStatePath(params: { userId: params.auth.userId, accessToken: params.auth.accessToken, accountId: params.auth.accountId, + deviceId: params.auth.deviceId, env: params.env, }); return path.join(storagePaths.rootDir, STARTUP_VERIFICATION_STATE_FILENAME); diff --git a/extensions/matrix/src/matrix/thread-bindings.ts b/extensions/matrix/src/matrix/thread-bindings.ts index 439aca2fe83..d3d8f5bf304 100644 --- a/extensions/matrix/src/matrix/thread-bindings.ts +++ b/extensions/matrix/src/matrix/thread-bindings.ts @@ -179,6 +179,7 @@ function resolveBindingsPath(params: { userId: params.auth.userId, accessToken: params.auth.accessToken, accountId: params.accountId, + deviceId: params.auth.deviceId, env: params.env, }); return path.join(storagePaths.rootDir, "thread-bindings.json");