matrix-js: stabilize verification flow and rename verify device command

This commit is contained in:
Gustavo Madeira Santana
2026-02-25 15:10:26 -05:00
parent e2e64ba04d
commit 7a44a6370d
11 changed files with 362 additions and 63 deletions

View File

@@ -147,10 +147,10 @@ Bootstrap cross-signing and verification state:
openclaw matrix-js verify bootstrap
```
Verify with a recovery key:
Verify this device with a recovery key:
```bash
openclaw matrix-js verify recovery-key "<your-recovery-key>"
openclaw matrix-js verify device "<your-recovery-key>"
```
Use `openclaw matrix-js verify status --json` when scripting verification checks.

View File

@@ -0,0 +1,82 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const bootstrapMatrixVerificationMock = vi.fn();
const getMatrixVerificationStatusMock = vi.fn();
const verifyMatrixRecoveryKeyMock = vi.fn();
vi.mock("./matrix/actions/verification.js", () => ({
bootstrapMatrixVerification: (...args: unknown[]) => bootstrapMatrixVerificationMock(...args),
getMatrixVerificationStatus: (...args: unknown[]) => getMatrixVerificationStatusMock(...args),
verifyMatrixRecoveryKey: (...args: unknown[]) => verifyMatrixRecoveryKeyMock(...args),
}));
let registerMatrixJsCli: typeof import("./cli.js").registerMatrixJsCli;
function buildProgram(): Command {
const program = new Command();
registerMatrixJsCli({ program });
return program;
}
describe("matrix-js CLI verification commands", () => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
process.exitCode = undefined;
({ registerMatrixJsCli } = await import("./cli.js"));
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
process.exitCode = undefined;
});
it("sets non-zero exit code for device verification failures in JSON mode", async () => {
verifyMatrixRecoveryKeyMock.mockResolvedValue({
success: false,
error: "invalid key",
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "device", "bad-key", "--json"], {
from: "user",
});
expect(process.exitCode).toBe(1);
});
it("sets non-zero exit code for bootstrap failures in JSON mode", async () => {
bootstrapMatrixVerificationMock.mockResolvedValue({
success: false,
error: "bootstrap failed",
verification: {},
crossSigning: {},
pendingVerifications: 0,
cryptoBootstrap: null,
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "bootstrap", "--json"], { from: "user" });
expect(process.exitCode).toBe(1);
});
it("keeps zero exit code for successful bootstrap in JSON mode", async () => {
process.exitCode = 0;
bootstrapMatrixVerificationMock.mockResolvedValue({
success: true,
verification: {},
crossSigning: {},
pendingVerifications: 0,
cryptoBootstrap: {},
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "bootstrap", "--json"], { from: "user" });
expect(process.exitCode).toBe(0);
});
});

View File

@@ -5,6 +5,23 @@ import {
verifyMatrixRecoveryKey,
} from "./matrix/actions/verification.js";
let matrixJsCliExitScheduled = false;
function scheduleMatrixJsCliExit(): void {
if (matrixJsCliExitScheduled || process.env.VITEST) {
return;
}
matrixJsCliExitScheduled = 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 printVerificationStatus(status: {
verified: boolean;
userId: string | null;
@@ -25,7 +42,7 @@ function printVerificationStatus(status: {
console.log("Verified: no");
console.log(`User: ${status.userId ?? "unknown"}`);
console.log(`Device: ${status.deviceId ?? "unknown"}`);
console.log("Run 'openclaw matrix-js verify recovery-key <key>' to verify this device.");
console.log("Run 'openclaw matrix-js verify device <key>' to verify this device.");
}
console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`);
if (status.recoveryKeyCreatedAt) {
@@ -66,7 +83,9 @@ export function registerMatrixJsCli(params: { program: Command }): void {
} else {
console.error(`Error: ${message}`);
}
process.exitCode = 1;
markCliFailure();
} finally {
scheduleMatrixJsCliExit();
}
});
@@ -92,6 +111,9 @@ export function registerMatrixJsCli(params: { program: Command }): void {
});
if (options.json) {
console.log(JSON.stringify(result, null, 2));
if (!result.success) {
markCliFailure();
}
return;
}
console.log(`Bootstrap success: ${result.success ? "yes" : "no"}`);
@@ -106,7 +128,7 @@ export function registerMatrixJsCli(params: { program: Command }): void {
);
console.log(`Pending verifications: ${result.pendingVerifications}`);
if (!result.success) {
process.exitCode = 1;
markCliFailure();
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -115,13 +137,15 @@ export function registerMatrixJsCli(params: { program: Command }): void {
} else {
console.error(`Verification bootstrap failed: ${message}`);
}
process.exitCode = 1;
markCliFailure();
} finally {
scheduleMatrixJsCliExit();
}
},
);
verify
.command("recovery-key <key>")
.command("device <key>")
.description("Verify device using a Matrix recovery key")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--json", "Output as JSON")
@@ -130,6 +154,9 @@ export function registerMatrixJsCli(params: { program: Command }): void {
const result = await verifyMatrixRecoveryKey(key, { accountId: options.account });
if (options.json) {
console.log(JSON.stringify(result, null, 2));
if (!result.success) {
markCliFailure();
}
} else if (result.success) {
console.log("Device verification completed successfully.");
console.log(`User: ${result.userId ?? "unknown"}`);
@@ -139,7 +166,7 @@ export function registerMatrixJsCli(params: { program: Command }): void {
}
} else {
console.error(`Verification failed: ${result.error ?? "unknown error"}`);
process.exitCode = 1;
markCliFailure();
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -148,7 +175,9 @@ export function registerMatrixJsCli(params: { program: Command }): void {
} else {
console.error(`Verification failed: ${message}`);
}
process.exitCode = 1;
markCliFailure();
} finally {
scheduleMatrixJsCliExit();
}
});
}

