diff --git a/extensions/matrix/src/matrix/client/storage.test.ts b/extensions/matrix/src/matrix/client/storage.test.ts index 712560263a9..e273d5c3979 100644 --- a/extensions/matrix/src/matrix/client/storage.test.ts +++ b/extensions/matrix/src/matrix/client/storage.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { resolveMatrixAccountStorageRoot } from "openclaw/plugin-sdk/matrix"; import { afterEach, describe, expect, it, vi } from "vitest"; import { setMatrixRuntime } from "../../runtime.js"; import { maybeMigrateLegacyStorage, resolveMatrixStoragePaths } from "./storage.js"; @@ -153,4 +154,61 @@ describe("matrix client storage paths", () => { expect(fs.existsSync(storagePaths.storagePath)).toBe(false); expect(fs.existsSync(path.join(legacyRoot, "crypto"))).toBe(true); }); + + it("reuses an existing token-hash storage root after the access token changes", () => { + const stateDir = setupStateDir(); + const oldStoragePaths = resolveMatrixStoragePaths({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "secret-token-old", + env: {}, + }); + fs.mkdirSync(oldStoragePaths.rootDir, { recursive: true }); + fs.writeFileSync(oldStoragePaths.storagePath, '{"legacy":true}'); + + const rotatedStoragePaths = resolveMatrixStoragePaths({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "secret-token-new", + env: {}, + }); + + expect(rotatedStoragePaths.rootDir).toBe(oldStoragePaths.rootDir); + expect(rotatedStoragePaths.tokenHash).toBe(oldStoragePaths.tokenHash); + expect(rotatedStoragePaths.storagePath).toBe(oldStoragePaths.storagePath); + }); + + it("prefers a populated older token-hash storage root over a newer empty root", () => { + const stateDir = setupStateDir(); + const oldStoragePaths = resolveMatrixStoragePaths({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "secret-token-old", + env: {}, + }); + fs.mkdirSync(oldStoragePaths.rootDir, { recursive: true }); + fs.writeFileSync(oldStoragePaths.storagePath, '{"legacy":true}'); + + 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({ accessTokenHash: newerCanonicalPaths.tokenHash }, null, 2), + ); + + const resolvedPaths = resolveMatrixStoragePaths({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "secret-token-new", + env: {}, + }); + + expect(resolvedPaths.rootDir).toBe(oldStoragePaths.rootDir); + expect(resolvedPaths.tokenHash).toBe(oldStoragePaths.tokenHash); + }); }); diff --git a/extensions/matrix/src/matrix/client/storage.ts b/extensions/matrix/src/matrix/client/storage.ts index a24deb1f13a..d5768aa3c6a 100644 --- a/extensions/matrix/src/matrix/client/storage.ts +++ b/extensions/matrix/src/matrix/client/storage.ts @@ -11,6 +11,10 @@ import type { MatrixStoragePaths } from "./types.js"; export const DEFAULT_ACCOUNT_KEY = "default"; const STORAGE_META_FILENAME = "storage-meta.json"; +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"; type LegacyMoveRecord = { sourcePath: string; @@ -27,30 +31,129 @@ function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): { return { storagePath: legacy.storagePath, cryptoPath: legacy.cryptoPath }; } +function scoreStorageRoot(rootDir: string): number { + let score = 0; + if (fs.existsSync(path.join(rootDir, "bot-storage.json"))) { + score += 8; + } + if (fs.existsSync(path.join(rootDir, "crypto"))) { + score += 8; + } + if (fs.existsSync(path.join(rootDir, THREAD_BINDINGS_FILENAME))) { + score += 4; + } + if (fs.existsSync(path.join(rootDir, LEGACY_CRYPTO_MIGRATION_FILENAME))) { + score += 3; + } + if (fs.existsSync(path.join(rootDir, RECOVERY_KEY_FILENAME))) { + score += 2; + } + if (fs.existsSync(path.join(rootDir, IDB_SNAPSHOT_FILENAME))) { + score += 2; + } + if (fs.existsSync(path.join(rootDir, STORAGE_META_FILENAME))) { + score += 1; + } + return score; +} + +function resolveStorageRootMtimeMs(rootDir: string): number { + try { + return fs.statSync(rootDir).mtimeMs; + } catch { + return 0; + } +} + +function resolvePreferredMatrixStorageRoot(params: { + canonicalRootDir: string; + canonicalTokenHash: string; +}): { + rootDir: string; + tokenHash: string; +} { + const parentDir = path.dirname(params.canonicalRootDir); + const bestCurrentScore = scoreStorageRoot(params.canonicalRootDir); + let best = { + rootDir: params.canonicalRootDir, + tokenHash: params.canonicalTokenHash, + score: bestCurrentScore, + mtimeMs: resolveStorageRootMtimeMs(params.canonicalRootDir), + }; + + let siblingEntries: fs.Dirent[] = []; + try { + siblingEntries = fs.readdirSync(parentDir, { withFileTypes: true }); + } catch { + return { + rootDir: best.rootDir, + tokenHash: best.tokenHash, + }; + } + + for (const entry of siblingEntries) { + if (!entry.isDirectory()) { + continue; + } + if (entry.name === params.canonicalTokenHash) { + continue; + } + const candidateRootDir = path.join(parentDir, entry.name); + const candidateScore = scoreStorageRoot(candidateRootDir); + if (candidateScore <= 0) { + continue; + } + const candidateMtimeMs = resolveStorageRootMtimeMs(candidateRootDir); + if ( + candidateScore > best.score || + (best.rootDir !== params.canonicalRootDir && + candidateScore === best.score && + candidateMtimeMs > best.mtimeMs) + ) { + best = { + rootDir: candidateRootDir, + tokenHash: entry.name, + score: candidateScore, + mtimeMs: candidateMtimeMs, + }; + } + } + + return { + rootDir: best.rootDir, + tokenHash: best.tokenHash, + }; +} + export function resolveMatrixStoragePaths(params: { homeserver: string; userId: string; accessToken: string; accountId?: string | null; env?: NodeJS.ProcessEnv; + stateDir?: string; }): MatrixStoragePaths { const env = params.env ?? process.env; - const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir); - const { rootDir, accountKey, tokenHash } = resolveMatrixAccountStorageRoot({ + const stateDir = params.stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir); + const canonical = resolveMatrixAccountStorageRoot({ stateDir, homeserver: params.homeserver, userId: params.userId, accessToken: params.accessToken, accountId: params.accountId, }); + const { rootDir, tokenHash } = resolvePreferredMatrixStorageRoot({ + canonicalRootDir: canonical.rootDir, + canonicalTokenHash: canonical.tokenHash, + }); return { rootDir, storagePath: path.join(rootDir, "bot-storage.json"), cryptoPath: path.join(rootDir, "crypto"), metaPath: path.join(rootDir, STORAGE_META_FILENAME), recoveryKeyPath: path.join(rootDir, "recovery-key.json"), - idbSnapshotPath: path.join(rootDir, "crypto-idb-snapshot.json"), - accountKey, + idbSnapshotPath: path.join(rootDir, IDB_SNAPSHOT_FILENAME), + accountKey: canonical.accountKey, tokenHash, }; } diff --git a/extensions/matrix/src/matrix/client/types.ts b/extensions/matrix/src/matrix/client/types.ts index 8830062e942..6b189af6a95 100644 --- a/extensions/matrix/src/matrix/client/types.ts +++ b/extensions/matrix/src/matrix/client/types.ts @@ -12,9 +12,10 @@ export type MatrixResolvedConfig = { /** * Authenticated Matrix configuration. * Note: deviceId is NOT included here because it's implicit in the accessToken. - * The crypto storage assumes the device ID (and thus access token) does not change - * between restarts. If the access token becomes invalid or crypto storage is lost, - * both will need to be recreated together. + * Matrix storage reuses the most complete account-scoped root it can find for the + * same homeserver/user/account tuple so token refreshes do not strand prior state. + * If the device identity itself changes or crypto storage is lost, crypto state may + * still need to be recreated together with the new access token. */ export type MatrixAuth = { accountId: string; diff --git a/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.ts b/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.ts index 5788d1ac1a7..5804ab8adae 100644 --- a/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.ts +++ b/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.ts @@ -1,12 +1,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { - readJsonFileWithFallback, - resolveMatrixAccountStorageRoot, - writeJsonFileAtomically, -} from "openclaw/plugin-sdk/matrix"; +import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/matrix"; import { getMatrixRuntime } from "../../runtime.js"; +import { resolveMatrixStoragePaths } from "../client/storage.js"; import type { MatrixAuth } from "../client/types.js"; import type { MatrixClient } from "../sdk.js"; @@ -51,12 +48,12 @@ async function resolvePendingMigrationStatePath(params: { statePath: string; value: MatrixLegacyCryptoMigrationState | null; }> { - const { rootDir } = resolveMatrixAccountStorageRoot({ - stateDir: params.stateDir, + const { rootDir } = resolveMatrixStoragePaths({ homeserver: params.auth.homeserver, userId: params.auth.userId, accessToken: params.auth.accessToken, accountId: params.auth.accountId, + stateDir: params.stateDir, }); const directStatePath = path.join(rootDir, "legacy-crypto-migration.json"); const { value: directValue } = diff --git a/extensions/matrix/src/matrix/thread-bindings.test.ts b/extensions/matrix/src/matrix/thread-bindings.test.ts index ecf77e11733..d903c57cc11 100644 --- a/extensions/matrix/src/matrix/thread-bindings.test.ts +++ b/extensions/matrix/src/matrix/thread-bindings.test.ts @@ -234,6 +234,78 @@ describe("matrix thread bindings", () => { ); }); + it("reloads persisted bindings after the Matrix access token changes", async () => { + const initialAuth = { + ...auth, + accessToken: "token-old", + }; + const rotatedAuth = { + ...auth, + accessToken: "token-new", + }; + + const initialManager = await createMatrixThreadBindingManager({ + accountId: "ops", + auth: initialAuth, + client: {} as never, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + enableSweeper: false, + }); + + await getSessionBindingService().bind({ + targetSessionKey: "agent:ops:subagent:child", + targetKind: "subagent", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "$thread", + parentConversationId: "!room:example", + }, + placement: "current", + }); + + initialManager.stop(); + resetMatrixThreadBindingsForTests(); + __testing.resetSessionBindingAdaptersForTests(); + + await createMatrixThreadBindingManager({ + accountId: "ops", + auth: rotatedAuth, + client: {} as never, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + enableSweeper: false, + }); + + expect( + getSessionBindingService().resolveByConversation({ + channel: "matrix", + accountId: "ops", + conversationId: "$thread", + parentConversationId: "!room:example", + }), + ).toMatchObject({ + targetSessionKey: "agent:ops:subagent:child", + }); + + const initialBindingsPath = path.join( + resolveMatrixStoragePaths({ + ...initialAuth, + env: process.env, + }).rootDir, + "thread-bindings.json", + ); + const rotatedBindingsPath = path.join( + resolveMatrixStoragePaths({ + ...rotatedAuth, + env: process.env, + }).rootDir, + "thread-bindings.json", + ); + expect(rotatedBindingsPath).toBe(initialBindingsPath); + }); + it("updates lifecycle windows by session key and refreshes activity", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-06T10:00:00.000Z"));