mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Matrix: warn and clean stale managed devices
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
118
extensions/matrix/src/matrix/actions/devices.test.ts
Normal file
118
extensions/matrix/src/matrix/actions/devices.test.ts
Normal 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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
44
extensions/matrix/src/matrix/actions/devices.ts
Normal file
44
extensions/matrix/src/matrix/actions/devices.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
45
extensions/matrix/src/matrix/device-health.test.ts
Normal file
45
extensions/matrix/src/matrix/device-health.test.ts
Normal 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" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
31
extensions/matrix/src/matrix/device-health.ts
Normal file
31
extensions/matrix/src/matrix/device-health.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user