View File

@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
const loadConfigMock = vi.fn(() => ({}));
const getActiveMatrixClientMock = vi.fn();
const createMatrixClientMock = vi.fn();
const isBunRuntimeMock = vi.fn(() => false);
const resolveMatrixAuthMock = vi.fn();
vi.mock("../../runtime.js", () => ({
getMatrixRuntime: () => ({
config: {
loadConfig: (...args: unknown[]) => loadConfigMock(...args),
},
}),
}));
vi.mock("../active-client.js", () => ({
getActiveMatrixClient: (...args: unknown[]) => getActiveMatrixClientMock(...args),
}));
vi.mock("../client.js", () => ({
createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args),
isBunRuntime: () => isBunRuntimeMock(),
resolveMatrixAuth: (...args: unknown[]) => resolveMatrixAuthMock(...args),
}));
let resolveActionClient: typeof import("./client.js").resolveActionClient;
function createMockMatrixClient(): MatrixClient {
return {
prepareForOneOff: vi.fn(async () => undefined),
} as unknown as MatrixClient;
}
describe("resolveActionClient", () => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
getActiveMatrixClientMock.mockReturnValue(null);
isBunRuntimeMock.mockReturnValue(false);
resolveMatrixAuthMock.mockResolvedValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
password: undefined,
deviceId: "DEVICE123",
encryption: false,
});
createMatrixClientMock.mockResolvedValue(createMockMatrixClient());
({ resolveActionClient } = await import("./client.js"));
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("creates a one-off client even when OPENCLAW_GATEWAY_PORT is set", async () => {
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799");
const result = await resolveActionClient({ accountId: "default" });
expect(getActiveMatrixClientMock).toHaveBeenCalledWith("default");
expect(resolveMatrixAuthMock).toHaveBeenCalledTimes(1);
expect(createMatrixClientMock).toHaveBeenCalledTimes(1);
expect(createMatrixClientMock).toHaveBeenCalledWith(
expect.objectContaining({
autoBootstrapCrypto: false,
}),
);
const oneOffClient = await createMatrixClientMock.mock.results[0]?.value;
expect(oneOffClient.prepareForOneOff).toHaveBeenCalledTimes(1);
expect(result.stopOnDone).toBe(true);
});
it("reuses active monitor client when available", async () => {
const activeClient = createMockMatrixClient();
getActiveMatrixClientMock.mockReturnValue(activeClient);
const result = await resolveActionClient({ accountId: "default" });
expect(result).toEqual({ client: activeClient, stopOnDone: false });
expect(resolveMatrixAuthMock).not.toHaveBeenCalled();
expect(createMatrixClientMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,11 +1,6 @@
import { getMatrixRuntime } from "../../runtime.js";
import { getActiveMatrixClient } from "../active-client.js";
import {
createMatrixClient,
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
} from "../client.js";
import { createMatrixClient, isBunRuntime, resolveMatrixAuth } from "../client.js";
import type { CoreConfig } from "../types.js";
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
@@ -26,15 +21,6 @@ export async function resolveActionClient(
if (active) {
return { client: active, stopOnDone: false };
}
const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT);
if (shouldShareClient) {
const client = await resolveSharedMatrixClient({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
timeoutMs: opts.timeoutMs,
accountId: opts.accountId,
});
return { client, stopOnDone: false };
}
const auth = await resolveMatrixAuth({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
accountId: opts.accountId,
@@ -48,15 +34,8 @@ export async function resolveActionClient(
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
accountId: opts.accountId,
autoBootstrapCrypto: false,
});
if (auth.encryption && client.crypto) {
try {
const joinedRooms = await client.getJoinedRooms();
await client.crypto.prepare(joinedRooms);
} catch {
// Ignore crypto prep failures for one-off actions.
}
}
await client.start();
await client.prepareForOneOff();
return { client, stopOnDone: true };
}

View File

@@ -17,6 +17,7 @@ export async function createMatrixClient(params: {
localTimeoutMs?: number;
initialSyncLimit?: number;
accountId?: string | null;
autoBootstrapCrypto?: boolean;
}): Promise<MatrixClient> {
ensureMatrixSdkLoggingConfigured();
const env = process.env;
@@ -52,5 +53,6 @@ export async function createMatrixClient(params: {
recoveryKeyPath: storagePaths.recoveryKeyPath,
idbSnapshotPath: storagePaths.idbSnapshotPath,
cryptoDatabasePrefix,
autoBootstrapCrypto: params.autoBootstrapCrypto,
});
}

View File

@@ -337,7 +337,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
logger.info("matrix: device is verified and ready for encrypted rooms");
} else {
logger.info(
"matrix: device not verified — run 'openclaw matrix-js verify recovery-key <key>' to enable E2EE",
"matrix: device not verified — run 'openclaw matrix-js verify device <key>' to enable E2EE",
);
}
} catch (err) {

View File

@@ -947,4 +947,46 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(result.crossSigning.published).toBe(true);
expect(result.cryptoBootstrap).not.toBeNull();
});
it("does not report bootstrap errors when final verification state is healthy", async () => {
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
requestOwnUserVerification: vi.fn(async () => null),
isCrossSigningReady: vi.fn(async () => true),
userHasCrossSigningKeys: vi.fn(async () => true),
getSecretStorageStatus: vi.fn(async () => ({
ready: true,
defaultKeyId: "SSSSKEY",
secretStorageKeyValidityMap: { SSSSKEY: true },
})),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
})),
}));
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
});
vi.spyOn(client, "getOwnCrossSigningPublicationStatus").mockResolvedValue({
userId: "@bot:example.org",
masterKeyPublished: true,
selfSigningKeyPublished: true,
userSigningKeyPublished: true,
published: true,
});
const result = await client.bootstrapOwnDeviceVerification({
recoveryKey: "not-a-valid-recovery-key",
});
expect(result.success).toBe(true);
expect(result.error).toBeUndefined();
});
});

