Matrix: restore doctor migration previews

This commit is contained in:
Gustavo Madeira Santana
2026-03-19 08:09:52 -04:00
parent 4443cc771a
commit 34ee75b174
3 changed files with 436 additions and 0 deletions

View File

@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
import * as noteModule from "../terminal/note.js";
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
@@ -203,6 +204,250 @@ describe("doctor config flow", () => {
).toBe("existing-session");
});
it("previews Matrix legacy sync-store migration in read-only mode", async () => {
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
try {
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 },
confirm: async () => false,
});
});
const warning = noteSpy.mock.calls.find(
(call) =>
call[1] === "Doctor warnings" &&
String(call[0]).includes("Matrix plugin upgraded in place."),
);
expect(warning?.[0]).toContain("Legacy sync store:");
expect(warning?.[0]).toContain(
'Run "openclaw doctor --fix" to migrate this Matrix state now.',
);
} finally {
noteSpy.mockRestore();
}
});
it("previews Matrix encrypted-state migration in read-only mode", async () => {
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
try {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
const { rootDir: accountRoot } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
});
await fs.mkdir(path.join(accountRoot, "crypto"), { 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(accountRoot, "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: "DEVICE123" }),
);
await loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true },
confirm: async () => false,
});
});
const warning = noteSpy.mock.calls.find(
(call) =>
call[1] === "Doctor warnings" &&
String(call[0]).includes("Matrix encrypted-state migration is pending"),
);
expect(warning?.[0]).toContain("Legacy crypto store:");
expect(warning?.[0]).toContain("New recovery key file:");
} finally {
noteSpy.mockRestore();
}
});
it("migrates Matrix legacy state on doctor repair", async () => {
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
try {
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 migratedRoot = path.join(
stateDir,
"matrix",
"accounts",
"default",
"matrix.example.org__bot_example.org",
);
const migratedChildren = await fs.readdir(migratedRoot);
expect(migratedChildren.length).toBe(1);
expect(
await fs
.access(path.join(migratedRoot, migratedChildren[0] ?? "", "bot-storage.json"))
.then(() => true)
.catch(() => false),
).toBe(true);
expect(
await fs
.access(path.join(stateDir, "matrix", "bot-storage.json"))
.then(() => true)
.catch(() => false),
).toBe(false);
});
expect(
noteSpy.mock.calls.some(
(call) =>
call[1] === "Doctor changes" &&
String(call[0]).includes("Matrix plugin upgraded in place."),
),
).toBe(true);
} finally {
noteSpy.mockRestore();
}
});
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: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "tok-123",
},
},
plugins: {
installs: {
matrix: {
source: "path",
sourcePath: "/tmp/openclaw-matrix-missing",
installPath: "/tmp/openclaw-matrix-missing",
},
},
},
});
expect(
doctorWarnings.some(
(line) => line.includes("custom path") && line.includes("/tmp/openclaw-matrix-missing"),
),
).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("notes legacy browser extension migration changes", async () => {
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
try {

View File

@@ -26,6 +26,23 @@ import {
isTrustedSafeBinPath,
normalizeTrustedSafeBinDirs,
} from "../infra/exec-safe-bin-trust.js";
import {
autoPrepareLegacyMatrixCrypto,
detectLegacyMatrixCrypto,
} from "../infra/matrix-legacy-crypto.js";
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 { resolveTelegramAccount } from "../plugin-sdk/account-resolution.js";
import {
@@ -312,6 +329,56 @@ function scanTelegramAllowFromUsernameEntries(cfg: OpenClawConfig): TelegramAllo
return hits;
}
function formatMatrixLegacyStatePreview(
detection: Exclude<ReturnType<typeof detectLegacyMatrixState>, null | { warning: string }>,
): string {
return [
"- Matrix plugin upgraded in place.",
`- Legacy sync store: ${detection.legacyStoragePath} -> ${detection.targetStoragePath}`,
`- Legacy crypto store: ${detection.legacyCryptoPath} -> ${detection.targetCryptoPath}`,
...(detection.selectionNote ? [`- ${detection.selectionNote}`] : []),
'- Run "openclaw doctor --fix" to migrate this Matrix state now.',
].join("\n");
}
function formatMatrixLegacyCryptoPreview(
detection: ReturnType<typeof detectLegacyMatrixCrypto>,
): string[] {
const notes: string[] = [];
for (const warning of detection.warnings) {
notes.push(`- ${warning}`);
}
for (const plan of detection.plans) {
notes.push(
[
`- Matrix encrypted-state migration is pending for account "${plan.accountId}".`,
`- Legacy crypto store: ${plan.legacyCryptoPath}`,
`- New recovery key file: ${plan.recoveryKeyPath}`,
`- Migration state file: ${plan.statePath}`,
'- Run "openclaw doctor --fix" to extract any saved backup key now. Backed-up room keys will restore automatically on next gateway start.',
].join("\n"),
);
}
return notes;
}
async function collectMatrixInstallPathWarnings(cfg: OpenClawConfig): Promise<string[]> {
const issue = await detectPluginInstallPathIssue({
pluginId: "matrix",
install: cfg.plugins?.installs?.matrix,
});
if (!issue) {
return [];
}
return formatPluginInstallPathIssue({
issue,
pluginLabel: "Matrix",
defaultInstallCommand: "openclaw plugins install @openclaw/matrix",
repoInstallCommand: "openclaw plugins install ./extensions/matrix",
formatCommand: formatCliCommand,
}).map((entry) => `- ${entry}`);
}
async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promise<{
config: OpenClawConfig;
changes: string[];
@@ -1699,6 +1766,110 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
}
}
const matrixLegacyState = detectLegacyMatrixState({
cfg: candidate,
env: process.env,
});
const matrixLegacyCrypto = detectLegacyMatrixCrypto({
cfg: candidate,
env: process.env,
});
const pendingMatrixMigration = hasPendingMatrixMigration({
cfg: candidate,
env: process.env,
});
const actionableMatrixMigration = hasActionableMatrixMigration({
cfg: candidate,
env: process.env,
});
if (shouldRepair) {
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 migration warnings are present, but no on-disk Matrix mutation is actionable yet. No pre-migration snapshot was needed.",
"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) {
note(`- ${matrixLegacyState.warning}`, "Doctor warnings");
} else {
note(formatMatrixLegacyStatePreview(matrixLegacyState), "Doctor warnings");
}
}
if (
!shouldRepair &&
(matrixLegacyCrypto.warnings.length > 0 || matrixLegacyCrypto.plans.length > 0)
) {
for (const preview of formatMatrixLegacyCryptoPreview(matrixLegacyCrypto)) {
note(preview, "Doctor warnings");
}
}
const matrixInstallWarnings = await collectMatrixInstallPathWarnings(candidate);
if (matrixInstallWarnings.length > 0) {
note(matrixInstallWarnings.join("\n"), "Doctor warnings");
}
const missingDefaultAccountBindingWarnings =
collectMissingDefaultAccountBindingWarnings(candidate);
if (missingDefaultAccountBindingWarnings.length > 0) {

View File

@@ -36,6 +36,10 @@ import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
import { startHeartbeatRunner, type HeartbeatRunner } from "../infra/heartbeat-runner.js";
import { getMachineDisplayName } from "../infra/machine-name.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,
@@ -525,6 +529,22 @@ export async function startGatewayServer(
env: process.env,
log,
});
const matrixInstallPathIssue = await detectPluginInstallPathIssue({
pluginId: "matrix",
install: cfgAtStart.plugins?.installs?.matrix,
});
if (matrixInstallPathIssue) {
const lines = formatPluginInstallPathIssue({
issue: matrixInstallPathIssue,
pluginLabel: "Matrix",
defaultInstallCommand: "openclaw plugins install @openclaw/matrix",
repoInstallCommand: "openclaw plugins install ./extensions/matrix",
formatCommand: formatCliCommand,
});
log.warn(
`gateway: matrix install path warning:\n${lines.map((entry) => `- ${entry}`).join("\n")}`,
);
}
initSubagentRegistry();
const defaultAgentId = resolveDefaultAgentId(cfgAtStart);