Compare commits
1 Commits
main
...
codex/matr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a48eb9ff7f |
4
extensions/matrix/runtime-api.ts
Normal file
4
extensions/matrix/runtime-api.ts
Normal 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";
|
||||||
106
extensions/matrix/src/account-selection.ts
Normal file
106
extensions/matrix/src/account-selection.ts
Normal 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));
|
||||||
|
}
|
||||||
61
extensions/matrix/src/auth-precedence.ts
Normal file
61
extensions/matrix/src/auth-precedence.ts
Normal 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;
|
||||||
|
}
|
||||||
92
extensions/matrix/src/env-vars.ts
Normal file
92
extensions/matrix/src/env-vars.ts
Normal 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));
|
||||||
|
}
|
||||||
93
extensions/matrix/src/storage-paths.ts
Normal file
93
extensions/matrix/src/storage-paths.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js";
|
||||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||||
import * as noteModule from "../terminal/note.js";
|
import * as noteModule from "../terminal/note.js";
|
||||||
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
||||||
@ -203,6 +204,250 @@ describe("doctor config flow", () => {
|
|||||||
).toBe("existing-session");
|
).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 () => {
|
it("notes legacy browser extension migration changes", async () => {
|
||||||
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -21,6 +21,23 @@ import {
|
|||||||
isTrustedSafeBinPath,
|
isTrustedSafeBinPath,
|
||||||
normalizeTrustedSafeBinDirs,
|
normalizeTrustedSafeBinDirs,
|
||||||
} from "../infra/exec-safe-bin-trust.js";
|
} 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 { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
||||||
import { resolveTelegramAccount } from "../plugin-sdk/account-resolution.js";
|
import { resolveTelegramAccount } from "../plugin-sdk/account-resolution.js";
|
||||||
import {
|
import {
|
||||||
@ -316,6 +333,56 @@ function scanTelegramAllowFromUsernameEntries(cfg: OpenClawConfig): TelegramAllo
|
|||||||
return hits;
|
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<{
|
async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promise<{
|
||||||
config: OpenClawConfig;
|
config: OpenClawConfig;
|
||||||
changes: string[];
|
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 =
|
const missingDefaultAccountBindingWarnings =
|
||||||
collectMissingDefaultAccountBindingWarnings(candidate);
|
collectMissingDefaultAccountBindingWarnings(candidate);
|
||||||
if (missingDefaultAccountBindingWarnings.length > 0) {
|
if (missingDefaultAccountBindingWarnings.length > 0) {
|
||||||
|
|||||||
180
src/gateway/server-startup-matrix-migration.test.ts
Normal file
180
src/gateway/server-startup-matrix-migration.test.ts
Normal 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
92
src/gateway/server-startup-matrix-migration.ts
Normal file
92
src/gateway/server-startup-matrix-migration.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -36,6 +36,10 @@ import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
|
|||||||
import { startHeartbeatRunner, type HeartbeatRunner } from "../infra/heartbeat-runner.js";
|
import { startHeartbeatRunner, type HeartbeatRunner } from "../infra/heartbeat-runner.js";
|
||||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||||
import { ensureOpenClawCliOnPath } from "../infra/path-env.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 { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js";
|
||||||
import {
|
import {
|
||||||
primeRemoteSkillsCache,
|
primeRemoteSkillsCache,
|
||||||
@ -98,6 +102,7 @@ import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js";
|
|||||||
import { createGatewayRuntimeState } from "./server-runtime-state.js";
|
import { createGatewayRuntimeState } from "./server-runtime-state.js";
|
||||||
import { resolveSessionKeyForRun } from "./server-session-key.js";
|
import { resolveSessionKeyForRun } from "./server-session-key.js";
|
||||||
import { logGatewayStartup } from "./server-startup-log.js";
|
import { logGatewayStartup } from "./server-startup-log.js";
|
||||||
|
import { runStartupMatrixMigration } from "./server-startup-matrix-migration.js";
|
||||||
import { startGatewaySidecars } from "./server-startup.js";
|
import { startGatewaySidecars } from "./server-startup.js";
|
||||||
import { startGatewayTailscaleExposure } from "./server-tailscale.js";
|
import { startGatewayTailscaleExposure } from "./server-tailscale.js";
|
||||||
import { createWizardSessionTracker } from "./server-wizard-sessions.js";
|
import { createWizardSessionTracker } from "./server-wizard-sessions.js";
|
||||||
@ -404,6 +409,29 @@ export async function startGatewayServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let secretsDegraded = false;
|
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 = (
|
const emitSecretsStateEvent = (
|
||||||
code: "SECRETS_RELOADER_DEGRADED" | "SECRETS_RELOADER_RECOVERED",
|
code: "SECRETS_RELOADER_DEGRADED" | "SECRETS_RELOADER_RECOVERED",
|
||||||
message: string,
|
message: string,
|
||||||
|
|||||||
448
src/infra/matrix-legacy-crypto.test.ts
Normal file
448
src/infra/matrix-legacy-crypto.test.ts
Normal 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"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
493
src/infra/matrix-legacy-crypto.ts
Normal file
493
src/infra/matrix-legacy-crypto.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
244
src/infra/matrix-legacy-state.test.ts
Normal file
244
src/infra/matrix-legacy-state.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
156
src/infra/matrix-legacy-state.ts
Normal file
156
src/infra/matrix-legacy-state.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
273
src/infra/matrix-migration-config.test.ts
Normal file
273
src/infra/matrix-migration-config.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
268
src/infra/matrix-migration-config.ts
Normal file
268
src/infra/matrix-migration-config.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
251
src/infra/matrix-migration-snapshot.test.ts
Normal file
251
src/infra/matrix-migration-snapshot.test.ts
Normal 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"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
151
src/infra/matrix-migration-snapshot.ts
Normal file
151
src/infra/matrix-migration-snapshot.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
186
src/infra/matrix-plugin-helper.test.ts
Normal file
186
src/infra/matrix-plugin-helper.test.ts
Normal 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"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
173
src/infra/matrix-plugin-helper.ts
Normal file
173
src/infra/matrix-plugin-helper.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user