Matrix: restore startup and doctor migration

This commit is contained in:
Gustavo Madeira Santana 2026-03-19 07:59:01 -04:00
parent d073ec42cd
commit a48eb9ff7f
No known key found for this signature in database
20 changed files with 3715 additions and 0 deletions

View File

@ -0,0 +1,4 @@
export * from "./src/account-selection.js";
export * from "./src/auth-precedence.js";
export * from "./src/env-vars.js";
export * from "./src/storage-paths.js";

View File

@ -0,0 +1,106 @@
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { listMatrixEnvAccountIds } from "./env-vars.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record<string, unknown> | null {
return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null;
}
export function findMatrixAccountEntry(
cfg: OpenClawConfig,
accountId: string,
): Record<string, unknown> | null {
const channel = resolveMatrixChannelConfig(cfg);
if (!channel) {
return null;
}
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
if (!accounts) {
return null;
}
const normalizedAccountId = normalizeAccountId(accountId);
for (const [rawAccountId, value] of Object.entries(accounts)) {
if (normalizeAccountId(rawAccountId) === normalizedAccountId && isRecord(value)) {
return value;
}
}
return null;
}
export function resolveConfiguredMatrixAccountIds(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): string[] {
const channel = resolveMatrixChannelConfig(cfg);
const ids = new Set<string>(listMatrixEnvAccountIds(env));
const accounts = channel && isRecord(channel.accounts) ? channel.accounts : null;
if (accounts) {
for (const [accountId, value] of Object.entries(accounts)) {
if (isRecord(value)) {
ids.add(normalizeAccountId(accountId));
}
}
}
if (ids.size === 0 && channel) {
ids.add(DEFAULT_ACCOUNT_ID);
}
return Array.from(ids).toSorted((a, b) => a.localeCompare(b));
}
export function resolveMatrixDefaultOrOnlyAccountId(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): string {
const channel = resolveMatrixChannelConfig(cfg);
if (!channel) {
return DEFAULT_ACCOUNT_ID;
}
const configuredDefault = normalizeOptionalAccountId(
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
);
const configuredAccountIds = resolveConfiguredMatrixAccountIds(cfg, env);
if (configuredDefault && configuredAccountIds.includes(configuredDefault)) {
return configuredDefault;
}
if (configuredAccountIds.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
if (configuredAccountIds.length === 1) {
return configuredAccountIds[0] ?? DEFAULT_ACCOUNT_ID;
}
return DEFAULT_ACCOUNT_ID;
}
export function requiresExplicitMatrixDefaultAccount(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): boolean {
const channel = resolveMatrixChannelConfig(cfg);
if (!channel) {
return false;
}
const configuredAccountIds = resolveConfiguredMatrixAccountIds(cfg, env);
if (configuredAccountIds.length <= 1) {
return false;
}
const configuredDefault = normalizeOptionalAccountId(
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
);
return !(configuredDefault && configuredAccountIds.includes(configuredDefault));
}

View File

@ -0,0 +1,61 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
export type MatrixResolvedStringField =
| "homeserver"
| "userId"
| "accessToken"
| "password"
| "deviceId"
| "deviceName";
export type MatrixResolvedStringValues = Record<MatrixResolvedStringField, string>;
type MatrixStringSourceMap = Partial<Record<MatrixResolvedStringField, string>>;
const MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS = new Set<MatrixResolvedStringField>([
"userId",
"accessToken",
"password",
"deviceId",
]);
function resolveMatrixStringSourceValue(value: string | undefined): string {
return typeof value === "string" ? value : "";
}
function shouldAllowBaseAuthFallback(accountId: string, field: MatrixResolvedStringField): boolean {
return (
normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID ||
!MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS.has(field)
);
}
export function resolveMatrixAccountStringValues(params: {
accountId: string;
account?: MatrixStringSourceMap;
scopedEnv?: MatrixStringSourceMap;
channel?: MatrixStringSourceMap;
globalEnv?: MatrixStringSourceMap;
}): MatrixResolvedStringValues {
const fields: MatrixResolvedStringField[] = [
"homeserver",
"userId",
"accessToken",
"password",
"deviceId",
"deviceName",
];
const resolved = {} as MatrixResolvedStringValues;
for (const field of fields) {
resolved[field] =
resolveMatrixStringSourceValue(params.account?.[field]) ||
resolveMatrixStringSourceValue(params.scopedEnv?.[field]) ||
(shouldAllowBaseAuthFallback(params.accountId, field)
? resolveMatrixStringSourceValue(params.channel?.[field]) ||
resolveMatrixStringSourceValue(params.globalEnv?.[field])
: "");
}
return resolved;
}

View File

@ -0,0 +1,92 @@
import { normalizeAccountId, normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id";
const MATRIX_SCOPED_ENV_SUFFIXES = [
"HOMESERVER",
"USER_ID",
"ACCESS_TOKEN",
"PASSWORD",
"DEVICE_ID",
"DEVICE_NAME",
] as const;
const MATRIX_GLOBAL_ENV_KEYS = MATRIX_SCOPED_ENV_SUFFIXES.map((suffix) => `MATRIX_${suffix}`);
const MATRIX_SCOPED_ENV_RE = new RegExp(`^MATRIX_(.+)_(${MATRIX_SCOPED_ENV_SUFFIXES.join("|")})$`);
export function resolveMatrixEnvAccountToken(accountId: string): string {
return Array.from(normalizeAccountId(accountId))
.map((char) =>
/[a-z0-9]/.test(char)
? char.toUpperCase()
: `_X${char.codePointAt(0)?.toString(16).toUpperCase() ?? "00"}_`,
)
.join("");
}
export function getMatrixScopedEnvVarNames(accountId: string): {
homeserver: string;
userId: string;
accessToken: string;
password: string;
deviceId: string;
deviceName: string;
} {
const token = resolveMatrixEnvAccountToken(accountId);
return {
homeserver: `MATRIX_${token}_HOMESERVER`,
userId: `MATRIX_${token}_USER_ID`,
accessToken: `MATRIX_${token}_ACCESS_TOKEN`,
password: `MATRIX_${token}_PASSWORD`,
deviceId: `MATRIX_${token}_DEVICE_ID`,
deviceName: `MATRIX_${token}_DEVICE_NAME`,
};
}
function decodeMatrixEnvAccountToken(token: string): string | undefined {
let decoded = "";
for (let index = 0; index < token.length; ) {
const hexEscape = /^_X([0-9A-F]+)_/.exec(token.slice(index));
if (hexEscape) {
const hex = hexEscape[1];
const codePoint = hex ? Number.parseInt(hex, 16) : Number.NaN;
if (!Number.isFinite(codePoint)) {
return undefined;
}
const char = String.fromCodePoint(codePoint);
decoded += char;
index += hexEscape[0].length;
continue;
}
const char = token[index];
if (!char || !/[A-Z0-9]/.test(char)) {
return undefined;
}
decoded += char.toLowerCase();
index += 1;
}
const normalized = normalizeOptionalAccountId(decoded);
if (!normalized) {
return undefined;
}
return resolveMatrixEnvAccountToken(normalized) === token ? normalized : undefined;
}
export function listMatrixEnvAccountIds(env: NodeJS.ProcessEnv = process.env): string[] {
const ids = new Set<string>();
for (const key of MATRIX_GLOBAL_ENV_KEYS) {
if (typeof env[key] === "string" && env[key]?.trim()) {
ids.add(normalizeAccountId("default"));
break;
}
}
for (const key of Object.keys(env)) {
const match = MATRIX_SCOPED_ENV_RE.exec(key);
if (!match) {
continue;
}
const accountId = decodeMatrixEnvAccountToken(match[1]);
if (accountId) {
ids.add(accountId);
}
}
return Array.from(ids).toSorted((a, b) => a.localeCompare(b));
}

View File

@ -0,0 +1,93 @@
import crypto from "node:crypto";
import path from "node:path";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
export function sanitizeMatrixPathSegment(value: string): string {
const cleaned = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "_")
.replace(/^_+|_+$/g, "");
return cleaned || "unknown";
}
export function resolveMatrixHomeserverKey(homeserver: string): string {
try {
const url = new URL(homeserver);
if (url.host) {
return sanitizeMatrixPathSegment(url.host);
}
} catch {
// fall through
}
return sanitizeMatrixPathSegment(homeserver);
}
export function hashMatrixAccessToken(accessToken: string): string {
return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16);
}
export function resolveMatrixCredentialsFilename(accountId?: string | null): string {
const normalized = normalizeAccountId(accountId);
return normalized === DEFAULT_ACCOUNT_ID ? "credentials.json" : `credentials-${normalized}.json`;
}
export function resolveMatrixCredentialsDir(stateDir: string): string {
return path.join(stateDir, "credentials", "matrix");
}
export function resolveMatrixCredentialsPath(params: {
stateDir: string;
accountId?: string | null;
}): string {
return path.join(
resolveMatrixCredentialsDir(params.stateDir),
resolveMatrixCredentialsFilename(params.accountId),
);
}
export function resolveMatrixLegacyFlatStoreRoot(stateDir: string): string {
return path.join(stateDir, "matrix");
}
export function resolveMatrixLegacyFlatStoragePaths(stateDir: string): {
rootDir: string;
storagePath: string;
cryptoPath: string;
} {
const rootDir = resolveMatrixLegacyFlatStoreRoot(stateDir);
return {
rootDir,
storagePath: path.join(rootDir, "bot-storage.json"),
cryptoPath: path.join(rootDir, "crypto"),
};
}
export function resolveMatrixAccountStorageRoot(params: {
stateDir: string;
homeserver: string;
userId: string;
accessToken: string;
accountId?: string | null;
}): {
rootDir: string;
accountKey: string;
tokenHash: string;
} {
const accountKey = sanitizeMatrixPathSegment(params.accountId ?? DEFAULT_ACCOUNT_ID);
const userKey = sanitizeMatrixPathSegment(params.userId);
const serverKey = resolveMatrixHomeserverKey(params.homeserver);
const tokenHash = hashMatrixAccessToken(params.accessToken);
return {
rootDir: path.join(
params.stateDir,
"matrix",
"accounts",
accountKey,
`${serverKey}__${userKey}`,
tokenHash,
),
accountKey,
tokenHash,
};
}

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

