mirror of https://github.com/openclaw/openclaw.git
Matrix: add backup reset and fix migration targeting
This commit is contained in:
parent
3d7fd9088b
commit
6f78fe27d6
|
|
@ -251,6 +251,12 @@ Verbose restore diagnostics:
|
|||
openclaw matrix verify backup restore --verbose
|
||||
```
|
||||
|
||||
Delete the current server backup and create a fresh backup baseline:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify backup reset --yes
|
||||
```
|
||||
|
||||
All `verify` commands are concise by default (including quiet internal SDK logging) and show detailed diagnostics only with `--verbose`.
|
||||
Use `--json` for full machine-readable output when scripting.
|
||||
|
||||
|
|
@ -291,6 +297,9 @@ If the homeserver requires interactive auth to upload cross-signing keys, OpenCl
|
|||
|
||||
Use `--force-reset-cross-signing` only when you intentionally want to discard the current cross-signing identity and create a new one.
|
||||
|
||||
If you intentionally want to discard the current room-key backup and start a new backup baseline for future messages, use `openclaw matrix verify backup reset --yes`.
|
||||
Do this only when you accept that unrecoverable old encrypted history will stay unavailable.
|
||||
|
||||
### Startup behavior
|
||||
|
||||
When `encryption: true`, Matrix defaults `startupVerification` to `"if-unverified"`.
|
||||
|
|
|
|||
|
|
@ -93,7 +93,13 @@ If your old installation had local-only encrypted history that was never backed
|
|||
openclaw matrix verify device "<your-recovery-key>"
|
||||
```
|
||||
|
||||
7. If no server-side key backup exists yet, create one for future recoveries:
|
||||
7. If you are intentionally abandoning unrecoverable old history and want a fresh backup baseline for future messages, run:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify backup reset --yes
|
||||
```
|
||||
|
||||
8. If no server-side key backup exists yet, create one for future recoveries:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify bootstrap
|
||||
|
|
@ -202,6 +208,8 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins
|
|||
- Meaning: the stored key does not match the active Matrix backup.
|
||||
- What to do: rerun `openclaw matrix verify device "<your-recovery-key>"` with the correct key.
|
||||
|
||||
If you accept losing unrecoverable old encrypted history, you can instead reset the current backup baseline with `openclaw matrix verify backup reset --yes`.
|
||||
|
||||
`Backup trust chain is not verified on this device. Re-run 'openclaw matrix verify device <key>'.`
|
||||
|
||||
- Meaning: the backup exists, but this device does not trust the cross-signing chain strongly enough yet.
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const matrixSetupApplyAccountConfigMock = vi.fn();
|
|||
const matrixSetupValidateInputMock = vi.fn();
|
||||
const matrixRuntimeLoadConfigMock = vi.fn();
|
||||
const matrixRuntimeWriteConfigFileMock = vi.fn();
|
||||
const resetMatrixRoomKeyBackupMock = vi.fn();
|
||||
const restoreMatrixRoomKeyBackupMock = vi.fn();
|
||||
const setMatrixSdkConsoleLoggingMock = vi.fn();
|
||||
const setMatrixSdkLogModeMock = vi.fn();
|
||||
|
|
@ -24,6 +25,7 @@ vi.mock("./matrix/actions/verification.js", () => ({
|
|||
bootstrapMatrixVerification: (...args: unknown[]) => bootstrapMatrixVerificationMock(...args),
|
||||
getMatrixRoomKeyBackupStatus: (...args: unknown[]) => getMatrixRoomKeyBackupStatusMock(...args),
|
||||
getMatrixVerificationStatus: (...args: unknown[]) => getMatrixVerificationStatusMock(...args),
|
||||
resetMatrixRoomKeyBackup: (...args: unknown[]) => resetMatrixRoomKeyBackupMock(...args),
|
||||
restoreMatrixRoomKeyBackup: (...args: unknown[]) => restoreMatrixRoomKeyBackupMock(...args),
|
||||
verifyMatrixRecoveryKey: (...args: unknown[]) => verifyMatrixRecoveryKeyMock(...args),
|
||||
}));
|
||||
|
|
@ -118,6 +120,21 @@ describe("matrix CLI verification commands", () => {
|
|||
pendingVerifications: 0,
|
||||
cryptoBootstrap: {},
|
||||
});
|
||||
resetMatrixRoomKeyBackupMock.mockResolvedValue({
|
||||
success: true,
|
||||
previousVersion: "1",
|
||||
deletedVersion: "1",
|
||||
createdVersion: "2",
|
||||
backup: {
|
||||
serverVersion: "2",
|
||||
activeVersion: "2",
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
decryptionKeyCached: true,
|
||||
keyLoadAttempted: false,
|
||||
keyLoadError: null,
|
||||
},
|
||||
});
|
||||
updateMatrixOwnProfileMock.mockResolvedValue({
|
||||
skipped: false,
|
||||
displayNameUpdated: true,
|
||||
|
|
@ -195,6 +212,32 @@ describe("matrix CLI verification commands", () => {
|
|||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("sets non-zero exit code for backup reset failures in JSON mode", async () => {
|
||||
resetMatrixRoomKeyBackupMock.mockResolvedValue({
|
||||
success: false,
|
||||
error: "reset failed",
|
||||
previousVersion: "1",
|
||||
deletedVersion: "1",
|
||||
createdVersion: null,
|
||||
backup: {
|
||||
serverVersion: null,
|
||||
activeVersion: null,
|
||||
trusted: null,
|
||||
matchesDecryptionKey: null,
|
||||
decryptionKeyCached: null,
|
||||
keyLoadAttempted: false,
|
||||
keyLoadError: null,
|
||||
},
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "backup", "reset", "--yes", "--json"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("lists matrix devices", async () => {
|
||||
listMatrixOwnDevicesMock.mockResolvedValue([
|
||||
{
|
||||
|
|
@ -732,6 +775,65 @@ describe("matrix CLI verification commands", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("includes backup reset guidance when the backup key does not match this device", async () => {
|
||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||
encryptionEnabled: true,
|
||||
verified: true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "21868",
|
||||
backup: {
|
||||
serverVersion: "21868",
|
||||
activeVersion: "21868",
|
||||
trusted: true,
|
||||
matchesDecryptionKey: false,
|
||||
decryptionKeyCached: true,
|
||||
keyLoadAttempted: false,
|
||||
keyLoadError: null,
|
||||
},
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyCreatedAt: "2026-03-09T14:40:00.000Z",
|
||||
pendingVerifications: 0,
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "status"], { from: "user" });
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"- If you want a fresh backup baseline and accept losing unrecoverable history, run 'openclaw matrix verify backup reset --yes'.",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires --yes before resetting the Matrix room-key backup", async () => {
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "backup", "reset"], { from: "user" });
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(resetMatrixRoomKeyBackupMock).not.toHaveBeenCalled();
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
"Backup reset failed: Refusing to reset Matrix room-key backup without --yes",
|
||||
);
|
||||
});
|
||||
|
||||
it("resets the Matrix room-key backup when confirmed", async () => {
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "backup", "reset", "--yes"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(resetMatrixRoomKeyBackupMock).toHaveBeenCalledWith({ accountId: "default" });
|
||||
expect(console.log).toHaveBeenCalledWith("Reset success: yes");
|
||||
expect(console.log).toHaveBeenCalledWith("Previous backup version: 1");
|
||||
expect(console.log).toHaveBeenCalledWith("Deleted backup version: 1");
|
||||
expect(console.log).toHaveBeenCalledWith("Current backup version: 2");
|
||||
expect(console.log).toHaveBeenCalledWith("Backup: active and trusted on this device");
|
||||
});
|
||||
|
||||
it("prints resolved account-aware guidance when a named Matrix account is selected implicitly", async () => {
|
||||
resolveMatrixAuthContextMock.mockImplementation(
|
||||
({ cfg, accountId }: { cfg: unknown; accountId?: string | null }) => ({
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
bootstrapMatrixVerification,
|
||||
getMatrixRoomKeyBackupStatus,
|
||||
getMatrixVerificationStatus,
|
||||
resetMatrixRoomKeyBackup,
|
||||
restoreMatrixRoomKeyBackup,
|
||||
verifyMatrixRecoveryKey,
|
||||
} from "./matrix/actions/verification.js";
|
||||
|
|
@ -572,10 +573,16 @@ function buildVerificationGuidance(
|
|||
nextSteps.add(
|
||||
`Backup key mismatch on this device. Re-run '${formatMatrixCliCommand("verify device <key>", accountId)}' with the matching recovery key.`,
|
||||
);
|
||||
nextSteps.add(
|
||||
`If you want a fresh backup baseline and accept losing unrecoverable history, run '${formatMatrixCliCommand("verify backup reset --yes", accountId)}'.`,
|
||||
);
|
||||
} else if (backupIssue.code === "untrusted-signature") {
|
||||
nextSteps.add(
|
||||
`Backup trust chain is not verified on this device. Re-run '${formatMatrixCliCommand("verify device <key>", accountId)}'.`,
|
||||
);
|
||||
nextSteps.add(
|
||||
`If you want a fresh backup baseline and accept losing unrecoverable history, run '${formatMatrixCliCommand("verify backup reset --yes", accountId)}'.`,
|
||||
);
|
||||
} else if (backupIssue.code === "indeterminate") {
|
||||
nextSteps.add(
|
||||
`Run '${formatMatrixCliCommand("verify status --verbose", accountId)}' to inspect backup trust diagnostics.`,
|
||||
|
|
@ -828,6 +835,47 @@ export function registerMatrixCli(params: { program: Command }): void {
|
|||
});
|
||||
});
|
||||
|
||||
backup
|
||||
.command("reset")
|
||||
.description("Delete the current server backup and create a fresh room-key backup baseline")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--yes", "Confirm destructive backup reset", false)
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(
|
||||
async (options: { account?: string; yes?: boolean; verbose?: boolean; json?: boolean }) => {
|
||||
const accountId = resolveMatrixCliAccountId(options.account);
|
||||
await runMatrixCliCommand({
|
||||
verbose: options.verbose === true,
|
||||
json: options.json === true,
|
||||
run: async () => {
|
||||
if (options.yes !== true) {
|
||||
throw new Error("Refusing to reset Matrix room-key backup without --yes");
|
||||
}
|
||||
return await resetMatrixRoomKeyBackup({ accountId });
|
||||
},
|
||||
onText: (result, verbose) => {
|
||||
printAccountLabel(accountId);
|
||||
console.log(`Reset success: ${result.success ? "yes" : "no"}`);
|
||||
if (result.error) {
|
||||
console.log(`Error: ${result.error}`);
|
||||
}
|
||||
console.log(`Previous backup version: ${result.previousVersion ?? "none"}`);
|
||||
console.log(`Deleted backup version: ${result.deletedVersion ?? "none"}`);
|
||||
console.log(`Current backup version: ${result.createdVersion ?? "none"}`);
|
||||
printBackupSummary(result.backup);
|
||||
if (verbose) {
|
||||
printTimestamp("Reset at", result.resetAt);
|
||||
printBackupStatus(result.backup);
|
||||
}
|
||||
},
|
||||
shouldFail: (result) => !result.success,
|
||||
errorPrefix: "Backup reset failed",
|
||||
onJsonError: (message) => ({ success: false, error: message }),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
backup
|
||||
.command("restore")
|
||||
.description("Restore encrypted room keys from server backup")
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export {
|
|||
listMatrixVerifications,
|
||||
mismatchMatrixVerificationSas,
|
||||
requestMatrixVerification,
|
||||
resetMatrixRoomKeyBackup,
|
||||
restoreMatrixRoomKeyBackup,
|
||||
scanMatrixVerificationQr,
|
||||
startMatrixVerification,
|
||||
|
|
|
|||
|
|
@ -215,6 +215,10 @@ export async function restoreMatrixRoomKeyBackup(
|
|||
);
|
||||
}
|
||||
|
||||
export async function resetMatrixRoomKeyBackup(opts: MatrixActionClientOpts = {}) {
|
||||
return await withStartedActionClient(opts, async (client) => await client.resetRoomKeyBackup());
|
||||
}
|
||||
|
||||
export async function bootstrapMatrixVerification(
|
||||
opts: MatrixActionClientOpts & {
|
||||
recoveryKey?: string;
|
||||
|
|
|
|||
|
|
@ -1342,6 +1342,90 @@ describe("MatrixClient crypto bootstrapping", () => {
|
|||
expect(result.backup.matchesDecryptionKey).toBe(false);
|
||||
});
|
||||
|
||||
it("resets the current room-key backup and creates a fresh trusted version", async () => {
|
||||
const checkKeyBackupAndEnable = vi.fn(async () => {});
|
||||
const bootstrapSecretStorage = vi.fn(async () => {});
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
bootstrapSecretStorage,
|
||||
checkKeyBackupAndEnable,
|
||||
getActiveSessionBackupVersion: vi.fn(async () => "21869"),
|
||||
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
|
||||
getKeyBackupInfo: vi.fn(async () => ({
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
auth_data: {},
|
||||
version: "21869",
|
||||
})),
|
||||
isKeyBackupTrusted: vi.fn(async () => ({
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
encryption: true,
|
||||
});
|
||||
vi.spyOn(client, "doRequest").mockImplementation(async (method, endpoint) => {
|
||||
if (method === "GET" && String(endpoint).includes("/room_keys/version")) {
|
||||
return { version: "21868" };
|
||||
}
|
||||
if (method === "DELETE" && String(endpoint).includes("/room_keys/version/21868")) {
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const result = await client.resetRoomKeyBackup();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.previousVersion).toBe("21868");
|
||||
expect(result.deletedVersion).toBe("21868");
|
||||
expect(result.createdVersion).toBe("21869");
|
||||
expect(bootstrapSecretStorage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ setupNewKeyBackup: true }),
|
||||
);
|
||||
expect(checkKeyBackupAndEnable).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails reset when the recreated backup still does not match the local decryption key", async () => {
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
bootstrapSecretStorage: vi.fn(async () => {}),
|
||||
checkKeyBackupAndEnable: vi.fn(async () => {}),
|
||||
getActiveSessionBackupVersion: vi.fn(async () => "21868"),
|
||||
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
|
||||
getKeyBackupInfo: vi.fn(async () => ({
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
auth_data: {},
|
||||
version: "21868",
|
||||
})),
|
||||
isKeyBackupTrusted: vi.fn(async () => ({
|
||||
trusted: true,
|
||||
matchesDecryptionKey: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
encryption: true,
|
||||
});
|
||||
vi.spyOn(client, "doRequest").mockImplementation(async (method, endpoint) => {
|
||||
if (method === "GET" && String(endpoint).includes("/room_keys/version")) {
|
||||
return { version: "21868" };
|
||||
}
|
||||
if (method === "DELETE" && String(endpoint).includes("/room_keys/version/21868")) {
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const result = await client.resetRoomKeyBackup();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("does not have the matching backup decryption key");
|
||||
expect(result.createdVersion).toBe("21868");
|
||||
expect(result.backup.matchesDecryptionKey).toBe(false);
|
||||
});
|
||||
|
||||
it("reports bootstrap failure when cross-signing keys are not published", async () => {
|
||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
||||
|
|
|
|||
|
|
@ -84,6 +84,16 @@ export type MatrixRoomKeyBackupRestoreResult = {
|
|||
backup: MatrixRoomKeyBackupStatus;
|
||||
};
|
||||
|
||||
export type MatrixRoomKeyBackupResetResult = {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
previousVersion: string | null;
|
||||
deletedVersion: string | null;
|
||||
createdVersion: string | null;
|
||||
resetAt?: string;
|
||||
backup: MatrixRoomKeyBackupStatus;
|
||||
};
|
||||
|
||||
export type MatrixRecoveryKeyVerificationResult = MatrixOwnDeviceVerificationStatus & {
|
||||
success: boolean;
|
||||
verifiedAt?: string;
|
||||
|
|
@ -126,6 +136,17 @@ function normalizeOptionalString(value: string | null | undefined): string | nul
|
|||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function isMatrixNotFoundError(err: unknown): boolean {
|
||||
const errObj = err as { statusCode?: number; body?: { errcode?: string } };
|
||||
if (errObj?.statusCode === 404 || errObj?.body?.errcode === "M_NOT_FOUND") {
|
||||
return true;
|
||||
}
|
||||
const message = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
||||
return (
|
||||
message.includes("m_not_found") || message.includes("[404]") || message.includes("not found")
|
||||
);
|
||||
}
|
||||
|
||||
export class MatrixClient {
|
||||
private readonly client: MatrixJsClient;
|
||||
private readonly emitter = new EventEmitter();
|
||||
|
|
@ -900,6 +921,92 @@ export class MatrixClient {
|
|||
}
|
||||
}
|
||||
|
||||
async resetRoomKeyBackup(): Promise<MatrixRoomKeyBackupResetResult> {
|
||||
let previousVersion: string | null = null;
|
||||
let deletedVersion: string | null = null;
|
||||
const fail = async (error: string): Promise<MatrixRoomKeyBackupResetResult> => {
|
||||
const backup = await this.getRoomKeyBackupStatus();
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
previousVersion,
|
||||
deletedVersion,
|
||||
createdVersion: backup.serverVersion,
|
||||
backup,
|
||||
};
|
||||
};
|
||||
|
||||
if (!this.encryptionEnabled) {
|
||||
return await fail("Matrix encryption is disabled for this client");
|
||||
}
|
||||
|
||||
await this.ensureStartedForCryptoControlPlane();
|
||||
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
|
||||
if (!crypto) {
|
||||
return await fail("Matrix crypto is not available (start client with encryption enabled)");
|
||||
}
|
||||
|
||||
previousVersion = await this.resolveRoomKeyBackupVersion();
|
||||
|
||||
try {
|
||||
if (previousVersion) {
|
||||
try {
|
||||
await this.doRequest(
|
||||
"DELETE",
|
||||
`/_matrix/client/v3/room_keys/version/${encodeURIComponent(previousVersion)}`,
|
||||
);
|
||||
} catch (err) {
|
||||
if (!isMatrixNotFoundError(err)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
deletedVersion = previousVersion;
|
||||
}
|
||||
|
||||
await this.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto, {
|
||||
setupNewKeyBackup: true,
|
||||
});
|
||||
await this.enableTrustedRoomKeyBackupIfPossible(crypto);
|
||||
|
||||
const backup = await this.getRoomKeyBackupStatus();
|
||||
const createdVersion = backup.serverVersion;
|
||||
if (!createdVersion) {
|
||||
return await fail("Matrix room key backup is still missing after reset.");
|
||||
}
|
||||
if (backup.activeVersion !== createdVersion) {
|
||||
return await fail(
|
||||
"Matrix room key backup was recreated on the server but is not active on this device.",
|
||||
);
|
||||
}
|
||||
if (backup.decryptionKeyCached === false) {
|
||||
return await fail(
|
||||
"Matrix room key backup was recreated but its decryption key is not cached on this device.",
|
||||
);
|
||||
}
|
||||
if (backup.matchesDecryptionKey === false) {
|
||||
return await fail(
|
||||
"Matrix room key backup was recreated but this device does not have the matching backup decryption key.",
|
||||
);
|
||||
}
|
||||
if (backup.trusted === false) {
|
||||
return await fail(
|
||||
"Matrix room key backup was recreated but is not trusted on this device.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
previousVersion,
|
||||
deletedVersion,
|
||||
createdVersion,
|
||||
resetAt: new Date().toISOString(),
|
||||
backup,
|
||||
};
|
||||
} catch (err) {
|
||||
return await fail(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async getOwnCrossSigningPublicationStatus(): Promise<MatrixOwnCrossSigningPublicationStatus> {
|
||||
const userId = this.client.getUserId() ?? this.selfUserId ?? null;
|
||||
if (!userId) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "../routing/session-key.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 resolveConfiguredMatrixAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
if (!channel) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
|
||||
if (!accounts) {
|
||||
return [DEFAULT_ACCOUNT_ID];
|
||||
}
|
||||
|
||||
const ids = Object.keys(accounts)
|
||||
.map((accountId) => normalizeAccountId(accountId))
|
||||
.filter((accountId) => accountId.length > 0 && isRecord(accounts[accountId]));
|
||||
|
||||
return Array.from(new Set(ids.length > 0 ? ids : [DEFAULT_ACCOUNT_ID])).toSorted((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMatrixDefaultOrOnlyAccountId(cfg: OpenClawConfig): string {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
if (!channel) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
|
||||
const configuredDefault = normalizeOptionalAccountId(
|
||||
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
|
||||
);
|
||||
if (configuredDefault && accounts && isRecord(accounts[configuredDefault])) {
|
||||
return configuredDefault;
|
||||
}
|
||||
if (accounts && isRecord(accounts[DEFAULT_ACCOUNT_ID])) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
const configuredAccountIds = resolveConfiguredMatrixAccountIds(cfg);
|
||||
if (configuredAccountIds.length === 1) {
|
||||
return configuredAccountIds[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
|
@ -119,4 +119,80 @@ describe("matrix legacy encrypted-state migration", () => {
|
|||
expect(state.restoreStatus).toBe("manual-action-required");
|
||||
});
|
||||
});
|
||||
|
||||
it("prepares flat legacy crypto for the only configured non-default Matrix account", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,11 +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, normalizeAccountId } from "../routing/session-key.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "../routing/session-key.js";
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixChannelConfig,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "./matrix-account-selection.js";
|
||||
import {
|
||||
resolveMatrixAccountStorageRoot,
|
||||
resolveMatrixCredentialsPath,
|
||||
|
|
@ -150,23 +151,12 @@ function loadStoredMatrixCredentials(
|
|||
}
|
||||
}
|
||||
|
||||
function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record<string, unknown> | null {
|
||||
return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null;
|
||||
function resolveMatrixAccountIds(cfg: OpenClawConfig): string[] {
|
||||
return resolveConfiguredMatrixAccountIds(cfg);
|
||||
}
|
||||
|
||||
function resolveMatrixAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
if (!channel) {
|
||||
return [];
|
||||
}
|
||||
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
|
||||
if (!accounts) {
|
||||
return [DEFAULT_ACCOUNT_ID];
|
||||
}
|
||||
const ids = Object.keys(accounts).map((accountId) => normalizeAccountId(accountId));
|
||||
return Array.from(new Set(ids.length > 0 ? ids : [DEFAULT_ACCOUNT_ID])).toSorted((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
function resolveMatrixFlatStoreTargetAccountId(cfg: OpenClawConfig): string {
|
||||
return resolveMatrixDefaultOrOnlyAccountId(cfg);
|
||||
}
|
||||
|
||||
function resolveMatrixAccountConfig(
|
||||
|
|
@ -205,14 +195,7 @@ function resolveLegacyMatrixFlatStorePlan(params: {
|
|||
};
|
||||
}
|
||||
|
||||
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
|
||||
const configuredDefault = normalizeOptionalAccountId(
|
||||
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
|
||||
);
|
||||
const accountId =
|
||||
configuredDefault && accounts && isRecord(accounts[configuredDefault])
|
||||
? configuredDefault
|
||||
: DEFAULT_ACCOUNT_ID;
|
||||
const accountId = resolveMatrixFlatStoreTargetAccountId(params.cfg);
|
||||
const stored = loadStoredMatrixCredentials(params.env, accountId);
|
||||
const account = resolveMatrixAccountConfig(params.cfg, accountId);
|
||||
const homeserver = typeof account.homeserver === "string" ? account.homeserver.trim() : "";
|
||||
|
|
|
|||
|
|
@ -119,4 +119,51 @@ describe("matrix legacy state migration", () => {
|
|||
expect(detection.selectionNote).toContain('account "work"');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ 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 {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "../routing/session-key.js";
|
||||
resolveMatrixChannelConfig,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "./matrix-account-selection.js";
|
||||
import {
|
||||
resolveMatrixAccountStorageRoot,
|
||||
resolveMatrixCredentialsPath as resolveSharedMatrixCredentialsPath,
|
||||
|
|
@ -86,10 +86,6 @@ function loadStoredMatrixCredentials(
|
|||
}
|
||||
}
|
||||
|
||||
function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record<string, unknown> | null {
|
||||
return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null;
|
||||
}
|
||||
|
||||
function resolveMatrixAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
|
|
@ -111,22 +107,7 @@ function resolveMatrixAccountConfig(
|
|||
}
|
||||
|
||||
function resolveMatrixTargetAccountId(cfg: OpenClawConfig): string {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
if (!channel) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
|
||||
const configuredDefault = normalizeOptionalAccountId(
|
||||
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
|
||||
);
|
||||
if (configuredDefault && accounts && isRecord(accounts[configuredDefault])) {
|
||||
return configuredDefault;
|
||||
}
|
||||
if (accounts && isRecord(accounts[DEFAULT_ACCOUNT_ID])) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
return resolveMatrixDefaultOrOnlyAccountId(cfg);
|
||||
}
|
||||
|
||||
function resolveMatrixFlatStoreSelectionNote(params: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue