mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 03:30:29 +00:00
Matrix: harden migration workflow
This commit is contained in:
@@ -2,6 +2,7 @@ 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 { resolveMatrixAccountStorageRoot } from "../infra/matrix-storage-paths.js";
|
||||
import * as noteModule from "../terminal/note.js";
|
||||
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
||||
import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js";
|
||||
@@ -203,6 +204,219 @@ describe("doctor config flow", () => {
|
||||
).toBe("existing-session");
|
||||
});
|
||||
|
||||
it("previews Matrix legacy sync-store migration in read-only mode", async () => {
|
||||
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
||||
try {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(stateDir, "openclaw.json"),
|
||||
JSON.stringify({
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(stateDir, "matrix", "bot-storage.json"),
|
||||
'{"next_batch":"s1"}',
|
||||
);
|
||||
await loadAndMaybeMigrateDoctorConfig({
|
||||
options: { nonInteractive: true },
|
||||
confirm: async () => false,
|
||||
});
|
||||
});
|
||||
|
||||
const warning = noteSpy.mock.calls.find(
|
||||
(call) =>
|
||||
call[1] === "Doctor warnings" &&
|
||||
String(call[0]).includes("Matrix plugin upgraded in place."),
|
||||
);
|
||||
expect(warning?.[0]).toContain("Legacy sync store:");
|
||||
expect(warning?.[0]).toContain(
|
||||
'Run "openclaw doctor --fix" to migrate this Matrix state now.',
|
||||
);
|
||||
} finally {
|
||||
noteSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("previews Matrix encrypted-state migration in read-only mode", async () => {
|
||||
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
||||
try {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const { rootDir: accountRoot } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
});
|
||||
await fs.mkdir(path.join(accountRoot, "crypto"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(stateDir, "openclaw.json"),
|
||||
JSON.stringify({
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(accountRoot, "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: "DEVICE123" }),
|
||||
);
|
||||
await loadAndMaybeMigrateDoctorConfig({
|
||||
options: { nonInteractive: true },
|
||||
confirm: async () => false,
|
||||
});
|
||||
});
|
||||
|
||||
const warning = noteSpy.mock.calls.find(
|
||||
(call) =>
|
||||
call[1] === "Doctor warnings" &&
|
||||
String(call[0]).includes("Matrix encrypted-state migration is pending"),
|
||||
);
|
||||
expect(warning?.[0]).toContain("Legacy crypto store:");
|
||||
expect(warning?.[0]).toContain("New recovery key file:");
|
||||
} finally {
|
||||
noteSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("migrates Matrix legacy state on doctor repair", async () => {
|
||||
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
||||
try {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(stateDir, "openclaw.json"),
|
||||
JSON.stringify({
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(stateDir, "matrix", "bot-storage.json"),
|
||||
'{"next_batch":"s1"}',
|
||||
);
|
||||
await loadAndMaybeMigrateDoctorConfig({
|
||||
options: { nonInteractive: true, repair: true },
|
||||
confirm: async () => false,
|
||||
});
|
||||
|
||||
const migratedRoot = path.join(
|
||||
stateDir,
|
||||
"matrix",
|
||||
"accounts",
|
||||
"default",
|
||||
"matrix.example.org__bot_example.org",
|
||||
);
|
||||
const migratedChildren = await fs.readdir(migratedRoot);
|
||||
expect(migratedChildren.length).toBe(1);
|
||||
expect(
|
||||
await fs
|
||||
.access(path.join(migratedRoot, migratedChildren[0] ?? "", "bot-storage.json"))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await fs
|
||||
.access(path.join(stateDir, "matrix", "bot-storage.json"))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
expect(
|
||||
noteSpy.mock.calls.some(
|
||||
(call) =>
|
||||
call[1] === "Doctor changes" &&
|
||||
String(call[0]).includes("Matrix plugin upgraded in place."),
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
noteSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("creates a Matrix migration snapshot before doctor repair mutates Matrix state", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(stateDir, "openclaw.json"),
|
||||
JSON.stringify({
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
|
||||
|
||||
await loadAndMaybeMigrateDoctorConfig({
|
||||
options: { nonInteractive: true, repair: true },
|
||||
confirm: async () => false,
|
||||
});
|
||||
|
||||
const snapshotDir = path.join(home, "Backups", "openclaw-migrations");
|
||||
const snapshotEntries = await fs.readdir(snapshotDir);
|
||||
expect(snapshotEntries.some((entry) => entry.endsWith(".tar.gz"))).toBe(true);
|
||||
|
||||
const marker = JSON.parse(
|
||||
await fs.readFile(path.join(stateDir, "matrix", "migration-snapshot.json"), "utf8"),
|
||||
) as {
|
||||
archivePath: string;
|
||||
};
|
||||
expect(marker.archivePath).toContain(path.join("Backups", "openclaw-migrations"));
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when Matrix is installed from a stale custom path", async () => {
|
||||
const doctorWarnings = await collectDoctorWarnings({
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
installs: {
|
||||
matrix: {
|
||||
source: "path",
|
||||
sourcePath: "/tmp/openclaw-matrix-missing",
|
||||
installPath: "/tmp/openclaw-matrix-missing",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
doctorWarnings.some(
|
||||
(line) =>
|
||||
line.includes("custom path") && line.includes("/tmp/openclaw-matrix-missing"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("notes legacy browser extension migration changes", async () => {
|
||||
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
||||
try {
|
||||
@@ -233,6 +447,38 @@ describe("doctor config flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
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("preserves discord streaming intent while stripping unsupported keys on repair", async () => {
|
||||
const result = await runDoctorConfigWithInput({
|
||||
repair: true,
|
||||
|
||||
@@ -21,10 +21,6 @@ import {
|
||||
isTrustedSafeBinPath,
|
||||
normalizeTrustedSafeBinDirs,
|
||||
} from "../infra/exec-safe-bin-trust.js";
|
||||
import {
|
||||
detectMatrixInstallPathIssue,
|
||||
formatMatrixInstallPathIssue,
|
||||
} from "../infra/matrix-install-path-warnings.js";
|
||||
import {
|
||||
autoPrepareLegacyMatrixCrypto,
|
||||
detectLegacyMatrixCrypto,
|
||||
@@ -33,6 +29,15 @@ import {
|
||||
autoMigrateLegacyMatrixState,
|
||||
detectLegacyMatrixState,
|
||||
} from "../infra/matrix-legacy-state.js";
|
||||
import {
|
||||
hasActionableMatrixMigration,
|
||||
hasPendingMatrixMigration,
|
||||
maybeCreateMatrixMigrationSnapshot,
|
||||
} from "../infra/matrix-migration-snapshot.js";
|
||||
import {
|
||||
detectPluginInstallPathIssue,
|
||||
formatPluginInstallPathIssue,
|
||||
} from "../infra/plugin-install-path-warnings.js";
|
||||
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
||||
import { resolveTelegramAccount } from "../plugin-sdk/account-resolution.js";
|
||||
import {
|
||||
@@ -334,12 +339,18 @@ function formatMatrixLegacyCryptoPreview(
|
||||
}
|
||||
|
||||
async function collectMatrixInstallPathWarnings(cfg: OpenClawConfig): Promise<string[]> {
|
||||
const issue = await detectMatrixInstallPathIssue(cfg);
|
||||
const issue = await detectPluginInstallPathIssue({
|
||||
pluginId: "matrix",
|
||||
install: cfg.plugins?.installs?.matrix,
|
||||
});
|
||||
if (!issue) {
|
||||
return [];
|
||||
}
|
||||
return formatMatrixInstallPathIssue({
|
||||
return formatPluginInstallPathIssue({
|
||||
issue,
|
||||
pluginLabel: "Matrix",
|
||||
defaultInstallCommand: "openclaw plugins install @openclaw/matrix",
|
||||
repoInstallCommand: "openclaw plugins install ./extensions/matrix",
|
||||
formatCommand: formatCliCommand,
|
||||
}).map((entry) => `- ${entry}`);
|
||||
}
|
||||
@@ -1835,39 +1846,80 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
cfg: candidate,
|
||||
env: process.env,
|
||||
});
|
||||
const pendingMatrixMigration = hasPendingMatrixMigration({
|
||||
cfg: candidate,
|
||||
env: process.env,
|
||||
});
|
||||
const actionableMatrixMigration = hasActionableMatrixMigration({
|
||||
cfg: candidate,
|
||||
env: process.env,
|
||||
});
|
||||
if (shouldRepair) {
|
||||
const matrixStateRepair = await autoMigrateLegacyMatrixState({
|
||||
cfg: candidate,
|
||||
env: process.env,
|
||||
});
|
||||
if (matrixStateRepair.changes.length > 0) {
|
||||
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 plugin upgraded in place.",
|
||||
...matrixStateRepair.changes.map((entry) => `- ${entry}`),
|
||||
"- No user action required.",
|
||||
].join("\n"),
|
||||
"Doctor changes",
|
||||
"- Matrix migration warnings are present, but no on-disk Matrix mutation is actionable yet. No pre-migration snapshot was needed.",
|
||||
"Doctor warnings",
|
||||
);
|
||||
}
|
||||
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");
|
||||
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) {
|
||||
|
||||
130
src/gateway/server-startup-matrix-migration.test.ts
Normal file
130
src/gateway/server-startup-matrix-migration.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
68
src/gateway/server-startup-matrix-migration.ts
Normal file
68
src/gateway/server-startup-matrix-migration.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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;
|
||||
};
|
||||
|
||||
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 migrateLegacyState({
|
||||
cfg: params.cfg,
|
||||
env,
|
||||
log: params.log,
|
||||
});
|
||||
await prepareLegacyCrypto({
|
||||
cfg: params.cfg,
|
||||
env,
|
||||
log: params.log,
|
||||
});
|
||||
}
|
||||
@@ -35,13 +35,11 @@ import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js
|
||||
import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
|
||||
import { startHeartbeatRunner, type HeartbeatRunner } from "../infra/heartbeat-runner.js";
|
||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||
import {
|
||||
detectMatrixInstallPathIssue,
|
||||
formatMatrixInstallPathIssue,
|
||||
} from "../infra/matrix-install-path-warnings.js";
|
||||
import { autoPrepareLegacyMatrixCrypto } from "../infra/matrix-legacy-crypto.js";
|
||||
import { autoMigrateLegacyMatrixState } from "../infra/matrix-legacy-state.js";
|
||||
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
|
||||
import {
|
||||
detectPluginInstallPathIssue,
|
||||
formatPluginInstallPathIssue,
|
||||
} from "../infra/plugin-install-path-warnings.js";
|
||||
import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js";
|
||||
import {
|
||||
primeRemoteSkillsCache,
|
||||
@@ -104,6 +102,7 @@ import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js";
|
||||
import { createGatewayRuntimeState } from "./server-runtime-state.js";
|
||||
import { resolveSessionKeyForRun } from "./server-session-key.js";
|
||||
import { logGatewayStartup } from "./server-startup-log.js";
|
||||
import { runStartupMatrixMigration } from "./server-startup-matrix-migration.js";
|
||||
import { startGatewaySidecars } from "./server-startup.js";
|
||||
import { startGatewayTailscaleExposure } from "./server-tailscale.js";
|
||||
import { createWizardSessionTracker } from "./server-wizard-sessions.js";
|
||||
@@ -410,22 +409,23 @@ export async function startGatewayServer(
|
||||
}
|
||||
|
||||
let secretsDegraded = false;
|
||||
await autoMigrateLegacyMatrixState({
|
||||
cfg: autoEnable.changes.length > 0 ? autoEnable.config : configSnapshot.config,
|
||||
const matrixMigrationConfig =
|
||||
autoEnable.changes.length > 0 ? autoEnable.config : configSnapshot.config;
|
||||
await runStartupMatrixMigration({
|
||||
cfg: matrixMigrationConfig,
|
||||
env: process.env,
|
||||
log,
|
||||
});
|
||||
await autoPrepareLegacyMatrixCrypto({
|
||||
cfg: autoEnable.changes.length > 0 ? autoEnable.config : configSnapshot.config,
|
||||
env: process.env,
|
||||
log,
|
||||
const matrixInstallPathIssue = await detectPluginInstallPathIssue({
|
||||
pluginId: "matrix",
|
||||
install: matrixMigrationConfig.plugins?.installs?.matrix,
|
||||
});
|
||||
const matrixInstallPathIssue = await detectMatrixInstallPathIssue(
|
||||
autoEnable.changes.length > 0 ? autoEnable.config : configSnapshot.config,
|
||||
);
|
||||
if (matrixInstallPathIssue) {
|
||||
const lines = formatMatrixInstallPathIssue({
|
||||
const lines = formatPluginInstallPathIssue({
|
||||
issue: matrixInstallPathIssue,
|
||||
pluginLabel: "Matrix",
|
||||
defaultInstallCommand: "openclaw plugins install @openclaw/matrix",
|
||||
repoInstallCommand: "openclaw plugins install ./extensions/matrix",
|
||||
formatCommand: formatCliCommand,
|
||||
});
|
||||
log.warn(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
findMatrixAccountEntry,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "./matrix-account-selection.js";
|
||||
@@ -36,6 +37,22 @@ describe("matrix account selection", () => {
|
||||
};
|
||||
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires an explicit default when multiple Matrix accounts exist without one", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: { homeserver: "https://matrix.example.org" },
|
||||
alerts: { homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(true);
|
||||
});
|
||||
|
||||
it("finds the raw Matrix account entry by normalized account id", () => {
|
||||
|
||||
@@ -79,3 +79,18 @@ export function resolveMatrixDefaultOrOnlyAccountId(cfg: OpenClawConfig): string
|
||||
}
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
export function requiresExplicitMatrixDefaultAccount(cfg: OpenClawConfig): boolean {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
if (!channel) {
|
||||
return false;
|
||||
}
|
||||
const configuredAccountIds = resolveConfiguredMatrixAccountIds(cfg);
|
||||
if (configuredAccountIds.length <= 1) {
|
||||
return false;
|
||||
}
|
||||
const configuredDefault = normalizeOptionalAccountId(
|
||||
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
|
||||
);
|
||||
return !(configuredDefault && configuredAccountIds.includes(configuredDefault));
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
detectMatrixInstallPathIssue,
|
||||
formatMatrixInstallPathIssue,
|
||||
} from "./matrix-install-path-warnings.js";
|
||||
|
||||
describe("matrix install path warnings", () => {
|
||||
it("detects stale custom Matrix plugin paths", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
plugins: {
|
||||
installs: {
|
||||
matrix: {
|
||||
source: "path",
|
||||
sourcePath: "/tmp/openclaw-matrix-missing",
|
||||
installPath: "/tmp/openclaw-matrix-missing",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const issue = await detectMatrixInstallPathIssue(cfg);
|
||||
expect(issue).toEqual({ missingPath: "/tmp/openclaw-matrix-missing" });
|
||||
expect(
|
||||
formatMatrixInstallPathIssue({
|
||||
issue: issue!,
|
||||
}),
|
||||
).toEqual([
|
||||
"Matrix is installed from a custom path that no longer exists: /tmp/openclaw-matrix-missing",
|
||||
'Reinstall with "openclaw plugins install @openclaw/matrix".',
|
||||
'If you are running from a repo checkout, you can also use "openclaw plugins install ./extensions/matrix".',
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips warnings when the configured custom path exists", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const pluginPath = path.join(home, "matrix-plugin");
|
||||
await fs.mkdir(pluginPath, { recursive: true });
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
plugins: {
|
||||
installs: {
|
||||
matrix: {
|
||||
source: "path",
|
||||
sourcePath: pluginPath,
|
||||
installPath: pluginPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(detectMatrixInstallPathIssue(cfg)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
|
||||
export type MatrixInstallPathIssue = {
|
||||
missingPath: string;
|
||||
};
|
||||
|
||||
function resolveMatrixInstallCandidatePaths(cfg: OpenClawConfig): string[] {
|
||||
const install = cfg.plugins?.installs?.matrix;
|
||||
if (!install || install.source !== "path") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [install.sourcePath, install.installPath]
|
||||
.map((value) => (typeof value === "string" ? value.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export async function detectMatrixInstallPathIssue(
|
||||
cfg: OpenClawConfig,
|
||||
): Promise<MatrixInstallPathIssue | null> {
|
||||
const candidatePaths = resolveMatrixInstallCandidatePaths(cfg);
|
||||
if (candidatePaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const candidatePath of candidatePaths) {
|
||||
try {
|
||||
await fs.access(path.resolve(candidatePath));
|
||||
return null;
|
||||
} catch {
|
||||
// keep checking remaining candidates
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
missingPath: candidatePaths[0] ?? "(unknown)",
|
||||
};
|
||||
}
|
||||
|
||||
export function formatMatrixInstallPathIssue(params: {
|
||||
issue: MatrixInstallPathIssue;
|
||||
formatCommand?: (command: string) => string;
|
||||
}): string[] {
|
||||
const formatCommand = params.formatCommand ?? ((command: string) => command);
|
||||
return [
|
||||
`Matrix is installed from a custom path that no longer exists: ${params.issue.missingPath}`,
|
||||
`Reinstall with "${formatCommand("openclaw plugins install @openclaw/matrix")}".`,
|
||||
`If you are running from a repo checkout, you can also use "${formatCommand("openclaw plugins install ./extensions/matrix")}".`,
|
||||
];
|
||||
}
|
||||
@@ -258,4 +258,41 @@ describe("matrix legacy encrypted-state migration", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,21 +5,12 @@ 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 { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
||||
import { resolveConfiguredMatrixAccountIds } from "./matrix-account-selection.js";
|
||||
import {
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixChannelConfig,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "./matrix-account-selection.js";
|
||||
import {
|
||||
credentialsMatchResolvedIdentity,
|
||||
loadStoredMatrixCredentials,
|
||||
resolveMatrixMigrationConfigFields,
|
||||
resolveLegacyMatrixFlatStoreTarget,
|
||||
resolveMatrixMigrationAccountTarget,
|
||||
} from "./matrix-migration-config.js";
|
||||
import {
|
||||
resolveMatrixAccountStorageRoot,
|
||||
resolveMatrixLegacyFlatStoragePaths,
|
||||
} from "./matrix-storage-paths.js";
|
||||
import { resolveMatrixLegacyFlatStoragePaths } from "./matrix-storage-paths.js";
|
||||
|
||||
type MatrixLegacyCryptoCounts = {
|
||||
total: number;
|
||||
@@ -114,10 +105,6 @@ function resolveMatrixAccountIds(cfg: OpenClawConfig): string[] {
|
||||
return resolveConfiguredMatrixAccountIds(cfg);
|
||||
}
|
||||
|
||||
function resolveMatrixFlatStoreTargetAccountId(cfg: OpenClawConfig): string {
|
||||
return resolveMatrixDefaultOrOnlyAccountId(cfg);
|
||||
}
|
||||
|
||||
function resolveLegacyMatrixFlatStorePlan(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
@@ -127,60 +114,27 @@ function resolveLegacyMatrixFlatStorePlan(params: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channel = resolveMatrixChannelConfig(params.cfg);
|
||||
if (!channel) {
|
||||
return {
|
||||
warning:
|
||||
`Legacy Matrix encrypted state detected at ${legacy.cryptoPath}, but channels.matrix is not configured yet. ` +
|
||||
'Configure Matrix, then rerun "openclaw doctor --fix" or restart the gateway.',
|
||||
};
|
||||
}
|
||||
|
||||
const accountId = resolveMatrixFlatStoreTargetAccountId(params.cfg);
|
||||
const stored = loadStoredMatrixCredentials(params.env, accountId);
|
||||
const resolved = resolveMatrixMigrationConfigFields({
|
||||
const target = resolveLegacyMatrixFlatStoreTarget({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
accountId,
|
||||
detectedPath: legacy.cryptoPath,
|
||||
detectedKind: "encrypted state",
|
||||
});
|
||||
const matchingStored = credentialsMatchResolvedIdentity(stored, {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: resolved.userId,
|
||||
})
|
||||
? stored
|
||||
: null;
|
||||
const homeserver = resolved.homeserver;
|
||||
const userId = resolved.userId || matchingStored?.userId || "";
|
||||
const accessToken = resolved.accessToken || matchingStored?.accessToken || "";
|
||||
|
||||
if (!homeserver || !userId || !accessToken) {
|
||||
return {
|
||||
warning:
|
||||
`Legacy Matrix encrypted state detected at ${legacy.cryptoPath}, but the account-scoped target 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.',
|
||||
};
|
||||
if ("warning" in target) {
|
||||
return target;
|
||||
}
|
||||
|
||||
const stateDir = resolveStateDir(params.env, os.homedir);
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken,
|
||||
accountId,
|
||||
});
|
||||
const metadata = loadLegacyBotSdkMetadata(legacy.cryptoPath);
|
||||
return {
|
||||
accountId,
|
||||
rootDir,
|
||||
recoveryKeyPath: path.join(rootDir, "recovery-key.json"),
|
||||
statePath: path.join(rootDir, "legacy-crypto-migration.json"),
|
||||
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,
|
||||
userId,
|
||||
accessToken,
|
||||
deviceId: metadata.deviceId ?? stored?.deviceId ?? null,
|
||||
homeserver: target.homeserver,
|
||||
userId: target.userId,
|
||||
accessToken: target.accessToken,
|
||||
deviceId: metadata.deviceId ?? target.storedDeviceId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -219,34 +173,16 @@ function resolveMatrixLegacyCryptoPlans(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const stateDir = resolveStateDir(params.env, os.homedir);
|
||||
for (const accountId of resolveMatrixAccountIds(params.cfg)) {
|
||||
const stored = loadStoredMatrixCredentials(params.env, accountId);
|
||||
const resolved = resolveMatrixMigrationConfigFields({
|
||||
const target = resolveMatrixMigrationAccountTarget({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
accountId,
|
||||
});
|
||||
const matchingStored = credentialsMatchResolvedIdentity(stored, {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: resolved.userId,
|
||||
})
|
||||
? stored
|
||||
: null;
|
||||
const homeserver = resolved.homeserver;
|
||||
const userId = resolved.userId || matchingStored?.userId || "";
|
||||
const accessToken = resolved.accessToken || matchingStored?.accessToken || "";
|
||||
if (!homeserver || !userId || !accessToken) {
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken,
|
||||
accountId,
|
||||
});
|
||||
const legacyCryptoPath = path.join(rootDir, "crypto");
|
||||
const legacyCryptoPath = path.join(target.rootDir, "crypto");
|
||||
if (!fs.existsSync(legacyCryptoPath) || !isLegacyBotSdkCryptoStore(legacyCryptoPath)) {
|
||||
continue;
|
||||
}
|
||||
@@ -261,15 +197,15 @@ function resolveMatrixLegacyCryptoPlans(params: {
|
||||
}
|
||||
const metadata = loadLegacyBotSdkMetadata(legacyCryptoPath);
|
||||
plans.push({
|
||||
accountId,
|
||||
rootDir,
|
||||
recoveryKeyPath: path.join(rootDir, "recovery-key.json"),
|
||||
statePath: path.join(rootDir, "legacy-crypto-migration.json"),
|
||||
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,
|
||||
userId,
|
||||
accessToken,
|
||||
deviceId: metadata.deviceId ?? stored?.deviceId ?? null,
|
||||
homeserver: target.homeserver,
|
||||
userId: target.userId,
|
||||
accessToken: target.accessToken,
|
||||
deviceId: metadata.deviceId ?? target.storedDeviceId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,39 @@ describe("matrix legacy state migration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -3,20 +3,8 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||
import {
|
||||
resolveMatrixChannelConfig,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "./matrix-account-selection.js";
|
||||
import {
|
||||
credentialsMatchResolvedIdentity,
|
||||
loadStoredMatrixCredentials,
|
||||
resolveMatrixMigrationConfigFields,
|
||||
} from "./matrix-migration-config.js";
|
||||
import {
|
||||
resolveMatrixAccountStorageRoot,
|
||||
resolveMatrixLegacyFlatStoragePaths,
|
||||
} from "./matrix-storage-paths.js";
|
||||
import { resolveLegacyMatrixFlatStoreTarget } from "./matrix-migration-config.js";
|
||||
import { resolveMatrixLegacyFlatStoragePaths } from "./matrix-storage-paths.js";
|
||||
|
||||
export type MatrixLegacyStateMigrationResult = {
|
||||
migrated: boolean;
|
||||
@@ -34,10 +22,6 @@ type MatrixLegacyStatePlan = {
|
||||
selectionNote?: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function resolveLegacyMatrixPaths(env: NodeJS.ProcessEnv): {
|
||||
rootDir: string;
|
||||
storagePath: string;
|
||||
@@ -47,36 +31,6 @@ function resolveLegacyMatrixPaths(env: NodeJS.ProcessEnv): {
|
||||
return resolveMatrixLegacyFlatStoragePaths(stateDir);
|
||||
}
|
||||
|
||||
function resolveMatrixTargetAccountId(cfg: OpenClawConfig): string {
|
||||
return resolveMatrixDefaultOrOnlyAccountId(cfg);
|
||||
}
|
||||
|
||||
function resolveMatrixFlatStoreSelectionNote(params: {
|
||||
channel: Record<string, unknown>;
|
||||
accountId: string;
|
||||
}): string | undefined {
|
||||
const accounts = isRecord(params.channel.accounts) ? params.channel.accounts : null;
|
||||
if (!accounts) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const configuredAccounts = Array.from(
|
||||
new Set(
|
||||
Object.keys(accounts)
|
||||
.map((accountId) => normalizeAccountId(accountId))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
if (configuredAccounts.length <= 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
`Legacy Matrix flat store uses one shared on-disk state, so it will be migrated into ` +
|
||||
`account "${params.accountId}".`
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatrixMigrationPlan(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
@@ -86,59 +40,24 @@ function resolveMatrixMigrationPlan(params: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channel = resolveMatrixChannelConfig(params.cfg);
|
||||
if (!channel) {
|
||||
return {
|
||||
warning:
|
||||
`Legacy Matrix state detected at ${legacy.rootDir}, but channels.matrix is not configured yet. ` +
|
||||
'Configure Matrix, then rerun "openclaw doctor --fix" or restart the gateway.',
|
||||
};
|
||||
}
|
||||
|
||||
const accountId = resolveMatrixTargetAccountId(params.cfg);
|
||||
const stored = loadStoredMatrixCredentials(params.env, accountId);
|
||||
const selectionNote = resolveMatrixFlatStoreSelectionNote({ channel, accountId });
|
||||
const resolved = resolveMatrixMigrationConfigFields({
|
||||
const target = resolveLegacyMatrixFlatStoreTarget({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
accountId,
|
||||
detectedPath: legacy.rootDir,
|
||||
detectedKind: "state",
|
||||
});
|
||||
const matchingStored = credentialsMatchResolvedIdentity(stored, {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: resolved.userId,
|
||||
})
|
||||
? stored
|
||||
: null;
|
||||
const homeserver = resolved.homeserver;
|
||||
const userId = resolved.userId || matchingStored?.userId || "";
|
||||
const accessToken = resolved.accessToken || matchingStored?.accessToken || "";
|
||||
|
||||
if (!homeserver || !userId || !accessToken) {
|
||||
return {
|
||||
warning:
|
||||
`Legacy Matrix state detected at ${legacy.rootDir}, but the new account-scoped target 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.',
|
||||
};
|
||||
if ("warning" in target) {
|
||||
return target;
|
||||
}
|
||||
|
||||
const stateDir = resolveStateDir(params.env, os.homedir);
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken,
|
||||
accountId,
|
||||
});
|
||||
|
||||
return {
|
||||
accountId,
|
||||
accountId: target.accountId,
|
||||
legacyStoragePath: legacy.storagePath,
|
||||
legacyCryptoPath: legacy.cryptoPath,
|
||||
targetRootDir: rootDir,
|
||||
targetStoragePath: path.join(rootDir, "bot-storage.json"),
|
||||
targetCryptoPath: path.join(rootDir, "crypto"),
|
||||
selectionNote,
|
||||
targetRootDir: target.rootDir,
|
||||
targetStoragePath: path.join(target.rootDir, "bot-storage.json"),
|
||||
targetCryptoPath: path.join(target.rootDir, "crypto"),
|
||||
selectionNote: target.selectionNote,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,18 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { normalizeAccountId } from "../routing/session-key.js";
|
||||
import { findMatrixAccountEntry, resolveMatrixChannelConfig } from "./matrix-account-selection.js";
|
||||
import { resolveMatrixCredentialsPath } from "./matrix-storage-paths.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||
import {
|
||||
findMatrixAccountEntry,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixChannelConfig,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "./matrix-account-selection.js";
|
||||
import {
|
||||
resolveMatrixAccountStorageRoot,
|
||||
resolveMatrixCredentialsPath,
|
||||
} from "./matrix-storage-paths.js";
|
||||
|
||||
export type MatrixStoredCredentials = {
|
||||
homeserver: string;
|
||||
@@ -13,6 +22,21 @@ export type MatrixStoredCredentials = {
|
||||
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() : "";
|
||||
}
|
||||
@@ -59,6 +83,19 @@ function resolveMatrixAccountConfigEntry(
|
||||
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;
|
||||
@@ -138,3 +175,89 @@ export function credentialsMatchResolvedIdentity(
|
||||
}
|
||||
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,
|
||||
})
|
||||
? 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: stored?.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),
|
||||
};
|
||||
}
|
||||
|
||||
198
src/infra/matrix-migration-snapshot.test.ts
Normal file
198
src/infra/matrix-migration-snapshot.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.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);
|
||||
});
|
||||
});
|
||||
});
|
||||
144
src/infra/matrix-migration-snapshot.ts
Normal file
144
src/infra/matrix-migration-snapshot.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
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";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user