@ -21,6 +21,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 {
@ -316,6 +333,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[];
@ -1771,6 +1838,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

@ -0,0 +1,180 @@
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",
);
});
});
it("downgrades migration step failures to warnings so startup can continue", 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 () => {
throw new Error("disk full");
});
const warn = vi.fn();
await expect(
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: { warn },
}),
).resolves.toBeUndefined();
expect(maybeCreateMatrixMigrationSnapshotMock).toHaveBeenCalledOnce();
expect(autoMigrateLegacyMatrixStateMock).toHaveBeenCalledOnce();
expect(autoPrepareLegacyMatrixCryptoMock).toHaveBeenCalledOnce();
expect(warn).toHaveBeenCalledWith(
"gateway: legacy Matrix encrypted-state preparation failed during Matrix migration; continuing startup: Error: disk full",
);
});
});
});

View File

@ -0,0 +1,92 @@
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;
};
async function runBestEffortMatrixMigrationStep(params: {
label: string;
log: MatrixMigrationLogger;
run: () => Promise<unknown>;
}): Promise<void> {
try {
await params.run();
} catch (err) {
params.log.warn?.(
`gateway: ${params.label} failed during Matrix migration; continuing startup: ${String(err)}`,
);
}
}
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 runBestEffortMatrixMigrationStep({
label: "legacy Matrix state migration",
log: params.log,
run: () =>
migrateLegacyState({
cfg: params.cfg,
env,
log: params.log,
}),
});
await runBestEffortMatrixMigrationStep({
label: "legacy Matrix encrypted-state preparation",
log: params.log,
run: () =>
prepareLegacyCrypto({
cfg: params.cfg,
env,
log: params.log,
}),
});
}

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,
@ -98,6 +102,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";
@ -404,6 +409,29 @@ export async function startGatewayServer(
}
let secretsDegraded = false;
const matrixMigrationConfig =
autoEnable.changes.length > 0 ? autoEnable.config : configSnapshot.config;
await runStartupMatrixMigration({
cfg: matrixMigrationConfig,
env: process.env,
log,
});
const matrixInstallPathIssue = await detectPluginInstallPathIssue({
pluginId: "matrix",
install: matrixMigrationConfig.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")}`,
);
}
const emitSecretsStateEvent = (
code: "SECRETS_RELOADER_DEGRADED" | "SECRETS_RELOADER_RECOVERED",
message: string,

View File

@ -0,0 +1,448 @@
import fs from "node:fs";
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 type { OpenClawConfig } from "../config/config.js";
import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js";
import { MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE } from "./matrix-plugin-helper.js";
function writeFile(filePath: string, value: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, value, "utf8");
}
function writeMatrixPluginFixture(rootDir: string): void {
fs.mkdirSync(rootDir, { recursive: true });
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "matrix",
configSchema: {
type: "object",
additionalProperties: false,
},
}),
"utf8",
);
fs.writeFileSync(path.join(rootDir, "index.js"), "export default {};\n", "utf8");
fs.writeFileSync(
path.join(rootDir, "legacy-crypto-inspector.js"),
[
"export async function inspectLegacyMatrixCryptoStore() {",
' return { deviceId: "FIXTURE", roomKeyCounts: { total: 1, backedUp: 1 }, backupVersion: "1", decryptionKeyBase64: null };',
"}",
].join("\n"),
"utf8",
);
}
const matrixHelperEnv = {
OPENCLAW_BUNDLED_PLUGINS_DIR: (home: string) => path.join(home, "bundled"),
};
describe("matrix legacy encrypted-state migration", () => {
it("extracts a saved backup key into the new recovery-key path", async () => {
await withTempHome(
async (home) => {
writeMatrixPluginFixture(path.join(home, "bundled", "matrix"));
const stateDir = path.join(home, ".openclaw");
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
};
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
});
writeFile(path.join(rootDir, "crypto", "bot-sdk.json"), '{"deviceId":"DEVICE123"}');
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.warnings).toEqual([]);
expect(detection.plans).toHaveLength(1);
const inspectLegacyStore = vi.fn(async () => ({
deviceId: "DEVICE123",
roomKeyCounts: { total: 12, backedUp: 12 },
backupVersion: "1",
decryptionKeyBase64: "YWJjZA==",
}));
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: { inspectLegacyStore },
});
expect(result.migrated).toBe(true);
expect(result.warnings).toEqual([]);
expect(inspectLegacyStore).toHaveBeenCalledOnce();
const recovery = JSON.parse(
fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"),
) as {
privateKeyBase64: string;
};
expect(recovery.privateKeyBase64).toBe("YWJjZA==");
const state = JSON.parse(
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
) as {
restoreStatus: string;
decryptionKeyImported: boolean;
};
expect(state.restoreStatus).toBe("pending");
expect(state.decryptionKeyImported).toBe(true);
},
{ env: matrixHelperEnv },
);
});
it("warns when legacy local-only room keys cannot be recovered automatically", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
};
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
});
writeFile(path.join(rootDir, "crypto", "bot-sdk.json"), '{"deviceId":"DEVICE123"}');
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: "DEVICE123",
roomKeyCounts: { total: 15, backedUp: 10 },
backupVersion: null,
decryptionKeyBase64: null,
}),
},
});
expect(result.migrated).toBe(true);
expect(result.warnings).toContain(
'Legacy Matrix encrypted state for account "default" contains 5 room key(s) that were never backed up. Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.',
);
expect(result.warnings).toContain(
'Legacy Matrix encrypted state for account "default" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.',
);
const state = JSON.parse(
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
) as {
restoreStatus: string;
};
expect(state.restoreStatus).toBe("manual-action-required");
});
});
it("warns instead of throwing when recovery-key persistence fails", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
};
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
});
writeFile(path.join(rootDir, "crypto", "bot-sdk.json"), '{"deviceId":"DEVICE123"}');
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: "DEVICE123",
roomKeyCounts: { total: 12, backedUp: 12 },
backupVersion: "1",
decryptionKeyBase64: "YWJjZA==",
}),
writeJsonFileAtomically: async (filePath) => {
if (filePath.endsWith("recovery-key.json")) {
throw new Error("disk full");
}
writeFile(filePath, JSON.stringify({ ok: true }, null, 2));
},
},
});
expect(result.migrated).toBe(false);
expect(result.warnings).toContain(
`Failed writing Matrix recovery key for account "default" (${path.join(rootDir, "recovery-key.json")}): Error: disk full`,
);
expect(fs.existsSync(path.join(rootDir, "recovery-key.json"))).toBe(false);
expect(fs.existsSync(path.join(rootDir, "legacy-crypto-migration.json"))).toBe(false);
});
});
it("prepares flat legacy crypto for the only configured non-default Matrix account", async () => {
await withTempHome(
async (home) => {
writeMatrixPluginFixture(path.join(home, "bundled", "matrix"));
const stateDir = path.join(home, ".openclaw");
writeFile(
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: "DEVICEOPS" }),
);
writeFile(
path.join(stateDir, "credentials", "matrix", "credentials-ops.json"),
JSON.stringify(
{
homeserver: "https://matrix.example.org",
userId: "@ops-bot:example.org",
accessToken: "tok-ops",
deviceId: "DEVICEOPS",
},
null,
2,
),
);
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://matrix.example.org",
userId: "@ops-bot:example.org",
},
},
},
},
};
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: "https://matrix.example.org",
userId: "@ops-bot:example.org",
accessToken: "tok-ops",
accountId: "ops",
});
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.warnings).toEqual([]);
expect(detection.plans).toHaveLength(1);
expect(detection.plans[0]?.accountId).toBe("ops");
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: "DEVICEOPS",
roomKeyCounts: { total: 6, backedUp: 6 },
backupVersion: "21868",
decryptionKeyBase64: "YWJjZA==",
}),
},
});
expect(result.migrated).toBe(true);
expect(result.warnings).toEqual([]);
const recovery = JSON.parse(
fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"),
) as {
privateKeyBase64: string;
};
expect(recovery.privateKeyBase64).toBe("YWJjZA==");
const state = JSON.parse(
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
) as {
accountId: string;
};
expect(state.accountId).toBe("ops");
},
{ env: matrixHelperEnv },
);
});
it("uses scoped Matrix env vars when resolving flat legacy crypto migration", async () => {
await withTempHome(
async (home) => {
writeMatrixPluginFixture(path.join(home, "bundled", "matrix"));
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: {},
},
},
},
};
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: "https://matrix.example.org",
userId: "@ops-bot:example.org",
accessToken: "tok-ops-env",
accountId: "ops",
});
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.warnings).toEqual([]);
expect(detection.plans).toHaveLength(1);
expect(detection.plans[0]?.accountId).toBe("ops");
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: "DEVICEOPS",
roomKeyCounts: { total: 4, backedUp: 4 },
backupVersion: "9001",
decryptionKeyBase64: "YWJjZA==",
}),
},
});
expect(result.migrated).toBe(true);
expect(result.warnings).toEqual([]);
const recovery = JSON.parse(
fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"),
) as {
privateKeyBase64: string;
};
expect(recovery.privateKeyBase64).toBe("YWJjZA==");
},
{
env: {
...matrixHelperEnv,
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_USER_ID: "@ops-bot:example.org",
MATRIX_OPS_ACCESS_TOKEN: "tok-ops-env",
},
},
);
});
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.',
);
});
});
it("warns instead of throwing when a legacy crypto path is a file", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(path.join(stateDir, "matrix", "crypto"), "not-a-directory");
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
};
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.plans).toHaveLength(0);
expect(detection.warnings).toContain(
`Legacy Matrix encrypted state path exists but is not a directory: ${path.join(stateDir, "matrix", "crypto")}. OpenClaw skipped automatic crypto migration for that path.`,
);
});
});
it("reports a missing matrix plugin helper once when encrypted-state migration cannot run", async () => {
await withTempHome(
async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
'{"deviceId":"DEVICE123"}',
);
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
};
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
});
expect(result.migrated).toBe(false);
expect(
result.warnings.filter(
(warning) => warning === MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE,
),
).toHaveLength(1);
},
{
env: {
OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "empty-bundled"),
},
},
);
});
});

