openclaw/extensions/matrix/src/cli.ts

1036 lines
36 KiB
TypeScript

import type { Command } from "commander";
import {
formatZonedTimestamp,
normalizeAccountId,
type ChannelSetupInput,
} from "openclaw/plugin-sdk/matrix";
import { matrixPlugin } from "./channel.js";
import { resolveMatrixAccount, resolveMatrixAccountConfig } from "./matrix/accounts.js";
import { listMatrixOwnDevices, pruneMatrixStaleGatewayDevices } from "./matrix/actions/devices.js";
import { updateMatrixOwnProfile } from "./matrix/actions/profile.js";
import {
bootstrapMatrixVerification,
getMatrixRoomKeyBackupStatus,
getMatrixVerificationStatus,
restoreMatrixRoomKeyBackup,
verifyMatrixRecoveryKey,
} from "./matrix/actions/verification.js";
import { resolveMatrixAuthContext } from "./matrix/client.js";
import { setMatrixSdkConsoleLogging, setMatrixSdkLogMode } from "./matrix/client/logging.js";
import { resolveMatrixConfigPath, updateMatrixAccountConfig } from "./matrix/config-update.js";
import { isOpenClawManagedMatrixDevice } from "./matrix/device-health.js";
import { applyMatrixProfileUpdate, type MatrixProfileUpdateResult } from "./profile-update.js";
import { getMatrixRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
let matrixCliExitScheduled = false;
function scheduleMatrixCliExit(): void {
if (matrixCliExitScheduled || process.env.VITEST) {
return;
}
matrixCliExitScheduled = true;
// matrix-js-sdk rust crypto can leave background async work alive after command completion.
setTimeout(() => {
process.exit(process.exitCode ?? 0);
}, 0);
}
function markCliFailure(): void {
process.exitCode = 1;
}
function toErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function printJson(payload: unknown): void {
console.log(JSON.stringify(payload, null, 2));
}
function formatLocalTimestamp(value: string | null | undefined): string | null {
if (!value) {
return null;
}
const parsed = new Date(value);
if (!Number.isFinite(parsed.getTime())) {
return value;
}
return formatZonedTimestamp(parsed, { displaySeconds: true }) ?? value;
}
function printTimestamp(label: string, value: string | null | undefined): void {
const formatted = formatLocalTimestamp(value);
if (formatted) {
console.log(`${label}: ${formatted}`);
}
}
function printAccountLabel(accountId?: string): void {
console.log(`Account: ${normalizeAccountId(accountId)}`);
}
function resolveMatrixCliAccountId(accountId?: string): string {
const cfg = getMatrixRuntime().config.loadConfig() as CoreConfig;
return resolveMatrixAuthContext({ cfg, accountId }).accountId;
}
function formatMatrixCliCommand(command: string, accountId?: string): string {
const normalizedAccountId = normalizeAccountId(accountId);
const suffix = normalizedAccountId === "default" ? "" : ` --account ${normalizedAccountId}`;
return `openclaw matrix ${command}${suffix}`;
}
function printMatrixOwnDevices(
devices: Array<{
deviceId: string;
displayName: string | null;
lastSeenIp: string | null;
lastSeenTs: number | null;
current: boolean;
}>,
): void {
if (devices.length === 0) {
console.log("Devices: none");
return;
}
for (const device of devices) {
const labels = [device.current ? "current" : null, device.displayName].filter(Boolean);
console.log(`- ${device.deviceId}${labels.length ? ` (${labels.join(", ")})` : ""}`);
if (device.lastSeenTs) {
printTimestamp(" Last seen", new Date(device.lastSeenTs).toISOString());
}
if (device.lastSeenIp) {
console.log(` Last IP: ${device.lastSeenIp}`);
}
}
}
function configureCliLogMode(verbose: boolean): void {
setMatrixSdkLogMode(verbose ? "default" : "quiet");
setMatrixSdkConsoleLogging(verbose);
}
function parseOptionalInt(value: string | undefined, fieldName: string): number | undefined {
const trimmed = value?.trim();
if (!trimmed) {
return undefined;
}
const parsed = Number.parseInt(trimmed, 10);
if (!Number.isFinite(parsed)) {
throw new Error(`${fieldName} must be an integer`);
}
return parsed;
}
type MatrixCliAccountAddResult = {
accountId: string;
configPath: string;
useEnv: boolean;
deviceHealth: {
currentDeviceId: string | null;
staleOpenClawDeviceIds: string[];
};
verificationBootstrap: {
attempted: boolean;
success: boolean;
recoveryKeyCreatedAt: string | null;
backupVersion: string | null;
error?: string;
};
profile: {
attempted: boolean;
displayNameUpdated: boolean;
avatarUpdated: boolean;
resolvedAvatarUrl: string | null;
convertedAvatarFromHttp: boolean;
error?: string;
};
};
async function addMatrixAccount(params: {
account?: string;
name?: string;
avatarUrl?: string;
homeserver?: string;
userId?: string;
accessToken?: string;
password?: string;
deviceName?: string;
initialSyncLimit?: string;
useEnv?: boolean;
}): Promise<MatrixCliAccountAddResult> {
const runtime = getMatrixRuntime();
const cfg = runtime.config.loadConfig() as CoreConfig;
const setup = matrixPlugin.setup;
if (!setup?.applyAccountConfig) {
throw new Error("Matrix account setup is unavailable.");
}
const input: ChannelSetupInput & { avatarUrl?: string } = {
name: params.name,
avatarUrl: params.avatarUrl,
homeserver: params.homeserver,
userId: params.userId,
accessToken: params.accessToken,
password: params.password,
deviceName: params.deviceName,
initialSyncLimit: parseOptionalInt(params.initialSyncLimit, "--initial-sync-limit"),
useEnv: params.useEnv === true,
};
const accountId =
setup.resolveAccountId?.({
cfg,
accountId: params.account,
input,
}) ?? normalizeAccountId(params.account?.trim() || params.name?.trim());
const existingAccount = resolveMatrixAccount({ cfg, accountId });
const validationError = setup.validateInput?.({
cfg,
accountId,
input,
});
if (validationError) {
throw new Error(validationError);
}
const updated = setup.applyAccountConfig({
cfg,
accountId,
input,
}) as CoreConfig;
await runtime.config.writeConfigFile(updated as never);
const accountConfig = resolveMatrixAccountConfig({ cfg: updated, accountId });
let verificationBootstrap: MatrixCliAccountAddResult["verificationBootstrap"] = {
attempted: false,
success: false,
recoveryKeyCreatedAt: null,
backupVersion: null,
};
if (existingAccount.configured !== true && accountConfig.encryption === true) {
try {
const bootstrap = await bootstrapMatrixVerification({ accountId });
verificationBootstrap = {
attempted: true,
success: bootstrap.success === true,
recoveryKeyCreatedAt: bootstrap.verification.recoveryKeyCreatedAt,
backupVersion: bootstrap.verification.backupVersion,
...(bootstrap.success
? {}
: { error: bootstrap.error ?? "Matrix verification bootstrap failed" }),
};
} catch (err) {
verificationBootstrap = {
attempted: true,
success: false,
recoveryKeyCreatedAt: null,
backupVersion: null,
error: toErrorMessage(err),
};
}
}
const desiredDisplayName = input.name?.trim();
const desiredAvatarUrl = input.avatarUrl?.trim();
let profile: MatrixCliAccountAddResult["profile"] = {
attempted: false,
displayNameUpdated: false,
avatarUpdated: false,
resolvedAvatarUrl: null,
convertedAvatarFromHttp: false,
};
if (desiredDisplayName || desiredAvatarUrl) {
try {
const synced = await updateMatrixOwnProfile({
accountId,
displayName: desiredDisplayName,
avatarUrl: desiredAvatarUrl,
});
let resolvedAvatarUrl = synced.resolvedAvatarUrl;
if (synced.convertedAvatarFromHttp && synced.resolvedAvatarUrl) {
const latestCfg = runtime.config.loadConfig() as CoreConfig;
const withAvatar = updateMatrixAccountConfig(latestCfg, accountId, {
avatarUrl: synced.resolvedAvatarUrl,
});
await runtime.config.writeConfigFile(withAvatar as never);
resolvedAvatarUrl = synced.resolvedAvatarUrl;
}
profile = {
attempted: true,
displayNameUpdated: synced.displayNameUpdated,
avatarUpdated: synced.avatarUpdated,
resolvedAvatarUrl,
convertedAvatarFromHttp: synced.convertedAvatarFromHttp,
};
} catch (err) {
profile = {
attempted: true,
displayNameUpdated: false,
avatarUpdated: false,
resolvedAvatarUrl: null,
convertedAvatarFromHttp: false,
error: toErrorMessage(err),
};
}
}
const addedDevices = await listMatrixOwnDevices({ accountId });
const currentDeviceId = addedDevices.find((device) => device.current)?.deviceId ?? null;
const staleOpenClawDeviceIds = addedDevices
.filter((device) => !device.current && isOpenClawManagedMatrixDevice(device.displayName))
.map((device) => device.deviceId);
return {
accountId,
configPath: resolveMatrixConfigPath(updated, accountId),
useEnv: input.useEnv === true,
deviceHealth: {
currentDeviceId,
staleOpenClawDeviceIds,
},
verificationBootstrap,
profile,
};
}
type MatrixCliProfileSetResult = MatrixProfileUpdateResult;
async function setMatrixProfile(params: {
account?: string;
name?: string;
avatarUrl?: string;
}): Promise<MatrixCliProfileSetResult> {
return await applyMatrixProfileUpdate({
account: params.account,
displayName: params.name,
avatarUrl: params.avatarUrl,
});
}
type MatrixCliCommandConfig<TResult> = {
verbose: boolean;
json: boolean;
run: () => Promise<TResult>;
onText: (result: TResult, verbose: boolean) => void;
onJson?: (result: TResult) => unknown;
shouldFail?: (result: TResult) => boolean;
errorPrefix: string;
onJsonError?: (message: string) => unknown;
};
async function runMatrixCliCommand<TResult>(
config: MatrixCliCommandConfig<TResult>,
): Promise<void> {
configureCliLogMode(config.verbose);
try {
const result = await config.run();
if (config.json) {
printJson(config.onJson ? config.onJson(result) : result);
} else {
config.onText(result, config.verbose);
}
if (config.shouldFail?.(result)) {
markCliFailure();
}
} catch (err) {
const message = toErrorMessage(err);
if (config.json) {
printJson(config.onJsonError ? config.onJsonError(message) : { error: message });
} else {
console.error(`${config.errorPrefix}: ${message}`);
}
markCliFailure();
} finally {
scheduleMatrixCliExit();
}
}
type MatrixCliBackupStatus = {
serverVersion: string | null;
activeVersion: string | null;
trusted: boolean | null;
matchesDecryptionKey: boolean | null;
decryptionKeyCached: boolean | null;
keyLoadAttempted: boolean;
keyLoadError: string | null;
};
type MatrixCliVerificationStatus = {
encryptionEnabled: boolean;
verified: boolean;
userId: string | null;
deviceId: string | null;
localVerified: boolean;
crossSigningVerified: boolean;
signedByOwner: boolean;
backupVersion: string | null;
backup?: MatrixCliBackupStatus;
recoveryKeyStored: boolean;
recoveryKeyCreatedAt: string | null;
pendingVerifications: number;
};
function resolveBackupStatus(status: {
backupVersion: string | null;
backup?: MatrixCliBackupStatus;
}): MatrixCliBackupStatus {
return {
serverVersion: status.backup?.serverVersion ?? status.backupVersion ?? null,
activeVersion: status.backup?.activeVersion ?? null,
trusted: status.backup?.trusted ?? null,
matchesDecryptionKey: status.backup?.matchesDecryptionKey ?? null,
decryptionKeyCached: status.backup?.decryptionKeyCached ?? null,
keyLoadAttempted: status.backup?.keyLoadAttempted ?? false,
keyLoadError: status.backup?.keyLoadError ?? null,
};
}
type MatrixCliBackupIssueCode =
| "missing-server-backup"
| "key-load-failed"
| "key-not-loaded"
| "key-mismatch"
| "untrusted-signature"
| "inactive"
| "indeterminate"
| "ok";
type MatrixCliBackupIssue = {
code: MatrixCliBackupIssueCode;
summary: string;
message: string | null;
};
function yesNoUnknown(value: boolean | null): string {
if (value === true) {
return "yes";
}
if (value === false) {
return "no";
}
return "unknown";
}
function printBackupStatus(backup: MatrixCliBackupStatus): void {
console.log(`Backup server version: ${backup.serverVersion ?? "none"}`);
console.log(`Backup active on this device: ${backup.activeVersion ?? "no"}`);
console.log(`Backup trusted by this device: ${yesNoUnknown(backup.trusted)}`);
console.log(`Backup matches local decryption key: ${yesNoUnknown(backup.matchesDecryptionKey)}`);
console.log(`Backup key cached locally: ${yesNoUnknown(backup.decryptionKeyCached)}`);
console.log(`Backup key load attempted: ${yesNoUnknown(backup.keyLoadAttempted)}`);
if (backup.keyLoadError) {
console.log(`Backup key load error: ${backup.keyLoadError}`);
}
}
function printVerificationIdentity(status: {
userId: string | null;
deviceId: string | null;
}): void {
console.log(`User: ${status.userId ?? "unknown"}`);
console.log(`Device: ${status.deviceId ?? "unknown"}`);
}
function printVerificationBackupSummary(status: {
backupVersion: string | null;
backup?: MatrixCliBackupStatus;
}): void {
printBackupSummary(resolveBackupStatus(status));
}
function printVerificationBackupStatus(status: {
backupVersion: string | null;
backup?: MatrixCliBackupStatus;
}): void {
printBackupStatus(resolveBackupStatus(status));
}
function printVerificationTrustDiagnostics(status: {
localVerified: boolean;
crossSigningVerified: boolean;
signedByOwner: boolean;
}): void {
console.log(`Locally trusted: ${status.localVerified ? "yes" : "no"}`);
console.log(`Cross-signing verified: ${status.crossSigningVerified ? "yes" : "no"}`);
console.log(`Signed by owner: ${status.signedByOwner ? "yes" : "no"}`);
}
function printVerificationGuidance(status: MatrixCliVerificationStatus, accountId?: string): void {
printGuidance(buildVerificationGuidance(status, accountId));
}
function resolveBackupIssue(backup: MatrixCliBackupStatus): MatrixCliBackupIssue {
if (!backup.serverVersion) {
return {
code: "missing-server-backup",
summary: "missing on server",
message: "no room-key backup exists on the homeserver",
};
}
if (backup.decryptionKeyCached === false) {
if (backup.keyLoadError) {
return {
code: "key-load-failed",
summary: "present but backup key unavailable on this device",
message: `backup decryption key could not be loaded from secret storage (${backup.keyLoadError})`,
};
}
if (backup.keyLoadAttempted) {
return {
code: "key-not-loaded",
summary: "present but backup key unavailable on this device",
message:
"backup decryption key is not loaded on this device (secret storage did not return a key)",
};
}
return {
code: "key-not-loaded",
summary: "present but backup key unavailable on this device",
message: "backup decryption key is not loaded on this device",
};
}
if (backup.matchesDecryptionKey === false) {
return {
code: "key-mismatch",
summary: "present but backup key mismatch on this device",
message: "backup key mismatch (this device does not have the matching backup decryption key)",
};
}
if (backup.trusted === false) {
return {
code: "untrusted-signature",
summary: "present but not trusted on this device",
message: "backup signature chain is not trusted by this device",
};
}
if (!backup.activeVersion) {
return {
code: "inactive",
summary: "present on server but inactive on this device",
message: "backup exists but is not active on this device",
};
}
if (
backup.trusted === null ||
backup.matchesDecryptionKey === null ||
backup.decryptionKeyCached === null
) {
return {
code: "indeterminate",
summary: "present but trust state unknown",
message: "backup trust state could not be fully determined",
};
}
return {
code: "ok",
summary: "active and trusted on this device",
message: null,
};
}
function printBackupSummary(backup: MatrixCliBackupStatus): void {
const issue = resolveBackupIssue(backup);
console.log(`Backup: ${issue.summary}`);
if (backup.serverVersion) {
console.log(`Backup version: ${backup.serverVersion}`);
}
}
function buildVerificationGuidance(
status: MatrixCliVerificationStatus,
accountId?: string,
): string[] {
const backup = resolveBackupStatus(status);
const backupIssue = resolveBackupIssue(backup);
const nextSteps = new Set<string>();
if (!status.verified) {
nextSteps.add(
`Run '${formatMatrixCliCommand("verify device <key>", accountId)}' to verify this device.`,
);
}
if (backupIssue.code === "missing-server-backup") {
nextSteps.add(
`Run '${formatMatrixCliCommand("verify bootstrap", accountId)}' to create a room key backup.`,
);
} else if (
backupIssue.code === "key-load-failed" ||
backupIssue.code === "key-not-loaded" ||
backupIssue.code === "inactive"
) {
if (status.recoveryKeyStored) {
nextSteps.add(
`Backup key is not loaded on this device. Run '${formatMatrixCliCommand("verify backup restore", accountId)}' to load it and restore old room keys.`,
);
} else {
nextSteps.add(
`Store a recovery key with '${formatMatrixCliCommand("verify device <key>", accountId)}', then run '${formatMatrixCliCommand("verify backup restore", accountId)}'.`,
);
}
} else if (backupIssue.code === "key-mismatch") {
nextSteps.add(
`Backup key mismatch on this device. Re-run '${formatMatrixCliCommand("verify device <key>", accountId)}' with the matching recovery key.`,
);
} else if (backupIssue.code === "untrusted-signature") {
nextSteps.add(
`Backup trust chain is not verified on this device. Re-run '${formatMatrixCliCommand("verify device <key>", accountId)}'.`,
);
} else if (backupIssue.code === "indeterminate") {
nextSteps.add(
`Run '${formatMatrixCliCommand("verify status --verbose", accountId)}' to inspect backup trust diagnostics.`,
);
}
if (status.pendingVerifications > 0) {
nextSteps.add(`Complete ${status.pendingVerifications} pending verification request(s).`);
}
return Array.from(nextSteps);
}
function printGuidance(lines: string[]): void {
if (lines.length === 0) {
return;
}
console.log("Next steps:");
for (const line of lines) {
console.log(`- ${line}`);
}
}
function printVerificationStatus(
status: MatrixCliVerificationStatus,
verbose = false,
accountId?: string,
): void {
console.log(`Verified by owner: ${status.verified ? "yes" : "no"}`);
const backup = resolveBackupStatus(status);
const backupIssue = resolveBackupIssue(backup);
printVerificationBackupSummary(status);
if (backupIssue.message) {
console.log(`Backup issue: ${backupIssue.message}`);
}
if (verbose) {
console.log("Diagnostics:");
printVerificationIdentity(status);
printVerificationTrustDiagnostics(status);
printVerificationBackupStatus(status);
console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`);
printTimestamp("Recovery key created at", status.recoveryKeyCreatedAt);
console.log(`Pending verifications: ${status.pendingVerifications}`);
} else {
console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`);
}
printVerificationGuidance(status, accountId);
}
export function registerMatrixCli(params: { program: Command }): void {
const root = params.program
.command("matrix")
.description("Matrix channel utilities")
.addHelpText("after", () => "\nDocs: https://docs.openclaw.ai/channels/matrix\n");
const account = root.command("account").description("Manage matrix channel accounts");
account
.command("add")
.description("Add or update a matrix account (wrapper around channel setup)")
.option("--account <id>", "Account ID (default: normalized --name, else default)")
.option("--name <name>", "Optional display name for this account")
.option("--avatar-url <url>", "Optional Matrix avatar URL (mxc:// or http(s) URL)")
.option("--homeserver <url>", "Matrix homeserver URL")
.option("--user-id <id>", "Matrix user ID")
.option("--access-token <token>", "Matrix access token")
.option("--password <password>", "Matrix password")
.option("--device-name <name>", "Matrix device display name")
.option("--initial-sync-limit <n>", "Matrix initial sync limit")
.option(
"--use-env",
"Use MATRIX_* env vars (or MATRIX_<ACCOUNT_ID>_* for non-default accounts)",
)
.option("--verbose", "Show setup details")
.option("--json", "Output as JSON")
.action(
async (options: {
account?: string;
name?: string;
avatarUrl?: string;
homeserver?: string;
userId?: string;
accessToken?: string;
password?: string;
deviceName?: string;
initialSyncLimit?: string;
useEnv?: boolean;
verbose?: boolean;
json?: boolean;
}) => {
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () =>
await addMatrixAccount({
account: options.account,
name: options.name,
avatarUrl: options.avatarUrl,
homeserver: options.homeserver,
userId: options.userId,
accessToken: options.accessToken,
password: options.password,
deviceName: options.deviceName,
initialSyncLimit: options.initialSyncLimit,
useEnv: options.useEnv === true,
}),
onText: (result) => {
console.log(`Saved matrix account: ${result.accountId}`);
console.log(`Config path: ${result.configPath}`);
console.log(
`Credentials source: ${result.useEnv ? "MATRIX_* / MATRIX_<ACCOUNT_ID>_* env vars" : "inline config"}`,
);
if (result.verificationBootstrap.attempted) {
if (result.verificationBootstrap.success) {
console.log("Matrix verification bootstrap: complete");
printTimestamp(
"Recovery key created at",
result.verificationBootstrap.recoveryKeyCreatedAt,
);
if (result.verificationBootstrap.backupVersion) {
console.log(`Backup version: ${result.verificationBootstrap.backupVersion}`);
}
} else {
console.error(
`Matrix verification bootstrap warning: ${result.verificationBootstrap.error}`,
);
}
}
if (result.deviceHealth.staleOpenClawDeviceIds.length > 0) {
console.log(
`Matrix device hygiene warning: stale OpenClaw devices detected (${result.deviceHealth.staleOpenClawDeviceIds.join(", ")}). Run 'openclaw matrix devices prune-stale --account ${result.accountId}'.`,
);
}
if (result.profile.attempted) {
if (result.profile.error) {
console.error(`Profile sync warning: ${result.profile.error}`);
} else {
console.log(
`Profile sync: name ${result.profile.displayNameUpdated ? "updated" : "unchanged"}, avatar ${result.profile.avatarUpdated ? "updated" : "unchanged"}`,
);
if (result.profile.convertedAvatarFromHttp && result.profile.resolvedAvatarUrl) {
console.log(`Avatar converted and saved as: ${result.profile.resolvedAvatarUrl}`);
}
}
}
const bindHint = `openclaw agents bind --agent <id> --bind matrix:${result.accountId}`;
console.log(`Bind this account to an agent: ${bindHint}`);
},
errorPrefix: "Account setup failed",
});
},
);
const profile = root.command("profile").description("Manage Matrix bot profile");
profile
.command("set")
.description("Update Matrix profile display name and/or avatar")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--name <name>", "Profile display name")
.option("--avatar-url <url>", "Profile avatar URL (mxc:// or http(s) URL)")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(
async (options: {
account?: string;
name?: string;
avatarUrl?: string;
verbose?: boolean;
json?: boolean;
}) => {
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () =>
await setMatrixProfile({
account: options.account,
name: options.name,
avatarUrl: options.avatarUrl,
}),
onText: (result) => {
printAccountLabel(result.accountId);
console.log(`Config path: ${result.configPath}`);
console.log(
`Profile update: name ${result.profile.displayNameUpdated ? "updated" : "unchanged"}, avatar ${result.profile.avatarUpdated ? "updated" : "unchanged"}`,
);
if (result.profile.convertedAvatarFromHttp && result.avatarUrl) {
console.log(`Avatar converted and saved as: ${result.avatarUrl}`);
}
},
errorPrefix: "Profile update failed",
});
},
);
const verify = root.command("verify").description("Device verification for Matrix E2EE");
verify
.command("status")
.description("Check Matrix device verification status")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--verbose", "Show detailed diagnostics")
.option("--include-recovery-key", "Include stored recovery key in output")
.option("--json", "Output as JSON")
.action(
async (options: {
account?: string;
verbose?: boolean;
includeRecoveryKey?: boolean;
json?: boolean;
}) => {
const accountId = resolveMatrixCliAccountId(options.account);
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () =>
await getMatrixVerificationStatus({
accountId,
includeRecoveryKey: options.includeRecoveryKey === true,
}),
onText: (status, verbose) => {
printAccountLabel(accountId);
printVerificationStatus(status, verbose, accountId);
},
errorPrefix: "Error",
});
},
);
const backup = verify.command("backup").description("Matrix room-key backup health and restore");
backup
.command("status")
.description("Show Matrix room-key backup status for this device")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(async (options: { account?: string; verbose?: boolean; json?: boolean }) => {
const accountId = resolveMatrixCliAccountId(options.account);
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () => await getMatrixRoomKeyBackupStatus({ accountId }),
onText: (status, verbose) => {
printAccountLabel(accountId);
printBackupSummary(status);
if (verbose) {
printBackupStatus(status);
}
},
errorPrefix: "Backup status failed",
});
});
backup
.command("restore")
.description("Restore encrypted room keys from server backup")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--recovery-key <key>", "Optional recovery key to load before restoring")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(
async (options: {
account?: string;
recoveryKey?: string;
verbose?: boolean;
json?: boolean;
}) => {
const accountId = resolveMatrixCliAccountId(options.account);
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () =>
await restoreMatrixRoomKeyBackup({
accountId,
recoveryKey: options.recoveryKey,
}),
onText: (result, verbose) => {
printAccountLabel(accountId);
console.log(`Restore success: ${result.success ? "yes" : "no"}`);
if (result.error) {
console.log(`Error: ${result.error}`);
}
console.log(`Backup version: ${result.backupVersion ?? "none"}`);
console.log(`Imported keys: ${result.imported}/${result.total}`);
printBackupSummary(result.backup);
if (verbose) {
console.log(
`Loaded key from secret storage: ${result.loadedFromSecretStorage ? "yes" : "no"}`,
);
printTimestamp("Restored at", result.restoredAt);
printBackupStatus(result.backup);
}
},
shouldFail: (result) => !result.success,
errorPrefix: "Backup restore failed",
onJsonError: (message) => ({ success: false, error: message }),
});
},
);
verify
.command("bootstrap")
.description("Bootstrap Matrix cross-signing and device verification state")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--recovery-key <key>", "Recovery key to apply before bootstrap")
.option("--force-reset-cross-signing", "Force reset cross-signing identity before bootstrap")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(
async (options: {
account?: string;
recoveryKey?: string;
forceResetCrossSigning?: boolean;
verbose?: boolean;
json?: boolean;
}) => {
const accountId = resolveMatrixCliAccountId(options.account);
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () =>
await bootstrapMatrixVerification({
accountId,
recoveryKey: options.recoveryKey,
forceResetCrossSigning: options.forceResetCrossSigning === true,
}),
onText: (result, verbose) => {
printAccountLabel(accountId);
console.log(`Bootstrap success: ${result.success ? "yes" : "no"}`);
if (result.error) {
console.log(`Error: ${result.error}`);
}
console.log(`Verified by owner: ${result.verification.verified ? "yes" : "no"}`);
printVerificationIdentity(result.verification);
if (verbose) {
printVerificationTrustDiagnostics(result.verification);
console.log(
`Cross-signing published: ${result.crossSigning.published ? "yes" : "no"} (master=${result.crossSigning.masterKeyPublished ? "yes" : "no"}, self=${result.crossSigning.selfSigningKeyPublished ? "yes" : "no"}, user=${result.crossSigning.userSigningKeyPublished ? "yes" : "no"})`,
);
printVerificationBackupStatus(result.verification);
printTimestamp("Recovery key created at", result.verification.recoveryKeyCreatedAt);
console.log(`Pending verifications: ${result.pendingVerifications}`);
} else {
console.log(
`Cross-signing published: ${result.crossSigning.published ? "yes" : "no"}`,
);
printVerificationBackupSummary(result.verification);
}
printVerificationGuidance(
{
...result.verification,
pendingVerifications: result.pendingVerifications,
},
accountId,
);
},
shouldFail: (result) => !result.success,
errorPrefix: "Verification bootstrap failed",
onJsonError: (message) => ({ success: false, error: message }),
});
},
);
verify
.command("device <key>")
.description("Verify device using a Matrix recovery key")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(
async (key: string, options: { account?: string; verbose?: boolean; json?: boolean }) => {
const accountId = resolveMatrixCliAccountId(options.account);
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () => await verifyMatrixRecoveryKey(key, { accountId }),
onText: (result, verbose) => {
printAccountLabel(accountId);
if (!result.success) {
console.error(`Verification failed: ${result.error ?? "unknown error"}`);
return;
}
console.log("Device verification completed successfully.");
printVerificationIdentity(result);
printVerificationBackupSummary(result);
if (verbose) {
printVerificationTrustDiagnostics(result);
printVerificationBackupStatus(result);
printTimestamp("Recovery key created at", result.recoveryKeyCreatedAt);
printTimestamp("Verified at", result.verifiedAt);
}
printVerificationGuidance(
{
...result,
pendingVerifications: 0,
},
accountId,
);
},
shouldFail: (result) => !result.success,
errorPrefix: "Verification failed",
onJsonError: (message) => ({ success: false, error: message }),
});
},
);
const devices = root.command("devices").description("Inspect and clean up Matrix devices");
devices
.command("list")
.description("List server-side Matrix devices for this account")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(async (options: { account?: string; verbose?: boolean; json?: boolean }) => {
const accountId = resolveMatrixCliAccountId(options.account);
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () => await listMatrixOwnDevices({ accountId }),
onText: (result) => {
printAccountLabel(accountId);
printMatrixOwnDevices(result);
},
errorPrefix: "Device listing failed",
});
});
devices
.command("prune-stale")
.description("Delete stale OpenClaw-managed devices for this account")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(async (options: { account?: string; verbose?: boolean; json?: boolean }) => {
const accountId = resolveMatrixCliAccountId(options.account);
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () => await pruneMatrixStaleGatewayDevices({ accountId }),
onText: (result, verbose) => {
printAccountLabel(accountId);
console.log(
`Deleted stale OpenClaw devices: ${result.deletedDeviceIds.length ? result.deletedDeviceIds.join(", ") : "none"}`,
);
console.log(`Current device: ${result.currentDeviceId ?? "unknown"}`);
console.log(`Remaining devices: ${result.remainingDevices.length}`);
if (verbose) {
console.log("Devices before cleanup:");
printMatrixOwnDevices(result.before);
console.log("Devices after cleanup:");
printMatrixOwnDevices(result.remainingDevices);
}
},
errorPrefix: "Device cleanup failed",
});
});
}