Matrix: fix verification client lifecycle and quiet CLI noise

This commit is contained in:
Gustavo Madeira Santana
2026-03-09 02:56:02 -04:00
parent a3573ac71f
commit df6b6762c0
12 changed files with 144 additions and 24 deletions

View File

@@ -12,6 +12,7 @@ const matrixSetupValidateInputMock = vi.fn();
const matrixRuntimeLoadConfigMock = vi.fn();
const matrixRuntimeWriteConfigFileMock = vi.fn();
const restoreMatrixRoomKeyBackupMock = vi.fn();
const setMatrixSdkConsoleLoggingMock = vi.fn();
const setMatrixSdkLogModeMock = vi.fn();
const updateMatrixOwnProfileMock = vi.fn();
const verifyMatrixRecoveryKeyMock = vi.fn();
@@ -25,6 +26,7 @@ vi.mock("./matrix/actions/verification.js", () => ({
}));
vi.mock("./matrix/client/logging.js", () => ({
setMatrixSdkConsoleLogging: (...args: unknown[]) => setMatrixSdkConsoleLoggingMock(...args),
setMatrixSdkLogMode: (...args: unknown[]) => setMatrixSdkLogModeMock(...args),
}));

View File

@@ -14,7 +14,7 @@ import {
restoreMatrixRoomKeyBackup,
verifyMatrixRecoveryKey,
} from "./matrix/actions/verification.js";
import { setMatrixSdkLogMode } from "./matrix/client/logging.js";
import { setMatrixSdkConsoleLogging, setMatrixSdkLogMode } from "./matrix/client/logging.js";
import { resolveMatrixConfigPath, updateMatrixAccountConfig } from "./matrix/config-update.js";
import { applyMatrixProfileUpdate, type MatrixProfileUpdateResult } from "./profile-update.js";
import { getMatrixRuntime } from "./runtime.js";
@@ -69,6 +69,7 @@ function printAccountLabel(accountId?: string): void {
function configureCliLogMode(verbose: boolean): void {
setMatrixSdkLogMode(verbose ? "default" : "quiet");
setMatrixSdkConsoleLogging(verbose);
}
function parseOptionalInt(value: string | undefined, fieldName: string): number | undefined {

View File

@@ -32,6 +32,7 @@ let resolveActionClient: typeof import("./client.js").resolveActionClient;
function createMockMatrixClient(): MatrixClient {
return {
prepareForOneOff: vi.fn(async () => undefined),
start: vi.fn(async () => undefined),
} as unknown as MatrixClient;
}
@@ -92,6 +93,30 @@ describe("resolveActionClient", () => {
expect(result.stopOnDone).toBe(true);
});
it("skips one-off room preparation when readiness is disabled", async () => {
const result = await resolveActionClient({
accountId: "default",
readiness: "none",
});
const oneOffClient = await createMatrixClientMock.mock.results[0]?.value;
expect(oneOffClient.prepareForOneOff).not.toHaveBeenCalled();
expect(oneOffClient.start).not.toHaveBeenCalled();
expect(result.stopOnDone).toBe(true);
});
it("starts one-off clients when started readiness is required", async () => {
const result = await resolveActionClient({
accountId: "default",
readiness: "started",
});
const oneOffClient = await createMatrixClientMock.mock.results[0]?.value;
expect(oneOffClient.start).toHaveBeenCalledTimes(1);
expect(oneOffClient.prepareForOneOff).not.toHaveBeenCalled();
expect(result.stopOnDone).toBe(true);
});
it("reuses active monitor client when available", async () => {
const activeClient = createMockMatrixClient();
getActiveMatrixClientMock.mockReturnValue(activeClient);
@@ -103,6 +128,20 @@ describe("resolveActionClient", () => {
expect(createMatrixClientMock).not.toHaveBeenCalled();
});
it("starts active clients when started readiness is required", async () => {
const activeClient = createMockMatrixClient();
getActiveMatrixClientMock.mockReturnValue(activeClient);
const result = await resolveActionClient({
accountId: "default",
readiness: "started",
});
expect(result).toEqual({ client: activeClient, stopOnDone: false });
expect(activeClient.start).toHaveBeenCalledTimes(1);
expect(activeClient.prepareForOneOff).not.toHaveBeenCalled();
});
it("uses the implicit resolved account id for active client lookup and storage", async () => {
loadConfigMock.mockReturnValue({
channels: {

View File

@@ -15,11 +15,28 @@ export function ensureNodeRuntime() {
}
}
async function ensureActionClientReadiness(
client: MatrixActionClient["client"],
readiness: MatrixActionClientOpts["readiness"],
opts: { createdForOneOff: boolean },
): Promise<void> {
if (readiness === "started") {
await client.start();
return;
}
if (readiness === "prepared" || (!readiness && opts.createdForOneOff)) {
await client.prepareForOneOff();
}
}
export async function resolveActionClient(
opts: MatrixActionClientOpts = {},
): Promise<MatrixActionClient> {
ensureNodeRuntime();
if (opts.client) {
await ensureActionClientReadiness(opts.client, opts.readiness, {
createdForOneOff: false,
});
return { client: opts.client, stopOnDone: false };
}
const cfg = getMatrixRuntime().config.loadConfig() as CoreConfig;
@@ -29,6 +46,9 @@ export async function resolveActionClient(
});
const active = getActiveMatrixClient(authContext.accountId);
if (active) {
await ensureActionClientReadiness(active, opts.readiness, {
createdForOneOff: false,
});
return { client: active, stopOnDone: false };
}
const auth = await resolveMatrixAuth({
@@ -46,7 +66,9 @@ export async function resolveActionClient(
accountId: auth.accountId,
autoBootstrapCrypto: false,
});
await client.prepareForOneOff();
await ensureActionClientReadiness(client, opts.readiness, {
createdForOneOff: true,
});
return { client, stopOnDone: true };
}

View File

@@ -48,6 +48,7 @@ export type MatrixActionClientOpts = {
client?: MatrixClient;
timeoutMs?: number;
accountId?: string | null;
readiness?: "none" | "prepared" | "started";
};
export type MatrixMessageSummary = {

View File

@@ -20,7 +20,7 @@ function resolveVerificationId(input: string): string {
export async function listMatrixVerifications(opts: MatrixActionClientOpts = {}) {
return await withResolvedActionClient(
opts,
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
return await crypto.listVerifications();
@@ -38,7 +38,7 @@ export async function requestMatrixVerification(
} = {},
) {
return await withResolvedActionClient(
params,
{ ...params, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
const ownUser = params.ownUser ?? (!params.userId && !params.deviceId && !params.roomId);
@@ -58,7 +58,7 @@ export async function acceptMatrixVerification(
opts: MatrixActionClientOpts = {},
) {
return await withResolvedActionClient(
opts,
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
return await crypto.acceptVerification(resolveVerificationId(requestId));
@@ -72,7 +72,7 @@ export async function cancelMatrixVerification(
opts: MatrixActionClientOpts & { reason?: string; code?: string } = {},
) {
return await withResolvedActionClient(
opts,
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
return await crypto.cancelVerification(resolveVerificationId(requestId), {
@@ -89,7 +89,7 @@ export async function startMatrixVerification(
opts: MatrixActionClientOpts & { method?: "sas" } = {},
) {
return await withResolvedActionClient(
opts,
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
return await crypto.startVerification(resolveVerificationId(requestId), opts.method ?? "sas");
@@ -103,7 +103,7 @@ export async function generateMatrixVerificationQr(
opts: MatrixActionClientOpts = {},
) {
return await withResolvedActionClient(
opts,
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
return await crypto.generateVerificationQr(resolveVerificationId(requestId));
@@ -118,7 +118,7 @@ export async function scanMatrixVerificationQr(
opts: MatrixActionClientOpts = {},
) {
return await withResolvedActionClient(
opts,
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
const payload = qrDataBase64.trim();
@@ -136,7 +136,7 @@ export async function getMatrixVerificationSas(
opts: MatrixActionClientOpts = {},
) {
return await withResolvedActionClient(
opts,
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
return await crypto.getVerificationSas(resolveVerificationId(requestId));
@@ -150,7 +150,7 @@ export async function confirmMatrixVerificationSas(
opts: MatrixActionClientOpts = {},
) {
return await withResolvedActionClient(
opts,
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
return await crypto.confirmVerificationSas(resolveVerificationId(requestId));
@@ -164,7 +164,7 @@ export async function mismatchMatrixVerificationSas(
opts: MatrixActionClientOpts = {},
) {
return await withResolvedActionClient(
opts,
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
return await crypto.mismatchVerificationSas(resolveVerificationId(requestId));
@@ -178,7 +178,7 @@ export async function confirmMatrixVerificationReciprocateQr(
opts: MatrixActionClientOpts = {},
) {
return await withResolvedActionClient(
opts,
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
return await crypto.confirmVerificationReciprocateQr(resolveVerificationId(requestId));
@@ -191,7 +191,7 @@ export async function getMatrixEncryptionStatus(
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {},
) {
return await withResolvedActionClient(
opts,
{ ...opts, readiness: "started" },
async (client) => {
const crypto = requireCrypto(client);
const recoveryKey = await crypto.getRecoveryKey();
@@ -211,7 +211,7 @@ export async function getMatrixVerificationStatus(
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {},
) {
return await withResolvedActionClient(
opts,
{ ...opts, readiness: "started" },
async (client) => {
const status = await client.getOwnDeviceVerificationStatus();
const payload = {
@@ -233,7 +233,7 @@ export async function getMatrixVerificationStatus(
export async function getMatrixRoomKeyBackupStatus(opts: MatrixActionClientOpts = {}) {
return await withResolvedActionClient(
opts,
{ ...opts, readiness: "started" },
async (client) => await client.getRoomKeyBackupStatus(),
"persist",
);
@@ -244,7 +244,7 @@ export async function verifyMatrixRecoveryKey(
opts: MatrixActionClientOpts = {},
) {
return await withResolvedActionClient(
opts,
{ ...opts, readiness: "started" },
async (client) => await client.verifyWithRecoveryKey(recoveryKey),
"persist",
);
@@ -256,7 +256,7 @@ export async function restoreMatrixRoomKeyBackup(
} = {},
) {
return await withResolvedActionClient(
opts,
{ ...opts, readiness: "started" },
async (client) =>
await client.restoreRoomKeyBackup({
recoveryKey: opts.recoveryKey?.trim() || undefined,
@@ -272,7 +272,7 @@ export async function bootstrapMatrixVerification(
} = {},
) {
return await withResolvedActionClient(
opts,
{ ...opts, readiness: "started" },
async (client) =>
await client.bootstrapOwnDeviceVerification({
recoveryKey: opts.recoveryKey?.trim() || undefined,

View File

@@ -1,8 +1,12 @@
import { ConsoleLogger, LogService } from "../sdk/logger.js";
import { logger as matrixJsSdkRootLogger } from "matrix-js-sdk/lib/logger.js";
import { ConsoleLogger, LogService, setMatrixConsoleLogging } from "../sdk/logger.js";
let matrixSdkLoggingConfigured = false;
let matrixSdkLogMode: "default" | "quiet" = "default";
const matrixSdkBaseLogger = new ConsoleLogger();
const matrixSdkSilentMethodFactory = () => () => {};
let matrixSdkRootMethodFactory: unknown;
let matrixSdkRootLoggerInitialized = false;
type MatrixJsSdkLogger = {
trace: (...messageOrObject: unknown[]) => void;
@@ -40,11 +44,30 @@ export function setMatrixSdkLogMode(mode: "default" | "quiet"): void {
applyMatrixSdkLogger();
}
export function setMatrixSdkConsoleLogging(enabled: boolean): void {
setMatrixConsoleLogging(enabled);
}
export function createMatrixJsSdkClientLogger(prefix = "matrix"): MatrixJsSdkLogger {
return createMatrixJsSdkLoggerInstance(prefix);
}
function applyMatrixJsSdkRootLoggerMode(): void {
const rootLogger = matrixJsSdkRootLogger as {
methodFactory?: unknown;
rebuild?: () => void;
};
if (!matrixSdkRootLoggerInitialized) {
matrixSdkRootMethodFactory = rootLogger.methodFactory;
matrixSdkRootLoggerInitialized = true;
}
rootLogger.methodFactory =
matrixSdkLogMode === "quiet" ? matrixSdkSilentMethodFactory : matrixSdkRootMethodFactory;
rootLogger.rebuild?.();
}
function applyMatrixSdkLogger(): void {
applyMatrixJsSdkRootLoggerMode();
if (matrixSdkLogMode === "quiet") {
LogService.setLogger({
trace: () => {},

View File

@@ -1068,13 +1068,13 @@ describe("MatrixClient crypto bootstrapping", () => {
encryption: true,
recoveryKeyPath: path.join(recoveryDir, "recovery-key.json"),
});
await client.start();
const result = await client.verifyWithRecoveryKey(encoded as string);
expect(result.success).toBe(true);
expect(result.verified).toBe(true);
expect(result.recoveryKeyStored).toBe(true);
expect(result.deviceId).toBe("DEVICE123");
expect(matrixJsClient.startClient).toHaveBeenCalledTimes(1);
expect(bootstrapSecretStorage).toHaveBeenCalled();
expect(bootstrapCrossSigning).toHaveBeenCalled();
});
@@ -1265,6 +1265,7 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(result.imported).toBe(4);
expect(result.total).toBe(10);
expect(result.loadedFromSecretStorage).toBe(true);
expect(matrixJsClient.startClient).toHaveBeenCalledTimes(1);
expect(loadSessionBackupPrivateKeyFromSecretStorage).toHaveBeenCalledTimes(1);
expect(restoreKeyBackup).toHaveBeenCalledTimes(1);
});
@@ -1330,6 +1331,7 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(result.error).toContain(
"Cross-signing bootstrap finished but server keys are still not published",
);
expect(matrixJsClient.startClient).toHaveBeenCalledTimes(1);
});
it("reports bootstrap success when own device is verified and keys are published", async () => {

View File

@@ -247,6 +247,10 @@ export class MatrixClient {
private idbPersistTimer: ReturnType<typeof setInterval> | null = null;
async start(): Promise<void> {
await this.startSyncSession({ bootstrapCrypto: true });
}
private async startSyncSession(opts: { bootstrapCrypto: boolean }): Promise<void> {
if (this.started) {
return;
}
@@ -257,7 +261,7 @@ export class MatrixClient {
await this.client.startClient({
initialSyncLimit: this.initialSyncLimit,
});
if (this.autoBootstrapCrypto) {
if (opts.bootstrapCrypto && this.autoBootstrapCrypto) {
await this.bootstrapCryptoIfNeeded();
}
this.started = true;
@@ -281,6 +285,13 @@ export class MatrixClient {
}
}
private async ensureStartedForCryptoControlPlane(): Promise<void> {
if (this.started) {
return;
}
await this.startSyncSession({ bootstrapCrypto: false });
}
stop(): void {
if (this.idbPersistTimer) {
clearInterval(this.idbPersistTimer);
@@ -740,6 +751,7 @@ export class MatrixClient {
return await fail("Matrix encryption is disabled for this client");
}
await this.ensureStartedForCryptoControlPlane();
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
if (!crypto) {
return await fail("Matrix crypto is not available (start client with encryption enabled)");
@@ -808,7 +820,7 @@ export class MatrixClient {
return await fail("Matrix encryption is disabled for this client");
}
await this.initializeCryptoIfNeeded();
await this.ensureStartedForCryptoControlPlane();
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
if (!crypto) {
return await fail("Matrix crypto is not available (start client with encryption enabled)");
@@ -925,7 +937,7 @@ export class MatrixClient {
let bootstrapError: string | undefined;
let bootstrapSummary: MatrixCryptoBootstrapResult | null = null;
try {
await this.initializeCryptoIfNeeded();
await this.ensureStartedForCryptoControlPlane();
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
if (!crypto) {
throw new Error("Matrix crypto is not available (start client with encryption enabled)");

View File

@@ -14,7 +14,16 @@ export function noop(): void {
// no-op
}
let forceConsoleLogging = false;
export function setMatrixConsoleLogging(enabled: boolean): void {
forceConsoleLogging = enabled;
}
function resolveRuntimeLogger(module: string): RuntimeLogger | null {
if (forceConsoleLogging) {
return null;
}
try {
return getMatrixRuntime().logging.getChildLogger({ module: `matrix:${module}` });
} catch {

View File

@@ -43,6 +43,12 @@ describe("warning filter", () => {
message: "SQLite is an experimental feature and might change at any time",
}),
).toBe(true);
expect(
shouldIgnoreWarning({
name: "Warning",
message: "`--localstorage-file` was provided without a valid path",
}),
).toBe(true);
});
it("keeps unknown warnings visible", () => {

View File

@@ -23,6 +23,9 @@ export function shouldIgnoreWarning(warning: ProcessWarning): boolean {
) {
return true;
}
if (warning.message?.includes("`--localstorage-file` was provided without a valid path")) {
return true;
}
return false;
}