View File

@@ -109,6 +109,7 @@ export class MatrixClient {
private readonly verificationManager = new MatrixVerificationManager();
private readonly recoveryKeyStore: MatrixRecoveryKeyStore;
private readonly cryptoBootstrapper: MatrixCryptoBootstrapper<MatrixRawEvent>;
private readonly autoBootstrapCrypto: boolean;
readonly dms = {
update: async (): Promise<void> => {
@@ -134,6 +135,7 @@ export class MatrixClient {
recoveryKeyPath?: string;
idbSnapshotPath?: string;
cryptoDatabasePrefix?: string;
autoBootstrapCrypto?: boolean;
} = {},
) {
this.httpClient = new MatrixAuthedHttpClient(homeserver, accessToken);
@@ -144,6 +146,7 @@ export class MatrixClient {
this.idbSnapshotPath = opts.idbSnapshotPath;
this.cryptoDatabasePrefix = opts.cryptoDatabasePrefix;
this.selfUserId = opts.userId?.trim() || null;
this.autoBootstrapCrypto = opts.autoBootstrapCrypto !== false;
this.recoveryKeyStore = new MatrixRecoveryKeyStore(opts.recoveryKeyPath);
const cryptoCallbacks = this.encryptionEnabled
? this.recoveryKeyStore.buildCryptoCallbacks()
@@ -229,12 +232,30 @@ export class MatrixClient {
await this.client.startClient({
initialSyncLimit: this.initialSyncLimit,
});
await this.bootstrapCryptoIfNeeded();
if (this.autoBootstrapCrypto) {
await this.bootstrapCryptoIfNeeded();
}
this.started = true;
this.emitOutstandingInviteEvents();
await this.refreshDmCache().catch(noop);
}
async prepareForOneOff(): Promise<void> {
if (!this.encryptionEnabled) {
return;
}
await this.initializeCryptoIfNeeded();
if (!this.crypto) {
return;
}
try {
const joinedRooms = await this.getJoinedRooms();
await this.crypto.prepare(joinedRooms);
} catch {
// One-off commands should continue even if crypto room prep is incomplete.
}
}
stop(): void {
if (this.idbPersistTimer) {
clearInterval(this.idbPersistTimer);
@@ -709,11 +730,10 @@ export class MatrixClient {
const verification = await this.getOwnDeviceVerificationStatus();
const crossSigning = await this.getOwnCrossSigningPublicationStatus();
const success = verification.verified && crossSigning.published;
const error =
bootstrapError ??
(success
? undefined
: "Matrix verification bootstrap did not produce a verified device with published cross-signing keys");
const error = success
? undefined
: (bootstrapError ??
"Matrix verification bootstrap did not produce a verified device with published cross-signing keys");
return {
success,
error,

View File

@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
const getActiveMatrixClientMock = vi.fn();
const createMatrixClientMock = vi.fn();
const isBunRuntimeMock = vi.fn(() => false);
const resolveMatrixAuthMock = vi.fn();
vi.mock("../active-client.js", () => ({
getActiveMatrixClient: (...args: unknown[]) => getActiveMatrixClientMock(...args),
}));
vi.mock("../client.js", () => ({
createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args),
isBunRuntime: () => isBunRuntimeMock(),
resolveMatrixAuth: (...args: unknown[]) => resolveMatrixAuthMock(...args),
}));
let resolveMatrixClient: typeof import("./client.js").resolveMatrixClient;
function createMockMatrixClient(): MatrixClient {
return {
prepareForOneOff: vi.fn(async () => undefined),
} as unknown as MatrixClient;
}
describe("resolveMatrixClient", () => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
getActiveMatrixClientMock.mockReturnValue(null);
isBunRuntimeMock.mockReturnValue(false);
resolveMatrixAuthMock.mockResolvedValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
password: undefined,
deviceId: "DEVICE123",
encryption: false,
});
createMatrixClientMock.mockResolvedValue(createMockMatrixClient());
({ resolveMatrixClient } = await import("./client.js"));
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("creates a one-off client even when OPENCLAW_GATEWAY_PORT is set", async () => {
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799");
const result = await resolveMatrixClient({ accountId: "default" });
expect(getActiveMatrixClientMock).toHaveBeenCalledWith("default");
expect(resolveMatrixAuthMock).toHaveBeenCalledTimes(1);
expect(createMatrixClientMock).toHaveBeenCalledTimes(1);
expect(createMatrixClientMock).toHaveBeenCalledWith(
expect.objectContaining({
autoBootstrapCrypto: false,
}),
);
const oneOffClient = await createMatrixClientMock.mock.results[0]?.value;
expect(oneOffClient.prepareForOneOff).toHaveBeenCalledTimes(1);
expect(result.stopOnDone).toBe(true);
});
it("reuses active monitor client when available", async () => {
const activeClient = createMockMatrixClient();
getActiveMatrixClientMock.mockReturnValue(activeClient);
const result = await resolveMatrixClient({ accountId: "default" });
expect(result).toEqual({ client: activeClient, stopOnDone: false });
expect(resolveMatrixAuthMock).not.toHaveBeenCalled();
expect(createMatrixClientMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,11 +1,6 @@
import { getMatrixRuntime } from "../../runtime.js";
import { getActiveMatrixClient } from "../active-client.js";
import {
createMatrixClient,
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
} from "../client.js";
import { createMatrixClient, isBunRuntime, resolveMatrixAuth } from "../client.js";
import type { MatrixClient } from "../sdk.js";
import type { CoreConfig } from "../types.js";
@@ -38,14 +33,6 @@ export async function resolveMatrixClient(opts: {
if (active) {
return { client: active, stopOnDone: false };
}
const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT);
if (shouldShareClient) {
const client = await resolveSharedMatrixClient({
timeoutMs: opts.timeoutMs,
accountId: opts.accountId,
});
return { client, stopOnDone: false };
}
const auth = await resolveMatrixAuth({ accountId: opts.accountId });
const client = await createMatrixClient({
homeserver: auth.homeserver,
@@ -56,15 +43,8 @@ export async function resolveMatrixClient(opts: {
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
accountId: opts.accountId,
autoBootstrapCrypto: false,
});
if (auth.encryption && client.crypto) {
try {
const joinedRooms = await client.getJoinedRooms();
await client.crypto.prepare(joinedRooms);
} catch {
// Ignore crypto prep failures for one-off sends; normal sync will retry.
}
}
await client.start();
await client.prepareForOneOff();
return { client, stopOnDone: true };
}