View File

@ -0,0 +1,493 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
resolveConfiguredMatrixAccountIds,
resolveMatrixLegacyFlatStoragePaths,
} from "../../extensions/matrix/runtime-api.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { writeJsonFileAtomically as writeJsonFileAtomicallyImpl } from "../plugin-sdk/json-store.js";
import {
resolveLegacyMatrixFlatStoreTarget,
resolveMatrixMigrationAccountTarget,
} from "./matrix-migration-config.js";
import {
MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE,
isMatrixLegacyCryptoInspectorAvailable,
loadMatrixLegacyCryptoInspector,
type MatrixLegacyCryptoInspector,
} from "./matrix-plugin-helper.js";
type MatrixLegacyCryptoCounts = {
total: number;
backedUp: number;
};
type MatrixLegacyCryptoSummary = {
deviceId: string | null;
roomKeyCounts: MatrixLegacyCryptoCounts | null;
backupVersion: string | null;
decryptionKeyBase64: string | null;
};
type MatrixLegacyCryptoMigrationState = {
version: 1;
source: "matrix-bot-sdk-rust";
accountId: string;
deviceId: string | null;
roomKeyCounts: MatrixLegacyCryptoCounts | null;
backupVersion: string | null;
decryptionKeyImported: boolean;
restoreStatus: "pending" | "completed" | "manual-action-required";
detectedAt: string;
restoredAt?: string;
importedCount?: number;
totalCount?: number;
lastError?: string | null;
};
type MatrixLegacyCryptoPlan = {
accountId: string;
rootDir: string;
recoveryKeyPath: string;
statePath: string;
legacyCryptoPath: string;
homeserver: string;
userId: string;
accessToken: string;
deviceId: string | null;
};
type MatrixLegacyCryptoDetection = {
plans: MatrixLegacyCryptoPlan[];
warnings: string[];
};
type MatrixLegacyCryptoPreparationResult = {
migrated: boolean;
changes: string[];
warnings: string[];
};
type MatrixLegacyCryptoPrepareDeps = {
inspectLegacyStore: MatrixLegacyCryptoInspector;
writeJsonFileAtomically: typeof writeJsonFileAtomicallyImpl;
};
type MatrixLegacyBotSdkMetadata = {
deviceId: string | null;
};
type MatrixStoredRecoveryKey = {
version: 1;
createdAt: string;
keyId?: string | null;
encodedPrivateKey?: string;
privateKeyBase64: string;
keyInfo?: {
passphrase?: unknown;
name?: string;
};
};
function detectLegacyBotSdkCryptoStore(cryptoRootDir: string): {
detected: boolean;
warning?: string;
} {
try {
const stat = fs.statSync(cryptoRootDir);
if (!stat.isDirectory()) {
return {
detected: false,
warning:
`Legacy Matrix encrypted state path exists but is not a directory: ${cryptoRootDir}. ` +
"OpenClaw skipped automatic crypto migration for that path.",
};
}
} catch (err) {
return {
detected: false,
warning:
`Failed reading legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` +
"OpenClaw skipped automatic crypto migration for that path.",
};
}
try {
return {
detected:
fs.existsSync(path.join(cryptoRootDir, "bot-sdk.json")) ||
fs.existsSync(path.join(cryptoRootDir, "matrix-sdk-crypto.sqlite3")) ||
fs
.readdirSync(cryptoRootDir, { withFileTypes: true })
.some(
(entry) =>
entry.isDirectory() &&
fs.existsSync(path.join(cryptoRootDir, entry.name, "matrix-sdk-crypto.sqlite3")),
),
};
} catch (err) {
return {
detected: false,
warning:
`Failed scanning legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` +
"OpenClaw skipped automatic crypto migration for that path.",
};
}
}
function resolveMatrixAccountIds(cfg: OpenClawConfig): string[] {
return resolveConfiguredMatrixAccountIds(cfg);
}
function resolveLegacyMatrixFlatStorePlan(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): MatrixLegacyCryptoPlan | { warning: string } | null {
const legacy = resolveMatrixLegacyFlatStoragePaths(resolveStateDir(params.env, os.homedir));
if (!fs.existsSync(legacy.cryptoPath)) {
return null;
}
const legacyStore = detectLegacyBotSdkCryptoStore(legacy.cryptoPath);
if (legacyStore.warning) {
return { warning: legacyStore.warning };
}
if (!legacyStore.detected) {
return null;
}
const target = resolveLegacyMatrixFlatStoreTarget({
cfg: params.cfg,
env: params.env,
detectedPath: legacy.cryptoPath,
detectedKind: "encrypted state",
});
if ("warning" in target) {
return target;
}
const metadata = loadLegacyBotSdkMetadata(legacy.cryptoPath);
return {
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: target.homeserver,
userId: target.userId,
accessToken: target.accessToken,
deviceId: metadata.deviceId ?? target.storedDeviceId,
};
}
function loadLegacyBotSdkMetadata(cryptoRootDir: string): MatrixLegacyBotSdkMetadata {
const metadataPath = path.join(cryptoRootDir, "bot-sdk.json");
const fallback: MatrixLegacyBotSdkMetadata = { deviceId: null };
try {
if (!fs.existsSync(metadataPath)) {
return fallback;
}
const parsed = JSON.parse(fs.readFileSync(metadataPath, "utf8")) as {
deviceId?: unknown;
};
return {
deviceId:
typeof parsed.deviceId === "string" && parsed.deviceId.trim() ? parsed.deviceId : null,
};
} catch {
return fallback;
}
}
function resolveMatrixLegacyCryptoPlans(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): MatrixLegacyCryptoDetection {
const warnings: string[] = [];
const plans: MatrixLegacyCryptoPlan[] = [];
const flatPlan = resolveLegacyMatrixFlatStorePlan(params);
if (flatPlan) {
if ("warning" in flatPlan) {
warnings.push(flatPlan.warning);
} else {
plans.push(flatPlan);
}
}
for (const accountId of resolveMatrixAccountIds(params.cfg)) {
const target = resolveMatrixMigrationAccountTarget({
cfg: params.cfg,
env: params.env,
accountId,
});
if (!target) {
continue;
}
const legacyCryptoPath = path.join(target.rootDir, "crypto");
if (!fs.existsSync(legacyCryptoPath)) {
continue;
}
const detectedStore = detectLegacyBotSdkCryptoStore(legacyCryptoPath);
if (detectedStore.warning) {
warnings.push(detectedStore.warning);
continue;
}
if (!detectedStore.detected) {
continue;
}
if (
plans.some(
(plan) =>
plan.accountId === accountId &&
path.resolve(plan.legacyCryptoPath) === path.resolve(legacyCryptoPath),
)
) {
continue;
}
const metadata = loadLegacyBotSdkMetadata(legacyCryptoPath);
plans.push({
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: target.homeserver,
userId: target.userId,
accessToken: target.accessToken,
deviceId: metadata.deviceId ?? target.storedDeviceId,
});
}
return { plans, warnings };
}
function loadStoredRecoveryKey(filePath: string): MatrixStoredRecoveryKey | null {
try {
if (!fs.existsSync(filePath)) {
return null;
}
return JSON.parse(fs.readFileSync(filePath, "utf8")) as MatrixStoredRecoveryKey;
} catch {
return null;
}
}
function loadLegacyCryptoMigrationState(filePath: string): MatrixLegacyCryptoMigrationState | null {
try {
if (!fs.existsSync(filePath)) {
return null;
}
return JSON.parse(fs.readFileSync(filePath, "utf8")) as MatrixLegacyCryptoMigrationState;
} catch {
return null;
}
}
async function persistLegacyMigrationState(params: {
filePath: string;
state: MatrixLegacyCryptoMigrationState;
writeJsonFileAtomically: typeof writeJsonFileAtomicallyImpl;
}): Promise<void> {
await params.writeJsonFileAtomically(params.filePath, params.state);
}
export function detectLegacyMatrixCrypto(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): MatrixLegacyCryptoDetection {
const detection = resolveMatrixLegacyCryptoPlans({
cfg: params.cfg,
env: params.env ?? process.env,
});
if (
detection.plans.length > 0 &&
!isMatrixLegacyCryptoInspectorAvailable({
cfg: params.cfg,
env: params.env,
})
) {
return {
plans: detection.plans,
warnings: [...detection.warnings, MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE],
};
}
return detection;
}
export async function autoPrepareLegacyMatrixCrypto(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
log?: { info?: (message: string) => void; warn?: (message: string) => void };
deps?: Partial<MatrixLegacyCryptoPrepareDeps>;
}): Promise<MatrixLegacyCryptoPreparationResult> {
const env = params.env ?? process.env;
const detection = params.deps?.inspectLegacyStore
? resolveMatrixLegacyCryptoPlans({ cfg: params.cfg, env })
: detectLegacyMatrixCrypto({ cfg: params.cfg, env });
const warnings = [...detection.warnings];
const changes: string[] = [];
let inspectLegacyStore = params.deps?.inspectLegacyStore;
const writeJsonFileAtomically =
params.deps?.writeJsonFileAtomically ?? writeJsonFileAtomicallyImpl;
if (!inspectLegacyStore) {
try {
inspectLegacyStore = await loadMatrixLegacyCryptoInspector({
cfg: params.cfg,
env,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (!warnings.includes(message)) {
warnings.push(message);
}
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: false,
changes,
warnings,
};
}
}
for (const plan of detection.plans) {
const existingState = loadLegacyCryptoMigrationState(plan.statePath);
if (existingState?.version === 1) {
continue;
}
if (!plan.deviceId) {
warnings.push(
`Legacy Matrix encrypted state detected at ${plan.legacyCryptoPath}, but no device ID was found for account "${plan.accountId}". ` +
`OpenClaw will continue, but old encrypted history cannot be recovered automatically.`,
);
continue;
}
let summary: MatrixLegacyCryptoSummary;
try {
summary = await inspectLegacyStore({
cryptoRootDir: plan.legacyCryptoPath,
userId: plan.userId,
deviceId: plan.deviceId,
log: params.log?.info,
});
} catch (err) {
warnings.push(
`Failed inspecting legacy Matrix encrypted state for account "${plan.accountId}" (${plan.legacyCryptoPath}): ${String(err)}`,
);
continue;
}
let decryptionKeyImported = false;
if (summary.decryptionKeyBase64) {
const existingRecoveryKey = loadStoredRecoveryKey(plan.recoveryKeyPath);
if (
existingRecoveryKey?.privateKeyBase64 &&
existingRecoveryKey.privateKeyBase64 !== summary.decryptionKeyBase64
) {
warnings.push(
`Legacy Matrix backup key was found for account "${plan.accountId}", but ${plan.recoveryKeyPath} already contains a different recovery key. Leaving the existing file unchanged.`,
);
} else if (!existingRecoveryKey?.privateKeyBase64) {
const payload: MatrixStoredRecoveryKey = {
version: 1,
createdAt: new Date().toISOString(),
keyId: null,
privateKeyBase64: summary.decryptionKeyBase64,
};
try {
await writeJsonFileAtomically(plan.recoveryKeyPath, payload);
changes.push(
`Imported Matrix legacy backup key for account "${plan.accountId}": ${plan.recoveryKeyPath}`,
);
decryptionKeyImported = true;
} catch (err) {
warnings.push(
`Failed writing Matrix recovery key for account "${plan.accountId}" (${plan.recoveryKeyPath}): ${String(err)}`,
);
}
} else {
decryptionKeyImported = true;
}
}
const localOnlyKeys =
summary.roomKeyCounts && summary.roomKeyCounts.total > summary.roomKeyCounts.backedUp
? summary.roomKeyCounts.total - summary.roomKeyCounts.backedUp
: 0;
if (localOnlyKeys > 0) {
warnings.push(
`Legacy Matrix encrypted state for account "${plan.accountId}" contains ${localOnlyKeys} room key(s) that were never backed up. ` +
"Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.",
);
}
if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.backedUp ?? 0) > 0) {
warnings.push(
`Legacy Matrix encrypted state for account "${plan.accountId}" has backed-up room keys, but no local backup decryption key was found. ` +
`Ask the operator to run "openclaw matrix verify backup restore --recovery-key <key>" after upgrade if they have the recovery key.`,
);
}
if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.total ?? 0) > 0) {
warnings.push(
`Legacy Matrix encrypted state for account "${plan.accountId}" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.`,
);
}
// If recovery-key persistence failed, leave the migration state absent so the next startup can retry.
if (
summary.decryptionKeyBase64 &&
!decryptionKeyImported &&
!loadStoredRecoveryKey(plan.recoveryKeyPath)
) {
continue;
}
const state: MatrixLegacyCryptoMigrationState = {
version: 1,
source: "matrix-bot-sdk-rust",
accountId: plan.accountId,
deviceId: summary.deviceId,
roomKeyCounts: summary.roomKeyCounts,
backupVersion: summary.backupVersion,
decryptionKeyImported,
restoreStatus: decryptionKeyImported ? "pending" : "manual-action-required",
detectedAt: new Date().toISOString(),
lastError: null,
};
try {
await persistLegacyMigrationState({
filePath: plan.statePath,
state,
writeJsonFileAtomically,
});
changes.push(
`Prepared Matrix legacy encrypted-state migration for account "${plan.accountId}": ${plan.statePath}`,
);
} catch (err) {
warnings.push(
`Failed writing Matrix legacy encrypted-state migration record for account "${plan.accountId}" (${plan.statePath}): ${String(err)}`,
);
}
}
if (changes.length > 0) {
params.log?.info?.(
`matrix: prepared encrypted-state upgrade.\n${changes.map((entry) => `- ${entry}`).join("\n")}`,
);
}
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: changes.length > 0,
changes,
warnings,
};
}

