Matrix: harden migration workflow

This commit is contained in:
Gustavo Madeira Santana
2026-03-10 20:07:40 -04:00
parent 9720b75191
commit ae61eaff3a
25 changed files with 1326 additions and 382 deletions

View File

@@ -330,7 +330,10 @@ If the current device is already owner-signed, OpenClaw preserves that identity
Upgrading from the previous public Matrix plugin:
- OpenClaw automatically reuses the same Matrix account, access token, and device identity when possible.
- Before any actionable Matrix migration changes run, OpenClaw creates or reuses a recovery snapshot under `~/Backups/openclaw-migrations/`.
- If you use multiple Matrix accounts, set `channels.matrix.defaultAccount` before upgrading from the old flat-store layout so OpenClaw knows which account should receive that shared legacy state.
- If the previous plugin stored a Matrix room-key backup decryption key locally, startup or `openclaw doctor --fix` will import it into the new recovery-key flow automatically.
- If the Matrix access token changed after migration was prepared, startup now scans sibling token-hash storage roots for pending legacy restore state before giving up on the automatic backup restore.
- On the next gateway start, backed-up room keys are restored automatically into the new crypto store.
- If the old plugin had local-only room keys that were never backed up, OpenClaw will warn clearly. Those keys cannot be exported automatically from the previous rust crypto store, so some old encrypted history may remain unavailable until recovered manually.
- See [Matrix migration](/install/migrating-matrix) for the full upgrade flow, limits, recovery commands, and common migration messages.

View File

@@ -178,6 +178,11 @@ Doctor only auto-migrates `notify: true` jobs when it can do so without
changing behavior. If a job combines legacy notify fallback with an existing
non-webhook delivery mode, doctor warns and leaves that job for manual review.
Matrix upgrades use the same repair flow. When `openclaw doctor --fix` has actionable
Matrix migration work, it creates or reuses a pre-migration snapshot under
`~/Backups/openclaw-migrations/` before mutating Matrix state. If that snapshot
step fails, doctor warns and skips the Matrix migration changes for that run.
### 4) State integrity checks (session persistence, routing, and safety)
The state directory is the operational brainstem. If it vanishes, you lose

View File

@@ -23,6 +23,7 @@ You do not need to rename config keys or reinstall the plugin under a new name.
## What the migration does automatically
When the gateway starts, and when you run [`openclaw doctor --fix`](/gateway/doctor), OpenClaw tries to repair old Matrix state automatically.
Before any actionable Matrix migration step mutates on-disk state, OpenClaw creates or reuses a focused recovery snapshot.
When you use `openclaw update`, the exact trigger depends on how OpenClaw is installed:
@@ -32,13 +33,22 @@ When you use `openclaw update`, the exact trigger depends on how OpenClaw is ins
Automatic migration covers:
- creating or reusing a pre-migration snapshot under `~/Backups/openclaw-migrations/`
- reusing your cached Matrix credentials
- keeping the same account selection and `channels.matrix` config
- moving the oldest flat Matrix sync store into the current account-scoped location
- moving the oldest flat Matrix crypto store into the current account-scoped location when the target account can be resolved safely
- extracting a previously saved Matrix room-key backup decryption key from the old rust crypto store, when that key exists locally
- scanning sibling token-hash storage roots for pending encrypted-state restore metadata when the Matrix access token changed but the account/device identity stayed the same
- restoring backed-up room keys into the new crypto store on the next Matrix startup
Snapshot details:
- OpenClaw writes a marker file at `~/.openclaw/matrix/migration-snapshot.json` after a successful snapshot so later startup and repair passes can reuse the same archive.
- These automatic Matrix migration snapshots back up config + state only (`includeWorkspace: false`).
- If Matrix only has warning-only migration state, for example because `userId` or `accessToken` is still missing, OpenClaw does not create the snapshot yet because no Matrix mutation is actionable.
- If the snapshot step fails, OpenClaw skips Matrix migration for that run instead of mutating state without a recovery point.
About multi-account upgrades:
- the oldest flat Matrix store (`~/.openclaw/matrix/bot-storage.json` and `~/.openclaw/matrix/crypto/`) came from a single-store layout, so OpenClaw can only migrate it into one resolved Matrix account target
@@ -54,12 +64,13 @@ OpenClaw cannot automatically recover:
- local-only room keys that were never backed up
- encrypted state when the target Matrix account cannot be resolved yet because `homeserver`, `userId`, or `accessToken` are still unavailable
- custom plugin path installs that now point at a missing directory
- automatic migration of one shared flat Matrix store when multiple Matrix accounts are configured but `channels.matrix.defaultAccount` is not set
- custom plugin path installs that are pinned to a repo path instead of the standard Matrix package
- a missing recovery key when the old store had backed-up keys but did not keep the decryption key locally
Current warning scope:
- stale custom Matrix plugin path installs are surfaced by both gateway startup and `openclaw doctor`
- custom Matrix plugin path installs are surfaced by both gateway startup and `openclaw doctor`
If your old installation had local-only encrypted history that was never backed up, some older encrypted messages may remain unreadable after the upgrade.
@@ -73,6 +84,8 @@ If your old installation had local-only encrypted history that was never backed
openclaw doctor --fix
```
If Matrix has actionable migration work, doctor will create or reuse the pre-migration snapshot first and print the archive path.
3. Start or restart the gateway.
4. Check current verification and backup state:
@@ -109,9 +122,10 @@ If your old installation had local-only encrypted history that was never backed
Encrypted migration is a two-stage process:
1. Startup or `openclaw doctor --fix` inspects the old Matrix crypto store.
2. If a backup decryption key is found, OpenClaw writes it into the new recovery-key flow and marks room-key restore as pending.
3. On the next Matrix startup, OpenClaw restores backed-up room keys into the new crypto store automatically.
1. Startup or `openclaw doctor --fix` creates or reuses the pre-migration snapshot if encrypted migration is actionable.
2. Startup or `openclaw doctor --fix` inspects the old Matrix crypto store.
3. If a backup decryption key is found, OpenClaw writes it into the new recovery-key flow and marks room-key restore as pending.
4. On the next Matrix startup, OpenClaw restores backed-up room keys into the new crypto store automatically.
If the old store reports room keys that were never backed up, OpenClaw warns instead of pretending recovery succeeded.
@@ -124,6 +138,16 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins
- Meaning: the old on-disk Matrix state was detected and migrated into the current layout.
- What to do: nothing unless the same output also includes warnings.
`Matrix migration snapshot created before applying Matrix upgrades.`
- Meaning: OpenClaw created a recovery archive before mutating Matrix state.
- What to do: keep the printed archive path until you confirm migration succeeded.
`Matrix migration snapshot reused before applying Matrix upgrades.`
- Meaning: OpenClaw found an existing Matrix migration snapshot marker and reused that archive instead of creating a duplicate backup.
- What to do: keep the printed archive path until you confirm migration succeeded.
`Legacy Matrix state detected at ... but channels.matrix is not configured yet.`
- Meaning: old Matrix state exists, but OpenClaw cannot map it to a current Matrix account because Matrix is not configured.
@@ -134,6 +158,11 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins
- Meaning: OpenClaw found old state, but it still cannot determine the exact current account/device root.
- What to do: start the gateway once with a working Matrix login, or rerun `openclaw doctor --fix` after cached credentials exist.
`Legacy Matrix state detected at ... but multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set.`
- Meaning: OpenClaw found one shared flat Matrix store, but it refuses to guess which named Matrix account should receive it.
- What to do: set `channels.matrix.defaultAccount` to the intended account, then rerun `openclaw doctor --fix` or restart the gateway.
`Matrix legacy sync store not migrated because the target already exists (...)`
- Meaning: the new account-scoped location already has a sync or crypto store, so OpenClaw did not overwrite it automatically.
@@ -154,6 +183,31 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins
- Meaning: the encrypted store exists, but OpenClaw cannot safely decide which current account/device it belongs to.
- What to do: start the gateway once with a working Matrix login, or rerun `openclaw doctor --fix` after cached credentials are available.
`Legacy Matrix encrypted state detected at ... but multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set.`
- Meaning: OpenClaw found one shared flat legacy crypto store, but it refuses to guess which named Matrix account should receive it.
- What to do: set `channels.matrix.defaultAccount` to the intended account, then rerun `openclaw doctor --fix` or restart the gateway.
`Matrix migration warnings are present, but no on-disk Matrix mutation is actionable yet. No pre-migration snapshot was needed.`
- Meaning: OpenClaw detected old Matrix state, but the migration is still blocked on missing identity or credential data.
- What to do: finish Matrix login or config setup, then rerun `openclaw doctor --fix` or restart the gateway.
`gateway: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ...`
- Meaning: OpenClaw refused to mutate Matrix state because it could not create the recovery snapshot first.
- What to do: resolve the backup error, then rerun `openclaw doctor --fix` or restart the gateway.
`Failed migrating legacy Matrix client storage: ...`
- Meaning: the Matrix client-side fallback found old flat storage, but the move failed. OpenClaw now aborts that fallback instead of silently starting with a fresh store.
- What to do: inspect filesystem permissions or conflicts, keep the old state intact, and retry after fixing the error.
`Matrix is installed from a custom path: ...`
- Meaning: Matrix is pinned to a path install, so mainline updates do not automatically replace it with the repo's standard Matrix package.
- What to do: reinstall with `openclaw plugins install @openclaw/matrix` when you want to return to the default Matrix plugin.
### Encrypted-state recovery messages
`matrix: restored X/Y room key(s) from legacy encrypted-state backup`

View File

@@ -133,7 +133,7 @@ openclaw doctor --fix
openclaw gateway restart
```
For Matrix upgrades specifically, startup is what finishes the in-place Matrix state migration and any pending backed-up room-key restore. See [Matrix migration](/install/migrating-matrix).
For Matrix upgrades specifically, `openclaw doctor --fix` or the next gateway startup will create or reuse a pre-migration snapshot under `~/Backups/openclaw-migrations/` before actionable Matrix state changes run. Startup is still what finishes the in-place Matrix state migration and any pending backed-up room-key restore. See [Matrix migration](/install/migrating-matrix).
If you installed via **npm/pnpm** (no git metadata), `openclaw update` will try to update via your package manager, run a non-interactive doctor pass when possible, and restart the gateway by default. If it cant detect the install, use “Update (global install)” instead.

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" };
}

