From e5fbedf012d56032956ce7b17edfbcae0a23999d Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 8 Mar 2026 22:12:03 -0400 Subject: [PATCH] Matrix: improve migration startup warnings --- docs/install/migrating-matrix.md | 3 +- src/commands/doctor-config-flow.ts | 36 ++++-------- src/gateway/server.impl.ts | 16 +++++ .../matrix-install-path-warnings.test.ts | 58 +++++++++++++++++++ src/infra/matrix-install-path-warnings.ts | 52 +++++++++++++++++ src/infra/matrix-legacy-state.test.ts | 36 ++++++++++++ src/infra/matrix-legacy-state.ts | 38 +++++++++++- 7 files changed, 209 insertions(+), 30 deletions(-) create mode 100644 src/infra/matrix-install-path-warnings.test.ts create mode 100644 src/infra/matrix-install-path-warnings.ts diff --git a/docs/install/migrating-matrix.md b/docs/install/migrating-matrix.md index 62352236e81..70a8935213e 100644 --- a/docs/install/migrating-matrix.md +++ b/docs/install/migrating-matrix.md @@ -59,8 +59,7 @@ OpenClaw cannot automatically recover: Current warning scope: -- stale custom Matrix plugin path installs are surfaced by `openclaw doctor` today -- gateway startup does not currently emit a separate Matrix-specific custom-path warning +- stale 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. diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 177182e7328..5dd0d71b9f8 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -26,6 +26,10 @@ import { isTrustedSafeBinPath, normalizeTrustedSafeBinDirs, } from "../infra/exec-safe-bin-trust.js"; +import { + detectMatrixInstallPathIssue, + formatMatrixInstallPathIssue, +} from "../infra/matrix-install-path-warnings.js"; import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto, @@ -300,6 +304,7 @@ function formatMatrixLegacyStatePreview( "- 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"); } @@ -326,33 +331,14 @@ function formatMatrixLegacyCryptoPreview( } async function collectMatrixInstallPathWarnings(cfg: OpenClawConfig): Promise { - const install = cfg.plugins?.installs?.matrix; - if (!install || install.source !== "path") { + const issue = await detectMatrixInstallPathIssue(cfg); + if (!issue) { return []; } - - const candidatePaths = [install.sourcePath, install.installPath] - .map((value) => (typeof value === "string" ? value.trim() : "")) - .filter(Boolean); - if (candidatePaths.length === 0) { - return []; - } - - for (const candidatePath of candidatePaths) { - try { - await fs.access(path.resolve(candidatePath)); - return []; - } catch { - // keep checking remaining candidates - } - } - - const missingPath = candidatePaths[0] ?? "(unknown)"; - return [ - `- Matrix is installed from a custom path that no longer exists: ${missingPath}`, - `- Reinstall with "${formatCliCommand("openclaw plugins install @openclaw/matrix")}".`, - `- If you are running from a repo checkout, you can also use "${formatCliCommand("openclaw plugins install ./extensions/matrix")}".`, - ]; + return formatMatrixInstallPathIssue({ + issue, + formatCommand: formatCliCommand, + }).map((entry) => `- ${entry}`); } function scanTelegramAllowFromUsernameEntries(cfg: OpenClawConfig): TelegramAllowFromUsernameHit[] { diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 4a6bc1cd365..b68126f7948 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -34,6 +34,10 @@ 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"; @@ -343,6 +347,18 @@ export async function startGatewayServer( env: process.env, log, }); + const matrixInstallPathIssue = await detectMatrixInstallPathIssue( + autoEnable.changes.length > 0 ? autoEnable.config : configSnapshot.config, + ); + if (matrixInstallPathIssue) { + const lines = formatMatrixInstallPathIssue({ + issue: matrixInstallPathIssue, + formatCommand: formatCliCommand, + }); + log.warn( + `gateway: matrix install path warning:\n${lines.map((entry) => `- ${entry}`).join("\n")}`, + ); + } const emitSecretsStateEvent = ( code: "SECRETS_RELOADER_DEGRADED" | "SECRETS_RELOADER_RECOVERED", message: string, diff --git a/src/infra/matrix-install-path-warnings.test.ts b/src/infra/matrix-install-path-warnings.test.ts new file mode 100644 index 00000000000..a7f2e420a57 --- /dev/null +++ b/src/infra/matrix-install-path-warnings.test.ts @@ -0,0 +1,58 @@ +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(); + }); + }); +}); diff --git a/src/infra/matrix-install-path-warnings.ts b/src/infra/matrix-install-path-warnings.ts new file mode 100644 index 00000000000..5fdac0b42ae --- /dev/null +++ b/src/infra/matrix-install-path-warnings.ts @@ -0,0 +1,52 @@ +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 { + 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")}".`, + ]; +} diff --git a/src/infra/matrix-legacy-state.test.ts b/src/infra/matrix-legacy-state.test.ts index 4f85d82ad41..22bdf76c7cd 100644 --- a/src/infra/matrix-legacy-state.test.ts +++ b/src/infra/matrix-legacy-state.test.ts @@ -83,4 +83,40 @@ describe("matrix legacy state migration", () => { expect(fs.existsSync(detection.targetStoragePath)).toBe(true); }); }); + + it("records which account receives a flat legacy store when multiple Matrix accounts exist", 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: { + defaultAccount: "work", + 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(false); + if (!detection || "warning" in detection) { + throw new Error("expected a migratable Matrix legacy state plan"); + } + + expect(detection.accountId).toBe("work"); + expect(detection.selectionNote).toContain('account "work"'); + }); + }); }); diff --git a/src/infra/matrix-legacy-state.ts b/src/infra/matrix-legacy-state.ts index 9cde9bf05dd..80f61f869ea 100644 --- a/src/infra/matrix-legacy-state.ts +++ b/src/infra/matrix-legacy-state.ts @@ -33,6 +33,7 @@ type MatrixLegacyStatePlan = { targetRootDir: string; targetStoragePath: string; targetCryptoPath: string; + selectionNote?: string; }; function isRecord(value: unknown): value is Record { @@ -128,6 +129,32 @@ function resolveMatrixTargetAccountId(cfg: OpenClawConfig): string { return DEFAULT_ACCOUNT_ID; } +function resolveMatrixFlatStoreSelectionNote(params: { + channel: Record; + 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; @@ -149,6 +176,7 @@ function resolveMatrixMigrationPlan(params: { const accountId = resolveMatrixTargetAccountId(params.cfg); const account = resolveMatrixAccountConfig(params.cfg, accountId); const stored = loadStoredMatrixCredentials(params.env, accountId); + const selectionNote = resolveMatrixFlatStoreSelectionNote({ channel, accountId }); const homeserver = typeof account.homeserver === "string" ? account.homeserver.trim() : ""; const configUserId = typeof account.userId === "string" ? account.userId.trim() : ""; @@ -191,6 +219,7 @@ function resolveMatrixMigrationPlan(params: { targetRootDir: rootDir, targetStoragePath: path.join(rootDir, "bot-storage.json"), targetCryptoPath: path.join(rootDir, "crypto"), + selectionNote, }; } @@ -266,10 +295,13 @@ export async function autoMigrateLegacyMatrixState(params: { }); if (changes.length > 0) { + const details = [ + ...changes.map((entry) => `- ${entry}`), + ...(detection.selectionNote ? [`- ${detection.selectionNote}`] : []), + "- No user action required.", + ]; params.log?.info?.( - `matrix: plugin upgraded in place for account "${detection.accountId}".\n${changes - .map((entry) => `- ${entry}`) - .join("\n")}\n- No user action required.`, + `matrix: plugin upgraded in place for account "${detection.accountId}".\n${details.join("\n")}`, ); } if (warnings.length > 0) {