mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Matrix: preserve storage across token changes
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user