View File

@@ -296,6 +296,42 @@ describe("doctor config flow", () => {
}
});
it("creates a Matrix migration snapshot before doctor repair mutates Matrix state", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
await fs.writeFile(
path.join(stateDir, "openclaw.json"),
JSON.stringify({
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
}),
);
await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
await loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true, repair: true },
confirm: async () => false,
});
const snapshotDir = path.join(home, "Backups", "openclaw-migrations");
const snapshotEntries = await fs.readdir(snapshotDir);
expect(snapshotEntries.some((entry) => entry.endsWith(".tar.gz"))).toBe(true);
const marker = JSON.parse(
await fs.readFile(path.join(stateDir, "matrix", "migration-snapshot.json"), "utf8"),
) as {
archivePath: string;
};
expect(marker.archivePath).toContain(path.join("Backups", "openclaw-migrations"));
});
});
it("warns when Matrix is installed from a stale custom path", async () => {
const doctorWarnings = await collectDoctorWarnings({
channels: {
@@ -325,6 +361,38 @@ describe("doctor config flow", () => {
).toBe(true);
});
it("warns when Matrix is installed from an existing custom path", async () => {
await withTempHome(async (home) => {
const pluginPath = path.join(home, "matrix-plugin");
await fs.mkdir(pluginPath, { recursive: true });
const doctorWarnings = await collectDoctorWarnings({
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "tok-123",
},
},
plugins: {
installs: {
matrix: {
source: "path",
sourcePath: pluginPath,
installPath: pluginPath,
},
},
},
});
expect(
doctorWarnings.some((line) => line.includes("Matrix is installed from a custom path")),
).toBe(true);
expect(
doctorWarnings.some((line) => line.includes("will not automatically replace that plugin")),
).toBe(true);
});
});
it("preserves discord streaming intent while stripping unsupported keys on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,

View File

@@ -26,10 +26,6 @@ import {
isTrustedSafeBinPath,
normalizeTrustedSafeBinDirs,
} from "../infra/exec-safe-bin-trust.js";
import {
detectMatrixInstallPathIssue,
formatMatrixInstallPathIssue,
} from "../infra/matrix-install-path-warnings.js";
import {
autoPrepareLegacyMatrixCrypto,
detectLegacyMatrixCrypto,
@@ -38,6 +34,15 @@ import {
autoMigrateLegacyMatrixState,
detectLegacyMatrixState,
} from "../infra/matrix-legacy-state.js";
import {
hasActionableMatrixMigration,
hasPendingMatrixMigration,
maybeCreateMatrixMigrationSnapshot,
} from "../infra/matrix-migration-snapshot.js";
import {
detectPluginInstallPathIssue,
formatPluginInstallPathIssue,
} from "../infra/plugin-install-path-warnings.js";
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
import {
formatChannelAccountsDefaultPath,
@@ -331,12 +336,18 @@ function formatMatrixLegacyCryptoPreview(
}
async function collectMatrixInstallPathWarnings(cfg: OpenClawConfig): Promise<string[]> {
const issue = await detectMatrixInstallPathIssue(cfg);
const issue = await detectPluginInstallPathIssue({
pluginId: "matrix",
install: cfg.plugins?.installs?.matrix,
});
if (!issue) {
return [];
}
return formatMatrixInstallPathIssue({
return formatPluginInstallPathIssue({
issue,
pluginLabel: "Matrix",
defaultInstallCommand: "openclaw plugins install @openclaw/matrix",
repoInstallCommand: "openclaw plugins install ./extensions/matrix",
formatCommand: formatCliCommand,
}).map((entry) => `- ${entry}`);
}
@@ -1797,39 +1808,80 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
cfg: candidate,
env: process.env,
});
const pendingMatrixMigration = hasPendingMatrixMigration({
cfg: candidate,
env: process.env,
});
const actionableMatrixMigration = hasActionableMatrixMigration({
cfg: candidate,
env: process.env,
});
if (shouldRepair) {
const matrixStateRepair = await autoMigrateLegacyMatrixState({
cfg: candidate,
env: process.env,
});
if (matrixStateRepair.changes.length > 0) {
let matrixSnapshotReady = true;
if (actionableMatrixMigration) {
try {
const snapshot = await maybeCreateMatrixMigrationSnapshot({
trigger: "doctor-fix",
env: process.env,
});
note(
`Matrix migration snapshot ${snapshot.created ? "created" : "reused"} before applying Matrix upgrades.\n- ${snapshot.archivePath}`,
"Doctor changes",
);
} catch (err) {
matrixSnapshotReady = false;
note(
`- Failed creating a Matrix migration snapshot before repair: ${String(err)}`,
"Doctor warnings",
);
note(
'- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".',
"Doctor warnings",
);
}
} else if (pendingMatrixMigration) {
note(
[
"Matrix plugin upgraded in place.",
...matrixStateRepair.changes.map((entry) => `- ${entry}`),
"- No user action required.",
].join("\n"),
"Doctor changes",
"- Matrix migration warnings are present, but no on-disk Matrix mutation is actionable yet. No pre-migration snapshot was needed.",
"Doctor warnings",
);
}
if (matrixStateRepair.warnings.length > 0) {
note(matrixStateRepair.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings");
}
const matrixCryptoRepair = await autoPrepareLegacyMatrixCrypto({
cfg: candidate,
env: process.env,
});
if (matrixCryptoRepair.changes.length > 0) {
note(
[
"Matrix encrypted-state migration prepared.",
...matrixCryptoRepair.changes.map((entry) => `- ${entry}`),
].join("\n"),
"Doctor changes",
);
}
if (matrixCryptoRepair.warnings.length > 0) {
note(matrixCryptoRepair.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings");
if (matrixSnapshotReady) {
const matrixStateRepair = await autoMigrateLegacyMatrixState({
cfg: candidate,
env: process.env,
});
if (matrixStateRepair.changes.length > 0) {
note(
[
"Matrix plugin upgraded in place.",
...matrixStateRepair.changes.map((entry) => `- ${entry}`),
"- No user action required.",
].join("\n"),
"Doctor changes",
);
}
if (matrixStateRepair.warnings.length > 0) {
note(matrixStateRepair.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings");
}
const matrixCryptoRepair = await autoPrepareLegacyMatrixCrypto({
cfg: candidate,
env: process.env,
});
if (matrixCryptoRepair.changes.length > 0) {
note(
[
"Matrix encrypted-state migration prepared.",
...matrixCryptoRepair.changes.map((entry) => `- ${entry}`),
].join("\n"),
"Doctor changes",
);
}
if (matrixCryptoRepair.warnings.length > 0) {
note(
matrixCryptoRepair.warnings.map((entry) => `- ${entry}`).join("\n"),
"Doctor warnings",
);
}
}
} else if (matrixLegacyState) {
if ("warning" in matrixLegacyState) {

View File

@@ -0,0 +1,130 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { runStartupMatrixMigration } from "./server-startup-matrix-migration.js";
describe("runStartupMatrixMigration", () => {
it("creates a snapshot before actionable startup migration", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"legacy":true}');
const maybeCreateMatrixMigrationSnapshotMock = vi.fn(async () => ({
created: true,
archivePath: "/tmp/snapshot.tar.gz",
markerPath: "/tmp/migration-snapshot.json",
}));
const autoMigrateLegacyMatrixStateMock = vi.fn(async () => ({
migrated: true,
changes: [],
warnings: [],
}));
const autoPrepareLegacyMatrixCryptoMock = vi.fn(async () => ({
migrated: false,
changes: [],
warnings: [],
}));
await runStartupMatrixMigration({
cfg: {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
} as never,
env: process.env,
deps: {
maybeCreateMatrixMigrationSnapshot: maybeCreateMatrixMigrationSnapshotMock,
autoMigrateLegacyMatrixState: autoMigrateLegacyMatrixStateMock,
autoPrepareLegacyMatrixCrypto: autoPrepareLegacyMatrixCryptoMock,
},
log: {},
});
expect(maybeCreateMatrixMigrationSnapshotMock).toHaveBeenCalledWith(
expect.objectContaining({ trigger: "gateway-startup" }),
);
expect(autoMigrateLegacyMatrixStateMock).toHaveBeenCalledOnce();
expect(autoPrepareLegacyMatrixCryptoMock).toHaveBeenCalledOnce();
});
});
it("skips snapshot creation when startup only has warning-only migration state", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"legacy":true}');
const maybeCreateMatrixMigrationSnapshotMock = vi.fn();
const autoMigrateLegacyMatrixStateMock = vi.fn();
const autoPrepareLegacyMatrixCryptoMock = vi.fn();
const info = vi.fn();
await runStartupMatrixMigration({
cfg: {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
},
},
} as never,
env: process.env,
deps: {
maybeCreateMatrixMigrationSnapshot: maybeCreateMatrixMigrationSnapshotMock as never,
autoMigrateLegacyMatrixState: autoMigrateLegacyMatrixStateMock as never,
autoPrepareLegacyMatrixCrypto: autoPrepareLegacyMatrixCryptoMock as never,
},
log: { info },
});
expect(maybeCreateMatrixMigrationSnapshotMock).not.toHaveBeenCalled();
expect(autoMigrateLegacyMatrixStateMock).not.toHaveBeenCalled();
expect(autoPrepareLegacyMatrixCryptoMock).not.toHaveBeenCalled();
expect(info).toHaveBeenCalledWith(
"matrix: migration remains in a warning-only state; no pre-migration snapshot was needed yet",
);
});
});
it("skips startup migration when snapshot creation fails", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"legacy":true}');
const maybeCreateMatrixMigrationSnapshotMock = vi.fn(async () => {
throw new Error("backup failed");
});
const autoMigrateLegacyMatrixStateMock = vi.fn();
const autoPrepareLegacyMatrixCryptoMock = vi.fn();
const warn = vi.fn();
await runStartupMatrixMigration({
cfg: {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
} as never,
env: process.env,
deps: {
maybeCreateMatrixMigrationSnapshot: maybeCreateMatrixMigrationSnapshotMock,
autoMigrateLegacyMatrixState: autoMigrateLegacyMatrixStateMock as never,
autoPrepareLegacyMatrixCrypto: autoPrepareLegacyMatrixCryptoMock as never,
},
log: { warn },
});
expect(autoMigrateLegacyMatrixStateMock).not.toHaveBeenCalled();
expect(autoPrepareLegacyMatrixCryptoMock).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
"gateway: failed creating a Matrix migration snapshot; skipping Matrix migration for now: Error: backup failed",
);
});
});
});