View File

@ -0,0 +1,244 @@
import fs from "node:fs";
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 { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./matrix-legacy-state.js";
function writeFile(filePath: string, value: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, value, "utf-8");
}
describe("matrix legacy state migration", () => {
it("migrates the flat legacy Matrix store into account-scoped storage", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto");
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
};
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");
}
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
expect(result.migrated).toBe(true);
expect(result.warnings).toEqual([]);
expect(fs.existsSync(path.join(stateDir, "matrix", "bot-storage.json"))).toBe(false);
expect(fs.existsSync(path.join(stateDir, "matrix", "crypto"))).toBe(false);
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true);
});
});
it("uses cached Matrix credentials when the config no longer stores an access token", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
writeFile(
path.join(stateDir, "credentials", "matrix", "credentials.json"),
JSON.stringify(
{
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-from-cache",
},
null,
2,
),
);
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret", // pragma: allowlist secret
},
},
};
const detection = detectLegacyMatrixState({ cfg, env: process.env });
expect(detection && "warning" in detection).toBe(false);
if (!detection || "warning" in detection) {
throw new Error("expected cached credentials to make Matrix migration resolvable");
}
expect(detection.targetRootDir).toContain("matrix.example.org__bot_example.org");
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
expect(result.migrated).toBe(true);
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"');
});
});
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) => {
const stateDir = path.join(home, ".openclaw");
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto");
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {},
},
},
},
};
const detection = detectLegacyMatrixState({ cfg, env: process.env });
expect(detection && "warning" in detection).toBe(false);
if (!detection || "warning" in detection) {
throw new Error("expected scoped Matrix env vars to resolve a legacy state plan");
}
expect(detection.accountId).toBe("ops");
expect(detection.targetRootDir).toContain("matrix.example.org__ops-bot_example.org");
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
expect(result.migrated).toBe(true);
expect(result.warnings).toEqual([]);
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true);
},
{
env: {
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_USER_ID: "@ops-bot:example.org",
MATRIX_OPS_ACCESS_TOKEN: "tok-ops-env",
},
},
);
});
it("migrates flat legacy Matrix state into the only configured non-default account", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto");
writeFile(
path.join(stateDir, "credentials", "matrix", "credentials-ops.json"),
JSON.stringify(
{
homeserver: "https://matrix.example.org",
userId: "@ops-bot:example.org",
accessToken: "tok-ops",
},
null,
2,
),
);
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://matrix.example.org",
userId: "@ops-bot:example.org",
},
},
},
},
};
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("ops");
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
expect(result.migrated).toBe(true);
expect(result.warnings).toEqual([]);
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true);
});
});
});

