Skip to content

Commit f420157

Browse files
authored
fix(matrix): warn on accumulated token storage roots (#97353)
1 parent 1a15879 commit f420157

3 files changed

Lines changed: 117 additions & 33 deletions

File tree

docs/channels/matrix.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,8 @@ openclaw matrix devices prune-stale
499499

500500
Encrypted runtime state lives under `~/.openclaw/matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/` and includes the sync store, crypto store, recovery key, IDB snapshot, thread bindings, and startup verification state. When the token changes but the account identity stays the same, OpenClaw reuses the best existing root so prior state remains visible.
501501

502+
A single older token-hash root can be a normal token-rotation continuity path. If OpenClaw logs `matrix: multiple populated token-hash storage roots detected`, inspect the account directory and archive stale sibling roots only after confirming the selected active root is healthy. Prefer moving stale roots into an `_archive/` directory over deleting them immediately.
503+
502504
</Accordion>
503505
</AccordionGroup>
504506

extensions/matrix/src/matrix/client/storage.test.ts

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,21 @@ describe("matrix client storage paths", () => {
8585
}
8686
});
8787

88+
function createTestLogger() {
89+
return {
90+
info: vi.fn(),
91+
warn: vi.fn(),
92+
error: vi.fn(),
93+
};
94+
}
95+
8896
function setupStateDir(
8997
cfg: Record<string, unknown> = {
9098
channels: {
9199
matrix: {},
92100
},
93101
},
102+
logger = createTestLogger(),
94103
): string {
95104
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-storage-"));
96105
const stateDir = path.join(homeDir, ".openclaw");
@@ -99,11 +108,7 @@ describe("matrix client storage paths", () => {
99108
installMatrixTestRuntime({
100109
cfg,
101110
logging: {
102-
getChildLogger: () => ({
103-
info: () => {},
104-
warn: () => {},
105-
error: () => {},
106-
}),
111+
getChildLogger: () => logger,
107112
},
108113
stateDir,
109114
});
@@ -662,7 +667,8 @@ describe("matrix client storage paths", () => {
662667
});
663668

664669
it("reuses an existing token-hash storage root for the same device after the access token changes", () => {
665-
setupStateDir();
670+
const logger = createTestLogger();
671+
setupStateDir(undefined, logger);
666672
const oldStoragePaths = seedExistingStorageRoot({
667673
accessToken: "secret-token-old",
668674
deviceId: "DEVICE123",
@@ -683,6 +689,54 @@ describe("matrix client storage paths", () => {
683689
expect(rotatedStoragePaths.rootDir).toBe(oldStoragePaths.rootDir);
684690
expect(rotatedStoragePaths.tokenHash).toBe(oldStoragePaths.tokenHash);
685691
expect(rotatedStoragePaths.storagePath).toBe(oldStoragePaths.storagePath);
692+
expect(logger.warn).not.toHaveBeenCalled();
693+
});
694+
695+
it("warns with structured metadata when populated token-hash storage roots accumulate", () => {
696+
const logger = createTestLogger();
697+
const stateDir = setupStateDir(undefined, logger);
698+
const oldStoragePaths = seedExistingStorageRoot({
699+
accessToken: "secret-token-old",
700+
deviceId: "DEVICE123",
701+
storageMeta: {
702+
homeserver: defaultStorageAuth.homeserver,
703+
userId: defaultStorageAuth.userId,
704+
accountId: "default",
705+
accessTokenHash: resolveDefaultStoragePaths({ accessToken: "secret-token-old" }).tokenHash,
706+
deviceId: "DEVICE123",
707+
},
708+
});
709+
const canonicalPaths = seedCanonicalStorageRoot({
710+
stateDir,
711+
accessToken: "secret-token-new",
712+
storageMeta: {
713+
homeserver: defaultStorageAuth.homeserver,
714+
userId: defaultStorageAuth.userId,
715+
accountId: "default",
716+
accessTokenHash: resolveDefaultStoragePaths({ accessToken: "secret-token-new" }).tokenHash,
717+
deviceId: "DEVICE123",
718+
},
719+
});
720+
fs.mkdirSync(path.join(canonicalPaths.rootDir, "crypto"), { recursive: true });
721+
722+
const resolvedPaths = resolveDefaultStoragePaths({
723+
accessToken: "secret-token-new",
724+
deviceId: "DEVICE123",
725+
});
726+
727+
expect(resolvedPaths.rootDir).toBe(canonicalPaths.rootDir);
728+
expect(logger.warn).toHaveBeenCalledTimes(1);
729+
expect(logger.warn).toHaveBeenCalledWith(
730+
"matrix: multiple populated token-hash storage roots detected",
731+
{
732+
parentDir: path.dirname(canonicalPaths.rootDir),
733+
canonicalTokenHash: canonicalPaths.tokenHash,
734+
selectedTokenHash: canonicalPaths.tokenHash,
735+
populatedTokenHashes: [canonicalPaths.tokenHash, oldStoragePaths.tokenHash],
736+
populatedSiblingTokenHashes: [oldStoragePaths.tokenHash],
737+
populatedRootCount: 2,
738+
},
739+
);
686740
});
687741

688742
it("reads legacy storage metadata until doctor migrates it to SQLite", () => {

extensions/matrix/src/matrix/client/storage.ts

Lines changed: 55 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,13 @@ function resolveStorageRootMtimeMs(rootDir: string): number {
139139
}
140140
}
141141

142+
type PopulatedMatrixStorageRoot = {
143+
tokenHash: string;
144+
rootDir: string;
145+
score: number;
146+
mtimeMs: number;
147+
};
148+
142149
export function normalizeMatrixStorageMetadata(value: unknown): MatrixStorageMetadata | null {
143150
if (!isRecord(value)) {
144151
return null;
@@ -251,11 +258,12 @@ function resolvePreferredMatrixStorageRoot(params: {
251258
} {
252259
const parentDir = path.dirname(params.canonicalRootDir);
253260
const bestCurrentScore = scoreStorageRoot(params.canonicalRootDir);
261+
const bestCurrentMtimeMs = resolveStorageRootMtimeMs(params.canonicalRootDir);
254262
let best = {
255263
rootDir: params.canonicalRootDir,
256264
tokenHash: params.canonicalTokenHash,
257265
score: bestCurrentScore,
258-
mtimeMs: resolveStorageRootMtimeMs(params.canonicalRootDir),
266+
mtimeMs: bestCurrentMtimeMs,
259267
};
260268

261269
// Without a confirmed device identity, reusing a populated sibling root after
@@ -267,18 +275,6 @@ function resolvePreferredMatrixStorageRoot(params: {
267275
};
268276
}
269277

270-
const canonicalMetadata = readStoredRootMetadata(params.canonicalRootDir);
271-
if (
272-
canonicalMetadata.accessTokenHash === params.canonicalTokenHash &&
273-
canonicalMetadata.deviceId?.trim() === params.deviceId.trim() &&
274-
canonicalMetadata.currentTokenStateClaimed === true
275-
) {
276-
return {
277-
rootDir: best.rootDir,
278-
tokenHash: best.tokenHash,
279-
};
280-
}
281-
282278
let siblingEntries: fs.Dirent[];
283279
try {
284280
siblingEntries = fs.readdirSync(parentDir, { withFileTypes: true });
@@ -289,7 +285,9 @@ function resolvePreferredMatrixStorageRoot(params: {
289285
};
290286
}
291287

292-
for (const entry of siblingEntries) {
288+
const compatiblePopulatedSiblings: PopulatedMatrixStorageRoot[] = [];
289+
const populatedTokenHashes = bestCurrentScore > 0 ? [params.canonicalTokenHash] : [];
290+
for (const entry of siblingEntries.toSorted((a, b) => a.name.localeCompare(b.name))) {
293291
if (!entry.isDirectory()) {
294292
continue;
295293
}
@@ -315,22 +313,52 @@ function resolvePreferredMatrixStorageRoot(params: {
315313
if (candidateScore <= 0) {
316314
continue;
317315
}
318-
const candidateMtimeMs = resolveStorageRootMtimeMs(candidateRootDir);
319-
if (
320-
candidateScore > best.score ||
321-
(best.rootDir !== params.canonicalRootDir &&
322-
candidateScore === best.score &&
323-
candidateMtimeMs > best.mtimeMs)
324-
) {
325-
best = {
326-
rootDir: candidateRootDir,
327-
tokenHash: entry.name,
328-
score: candidateScore,
329-
mtimeMs: candidateMtimeMs,
330-
};
316+
populatedTokenHashes.push(entry.name);
317+
compatiblePopulatedSiblings.push({
318+
rootDir: candidateRootDir,
319+
tokenHash: entry.name,
320+
score: candidateScore,
321+
mtimeMs: resolveStorageRootMtimeMs(candidateRootDir),
322+
});
323+
}
324+
325+
const canonicalMetadata = readStoredRootMetadata(params.canonicalRootDir);
326+
const shouldKeepCanonicalCurrentRoot =
327+
canonicalMetadata.accessTokenHash === params.canonicalTokenHash &&
328+
canonicalMetadata.deviceId?.trim() === params.deviceId.trim() &&
329+
canonicalMetadata.currentTokenStateClaimed === true;
330+
331+
if (!shouldKeepCanonicalCurrentRoot) {
332+
for (const candidate of compatiblePopulatedSiblings) {
333+
if (
334+
candidate.score > best.score ||
335+
(best.rootDir !== params.canonicalRootDir &&
336+
candidate.score === best.score &&
337+
candidate.mtimeMs > best.mtimeMs)
338+
) {
339+
best = {
340+
rootDir: candidate.rootDir,
341+
tokenHash: candidate.tokenHash,
342+
score: candidate.score,
343+
mtimeMs: candidate.mtimeMs,
344+
};
345+
}
331346
}
332347
}
333348

349+
if (populatedTokenHashes.length > 1) {
350+
getMatrixRuntime()
351+
.logging.getChildLogger({ module: "matrix-storage" })
352+
.warn("matrix: multiple populated token-hash storage roots detected", {
353+
parentDir,
354+
canonicalTokenHash: params.canonicalTokenHash,
355+
selectedTokenHash: best.tokenHash,
356+
populatedTokenHashes,
357+
populatedSiblingTokenHashes: compatiblePopulatedSiblings.map((root) => root.tokenHash),
358+
populatedRootCount: populatedTokenHashes.length,
359+
});
360+
}
361+
334362
return {
335363
rootDir: best.rootDir,
336364
tokenHash: best.tokenHash,

0 commit comments

Comments
 (0)