View File

@@ -0,0 +1,68 @@
import type { OpenClawConfig } from "../config/config.js";
import { autoPrepareLegacyMatrixCrypto } from "../infra/matrix-legacy-crypto.js";
import { autoMigrateLegacyMatrixState } from "../infra/matrix-legacy-state.js";
import {
hasActionableMatrixMigration,
hasPendingMatrixMigration,
maybeCreateMatrixMigrationSnapshot,
} from "../infra/matrix-migration-snapshot.js";
type MatrixMigrationLogger = {
info?: (message: string) => void;
warn?: (message: string) => void;
};
export async function runStartupMatrixMigration(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
log: MatrixMigrationLogger;
deps?: {
maybeCreateMatrixMigrationSnapshot?: typeof maybeCreateMatrixMigrationSnapshot;
autoMigrateLegacyMatrixState?: typeof autoMigrateLegacyMatrixState;
autoPrepareLegacyMatrixCrypto?: typeof autoPrepareLegacyMatrixCrypto;
};
}): Promise<void> {
const env = params.env ?? process.env;
const createSnapshot =
params.deps?.maybeCreateMatrixMigrationSnapshot ?? maybeCreateMatrixMigrationSnapshot;
const migrateLegacyState =
params.deps?.autoMigrateLegacyMatrixState ?? autoMigrateLegacyMatrixState;
const prepareLegacyCrypto =
params.deps?.autoPrepareLegacyMatrixCrypto ?? autoPrepareLegacyMatrixCrypto;
const actionable = hasActionableMatrixMigration({ cfg: params.cfg, env });
const pending = actionable || hasPendingMatrixMigration({ cfg: params.cfg, env });
if (!pending) {
return;
}
if (!actionable) {
params.log.info?.(
"matrix: migration remains in a warning-only state; no pre-migration snapshot was needed yet",
);
return;
}
try {
await createSnapshot({
trigger: "gateway-startup",
env,
log: params.log,
});
} catch (err) {
params.log.warn?.(
`gateway: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ${String(err)}`,
);
return;
}
await migrateLegacyState({
cfg: params.cfg,
env,
log: params.log,
});
await prepareLegacyCrypto({
cfg: params.cfg,
env,
log: params.log,
});
}