View File

@ -0,0 +1,156 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveMatrixLegacyFlatStoragePaths } from "../../extensions/matrix/runtime-api.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { resolveLegacyMatrixFlatStoreTarget } from "./matrix-migration-config.js";
export type MatrixLegacyStateMigrationResult = {
migrated: boolean;
changes: string[];
warnings: string[];
};
type MatrixLegacyStatePlan = {
accountId: string;
legacyStoragePath: string;
legacyCryptoPath: string;
targetRootDir: string;
targetStoragePath: string;
targetCryptoPath: string;
selectionNote?: string;
};
function resolveLegacyMatrixPaths(env: NodeJS.ProcessEnv): {
rootDir: string;
storagePath: string;
cryptoPath: string;
} {
const stateDir = resolveStateDir(env, os.homedir);
return resolveMatrixLegacyFlatStoragePaths(stateDir);
}
function resolveMatrixMigrationPlan(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): MatrixLegacyStatePlan | { warning: string } | null {
const legacy = resolveLegacyMatrixPaths(params.env);
if (!fs.existsSync(legacy.storagePath) && !fs.existsSync(legacy.cryptoPath)) {
return null;
}
const target = resolveLegacyMatrixFlatStoreTarget({
cfg: params.cfg,
env: params.env,
detectedPath: legacy.rootDir,
detectedKind: "state",
});
if ("warning" in target) {
return target;
}
return {
accountId: target.accountId,
legacyStoragePath: legacy.storagePath,
legacyCryptoPath: legacy.cryptoPath,
targetRootDir: target.rootDir,
targetStoragePath: path.join(target.rootDir, "bot-storage.json"),
targetCryptoPath: path.join(target.rootDir, "crypto"),
selectionNote: target.selectionNote,
};
}
export function detectLegacyMatrixState(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): MatrixLegacyStatePlan | { warning: string } | null {
return resolveMatrixMigrationPlan({
cfg: params.cfg,
env: params.env ?? process.env,
});
}
function moveLegacyPath(params: {
sourcePath: string;
targetPath: string;
label: string;
changes: string[];
warnings: string[];
}): void {
if (!fs.existsSync(params.sourcePath)) {
return;
}
if (fs.existsSync(params.targetPath)) {
params.warnings.push(
`Matrix legacy ${params.label} not migrated because the target already exists (${params.targetPath}).`,
);
return;
}
try {
fs.mkdirSync(path.dirname(params.targetPath), { recursive: true });
fs.renameSync(params.sourcePath, params.targetPath);
params.changes.push(
`Migrated Matrix legacy ${params.label}: ${params.sourcePath} -> ${params.targetPath}`,
);
} catch (err) {
params.warnings.push(
`Failed migrating Matrix legacy ${params.label} (${params.sourcePath} -> ${params.targetPath}): ${String(err)}`,
);
}
}
export async function autoMigrateLegacyMatrixState(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
log?: { info?: (message: string) => void; warn?: (message: string) => void };
}): Promise<MatrixLegacyStateMigrationResult> {
const env = params.env ?? process.env;
const detection = detectLegacyMatrixState({ cfg: params.cfg, env });
if (!detection) {
return { migrated: false, changes: [], warnings: [] };
}
if ("warning" in detection) {
params.log?.warn?.(`matrix: ${detection.warning}`);
return { migrated: false, changes: [], warnings: [detection.warning] };
}
const changes: string[] = [];
const warnings: string[] = [];
moveLegacyPath({
sourcePath: detection.legacyStoragePath,
targetPath: detection.targetStoragePath,
label: "sync store",
changes,
warnings,
});
moveLegacyPath({
sourcePath: detection.legacyCryptoPath,
targetPath: detection.targetCryptoPath,
label: "crypto store",
changes,
warnings,
});
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${details.join("\n")}`,
);
}
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy state migration warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: changes.length > 0,
changes,
warnings,
};
}

View File

@ -0,0 +1,273 @@
import fs from "node:fs";
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 { resolveMatrixMigrationAccountTarget } from "./matrix-migration-config.js";
function writeFile(filePath: string, value: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, value, "utf8");
}
describe("resolveMatrixMigrationAccountTarget", () => {
it("reuses stored user identity for token-only configs when the access token matches", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(
path.join(stateDir, "credentials", "matrix", "credentials-ops.json"),
JSON.stringify(
{
homeserver: "https://matrix.example.org",
userId: "@ops-bot:example.org",
accessToken: "tok-ops",
deviceId: "DEVICE-OPS",
},
null,
2,
),
);
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "tok-ops",
},
},
},
},
};
const target = resolveMatrixMigrationAccountTarget({
cfg,
env: process.env,
accountId: "ops",
});
expect(target).not.toBeNull();
expect(target?.userId).toBe("@ops-bot:example.org");
expect(target?.storedDeviceId).toBe("DEVICE-OPS");
});
});
it("ignores stored device IDs from stale cached Matrix credentials", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(
path.join(stateDir, "credentials", "matrix", "credentials-ops.json"),
JSON.stringify(
{
homeserver: "https://matrix.example.org",
userId: "@old-bot:example.org",
accessToken: "tok-old",
deviceId: "DEVICE-OLD",
},
null,
2,
),
);
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://matrix.example.org",
userId: "@new-bot:example.org",
accessToken: "tok-new",
},
},
},
},
};
const target = resolveMatrixMigrationAccountTarget({
cfg,
env: process.env,
accountId: "ops",
});
expect(target).not.toBeNull();
expect(target?.userId).toBe("@new-bot:example.org");
expect(target?.accessToken).toBe("tok-new");
expect(target?.storedDeviceId).toBeNull();
});
});
it("does not trust stale stored creds on the same homeserver when the token changes", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(
path.join(stateDir, "credentials", "matrix", "credentials-ops.json"),
JSON.stringify(
{
homeserver: "https://matrix.example.org",
userId: "@old-bot:example.org",
accessToken: "tok-old",
deviceId: "DEVICE-OLD",
},
null,
2,
),
);
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "tok-new",
},
},
},
},
};
const target = resolveMatrixMigrationAccountTarget({
cfg,
env: process.env,
accountId: "ops",
});
expect(target).toBeNull();
});
});
it("does not inherit the base userId for non-default token-only accounts", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(
path.join(stateDir, "credentials", "matrix", "credentials-ops.json"),
JSON.stringify(
{
homeserver: "https://matrix.example.org",
userId: "@ops-bot:example.org",
accessToken: "tok-ops",
deviceId: "DEVICE-OPS",
},
null,
2,
),
);
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@base-bot:example.org",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "tok-ops",
},
},
},
},
};
const target = resolveMatrixMigrationAccountTarget({
cfg,
env: process.env,
accountId: "ops",
});
expect(target).not.toBeNull();
expect(target?.userId).toBe("@ops-bot:example.org");
expect(target?.storedDeviceId).toBe("DEVICE-OPS");
});
});
it("does not inherit the base access token for non-default accounts", async () => {
await withTempHome(async () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@base-bot:example.org",
accessToken: "tok-base",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
userId: "@ops-bot:example.org",
},
},
},
},
};
const target = resolveMatrixMigrationAccountTarget({
cfg,
env: process.env,
accountId: "ops",
});
expect(target).toBeNull();
});
});
it("does not inherit the global Matrix access token for non-default accounts", async () => {
await withTempHome(
async () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://matrix.example.org",
userId: "@ops-bot:example.org",
},
},
},
},
};
const target = resolveMatrixMigrationAccountTarget({
cfg,
env: process.env,
accountId: "ops",
});
expect(target).toBeNull();
},
{
env: {
MATRIX_ACCESS_TOKEN: "tok-global",
},
},
);
});
it("uses the same scoped env token encoding as runtime account auth", async () => {
await withTempHome(async () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
"ops-prod": {},
},
},
},
};
const env = {
MATRIX_OPS_X2D_PROD_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_X2D_PROD_USER_ID: "@ops-prod:example.org",
MATRIX_OPS_X2D_PROD_ACCESS_TOKEN: "tok-ops-prod",
} as NodeJS.ProcessEnv;
const target = resolveMatrixMigrationAccountTarget({
cfg,
env,
accountId: "ops-prod",
});
expect(target).not.toBeNull();
expect(target?.homeserver).toBe("https://matrix.example.org");
expect(target?.userId).toBe("@ops-prod:example.org");
expect(target?.accessToken).toBe("tok-ops-prod");
});
});
});

View File

@ -0,0 +1,268 @@
import fs from "node:fs";
import os from "node:os";
import {
findMatrixAccountEntry,
getMatrixScopedEnvVarNames,
requiresExplicitMatrixDefaultAccount,
resolveMatrixAccountStringValues,
resolveConfiguredMatrixAccountIds,
resolveMatrixAccountStorageRoot,
resolveMatrixChannelConfig,
resolveMatrixCredentialsPath,
resolveMatrixDefaultOrOnlyAccountId,
} from "../../extensions/matrix/runtime-api.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
export type MatrixStoredCredentials = {
homeserver: string;
userId: string;
accessToken: string;
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() : "";
}
function resolveScopedMatrixEnvConfig(
accountId: string,
env: NodeJS.ProcessEnv,
): {
homeserver: string;
userId: string;
accessToken: string;
} {
const keys = getMatrixScopedEnvVarNames(accountId);
return {
homeserver: clean(env[keys.homeserver]),
userId: clean(env[keys.userId]),
accessToken: clean(env[keys.accessToken]),
};
}
function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): {
homeserver: string;
userId: string;
accessToken: string;
} {
return {
homeserver: clean(env.MATRIX_HOMESERVER),
userId: clean(env.MATRIX_USER_ID),
accessToken: clean(env.MATRIX_ACCESS_TOKEN),
};
}
function resolveMatrixAccountConfigEntry(
cfg: OpenClawConfig,
accountId: string,
): Record<string, unknown> | null {
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;
accountId: string;
}): {
homeserver: string;
userId: string;
accessToken: string;
} {
const channel = resolveMatrixChannelConfig(params.cfg);
const account = resolveMatrixAccountConfigEntry(params.cfg, params.accountId);
const scopedEnv = resolveScopedMatrixEnvConfig(params.accountId, params.env);
const globalEnv = resolveGlobalMatrixEnvConfig(params.env);
const normalizedAccountId = normalizeAccountId(params.accountId);
const resolvedStrings = resolveMatrixAccountStringValues({
accountId: normalizedAccountId,
account: {
homeserver: clean(account?.homeserver),
userId: clean(account?.userId),
accessToken: clean(account?.accessToken),
},
scopedEnv,
channel: {
homeserver: clean(channel?.homeserver),
userId: clean(channel?.userId),
accessToken: clean(channel?.accessToken),
},
globalEnv,
});
return {
homeserver: resolvedStrings.homeserver,
userId: resolvedStrings.userId,
accessToken: resolvedStrings.accessToken,
};
}
export function loadStoredMatrixCredentials(
env: NodeJS.ProcessEnv,
accountId: string,
): MatrixStoredCredentials | null {
const stateDir = resolveStateDir(env, os.homedir);
const credentialsPath = resolveMatrixCredentialsPath({
stateDir,
accountId: normalizeAccountId(accountId),
});
try {
if (!fs.existsSync(credentialsPath)) {
return null;
}
const parsed = JSON.parse(
fs.readFileSync(credentialsPath, "utf8"),
) as Partial<MatrixStoredCredentials>;
if (
typeof parsed.homeserver !== "string" ||
typeof parsed.userId !== "string" ||
typeof parsed.accessToken !== "string"
) {
return null;
}
return {
homeserver: parsed.homeserver,
userId: parsed.userId,
accessToken: parsed.accessToken,
deviceId: typeof parsed.deviceId === "string" ? parsed.deviceId : undefined,
};
} catch {
return null;
}
}
export function credentialsMatchResolvedIdentity(
stored: MatrixStoredCredentials | null,
identity: {
homeserver: string;
userId: string;
accessToken: string;
},
): stored is MatrixStoredCredentials {
if (!stored || !identity.homeserver) {
return false;
}
if (!identity.userId) {
if (!identity.accessToken) {
return false;
}
return stored.homeserver === identity.homeserver && stored.accessToken === identity.accessToken;
}
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,
accessToken: resolved.accessToken,
})
? 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: matchingStored?.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,251 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.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);
});
});
it("treats legacy Matrix crypto as warning-only until the plugin helper is available", async () => {
await withTempHome(
async (home) => {
const stateDir = path.join(home, ".openclaw");
fs.mkdirSync(path.join(home, "empty-bundled"), { recursive: true });
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
});
fs.mkdirSync(path.join(rootDir, "crypto"), { recursive: true });
fs.writeFileSync(
path.join(rootDir, "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: "DEVICE123" }),
"utf8",
);
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
} as never;
const detection = detectLegacyMatrixCrypto({
cfg,
env: process.env,
});
expect(detection.plans).toHaveLength(1);
expect(detection.warnings).toContain(
"Legacy Matrix encrypted state was detected, but the Matrix plugin helper is unavailable. Install or repair @openclaw/matrix so OpenClaw can inspect the old rust crypto store before upgrading.",
);
expect(
hasActionableMatrixMigration({
cfg,
env: process.env,
}),
).toBe(false);
},
{
env: {
OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "empty-bundled"),
},
},
);
});
});

View File

@ -0,0 +1,151 @@
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";
import { isMatrixLegacyCryptoInspectorAvailable } from "./matrix-plugin-helper.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 &&
isMatrixLegacyCryptoInspectorAvailable({
cfg: params.cfg,
env,
})
);
}
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,
};
}

View File

@ -0,0 +1,186 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import {
isMatrixLegacyCryptoInspectorAvailable,
loadMatrixLegacyCryptoInspector,
} from "./matrix-plugin-helper.js";
function writeMatrixPluginFixture(rootDir: string, helperBody: string): void {
fs.mkdirSync(rootDir, { recursive: true });
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "matrix",
configSchema: {
type: "object",
additionalProperties: false,
},
}),
"utf8",
);
fs.writeFileSync(path.join(rootDir, "index.js"), "export default {};\n", "utf8");
fs.writeFileSync(path.join(rootDir, "legacy-crypto-inspector.js"), helperBody, "utf8");
}
describe("matrix plugin helper resolution", () => {
it("loads the legacy crypto inspector from the bundled matrix plugin", async () => {
await withTempHome(
async (home) => {
const bundledRoot = path.join(home, "bundled", "matrix");
writeMatrixPluginFixture(
bundledRoot,
[
"export async function inspectLegacyMatrixCryptoStore() {",
' return { deviceId: "BUNDLED", roomKeyCounts: { total: 7, backedUp: 6 }, backupVersion: "1", decryptionKeyBase64: "YWJjZA==" };',
"}",
].join("\n"),
);
const cfg = {} as const;
expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(true);
const inspectLegacyStore = await loadMatrixLegacyCryptoInspector({
cfg,
env: process.env,
});
await expect(
inspectLegacyStore({
cryptoRootDir: "/tmp/legacy",
userId: "@bot:example.org",
deviceId: "DEVICE123",
}),
).resolves.toEqual({
deviceId: "BUNDLED",
roomKeyCounts: { total: 7, backedUp: 6 },
backupVersion: "1",
decryptionKeyBase64: "YWJjZA==",
});
},
{
env: {
OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "bundled"),
},
},
);
});
it("prefers configured plugin load paths over bundled matrix plugins", async () => {
await withTempHome(
async (home) => {
const bundledRoot = path.join(home, "bundled", "matrix");
const customRoot = path.join(home, "plugins", "matrix-local");
writeMatrixPluginFixture(
bundledRoot,
[
"export async function inspectLegacyMatrixCryptoStore() {",
' return { deviceId: "BUNDLED", roomKeyCounts: null, backupVersion: null, decryptionKeyBase64: null };',
"}",
].join("\n"),
);
writeMatrixPluginFixture(
customRoot,
[
"export default async function inspectLegacyMatrixCryptoStore() {",
' return { deviceId: "CONFIG", roomKeyCounts: null, backupVersion: null, decryptionKeyBase64: null };',
"}",
].join("\n"),
);
const cfg = {
plugins: {
load: {
paths: [customRoot],
},
},
} as const;
expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(true);
const inspectLegacyStore = await loadMatrixLegacyCryptoInspector({
cfg,
env: process.env,
});
await expect(
inspectLegacyStore({
cryptoRootDir: "/tmp/legacy",
userId: "@bot:example.org",
deviceId: "DEVICE123",
}),
).resolves.toEqual({
deviceId: "CONFIG",
roomKeyCounts: null,
backupVersion: null,
decryptionKeyBase64: null,
});
},
{
env: {
OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "bundled"),
},
},
);
});
it("rejects helper files that escape the plugin root", async () => {
await withTempHome(
async (home) => {
const customRoot = path.join(home, "plugins", "matrix-local");
const outsideRoot = path.join(home, "outside");
fs.mkdirSync(customRoot, { recursive: true });
fs.mkdirSync(outsideRoot, { recursive: true });
fs.writeFileSync(
path.join(customRoot, "openclaw.plugin.json"),
JSON.stringify({
id: "matrix",
configSchema: {
type: "object",
additionalProperties: false,
},
}),
"utf8",
);
fs.writeFileSync(path.join(customRoot, "index.js"), "export default {};\n", "utf8");
const outsideHelper = path.join(outsideRoot, "legacy-crypto-inspector.js");
fs.writeFileSync(
outsideHelper,
'export default async function inspectLegacyMatrixCryptoStore() { return { deviceId: "ESCAPE", roomKeyCounts: null, backupVersion: null, decryptionKeyBase64: null }; }\n',
"utf8",
);
try {
fs.symlinkSync(
outsideHelper,
path.join(customRoot, "legacy-crypto-inspector.js"),
process.platform === "win32" ? "file" : undefined,
);
} catch {
return;
}
const cfg = {
plugins: {
load: {
paths: [customRoot],
},
},
} as const;
expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(false);
await expect(
loadMatrixLegacyCryptoInspector({
cfg,
env: process.env,
}),
).rejects.toThrow("Matrix plugin helper path is unsafe");
},
{
env: {
OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "empty-bundled"),
},
},
);
});
});

View File

@ -0,0 +1,173 @@
import fs from "node:fs";
import path from "node:path";
import { createJiti } from "jiti";
import type { OpenClawConfig } from "../config/config.js";
import {
loadPluginManifestRegistry,
type PluginManifestRecord,
} from "../plugins/manifest-registry.js";
import { openBoundaryFileSync } from "./boundary-file-read.js";
const MATRIX_PLUGIN_ID = "matrix";
const MATRIX_HELPER_CANDIDATES = [
"legacy-crypto-inspector.ts",
"legacy-crypto-inspector.js",
path.join("dist", "legacy-crypto-inspector.js"),
] as const;
export const MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE =
"Legacy Matrix encrypted state was detected, but the Matrix plugin helper is unavailable. Install or repair @openclaw/matrix so OpenClaw can inspect the old rust crypto store before upgrading.";
type MatrixLegacyCryptoInspectorParams = {
cryptoRootDir: string;
userId: string;
deviceId: string;
log?: (message: string) => void;
};
type MatrixLegacyCryptoInspectorResult = {
deviceId: string | null;
roomKeyCounts: {
total: number;
backedUp: number;
} | null;
backupVersion: string | null;
decryptionKeyBase64: string | null;
};
export type MatrixLegacyCryptoInspector = (
params: MatrixLegacyCryptoInspectorParams,
) => Promise<MatrixLegacyCryptoInspectorResult>;
function resolveMatrixPluginRecord(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
workspaceDir?: string;
}): PluginManifestRecord | null {
const registry = loadPluginManifestRegistry({
config: params.cfg,
workspaceDir: params.workspaceDir,
env: params.env,
});
return registry.plugins.find((plugin) => plugin.id === MATRIX_PLUGIN_ID) ?? null;
}
type MatrixLegacyCryptoInspectorPathResolution =
| { status: "ok"; helperPath: string }
| { status: "missing" }
| { status: "unsafe"; candidatePath: string };
function resolveMatrixLegacyCryptoInspectorPath(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
workspaceDir?: string;
}): MatrixLegacyCryptoInspectorPathResolution {
const plugin = resolveMatrixPluginRecord(params);
if (!plugin) {
return { status: "missing" };
}
for (const relativePath of MATRIX_HELPER_CANDIDATES) {
const candidatePath = path.join(plugin.rootDir, relativePath);
const opened = openBoundaryFileSync({
absolutePath: candidatePath,
rootPath: plugin.rootDir,
boundaryLabel: "plugin root",
rejectHardlinks: plugin.origin !== "bundled",
allowedType: "file",
});
if (opened.ok) {
fs.closeSync(opened.fd);
return { status: "ok", helperPath: opened.path };
}
if (opened.reason !== "path") {
return { status: "unsafe", candidatePath };
}
}
return { status: "missing" };
}
export function isMatrixLegacyCryptoInspectorAvailable(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
workspaceDir?: string;
}): boolean {
return resolveMatrixLegacyCryptoInspectorPath(params).status === "ok";
}
let jitiLoader: ReturnType<typeof createJiti> | null = null;
const inspectorCache = new Map<string, Promise<MatrixLegacyCryptoInspector>>();
function getJiti() {
if (!jitiLoader) {
jitiLoader = createJiti(import.meta.url, {
interopDefault: false,
extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json"],
});
}
return jitiLoader;
}
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function resolveInspectorExport(loaded: unknown): MatrixLegacyCryptoInspector | null {
if (!isObjectRecord(loaded)) {
return null;
}
const directInspector = loaded.inspectLegacyMatrixCryptoStore;
if (typeof directInspector === "function") {
return directInspector as MatrixLegacyCryptoInspector;
}
const directDefault = loaded.default;
if (typeof directDefault === "function") {
return directDefault as MatrixLegacyCryptoInspector;
}
if (!isObjectRecord(directDefault)) {
return null;
}
const nestedInspector = directDefault.inspectLegacyMatrixCryptoStore;
return typeof nestedInspector === "function"
? (nestedInspector as MatrixLegacyCryptoInspector)
: null;
}
export async function loadMatrixLegacyCryptoInspector(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
workspaceDir?: string;
}): Promise<MatrixLegacyCryptoInspector> {
const resolution = resolveMatrixLegacyCryptoInspectorPath(params);
if (resolution.status === "missing") {
throw new Error(MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE);
}
if (resolution.status === "unsafe") {
throw new Error(
`Matrix plugin helper path is unsafe: ${resolution.candidatePath}. Reinstall @openclaw/matrix and try again.`,
);
}
const helperPath = resolution.helperPath;
const cached = inspectorCache.get(helperPath);
if (cached) {
return await cached;
}
const pending = (async () => {
const loaded: unknown = await getJiti().import(helperPath);
const inspectLegacyMatrixCryptoStore = resolveInspectorExport(loaded);
if (!inspectLegacyMatrixCryptoStore) {
throw new Error(
`Matrix plugin helper at ${helperPath} does not export inspectLegacyMatrixCryptoStore(). Reinstall @openclaw/matrix and try again.`,
);
}
return inspectLegacyMatrixCryptoStore;
})();
inspectorCache.set(helperPath, pending);
try {
return await pending;
} catch (err) {
inspectorCache.delete(helperPath);
throw err;
}
}