mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 07:10:24 +00:00
Matrix: restore doctor migration previews
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user