View File

@@ -34,13 +34,11 @@ import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js
import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
import { startHeartbeatRunner, type HeartbeatRunner } from "../infra/heartbeat-runner.js";
import { getMachineDisplayName } from "../infra/machine-name.js";
import {
detectMatrixInstallPathIssue,
formatMatrixInstallPathIssue,
} from "../infra/matrix-install-path-warnings.js";
import { autoPrepareLegacyMatrixCrypto } from "../infra/matrix-legacy-crypto.js";
import { autoMigrateLegacyMatrixState } from "../infra/matrix-legacy-state.js";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import {
detectPluginInstallPathIssue,
formatPluginInstallPathIssue,
} from "../infra/plugin-install-path-warnings.js";
import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js";
import {
primeRemoteSkillsCache,
@@ -102,6 +100,7 @@ import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js";
import { createGatewayRuntimeState } from "./server-runtime-state.js";
import { resolveSessionKeyForRun } from "./server-session-key.js";
import { logGatewayStartup } from "./server-startup-log.js";
import { runStartupMatrixMigration } from "./server-startup-matrix-migration.js";
import { startGatewaySidecars } from "./server-startup.js";
import { startGatewayTailscaleExposure } from "./server-tailscale.js";
import { createWizardSessionTracker } from "./server-wizard-sessions.js";
@@ -337,22 +336,23 @@ export async function startGatewayServer(
}
let secretsDegraded = false;
await autoMigrateLegacyMatrixState({
cfg: autoEnable.changes.length > 0 ? autoEnable.config : configSnapshot.config,
const matrixMigrationConfig =
autoEnable.changes.length > 0 ? autoEnable.config : configSnapshot.config;
await runStartupMatrixMigration({
cfg: matrixMigrationConfig,
env: process.env,
log,
});
await autoPrepareLegacyMatrixCrypto({
cfg: autoEnable.changes.length > 0 ? autoEnable.config : configSnapshot.config,
env: process.env,
log,
const matrixInstallPathIssue = await detectPluginInstallPathIssue({
pluginId: "matrix",
install: matrixMigrationConfig.plugins?.installs?.matrix,
});
const matrixInstallPathIssue = await detectMatrixInstallPathIssue(
autoEnable.changes.length > 0 ? autoEnable.config : configSnapshot.config,
);
if (matrixInstallPathIssue) {
const lines = formatMatrixInstallPathIssue({
const lines = formatPluginInstallPathIssue({
issue: matrixInstallPathIssue,
pluginLabel: "Matrix",
defaultInstallCommand: "openclaw plugins install @openclaw/matrix",
repoInstallCommand: "openclaw plugins install ./extensions/matrix",
formatCommand: formatCliCommand,
});
log.warn(

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
findMatrixAccountEntry,
requiresExplicitMatrixDefaultAccount,
resolveConfiguredMatrixAccountIds,
resolveMatrixDefaultOrOnlyAccountId,
} from "./matrix-account-selection.js";
@@ -36,6 +37,22 @@ describe("matrix account selection", () => {
};
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops");
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(false);
});
it("requires an explicit default when multiple Matrix accounts exist without one", () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: { homeserver: "https://matrix.example.org" },
alerts: { homeserver: "https://matrix.example.org" },
},
},
},
};
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(true);
});
it("finds the raw Matrix account entry by normalized account id", () => {

View File

@@ -79,3 +79,18 @@ export function resolveMatrixDefaultOrOnlyAccountId(cfg: OpenClawConfig): string
}
return DEFAULT_ACCOUNT_ID;
}
export function requiresExplicitMatrixDefaultAccount(cfg: OpenClawConfig): boolean {
const channel = resolveMatrixChannelConfig(cfg);
if (!channel) {
return false;
}
const configuredAccountIds = resolveConfiguredMatrixAccountIds(cfg);
if (configuredAccountIds.length <= 1) {
return false;
}
const configuredDefault = normalizeOptionalAccountId(
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
);
return !(configuredDefault && configuredAccountIds.includes(configuredDefault));
}

View File

@@ -1,58 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import type { OpenClawConfig } from "../config/config.js";
import {
detectMatrixInstallPathIssue,
formatMatrixInstallPathIssue,
} from "./matrix-install-path-warnings.js";
describe("matrix install path warnings", () => {
it("detects stale custom Matrix plugin paths", async () => {
const cfg: OpenClawConfig = {
plugins: {
installs: {
matrix: {
source: "path",
sourcePath: "/tmp/openclaw-matrix-missing",
installPath: "/tmp/openclaw-matrix-missing",
},
},
},
};
const issue = await detectMatrixInstallPathIssue(cfg);
expect(issue).toEqual({ missingPath: "/tmp/openclaw-matrix-missing" });
expect(
formatMatrixInstallPathIssue({
issue: issue!,
}),
).toEqual([
"Matrix is installed from a custom path that no longer exists: /tmp/openclaw-matrix-missing",
'Reinstall with "openclaw plugins install @openclaw/matrix".',
'If you are running from a repo checkout, you can also use "openclaw plugins install ./extensions/matrix".',
]);
});
it("skips warnings when the configured custom path exists", async () => {
await withTempHome(async (home) => {
const pluginPath = path.join(home, "matrix-plugin");
await fs.mkdir(pluginPath, { recursive: true });
const cfg: OpenClawConfig = {
plugins: {
installs: {
matrix: {
source: "path",
sourcePath: pluginPath,
installPath: pluginPath,
},
},
},
};
await expect(detectMatrixInstallPathIssue(cfg)).resolves.toBeNull();
});
});
});

View File

@@ -1,52 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
export type MatrixInstallPathIssue = {
missingPath: string;
};
function resolveMatrixInstallCandidatePaths(cfg: OpenClawConfig): string[] {
const install = cfg.plugins?.installs?.matrix;
if (!install || install.source !== "path") {
return [];
}
return [install.sourcePath, install.installPath]
.map((value) => (typeof value === "string" ? value.trim() : ""))
.filter(Boolean);
}
export async function detectMatrixInstallPathIssue(
cfg: OpenClawConfig,
): Promise<MatrixInstallPathIssue | null> {
const candidatePaths = resolveMatrixInstallCandidatePaths(cfg);
if (candidatePaths.length === 0) {
return null;
}
for (const candidatePath of candidatePaths) {
try {
await fs.access(path.resolve(candidatePath));
return null;
} catch {
// keep checking remaining candidates
}
}
return {
missingPath: candidatePaths[0] ?? "(unknown)",
};
}
export function formatMatrixInstallPathIssue(params: {
issue: MatrixInstallPathIssue;
formatCommand?: (command: string) => string;
}): string[] {
const formatCommand = params.formatCommand ?? ((command: string) => command);
return [
`Matrix is installed from a custom path that no longer exists: ${params.issue.missingPath}`,
`Reinstall with "${formatCommand("openclaw plugins install @openclaw/matrix")}".`,
`If you are running from a repo checkout, you can also use "${formatCommand("openclaw plugins install ./extensions/matrix")}".`,
];
}

View File

@@ -258,4 +258,41 @@ describe("matrix legacy encrypted-state migration", () => {
},
);
});
it("requires channels.matrix.defaultAccount before preparing flat legacy crypto for one of multiple accounts", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: "DEVICEOPS" }),
);
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://matrix.example.org",
userId: "@ops-bot:example.org",
accessToken: "tok-ops",
},
alerts: {
homeserver: "https://matrix.example.org",
userId: "@alerts-bot:example.org",
accessToken: "tok-alerts",
},
},
},
},
};
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.plans).toHaveLength(0);
expect(detection.warnings).toContain(
"Legacy Matrix encrypted state detected at " +
path.join(stateDir, "matrix", "crypto") +
', but multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set. Set "channels.matrix.defaultAccount" to the intended target account before rerunning "openclaw doctor --fix" or restarting the gateway.',
);
});
});
});

