diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 39e7b9d00fe..4a461c58267 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -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 { diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index ed82ea4473f..e0599eca1bb 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -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, 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, +): 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 { + 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) { diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 18ab617b1ce..7a4c18b6593 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -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);