Matrix: preserve storage across token changes

This commit is contained in:
Gustavo Madeira Santana
2026-03-10 20:30:02 -04:00
parent ae61eaff3a
commit 3ff6b8ceb2
5 changed files with 245 additions and 14 deletions

View File

@@ -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);
});
});

View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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 } =

View File

@@ -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"));