View File

@@ -5,21 +5,12 @@ import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { writeJsonFileAtomically } from "../plugin-sdk/json-store.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import { resolveConfiguredMatrixAccountIds } from "./matrix-account-selection.js";
import {
resolveConfiguredMatrixAccountIds,
resolveMatrixChannelConfig,
resolveMatrixDefaultOrOnlyAccountId,
} from "./matrix-account-selection.js";
import {
credentialsMatchResolvedIdentity,
loadStoredMatrixCredentials,
resolveMatrixMigrationConfigFields,
resolveLegacyMatrixFlatStoreTarget,
resolveMatrixMigrationAccountTarget,
} from "./matrix-migration-config.js";
import {
resolveMatrixAccountStorageRoot,
resolveMatrixLegacyFlatStoragePaths,
} from "./matrix-storage-paths.js";
import { resolveMatrixLegacyFlatStoragePaths } from "./matrix-storage-paths.js";
type MatrixLegacyCryptoCounts = {
total: number;
@@ -114,10 +105,6 @@ function resolveMatrixAccountIds(cfg: OpenClawConfig): string[] {
return resolveConfiguredMatrixAccountIds(cfg);
}
function resolveMatrixFlatStoreTargetAccountId(cfg: OpenClawConfig): string {
return resolveMatrixDefaultOrOnlyAccountId(cfg);
}
function resolveLegacyMatrixFlatStorePlan(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
@@ -127,60 +114,27 @@ function resolveLegacyMatrixFlatStorePlan(params: {
return null;
}
const channel = resolveMatrixChannelConfig(params.cfg);
if (!channel) {
return {
warning:
`Legacy Matrix encrypted state detected at ${legacy.cryptoPath}, but channels.matrix is not configured yet. ` +
'Configure Matrix, then rerun "openclaw doctor --fix" or restart the gateway.',
};
}
const accountId = resolveMatrixFlatStoreTargetAccountId(params.cfg);
const stored = loadStoredMatrixCredentials(params.env, accountId);
const resolved = resolveMatrixMigrationConfigFields({
const target = resolveLegacyMatrixFlatStoreTarget({
cfg: params.cfg,
env: params.env,
accountId,
detectedPath: legacy.cryptoPath,
detectedKind: "encrypted state",
});
const matchingStored = credentialsMatchResolvedIdentity(stored, {
homeserver: resolved.homeserver,
userId: resolved.userId,
})
? stored
: null;
const homeserver = resolved.homeserver;
const userId = resolved.userId || matchingStored?.userId || "";
const accessToken = resolved.accessToken || matchingStored?.accessToken || "";
if (!homeserver || !userId || !accessToken) {
return {
warning:
`Legacy Matrix encrypted state detected at ${legacy.cryptoPath}, but the account-scoped target could not be resolved yet ` +
`(need homeserver, userId, and access token for channels.matrix${accountId === DEFAULT_ACCOUNT_ID ? "" : `.accounts.${accountId}`}). ` +
'Start the gateway once with a working Matrix login, or rerun "openclaw doctor --fix" after cached credentials are available.',
};
if ("warning" in target) {
return target;
}
const stateDir = resolveStateDir(params.env, os.homedir);
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver,
userId,
accessToken,
accountId,
});
const metadata = loadLegacyBotSdkMetadata(legacy.cryptoPath);
return {
accountId,
rootDir,
recoveryKeyPath: path.join(rootDir, "recovery-key.json"),
statePath: path.join(rootDir, "legacy-crypto-migration.json"),
accountId: target.accountId,
rootDir: target.rootDir,
recoveryKeyPath: path.join(target.rootDir, "recovery-key.json"),
statePath: path.join(target.rootDir, "legacy-crypto-migration.json"),
legacyCryptoPath: legacy.cryptoPath,
homeserver,
userId,
accessToken,
deviceId: metadata.deviceId ?? stored?.deviceId ?? null,
homeserver: target.homeserver,
userId: target.userId,
accessToken: target.accessToken,
deviceId: metadata.deviceId ?? target.storedDeviceId,
};
}
@@ -219,34 +173,16 @@ function resolveMatrixLegacyCryptoPlans(params: {
}
}
const stateDir = resolveStateDir(params.env, os.homedir);
for (const accountId of resolveMatrixAccountIds(params.cfg)) {
const stored = loadStoredMatrixCredentials(params.env, accountId);
const resolved = resolveMatrixMigrationConfigFields({
const target = resolveMatrixMigrationAccountTarget({
cfg: params.cfg,
env: params.env,
accountId,
});
const matchingStored = credentialsMatchResolvedIdentity(stored, {
homeserver: resolved.homeserver,
userId: resolved.userId,
})
? stored
: null;
const homeserver = resolved.homeserver;
const userId = resolved.userId || matchingStored?.userId || "";
const accessToken = resolved.accessToken || matchingStored?.accessToken || "";
if (!homeserver || !userId || !accessToken) {
if (!target) {
continue;
}
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver,
userId,
accessToken,
accountId,
});
const legacyCryptoPath = path.join(rootDir, "crypto");
const legacyCryptoPath = path.join(target.rootDir, "crypto");
if (!fs.existsSync(legacyCryptoPath) || !isLegacyBotSdkCryptoStore(legacyCryptoPath)) {
continue;
}
@@ -261,15 +197,15 @@ function resolveMatrixLegacyCryptoPlans(params: {
}
const metadata = loadLegacyBotSdkMetadata(legacyCryptoPath);
plans.push({
accountId,
rootDir,
recoveryKeyPath: path.join(rootDir, "recovery-key.json"),
statePath: path.join(rootDir, "legacy-crypto-migration.json"),
accountId: target.accountId,
rootDir: target.rootDir,
recoveryKeyPath: path.join(target.rootDir, "recovery-key.json"),
statePath: path.join(target.rootDir, "legacy-crypto-migration.json"),
legacyCryptoPath,
homeserver,
userId,
accessToken,
deviceId: metadata.deviceId ?? stored?.deviceId ?? null,
homeserver: target.homeserver,
userId: target.userId,
accessToken: target.accessToken,
deviceId: metadata.deviceId ?? target.storedDeviceId,
});
}

View File

@@ -120,6 +120,39 @@ describe("matrix legacy state migration", () => {
});
});
it("requires channels.matrix.defaultAccount before migrating a flat store into one of multiple accounts", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
work: {
homeserver: "https://matrix.example.org",
userId: "@work-bot:example.org",
accessToken: "tok-work",
},
alerts: {
homeserver: "https://matrix.example.org",
userId: "@alerts-bot:example.org",
accessToken: "tok-alerts",
},
},
},
},
};
const detection = detectLegacyMatrixState({ cfg, env: process.env });
expect(detection && "warning" in detection).toBe(true);
if (!detection || !("warning" in detection)) {
throw new Error("expected a warning-only Matrix legacy state result");
}
expect(detection.warning).toContain("channels.matrix.defaultAccount is not set");
});
});
it("uses scoped Matrix env vars when resolving a flat-store migration target", async () => {
await withTempHome(
async (home) => {

View File

@@ -3,20 +3,8 @@ import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import {
resolveMatrixChannelConfig,
resolveMatrixDefaultOrOnlyAccountId,
} from "./matrix-account-selection.js";
import {
credentialsMatchResolvedIdentity,
loadStoredMatrixCredentials,
resolveMatrixMigrationConfigFields,
} from "./matrix-migration-config.js";
import {
resolveMatrixAccountStorageRoot,
resolveMatrixLegacyFlatStoragePaths,
} from "./matrix-storage-paths.js";
import { resolveLegacyMatrixFlatStoreTarget } from "./matrix-migration-config.js";
import { resolveMatrixLegacyFlatStoragePaths } from "./matrix-storage-paths.js";
export type MatrixLegacyStateMigrationResult = {
migrated: boolean;
@@ -34,10 +22,6 @@ type MatrixLegacyStatePlan = {
selectionNote?: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function resolveLegacyMatrixPaths(env: NodeJS.ProcessEnv): {
rootDir: string;
storagePath: string;
@@ -47,36 +31,6 @@ function resolveLegacyMatrixPaths(env: NodeJS.ProcessEnv): {
return resolveMatrixLegacyFlatStoragePaths(stateDir);
}
function resolveMatrixTargetAccountId(cfg: OpenClawConfig): string {
return resolveMatrixDefaultOrOnlyAccountId(cfg);
}
function resolveMatrixFlatStoreSelectionNote(params: {
channel: Record<string, unknown>;
accountId: string;
}): string | undefined {
const accounts = isRecord(params.channel.accounts) ? params.channel.accounts : null;
if (!accounts) {
return undefined;
}
const configuredAccounts = Array.from(
new Set(
Object.keys(accounts)
.map((accountId) => normalizeAccountId(accountId))
.filter(Boolean),
),
);
if (configuredAccounts.length <= 1) {
return undefined;
}
return (
`Legacy Matrix flat store uses one shared on-disk state, so it will be migrated into ` +
`account "${params.accountId}".`
);
}
function resolveMatrixMigrationPlan(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
@@ -86,59 +40,24 @@ function resolveMatrixMigrationPlan(params: {
return null;
}
const channel = resolveMatrixChannelConfig(params.cfg);
if (!channel) {
return {
warning:
`Legacy Matrix state detected at ${legacy.rootDir}, but channels.matrix is not configured yet. ` +
'Configure Matrix, then rerun "openclaw doctor --fix" or restart the gateway.',
};
}
const accountId = resolveMatrixTargetAccountId(params.cfg);
const stored = loadStoredMatrixCredentials(params.env, accountId);
const selectionNote = resolveMatrixFlatStoreSelectionNote({ channel, accountId });
const resolved = resolveMatrixMigrationConfigFields({
const target = resolveLegacyMatrixFlatStoreTarget({
cfg: params.cfg,
env: params.env,
accountId,
detectedPath: legacy.rootDir,
detectedKind: "state",
});
const matchingStored = credentialsMatchResolvedIdentity(stored, {
homeserver: resolved.homeserver,
userId: resolved.userId,
})
? stored
: null;
const homeserver = resolved.homeserver;
const userId = resolved.userId || matchingStored?.userId || "";
const accessToken = resolved.accessToken || matchingStored?.accessToken || "";
if (!homeserver || !userId || !accessToken) {
return {
warning:
`Legacy Matrix state detected at ${legacy.rootDir}, but the new account-scoped target could not be resolved yet ` +
`(need homeserver, userId, and access token for channels.matrix${accountId === DEFAULT_ACCOUNT_ID ? "" : `.accounts.${accountId}`}). ` +
'Start the gateway once with a working Matrix login, or rerun "openclaw doctor --fix" after cached credentials are available.',
};
if ("warning" in target) {
return target;
}
const stateDir = resolveStateDir(params.env, os.homedir);
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver,
userId,
accessToken,
accountId,
});
return {
accountId,
accountId: target.accountId,
legacyStoragePath: legacy.storagePath,
legacyCryptoPath: legacy.cryptoPath,
targetRootDir: rootDir,
targetStoragePath: path.join(rootDir, "bot-storage.json"),
targetCryptoPath: path.join(rootDir, "crypto"),
selectionNote,
targetRootDir: target.rootDir,
targetStoragePath: path.join(target.rootDir, "bot-storage.json"),
targetCryptoPath: path.join(target.rootDir, "crypto"),
selectionNote: target.selectionNote,
};
}

View File

@@ -2,9 +2,18 @@ import fs from "node:fs";
import os from "node:os";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { normalizeAccountId } from "../routing/session-key.js";
import { findMatrixAccountEntry, resolveMatrixChannelConfig } from "./matrix-account-selection.js";
import { resolveMatrixCredentialsPath } from "./matrix-storage-paths.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import {
findMatrixAccountEntry,
requiresExplicitMatrixDefaultAccount,
resolveConfiguredMatrixAccountIds,
resolveMatrixChannelConfig,
resolveMatrixDefaultOrOnlyAccountId,
} from "./matrix-account-selection.js";
import {
resolveMatrixAccountStorageRoot,
resolveMatrixCredentialsPath,
} from "./matrix-storage-paths.js";
export type MatrixStoredCredentials = {
homeserver: string;
@@ -13,6 +22,21 @@ export type MatrixStoredCredentials = {
deviceId?: string;
};
export type MatrixMigrationAccountTarget = {
accountId: string;
homeserver: string;
userId: string;
accessToken: string;
rootDir: string;
storedDeviceId: string | null;
};
export type MatrixLegacyFlatStoreTarget = MatrixMigrationAccountTarget & {
selectionNote?: string;
};
type MatrixLegacyFlatStoreKind = "state" | "encrypted state";
function clean(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
@@ -59,6 +83,19 @@ function resolveMatrixAccountConfigEntry(
return findMatrixAccountEntry(cfg, accountId);
}
function resolveMatrixFlatStoreSelectionNote(
cfg: OpenClawConfig,
accountId: string,
): string | undefined {
if (resolveConfiguredMatrixAccountIds(cfg).length <= 1) {
return undefined;
}
return (
`Legacy Matrix flat store uses one shared on-disk state, so it will be migrated into ` +
`account "${accountId}".`
);
}
export function resolveMatrixMigrationConfigFields(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
@@ -138,3 +175,89 @@ export function credentialsMatchResolvedIdentity(
}
return stored.homeserver === identity.homeserver && stored.userId === identity.userId;
}
export function resolveMatrixMigrationAccountTarget(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
accountId: string;
}): MatrixMigrationAccountTarget | null {
const stored = loadStoredMatrixCredentials(params.env, params.accountId);
const resolved = resolveMatrixMigrationConfigFields(params);
const matchingStored = credentialsMatchResolvedIdentity(stored, {
homeserver: resolved.homeserver,
userId: resolved.userId,
})
? stored
: null;
const homeserver = resolved.homeserver;
const userId = resolved.userId || matchingStored?.userId || "";
const accessToken = resolved.accessToken || matchingStored?.accessToken || "";
if (!homeserver || !userId || !accessToken) {
return null;
}
const stateDir = resolveStateDir(params.env, os.homedir);
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver,
userId,
accessToken,
accountId: params.accountId,
});
return {
accountId: params.accountId,
homeserver,
userId,
accessToken,
rootDir,
storedDeviceId: stored?.deviceId ?? null,
};
}
export function resolveLegacyMatrixFlatStoreTarget(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
detectedPath: string;
detectedKind: MatrixLegacyFlatStoreKind;
}): MatrixLegacyFlatStoreTarget | { warning: string } {
const channel = resolveMatrixChannelConfig(params.cfg);
if (!channel) {
return {
warning:
`Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but channels.matrix is not configured yet. ` +
'Configure Matrix, then rerun "openclaw doctor --fix" or restart the gateway.',
};
}
if (requiresExplicitMatrixDefaultAccount(params.cfg)) {
return {
warning:
`Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set. ` +
'Set "channels.matrix.defaultAccount" to the intended target account before rerunning "openclaw doctor --fix" or restarting the gateway.',
};
}
const accountId = resolveMatrixDefaultOrOnlyAccountId(params.cfg);
const target = resolveMatrixMigrationAccountTarget({
cfg: params.cfg,
env: params.env,
accountId,
});
if (!target) {
const targetDescription =
params.detectedKind === "state"
? "the new account-scoped target"
: "the account-scoped target";
return {
warning:
`Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but ${targetDescription} could not be resolved yet ` +
`(need homeserver, userId, and access token for channels.matrix${accountId === DEFAULT_ACCOUNT_ID ? "" : `.accounts.${accountId}`}). ` +
'Start the gateway once with a working Matrix login, or rerun "openclaw doctor --fix" after cached credentials are available.',
};
}
return {
...target,
selectionNote: resolveMatrixFlatStoreSelectionNote(params.cfg, accountId),
};
}

View File

@@ -0,0 +1,198 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
const createBackupArchiveMock = vi.hoisted(() => vi.fn());
vi.mock("./backup-create.js", () => ({
createBackupArchive: (...args: unknown[]) => createBackupArchiveMock(...args),
}));
import {
hasActionableMatrixMigration,
maybeCreateMatrixMigrationSnapshot,
resolveMatrixMigrationSnapshotMarkerPath,
resolveMatrixMigrationSnapshotOutputDir,
} from "./matrix-migration-snapshot.js";
describe("matrix migration snapshots", () => {
afterEach(() => {
createBackupArchiveMock.mockReset();
});
it("creates a backup marker after writing a pre-migration snapshot", async () => {
await withTempHome(async (home) => {
const archivePath = path.join(home, "Backups", "openclaw-migrations", "snapshot.tar.gz");
fs.mkdirSync(path.dirname(archivePath), { recursive: true });
fs.writeFileSync(path.join(home, ".openclaw", "openclaw.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(home, ".openclaw", "state.txt"), "state\n", "utf8");
createBackupArchiveMock.mockResolvedValueOnce({
createdAt: "2026-03-10T18:00:00.000Z",
archivePath,
includeWorkspace: false,
});
const result = await maybeCreateMatrixMigrationSnapshot({ trigger: "unit-test" });
expect(result).toEqual({
created: true,
archivePath,
markerPath: resolveMatrixMigrationSnapshotMarkerPath(process.env),
});
expect(createBackupArchiveMock).toHaveBeenCalledWith(
expect.objectContaining({
output: resolveMatrixMigrationSnapshotOutputDir(process.env),
includeWorkspace: false,
}),
);
const marker = JSON.parse(
fs.readFileSync(resolveMatrixMigrationSnapshotMarkerPath(process.env), "utf8"),
) as {
archivePath: string;
trigger: string;
};
expect(marker.archivePath).toBe(archivePath);
expect(marker.trigger).toBe("unit-test");
});
});
it("reuses an existing snapshot marker when the archive still exists", async () => {
await withTempHome(async (home) => {
const archivePath = path.join(home, "Backups", "openclaw-migrations", "snapshot.tar.gz");
const markerPath = resolveMatrixMigrationSnapshotMarkerPath(process.env);
fs.mkdirSync(path.dirname(archivePath), { recursive: true });
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
fs.writeFileSync(archivePath, "archive", "utf8");
fs.writeFileSync(
markerPath,
JSON.stringify({
version: 1,
createdAt: "2026-03-10T18:00:00.000Z",
archivePath,
trigger: "older-run",
includeWorkspace: false,
}),
"utf8",
);
const result = await maybeCreateMatrixMigrationSnapshot({ trigger: "unit-test" });
expect(result.created).toBe(false);
expect(result.archivePath).toBe(archivePath);
expect(createBackupArchiveMock).not.toHaveBeenCalled();
});
});
it("recreates the snapshot when the marker exists but the archive is missing", async () => {
await withTempHome(async (home) => {
const markerPath = resolveMatrixMigrationSnapshotMarkerPath(process.env);
const replacementArchivePath = path.join(
home,
"Backups",
"openclaw-migrations",
"replacement.tar.gz",
);
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
fs.mkdirSync(path.dirname(replacementArchivePath), { recursive: true });
fs.writeFileSync(
markerPath,
JSON.stringify({
version: 1,
createdAt: "2026-03-10T18:00:00.000Z",
archivePath: path.join(home, "Backups", "openclaw-migrations", "missing.tar.gz"),
trigger: "older-run",
includeWorkspace: false,
}),
"utf8",
);
createBackupArchiveMock.mockResolvedValueOnce({
createdAt: "2026-03-10T19:00:00.000Z",
archivePath: replacementArchivePath,
includeWorkspace: false,
});
const result = await maybeCreateMatrixMigrationSnapshot({ trigger: "unit-test" });
expect(result.created).toBe(true);
expect(result.archivePath).toBe(replacementArchivePath);
const marker = JSON.parse(fs.readFileSync(markerPath, "utf8")) as { archivePath: string };
expect(marker.archivePath).toBe(replacementArchivePath);
});
});
it("surfaces backup creation failures without writing a marker", async () => {
await withTempHome(async () => {
createBackupArchiveMock.mockRejectedValueOnce(new Error("backup failed"));
await expect(maybeCreateMatrixMigrationSnapshot({ trigger: "unit-test" })).rejects.toThrow(
"backup failed",
);
expect(fs.existsSync(resolveMatrixMigrationSnapshotMarkerPath(process.env))).toBe(false);
});
});
it("does not treat warning-only Matrix migration as actionable", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
fs.mkdirSync(path.join(stateDir, "matrix", "crypto"), { recursive: true });
fs.writeFileSync(
path.join(stateDir, "matrix", "bot-storage.json"),
'{"legacy":true}',
"utf8",
);
fs.writeFileSync(
path.join(stateDir, "openclaw.json"),
JSON.stringify({
channels: {
matrix: {
homeserver: "https://matrix.example.org",
},
},
}),
"utf8",
);
expect(
hasActionableMatrixMigration({
cfg: {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
},
},
} as never,
env: process.env,
}),
).toBe(false);
});
});
it("treats resolvable Matrix legacy state as actionable", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
fs.mkdirSync(path.join(stateDir, "matrix"), { recursive: true });
fs.writeFileSync(
path.join(stateDir, "matrix", "bot-storage.json"),
'{"legacy":true}',
"utf8",
);
expect(
hasActionableMatrixMigration({
cfg: {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
} as never,
env: process.env,
}),
).toBe(true);
});
});
});

