Matrix: harden migration workflow

This commit is contained in:
Gustavo Madeira Santana
2026-03-10 20:07:40 -04:00
parent 25928f0d4d
commit 7f5225a365
25 changed files with 1504 additions and 382 deletions

View File

@@ -31,7 +31,7 @@ export async function createMatrixClient(params: {
accountId: params.accountId,
env,
});
maybeMigrateLegacyStorage({
await maybeMigrateLegacyStorage({
storagePaths,
env,
});

View File

@@ -1,14 +1,27 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { setMatrixRuntime } from "../../runtime.js";
import { maybeMigrateLegacyStorage, resolveMatrixStoragePaths } from "./storage.js";
const maybeCreateMatrixMigrationSnapshotMock = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("openclaw/plugin-sdk/matrix", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/matrix")>();
return {
...actual,
maybeCreateMatrixMigrationSnapshot: (params: unknown) =>
maybeCreateMatrixMigrationSnapshotMock(params),
};
});
describe("matrix client storage paths", () => {
const tempDirs: string[] = [];
afterEach(() => {
maybeCreateMatrixMigrationSnapshotMock.mockReset();
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
@@ -18,6 +31,13 @@ describe("matrix client storage paths", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-storage-"));
tempDirs.push(dir);
setMatrixRuntime({
logging: {
getChildLogger: () => ({
info: () => {},
warn: () => {},
error: () => {},
}),
},
state: {
resolveStateDir: () => dir,
},
@@ -55,7 +75,7 @@ describe("matrix client storage paths", () => {
);
});
it("falls back to migrating the older flat matrix storage layout", () => {
it("falls back to migrating the older flat matrix storage layout", async () => {
const stateDir = setupStateDir();
const storagePaths = resolveMatrixStoragePaths({
homeserver: "https://matrix.example.org",
@@ -67,13 +87,70 @@ describe("matrix client storage paths", () => {
fs.mkdirSync(path.join(legacyRoot, "crypto"), { recursive: true });
fs.writeFileSync(path.join(legacyRoot, "bot-storage.json"), '{"legacy":true}');
maybeMigrateLegacyStorage({
await maybeMigrateLegacyStorage({
storagePaths,
env: {},
});
expect(maybeCreateMatrixMigrationSnapshotMock).toHaveBeenCalledWith(
expect.objectContaining({ trigger: "matrix-client-fallback" }),
);
expect(fs.existsSync(path.join(legacyRoot, "bot-storage.json"))).toBe(false);
expect(fs.readFileSync(storagePaths.storagePath, "utf8")).toBe('{"legacy":true}');
expect(fs.existsSync(storagePaths.cryptoPath)).toBe(true);
});
it("refuses to migrate legacy storage when the snapshot step fails", async () => {
const stateDir = setupStateDir();
const storagePaths = resolveMatrixStoragePaths({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "secret-token",
env: {},
});
const legacyRoot = path.join(stateDir, "matrix");
fs.mkdirSync(path.join(legacyRoot, "crypto"), { recursive: true });
fs.writeFileSync(path.join(legacyRoot, "bot-storage.json"), '{"legacy":true}');
maybeCreateMatrixMigrationSnapshotMock.mockRejectedValueOnce(new Error("snapshot failed"));
await expect(
maybeMigrateLegacyStorage({
storagePaths,
env: {},
}),
).rejects.toThrow("snapshot failed");
expect(fs.existsSync(path.join(legacyRoot, "bot-storage.json"))).toBe(true);
expect(fs.existsSync(storagePaths.storagePath)).toBe(false);
});
it("rolls back moved legacy storage when the crypto move fails", async () => {
const stateDir = setupStateDir();
const storagePaths = resolveMatrixStoragePaths({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "secret-token",
env: {},
});
const legacyRoot = path.join(stateDir, "matrix");
fs.mkdirSync(path.join(legacyRoot, "crypto"), { recursive: true });
fs.writeFileSync(path.join(legacyRoot, "bot-storage.json"), '{"legacy":true}');
const realRenameSync = fs.renameSync.bind(fs);
const renameSync = vi.spyOn(fs, "renameSync");
renameSync.mockImplementation((sourcePath, targetPath) => {
if (String(targetPath) === storagePaths.cryptoPath) {
throw new Error("disk full");
}
return realRenameSync(sourcePath, targetPath);
});
await expect(
maybeMigrateLegacyStorage({
storagePaths,
env: {},
}),
).rejects.toThrow("disk full");
expect(fs.existsSync(path.join(legacyRoot, "bot-storage.json"))).toBe(true);
expect(fs.existsSync(storagePaths.storagePath)).toBe(false);
expect(fs.existsSync(path.join(legacyRoot, "crypto"))).toBe(true);
});
});

View File

@@ -2,6 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
maybeCreateMatrixMigrationSnapshot,
resolveMatrixAccountStorageRoot,
resolveMatrixLegacyFlatStoragePaths,
} from "openclaw/plugin-sdk/matrix";
@@ -11,6 +12,12 @@ import type { MatrixStoragePaths } from "./types.js";
export const DEFAULT_ACCOUNT_KEY = "default";
const STORAGE_META_FILENAME = "storage-meta.json";
type LegacyMoveRecord = {
sourcePath: string;
targetPath: string;
label: string;
};
function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): {
storagePath: string;
cryptoPath: string;
@@ -48,10 +55,10 @@ export function resolveMatrixStoragePaths(params: {
};
}
export function maybeMigrateLegacyStorage(params: {
export async function maybeMigrateLegacyStorage(params: {
storagePaths: MatrixStoragePaths;
env?: NodeJS.ProcessEnv;
}): void {
}): Promise<void> {
const hasNewStorage =
fs.existsSync(params.storagePaths.storagePath) || fs.existsSync(params.storagePaths.cryptoPath);
if (hasNewStorage) {
@@ -65,21 +72,82 @@ export function maybeMigrateLegacyStorage(params: {
return;
}
const logger = getMatrixRuntime().logging.getChildLogger({ module: "matrix-storage" });
await maybeCreateMatrixMigrationSnapshot({
trigger: "matrix-client-fallback",
env: params.env,
log: logger,
});
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
if (hasLegacyStorage) {
const moved: LegacyMoveRecord[] = [];
try {
if (hasLegacyStorage) {
moveLegacyStoragePathOrThrow({
sourcePath: legacy.storagePath,
targetPath: params.storagePaths.storagePath,
label: "sync store",
moved,
});
}
if (hasLegacyCrypto) {
moveLegacyStoragePathOrThrow({
sourcePath: legacy.cryptoPath,
targetPath: params.storagePaths.cryptoPath,
label: "crypto store",
moved,
});
}
} catch (err) {
const rollbackError = rollbackLegacyMoves(moved);
throw new Error(
rollbackError
? `Failed migrating legacy Matrix client storage: ${String(err)}. Rollback also failed: ${rollbackError}`
: `Failed migrating legacy Matrix client storage: ${String(err)}`,
);
}
if (moved.length > 0) {
logger.info(
`matrix: migrated legacy client storage into ${params.storagePaths.rootDir}\n${moved
.map((entry) => `- ${entry.label}: ${entry.sourcePath} -> ${entry.targetPath}`)
.join("\n")}`,
);
}
}
function moveLegacyStoragePathOrThrow(params: {
sourcePath: string;
targetPath: string;
label: string;
moved: LegacyMoveRecord[];
}): void {
if (!fs.existsSync(params.sourcePath)) {
return;
}
if (fs.existsSync(params.targetPath)) {
throw new Error(
`legacy Matrix ${params.label} target already exists (${params.targetPath}); refusing to overwrite it automatically`,
);
}
fs.renameSync(params.sourcePath, params.targetPath);
params.moved.push({
sourcePath: params.sourcePath,
targetPath: params.targetPath,
label: params.label,
});
}
function rollbackLegacyMoves(moved: LegacyMoveRecord[]): string | null {
for (const entry of moved.toReversed()) {
try {
fs.renameSync(legacy.storagePath, params.storagePaths.storagePath);
} catch {
// Ignore migration failures; new store will be created.
}
}
if (hasLegacyCrypto) {
try {
fs.renameSync(legacy.cryptoPath, params.storagePaths.cryptoPath);
} catch {
// Ignore migration failures; new store will be created.
if (!fs.existsSync(entry.targetPath) || fs.existsSync(entry.sourcePath)) {
continue;
}
fs.renameSync(entry.targetPath, entry.sourcePath);
} catch (err) {
return `${entry.label} (${entry.targetPath} -> ${entry.sourcePath}): ${String(err)}`;
}
}
return null;
}
export function writeStorageMeta(params: {

View File

@@ -145,4 +145,72 @@ describe("maybeRestoreLegacyMatrixBackup", () => {
expect(state.lastError).toBe("backup unavailable");
});
});
it("restores from a sibling token-hash directory when the access token changed", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
const oldAuth = {
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-old",
};
const newAuth = {
...oldAuth,
accessToken: "tok-new",
};
const { rootDir: oldRootDir } = resolveMatrixAccountStorageRoot({
stateDir,
...oldAuth,
});
const { rootDir: newRootDir } = resolveMatrixAccountStorageRoot({
stateDir,
...newAuth,
});
writeFile(
path.join(oldRootDir, "legacy-crypto-migration.json"),
JSON.stringify({
version: 1,
accountId: "default",
roomKeyCounts: { total: 3, backedUp: 3 },
restoreStatus: "pending",
}),
);
const restoreRoomKeyBackup = vi.fn(async () => ({
success: true,
restoredAt: "2026-03-08T10:00:00.000Z",
imported: 3,
total: 3,
loadedFromSecretStorage: true,
backupVersion: "1",
backup: createBackupStatus(),
}));
const result = await maybeRestoreLegacyMatrixBackup({
client: { restoreRoomKeyBackup },
auth: newAuth,
stateDir,
env: {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
HOME: home,
},
});
expect(result).toEqual({
kind: "restored",
imported: 3,
total: 3,
localOnlyKeys: 0,
});
const oldState = JSON.parse(
fs.readFileSync(path.join(oldRootDir, "legacy-crypto-migration.json"), "utf8"),
) as {
restoreStatus: string;
};
expect(oldState.restoreStatus).toBe("completed");
expect(fs.existsSync(path.join(newRootDir, "legacy-crypto-migration.json"))).toBe(false);
});
});
});

View File

@@ -1,3 +1,4 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
@@ -43,6 +44,52 @@ function isMigrationState(value: unknown): value is MatrixLegacyCryptoMigrationS
);
}
async function resolvePendingMigrationStatePath(params: {
stateDir: string;
auth: Pick<MatrixAuth, "homeserver" | "userId" | "accessToken" | "accountId">;
}): Promise<{
statePath: string;
value: MatrixLegacyCryptoMigrationState | null;
}> {
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir: params.stateDir,
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
accountId: params.auth.accountId,
});
const directStatePath = path.join(rootDir, "legacy-crypto-migration.json");
const { value: directValue } =
await readJsonFileWithFallback<MatrixLegacyCryptoMigrationState | null>(directStatePath, null);
if (isMigrationState(directValue) && directValue.restoreStatus === "pending") {
return { statePath: directStatePath, value: directValue };
}
const accountStorageDir = path.dirname(rootDir);
let siblingEntries: string[] = [];
try {
siblingEntries = (await fs.readdir(accountStorageDir, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((entry) => path.join(accountStorageDir, entry) !== rootDir)
.toSorted((left, right) => left.localeCompare(right));
} catch {
return { statePath: directStatePath, value: directValue };
}
for (const sibling of siblingEntries) {
const siblingStatePath = path.join(accountStorageDir, sibling, "legacy-crypto-migration.json");
const { value } = await readJsonFileWithFallback<MatrixLegacyCryptoMigrationState | null>(
siblingStatePath,
null,
);
if (isMigrationState(value) && value.restoreStatus === "pending") {
return { statePath: siblingStatePath, value };
}
}
return { statePath: directStatePath, value: directValue };
}
export async function maybeRestoreLegacyMatrixBackup(params: {
client: Pick<MatrixClient, "restoreRoomKeyBackup">;
auth: Pick<MatrixAuth, "homeserver" | "userId" | "accessToken" | "accountId">;
@@ -51,18 +98,10 @@ export async function maybeRestoreLegacyMatrixBackup(params: {
}): Promise<MatrixLegacyCryptoRestoreResult> {
const env = params.env ?? process.env;
const stateDir = params.stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir);
const { rootDir } = resolveMatrixAccountStorageRoot({
const { statePath, value } = await resolvePendingMigrationStatePath({
stateDir,
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
accountId: params.auth.accountId,
auth: params.auth,
});
const statePath = path.join(rootDir, "legacy-crypto-migration.json");
const { value } = await readJsonFileWithFallback<MatrixLegacyCryptoMigrationState | null>(
statePath,
null,
);
if (!isMigrationState(value) || value.restoreStatus !== "pending") {
return { kind: "skipped" };
}