fix(matrix): stabilize e2ee qa flows

This commit is contained in:
Gustavo Madeira Santana
2026-04-26 21:47:06 -04:00
parent 02d266c6c4
commit 99159f89da
39 changed files with 6348 additions and 924 deletions

View File

@@ -68,6 +68,8 @@ Key wizard behaviors:
- Room allowlist entries accept room IDs and aliases directly. Prefer `!room:server` or `#alias:server`; unresolved names are ignored at runtime by allowlist resolution.
- In invite auto-join allowlist mode, use only stable invite targets: `!roomId:server`, `#alias:server`, or `*`. Plain room names are rejected.
- To resolve room names before saving, use `openclaw channels resolve --channel matrix "Project Room"`.
- When setup enables E2EE, OpenClaw writes the encryption config and runs the
same verification bootstrap used by `openclaw matrix encryption setup`.
<Warning>
`channels.matrix.autoJoin` defaults to `off`.
@@ -292,7 +294,32 @@ Use strict room allowlists and mention requirements when enabling bot-to-bot tra
In encrypted (E2EE) rooms, outbound image events use `thumbnail_file` so image previews are encrypted alongside the full attachment. Unencrypted rooms still use plain `thumbnail_url`. No configuration is needed — the plugin detects E2EE state automatically.
Enable encryption:
Recommended setup flow:
```bash
openclaw matrix encryption setup
```
This enables `channels.matrix.encryption`, bootstraps Matrix secret storage and
cross-signing, creates room-key backup state when needed, then prints the
current verification and backup status with next steps.
For a new account, enable E2EE during account creation:
```bash
openclaw matrix account add \
--homeserver https://matrix.example.org \
--access-token syt_xxx \
--enable-e2ee
```
Multi-account setups can target a specific account:
```bash
openclaw matrix encryption setup --account assistant
```
Manual config equivalent:
```json5
{

View File

@@ -116,6 +116,7 @@ describe("matrix plugin", () => {
registerMatrixFullRuntime(api);
expect(runtimeMocks.ensureMatrixCryptoRuntime).not.toHaveBeenCalled();
expect(on.mock.calls.map(([hookName]) => hookName)).toEqual([
"subagent_spawning",
"subagent_ended",

View File

@@ -2,7 +2,6 @@ import {
defineBundledChannelEntry,
type OpenClawPluginApi,
} from "openclaw/plugin-sdk/channel-entry-contract";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { registerMatrixCliMetadata } from "./cli-metadata.js";
import { registerMatrixSubagentHooks } from "./subagent-hooks-api.js";
@@ -16,18 +15,6 @@ function loadMatrixHandlersRuntimeModule() {
}
export function registerMatrixFullRuntime(api: OpenClawPluginApi): void {
void loadMatrixHandlersRuntimeModule()
.then(({ ensureMatrixCryptoRuntime }) =>
ensureMatrixCryptoRuntime({ log: api.logger.info }).catch((err: unknown) => {
const message = formatErrorMessage(err);
api.logger.warn?.(`matrix: crypto runtime bootstrap failed: ${message}`);
}),
)
.catch((err: unknown) => {
const message = formatErrorMessage(err);
api.logger.warn?.(`matrix: failed loading crypto bootstrap runtime: ${message}`);
});
api.registerGatewayMethod("matrix.verify.recoveryKey", async (ctx) => {
const { handleVerifyRecoveryKey } = await loadMatrixHandlersRuntimeModule();
await handleVerifyRecoveryKey(ctx);

View File

@@ -138,6 +138,7 @@ describe("matrix setup post-write bootstrap", () => {
expect(verificationMocks.bootstrapMatrixVerification).toHaveBeenCalledWith({
accountId: "default",
cfg: nextCfg,
});
expect(log).toHaveBeenCalledWith('Matrix verification bootstrap: complete for "default".');
expect(log).toHaveBeenCalledWith('Matrix backup version for "default": 7');
@@ -177,6 +178,44 @@ describe("matrix setup post-write bootstrap", () => {
expect(error).not.toHaveBeenCalled();
});
it("bootstraps verification when setup enables encryption for an existing account", async () => {
const previousCfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@flurry:example.org",
accessToken: "token",
encryption: false,
},
},
} as CoreConfig;
const nextCfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@flurry:example.org",
accessToken: "token",
encryption: true,
},
},
} as CoreConfig;
mockBootstrapResult({ success: true, backupVersion: "8" });
await runAfterAccountConfigWritten({
previousCfg,
nextCfg,
accountId: "default",
input: {},
});
expect(verificationMocks.bootstrapMatrixVerification).toHaveBeenCalledWith({
accountId: "default",
cfg: nextCfg,
});
expect(log).toHaveBeenCalledWith('Matrix verification bootstrap: complete for "default".');
expect(log).toHaveBeenCalledWith('Matrix backup version for "default": 8');
});
it("logs a warning when verification bootstrap fails", async () => {
const { previousCfg, nextCfg, accountId, input } = applyDefaultAccountConfig();
mockBootstrapResult({
@@ -207,6 +246,7 @@ describe("matrix setup post-write bootstrap", () => {
expect(verificationMocks.bootstrapMatrixVerification).toHaveBeenCalledWith({
accountId: "default",
cfg: nextCfg,
});
expect(log).toHaveBeenCalledWith('Matrix verification bootstrap: complete for "default".');
},

View File

@@ -2,6 +2,7 @@ import { Command } from "commander";
import { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix-runtime-shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerMatrixCli, resetMatrixCliStateForTests } from "./cli.js";
import type { CoreConfig } from "./types.js";
const bootstrapMatrixVerificationMock = vi.fn();
const acceptMatrixVerificationMock = vi.fn();
@@ -133,6 +134,7 @@ function mockMatrixVerificationStatus(params: {
},
recoveryKeyStored: true,
recoveryKeyCreatedAt: params.recoveryKeyCreatedAt,
serverDeviceKnown: true,
pendingVerifications: 0,
verifiedAt: params.verifiedAt,
});
@@ -823,6 +825,20 @@ describe("matrix CLI verification commands", () => {
expect(getMatrixVerificationStatusMock).toHaveBeenCalledWith(
expect.objectContaining({ cfg: fakeCfg }),
);
expect(getMatrixVerificationStatusMock.mock.calls.at(-1)?.[0]).not.toHaveProperty("readiness");
});
it("allows verify status to use degraded local-state diagnostics", async () => {
mockMatrixVerificationStatus({ recoveryKeyCreatedAt: null });
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "status", "--allow-degraded-local-state"], {
from: "user",
});
expect(getMatrixVerificationStatusMock).toHaveBeenCalledWith(
expect.objectContaining({ readiness: "none" }),
);
});
it("passes loaded cfg to all verify subcommands", async () => {
@@ -1021,6 +1037,225 @@ describe("matrix CLI verification commands", () => {
);
});
it("enables E2EE and bootstraps verification from matrix account add", async () => {
matrixRuntimeLoadConfigMock.mockReturnValue({ channels: {} });
matrixSetupApplyAccountConfigMock.mockImplementation(
({ cfg, accountId }: { cfg: Record<string, unknown>; accountId: string }) => ({
...cfg,
channels: {
...(cfg.channels as Record<string, unknown> | undefined),
matrix: {
accounts: {
[accountId]: {
homeserver: "https://matrix.example.org",
},
},
},
},
}),
);
resolveMatrixAccountConfigMock.mockImplementation(
({ cfg, accountId }: { cfg: CoreConfig; accountId: string }) =>
cfg.channels?.matrix?.accounts?.[accountId] ?? {},
);
bootstrapMatrixVerificationMock.mockResolvedValue({
success: true,
verification: {
recoveryKeyCreatedAt: "2026-03-09T06:00:00.000Z",
backupVersion: "7",
},
crossSigning: {},
pendingVerifications: 0,
cryptoBootstrap: {},
});
const program = buildProgram();
await program.parseAsync(
[
"matrix",
"account",
"add",
"--account",
"ops",
"--homeserver",
"https://matrix.example.org",
"--access-token",
"token",
"--enable-e2ee",
],
{ from: "user" },
);
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
channels: {
matrix: {
enabled: true,
accounts: {
ops: expect.objectContaining({
encryption: true,
}),
},
},
},
}),
);
expect(bootstrapMatrixVerificationMock).toHaveBeenCalledWith({
accountId: "ops",
cfg: expect.objectContaining({
channels: {
matrix: expect.objectContaining({
accounts: expect.objectContaining({
ops: expect.objectContaining({
encryption: true,
}),
}),
}),
},
}),
});
expect(console.log).toHaveBeenCalledWith("Encryption: enabled");
expect(console.log).toHaveBeenCalledWith("Matrix verification bootstrap: complete");
});
it("enables E2EE and prints verification status from matrix encryption setup", async () => {
const cfg = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "token",
},
},
},
},
} as CoreConfig;
matrixRuntimeLoadConfigMock.mockReturnValue(cfg);
resolveMatrixAccountMock.mockReturnValue({
configured: true,
enabled: true,
config: cfg.channels?.matrix?.accounts?.ops,
});
resolveMatrixAccountConfigMock.mockReturnValue({
encryption: false,
});
bootstrapMatrixVerificationMock.mockResolvedValue({
success: true,
verification: {
recoveryKeyCreatedAt: "2026-03-09T06:00:00.000Z",
backupVersion: "7",
},
crossSigning: {},
pendingVerifications: 0,
cryptoBootstrap: {},
});
mockMatrixVerificationStatus({
recoveryKeyCreatedAt: "2026-03-09T06:00:00.000Z",
});
const program = buildProgram();
await program.parseAsync(["matrix", "encryption", "setup", "--account", "ops"], {
from: "user",
});
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
channels: {
matrix: {
enabled: true,
accounts: {
ops: expect.objectContaining({
encryption: true,
}),
},
},
},
}),
);
expect(bootstrapMatrixVerificationMock).toHaveBeenCalledWith({
accountId: "ops",
cfg: expect.objectContaining({
channels: expect.objectContaining({
matrix: expect.objectContaining({
accounts: expect.objectContaining({
ops: expect.objectContaining({ encryption: true }),
}),
}),
}),
}),
recoveryKey: undefined,
forceResetCrossSigning: false,
});
expect(getMatrixVerificationStatusMock).toHaveBeenCalledWith({
accountId: "ops",
cfg: expect.any(Object),
});
expect(console.log).toHaveBeenCalledWith("Account: ops");
expect(console.log).toHaveBeenCalledWith(
"Encryption config: enabled at channels.matrix.accounts.ops",
);
expect(console.log).toHaveBeenCalledWith("Bootstrap success: yes");
expect(console.log).toHaveBeenCalledWith("Verified by owner: yes");
expect(console.log).toHaveBeenCalledWith("Backup: active and trusted on this device");
});
it("skips encryption bootstrap when an encrypted account is already healthy", async () => {
const cfg = {
channels: {
matrix: {
accounts: {
ops: {
encryption: true,
homeserver: "https://matrix.example.org",
accessToken: "token",
},
},
},
},
} as CoreConfig;
matrixRuntimeLoadConfigMock.mockReturnValue(cfg);
resolveMatrixAccountMock.mockReturnValue({
configured: true,
enabled: true,
config: cfg.channels?.matrix?.accounts?.ops,
});
resolveMatrixAccountConfigMock.mockReturnValue({
encryption: true,
});
mockMatrixVerificationStatus({
recoveryKeyCreatedAt: "2026-03-09T06:00:00.000Z",
});
const program = buildProgram();
await program.parseAsync(["matrix", "encryption", "setup", "--account", "ops", "--json"], {
from: "user",
});
expect(bootstrapMatrixVerificationMock).not.toHaveBeenCalled();
expect(getMatrixVerificationStatusMock).toHaveBeenCalledTimes(1);
expect(getMatrixVerificationStatusMock).toHaveBeenCalledWith({
accountId: "ops",
cfg: expect.any(Object),
readiness: "none",
});
const jsonOutput = stdoutWriteMock.mock.calls.at(-1)?.[0];
expect(typeof jsonOutput).toBe("string");
expect(JSON.parse(String(jsonOutput))).toEqual(
expect.objectContaining({
accountId: "ops",
encryptionChanged: false,
bootstrap: expect.objectContaining({
success: true,
cryptoBootstrap: null,
}),
status: expect.objectContaining({
verified: true,
}),
}),
);
});
it("bootstraps verification for newly added encrypted accounts", async () => {
resolveMatrixAccountConfigMock.mockReturnValue({
encryption: true,
@@ -1072,6 +1307,7 @@ describe("matrix CLI verification commands", () => {
expect(bootstrapMatrixVerificationMock).toHaveBeenCalledWith({
accountId: "ops",
cfg: expect.any(Object),
});
expect(console.log).toHaveBeenCalledWith("Matrix verification bootstrap: complete");
expect(console.log).toHaveBeenCalledWith(
@@ -1218,6 +1454,7 @@ describe("matrix CLI verification commands", () => {
expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix.accounts.main-bot");
expect(updateMatrixOwnProfileMock).toHaveBeenCalledWith(
expect.objectContaining({
cfg: expect.any(Object),
accountId: "main-bot",
displayName: "Main Bot",
}),
@@ -1229,6 +1466,21 @@ describe("matrix CLI verification commands", () => {
it("forwards --avatar-url through account add setup and profile sync", async () => {
matrixRuntimeLoadConfigMock.mockReturnValue({ channels: {} });
matrixSetupApplyAccountConfigMock.mockImplementation(
({ cfg, accountId }: { cfg: Record<string, unknown>; accountId: string }) => ({
...cfg,
channels: {
...(cfg.channels as Record<string, unknown> | undefined),
matrix: {
accounts: {
[accountId]: {
homeserver: "https://matrix.example.org",
},
},
},
},
}),
);
const program = buildProgram();
await program.parseAsync(
@@ -1261,6 +1513,17 @@ describe("matrix CLI verification commands", () => {
);
expect(updateMatrixOwnProfileMock).toHaveBeenCalledWith(
expect.objectContaining({
cfg: expect.objectContaining({
channels: expect.objectContaining({
matrix: expect.objectContaining({
accounts: expect.objectContaining({
"ops-bot": expect.objectContaining({
homeserver: "https://matrix.example.org",
}),
}),
}),
}),
}),
accountId: "ops-bot",
displayName: "Ops Bot",
avatarUrl: "mxc://example/ops-avatar",

View File

@@ -245,6 +245,7 @@ type MatrixCliAccountAddResult = {
accountId: string;
configPath: string;
useEnv: boolean;
encryptionEnabled: boolean;
deviceHealth: {
currentDeviceId: string | null;
staleOpenClawDeviceIds: string[];
@@ -280,6 +281,7 @@ async function addMatrixAccount(params: {
initialSyncLimit?: string;
allowPrivateNetwork?: boolean;
useEnv?: boolean;
enableEncryption?: boolean;
}): Promise<MatrixCliAccountAddResult> {
const runtime = getMatrixRuntime();
const cfg = runtime.config.loadConfig() as CoreConfig;
@@ -315,11 +317,14 @@ async function addMatrixAccount(params: {
throw new Error(validationError);
}
const updated = matrixSetupAdapter.applyAccountConfig({
let updated = matrixSetupAdapter.applyAccountConfig({
cfg,
accountId,
input,
}) as CoreConfig;
if (params.enableEncryption === true) {
updated = updateMatrixAccountConfig(updated, accountId, { encryption: true });
}
await runtime.config.writeConfigFile(updated as never);
const accountConfig = resolveMatrixAccountConfig({ cfg: updated, accountId });
@@ -350,6 +355,7 @@ async function addMatrixAccount(params: {
if (desiredDisplayName || desiredAvatarUrl) {
try {
const synced = await updateMatrixOwnProfile({
cfg: updated,
accountId,
displayName: desiredDisplayName,
avatarUrl: desiredAvatarUrl,
@@ -406,6 +412,7 @@ async function addMatrixAccount(params: {
accountId,
configPath: resolveMatrixConfigPath(updated, accountId),
useEnv: input.useEnv === true,
encryptionEnabled: accountConfig.encryption === true,
deviceHealth,
verificationBootstrap,
profile,
@@ -591,6 +598,7 @@ type MatrixCliVerificationStatus = {
serverDeviceKnown?: boolean | null;
recoveryKeyStored: boolean;
recoveryKeyCreatedAt: string | null;
recoveryKeyId: string | null;
pendingVerifications: number;
recoveryKeyAccepted?: boolean;
backupUsable?: boolean;
@@ -659,6 +667,108 @@ type MatrixCliDirectRoomRepair = MatrixCliDirectRoomInspection & {
directContentAfter: Record<string, string[]>;
};
type MatrixCliVerificationBootstrap = Awaited<ReturnType<typeof bootstrapMatrixVerification>>;
type MatrixCliEncryptionSetupResult = {
accountId: string;
configPath: string;
encryptionChanged: boolean;
bootstrap: MatrixCliVerificationBootstrap;
status: MatrixCliVerificationStatus;
};
function isMatrixVerificationSetupComplete(status: MatrixCliVerificationStatus): boolean {
return (
status.encryptionEnabled &&
status.verified &&
status.crossSigningVerified &&
status.signedByOwner &&
status.serverDeviceKnown === true &&
resolveMatrixRoomKeyBackupIssue(resolveBackupStatus(status)).code === "ok"
);
}
function buildNoopMatrixVerificationBootstrap(
status: MatrixCliVerificationStatus,
): MatrixCliVerificationBootstrap {
const verification = {
...status,
backup: resolveBackupStatus(status),
serverDeviceKnown: status.serverDeviceKnown ?? null,
};
return {
success: true,
verification,
crossSigning: {
userId: status.userId,
masterKeyPublished: status.crossSigningVerified,
selfSigningKeyPublished: status.signedByOwner,
userSigningKeyPublished: status.signedByOwner,
published: status.crossSigningVerified && status.signedByOwner,
},
pendingVerifications: status.pendingVerifications,
cryptoBootstrap: null,
};
}
async function setupMatrixEncryption(params: {
account?: string;
recoveryKey?: string;
forceResetCrossSigning?: boolean;
}): Promise<MatrixCliEncryptionSetupResult> {
const runtime = getMatrixRuntime();
const { accountId, cfg } = resolveMatrixCliAccountContext(params.account);
const account = resolveMatrixAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(
`Matrix account "${accountId}" is not configured; run ${formatMatrixCliCommand(
"account add",
accountId,
)} first.`,
);
}
const currentAccountConfig = resolveMatrixAccountConfig({ cfg, accountId });
const encryptionChanged = currentAccountConfig.encryption !== true;
const updated = encryptionChanged
? updateMatrixAccountConfig(cfg, accountId, { encryption: true })
: cfg;
if (encryptionChanged) {
await runtime.config.writeConfigFile(updated as never);
}
const canUseExistingBootstrap =
!encryptionChanged && !params.recoveryKey && params.forceResetCrossSigning !== true;
const existingStatus = canUseExistingBootstrap
? await getMatrixVerificationStatus({ accountId, cfg: updated, readiness: "none" })
: null;
if (existingStatus && isMatrixVerificationSetupComplete(existingStatus)) {
return {
accountId,
configPath: resolveMatrixConfigPath(updated, accountId),
encryptionChanged,
bootstrap: buildNoopMatrixVerificationBootstrap(existingStatus),
status: existingStatus,
};
}
const bootstrap = await bootstrapMatrixVerification({
accountId,
cfg: updated,
recoveryKey: params.recoveryKey,
forceResetCrossSigning: params.forceResetCrossSigning === true,
});
const status = await getMatrixVerificationStatus({ accountId, cfg: updated });
return {
accountId,
configPath: resolveMatrixConfigPath(updated, accountId),
encryptionChanged,
bootstrap,
status,
};
}
function toCliDirectRoomCandidate(room: MatrixDirectRoomCandidate): MatrixCliDirectRoomCandidate {
return {
roomId: room.roomId,
@@ -1233,6 +1343,33 @@ function printVerificationStatus(
printVerificationGuidance(status, accountId);
}
function printMatrixEncryptionSetupResult(
result: MatrixCliEncryptionSetupResult,
verbose = false,
): void {
printAccountLabel(result.accountId);
console.log(
`Encryption config: ${result.encryptionChanged ? "enabled" : "already enabled"} at ${formatMatrixCliText(
result.configPath,
)}`,
);
console.log(`Bootstrap success: ${result.bootstrap.success ? "yes" : "no"}`);
if (result.bootstrap.error) {
console.log(`Bootstrap error: ${formatMatrixCliText(result.bootstrap.error)}`);
}
console.log(`Verified by owner: ${result.status.verified ? "yes" : "no"}`);
printVerificationBackupSummary(result.status);
if (verbose) {
printVerificationIdentity(result.status);
printVerificationTrustDiagnostics(result.status);
printVerificationBackupStatus(result.status);
console.log(`Recovery key stored: ${result.status.recoveryKeyStored ? "yes" : "no"}`);
printTimestamp("Recovery key created at", result.status.recoveryKeyCreatedAt);
console.log(`Pending verifications: ${result.status.pendingVerifications}`);
}
printVerificationGuidance(result.status, result.accountId);
}
export function registerMatrixCli(params: { program: Command }): void {
const root = params.program
.command("matrix")
@@ -1258,6 +1395,8 @@ export function registerMatrixCli(params: { program: Command }): void {
.option("--password <password>", "Matrix password")
.option("--device-name <name>", "Matrix device display name")
.option("--initial-sync-limit <n>", "Matrix initial sync limit")
.option("--enable-e2ee", "Enable Matrix end-to-end encryption and bootstrap verification")
.option("--encryption", "Alias for --enable-e2ee")
.option(
"--use-env",
"Use MATRIX_* env vars (or MATRIX_<ACCOUNT_ID>_* for non-default accounts)",
@@ -1277,6 +1416,8 @@ export function registerMatrixCli(params: { program: Command }): void {
password?: string;
deviceName?: string;
initialSyncLimit?: string;
enableE2ee?: boolean;
encryption?: boolean;
useEnv?: boolean;
verbose?: boolean;
json?: boolean;
@@ -1297,6 +1438,7 @@ export function registerMatrixCli(params: { program: Command }): void {
password: options.password,
deviceName: options.deviceName,
initialSyncLimit: options.initialSyncLimit,
enableEncryption: options.enableE2ee === true || options.encryption === true,
useEnv: options.useEnv === true,
}),
onText: (result) => {
@@ -1305,6 +1447,7 @@ export function registerMatrixCli(params: { program: Command }): void {
console.log(
`Credentials source: ${result.useEnv ? "MATRIX_* / MATRIX_<ACCOUNT_ID>_* env vars" : "inline config"}`,
);
console.log(`Encryption: ${result.encryptionEnabled ? "enabled" : "disabled"}`);
if (result.verificationBootstrap.attempted) {
if (result.verificationBootstrap.success) {
console.log("Matrix verification bootstrap: complete");
@@ -1466,6 +1609,44 @@ export function registerMatrixCli(params: { program: Command }): void {
},
);
const encryption = root.command("encryption").description("Set up Matrix end-to-end encryption");
encryption
.command("setup")
.description("Enable Matrix E2EE, bootstrap verification, and print next steps")
.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;
}) => {
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () =>
await setupMatrixEncryption({
account: options.account,
recoveryKey: options.recoveryKey,
forceResetCrossSigning: options.forceResetCrossSigning === true,
}),
onText: (result, verbose) => {
printMatrixEncryptionSetupResult(result, verbose);
},
onJson: (result) => ({ success: result.bootstrap.success, ...result }),
shouldFail: (result) => !result.bootstrap.success,
errorPrefix: "Encryption setup failed",
onJsonError: (message) => ({ success: false, error: message }),
});
},
);
const verify = root.command("verify").description("Device verification for Matrix E2EE");
verify
@@ -1721,9 +1902,14 @@ export function registerMatrixCli(params: { program: Command }): void {
.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(
"--allow-degraded-local-state",
"Return best-effort diagnostics without preparing the Matrix account",
)
.option("--json", "Output as JSON")
.action(
async (options: {
allowDegradedLocalState?: boolean;
account?: string;
verbose?: boolean;
includeRecoveryKey?: boolean;
@@ -1738,6 +1924,7 @@ export function registerMatrixCli(params: { program: Command }): void {
accountId,
cfg,
includeRecoveryKey: options.includeRecoveryKey === true,
...(options.allowDegradedLocalState === true ? { readiness: "none" as const } : {}),
}),
onText: (status, verbose) => {
printAccountLabel(accountId);

View File

@@ -195,6 +195,23 @@ describe("action client helpers", () => {
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
});
it("can discard read-only shared action clients without persisting crypto state", async () => {
const sharedClient = createMockMatrixClient();
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
const result = await withResolvedActionClient(
{ cfg: TEST_CFG, accountId: "default" },
async (client) => {
expect(client).toBe(sharedClient);
return "ok";
},
"discard",
);
expect(result).toBe("ok");
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "discard");
});
it("stops shared action clients when the wrapped call throws", async () => {
const sharedClient = createMockMatrixClient();
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);

View File

@@ -2,7 +2,7 @@ import { withResolvedRuntimeMatrixClient } from "../client-bootstrap.js";
import { resolveMatrixRoomId } from "../send.js";
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
type MatrixActionClientStopMode = "stop" | "persist";
type MatrixActionClientStopMode = "stop" | "persist" | "discard";
export async function withResolvedActionClient<T>(
opts: MatrixActionClientOpts,

View File

@@ -175,37 +175,43 @@ describe("matrix verification actions", () => {
expect(loadConfigMock).not.toHaveBeenCalled();
});
it("resolves verification status without starting the Matrix client", async () => {
it("prepares local crypto before resolving authoritative verification status", async () => {
const prepareForOneOff = vi.fn(async () => undefined);
const start = vi.fn(async () => undefined);
const getOwnDeviceVerificationStatus = vi.fn().mockResolvedValue({
encryptionEnabled: true,
verified: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
recoveryKeyStored: true,
recoveryKeyCreatedAt: null,
recoveryKeyId: "SSSS",
backupVersion: "11",
backup: {
serverVersion: "11",
activeVersion: "11",
trusted: true,
matchesDecryptionKey: true,
decryptionKeyCached: true,
keyLoadAttempted: false,
keyLoadError: null,
},
serverDeviceKnown: true,
});
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
prepareForOneOff,
crypto: {
listVerifications: vi.fn(async () => []),
getRecoveryKey: vi.fn(async () => ({
encodedPrivateKey: "rec-key",
})),
},
getOwnDeviceVerificationStatus: vi.fn(async () => ({
encryptionEnabled: true,
verified: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
recoveryKeyStored: true,
recoveryKeyCreatedAt: null,
recoveryKeyId: "SSSS",
backupVersion: "11",
backup: {
serverVersion: "11",
activeVersion: "11",
trusted: true,
matchesDecryptionKey: true,
decryptionKeyCached: true,
keyLoadAttempted: false,
keyLoadError: null,
},
})),
getOwnDeviceVerificationStatus,
start,
});
});
@@ -217,9 +223,68 @@ describe("matrix verification actions", () => {
recoveryKey: "rec-key",
});
expect(withResolvedActionClientMock).toHaveBeenCalledTimes(1);
expect(withResolvedActionClientMock).toHaveBeenCalledWith(
expect.objectContaining({ readiness: "none" }),
expect.any(Function),
"discard",
);
expect(prepareForOneOff).toHaveBeenCalledTimes(1);
expect(start).not.toHaveBeenCalled();
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(2);
expect(withStartedActionClientMock).not.toHaveBeenCalled();
});
it("fails closed before local Matrix prep when the current device is gone", async () => {
const prepareForOneOff = vi.fn(async () => undefined);
const getOwnDeviceVerificationStatus = vi.fn(async () => ({
encryptionEnabled: true,
verified: false,
userId: "@bot:example.org",
deviceId: "DEVICE123",
localVerified: false,
crossSigningVerified: false,
signedByOwner: false,
recoveryKeyStored: true,
recoveryKeyCreatedAt: null,
recoveryKeyId: "SSSS",
backupVersion: "11",
backup: {
serverVersion: "11",
activeVersion: "11",
trusted: true,
matchesDecryptionKey: true,
decryptionKeyCached: true,
keyLoadAttempted: false,
keyLoadError: null,
},
serverDeviceKnown: false,
}));
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
crypto: {
listVerifications: vi.fn(async () => []),
},
getOwnDeviceVerificationStatus,
prepareForOneOff,
});
});
const status = await getMatrixVerificationStatus();
expect(status).toMatchObject({
deviceId: "DEVICE123",
serverDeviceKnown: false,
pendingVerifications: 0,
});
expect(withResolvedActionClientMock).toHaveBeenCalledWith(
expect.objectContaining({ readiness: "none" }),
expect.any(Function),
"discard",
);
expect(prepareForOneOff).not.toHaveBeenCalled();
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(1);
});
it("resolves encryption and backup status without starting the Matrix client", async () => {
withResolvedActionClientMock
.mockImplementationOnce(async (_opts, run) => {
@@ -407,12 +472,9 @@ describe("matrix verification actions", () => {
expect(crypto.startVerification).toHaveBeenCalledWith("verification-1", "sas");
expect(confirmSas).toHaveBeenCalledWith(sas.sas, sas);
expect(crypto.confirmVerificationSas).toHaveBeenCalledWith("verification-1");
expect(bootstrapOwnDeviceVerification).toHaveBeenCalledWith({
allowAutomaticCrossSigningReset: false,
strict: false,
});
expect(getOwnCrossSigningPublicationStatus).not.toHaveBeenCalled();
expect(getOwnDeviceVerificationStatus).not.toHaveBeenCalled();
expect(bootstrapOwnDeviceVerification).not.toHaveBeenCalled();
expect(getOwnCrossSigningPublicationStatus).toHaveBeenCalledTimes(1);
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(1);
});
it("does not complete self-verification until the OpenClaw device has full Matrix identity trust", async () => {
@@ -442,10 +504,74 @@ describe("matrix verification actions", () => {
requestVerification: vi.fn(async () => requested),
startVerification: vi.fn(async () => sas),
};
const getOwnDeviceIdentityVerificationStatus = vi
const getOwnDeviceVerificationStatus = vi
.fn()
.mockResolvedValueOnce(mockUnverifiedOwnerStatus())
.mockResolvedValueOnce(mockVerifiedOwnerStatus());
const getOwnCrossSigningPublicationStatus = vi.fn(async () =>
mockCrossSigningPublicationStatus(),
);
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
crossSigning: mockCrossSigningPublicationStatus(),
success: true,
verification: mockUnverifiedOwnerStatus(),
}));
const trustOwnIdentityAfterSelfVerification = vi.fn(async () => {});
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
bootstrapOwnDeviceVerification,
crypto,
getOwnCrossSigningPublicationStatus,
getOwnDeviceVerificationStatus,
trustOwnIdentityAfterSelfVerification,
});
});
await expect(
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
).resolves.toMatchObject({
completed: true,
deviceOwnerVerified: true,
ownerVerification: {
verified: true,
},
});
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(2);
expect(getOwnCrossSigningPublicationStatus).toHaveBeenCalledTimes(2);
expect(trustOwnIdentityAfterSelfVerification).toHaveBeenCalledTimes(1);
});
it("does not let the SDK identity-only status read hang completed self-verification", async () => {
const requested = {
completed: false,
hasSas: false,
id: "verification-1",
phaseName: "requested",
transactionId: "tx-self",
};
const sas = {
...requested,
hasSas: true,
phaseName: "started",
sas: {
decimal: [1, 2, 3],
},
};
const completed = {
...sas,
completed: true,
phaseName: "done",
};
const crypto = {
confirmVerificationSas: vi.fn(async () => completed),
listVerifications: vi.fn(async () => [sas]),
requestVerification: vi.fn(async () => requested),
startVerification: vi.fn(async () => sas),
};
const getOwnDeviceIdentityVerificationStatus = vi.fn(
async () => await new Promise<never>(() => undefined),
);
const getOwnDeviceVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
const getOwnCrossSigningPublicationStatus = vi.fn(async () =>
mockCrossSigningPublicationStatus(),
@@ -472,15 +598,10 @@ describe("matrix verification actions", () => {
).resolves.toMatchObject({
completed: true,
deviceOwnerVerified: true,
ownerVerification: {
verified: true,
},
});
expect(getOwnDeviceIdentityVerificationStatus).toHaveBeenCalledTimes(2);
expect(getOwnDeviceIdentityVerificationStatus).not.toHaveBeenCalled();
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(1);
expect(getOwnCrossSigningPublicationStatus).toHaveBeenCalledTimes(2);
expect(trustOwnIdentityAfterSelfVerification).toHaveBeenCalledTimes(1);
});
it("does not complete self-verification until cross-signing keys are published", async () => {
@@ -510,7 +631,6 @@ describe("matrix verification actions", () => {
requestVerification: vi.fn(async () => requested),
startVerification: vi.fn(async () => sas),
};
const getOwnDeviceIdentityVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
const getOwnDeviceVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
const getOwnCrossSigningPublicationStatus = vi
.fn()
@@ -527,7 +647,6 @@ describe("matrix verification actions", () => {
bootstrapOwnDeviceVerification,
crypto,
getOwnCrossSigningPublicationStatus,
getOwnDeviceIdentityVerificationStatus,
getOwnDeviceVerificationStatus,
trustOwnIdentityAfterSelfVerification,
});
@@ -543,8 +662,7 @@ describe("matrix verification actions", () => {
},
});
expect(getOwnDeviceIdentityVerificationStatus).toHaveBeenCalledTimes(2);
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(1);
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(2);
expect(getOwnCrossSigningPublicationStatus).toHaveBeenCalledTimes(2);
expect(trustOwnIdentityAfterSelfVerification).not.toHaveBeenCalled();
});
@@ -722,6 +840,7 @@ describe("matrix verification actions", () => {
return await run({
bootstrapOwnDeviceVerification,
crypto,
getOwnCrossSigningPublicationStatus: vi.fn(async () => mockCrossSigningPublicationStatus()),
getOwnDeviceVerificationStatus: vi.fn(async () => mockVerifiedOwnerStatus()),
});
});
@@ -775,7 +894,6 @@ describe("matrix verification actions", () => {
getOwnCrossSigningPublicationStatus: vi.fn(async () =>
mockCrossSigningPublicationStatus(false),
),
getOwnDeviceIdentityVerificationStatus: vi.fn(async () => mockUnverifiedOwnerStatus()),
getOwnDeviceVerificationStatus: vi.fn(async () => mockUnverifiedOwnerStatus()),
});
});
@@ -787,10 +905,7 @@ describe("matrix verification actions", () => {
);
expect(crypto.cancelVerification).not.toHaveBeenCalled();
expect(bootstrapOwnDeviceVerification).toHaveBeenCalledWith({
allowAutomaticCrossSigningReset: false,
strict: false,
});
expect(bootstrapOwnDeviceVerification).not.toHaveBeenCalled();
});
it("cancels the pending self-verification request when acceptance times out", async () => {

View File

@@ -173,17 +173,17 @@ async function waitForMatrixSelfVerificationTrustStatus(params: {
timeoutMs: number;
}): Promise<MatrixOwnDeviceVerificationStatus> {
const startedAt = Date.now();
let last: MatrixDeviceVerificationStatus | undefined;
let last: MatrixOwnDeviceVerificationStatus | undefined;
let crossSigningPublished = false;
while (Date.now() - startedAt < params.timeoutMs) {
const [status, crossSigning] = await Promise.all([
params.client.getOwnDeviceIdentityVerificationStatus(),
params.client.getOwnDeviceVerificationStatus(),
params.client.getOwnCrossSigningPublicationStatus(),
]);
last = status;
crossSigningPublished = crossSigning.published;
if (last.verified && crossSigningPublished) {
return await params.client.getOwnDeviceVerificationStatus();
if (status.verified && crossSigningPublished) {
return status;
}
await sleep(Math.min(250, Math.max(25, params.timeoutMs - (Date.now() - startedAt))));
}
@@ -214,20 +214,20 @@ async function completeMatrixSelfVerification(params: {
completed: MatrixVerificationSummary;
timeoutMs: number;
}): Promise<MatrixSelfVerificationResult> {
const bootstrap = await params.client.bootstrapOwnDeviceVerification({
allowAutomaticCrossSigningReset: false,
strict: false,
});
if (!bootstrap.verification.verified) {
await params.client.trustOwnIdentityAfterSelfVerification?.();
const initial = await Promise.all([
params.client.getOwnDeviceVerificationStatus(),
params.client.getOwnCrossSigningPublicationStatus(),
]);
let ownerVerification = initial[0];
if (!ownerVerification.verified || !initial[1].published) {
if (!ownerVerification.verified) {
await params.client.trustOwnIdentityAfterSelfVerification?.();
}
ownerVerification = await waitForMatrixSelfVerificationTrustStatus({
client: params.client,
timeoutMs: params.timeoutMs,
});
}
const ownerVerification =
bootstrap.verification.verified && bootstrap.crossSigning.published
? bootstrap.verification
: await waitForMatrixSelfVerificationTrustStatus({
client: params.client,
timeoutMs: params.timeoutMs,
});
return {
...params.completed,
deviceOwnerVerified: ownerVerification.verified,
@@ -482,21 +482,42 @@ export async function getMatrixEncryptionStatus(
export async function getMatrixVerificationStatus(
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {},
) {
return await withResolvedActionClient(opts, async (client) => {
const status = await client.getOwnDeviceVerificationStatus();
const payload = {
...status,
pendingVerifications: client.crypto ? (await client.crypto.listVerifications()).length : 0,
};
if (!opts.includeRecoveryKey) {
return payload;
}
const recoveryKey = client.crypto ? await client.crypto.getRecoveryKey() : null;
return {
...payload,
recoveryKey: recoveryKey?.encodedPrivateKey ?? null,
};
});
const readiness = opts.readiness ?? "prepared";
return await withResolvedActionClient(
{ ...opts, readiness: "none" },
async (client) => {
const preflight = await readMatrixVerificationStatus(client, opts);
if (readiness === "none" || preflight.serverDeviceKnown === false) {
return preflight;
}
if (readiness === "started") {
await client.start();
} else {
await client.prepareForOneOff();
}
return await readMatrixVerificationStatus(client, opts);
},
"discard",
);
}
async function readMatrixVerificationStatus(
client: MatrixActionClient,
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean },
) {
const status = await client.getOwnDeviceVerificationStatus();
const payload = {
...status,
pendingVerifications: client.crypto ? (await client.crypto.listVerifications()).length : 0,
};
if (!opts.includeRecoveryKey) {
return payload;
}
const recoveryKey = client.crypto ? await client.crypto.getRecoveryKey() : null;
return {
...payload,
recoveryKey: recoveryKey?.encodedPrivateKey ?? null,
};
}
export async function getMatrixRoomKeyBackupStatus(opts: MatrixActionClientOpts = {}) {

View File

@@ -11,7 +11,7 @@ type ResolvedRuntimeMatrixClient = {
};
type MatrixRuntimeClientReadiness = "none" | "prepared" | "started";
type ResolvedRuntimeMatrixClientStopMode = "stop" | "persist";
type ResolvedRuntimeMatrixClientStopMode = "stop" | "persist" | "discard";
type MatrixResolvedClientHook = (
client: MatrixClient,
@@ -146,6 +146,10 @@ export async function stopResolvedRuntimeMatrixClient(
await resolved.client.stopAndPersist();
return;
}
if (mode === "discard") {
resolved.client.stopWithoutPersist();
return;
}
resolved.client.stop();
}

View File

@@ -44,6 +44,7 @@ export function createMockMatrixClient(): MatrixClient {
start: vi.fn(async () => undefined),
stop: vi.fn(() => undefined),
stopAndPersist: vi.fn(async () => undefined),
stopWithoutPersist: vi.fn(() => undefined),
} as unknown as MatrixClient;
}
@@ -114,7 +115,7 @@ export async function expectOneOffSharedMatrixClient(params?: {
timeoutMs?: number;
prepareForOneOffCalls?: number;
startCalls?: number;
releaseMode?: "persist" | "stop";
releaseMode?: "persist" | "stop" | "discard";
}) {
const {
getActiveMatrixClientMock,

View File

@@ -294,7 +294,7 @@ export function stopSharedClientInstance(client: MatrixClient): void {
export async function releaseSharedClientInstance(
client: MatrixClient,
mode: "stop" | "persist" = "stop",
mode: "stop" | "persist" | "discard" = "stop",
): Promise<boolean> {
const state = findSharedClientStateByInstance(client);
if (!state) {
@@ -307,6 +307,8 @@ export async function releaseSharedClientInstance(
deleteSharedClientState(state);
if (mode === "persist") {
await client.stopAndPersist();
} else if (mode === "discard") {
client.stopWithoutPersist();
} else {
client.stop();
}

View File

@@ -1,8 +1,53 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { ensureMatrixCryptoRuntime } from "./deps.js";
const logStub = vi.fn();
function resolveTestNativeBindingFilename(): string | null {
switch (process.platform) {
case "darwin":
return process.arch === "arm64"
? "matrix-sdk-crypto.darwin-arm64.node"
: process.arch === "x64"
? "matrix-sdk-crypto.darwin-x64.node"
: null;
case "linux": {
const report = process.report?.getReport?.() as
| { header?: { glibcVersionRuntime?: string } }
| undefined;
const isMusl = !report?.header?.glibcVersionRuntime;
if (process.arch === "x64") {
return isMusl
? "matrix-sdk-crypto.linux-x64-musl.node"
: "matrix-sdk-crypto.linux-x64-gnu.node";
}
if (process.arch === "arm64" && !isMusl) {
return "matrix-sdk-crypto.linux-arm64-gnu.node";
}
if (process.arch === "arm") {
return "matrix-sdk-crypto.linux-arm-gnueabihf.node";
}
if (process.arch === "s390x") {
return "matrix-sdk-crypto.linux-s390x-gnu.node";
}
return null;
}
case "win32":
return process.arch === "x64"
? "matrix-sdk-crypto.win32-x64-msvc.node"
: process.arch === "ia32"
? "matrix-sdk-crypto.win32-ia32-msvc.node"
: process.arch === "arm64"
? "matrix-sdk-crypto.win32-arm64-msvc.node"
: null;
default:
return null;
}
}
describe("ensureMatrixCryptoRuntime", () => {
it("returns immediately when matrix SDK loads", async () => {
const runCommand = vi.fn();
@@ -71,4 +116,47 @@ describe("ensureMatrixCryptoRuntime", () => {
expect(runCommand).not.toHaveBeenCalled();
expect(requireFn).toHaveBeenCalledTimes(1);
});
it("removes an incomplete native binding before loading the matrix SDK", async () => {
const nativeBindingFilename = resolveTestNativeBindingFilename();
if (!nativeBindingFilename) {
return;
}
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-crypto-runtime-"));
const scriptPath = path.join(tmpDir, "download-lib.js");
const nativeBindingPath = path.join(tmpDir, nativeBindingFilename);
fs.writeFileSync(scriptPath, "");
fs.writeFileSync(nativeBindingPath, Buffer.alloc(16));
let bootstrapped = false;
const requireFn = vi.fn(() => {
if (!bootstrapped) {
throw new Error(
"Cannot find module '@matrix-org/matrix-sdk-crypto-nodejs-linux-x64-gnu' (required by matrix sdk)",
);
}
return {};
});
const runCommand = vi.fn(async () => {
bootstrapped = true;
fs.writeFileSync(nativeBindingPath, Buffer.alloc(1_000_000));
return { code: 0, stdout: "", stderr: "" };
});
await ensureMatrixCryptoRuntime({
log: logStub,
requireFn,
runCommand,
resolveFn: () => scriptPath,
nodeExecutable: "/usr/bin/node",
});
expect(runCommand).toHaveBeenCalledTimes(1);
expect(requireFn).toHaveBeenCalledTimes(2);
expect(fs.statSync(nativeBindingPath).size).toBe(1_000_000);
expect(logStub).toHaveBeenCalledWith(
"matrix: removed incomplete native crypto runtime (16 bytes); it will be downloaded again",
);
});
});

View File

@@ -11,6 +11,7 @@ const REQUIRED_MATRIX_PACKAGES = [
"@matrix-org/matrix-sdk-crypto-nodejs",
"@matrix-org/matrix-sdk-crypto-wasm",
];
const MIN_MATRIX_CRYPTO_NATIVE_BINDING_BYTES = 1_000_000;
type MatrixCryptoRuntimeDeps = {
requireFn?: (id: string) => unknown;
@@ -85,6 +86,11 @@ async function runFixedCommandWithTimeout(params: {
let stderr = "";
let settled = false;
let timer: NodeJS.Timeout | null = null;
const killChildOnExit = () => {
if (!settled && proc.exitCode === null) {
proc.kill("SIGTERM");
}
};
const finalize = (result: CommandResult) => {
if (settled) {
@@ -94,8 +100,10 @@ async function runFixedCommandWithTimeout(params: {
if (timer) {
clearTimeout(timer);
}
process.off("exit", killChildOnExit);
resolve(result);
};
process.once("exit", killChildOnExit);
proc.stdout?.on("data", (chunk: Buffer | string) => {
stdout += chunk.toString();
@@ -148,6 +156,93 @@ function isMissingMatrixCryptoRuntimeError(error: unknown): boolean {
);
}
function isMuslRuntime(): boolean {
try {
const report = process.report?.getReport?.() as
| { header?: { glibcVersionRuntime?: string } }
| undefined;
return !report?.header?.glibcVersionRuntime;
} catch {
return true;
}
}
function resolveMatrixCryptoNativeBindingFilename(): string | null {
switch (process.platform) {
case "darwin":
return process.arch === "arm64"
? "matrix-sdk-crypto.darwin-arm64.node"
: process.arch === "x64"
? "matrix-sdk-crypto.darwin-x64.node"
: null;
case "linux":
if (process.arch === "x64") {
return isMuslRuntime()
? "matrix-sdk-crypto.linux-x64-musl.node"
: "matrix-sdk-crypto.linux-x64-gnu.node";
}
if (process.arch === "arm64" && !isMuslRuntime()) {
return "matrix-sdk-crypto.linux-arm64-gnu.node";
}
if (process.arch === "arm") {
return "matrix-sdk-crypto.linux-arm-gnueabihf.node";
}
if (process.arch === "s390x") {
return "matrix-sdk-crypto.linux-s390x-gnu.node";
}
return null;
case "win32":
return process.arch === "x64"
? "matrix-sdk-crypto.win32-x64-msvc.node"
: process.arch === "ia32"
? "matrix-sdk-crypto.win32-ia32-msvc.node"
: process.arch === "arm64"
? "matrix-sdk-crypto.win32-arm64-msvc.node"
: null;
default:
return null;
}
}
function resolveMatrixCryptoNativeBindingPath(resolveFn: (id: string) => string): string | null {
const filename = resolveMatrixCryptoNativeBindingFilename();
if (!filename) {
return null;
}
try {
return path.join(
path.dirname(resolveFn("@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js")),
filename,
);
} catch {
return null;
}
}
function removeIncompleteMatrixCryptoNativeBinding(params: {
bindingPath: string | null;
log?: (message: string) => void;
}): void {
const bindingPath = params.bindingPath;
if (!bindingPath) {
return;
}
try {
const stat = fs.statSync(bindingPath);
if (!stat.isFile() || stat.size >= MIN_MATRIX_CRYPTO_NATIVE_BINDING_BYTES) {
return;
}
fs.unlinkSync(bindingPath);
params.log?.(
`matrix: removed incomplete native crypto runtime (${stat.size} bytes); it will be downloaded again`,
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
}
export async function ensureMatrixCryptoRuntime(
params: MatrixCryptoRuntimeDeps = {},
): Promise<void> {
@@ -170,6 +265,9 @@ export async function ensureMatrixCryptoRuntime(
}
async function ensureMatrixCryptoRuntimeOnce(params: MatrixCryptoRuntimeDeps): Promise<void> {
const resolveFn = params.resolveFn ?? defaultResolveFn;
const nativeBindingPath = resolveMatrixCryptoNativeBindingPath(resolveFn);
removeIncompleteMatrixCryptoNativeBinding({ bindingPath: nativeBindingPath, log: params.log });
const requireFn = params.requireFn ?? defaultRequireFn;
try {
requireFn("@matrix-org/matrix-sdk-crypto-nodejs");
@@ -180,7 +278,6 @@ async function ensureMatrixCryptoRuntimeOnce(params: MatrixCryptoRuntimeDeps): P
}
}
const resolveFn = params.resolveFn ?? defaultResolveFn;
const scriptPath = resolveFn("@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js");
params.log?.("matrix: bootstrapping native crypto runtime");
const runCommand = params.runCommand ?? runFixedCommandWithTimeout;
@@ -192,11 +289,13 @@ async function ensureMatrixCryptoRuntimeOnce(params: MatrixCryptoRuntimeDeps): P
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
});
if (result.code !== 0) {
removeIncompleteMatrixCryptoNativeBinding({ bindingPath: nativeBindingPath, log: params.log });
throw new Error(
result.stderr.trim() || result.stdout.trim() || "Matrix crypto runtime bootstrap failed.",
);
}
removeIncompleteMatrixCryptoNativeBinding({ bindingPath: nativeBindingPath, log: params.log });
requireFn("@matrix-org/matrix-sdk-crypto-nodejs");
}

View File

@@ -196,6 +196,9 @@ function createHarness(params?: {
flushTasks,
runDetachedTask,
roomMessageListener: listeners.get("room.message") as RoomEventListener | undefined,
roomDecryptedEventListener: listeners.get("room.decrypted_event") as
| RoomEventListener
| undefined,
failedDecryptListener: listeners.get("room.failed_decryption") as
| FailedDecryptListener
| undefined,
@@ -402,6 +405,29 @@ describe("registerMatrixMonitorEvents verification routing", () => {
expect(body).toContain('Open "Verify by emoji"');
});
it("routes late-decrypted room messages through the normal room handler", async () => {
const { onRoomMessage, roomDecryptedEventListener, flushTasks } = createHarness();
if (!roomDecryptedEventListener) {
throw new Error("room.decrypted_event listener was not registered");
}
const event: MatrixRawEvent = {
event_id: "$decrypted1",
sender: "@alice:example.org",
type: EventType.RoomMessage,
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@bot late decrypt trigger",
},
};
roomDecryptedEventListener("!room:example.org", event);
await flushTasks();
expect(onRoomMessage).toHaveBeenCalledTimes(1);
expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event);
});
it("blocks verification request notices when dmPolicy pairing would block the sender", async () => {
const { onRoomMessage, sendMessage, roomMessageListener, logVerboseMessage, flushTasks } =
createHarness({

View File

@@ -256,6 +256,18 @@ export function registerMatrixMonitorEvents(params: {
const eventId = event?.event_id ?? "unknown";
const eventType = event?.type ?? "unknown";
logVerboseMessage(`matrix: decrypted event room=${roomId} type=${eventType} id=${eventId}`);
if (routeVerificationEvent(roomId, event)) {
return;
}
if (eventType !== EventType.RoomMessage) {
return;
}
void runMonitorTask(
`decrypted room message handler room=${roomId} id=${event.event_id ?? "unknown"}`,
async () => {
await onRoomMessage(roomId, event);
},
);
});
client.on(

View File

@@ -51,15 +51,16 @@ class FakeMatrixEvent extends EventEmitter {
private readonly roomId: string;
private readonly eventId: string;
private readonly sender: string;
private readonly type: string;
private type: string;
private readonly ts: number;
private readonly content: Record<string, unknown>;
private content: Record<string, unknown>;
private readonly stateKey?: string;
private readonly unsigned?: {
age?: number;
redacted_because?: unknown;
};
private readonly decryptionFailure: boolean;
readonly decryptionFailureReason?: string;
private decryptionFailure: boolean;
constructor(params: {
roomId: string;
@@ -74,6 +75,7 @@ class FakeMatrixEvent extends EventEmitter {
redacted_because?: unknown;
};
decryptionFailure?: boolean;
decryptionFailureReason?: string;
}) {
super();
this.roomId = params.roomId;
@@ -84,6 +86,7 @@ class FakeMatrixEvent extends EventEmitter {
this.content = params.content;
this.stateKey = params.stateKey;
this.unsigned = params.unsigned;
this.decryptionFailureReason = params.decryptionFailureReason;
this.decryptionFailure = params.decryptionFailure === true;
}
@@ -122,6 +125,12 @@ class FakeMatrixEvent extends EventEmitter {
isDecryptionFailure(): boolean {
return this.decryptionFailure;
}
markDecrypted(params: { type: string; content: Record<string, unknown> }): void {
this.type = params.type;
this.content = params.content;
this.decryptionFailure = false;
}
}
type MatrixJsClientStub = {
@@ -249,6 +258,7 @@ vi.mock("matrix-js-sdk/lib/matrix.js", async () => {
});
const { encodeRecoveryKey } = await import("matrix-js-sdk/lib/crypto-api/recovery-key.js");
const { DecryptionFailureCode } = await import("matrix-js-sdk/lib/crypto-api/index.js");
const { MatrixClient } = await import("./sdk.js");
describe("MatrixClient request hardening", () => {
@@ -862,6 +872,128 @@ describe("MatrixClient event bridge", () => {
expect(delivered).toEqual(["m.room.message"]);
});
it("does not keep retrying terminal historical decryption failures", async () => {
vi.useFakeTimers();
const client = new MatrixClient("https://matrix.example.org", "token");
const failed: string[] = [];
client.on("room.failed_decryption", (_roomId, _event, error) => {
failed.push(error.message);
});
const encrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$historical",
sender: "@alice:example.org",
type: "m.room.encrypted",
ts: Date.now() - 60_000,
content: {},
decryptionFailure: true,
decryptionFailureReason: DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP,
});
matrixJsClient.decryptEventIfNeeded = vi.fn(async () => {});
await client.start();
matrixJsClient.emit("event", encrypted);
encrypted.emit("decrypted", encrypted, new Error("historical key missing"));
await vi.advanceTimersByTimeAsync(60_000);
expect(failed).toEqual(["historical key missing"]);
expect(matrixJsClient.decryptEventIfNeeded).not.toHaveBeenCalled();
});
it("emits a recovered message when decrypt retry succeeds without a second SDK decrypted event", async () => {
vi.useFakeTimers();
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
});
const delivered: string[] = [];
client.on("room.message", (_roomId, event) => {
delivered.push(event.type);
});
const encrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.encrypted",
ts: Date.now(),
content: {},
decryptionFailure: true,
});
matrixJsClient.decryptEventIfNeeded = vi.fn(async () => {
encrypted.markDecrypted({
type: "m.room.message",
content: {
msgtype: "m.text",
body: "hello",
},
});
});
await client.start();
matrixJsClient.emit("event", encrypted);
encrypted.emit("decrypted", encrypted, new Error("missing room key"));
expect(delivered).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1_500);
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
expect(delivered).toEqual(["m.room.message"]);
});
it("retries encrypted events that already failed before the bridge attaches", async () => {
vi.useFakeTimers();
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
});
const failed: string[] = [];
const delivered: string[] = [];
client.on("room.failed_decryption", (_roomId, _event, error) => {
failed.push(error.message);
});
client.on("room.message", (_roomId, event) => {
delivered.push(event.type);
});
const encrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.encrypted",
ts: Date.now(),
content: {},
decryptionFailure: true,
});
matrixJsClient.decryptEventIfNeeded = vi.fn(async () => {
encrypted.markDecrypted({
type: "m.room.message",
content: {
msgtype: "m.text",
body: "hello",
},
});
});
await client.start();
matrixJsClient.emit("event", encrypted);
expect(failed).toHaveLength(0);
expect(delivered).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1_500);
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
expect(delivered).toEqual(["m.room.message"]);
});
it("stops decryption retries after hitting retry cap", async () => {
vi.useFakeTimers();
const client = new MatrixClient("https://matrix.example.org", "token");
@@ -894,8 +1026,11 @@ describe("MatrixClient event bridge", () => {
await vi.advanceTimersByTimeAsync(200_000);
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(8);
encrypted.emit("decrypted", encrypted, new Error("missing room key again"));
await vi.advanceTimersByTimeAsync(200_000);
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(8);
expect(failed).toEqual(["missing room key"]);
});
it("does not start duplicate retries when crypto signals fire while retry is in-flight", async () => {
@@ -1502,9 +1637,9 @@ describe("MatrixClient crypto bootstrapping", () => {
});
});
it("schedules periodic crypto snapshot persistence with fake timers", async () => {
vi.useFakeTimers();
it("schedules periodic crypto snapshot persistence", async () => {
const databasesSpy = vi.spyOn(indexedDB, "databases").mockResolvedValue([]);
const setIntervalSpy = vi.spyOn(globalThis, "setInterval");
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
@@ -1513,17 +1648,10 @@ describe("MatrixClient crypto bootstrapping", () => {
});
await client.start();
const callsAfterStart = databasesSpy.mock.calls.length;
await vi.advanceTimersByTimeAsync(60_000);
await vi.waitFor(() => {
expect(databasesSpy.mock.calls.length).toBeGreaterThan(callsAfterStart);
});
expect(databasesSpy).toHaveBeenCalled();
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 60_000);
client.stop();
const callsAfterStop = databasesSpy.mock.calls.length;
await vi.advanceTimersByTimeAsync(120_000);
expect(databasesSpy.mock.calls.length).toBe(callsAfterStop);
});
it("reports own verification status when crypto marks device as verified", async () => {
@@ -1609,6 +1737,63 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(status.serverDeviceKnown).toBeNull();
});
it("reports the current Matrix device missing when the homeserver rejects the token", async () => {
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
matrixJsClient.getDevices = vi.fn(async () => {
throw Object.assign(new Error("M_UNKNOWN_TOKEN: access token invalidated"), {
body: { errcode: "M_UNKNOWN_TOKEN" },
statusCode: 401,
});
});
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
requestOwnUserVerification: vi.fn(async () => null),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
})),
}));
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
});
await client.start();
const status = await client.getOwnDeviceVerificationStatus();
expect(status.serverDeviceKnown).toBe(false);
});
it("returns degraded verification diagnostics when Matrix SDK status calls stall", async () => {
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
localTimeoutMs: 1,
});
vi.spyOn(client, "getRoomKeyBackupStatus").mockImplementation(
async () => await new Promise<never>(() => undefined),
);
vi.spyOn(client, "getDeviceVerificationStatus").mockImplementation(
async () => await new Promise<never>(() => undefined),
);
vi.spyOn(client, "listOwnDevices").mockImplementation(
async () => await new Promise<never>(() => undefined),
);
const status = await client.getOwnDeviceVerificationStatus();
expect(status.userId).toBe("@bot:example.org");
expect(status.deviceId).toBe("DEVICE123");
expect(status.verified).toBe(false);
expect(status.crossSigningVerified).toBe(false);
expect(status.backupVersion).toBeNull();
expect(status.backup.keyLoadAttempted).toBe(false);
expect(status.serverDeviceKnown).toBeNull();
});
it("does not treat local-only trust as Matrix identity trust", async () => {
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
@@ -2016,6 +2201,75 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(persisted.encodedPrivateKey).toBe(previousEncoded);
});
it("returns recovery-key diagnostics without bootstrapping when backup is already usable", async () => {
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
const bootstrapCrossSigning = vi.fn(async () => {
throw new Error("bootstrap should not run");
});
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning,
bootstrapSecretStorage: vi.fn(async () => {}),
requestOwnUserVerification: vi.fn(async () => null),
getSecretStorageStatus: vi.fn(async () => ({
ready: true,
defaultKeyId: "SSSSKEY",
secretStorageKeyValidityMap: { SSSSKEY: true },
})),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => false,
localVerified: false,
crossSigningVerified: false,
signedByOwner: false,
})),
checkKeyBackupAndEnable: vi.fn(async () => {}),
getActiveSessionBackupVersion: vi.fn(async () => "11"),
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "11",
})),
isKeyBackupTrusted: vi.fn(async () => ({
trusted: true,
matchesDecryptionKey: true,
})),
}));
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-restored-"));
const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json");
fs.writeFileSync(
recoveryKeyPath,
JSON.stringify({
version: 1,
createdAt: new Date().toISOString(),
keyId: "SSSSKEY",
encodedPrivateKey: encoded,
privateKeyBase64: Buffer.from(
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)),
).toString("base64"),
}),
"utf8",
);
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
recoveryKeyPath,
});
const result = await client.verifyWithRecoveryKey(encoded as string);
expect(bootstrapCrossSigning).not.toHaveBeenCalled();
expect(result.success).toBe(false);
expect(result.recoveryKeyAccepted).toBe(true);
expect(result.backupUsable).toBe(true);
expect(result.deviceOwnerVerified).toBe(false);
expect(result.error).toContain("full Matrix identity trust");
});
it("fails recovery-key verification when backup remains untrusted after device verification", async () => {
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));

View File

@@ -106,6 +106,89 @@ export type MatrixRoomKeyBackupStatus = {
keyLoadError: string | null;
};
const MATRIX_STATUS_DIAGNOSTIC_TIMEOUT_MS = 10_000;
function unresolvedMatrixRoomKeyBackupStatus(): MatrixRoomKeyBackupStatus {
return {
serverVersion: null,
activeVersion: null,
trusted: null,
matchesDecryptionKey: null,
decryptionKeyCached: null,
keyLoadAttempted: false,
keyLoadError: null,
};
}
function unresolvedMatrixDeviceVerificationStatus(params: {
userId: string | null;
deviceId: string | null;
}): MatrixDeviceVerificationStatus {
return {
encryptionEnabled: true,
userId: params.userId,
deviceId: params.deviceId,
verified: false,
localVerified: false,
crossSigningVerified: false,
signedByOwner: false,
};
}
async function resolveMatrixDiagnostic<T>(
promise: Promise<T>,
timeoutMs: number,
): Promise<T | null> {
const result = await resolveMatrixDiagnosticResult(promise, timeoutMs);
return result.value;
}
async function resolveMatrixDiagnosticResult<T>(
promise: Promise<T>,
timeoutMs: number,
): Promise<{ error: unknown; timedOut: boolean; value: T | null }> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
const guarded = promise
.then((value) => ({ error: null, timedOut: false, value }))
.catch((error: unknown) => ({ error, timedOut: false, value: null }));
const timeout = new Promise<{ error: null; timedOut: true; value: null }>((resolve) => {
timeoutId = setTimeout(
() => resolve({ error: null, timedOut: true, value: null }),
timeoutMs,
);
timeoutId.unref?.();
});
return await Promise.race([guarded, timeout]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
function isMatrixAccessTokenInvalidatedError(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false;
}
const err = error as {
body?: { errcode?: string };
data?: { errcode?: string };
statusCode?: number;
};
const errcode = err.body?.errcode ?? err.data?.errcode;
if (err.statusCode === 401 && errcode === "M_UNKNOWN_TOKEN") {
return true;
}
const reason = formatMatrixErrorReason(error);
return (
reason.includes("m_unknown_token") ||
reason.includes("unknown token") ||
(reason.includes("access token") &&
(reason.includes("invalid") || reason.includes("unrecognized") || reason.includes("unknown")))
);
}
export type MatrixRoomKeyBackupRestoreResult = {
success: boolean;
error?: string;
@@ -615,6 +698,12 @@ export class MatrixClient {
await this.stopPersistPromise;
}
stopWithoutPersist(): void {
this.stopSyncWithoutPersist();
this.decryptBridge?.stop();
this.stopPersistPromise = Promise.resolve();
}
private async bootstrapCryptoIfNeeded(abortSignal?: AbortSignal): Promise<void> {
if (!this.encryptionEnabled || !this.cryptoInitialized || this.cryptoBootstrapped) {
return;
@@ -731,7 +820,9 @@ export class MatrixClient {
}
async getJoinedRooms(): Promise<string[]> {
const joined = await this.client.getJoinedRooms();
const joined = (await this.doRequest("GET", "/_matrix/client/v3/joined_rooms")) as {
joined_rooms?: unknown;
};
return Array.isArray(joined.joined_rooms) ? joined.joined_rooms : [];
}
@@ -744,6 +835,19 @@ export class MatrixClient {
return Object.keys(joined);
}
hasSyncedJoinedRoomMember(roomId: string, userId: string): boolean {
const room = (
this.client as {
getRoom?: (roomId: string) => {
currentState?: {
getMember?: (userId: string) => { membership?: string | null } | null;
};
} | null;
}
).getRoom?.(roomId);
return room?.currentState?.getMember?.(userId)?.membership === "join";
}
async getRoomStateEvent(
roomId: string,
eventType: string,
@@ -1127,23 +1231,34 @@ export class MatrixClient {
const recoveryKey = this.recoveryKeyStore.getRecoveryKeySummary();
const userId = this.client.getUserId() ?? this.selfUserId ?? null;
const deviceId = this.client.getDeviceId()?.trim() || null;
const diagnosticTimeoutMs = Math.min(this.localTimeoutMs, MATRIX_STATUS_DIAGNOSTIC_TIMEOUT_MS);
const [backup, deviceVerification, ownDevices] = await Promise.all([
this.getRoomKeyBackupStatus(),
this.getDeviceVerificationStatus(userId, deviceId),
this.listOwnDevices().catch(() => null),
resolveMatrixDiagnostic(this.getRoomKeyBackupStatus(), diagnosticTimeoutMs),
resolveMatrixDiagnostic(
this.getDeviceVerificationStatus(userId, deviceId),
diagnosticTimeoutMs,
),
resolveMatrixDiagnosticResult(this.listOwnDevices(), diagnosticTimeoutMs),
]);
const resolvedBackup = backup ?? unresolvedMatrixRoomKeyBackupStatus();
const resolvedDeviceVerification =
deviceVerification ?? unresolvedMatrixDeviceVerificationStatus({ userId, deviceId });
const serverDeviceKnown = deviceId
? (ownDevices?.some((device) => device.deviceId === deviceId) ?? null)
? ownDevices.value
? ownDevices.value.some((device) => device.deviceId === deviceId)
: isMatrixAccessTokenInvalidatedError(ownDevices.error)
? false
: null
: null;
return {
...deviceVerification,
verified: deviceVerification.crossSigningVerified,
...resolvedDeviceVerification,
verified: resolvedDeviceVerification.crossSigningVerified,
recoveryKeyStored: Boolean(recoveryKey),
recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null,
recoveryKeyId: recoveryKey?.keyId ?? null,
backupVersion: backup.serverVersion,
backup,
backupVersion: resolvedBackup.serverVersion,
backup: resolvedBackup,
serverDeviceKnown,
};
}
@@ -1241,6 +1356,61 @@ export class MatrixClient {
return await fail(formatMatrixErrorMessage(err));
}
const storedRecoveryKeyMatches =
this.recoveryKeyStore.getRecoveryKeySummary()?.encodedPrivateKey?.trim() ===
trimmedRecoveryKey;
if (backupUsableBeforeStagedRecovery && storedRecoveryKeyMatches) {
const status = await this.getOwnDeviceVerificationStatus();
const backupUsable =
resolveMatrixRoomKeyBackupReadinessError(status.backup, {
requireServerBackup: true,
}) === null;
const backupError = resolveMatrixRoomKeyBackupReadinessError(status.backup, {
requireServerBackup: false,
});
const recoveryKeyAccepted = backupUsable;
if (!status.verified) {
if (recoveryKeyAccepted) {
this.recoveryKeyStore.commitStagedRecoveryKey({
keyId: stagedKeyId,
});
} else {
this.recoveryKeyStore.discardStagedRecoveryKey();
}
return {
success: false,
recoveryKeyAccepted,
backupUsable,
deviceOwnerVerified: false,
error:
"Matrix recovery key was applied, but this device still lacks full Matrix identity trust. The recovery key can unlock usable backup material only when 'Backup usable' is yes; full identity trust still requires Matrix cross-signing verification.",
...status,
};
}
if (backupError) {
this.recoveryKeyStore.discardStagedRecoveryKey();
return {
success: false,
recoveryKeyAccepted,
backupUsable,
deviceOwnerVerified: true,
error: backupError,
...status,
};
}
this.recoveryKeyStore.commitStagedRecoveryKey({
keyId: stagedKeyId,
});
return {
success: true,
recoveryKeyAccepted: true,
backupUsable,
deviceOwnerVerified: true,
verifiedAt: new Date().toISOString(),
...status,
};
}
try {
const cryptoBootstrapper = this.cryptoBootstrapper;
if (!cryptoBootstrapper) {
@@ -1275,9 +1445,6 @@ export class MatrixClient {
!stagedRecoveryKeyConfirmedBySecretStorage &&
!backupUsableBeforeStagedRecovery &&
backupUsable;
const storedRecoveryKeyMatches =
this.recoveryKeyStore.getRecoveryKeySummary()?.encodedPrivateKey?.trim() ===
trimmedRecoveryKey;
const stagedRecoveryKeyValidated =
(stagedRecoveryKeyUsed &&
(stagedRecoveryKeyConfirmedBySecretStorage || stagedRecoveryKeyUnlockedBackup)) ||
@@ -1585,6 +1752,7 @@ export class MatrixClient {
let bootstrapError: string | undefined;
let bootstrapSummary: MatrixCryptoBootstrapResult | null = null;
let rawRecoveryKey: string | undefined;
try {
await this.ensureStartedForCryptoControlPlane();
await this.ensureCryptoSupportInitialized();
@@ -1593,7 +1761,7 @@ export class MatrixClient {
throw new Error("Matrix crypto is not available (start client with encryption enabled)");
}
const rawRecoveryKey = params?.recoveryKey?.trim();
rawRecoveryKey = params?.recoveryKey?.trim();
if (rawRecoveryKey) {
this.recoveryKeyStore.stageEncodedRecoveryKey({
encodedPrivateKey: rawRecoveryKey,
@@ -1607,7 +1775,12 @@ export class MatrixClient {
}
bootstrapSummary = await cryptoBootstrapper.bootstrap(
crypto,
createMatrixExplicitBootstrapOptions(params),
createMatrixExplicitBootstrapOptions({
...params,
allowAutomaticCrossSigningReset: rawRecoveryKey
? false
: params?.allowAutomaticCrossSigningReset,
}),
);
await this.ensureRoomKeyBackupEnabled(crypto);
} catch (err) {
@@ -1625,6 +1798,7 @@ export class MatrixClient {
const backupError =
verificationError === null
? resolveMatrixRoomKeyBackupReadinessError(verification.backup, {
allowUntrustedMatchingKey: Boolean(rawRecoveryKey),
requireServerBackup: true,
})
: null;

View File

@@ -1,4 +1,5 @@
import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js";
import { DecryptionFailureCode } from "matrix-js-sdk/lib/crypto-api/index.js";
import { MatrixEventEvent, type MatrixEvent } from "matrix-js-sdk/lib/matrix.js";
import { LogService, noop } from "./logger.js";
@@ -46,11 +47,34 @@ function isDecryptionFailure(event: MatrixEvent): boolean {
);
}
function getDecryptionFailureReason(event: MatrixEvent): DecryptionFailureCode | null {
const reason = (event as { decryptionFailureReason?: unknown }).decryptionFailureReason;
return typeof reason === "string" && reason in DecryptionFailureCode
? (reason as DecryptionFailureCode)
: null;
}
function shouldRetryDecryptionFailure(event: MatrixEvent): boolean {
if (!isDecryptionFailure(event)) {
return false;
}
const reason = getDecryptionFailureReason(event);
if (!reason) {
return true;
}
return (
reason === DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID ||
reason === DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX ||
reason === DecryptionFailureCode.UNKNOWN_ERROR
);
}
export class MatrixDecryptBridge<TRawEvent extends DecryptBridgeRawEvent> {
private readonly trackedEncryptedEvents = new WeakSet<object>();
private readonly decryptedMessageDedupe = new Map<string, number>();
private readonly decryptRetries = new Map<string, MatrixDecryptRetryState>();
private readonly failedDecryptionsNotified = new Set<string>();
private readonly exhaustedDecryptRetries = new Set<string>();
private activeRetryRuns = 0;
private readonly retryIdleResolvers = new Set<() => void>();
private cryptoRetrySignalsBound = false;
@@ -91,6 +115,11 @@ export class MatrixDecryptBridge<TRawEvent extends DecryptBridgeRawEvent> {
err,
});
});
if (shouldRetryDecryptionFailure(event)) {
const raw = this.deps.toRaw(event);
const eventId = raw.event_id || event.getId() || "";
this.scheduleDecryptRetry({ event, roomId, eventId });
}
}
retryPendingNow(reason: string): void {
@@ -170,11 +199,15 @@ export class MatrixDecryptBridge<TRawEvent extends DecryptBridgeRawEvent> {
if (params.err) {
this.emitFailedDecryptionOnce(retryKey, decryptedRoomId, decryptedRaw, params.err);
this.scheduleDecryptRetry({
event: params.encryptedEvent,
roomId: decryptedRoomId,
eventId: retryEventId,
});
if (shouldRetryDecryptionFailure(params.decryptedEvent)) {
this.scheduleDecryptRetry({
event: params.encryptedEvent,
roomId: decryptedRoomId,
eventId: retryEventId,
});
} else if (retryKey) {
this.clearDecryptRetry(retryKey);
}
return;
}
@@ -185,11 +218,15 @@ export class MatrixDecryptBridge<TRawEvent extends DecryptBridgeRawEvent> {
decryptedRaw,
new Error("Matrix event failed to decrypt"),
);
this.scheduleDecryptRetry({
event: params.encryptedEvent,
roomId: decryptedRoomId,
eventId: retryEventId,
});
if (shouldRetryDecryptionFailure(params.decryptedEvent)) {
this.scheduleDecryptRetry({
event: params.encryptedEvent,
roomId: decryptedRoomId,
eventId: retryEventId,
});
} else if (retryKey) {
this.clearDecryptRetry(retryKey);
}
return;
}
@@ -226,12 +263,20 @@ export class MatrixDecryptBridge<TRawEvent extends DecryptBridgeRawEvent> {
return;
}
const existing = this.decryptRetries.get(retryKey);
if (this.exhaustedDecryptRetries.has(retryKey)) {
return;
}
if (existing?.timer || existing?.inFlight) {
return;
}
const attempts = (existing?.attempts ?? 0) + 1;
if (attempts > MATRIX_DECRYPT_RETRY_MAX_ATTEMPTS) {
this.clearDecryptRetry(retryKey);
const retry = this.decryptRetries.get(retryKey);
if (retry?.timer) {
clearTimeout(retry.timer);
}
this.decryptRetries.delete(retryKey);
this.exhaustedDecryptRetries.add(retryKey);
LogService.debug(
"MatrixClientLite",
`Giving up decryption retry for ${params.eventId} in ${params.roomId} after ${attempts - 1} attempts`,
@@ -289,11 +334,19 @@ export class MatrixDecryptBridge<TRawEvent extends DecryptBridgeRawEvent> {
return;
}
if (isDecryptionFailure(state.event)) {
if (!shouldRetryDecryptionFailure(state.event)) {
this.clearDecryptRetry(retryKey);
return;
}
this.scheduleDecryptRetry(state);
return;
}
this.clearDecryptRetry(retryKey);
const raw = this.deps.toRaw(state.event);
this.rememberDecryptedMessage(state.roomId, raw.event_id);
this.deps.emitDecryptedEvent(state.roomId, raw);
this.deps.emitMessage(state.roomId, raw);
}
private clearDecryptRetry(retryKey: string): void {
@@ -302,6 +355,7 @@ export class MatrixDecryptBridge<TRawEvent extends DecryptBridgeRawEvent> {
clearTimeout(state.timer);
}
this.decryptRetries.delete(retryKey);
this.exhaustedDecryptRetries.delete(retryKey);
this.failedDecryptionsNotified.delete(retryKey);
}

View File

@@ -295,6 +295,7 @@ export function createMatrixNamedAccountsConfig(params: {
{
homeserver: string;
accessToken?: string;
encryption?: boolean;
}
>;
}): CoreConfig {

View File

@@ -22,10 +22,15 @@ export async function maybeBootstrapNewEncryptedMatrixAccount(params: {
cfg: params.cfg,
accountId: params.accountId,
});
const previousAccountConfig = resolveMatrixAccountConfig({
cfg: params.previousCfg,
accountId: params.accountId,
});
if (
hasExplicitMatrixAccountConfig(params.previousCfg, params.accountId) ||
accountConfig.encryption !== true
accountConfig.encryption !== true ||
(hasExplicitMatrixAccountConfig(params.previousCfg, params.accountId) &&
previousAccountConfig.encryption === true)
) {
return {
attempted: false,
@@ -36,7 +41,10 @@ export async function maybeBootstrapNewEncryptedMatrixAccount(params: {
}
try {
const bootstrap = await bootstrapMatrixVerification({ accountId: params.accountId });
const bootstrap = await bootstrapMatrixVerification({
accountId: params.accountId,
cfg: params.cfg,
});
return {
attempted: true,
success: bootstrap.success,

View File

@@ -1578,6 +1578,129 @@ describe("qa mock openai server", () => {
});
});
it("uses exact marker directives from request context when the latest user text is generic", async () => {
const server = await startQaMockOpenAiServer({
host: "127.0.0.1",
port: 0,
});
cleanups.push(async () => {
await server.stop();
});
const response = await fetch(`${server.baseUrl}/v1/responses`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
stream: false,
input: [
{
role: "user",
content: [
{
type: "input_text",
text: "@qa-sut:matrix-qa.test reply with only this exact marker: MATRIX_QA_CANARY_TEST",
},
],
},
{
role: "user",
content: [
{
type: "input_text",
text: "Continue with the QA scenario plan and report worked, failed, and blocked items.",
},
],
},
],
}),
});
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
output: [
{
content: [{ text: "MATRIX_QA_CANARY_TEST" }],
},
],
});
});
it("uses image generation directives from request context when the latest user text is generic", async () => {
const server = await startQaMockOpenAiServer({
host: "127.0.0.1",
port: 0,
});
cleanups.push(async () => {
await server.stop();
});
const matrixPrompt =
"@qa-sut:matrix-qa.test Image generation check: generate a QA lighthouse image and summarize it in one short sentence.";
const genericPrompt =
"Continue with the QA scenario plan and report worked, failed, and blocked items.";
const toolPlan = await fetch(`${server.baseUrl}/v1/responses`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
stream: false,
input: [makeUserInput(matrixPrompt), makeUserInput(genericPrompt)],
}),
});
expect(toolPlan.status).toBe(200);
expect(await toolPlan.json()).toMatchObject({
output: [
{
type: "function_call",
name: "image_generate",
arguments: expect.stringContaining("qa-lighthouse.png"),
},
],
});
const toolResult = await fetch(`${server.baseUrl}/v1/responses`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
stream: false,
input: [
makeUserInput(matrixPrompt),
makeUserInput(genericPrompt),
{
type: "function_call",
name: "image_generate",
call_id: "call_mock_image_generate_1",
arguments: JSON.stringify({
prompt: "A QA lighthouse",
filename: "qa-lighthouse.png",
}),
},
{
type: "function_call_output",
call_id: "call_mock_image_generate_1",
output: "MEDIA:/tmp/qa-lighthouse.png",
},
],
}),
});
expect(toolResult.status).toBe(200);
expect(await toolResult.json()).toMatchObject({
output: [
{
content: [{ text: expect.stringContaining("MEDIA:/tmp/qa-lighthouse.png") }],
},
],
});
});
it("records image inputs and describes attached images", async () => {
const server = await startQaMockOpenAiServer({
host: "127.0.0.1",

View File

@@ -150,6 +150,7 @@ const QA_BLOCK_STREAMING_PROMPT_RE = /block streaming qa check/i;
const QA_SUBAGENT_DIRECT_FALLBACK_PROMPT_RE = /subagent direct fallback qa check/i;
const QA_SUBAGENT_DIRECT_FALLBACK_WORKER_RE = /subagent direct fallback worker/i;
const QA_SUBAGENT_DIRECT_FALLBACK_MARKER = "QA-SUBAGENT-DIRECT-FALLBACK-OK";
const QA_IMAGE_GENERATION_PROMPT_RE = /image generation check|capability flip image check/i;
const QA_REASONING_ONLY_RETRY_NEEDLE =
"recorded reasoning but did not produce a user-visible answer";
const QA_EMPTY_RESPONSE_RETRY_NEEDLE =
@@ -671,10 +672,10 @@ function buildAssistantText(
const mediaPath = /MEDIA:([^\n]+)/.exec(toolOutput)?.[1]?.trim();
const exactReplyDirective =
extractExactReplyDirective(prompt) ?? extractExactReplyDirective(allInputText);
const finishExactlyDirective =
extractFinishExactlyDirective(prompt) ?? extractFinishExactlyDirective(allInputText);
const exactMarkerDirective =
extractExactMarkerDirective(prompt) ?? extractExactMarkerDirective(allInputText);
const finishExactlyDirective =
extractFinishExactlyDirective(prompt) ?? extractFinishExactlyDirective(allInputText);
const imageInputCount = countImageInputs(input);
const activeMemorySummary = extractActiveMemorySummary(allInputText);
const snackPreference = extractSnackPreference(activeMemorySummary ?? memorySnippet);
@@ -703,10 +704,10 @@ function buildAssistantText(
if (isHeartbeatPrompt(prompt)) {
return "HEARTBEAT_OK";
}
if (/\bmarker\b/i.test(prompt) && exactReplyDirective) {
if (/\bmarker\b/i.test(allInputText) && exactReplyDirective) {
return exactReplyDirective;
}
if (/\bmarker\b/i.test(prompt) && exactMarkerDirective) {
if (/\bmarker\b/i.test(allInputText) && exactMarkerDirective) {
return exactMarkerDirective;
}
if (/visible skill marker/i.test(prompt)) {
@@ -753,7 +754,7 @@ function buildAssistantText(
if (/switch(?:ing)? models?/i.test(prompt)) {
return `Protocol note: model switch acknowledged. Continuing on ${model || "the requested model"}.`;
}
if (/(image generation check|capability flip image check)/i.test(prompt) && mediaPath) {
if (QA_IMAGE_GENERATION_PROMPT_RE.test(allInputText) && mediaPath) {
return `Protocol note: generated the QA lighthouse image successfully.\nMEDIA:${mediaPath}`;
}
if (QA_SKILL_WORKSHOP_GIF_PROMPT_RE.test(prompt) && toolOutput) {
@@ -1146,6 +1147,8 @@ async function buildResponsesPayload(
const allInputText = extractAllRequestTexts(input, body);
const exactReplyDirective =
extractExactReplyDirective(prompt) ?? extractExactReplyDirective(allInputText);
const exactMarkerDirective =
extractExactMarkerDirective(prompt) ?? extractExactMarkerDirective(allInputText);
const firstExactMarkerDirective = extractLabeledMarkerDirective(
allInputText,
"first exact marker",
@@ -1269,6 +1272,12 @@ async function buildResponsesPayload(
},
]);
}
if (/\bmarker\b/i.test(allInputText) && exactReplyDirective) {
return buildAssistantEvents(exactReplyDirective);
}
if (/\bmarker\b/i.test(allInputText) && exactMarkerDirective) {
return buildAssistantEvents(exactMarkerDirective);
}
if (QA_SKILL_WORKSHOP_REVIEW_PROMPT_RE.test(allInputText)) {
return buildAssistantEvents(
JSON.stringify({
@@ -1485,7 +1494,7 @@ async function buildResponsesPayload(
});
}
}
if (/(image generation check|capability flip image check)/i.test(prompt) && !toolOutput) {
if (QA_IMAGE_GENERATION_PROMPT_RE.test(allInputText) && !toolOutput) {
return buildToolCallEventsWithArgs("image_generate", {
prompt: "A QA lighthouse on a dark sea with a tiny protocol droid silhouette.",
filename: "qa-lighthouse.png",

View File

@@ -491,26 +491,81 @@ describe("matrix live qa runtime", () => {
expect(report).toContain("observed events: /tmp/observed.json");
});
it("batches Matrix scenarios by config key while preserving stable in-group order", () => {
it("keeps Matrix scenario execution in catalog order across config changes", () => {
const scenarios = liveTesting.findMatrixQaScenarios([
"matrix-top-level-reply-shape",
"matrix-room-thread-reply-override",
"matrix-thread-follow-up",
"matrix-room-quiet-streaming-preview",
"matrix-reaction-notification",
"matrix-e2ee-cli-encryption-setup-multi-account",
"matrix-e2ee-cli-setup-then-gateway-reply",
"matrix-e2ee-cli-self-verification",
]);
expect(
liveTesting.scheduleMatrixQaScenariosByConfig(scenarios).map(({ scenario }) => scenario.id),
liveTesting
.scheduleMatrixQaScenariosInCatalogOrder(scenarios)
.map(({ scenario }) => scenario.id),
).toEqual([
"matrix-thread-follow-up",
"matrix-top-level-reply-shape",
"matrix-reaction-notification",
"matrix-room-thread-reply-override",
"matrix-room-quiet-streaming-preview",
"matrix-e2ee-cli-encryption-setup-multi-account",
"matrix-e2ee-cli-setup-then-gateway-reply",
"matrix-e2ee-cli-self-verification",
]);
});
it("uses the scenario timeout for post-restart Matrix readiness", () => {
expect(
liveTesting.getMatrixQaScenarioRestartReadyTimeoutMs({
timeoutMs: 180_000,
}),
).toBe(180_000);
});
it("retries Matrix gateway config patches after a stale config hash", async () => {
const patch = {
channels: {
matrix: {
enabled: true,
},
},
};
const gateway = {
call: vi
.fn()
.mockResolvedValueOnce({ hash: "hash-old" })
.mockRejectedValueOnce(
new Error("config changed since last load; re-run config.get and retry"),
)
.mockResolvedValueOnce({ hash: "hash-fresh" })
.mockResolvedValueOnce(undefined),
};
await liveTesting.patchMatrixQaGatewayConfig({
gateway: gateway as never,
patch,
restartDelayMs: 250,
});
expect(gateway.call).toHaveBeenNthCalledWith(1, "config.get", {}, { timeoutMs: 60_000 });
expect(gateway.call).toHaveBeenNthCalledWith(
2,
"config.patch",
{
baseHash: "hash-old",
raw: JSON.stringify(patch, null, 2),
restartDelayMs: 250,
},
{ timeoutMs: 60_000 },
);
expect(gateway.call).toHaveBeenNthCalledWith(3, "config.get", {}, { timeoutMs: 60_000 });
expect(gateway.call).toHaveBeenNthCalledWith(
4,
"config.patch",
{
baseHash: "hash-fresh",
raw: JSON.stringify(patch, null, 2),
restartDelayMs: 250,
},
{ timeoutMs: 60_000 },
);
});
it("treats only connected, healthy Matrix accounts as ready", () => {
expect(liveTesting.isMatrixAccountReady({ running: true, connected: true })).toBe(true);
expect(liveTesting.isMatrixAccountReady({ running: true, connected: false })).toBe(false);

View File

@@ -54,7 +54,7 @@ const DEFAULT_MATRIX_QA_CLEANUP_TIMEOUT_MS = 90_000;
type MatrixQaLiveLaneGatewayHarness = {
gateway: MatrixQaGatewayChild;
stop(): Promise<void>;
stop(opts?: { keepTemp?: boolean; preserveToDir?: string }): Promise<void>;
};
function buildMatrixQaGatewayConfigKey(overrides?: MatrixQaConfigOverrides) {
@@ -310,23 +310,14 @@ function buildMatrixQaScenarioResult(params: {
};
}
function scheduleMatrixQaScenariosByConfig(
function scheduleMatrixQaScenariosInCatalogOrder(
scenarios: readonly (typeof MATRIX_QA_SCENARIOS)[number][],
): MatrixQaScheduledScenario[] {
const grouped = new Map<string, MatrixQaScheduledScenario[]>();
return scenarios.map((scenario, originalIndex) => ({ originalIndex, scenario }));
}
scenarios.forEach((scenario, originalIndex) => {
const configKey = buildMatrixQaGatewayConfigKey(scenario.configOverrides);
const existing = grouped.get(configKey);
const scheduled = { originalIndex, scenario };
if (existing) {
existing.push(scheduled);
return;
}
grouped.set(configKey, [scheduled]);
});
return [...grouped.values()].flat();
function getMatrixQaScenarioRestartReadyTimeoutMs(scenario: { timeoutMs: number }): number {
return scenario.timeoutMs;
}
export type MatrixQaRunResult = {
@@ -411,6 +402,7 @@ async function waitForMatrixChannelReady(
const pollMs = opts?.pollMs ?? 500;
const timeoutMs = opts?.timeoutMs ?? 60_000;
const startedAt = Date.now();
let lastAccounts: unknown;
while (Date.now() - startedAt < timeoutMs) {
try {
const payload = (await gateway.call(
@@ -430,6 +422,7 @@ async function waitForMatrixChannelReady(
>;
};
const accounts = payload.channelAccounts?.matrix ?? [];
lastAccounts = accounts;
const match = accounts.find((entry) => entry.accountId === accountId);
if (isMatrixAccountReady(match)) {
return;
@@ -439,7 +432,11 @@ async function waitForMatrixChannelReady(
}
await sleep(pollMs);
}
throw new Error(`matrix account "${accountId}" did not become ready`);
throw new Error(
`matrix account "${accountId}" did not become ready; last matrix accounts: ${JSON.stringify(
lastAccounts ?? [],
)}`,
);
}
async function patchMatrixQaGatewayConfig(params: {
@@ -447,21 +444,35 @@ async function patchMatrixQaGatewayConfig(params: {
patch: Record<string, unknown>;
restartDelayMs?: number;
}) {
const snapshot = (await params.gateway.call("config.get", {}, { timeoutMs: 60_000 })) as {
hash?: string;
};
if (!snapshot.hash) {
throw new Error("Matrix QA config patch requires config.get hash");
for (let attempt = 0; attempt < 2; attempt += 1) {
const snapshot = (await params.gateway.call("config.get", {}, { timeoutMs: 60_000 })) as {
hash?: string;
};
if (!snapshot.hash) {
throw new Error("Matrix QA config patch requires config.get hash");
}
try {
await params.gateway.call(
"config.patch",
{
raw: JSON.stringify(params.patch, null, 2),
baseHash: snapshot.hash,
restartDelayMs: params.restartDelayMs ?? 0,
},
{ timeoutMs: 60_000 },
);
return;
} catch (error) {
if (attempt === 0 && isMatrixQaStaleConfigPatchError(error)) {
continue;
}
throw error;
}
}
await params.gateway.call(
"config.patch",
{
raw: JSON.stringify(params.patch, null, 2),
baseHash: snapshot.hash,
restartDelayMs: params.restartDelayMs ?? 0,
},
{ timeoutMs: 60_000 },
);
}
function isMatrixQaStaleConfigPatchError(error: unknown) {
return formatErrorMessage(error).toLowerCase().includes("config changed since last load");
}
async function startMatrixQaLiveLaneGateway(params: {
@@ -582,6 +593,7 @@ export async function runMatrixQaLive(params: {
let canaryArtifact: MatrixQaCanaryArtifact | undefined;
let gatewayHarness: MatrixQaLiveLaneGatewayHarness | null = null;
let gatewayHarnessKey: string | null = null;
let preservedGatewayDebugDirPath: string | undefined;
let canaryFailed = false;
const syncState: { driver?: string; observer?: string } = {};
const syncStreams: MatrixQaSyncStreams = {};
@@ -604,7 +616,7 @@ export async function runMatrixQaLive(params: {
const defaultConfigSnapshot = buildMatrixQaConfigSnapshot(gatewayConfigParams);
const scenarioConfigSnapshots: MatrixQaScenarioConfigEntry[] = [];
const scheduledScenarios = scheduleMatrixQaScenariosByConfig(scenarios);
const scheduledScenarios = scheduleMatrixQaScenariosInCatalogOrder(scenarios);
try {
const ensureGatewayHarness = async (overrides?: MatrixQaConfigOverrides) => {
@@ -754,6 +766,7 @@ export async function runMatrixQaLive(params: {
gatewayRuntimeEnv: scenarioGateway.harness.gateway.runtimeEnv,
gatewayStateDir: scenarioGateway.harness.gateway.runtimeEnv?.OPENCLAW_STATE_DIR,
outputDir,
registrationToken: harness.registrationToken,
restartGateway: async () => {
if (!gatewayHarness) {
throw new Error("Matrix restart scenario requires a live gateway");
@@ -761,7 +774,9 @@ export async function runMatrixQaLive(params: {
writeMatrixQaProgress(`gateway restart start ${scenario.id}`);
const measuredRestart = await measureMatrixQaStep(async () => {
await scenarioGateway.harness.gateway.restart();
await waitForMatrixChannelReady(scenarioGateway.harness.gateway, sutAccountId);
await waitForMatrixChannelReady(scenarioGateway.harness.gateway, sutAccountId, {
timeoutMs: getMatrixQaScenarioRestartReadyTimeoutMs(scenario),
});
});
gatewayRestartMs += measuredRestart.durationMs;
scenarioRestartGatewayMs += measuredRestart.durationMs;
@@ -769,7 +784,7 @@ export async function runMatrixQaLive(params: {
`gateway restart done ${scenario.id} ${formatMatrixQaDurationMs(measuredRestart.durationMs)}`,
);
},
restartGatewayAfterStateMutation: async (mutateState) => {
restartGatewayAfterStateMutation: async (mutateState, opts) => {
if (!gatewayHarness) {
throw new Error(
"Matrix persisted-state restart scenario requires a live gateway",
@@ -785,7 +800,14 @@ export async function runMatrixQaLive(params: {
writeMatrixQaProgress(`gateway hard restart start ${scenario.id}`);
const measuredRestart = await measureMatrixQaStep(async () => {
await restartAfterStateMutation(mutateState);
await waitForMatrixChannelReady(scenarioGateway.harness.gateway, sutAccountId);
await waitForMatrixChannelReady(
scenarioGateway.harness.gateway,
opts?.waitAccountId ?? sutAccountId,
{
timeoutMs:
opts?.timeoutMs ?? getMatrixQaScenarioRestartReadyTimeoutMs(scenario),
},
);
});
gatewayRestartMs += measuredRestart.durationMs;
scenarioRestartGatewayMs += measuredRestart.durationMs;
@@ -802,7 +824,9 @@ export async function runMatrixQaLive(params: {
await scenarioGateway.harness.gateway.restart();
await sleep(250);
await queueMessage();
await waitForMatrixChannelReady(scenarioGateway.harness.gateway, sutAccountId);
await waitForMatrixChannelReady(scenarioGateway.harness.gateway, sutAccountId, {
timeoutMs: getMatrixQaScenarioRestartReadyTimeoutMs(scenario),
});
});
gatewayRestartMs += measuredRestart.durationMs;
scenarioRestartGatewayMs += measuredRestart.durationMs;
@@ -827,6 +851,12 @@ export async function runMatrixQaLive(params: {
restartDelayMs: opts?.restartDelayMs,
});
},
waitGatewayAccountReady: async (accountId, opts) => {
await waitForMatrixChannelReady(scenarioGateway.harness.gateway, accountId, {
timeoutMs:
opts?.timeoutMs ?? getMatrixQaScenarioRestartReadyTimeoutMs(scenario),
});
},
}),
),
);
@@ -871,9 +901,20 @@ export async function runMatrixQaLive(params: {
} finally {
if (gatewayHarness) {
try {
const shouldPreserveGatewayDebugArtifacts = scenarioResults.some(
(scenario) => scenario?.status === "fail",
);
preservedGatewayDebugDirPath = shouldPreserveGatewayDebugArtifacts
? path.join(outputDir, "gateway-debug")
: undefined;
await cleanupMatrixQaResource({
label: "Matrix live gateway cleanup",
action: () => gatewayHarness!.stop(),
action: () =>
gatewayHarness!.stop(
preservedGatewayDebugDirPath
? { preserveToDir: preservedGatewayDebugDirPath }
: undefined,
),
});
} catch (error) {
appendLiveLaneIssue(cleanupErrors, "live gateway cleanup", error);
@@ -899,6 +940,13 @@ export async function runMatrixQaLive(params: {
details: cleanupErrors.join("\n"),
});
}
if (preservedGatewayDebugDirPath) {
checks.push({
name: "Matrix gateway debug logs",
status: "pass",
details: `preserved at: ${preservedGatewayDebugDirPath}`,
});
}
const finishedAtDate = new Date();
const finishedAt = finishedAtDate.toISOString();
@@ -1037,7 +1085,8 @@ export async function runMatrixQaLive(params: {
export const __testing = {
buildMatrixQaSummary,
scheduleMatrixQaScenariosByConfig,
getMatrixQaScenarioRestartReadyTimeoutMs,
scheduleMatrixQaScenariosInCatalogOrder,
MATRIX_QA_SCENARIOS,
buildMatrixQaConfig,
buildMatrixQaConfigSnapshot,

View File

@@ -59,6 +59,14 @@ export type MatrixQaScenarioId =
| "matrix-e2ee-bootstrap-success"
| "matrix-e2ee-recovery-key-lifecycle"
| "matrix-e2ee-recovery-owner-verification-required"
| "matrix-e2ee-cli-account-add-enable-e2ee"
| "matrix-e2ee-cli-encryption-setup"
| "matrix-e2ee-cli-encryption-setup-idempotent"
| "matrix-e2ee-cli-encryption-setup-bootstrap-failure"
| "matrix-e2ee-cli-recovery-key-setup"
| "matrix-e2ee-cli-recovery-key-invalid"
| "matrix-e2ee-cli-encryption-setup-multi-account"
| "matrix-e2ee-cli-setup-then-gateway-reply"
| "matrix-e2ee-cli-self-verification"
| "matrix-e2ee-state-loss-external-recovery-key"
| "matrix-e2ee-state-loss-stored-recovery-key"
@@ -68,6 +76,7 @@ export type MatrixQaScenarioId =
| "matrix-e2ee-server-backup-deleted-local-reupload-restores"
| "matrix-e2ee-corrupt-crypto-idb-snapshot"
| "matrix-e2ee-server-device-deleted-local-state-intact"
| "matrix-e2ee-server-device-deleted-relogin-recovers"
| "matrix-e2ee-sync-state-loss-crypto-intact"
| "matrix-e2ee-wrong-account-recovery-key"
| "matrix-e2ee-history-exists-backup-empty"
@@ -238,6 +247,11 @@ const MATRIX_QA_E2EE_CONFIG = {
startupVerification: "off",
} satisfies MatrixQaConfigOverrides;
const MATRIX_QA_E2EE_CLI_SETUP_CONFIG = {
encryption: false,
startupVerification: "off",
} satisfies MatrixQaConfigOverrides;
export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [
{
id: "matrix-thread-follow-up",
@@ -590,6 +604,86 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-cli-account-add-enable-e2ee",
timeoutMs: 120_000,
title: "Matrix E2EE CLI account add enables encryption and bootstraps verification",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-cli-account-add-enable-e2ee",
name: "Matrix QA E2EE CLI Account Add Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-cli-encryption-setup",
timeoutMs: 120_000,
title: "Matrix E2EE CLI encryption setup upgrades an existing account",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-cli-encryption-setup",
name: "Matrix QA E2EE CLI Encryption Setup Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-cli-encryption-setup-idempotent",
timeoutMs: 120_000,
title: "Matrix E2EE CLI encryption setup is idempotent on encrypted accounts",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-cli-encryption-setup-idempotent",
name: "Matrix QA E2EE CLI Encryption Setup Idempotent Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-cli-encryption-setup-bootstrap-failure",
timeoutMs: 120_000,
title: "Matrix E2EE CLI encryption setup reports bootstrap failures",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-cli-encryption-setup-bootstrap-failure",
name: "Matrix QA E2EE CLI Encryption Setup Failure Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-cli-recovery-key-setup",
timeoutMs: 120_000,
title: "Matrix E2EE CLI encryption setup accepts a recovery key on a second device",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-cli-recovery-key-setup",
name: "Matrix QA E2EE CLI Recovery Key Setup Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-cli-recovery-key-invalid",
timeoutMs: 120_000,
title: "Matrix E2EE CLI encryption setup rejects an invalid recovery key",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-cli-recovery-key-invalid",
name: "Matrix QA E2EE CLI Invalid Recovery Key Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-cli-encryption-setup-multi-account",
timeoutMs: 120_000,
title: "Matrix E2EE CLI encryption setup targets one account in a multi-account config",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-cli-encryption-setup-multi-account",
name: "Matrix QA E2EE CLI Multi Account Setup Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-cli-setup-then-gateway-reply",
timeoutMs: 180_000,
title: "Matrix E2EE CLI setup leaves the gateway able to reply in encrypted rooms",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-cli-setup-then-gateway-reply",
name: "Matrix QA E2EE CLI Setup Gateway Reply Room",
}),
configOverrides: MATRIX_QA_E2EE_CLI_SETUP_CONFIG,
},
{
id: "matrix-e2ee-cli-self-verification",
timeoutMs: 180_000,
@@ -598,7 +692,6 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [
scenarioId: "matrix-e2ee-cli-self-verification",
name: "Matrix QA E2EE CLI Self Verification Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-state-loss-external-recovery-key",
@@ -680,6 +773,16 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-server-device-deleted-relogin-recovers",
timeoutMs: 180_000,
title: "Matrix E2EE server-side device deletion recovers through re-login and backup restore",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-server-device-deleted-relogin-recovers",
name: "Matrix QA E2EE Server Device Relogin Recovery Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-sync-state-loss-crypto-intact",
timeoutMs: MATRIX_QA_E2EE_REPLY_TIMEOUT_MS,

View File

@@ -140,4 +140,40 @@ describe("Matrix QA CLI runtime", () => {
await rm(root, { force: true, recursive: true });
}
});
it("includes timed-out CLI output in diagnostics", async () => {
const root = await mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-timeout-"),
);
try {
await mkdir(path.join(root, "dist"));
await writeFile(
path.join(root, "dist", "index.mjs"),
[
"process.stdout.write('waiting for verification\\n');",
"process.stderr.write('matrix sdk still syncing\\n');",
"setInterval(() => {}, 1000);",
].join("\n"),
);
await expect(
runMatrixQaOpenClawCli({
args: ["matrix", "verify", "self"],
cwd: root,
env: process.env,
timeoutMs: 250,
}),
).rejects.toThrow(/stdout:\nwaiting for verification/);
await expect(
runMatrixQaOpenClawCli({
args: ["matrix", "verify", "self"],
cwd: root,
env: process.env,
timeoutMs: 250,
}),
).rejects.toThrow(/stderr:\nmatrix sdk still syncing/);
} finally {
await rm(root, { force: true, recursive: true });
}
});
});

View File

@@ -146,7 +146,15 @@ export function startMatrixQaOpenClawCli(params: {
child.kill("SIGTERM");
finish(
result,
new Error(`${formatMatrixQaCliCommand(params.args)} timed out after ${params.timeoutMs}ms`),
new Error(
[
`${formatMatrixQaCliCommand(params.args)} timed out after ${params.timeoutMs}ms`,
result.stderr.trim() ? `stderr:\n${redactMatrixQaCliOutput(result.stderr.trim())}` : null,
result.stdout.trim() ? `stdout:\n${redactMatrixQaCliOutput(result.stdout.trim())}` : null,
]
.filter(Boolean)
.join("\n"),
),
);
}, params.timeoutMs);

View File

@@ -0,0 +1,86 @@
import { randomUUID } from "node:crypto";
import { readFile, rename, writeFile } from "node:fs/promises";
export function isMatrixQaPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function requireMatrixQaGatewayConfigObject(config: unknown): Record<string, unknown> {
if (!isMatrixQaPlainRecord(config)) {
throw new Error("Matrix QA gateway config file must contain an object");
}
return config;
}
async function readMatrixQaGatewayConfigFile(configPath: string) {
return requireMatrixQaGatewayConfigObject(
JSON.parse(await readFile(configPath, "utf8")) as unknown,
);
}
async function writeMatrixQaGatewayConfigFile(configPath: string, config: unknown) {
const tempPath = `${configPath}.${randomUUID()}.tmp`;
await writeFile(tempPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
await rename(tempPath, configPath);
}
export async function readMatrixQaGatewayMatrixAccount(params: {
accountId: string;
configPath: string;
}) {
const config = await readMatrixQaGatewayConfigFile(params.configPath);
const channels = isMatrixQaPlainRecord(config.channels) ? config.channels : {};
const matrix = isMatrixQaPlainRecord(channels.matrix) ? channels.matrix : {};
const accounts = isMatrixQaPlainRecord(matrix.accounts) ? matrix.accounts : {};
const account = accounts[params.accountId];
if (!isMatrixQaPlainRecord(account)) {
throw new Error(`Matrix QA gateway account "${params.accountId}" missing from config`);
}
return account;
}
export async function replaceMatrixQaGatewayMatrixAccount(params: {
accountConfig: Record<string, unknown>;
accountId: string;
configPath: string;
}) {
const config = await readMatrixQaGatewayConfigFile(params.configPath);
const channels = isMatrixQaPlainRecord(config.channels) ? config.channels : {};
const matrix = isMatrixQaPlainRecord(channels.matrix) ? channels.matrix : {};
channels.matrix = {
...matrix,
defaultAccount: params.accountId,
accounts: {
[params.accountId]: params.accountConfig,
},
};
config.channels = channels;
await writeMatrixQaGatewayConfigFile(params.configPath, config);
}
export async function patchMatrixQaGatewayMatrixAccount(params: {
accountId: string;
accountPatch: Record<string, unknown>;
configPath: string;
}) {
const config = await readMatrixQaGatewayConfigFile(params.configPath);
const channels = isMatrixQaPlainRecord(config.channels) ? config.channels : {};
const matrix = isMatrixQaPlainRecord(channels.matrix) ? channels.matrix : {};
const accounts = isMatrixQaPlainRecord(matrix.accounts) ? matrix.accounts : {};
const existing = accounts[params.accountId];
if (!isMatrixQaPlainRecord(existing)) {
throw new Error(`Matrix QA gateway account "${params.accountId}" missing from config`);
}
channels.matrix = {
...matrix,
defaultAccount: params.accountId,
accounts: {
[params.accountId]: {
...existing,
...params.accountPatch,
},
},
};
config.channels = channels;
await writeMatrixQaGatewayConfigFile(params.configPath, config);
}

View File

@@ -30,9 +30,11 @@ export type MatrixQaScenarioContext = {
gatewayRuntimeEnv?: NodeJS.ProcessEnv;
gatewayStateDir?: string;
outputDir?: string;
registrationToken?: string;
restartGateway?: () => Promise<void>;
restartGatewayAfterStateMutation?: (
mutateState: (context: { stateDir: string }) => Promise<void>,
opts?: { timeoutMs?: number; waitAccountId?: string },
) => Promise<void>;
restartGatewayWithQueuedMessage?: (queueMessage: () => Promise<void>) => Promise<void>;
roomId: string;
@@ -50,6 +52,7 @@ export type MatrixQaScenarioContext = {
patch: Record<string, unknown>,
opts?: { restartDelayMs?: number },
) => Promise<void>;
waitGatewayAccountReady?: (accountId: string, opts?: { timeoutMs?: number }) => Promise<void>;
};
export const NO_REPLY_WINDOW_MS = 8_000;

View File

@@ -98,18 +98,22 @@ export async function rewriteMatrixSyncStoreCursor(params: { cursor: string; pat
}
async function scoreMatrixStateFile(params: {
accountId?: string;
context: MatrixQaScenarioContext;
pathname: string;
userId?: string;
}) {
let score = params.pathname.includes(`${path.sep}matrix${path.sep}`) ? 4 : 0;
const expectedUserId = params.userId ?? params.context.sutUserId;
const expectedAccountId = params.accountId ?? params.context.sutAccountId;
try {
const metadata = await readJsonFile(
path.join(path.dirname(params.pathname), "storage-meta.json"),
);
if (isRecord(metadata) && metadata.userId === params.context.sutUserId) {
if (isRecord(metadata) && metadata.userId === expectedUserId) {
score += 16;
}
if (isRecord(metadata) && metadata.accountId === params.context.sutAccountId) {
if (isRecord(metadata) && metadata.accountId === expectedAccountId) {
score += 8;
}
} catch {
@@ -119,9 +123,11 @@ async function scoreMatrixStateFile(params: {
}
async function resolveBestMatrixStateFile(params: {
accountId?: string;
context: MatrixQaScenarioContext;
filename: string;
stateDir: string;
userId?: string;
}) {
const candidates = await findFilesByName({
filename: params.filename,
@@ -136,6 +142,8 @@ async function resolveBestMatrixStateFile(params: {
score: await scoreMatrixStateFile({
context: params.context,
pathname,
...(params.accountId ? { accountId: params.accountId } : {}),
...(params.userId ? { userId: params.userId } : {}),
}),
})),
);
@@ -144,9 +152,11 @@ async function resolveBestMatrixStateFile(params: {
}
export async function waitForMatrixSyncStoreWithCursor(params: {
accountId?: string;
context: MatrixQaScenarioContext;
stateDir: string;
timeoutMs: number;
userId?: string;
}) {
const startedAt = Date.now();
let lastPath: string | null = null;
@@ -155,6 +165,8 @@ export async function waitForMatrixSyncStoreWithCursor(params: {
context: params.context,
filename: MATRIX_SYNC_STORE_FILENAME,
stateDir: params.stateDir,
...(params.accountId ? { accountId: params.accountId } : {}),
...(params.userId ? { userId: params.userId } : {}),
});
lastPath = pathname;
if (pathname) {

View File

@@ -14,6 +14,7 @@ import {
runMatrixQaE2eeServerBackupDeletedLocalStateIntactScenario,
runMatrixQaE2eeServerBackupDeletedLocalReuploadRestoresScenario,
runMatrixQaE2eeServerDeviceDeletedLocalStateIntactScenario,
runMatrixQaE2eeServerDeviceDeletedReloginRecoversScenario,
runMatrixQaE2eeStaleRecoveryKeyAfterBackupResetScenario,
runMatrixQaE2eeStateLossExternalRecoveryKeyScenario,
runMatrixQaE2eeStateLossNoRecoveryKeyScenario,
@@ -25,6 +26,14 @@ import {
runMatrixQaE2eeArtifactRedactionScenario,
runMatrixQaE2eeBasicReplyScenario,
runMatrixQaE2eeBootstrapSuccessScenario,
runMatrixQaE2eeCliAccountAddEnableE2eeScenario,
runMatrixQaE2eeCliEncryptionSetupBootstrapFailureScenario,
runMatrixQaE2eeCliEncryptionSetupIdempotentScenario,
runMatrixQaE2eeCliEncryptionSetupMultiAccountScenario,
runMatrixQaE2eeCliEncryptionSetupScenario,
runMatrixQaE2eeCliRecoveryKeyInvalidScenario,
runMatrixQaE2eeCliRecoveryKeySetupScenario,
runMatrixQaE2eeCliSetupThenGatewayReplyScenario,
runMatrixQaE2eeCliSelfVerificationScenario,
runMatrixQaE2eeDeviceSasVerificationScenario,
runMatrixQaE2eeDmSasVerificationScenario,
@@ -325,6 +334,22 @@ export async function runMatrixQaScenario(
return await runMatrixQaE2eeRecoveryKeyLifecycleScenario(context);
case "matrix-e2ee-recovery-owner-verification-required":
return await runMatrixQaE2eeRecoveryOwnerVerificationRequiredScenario(context);
case "matrix-e2ee-cli-account-add-enable-e2ee":
return await runMatrixQaE2eeCliAccountAddEnableE2eeScenario(context);
case "matrix-e2ee-cli-encryption-setup":
return await runMatrixQaE2eeCliEncryptionSetupScenario(context);
case "matrix-e2ee-cli-encryption-setup-idempotent":
return await runMatrixQaE2eeCliEncryptionSetupIdempotentScenario(context);
case "matrix-e2ee-cli-encryption-setup-bootstrap-failure":
return await runMatrixQaE2eeCliEncryptionSetupBootstrapFailureScenario(context);
case "matrix-e2ee-cli-recovery-key-setup":
return await runMatrixQaE2eeCliRecoveryKeySetupScenario(context);
case "matrix-e2ee-cli-recovery-key-invalid":
return await runMatrixQaE2eeCliRecoveryKeyInvalidScenario(context);
case "matrix-e2ee-cli-encryption-setup-multi-account":
return await runMatrixQaE2eeCliEncryptionSetupMultiAccountScenario(context);
case "matrix-e2ee-cli-setup-then-gateway-reply":
return await runMatrixQaE2eeCliSetupThenGatewayReplyScenario(context);
case "matrix-e2ee-cli-self-verification":
return await runMatrixQaE2eeCliSelfVerificationScenario(context);
case "matrix-e2ee-state-loss-external-recovery-key":
@@ -343,6 +368,8 @@ export async function runMatrixQaScenario(
return await runMatrixQaE2eeCorruptCryptoIdbSnapshotScenario(context);
case "matrix-e2ee-server-device-deleted-local-state-intact":
return await runMatrixQaE2eeServerDeviceDeletedLocalStateIntactScenario(context);
case "matrix-e2ee-server-device-deleted-relogin-recovers":
return await runMatrixQaE2eeServerDeviceDeletedReloginRecoversScenario(context);
case "matrix-e2ee-sync-state-loss-crypto-intact":
return await runMatrixQaE2eeSyncStateLossCryptoIntactScenario(context);
case "matrix-e2ee-wrong-account-recovery-key":

View File

@@ -30,6 +30,7 @@ export type MatrixQaScenarioArtifacts = {
attachmentFilename?: string;
attachmentKind?: string;
attachmentMsgtype?: string;
accountId?: string;
actorUserId?: string;
blocked?: MatrixQaScenarioArtifacts;
catchupDriverEventId?: string;
@@ -38,6 +39,7 @@ export type MatrixQaScenarioArtifacts = {
dedupeCommitObserved?: boolean;
duplicateWindowMs?: number;
driverEventId?: string;
driverUserId?: string;
editEventId?: string;
editedToken?: string;
expectedNoReplyWindowMs?: number;
@@ -101,6 +103,8 @@ export type MatrixQaScenarioArtifacts = {
backupRestored?: boolean;
backupReset?: boolean;
completedVerificationId?: string;
backupVersion?: string | null;
cliDeviceId?: string | null;
completedVerificationIds?: string[];
currentDeviceId?: string | null;
accountRoot?: string;
@@ -117,7 +121,11 @@ export type MatrixQaScenarioArtifacts = {
qrBytes?: number;
recoveryDeviceId?: string;
recoveryKeyPreserved?: boolean;
decoyAccountPreserved?: boolean;
defaultAccountPreserved?: boolean;
recoveryKeyAccepted?: boolean;
recoveryKeyId?: string | null;
recoveryKeyRejected?: boolean;
recoveryKeyStored?: boolean;
rotatedRecoveryKeyId?: string | null;
remainingDeviceIds?: string[];
@@ -132,9 +140,21 @@ export type MatrixQaScenarioArtifacts = {
replyEventId?: string;
statusError?: string;
statusExitCode?: number;
defaultStatusError?: string;
defaultStatusExitCode?: number;
serverDeviceKnown?: boolean | null;
replacementDeviceId?: string;
selfVerificationTransactionId?: string | null;
transportInterruption?: string;
encryptionChanged?: boolean;
encryptionEnabled?: boolean;
firstEncryptionChanged?: boolean;
gatewayUserId?: string;
secondEncryptionChanged?: boolean;
setupSuccess?: boolean;
verificationBootstrapAttempted?: boolean;
verificationBootstrapSuccess?: boolean;
gatewayReply?: MatrixQaReplyArtifact;
verificationRoomId?: string;
joinedRoomId?: string;
localEventId?: string;

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@ describe("matrix qa e2ee client storage", () => {
});
});
it("shares persisted crypto by actor and scopes sync replay by scenario", () => {
it("shares persisted crypto and sync state by actor account", () => {
const first = __testing.buildMatrixQaE2eeStoragePaths({
actorId: "driver",
outputDir: "/tmp/openclaw/.artifacts/qa-e2e/matrix-run",
@@ -34,27 +34,42 @@ describe("matrix qa e2ee client storage", () => {
);
expect(first.cryptoDatabasePrefix).toBe(second.cryptoDatabasePrefix);
expect(first.recoveryKeyPath).toBe(path.join(first.accountDir, "recovery-key.json"));
expect(first.storagePath).toBe(
path.join(
"/tmp/openclaw/.artifacts/qa-e2e/matrix-run",
"matrix-e2ee",
"accounts",
"driver",
"scenarios",
"matrix-e2ee-basic-reply",
"sync-store.json",
),
);
expect(second.storagePath).toBe(
path.join(
"/tmp/openclaw/.artifacts/qa-e2e/matrix-run",
"matrix-e2ee",
"accounts",
"driver",
"scenarios",
"matrix-e2ee-qr-verification",
"sync-store.json",
),
);
expect(first.storagePath).toBe(path.join(first.accountDir, "sync-store.json"));
expect(second.storagePath).toBe(first.storagePath);
});
it("records late-decrypted payload updates for an existing event id", () => {
const previous = {
eventId: "$reply",
kind: "message" as const,
roomId: "!room:matrix-qa.test",
sender: "@bot:matrix-qa.test",
type: "m.room.message",
};
expect(
__testing.shouldRecordMatrixQaObservedEventUpdate({
previous,
next: {
...previous,
body: "MATRIX_QA_E2EE_CLI_GATEWAY_OK",
msgtype: "m.text",
},
}),
).toBe(true);
expect(
__testing.shouldRecordMatrixQaObservedEventUpdate({
previous: {
...previous,
body: "MATRIX_QA_E2EE_CLI_GATEWAY_OK",
msgtype: "m.text",
},
next: {
...previous,
body: "MATRIX_QA_E2EE_CLI_GATEWAY_OK",
msgtype: "m.text",
},
}),
).toBe(false);
});
});

View File

@@ -21,7 +21,7 @@ import { findMatrixQaObservedEventMatch, normalizeMatrixQaObservedEvent } from "
import type { MatrixQaObservedEvent } from "./events.js";
import type { MatrixQaRoomEventWaitResult } from "./sync.js";
type MatrixQaE2eeActorId = "driver" | "observer" | `driver-${string}`;
type MatrixQaE2eeActorId = "driver" | "observer" | `driver-${string}` | `cli-${string}`;
type MatrixQaE2eeRuntime = typeof import("@openclaw/matrix/test-api.js");
@@ -43,6 +43,24 @@ const MATRIX_QA_E2EE_SYNC_FILTER = {
},
};
function shouldRecordMatrixQaObservedEventUpdate(params: {
next: MatrixQaObservedEvent;
previous: MatrixQaObservedEvent | undefined;
}) {
const previous = params.previous;
if (!previous) {
return true;
}
const next = params.next;
return (
(previous.body === undefined && next.body !== undefined) ||
(previous.formattedBody === undefined && next.formattedBody !== undefined) ||
(previous.msgtype === undefined && next.msgtype !== undefined) ||
(previous.mentions === undefined && next.mentions !== undefined) ||
(previous.attachment === undefined && next.attachment !== undefined)
);
}
export type MatrixQaE2eeScenarioClient = {
acceptVerification(id: string): Promise<MatrixVerificationSummary>;
bootstrapOwnDeviceVerification(params?: {
@@ -111,6 +129,7 @@ export type MatrixQaE2eeScenarioClient = {
roomId: string;
timeoutMs: number;
}): Promise<MatrixQaRoomEventWaitResult>;
waitForJoinedMember(params: { roomId: string; timeoutMs: number; userId: string }): Promise<void>;
waitForRoomEvent(params: {
predicate: (event: MatrixQaObservedEvent) => boolean;
roomId: string;
@@ -134,7 +153,6 @@ function buildMatrixQaE2eeStoragePaths(params: {
}) {
const rootDir = path.join(params.outputDir, "matrix-e2ee", "accounts", params.actorId);
const accountDir = path.join(rootDir, "account");
const scenarioKey = params.scenarioId.replace(/[^A-Za-z0-9_-]/g, "-").slice(-80);
const runKey = path
.basename(params.outputDir)
.replace(/[^A-Za-z0-9_-]/g, "-")
@@ -146,7 +164,7 @@ function buildMatrixQaE2eeStoragePaths(params: {
idbSnapshotPath: path.join(accountDir, "crypto-idb-snapshot.json"),
recoveryKeyPath: path.join(accountDir, "recovery-key.json"),
rootDir,
storagePath: path.join(rootDir, "scenarios", scenarioKey || "scenario", "sync-store.json"),
storagePath: path.join(accountDir, "sync-store.json"),
};
}
@@ -198,15 +216,21 @@ export async function createMatrixQaE2eeScenarioClient(
const client: MatrixClient = await createMatrixQaE2eeMatrixClient(params);
const localEvents: MatrixQaObservedEvent[] = [];
const verificationSummaries: MatrixVerificationSummary[] = [];
const observedEventIds = new Set<string>();
const observedEventsById = new Map<string, MatrixQaObservedEvent>();
let cursorIndex = 0;
const recordEvent = (roomId: string, event: MatrixRawEvent) => {
const normalized = normalizeMatrixQaObservedEvent(roomId, event);
if (!normalized || observedEventIds.has(normalized.eventId)) {
if (
!normalized ||
!shouldRecordMatrixQaObservedEventUpdate({
next: normalized,
previous: observedEventsById.get(normalized.eventId),
})
) {
return;
}
observedEventIds.add(normalized.eventId);
observedEventsById.set(normalized.eventId, normalized);
localEvents.push(normalized);
params.observedEvents.push(normalized);
};
@@ -300,6 +324,18 @@ export async function createMatrixQaE2eeScenarioClient(
);
},
prime,
async waitForJoinedMember(opts) {
const startedAt = Date.now();
while (Date.now() - startedAt < opts.timeoutMs) {
if (client.hasSyncedJoinedRoomMember(opts.roomId, opts.userId)) {
return;
}
await sleep(Math.min(250, Math.max(25, opts.timeoutMs - (Date.now() - startedAt))));
}
throw new Error(
`Matrix E2EE client did not sync joined membership for ${opts.userId} in ${opts.roomId}`,
);
},
async requestVerification(opts) {
return await requireCrypto().requestVerification(opts);
},
@@ -388,4 +424,5 @@ export const __testing = {
MATRIX_QA_E2EE_SYNC_FILTER,
buildMatrixQaE2eeStoragePaths,
findMatrixQaObservedEventMatch,
shouldRecordMatrixQaObservedEventUpdate,
};