View File

@@ -0,0 +1,144 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { writeJsonFileAtomically } from "../plugin-sdk/json-store.js";
import { createBackupArchive } from "./backup-create.js";
import { resolveRequiredHomeDir } from "./home-dir.js";
import { detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js";
import { detectLegacyMatrixState } from "./matrix-legacy-state.js";
const MATRIX_MIGRATION_SNAPSHOT_DIRNAME = "openclaw-migrations";
type MatrixMigrationSnapshotMarker = {
version: 1;
createdAt: string;
archivePath: string;
trigger: string;
includeWorkspace: boolean;
};
export type MatrixMigrationSnapshotResult = {
created: boolean;
archivePath: string;
markerPath: string;
};
function loadSnapshotMarker(filePath: string): MatrixMigrationSnapshotMarker | null {
try {
if (!fs.existsSync(filePath)) {
return null;
}
const parsed = JSON.parse(
fs.readFileSync(filePath, "utf8"),
) as Partial<MatrixMigrationSnapshotMarker>;
if (
parsed.version !== 1 ||
typeof parsed.createdAt !== "string" ||
typeof parsed.archivePath !== "string" ||
typeof parsed.trigger !== "string"
) {
return null;
}
return {
version: 1,
createdAt: parsed.createdAt,
archivePath: parsed.archivePath,
trigger: parsed.trigger,
includeWorkspace: parsed.includeWorkspace === true,
};
} catch {
return null;
}
}
export function resolveMatrixMigrationSnapshotMarkerPath(
env: NodeJS.ProcessEnv = process.env,
): string {
const stateDir = resolveStateDir(env, os.homedir);
return path.join(stateDir, "matrix", "migration-snapshot.json");
}
export function resolveMatrixMigrationSnapshotOutputDir(
env: NodeJS.ProcessEnv = process.env,
): string {
const homeDir = resolveRequiredHomeDir(env, os.homedir);
return path.join(homeDir, "Backups", MATRIX_MIGRATION_SNAPSHOT_DIRNAME);
}
export function hasPendingMatrixMigration(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): boolean {
const env = params.env ?? process.env;
const legacyState = detectLegacyMatrixState({ cfg: params.cfg, env });
if (legacyState) {
return true;
}
const legacyCrypto = detectLegacyMatrixCrypto({ cfg: params.cfg, env });
return legacyCrypto.plans.length > 0 || legacyCrypto.warnings.length > 0;
}
export function hasActionableMatrixMigration(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): boolean {
const env = params.env ?? process.env;
const legacyState = detectLegacyMatrixState({ cfg: params.cfg, env });
if (legacyState && !("warning" in legacyState)) {
return true;
}
const legacyCrypto = detectLegacyMatrixCrypto({ cfg: params.cfg, env });
return legacyCrypto.plans.length > 0;
}
export async function maybeCreateMatrixMigrationSnapshot(params: {
trigger: string;
env?: NodeJS.ProcessEnv;
outputDir?: string;
log?: { info?: (message: string) => void; warn?: (message: string) => void };
}): Promise<MatrixMigrationSnapshotResult> {
const env = params.env ?? process.env;
const markerPath = resolveMatrixMigrationSnapshotMarkerPath(env);
const existingMarker = loadSnapshotMarker(markerPath);
if (existingMarker?.archivePath && fs.existsSync(existingMarker.archivePath)) {
params.log?.info?.(
`matrix: reusing existing pre-migration backup snapshot: ${existingMarker.archivePath}`,
);
return {
created: false,
archivePath: existingMarker.archivePath,
markerPath,
};
}
if (existingMarker?.archivePath && !fs.existsSync(existingMarker.archivePath)) {
params.log?.warn?.(
`matrix: previous migration snapshot is missing (${existingMarker.archivePath}); creating a replacement backup before continuing`,
);
}
const snapshot = await createBackupArchive({
output: (() => {
const outputDir = params.outputDir ?? resolveMatrixMigrationSnapshotOutputDir(env);
fs.mkdirSync(outputDir, { recursive: true });
return outputDir;
})(),
includeWorkspace: false,
});
const marker: MatrixMigrationSnapshotMarker = {
version: 1,
createdAt: snapshot.createdAt,
archivePath: snapshot.archivePath,
trigger: params.trigger,
includeWorkspace: snapshot.includeWorkspace,
};
await writeJsonFileAtomically(markerPath, marker);
params.log?.info?.(`matrix: created pre-migration backup snapshot: ${snapshot.archivePath}`);
return {
created: true,
archivePath: snapshot.archivePath,
markerPath,
};
}