Matrix: warn and clean stale managed devices

This commit is contained in:
Gustavo Madeira Santana
2026-03-09 03:34:18 -04:00
parent 93b9df395b
commit a85f9632f0
8 changed files with 530 additions and 0 deletions

View File

@@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const bootstrapMatrixVerificationMock = vi.fn();
const getMatrixRoomKeyBackupStatusMock = vi.fn();
const getMatrixVerificationStatusMock = vi.fn();
const listMatrixOwnDevicesMock = vi.fn();
const pruneMatrixStaleGatewayDevicesMock = vi.fn();
const resolveMatrixAccountConfigMock = vi.fn();
const resolveMatrixAccountMock = vi.fn();
const matrixSetupApplyAccountConfigMock = vi.fn();
@@ -25,6 +27,12 @@ vi.mock("./matrix/actions/verification.js", () => ({
verifyMatrixRecoveryKey: (...args: unknown[]) => verifyMatrixRecoveryKeyMock(...args),
}));
vi.mock("./matrix/actions/devices.js", () => ({
listMatrixOwnDevices: (...args: unknown[]) => listMatrixOwnDevicesMock(...args),
pruneMatrixStaleGatewayDevices: (...args: unknown[]) =>
pruneMatrixStaleGatewayDevicesMock(...args),
}));
vi.mock("./matrix/client/logging.js", () => ({
setMatrixSdkConsoleLogging: (...args: unknown[]) => setMatrixSdkConsoleLoggingMock(...args),
setMatrixSdkLogMode: (...args: unknown[]) => setMatrixSdkLogModeMock(...args),
@@ -104,6 +112,14 @@ describe("matrix CLI verification commands", () => {
resolvedAvatarUrl: null,
convertedAvatarFromHttp: false,
});
listMatrixOwnDevicesMock.mockResolvedValue([]);
pruneMatrixStaleGatewayDevicesMock.mockResolvedValue({
before: [],
staleGatewayDeviceIds: [],
currentDeviceId: null,
deletedDeviceIds: [],
remainingDevices: [],
});
});
afterEach(() => {
@@ -166,6 +182,77 @@ describe("matrix CLI verification commands", () => {
expect(process.exitCode).toBe(1);
});
it("lists matrix devices", async () => {
listMatrixOwnDevicesMock.mockResolvedValue([
{
deviceId: "A7hWrQ70ea",
displayName: "OpenClaw Gateway",
lastSeenIp: "127.0.0.1",
lastSeenTs: 1_741_507_200_000,
current: true,
},
{
deviceId: "BritdXC6iL",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
]);
const program = buildProgram();
await program.parseAsync(["matrix", "devices", "list", "--account", "poe"], { from: "user" });
expect(listMatrixOwnDevicesMock).toHaveBeenCalledWith({ accountId: "poe" });
expect(console.log).toHaveBeenCalledWith("Account: poe");
expect(console.log).toHaveBeenCalledWith("- A7hWrQ70ea (current, OpenClaw Gateway)");
expect(console.log).toHaveBeenCalledWith(" Last IP: 127.0.0.1");
expect(console.log).toHaveBeenCalledWith("- BritdXC6iL (OpenClaw Gateway)");
});
it("prunes stale matrix gateway devices", async () => {
pruneMatrixStaleGatewayDevicesMock.mockResolvedValue({
before: [
{
deviceId: "A7hWrQ70ea",
displayName: "OpenClaw Gateway",
lastSeenIp: "127.0.0.1",
lastSeenTs: 1_741_507_200_000,
current: true,
},
{
deviceId: "BritdXC6iL",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
],
staleGatewayDeviceIds: ["BritdXC6iL"],
currentDeviceId: "A7hWrQ70ea",
deletedDeviceIds: ["BritdXC6iL"],
remainingDevices: [
{
deviceId: "A7hWrQ70ea",
displayName: "OpenClaw Gateway",
lastSeenIp: "127.0.0.1",
lastSeenTs: 1_741_507_200_000,
current: true,
},
],
});
const program = buildProgram();
await program.parseAsync(["matrix", "devices", "prune-stale", "--account", "poe"], {
from: "user",
});
expect(pruneMatrixStaleGatewayDevicesMock).toHaveBeenCalledWith({ accountId: "poe" });
expect(console.log).toHaveBeenCalledWith("Deleted stale OpenClaw devices: BritdXC6iL");
expect(console.log).toHaveBeenCalledWith("Current device: A7hWrQ70ea");
expect(console.log).toHaveBeenCalledWith("Remaining devices: 1");
});
it("adds a matrix account and prints a binding hint", async () => {
matrixRuntimeLoadConfigMock.mockReturnValue({ channels: {} });
matrixSetupApplyAccountConfigMock.mockImplementation(
@@ -235,6 +322,22 @@ describe("matrix CLI verification commands", () => {
resolveMatrixAccountConfigMock.mockReturnValue({
encryption: true,
});
listMatrixOwnDevicesMock.mockResolvedValue([
{
deviceId: "BritdXC6iL",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
{
deviceId: "du314Zpw3A",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: true,
},
]);
bootstrapMatrixVerificationMock.mockResolvedValue({
success: true,
verification: {
@@ -270,6 +373,9 @@ describe("matrix CLI verification commands", () => {
`Recovery key created at: ${formatExpectedLocalTimestamp("2026-03-09T06:00:00.000Z")}`,
);
expect(console.log).toHaveBeenCalledWith("Backup version: 7");
expect(console.log).toHaveBeenCalledWith(
"Matrix device hygiene warning: stale OpenClaw devices detected (BritdXC6iL). Run 'openclaw matrix devices prune-stale --account ops'.",
);
});
it("does not bootstrap verification when updating an already configured account", async () => {

View File

@@ -6,6 +6,7 @@ import {
} 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,
@@ -16,6 +17,7 @@ import {
} from "./matrix/actions/verification.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";
@@ -67,6 +69,31 @@ function printAccountLabel(accountId?: string): void {
console.log(`Account: ${normalizeAccountId(accountId)}`);
}
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);
@@ -88,6 +115,10 @@ type MatrixCliAccountAddResult = {
accountId: string;
configPath: string;
useEnv: boolean;
deviceHealth: {
currentDeviceId: string | null;
staleOpenClawDeviceIds: string[];
};
verificationBootstrap: {
attempted: boolean;
success: boolean;
@@ -233,10 +264,20 @@ async function addMatrixAccount(params: {
}
}
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,
};
@@ -638,6 +679,11 @@ export function registerMatrixCli(params: { program: Command }): void {
);
}
}
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}`);
@@ -900,4 +946,54 @@ export function registerMatrixCli(params: { program: Command }): void {
});
},
);
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 }) => {
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () => await listMatrixOwnDevices({ accountId: options.account }),
onText: (result) => {
printAccountLabel(options.account);
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 }) => {
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () => await pruneMatrixStaleGatewayDevices({ accountId: options.account }),
onText: (result, verbose) => {
printAccountLabel(options.account);
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",
});
});
}

View File

@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const withResolvedActionClientMock = vi.fn();
vi.mock("./client.js", () => ({
withResolvedActionClient: (...args: unknown[]) => withResolvedActionClientMock(...args),
}));
let listMatrixOwnDevices: typeof import("./devices.js").listMatrixOwnDevices;
let pruneMatrixStaleGatewayDevices: typeof import("./devices.js").pruneMatrixStaleGatewayDevices;
describe("matrix device actions", () => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
({ listMatrixOwnDevices, pruneMatrixStaleGatewayDevices } = await import("./devices.js"));
});
it("lists own devices on a started client", async () => {
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
listOwnDevices: vi.fn(async () => [
{
deviceId: "A7hWrQ70ea",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: true,
},
]),
});
});
const result = await listMatrixOwnDevices({ accountId: "poe" });
expect(withResolvedActionClientMock).toHaveBeenCalledWith(
{ accountId: "poe", readiness: "started" },
expect.any(Function),
"persist",
);
expect(result).toEqual([
expect.objectContaining({
deviceId: "A7hWrQ70ea",
current: true,
}),
]);
});
it("prunes stale OpenClaw-managed devices but preserves the current device", async () => {
const deleteOwnDevices = vi.fn(async () => ({
currentDeviceId: "du314Zpw3A",
deletedDeviceIds: ["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"],
remainingDevices: [
{
deviceId: "du314Zpw3A",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: true,
},
],
}));
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
listOwnDevices: vi.fn(async () => [
{
deviceId: "du314Zpw3A",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: true,
},
{
deviceId: "BritdXC6iL",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
{
deviceId: "G6NJU9cTgs",
displayName: "OpenClaw Debug",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
{
deviceId: "My3T0hkTE0",
displayName: "OpenClaw Gateway",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
{
deviceId: "phone123",
displayName: "Element iPhone",
lastSeenIp: null,
lastSeenTs: null,
current: false,
},
]),
deleteOwnDevices,
});
});
const result = await pruneMatrixStaleGatewayDevices({ accountId: "poe" });
expect(deleteOwnDevices).toHaveBeenCalledWith(["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"]);
expect(result.staleGatewayDeviceIds).toEqual(["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"]);
expect(result.deletedDeviceIds).toEqual(["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"]);
expect(result.remainingDevices).toEqual([
expect.objectContaining({
deviceId: "du314Zpw3A",
current: true,
}),
]);
});
});

View File

@@ -0,0 +1,44 @@
import { summarizeMatrixDeviceHealth } from "../device-health.js";
import { withResolvedActionClient } from "./client.js";
import type { MatrixActionClientOpts } from "./types.js";
export async function listMatrixOwnDevices(opts: MatrixActionClientOpts = {}) {
return await withResolvedActionClient(
{ ...opts, readiness: "started" },
async (client) => await client.listOwnDevices(),
"persist",
);
}
export async function pruneMatrixStaleGatewayDevices(opts: MatrixActionClientOpts = {}) {
return await withResolvedActionClient(
{ ...opts, readiness: "started" },
async (client) => {
const devices = await client.listOwnDevices();
const health = summarizeMatrixDeviceHealth(devices);
const staleGatewayDeviceIds = health.staleOpenClawDevices.map((device) => device.deviceId);
const deleted =
staleGatewayDeviceIds.length > 0
? await client.deleteOwnDevices(staleGatewayDeviceIds)
: {
currentDeviceId: devices.find((device) => device.current)?.deviceId ?? null,
deletedDeviceIds: [] as string[],
remainingDevices: devices,
};
return {
before: devices,
staleGatewayDeviceIds,
...deleted,
};
},
"persist",
);
}
export async function getMatrixDeviceHealth(opts: MatrixActionClientOpts = {}) {
return await withResolvedActionClient(
{ ...opts, readiness: "started" },
async (client) => summarizeMatrixDeviceHealth(await client.listOwnDevices()),
"persist",
);
}

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { isOpenClawManagedMatrixDevice, summarizeMatrixDeviceHealth } from "./device-health.js";
describe("matrix device health", () => {
it("detects OpenClaw-managed device names", () => {
expect(isOpenClawManagedMatrixDevice("OpenClaw Gateway")).toBe(true);
expect(isOpenClawManagedMatrixDevice("OpenClaw Debug")).toBe(true);
expect(isOpenClawManagedMatrixDevice("Element iPhone")).toBe(false);
expect(isOpenClawManagedMatrixDevice(null)).toBe(false);
});
it("summarizes stale OpenClaw-managed devices separately from the current device", () => {
const summary = summarizeMatrixDeviceHealth([
{
deviceId: "du314Zpw3A",
displayName: "OpenClaw Gateway",
current: true,
},
{
deviceId: "BritdXC6iL",
displayName: "OpenClaw Gateway",
current: false,
},
{
deviceId: "G6NJU9cTgs",
displayName: "OpenClaw Debug",
current: false,
},
{
deviceId: "phone123",
displayName: "Element iPhone",
current: false,
},
]);
expect(summary.currentDeviceId).toBe("du314Zpw3A");
expect(summary.currentOpenClawDevices).toEqual([
expect.objectContaining({ deviceId: "du314Zpw3A" }),
]);
expect(summary.staleOpenClawDevices).toEqual([
expect.objectContaining({ deviceId: "BritdXC6iL" }),
expect.objectContaining({ deviceId: "G6NJU9cTgs" }),
]);
});
});

View File

@@ -0,0 +1,31 @@
export type MatrixManagedDeviceInfo = {
deviceId: string;
displayName: string | null;
current: boolean;
};
export type MatrixDeviceHealthSummary = {
currentDeviceId: string | null;
staleOpenClawDevices: MatrixManagedDeviceInfo[];
currentOpenClawDevices: MatrixManagedDeviceInfo[];
};
const OPENCLAW_DEVICE_NAME_PREFIX = "OpenClaw ";
export function isOpenClawManagedMatrixDevice(displayName: string | null | undefined): boolean {
return displayName?.startsWith(OPENCLAW_DEVICE_NAME_PREFIX) === true;
}
export function summarizeMatrixDeviceHealth(
devices: MatrixManagedDeviceInfo[],
): MatrixDeviceHealthSummary {
const currentDeviceId = devices.find((device) => device.current)?.deviceId ?? null;
const openClawDevices = devices.filter((device) =>
isOpenClawManagedMatrixDevice(device.displayName),
);
return {
currentDeviceId,
staleOpenClawDevices: openClawDevices.filter((device) => !device.current),
currentOpenClawDevices: openClawDevices.filter((device) => device.current),
};
}

View File

@@ -23,6 +23,7 @@ import {
stopSharedClientForAccount,
} from "../client.js";
import { updateMatrixAccountConfig } from "../config-update.js";
import { summarizeMatrixDeviceHealth } from "../device-health.js";
import { syncMatrixOwnProfile } from "../profile.js";
import { createMatrixThreadBindingManager } from "../thread-bindings.js";
import { normalizeMatrixUserId } from "./allowlist.js";
@@ -400,6 +401,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
// If E2EE is enabled, report device verification status and request self-verification
// when configured and the device is still unverified.
if (auth.encryption && client.crypto) {
try {
const deviceHealth = summarizeMatrixDeviceHealth(await client.listOwnDevices());
if (deviceHealth.staleOpenClawDevices.length > 0) {
logger.warn(
`matrix: stale OpenClaw devices detected for ${auth.userId}: ${deviceHealth.staleOpenClawDevices.map((device) => device.deviceId).join(", ")}. Run 'openclaw matrix devices prune-stale --account ${effectiveAccountId}' to keep encrypted-room trust healthy.`,
);
}
} catch (err) {
logger.debug?.("Failed to inspect matrix device hygiene (non-fatal)", {
error: String(err),
});
}
try {
const startupVerification = await ensureMatrixStartupVerification({
client,

View File

@@ -107,6 +107,20 @@ export type MatrixVerificationBootstrapResult = {
cryptoBootstrap: MatrixCryptoBootstrapResult | null;
};
export type MatrixOwnDeviceInfo = {
deviceId: string;
displayName: string | null;
lastSeenIp: string | null;
lastSeenTs: number | null;
current: boolean;
};
export type MatrixOwnDeviceDeleteResult = {
currentDeviceId: string | null;
deletedDeviceIds: string[];
remainingDevices: MatrixOwnDeviceInfo[];
};
function normalizeOptionalString(value: string | null | undefined): string | null {
const normalized = value?.trim();
return normalized ? normalized : null;
@@ -983,6 +997,68 @@ export class MatrixClient {
};
}
async listOwnDevices(): Promise<MatrixOwnDeviceInfo[]> {
const currentDeviceId = this.client.getDeviceId()?.trim() || null;
const devices = await this.client.getDevices();
const entries = Array.isArray(devices?.devices) ? devices.devices : [];
return entries.map((device) => ({
deviceId: device.device_id,
displayName: device.display_name?.trim() || null,
lastSeenIp: device.last_seen_ip?.trim() || null,
lastSeenTs:
typeof device.last_seen_ts === "number" && Number.isFinite(device.last_seen_ts)
? device.last_seen_ts
: null,
current: currentDeviceId !== null && device.device_id === currentDeviceId,
}));
}
async deleteOwnDevices(deviceIds: string[]): Promise<MatrixOwnDeviceDeleteResult> {
const uniqueDeviceIds = [...new Set(deviceIds.map((value) => value.trim()).filter(Boolean))];
const currentDeviceId = this.client.getDeviceId()?.trim() || null;
const protectedDeviceIds = uniqueDeviceIds.filter((deviceId) => deviceId === currentDeviceId);
if (protectedDeviceIds.length > 0) {
throw new Error(`Refusing to delete the current Matrix device: ${protectedDeviceIds[0]}`);
}
const deleteWithAuth = async (authData?: Record<string, unknown>): Promise<void> => {
await this.client.deleteMultipleDevices(uniqueDeviceIds, authData as never);
};
if (uniqueDeviceIds.length > 0) {
try {
await deleteWithAuth();
} catch (err) {
const session =
err &&
typeof err === "object" &&
"data" in err &&
err.data &&
typeof err.data === "object" &&
"session" in err.data &&
typeof err.data.session === "string"
? err.data.session
: null;
const userId = await this.getUserId().catch(() => this.selfUserId);
if (!session || !userId || !this.password?.trim()) {
throw err;
}
await deleteWithAuth({
type: "m.login.password",
session,
identifier: { type: "m.id.user", user: userId },
password: this.password,
});
}
}
return {
currentDeviceId,
deletedDeviceIds: uniqueDeviceIds,
remainingDevices: await this.listOwnDevices(),
};
}
private async resolveActiveRoomKeyBackupVersion(
crypto: MatrixCryptoBootstrapApi,
): Promise<string | null> {