mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 01:00:23 +00:00
Matrix: harden migration workflow
This commit is contained in:
@@ -31,7 +31,7 @@ export async function createMatrixClient(params: {
|
||||
accountId: params.accountId,
|
||||
env,
|
||||
});
|
||||
maybeMigrateLegacyStorage({
|
||||
await maybeMigrateLegacyStorage({
|
||||
storagePaths,
|
||||
env,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user