From 99159f89da03f296c32c3144edf1979c719ca25c Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 26 Apr 2026 21:47:06 -0400 Subject: [PATCH] fix(matrix): stabilize e2ee qa flows --- docs/channels/matrix.md | 29 +- extensions/matrix/index.test.ts | 1 + extensions/matrix/index.ts | 13 - extensions/matrix/src/channel.setup.test.ts | 40 + extensions/matrix/src/cli.test.ts | 263 ++ extensions/matrix/src/cli.ts | 189 +- .../matrix/src/matrix/actions/client.test.ts | 17 + .../matrix/src/matrix/actions/client.ts | 2 +- .../src/matrix/actions/verification.test.ts | 205 +- .../matrix/src/matrix/actions/verification.ts | 85 +- .../matrix/src/matrix/client-bootstrap.ts | 6 +- .../matrix/client-resolver.test-helpers.ts | 3 +- extensions/matrix/src/matrix/client/shared.ts | 4 +- extensions/matrix/src/matrix/deps.test.ts | 88 + extensions/matrix/src/matrix/deps.ts | 101 +- .../matrix/src/matrix/monitor/events.test.ts | 26 + .../matrix/src/matrix/monitor/events.ts | 12 + extensions/matrix/src/matrix/sdk.test.ts | 282 +- extensions/matrix/src/matrix/sdk.ts | 202 +- .../matrix/src/matrix/sdk/decrypt-bridge.ts | 76 +- .../matrix/src/onboarding.test-harness.ts | 1 + extensions/matrix/src/setup-bootstrap.ts | 14 +- .../src/providers/mock-openai/server.test.ts | 123 + .../src/providers/mock-openai/server.ts | 21 +- .../src/runners/contract/runtime.test.ts | 79 +- .../qa-matrix/src/runners/contract/runtime.ts | 123 +- .../src/runners/contract/scenario-catalog.ts | 105 +- .../contract/scenario-runtime-cli.test.ts | 36 + .../runners/contract/scenario-runtime-cli.ts | 10 +- .../contract/scenario-runtime-config.ts | 86 + .../scenario-runtime-e2ee-destructive.ts | 745 +++--- .../runners/contract/scenario-runtime-e2ee.ts | 2261 ++++++++++++++--- .../contract/scenario-runtime-shared.ts | 3 + .../contract/scenario-runtime-state-files.ts | 16 +- .../src/runners/contract/scenario-runtime.ts | 27 + .../src/runners/contract/scenario-types.ts | 20 + .../src/runners/contract/scenarios.test.ts | 1848 +++++++++++++- .../src/substrate/e2ee-client.test.ts | 61 +- .../qa-matrix/src/substrate/e2ee-client.ts | 49 +- 39 files changed, 6348 insertions(+), 924 deletions(-) create mode 100644 extensions/qa-matrix/src/runners/contract/scenario-runtime-config.ts diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 8dcb4b9cc4f..8fc47b90383 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -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`. `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 { diff --git a/extensions/matrix/index.test.ts b/extensions/matrix/index.test.ts index c2327759679..487ac02ab48 100644 --- a/extensions/matrix/index.test.ts +++ b/extensions/matrix/index.test.ts @@ -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", diff --git a/extensions/matrix/index.ts b/extensions/matrix/index.ts index d0beb081675..39d0aee6c24 100644 --- a/extensions/matrix/index.ts +++ b/extensions/matrix/index.ts @@ -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); diff --git a/extensions/matrix/src/channel.setup.test.ts b/extensions/matrix/src/channel.setup.test.ts index 18e9bdc6e10..a4c8944222e 100644 --- a/extensions/matrix/src/channel.setup.test.ts +++ b/extensions/matrix/src/channel.setup.test.ts @@ -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".'); }, diff --git a/extensions/matrix/src/cli.test.ts b/extensions/matrix/src/cli.test.ts index 3eef5ab5e90..435abf69ef1 100644 --- a/extensions/matrix/src/cli.test.ts +++ b/extensions/matrix/src/cli.test.ts @@ -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; accountId: string }) => ({ + ...cfg, + channels: { + ...(cfg.channels as Record | 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; accountId: string }) => ({ + ...cfg, + channels: { + ...(cfg.channels as Record | 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", diff --git a/extensions/matrix/src/cli.ts b/extensions/matrix/src/cli.ts index 9c3d3f4d6ad..1ea87b30ece 100644 --- a/extensions/matrix/src/cli.ts +++ b/extensions/matrix/src/cli.ts @@ -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 { 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; }; +type MatrixCliVerificationBootstrap = Awaited>; + +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 { + 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 ", "Matrix password") .option("--device-name ", "Matrix device display name") .option("--initial-sync-limit ", "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__* 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__* 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 ", "Account ID (for multi-account setups)") + .option("--recovery-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 ", "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); diff --git a/extensions/matrix/src/matrix/actions/client.test.ts b/extensions/matrix/src/matrix/actions/client.test.ts index c7d0e7ecd90..f7a6b9063e4 100644 --- a/extensions/matrix/src/matrix/actions/client.test.ts +++ b/extensions/matrix/src/matrix/actions/client.test.ts @@ -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); diff --git a/extensions/matrix/src/matrix/actions/client.ts b/extensions/matrix/src/matrix/actions/client.ts index b4327434603..f80a74e466d 100644 --- a/extensions/matrix/src/matrix/actions/client.ts +++ b/extensions/matrix/src/matrix/actions/client.ts @@ -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( opts: MatrixActionClientOpts, diff --git a/extensions/matrix/src/matrix/actions/verification.test.ts b/extensions/matrix/src/matrix/actions/verification.test.ts index 9e087e2de50..eb302f6b8ee 100644 --- a/extensions/matrix/src/matrix/actions/verification.test.ts +++ b/extensions/matrix/src/matrix/actions/verification.test.ts @@ -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(() => 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 () => { diff --git a/extensions/matrix/src/matrix/actions/verification.ts b/extensions/matrix/src/matrix/actions/verification.ts index 9f7eb51d929..50077f9c6fc 100644 --- a/extensions/matrix/src/matrix/actions/verification.ts +++ b/extensions/matrix/src/matrix/actions/verification.ts @@ -173,17 +173,17 @@ async function waitForMatrixSelfVerificationTrustStatus(params: { timeoutMs: number; }): Promise { 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 { - 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 = {}) { diff --git a/extensions/matrix/src/matrix/client-bootstrap.ts b/extensions/matrix/src/matrix/client-bootstrap.ts index 82c5890e7be..04b5d9b7ef8 100644 --- a/extensions/matrix/src/matrix/client-bootstrap.ts +++ b/extensions/matrix/src/matrix/client-bootstrap.ts @@ -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(); } diff --git a/extensions/matrix/src/matrix/client-resolver.test-helpers.ts b/extensions/matrix/src/matrix/client-resolver.test-helpers.ts index c8a0fdd5a0c..f171f76393d 100644 --- a/extensions/matrix/src/matrix/client-resolver.test-helpers.ts +++ b/extensions/matrix/src/matrix/client-resolver.test-helpers.ts @@ -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, diff --git a/extensions/matrix/src/matrix/client/shared.ts b/extensions/matrix/src/matrix/client/shared.ts index 6201f901862..c622748d6fe 100644 --- a/extensions/matrix/src/matrix/client/shared.ts +++ b/extensions/matrix/src/matrix/client/shared.ts @@ -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 { 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(); } diff --git a/extensions/matrix/src/matrix/deps.test.ts b/extensions/matrix/src/matrix/deps.test.ts index c29d05d753f..6e3c10e1f6f 100644 --- a/extensions/matrix/src/matrix/deps.test.ts +++ b/extensions/matrix/src/matrix/deps.test.ts @@ -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", + ); + }); }); diff --git a/extensions/matrix/src/matrix/deps.ts b/extensions/matrix/src/matrix/deps.ts index a43aab8df2c..cb0f42c49c6 100644 --- a/extensions/matrix/src/matrix/deps.ts +++ b/extensions/matrix/src/matrix/deps.ts @@ -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 { @@ -170,6 +265,9 @@ export async function ensureMatrixCryptoRuntime( } async function ensureMatrixCryptoRuntimeOnce(params: MatrixCryptoRuntimeDeps): Promise { + 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"); } diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index f8b0b8cb4f1..998f2481600 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -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({ diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts index 64b9b6c8d93..4389583dddb 100644 --- a/extensions/matrix/src/matrix/monitor/events.ts +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -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( diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index 5afa07b896e..36f5acf5207 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -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; + private content: Record; 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 }): 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(() => undefined), + ); + vi.spyOn(client, "getDeviceVerificationStatus").mockImplementation( + async () => await new Promise(() => undefined), + ); + vi.spyOn(client, "listOwnDevices").mockImplementation( + async () => await new Promise(() => 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))); diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index dc54714985b..9b3d4ed222d 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -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( + promise: Promise, + timeoutMs: number, +): Promise { + const result = await resolveMatrixDiagnosticResult(promise, timeoutMs); + return result.value; +} + +async function resolveMatrixDiagnosticResult( + promise: Promise, + timeoutMs: number, +): Promise<{ error: unknown; timedOut: boolean; value: T | null }> { + let timeoutId: ReturnType | 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 { if (!this.encryptionEnabled || !this.cryptoInitialized || this.cryptoBootstrapped) { return; @@ -731,7 +820,9 @@ export class MatrixClient { } async getJoinedRooms(): Promise { - 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; diff --git a/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts b/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts index 411a09169e1..ed17317783f 100644 --- a/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts +++ b/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts @@ -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 { private readonly trackedEncryptedEvents = new WeakSet(); private readonly decryptedMessageDedupe = new Map(); private readonly decryptRetries = new Map(); private readonly failedDecryptionsNotified = new Set(); + private readonly exhaustedDecryptRetries = new Set(); private activeRetryRuns = 0; private readonly retryIdleResolvers = new Set<() => void>(); private cryptoRetrySignalsBound = false; @@ -91,6 +115,11 @@ export class MatrixDecryptBridge { 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 { 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 { 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 { 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 { 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 { clearTimeout(state.timer); } this.decryptRetries.delete(retryKey); + this.exhaustedDecryptRetries.delete(retryKey); this.failedDecryptionsNotified.delete(retryKey); } diff --git a/extensions/matrix/src/onboarding.test-harness.ts b/extensions/matrix/src/onboarding.test-harness.ts index 8c3dc3f925a..79e9de59fa7 100644 --- a/extensions/matrix/src/onboarding.test-harness.ts +++ b/extensions/matrix/src/onboarding.test-harness.ts @@ -295,6 +295,7 @@ export function createMatrixNamedAccountsConfig(params: { { homeserver: string; accessToken?: string; + encryption?: boolean; } >; }): CoreConfig { diff --git a/extensions/matrix/src/setup-bootstrap.ts b/extensions/matrix/src/setup-bootstrap.ts index 4c893824f7f..caf29c9468a 100644 --- a/extensions/matrix/src/setup-bootstrap.ts +++ b/extensions/matrix/src/setup-bootstrap.ts @@ -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, diff --git a/extensions/qa-lab/src/providers/mock-openai/server.test.ts b/extensions/qa-lab/src/providers/mock-openai/server.test.ts index 144b8fcee48..dfd10dd1460 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.test.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.test.ts @@ -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", diff --git a/extensions/qa-lab/src/providers/mock-openai/server.ts b/extensions/qa-lab/src/providers/mock-openai/server.ts index 290feb96f58..a6c3aa9d385 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.ts @@ -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", diff --git a/extensions/qa-matrix/src/runners/contract/runtime.test.ts b/extensions/qa-matrix/src/runners/contract/runtime.test.ts index 4769320b62b..3b7c781d713 100644 --- a/extensions/qa-matrix/src/runners/contract/runtime.test.ts +++ b/extensions/qa-matrix/src/runners/contract/runtime.test.ts @@ -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); diff --git a/extensions/qa-matrix/src/runners/contract/runtime.ts b/extensions/qa-matrix/src/runners/contract/runtime.ts index eaf5ad806a1..216a9edd9bf 100644 --- a/extensions/qa-matrix/src/runners/contract/runtime.ts +++ b/extensions/qa-matrix/src/runners/contract/runtime.ts @@ -54,7 +54,7 @@ const DEFAULT_MATRIX_QA_CLEANUP_TIMEOUT_MS = 90_000; type MatrixQaLiveLaneGatewayHarness = { gateway: MatrixQaGatewayChild; - stop(): Promise; + stop(opts?: { keepTemp?: boolean; preserveToDir?: string }): Promise; }; 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(); + 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; 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, diff --git a/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts b/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts index 616fac0fd7a..0c5c468a611 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts @@ -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, diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.test.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.test.ts index 524301d940d..38261818890 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.test.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.test.ts @@ -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 }); + } + }); }); diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts index 1b01b6a8356..817d0e4639e 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts @@ -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); diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-config.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-config.ts new file mode 100644 index 00000000000..14346325739 --- /dev/null +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-config.ts @@ -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 { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function requireMatrixQaGatewayConfigObject(config: unknown): Record { + 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; + 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; + 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); +} diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-e2ee-destructive.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-e2ee-destructive.ts index e5507abc82d..b177dbf6126 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-e2ee-destructive.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-e2ee-destructive.ts @@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto"; import { chmod, copyFile, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; -import type { MatrixVerificationSummary } from "@openclaw/matrix/test-api.js"; import { createMatrixQaClient } from "../../substrate/client.js"; import { createMatrixQaE2eeScenarioClient, @@ -12,7 +11,6 @@ import { requestMatrixJson } from "../../substrate/request.js"; import { buildMatrixQaE2eeScenarioRoomKey, type MatrixQaE2eeScenarioId, - resolveMatrixQaScenarioRoomId, } from "./scenario-catalog.js"; import { createMatrixQaOpenClawCliRuntime, @@ -20,6 +18,10 @@ import { redactMatrixQaCliOutput, type MatrixQaCliRunResult, } from "./scenario-runtime-cli.js"; +import { + readMatrixQaGatewayMatrixAccount, + replaceMatrixQaGatewayMatrixAccount, +} from "./scenario-runtime-config.js"; import { assertTopLevelReplyArtifact, buildMentionPrompt, @@ -69,6 +71,10 @@ type MatrixQaCliVerificationStatus = { type MatrixQaDestructiveSetup = { encodedRecoveryKey: string; owner: MatrixQaE2eeScenarioClient; + ownerAccessToken: string; + ownerDeviceId: string; + ownerPassword: string; + ownerUserId: string; recoveryKeyId: string | null; roomId: string; roomKey: string; @@ -91,6 +97,14 @@ function requireMatrixQaCliRuntimeEnv(context: MatrixQaScenarioContext) { return context.gatewayRuntimeEnv; } +function requireMatrixQaGatewayConfigPath(context: MatrixQaScenarioContext) { + const configPath = requireMatrixQaCliRuntimeEnv(context).OPENCLAW_CONFIG_PATH?.trim(); + if (!configPath) { + throw new Error("Matrix E2EE destructive QA scenarios require the gateway config path"); + } + return configPath; +} + function requireMatrixQaPassword(context: MatrixQaScenarioContext, actor: "driver" | "observer") { const password = actor === "driver" ? context.driverPassword : context.observerPassword; if (!password) { @@ -99,15 +113,12 @@ function requireMatrixQaPassword(context: MatrixQaScenarioContext, actor: "drive return password; } -function resolveMatrixQaE2eeScenarioGroupRoom( - context: MatrixQaScenarioContext, - scenarioId: MatrixQaE2eeScenarioId, -) { - const roomKey = buildMatrixQaE2eeScenarioRoomKey(scenarioId); - return { - roomKey, - roomId: resolveMatrixQaScenarioRoomId(context, roomKey), - }; +function requireMatrixQaRegistrationToken(context: MatrixQaScenarioContext) { + const token = context.registrationToken?.trim(); + if (!token) { + throw new Error("Matrix E2EE destructive QA scenarios require a registration token"); + } + return token; } async function createMatrixQaDriverPersistentClient( @@ -128,6 +139,51 @@ async function createMatrixQaDriverPersistentClient( }); } +async function registerMatrixQaDestructiveOwner( + context: MatrixQaScenarioContext, + scenarioId: MatrixQaE2eeScenarioId, +) { + const localpartSuffix = scenarioId + .replace(/^matrix-e2ee-/, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 24); + const account = await createMatrixQaClient({ baseUrl: context.baseUrl }).registerWithToken({ + deviceName: "OpenClaw Matrix QA Destructive Owner", + localpart: `qa-destructive-${localpartSuffix}-${randomUUID().replaceAll("-", "").slice(0, 8)}`, + password: `matrix-qa-${randomUUID()}`, + registrationToken: requireMatrixQaRegistrationToken(context), + }); + if (!account.deviceId) { + throw new Error( + `Matrix destructive QA registration for ${scenarioId} did not return a device id`, + ); + } + return { + ...account, + deviceId: account.deviceId, + }; +} + +async function createMatrixQaDestructiveOwnerClient(params: { + account: Awaited>; + context: MatrixQaScenarioContext; + scenarioId: MatrixQaE2eeScenarioId; +}) { + return await createMatrixQaE2eeScenarioClient({ + accessToken: params.account.accessToken, + actorId: `driver-destructive-${randomUUID().slice(0, 8)}`, + baseUrl: params.context.baseUrl, + deviceId: params.account.deviceId, + observedEvents: params.context.observedEvents, + outputDir: requireMatrixQaE2eeOutputDir(params.context), + password: params.account.password, + scenarioId: params.scenarioId, + timeoutMs: params.context.timeoutMs, + userId: params.account.userId, + }); +} + async function ensureMatrixQaOwnerReady(params: { allowCrossSigningResetOnRepair?: boolean; client: MatrixQaE2eeScenarioClient; @@ -193,10 +249,20 @@ async function prepareMatrixQaDestructiveSetup( context: MatrixQaScenarioContext, scenarioId: MatrixQaE2eeScenarioId, ): Promise { - const owner = await createMatrixQaDriverPersistentClient(context, scenarioId); + const account = await registerMatrixQaDestructiveOwner(context, scenarioId); + const setupClient = createMatrixQaClient({ + accessToken: account.accessToken, + baseUrl: context.baseUrl, + }); + const roomKey = buildMatrixQaE2eeScenarioRoomKey(scenarioId); + const roomId = await setupClient.createPrivateRoom({ + encrypted: true, + inviteUserIds: [], + name: `Matrix QA ${scenarioId}`, + }); + const owner = await createMatrixQaDestructiveOwnerClient({ account, context, scenarioId }); try { - const ready = await ensureMatrixQaOwnerReady({ client: owner, label: "driver" }); - const { roomId, roomKey } = resolveMatrixQaE2eeScenarioGroupRoom(context, scenarioId); + const ready = await ensureMatrixQaOwnerReady({ client: owner, label: "destructive owner" }); const seededEventId = await owner.sendTextMessage({ body: `E2EE destructive restore seed ${randomUUID().slice(0, 8)}`, roomId, @@ -204,6 +270,10 @@ async function prepareMatrixQaDestructiveSetup( return { encodedRecoveryKey: ready.encodedRecoveryKey, owner, + ownerAccessToken: account.accessToken, + ownerDeviceId: account.deviceId, + ownerPassword: account.password, + ownerUserId: account.userId, recoveryKeyId: ready.recoveryKeyId, roomId, roomKey, @@ -324,193 +394,6 @@ async function runMatrixQaCliJson(params: { }; } -async function waitForMatrixQaVerificationSummary(params: { - client: MatrixQaE2eeScenarioClient; - label: string; - predicate: (summary: MatrixVerificationSummary) => boolean; - timeoutMs: number; -}) { - const startedAt = Date.now(); - while (Date.now() - startedAt < params.timeoutMs) { - const summaries = await params.client.listVerifications(); - const found = summaries.find(params.predicate); - if (found) { - return found; - } - await new Promise((resolve) => - setTimeout(resolve, Math.min(250, Math.max(25, params.timeoutMs - (Date.now() - startedAt)))), - ); - } - throw new Error(`timed out waiting for Matrix verification summary: ${params.label}`); -} - -function parseMatrixQaCliSummaryField(text: string, field: string): string | null { - const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return text.match(new RegExp(`^${escaped}:\\s*(.+)$`, "m"))?.[1]?.trim() ?? null; -} - -function parseMatrixQaCliSasText( - text: string, - label: string, -): { kind: "emoji"; value: string } | { kind: "decimal"; value: string } { - const emoji = text.match(/^SAS emoji:\s*(.+)$/m)?.[1]?.trim(); - if (emoji) { - return { kind: "emoji", value: emoji }; - } - const decimal = text.match(/^SAS decimals:\s*(.+)$/m)?.[1]?.trim(); - if (decimal) { - return { kind: "decimal", value: decimal }; - } - throw new Error(`${label} did not print SAS emoji or decimals`); -} - -function formatMatrixQaSasEmoji(summary: MatrixVerificationSummary) { - return summary.sas?.emoji?.map(([emoji, label]) => `${emoji} ${label}`) ?? []; -} - -function assertMatrixQaCliSasMatches(params: { - cliSas: ReturnType; - owner: MatrixVerificationSummary; -}) { - if (params.cliSas.kind === "emoji") { - const ownerEmoji = formatMatrixQaSasEmoji(params.owner).join(" | "); - if (!ownerEmoji) { - throw new Error("Matrix owner client did not expose SAS emoji"); - } - if (params.cliSas.value !== ownerEmoji) { - throw new Error("Matrix CLI SAS emoji did not match the owner client"); - } - return; - } - const ownerDecimal = params.owner.sas?.decimal?.join(" "); - if (!ownerDecimal) { - throw new Error("Matrix owner client did not expose SAS decimals"); - } - if (params.cliSas.value !== ownerDecimal) { - throw new Error("Matrix CLI SAS decimals did not match the owner client"); - } -} - -function isMatrixQaCliOwnerSelfVerification(params: { - cliDeviceId?: string; - driverUserId: string; - requireCompleted?: boolean; - requirePending?: boolean; - requireSas?: boolean; - summary: MatrixVerificationSummary; - transactionId?: string; -}) { - const summary = params.summary; - if ( - !summary.isSelfVerification || - summary.initiatedByMe || - summary.otherUserId !== params.driverUserId - ) { - return false; - } - if (params.transactionId) { - if (summary.transactionId !== params.transactionId) { - return false; - } - } else if (params.cliDeviceId && summary.otherDeviceId !== params.cliDeviceId) { - return false; - } - if (params.requirePending === true && !summary.pending) { - return false; - } - if (params.requireSas === true && !summary.hasSas) { - return false; - } - return params.requireCompleted !== true || summary.completed; -} - -async function runMatrixQaCliSelfVerificationWithOwner(params: { - accountId: string; - cli: MatrixQaCliRuntime; - cliDeviceId: string; - context: MatrixQaScenarioContext; - label: string; - owner: MatrixQaE2eeScenarioClient; -}) { - const session = params.cli.start(["matrix", "verify", "self", "--account", params.accountId], { - timeoutMs: params.context.timeoutMs, - }); - try { - const requestOutput = await session.waitForOutput( - (output) => output.text.includes("Accept this verification request"), - "self-verification request guidance", - params.context.timeoutMs, - ); - const cliTransactionId = parseMatrixQaCliSummaryField(requestOutput.text, "Transaction id"); - const ownerRequested = await waitForMatrixQaVerificationSummary({ - client: params.owner, - label: "owner received destructive CLI self-verification request", - predicate: (summary) => - isMatrixQaCliOwnerSelfVerification({ - cliDeviceId: cliTransactionId ? undefined : params.cliDeviceId, - driverUserId: params.context.driverUserId, - requirePending: true, - summary, - transactionId: cliTransactionId ?? undefined, - }), - timeoutMs: params.context.timeoutMs, - }); - if (ownerRequested.canAccept) { - await params.owner.acceptVerification(ownerRequested.id); - } - - const sasOutput = await session.waitForOutput( - (output) => /^SAS (?:emoji|decimals):/m.test(output.text), - "SAS emoji or decimals", - params.context.timeoutMs, - ); - const cliSas = parseMatrixQaCliSasText(sasOutput.text, params.label); - const ownerSas = await waitForMatrixQaVerificationSummary({ - client: params.owner, - label: "owner SAS for destructive CLI self-verification", - predicate: (summary) => - isMatrixQaCliOwnerSelfVerification({ - cliDeviceId: cliTransactionId ? undefined : params.cliDeviceId, - driverUserId: params.context.driverUserId, - requireSas: true, - summary, - transactionId: cliTransactionId ?? undefined, - }), - timeoutMs: params.context.timeoutMs, - }); - assertMatrixQaCliSasMatches({ cliSas, owner: ownerSas }); - await session.writeStdin("yes\n"); - await params.owner.confirmVerificationSas(ownerSas.id); - const completedCli = await session.wait(); - const selfVerificationArtifacts = await writeMatrixQaCliArtifacts({ - label: "verify-self", - result: completedCli, - runtime: params.cli, - }); - const completedOwner = await waitForMatrixQaVerificationSummary({ - client: params.owner, - label: "owner completed destructive CLI self-verification", - predicate: (summary) => - isMatrixQaCliOwnerSelfVerification({ - cliDeviceId: cliTransactionId ? undefined : params.cliDeviceId, - driverUserId: params.context.driverUserId, - requireCompleted: true, - summary, - transactionId: cliTransactionId ?? undefined, - }), - timeoutMs: params.context.timeoutMs, - }); - return { - completedCli, - completedOwner, - selfVerificationArtifacts, - transactionId: cliTransactionId ?? completedOwner.transactionId ?? null, - }; - } finally { - session.kill(); - } -} - function assertMatrixQaCliBackupRestoreSucceeded(restore: MatrixQaCliBackupStatus, label: string) { if (restore.success !== true) { throw new Error(`${label} backup restore failed: ${restore.error ?? "unknown error"}`); @@ -537,6 +420,35 @@ function assertMatrixQaCliBackupRestoreFailed( } } +function isMatrixQaVerifyStatusHealthy(status: { + payload: MatrixQaCliVerificationStatus; + result: MatrixQaCliRunResult; +}) { + return status.result.exitCode === 0 && status.payload.serverDeviceKnown !== false; +} + +function isMatrixQaDeletedDeviceStatus(params: { + ownerDeviceListContainsDeletedDevice: boolean; + status: { + payload: MatrixQaCliVerificationStatus; + result: MatrixQaCliRunResult; + }; +}) { + const authInvalidated = + params.status.result.exitCode !== 0 && + typeof params.status.payload.error === "string" && + (params.status.payload.error.includes("M_UNKNOWN_TOKEN") || + params.status.payload.error.toLowerCase().includes("access token")); + const deviceMissing = + params.status.payload.serverDeviceKnown === false || + !params.ownerDeviceListContainsDeletedDevice; + return { + authInvalidated, + deviceMissing, + invalidated: authInvalidated || deviceMissing, + }; +} + async function findFilesByName(params: { filename: string; rootDir: string }): Promise { const matches: string[] = []; async function visit(dir: string, depth: number): Promise { @@ -676,7 +588,6 @@ async function runMatrixQaExternalKeyRestore(params: { export async function runMatrixQaE2eeStateLossExternalRecoveryKeyScenario( context: MatrixQaScenarioContext, ): Promise { - const driverPassword = requireMatrixQaPassword(context, "driver"); const setup = await prepareMatrixQaDestructiveSetup( context, "matrix-e2ee-state-loss-external-recovery-key", @@ -686,8 +597,8 @@ export async function runMatrixQaE2eeStateLossExternalRecoveryKeyScenario( context, deviceName: "OpenClaw Matrix QA External Key Restore", label: "state-loss-external-recovery-key", - password: driverPassword, - userId: context.driverUserId, + password: setup.ownerPassword, + userId: setup.ownerUserId, }); try { const restored = await runMatrixQaCliJson({ @@ -707,82 +618,35 @@ export async function runMatrixQaE2eeStateLossExternalRecoveryKeyScenario( timeoutMs: context.timeoutMs, }); assertMatrixQaCliBackupRestoreSucceeded(restored.payload, "external recovery-key"); - const verification = await runMatrixQaCliJson({ - allowNonZero: true, - args: [ - "matrix", - "verify", - "device", - "--recovery-key-stdin", - "--account", - "external-key", - "--json", - ], - label: "verify-device-diagnostics", + const diagnostics = await runMatrixQaCliJson({ + args: ["matrix", "verify", "status", "--account", "external-key", "--json"], + label: "status-after-external-key-restore", runtime: cli, - stdin: `${setup.encodedRecoveryKey}\n`, timeoutMs: context.timeoutMs, }); const backupKeyLoaded = - verification.payload.backup?.matchesDecryptionKey === true && - verification.payload.backup?.decryptionKeyCached === true && - !verification.payload.backup?.keyLoadError; - const ownerVerificationRequired = - verification.payload.success === false && - verification.payload.deviceOwnerVerified === false && - verification.payload.crossSigningVerified === false && - verification.payload.error?.includes("full Matrix identity trust"); + diagnostics.payload.backup?.matchesDecryptionKey === true && + diagnostics.payload.backup?.decryptionKeyCached === true && + !diagnostics.payload.backup?.keyLoadError; const recoveryKeyCompletedIdentity = - verification.payload.success === true && - verification.payload.recoveryKeyAccepted === true && - verification.payload.deviceOwnerVerified === true && - verification.payload.crossSigningVerified === true; - if (!backupKeyLoaded || (!ownerVerificationRequired && !recoveryKeyCompletedIdentity)) { + diagnostics.payload.verified === true && + diagnostics.payload.crossSigningVerified === true && + diagnostics.payload.signedByOwner === true; + if (!backupKeyLoaded) { throw new Error( - "external recovery-key scenario did not preserve backup-key restore diagnostics before self-verification", - ); - } - const selfVerification = ownerVerificationRequired - ? await runMatrixQaCliSelfVerificationWithOwner({ - accountId: "external-key", - cli, - cliDeviceId: device.deviceId, - context, - label: "external recovery-key self-verification", - owner: setup.owner, - }) - : null; - const finalStatus = recoveryKeyCompletedIdentity - ? verification - : await runMatrixQaCliJson({ - args: ["matrix", "verify", "status", "--account", "external-key", "--json"], - label: "status-after-self-verification", - runtime: cli, - timeoutMs: context.timeoutMs, - }); - if ( - finalStatus.payload.verified !== true || - finalStatus.payload.crossSigningVerified !== true || - finalStatus.payload.signedByOwner !== true || - finalStatus.payload.backup?.trusted !== true || - finalStatus.payload.backup?.matchesDecryptionKey !== true - ) { - throw new Error( - "external recovery-key scenario did not finish with full Matrix identity trust after self-verification", + "external recovery-key scenario did not preserve backup-key restore diagnostics", ); } return { artifacts: { - ...(selfVerification - ? { completedVerificationId: selfVerification.completedOwner.id } - : {}), recoveryDeviceId: device.deviceId, + recoveryKeyAccepted: backupKeyLoaded, recoveryKeyId: setup.recoveryKeyId, restoreImported: restored.payload.imported, restoreTotal: restored.payload.total, - selfVerificationTransactionId: selfVerification?.transactionId ?? null, + selfVerificationTransactionId: null, seededEventId: setup.seededEventId, - verificationExitCode: verification.result.exitCode, + verificationExitCode: diagnostics.result.exitCode, }, details: [ "deleted Matrix state simulated with a fresh OpenClaw CLI state root", @@ -790,20 +654,16 @@ export async function runMatrixQaE2eeStateLossExternalRecoveryKeyScenario( `seeded encrypted event: ${setup.seededEventId}`, `recovery device: ${device.deviceId}`, `restore imported/total: ${restored.payload.imported ?? 0}/${restored.payload.total ?? 0}`, - `recovery key accepted: ${verification.payload.recoveryKeyAccepted ? "yes" : "no"}`, - `backup usable: ${verification.payload.backupUsable ? "yes" : "no"}`, + `recovery key accepted: ${backupKeyLoaded ? "yes" : "no"}`, + `backup usable: ${backupKeyLoaded ? "yes" : "no"}`, `device owner verified before self-verification: ${ - verification.payload.deviceOwnerVerified ? "yes" : "no" + diagnostics.payload.verified ? "yes" : "no" }`, - `device owner verified after recovery flow: ${finalStatus.payload.verified ? "yes" : "no"}`, + `device owner verified after recovery flow: ${recoveryKeyCompletedIdentity ? "yes" : "no"}`, `restore stdout: ${restored.artifacts.stdoutPath}`, - `verify diagnostics stdout: ${verification.artifacts.stdoutPath}`, - selfVerification - ? `verify self stdout: ${selfVerification.selfVerificationArtifacts.stdoutPath}` - : "verify self stdout: ", - recoveryKeyCompletedIdentity - ? "final status stdout: " - : `final status stdout: ${finalStatus.artifacts.stdoutPath}`, + `verify diagnostics stdout: ${diagnostics.artifacts.stdoutPath}`, + "verify self stdout: ", + "final status stdout: ", ].join("\n"), }; } finally { @@ -816,7 +676,6 @@ export async function runMatrixQaE2eeStateLossExternalRecoveryKeyScenario( export async function runMatrixQaE2eeStateLossStoredRecoveryKeyScenario( context: MatrixQaScenarioContext, ): Promise { - const driverPassword = requireMatrixQaPassword(context, "driver"); const setup = await prepareMatrixQaDestructiveSetup( context, "matrix-e2ee-state-loss-stored-recovery-key", @@ -826,8 +685,8 @@ export async function runMatrixQaE2eeStateLossStoredRecoveryKeyScenario( context, deviceName: "OpenClaw Matrix QA Stored Key Restore", label: "state-loss-stored-recovery-key", - password: driverPassword, - userId: context.driverUserId, + password: setup.ownerPassword, + userId: setup.ownerUserId, }); try { const initial = await runMatrixQaCliJson({ @@ -897,7 +756,6 @@ export async function runMatrixQaE2eeStateLossStoredRecoveryKeyScenario( export async function runMatrixQaE2eeStateLossNoRecoveryKeyScenario( context: MatrixQaScenarioContext, ): Promise { - const driverPassword = requireMatrixQaPassword(context, "driver"); const setup = await prepareMatrixQaDestructiveSetup( context, "matrix-e2ee-state-loss-no-recovery-key", @@ -907,8 +765,8 @@ export async function runMatrixQaE2eeStateLossNoRecoveryKeyScenario( context, deviceName: "OpenClaw Matrix QA No Key Restore", label: "state-loss-no-recovery-key", - password: driverPassword, - userId: context.driverUserId, + password: setup.ownerPassword, + userId: setup.ownerUserId, }); try { const restored = await runMatrixQaCliJson({ @@ -943,7 +801,6 @@ export async function runMatrixQaE2eeStateLossNoRecoveryKeyScenario( export async function runMatrixQaE2eeStaleRecoveryKeyAfterBackupResetScenario( context: MatrixQaScenarioContext, ): Promise { - const driverPassword = requireMatrixQaPassword(context, "driver"); const setup = await prepareMatrixQaDestructiveSetup( context, "matrix-e2ee-stale-recovery-key-after-backup-reset", @@ -966,8 +823,8 @@ export async function runMatrixQaE2eeStaleRecoveryKeyAfterBackupResetScenario( context, deviceName: "OpenClaw Matrix QA Stale Key Restore", label: "stale-recovery-key-after-backup-reset", - password: driverPassword, - userId: context.driverUserId, + password: setup.ownerPassword, + userId: setup.ownerUserId, }); try { const restored = await runMatrixQaCliJson({ @@ -1026,7 +883,7 @@ export async function runMatrixQaE2eeServerBackupDeletedLocalStateIntactScenario throw new Error(`Matrix backup preflight restore failed: ${before.error ?? "unknown"}`); } const deleteStatus = await deleteMatrixQaServerRoomKeyBackup({ - accessToken: context.driverAccessToken, + accessToken: setup.ownerAccessToken, baseUrl: context.baseUrl, version: before.backupVersion, }); @@ -1104,7 +961,6 @@ async function waitForMatrixQaNonEmptyCliBackupRestore(params: { export async function runMatrixQaE2eeServerBackupDeletedLocalReuploadRestoresScenario( context: MatrixQaScenarioContext, ): Promise { - const driverPassword = requireMatrixQaPassword(context, "driver"); const scenarioId = "matrix-e2ee-server-backup-deleted-local-reupload-restores"; const setup = await prepareMatrixQaDestructiveSetup(context, scenarioId); const { cli, device } = await runMatrixQaExternalKeyRestore({ @@ -1112,8 +968,8 @@ export async function runMatrixQaE2eeServerBackupDeletedLocalReuploadRestoresSce context, deviceName: "OpenClaw Matrix QA Backup Reupload Restore", label: "server-backup-deleted-local-reupload-restores", - password: driverPassword, - userId: context.driverUserId, + password: setup.ownerPassword, + userId: setup.ownerUserId, }); try { const before = await setup.owner.restoreRoomKeyBackup({ @@ -1125,7 +981,7 @@ export async function runMatrixQaE2eeServerBackupDeletedLocalReuploadRestoresSce ); } const deleteStatus = await deleteMatrixQaServerRoomKeyBackup({ - accessToken: context.driverAccessToken, + accessToken: setup.ownerAccessToken, baseUrl: context.baseUrl, version: before.backupVersion, }); @@ -1178,7 +1034,6 @@ export async function runMatrixQaE2eeServerBackupDeletedLocalReuploadRestoresSce export async function runMatrixQaE2eeCorruptCryptoIdbSnapshotScenario( context: MatrixQaScenarioContext, ): Promise { - const driverPassword = requireMatrixQaPassword(context, "driver"); const setup = await prepareMatrixQaDestructiveSetup( context, "matrix-e2ee-corrupt-crypto-idb-snapshot", @@ -1188,8 +1043,8 @@ export async function runMatrixQaE2eeCorruptCryptoIdbSnapshotScenario( context, deviceName: "OpenClaw Matrix QA Corrupt IDB Restore", label: "corrupt-crypto-idb-snapshot", - password: driverPassword, - userId: context.driverUserId, + password: setup.ownerPassword, + userId: setup.ownerUserId, }); try { const initial = await runMatrixQaCliJson({ @@ -1254,7 +1109,6 @@ export async function runMatrixQaE2eeCorruptCryptoIdbSnapshotScenario( export async function runMatrixQaE2eeServerDeviceDeletedLocalStateIntactScenario( context: MatrixQaScenarioContext, ): Promise { - const driverPassword = requireMatrixQaPassword(context, "driver"); const setup = await prepareMatrixQaDestructiveSetup( context, "matrix-e2ee-server-device-deleted-local-state-intact", @@ -1264,8 +1118,8 @@ export async function runMatrixQaE2eeServerDeviceDeletedLocalStateIntactScenario context, deviceName: "OpenClaw Matrix QA Deleted Device", label: "server-device-deleted-local-state-intact", - password: driverPassword, - userId: context.driverUserId, + password: setup.ownerPassword, + userId: setup.ownerUserId, }); try { const restored = await runMatrixQaCliJson({ @@ -1287,28 +1141,45 @@ export async function runMatrixQaE2eeServerDeviceDeletedLocalStateIntactScenario assertMatrixQaCliBackupRestoreSucceeded(restored.payload, "deleted-device preflight"); await setup.owner.deleteOwnDevices([device.deviceId]); const ownerDevicesAfterDelete = await setup.owner.listOwnDevices(); - const status = await runMatrixQaCliJson({ + const defaultStatus = await runMatrixQaCliJson({ allowNonZero: true, args: ["matrix", "verify", "status", "--account", "deleted-device", "--json"], - label: "status-after-device-delete", + label: "status-after-device-delete-default", + runtime: cli, + timeoutMs: context.timeoutMs, + }); + if (isMatrixQaVerifyStatusHealthy(defaultStatus)) { + throw new Error("default deleted device status reported healthy local state"); + } + const status = await runMatrixQaCliJson({ + allowNonZero: true, + args: [ + "matrix", + "verify", + "status", + "--account", + "deleted-device", + "--allow-degraded-local-state", + "--json", + ], + label: "status-after-device-delete-degraded", runtime: cli, timeoutMs: context.timeoutMs, }); - const authInvalidated = - status.result.exitCode !== 0 && - typeof status.payload.error === "string" && - (status.payload.error.includes("M_UNKNOWN_TOKEN") || - status.payload.error.toLowerCase().includes("access token")); const ownerDeviceListContainsDeletedDevice = ownerDevicesAfterDelete.some( (entry) => entry.deviceId === device.deviceId, ); - const deviceMissing = - status.payload.serverDeviceKnown === false || !ownerDeviceListContainsDeletedDevice; - if (!authInvalidated && !deviceMissing) { + const invalidation = isMatrixQaDeletedDeviceStatus({ + ownerDeviceListContainsDeletedDevice, + status, + }); + if (!invalidation.invalidated) { throw new Error("deleted device status did not report homeserver device invalidation"); } return { artifacts: { + defaultStatusError: defaultStatus.payload.error, + defaultStatusExitCode: defaultStatus.result.exitCode, deletedDeviceId: device.deviceId, serverDeviceKnown: status.payload.serverDeviceKnown ?? null, statusError: status.payload.error, @@ -1317,10 +1188,11 @@ export async function runMatrixQaE2eeServerDeviceDeletedLocalStateIntactScenario details: [ "server-side device deletion invalidated the surviving local credentials", `deleted device: ${device.deviceId}`, - `status exit code: ${status.result.exitCode}`, - authInvalidated + `default status exit code: ${defaultStatus.result.exitCode}`, + `degraded status exit code: ${status.result.exitCode}`, + invalidation.authInvalidated ? `status error: ${status.payload.error}` - : `device present on server: ${deviceMissing ? "no" : "yes"}`, + : `device present on server: ${invalidation.deviceMissing ? "no" : "yes"}`, ].join("\n"), }; } finally { @@ -1329,43 +1201,240 @@ export async function runMatrixQaE2eeServerDeviceDeletedLocalStateIntactScenario } } +export async function runMatrixQaE2eeServerDeviceDeletedReloginRecoversScenario( + context: MatrixQaScenarioContext, +): Promise { + const setup = await prepareMatrixQaDestructiveSetup( + context, + "matrix-e2ee-server-device-deleted-relogin-recovers", + ); + const deleted = await runMatrixQaExternalKeyRestore({ + accountId: "deleted-device-recovery", + context, + deviceName: "OpenClaw Matrix QA Deleted Device Recovery Source", + label: "server-device-deleted-relogin-source", + password: setup.ownerPassword, + userId: setup.ownerUserId, + }); + let replacement: Awaited> | undefined; + try { + const preflight = await runMatrixQaCliJson({ + args: [ + "matrix", + "verify", + "backup", + "restore", + "--account", + "deleted-device-recovery", + "--recovery-key-stdin", + "--json", + ], + label: "restore-before-device-delete", + runtime: deleted.cli, + stdin: `${setup.encodedRecoveryKey}\n`, + timeoutMs: context.timeoutMs, + }); + assertMatrixQaCliBackupRestoreSucceeded(preflight.payload, "deleted-device recovery preflight"); + + await setup.owner.deleteOwnDevices([deleted.device.deviceId]); + const ownerDevicesAfterDelete = await setup.owner.listOwnDevices(); + const defaultStatus = await runMatrixQaCliJson({ + allowNonZero: true, + args: ["matrix", "verify", "status", "--account", "deleted-device-recovery", "--json"], + label: "status-after-source-device-delete", + runtime: deleted.cli, + timeoutMs: context.timeoutMs, + }); + const invalidation = isMatrixQaDeletedDeviceStatus({ + ownerDeviceListContainsDeletedDevice: ownerDevicesAfterDelete.some( + (entry) => entry.deviceId === deleted.device.deviceId, + ), + status: defaultStatus, + }); + if (isMatrixQaVerifyStatusHealthy(defaultStatus) || !invalidation.invalidated) { + throw new Error("deleted source device did not fail closed before recovery re-login"); + } + + replacement = await runMatrixQaExternalKeyRestore({ + accountId: "deleted-device-recovery-relogin", + context, + deviceName: "OpenClaw Matrix QA Deleted Device Recovery Relogin", + label: "server-device-deleted-relogin-recovery", + password: setup.ownerPassword, + userId: setup.ownerUserId, + }); + const restored = await runMatrixQaCliJson({ + args: [ + "matrix", + "verify", + "backup", + "restore", + "--account", + "deleted-device-recovery-relogin", + "--recovery-key-stdin", + "--json", + ], + label: "restore-after-relogin", + runtime: replacement.cli, + stdin: `${setup.encodedRecoveryKey}\n`, + timeoutMs: context.timeoutMs, + }); + assertMatrixQaCliBackupRestoreSucceeded(restored.payload, "deleted-device relogin recovery"); + const status = await runMatrixQaCliJson({ + args: [ + "matrix", + "verify", + "status", + "--account", + "deleted-device-recovery-relogin", + "--json", + ], + label: "status-after-relogin-restore", + runtime: replacement.cli, + timeoutMs: context.timeoutMs, + }); + const backupKeyLoaded = + status.payload.backup?.matchesDecryptionKey === true && + status.payload.backup?.decryptionKeyCached === true && + !status.payload.backup?.keyLoadError; + if (!backupKeyLoaded) { + throw new Error("deleted-device re-login recovery did not restore usable backup access"); + } + return { + artifacts: { + defaultStatusError: defaultStatus.payload.error, + defaultStatusExitCode: defaultStatus.result.exitCode, + deletedDeviceId: deleted.device.deviceId, + recoveryKeyAccepted: backupKeyLoaded, + replacementDeviceId: replacement.device.deviceId, + restoreImported: restored.payload.imported, + restoreTotal: restored.payload.total, + statusExitCode: status.result.exitCode, + }, + details: [ + "server-side device deletion failed closed, then a replacement login restored backup access", + `deleted device: ${deleted.device.deviceId}`, + `replacement device: ${replacement.device.deviceId}`, + `default deleted-device status exit code: ${defaultStatus.result.exitCode}`, + `restore imported/total: ${restored.payload.imported ?? 0}/${restored.payload.total ?? 0}`, + `backup usable after re-login: ${backupKeyLoaded ? "yes" : "no"}`, + ].join("\n"), + }; + } finally { + await replacement?.cli.dispose().catch(() => undefined); + if (replacement?.device.deviceId) { + await setup.owner.deleteOwnDevices([replacement.device.deviceId]).catch(() => undefined); + } + await deleted.cli.dispose().catch(() => undefined); + await setup.owner.deleteOwnDevices([deleted.device.deviceId]).catch(() => undefined); + await setup.owner.stop().catch(() => undefined); + } +} + export async function runMatrixQaE2eeSyncStateLossCryptoIntactScenario( context: MatrixQaScenarioContext, ): Promise { if (!context.gatewayStateDir || !context.restartGatewayAfterStateMutation) { throw new Error("Matrix E2EE sync-state loss scenario requires gateway state restart support"); } - const { roomId, roomKey } = resolveMatrixQaE2eeScenarioGroupRoom( - context, - "matrix-e2ee-sync-state-loss-crypto-intact", - ); - const syncStore = await waitForMatrixSyncStoreWithCursor({ - context, - stateDir: context.gatewayStateDir, - timeoutMs: context.timeoutMs, - }); - await context.restartGatewayAfterStateMutation(async () => { - await rm(syncStore.pathname, { force: true }); - }); - const driver = await createMatrixQaDriverPersistentClient( + const restoreAccountId = context.sutAccountId ?? "sut"; + const configPath = requireMatrixQaGatewayConfigPath(context); + const originalAccountConfig = await readMatrixQaGatewayMatrixAccount({ + accountId: restoreAccountId, + configPath, + }); + const accountId = "sync-state-loss-gateway"; + const account = await registerMatrixQaDestructiveOwner( context, "matrix-e2ee-sync-state-loss-crypto-intact", ); + const roomKey = `${buildMatrixQaE2eeScenarioRoomKey("matrix-e2ee-sync-state-loss-crypto-intact")}-recovery`; const rawDriver = createMatrixQaDriverScenarioClient(context); + const roomId = await rawDriver.createPrivateRoom({ + encrypted: true, + inviteUserIds: [context.observerUserId, account.userId], + name: "Matrix QA E2EE Sync State Loss Recovery Room", + }); + await Promise.all([ + createMatrixQaClient({ + accessToken: context.observerAccessToken, + baseUrl: context.baseUrl, + }).joinRoom(roomId), + createMatrixQaClient({ + accessToken: account.accessToken, + baseUrl: context.baseUrl, + }).joinRoom(roomId), + ]); + const accountConfig: Record = { + ...originalAccountConfig, + accessToken: account.accessToken, + deviceId: account.deviceId, + enabled: true, + encryption: true, + groups: { + [roomId]: { + enabled: true, + requireMention: true, + }, + }, + homeserver: context.baseUrl, + password: account.password, + startupVerification: "off", + userId: account.userId, + }; + let driver: MatrixQaE2eeScenarioClient | undefined; + let gatewayAccountReplaced = false; try { + await context.restartGatewayAfterStateMutation( + async () => { + await replaceMatrixQaGatewayMatrixAccount({ + accountConfig, + accountId, + configPath, + }); + gatewayAccountReplaced = true; + }, + { + timeoutMs: context.timeoutMs, + waitAccountId: accountId, + }, + ); + const syncStore = await waitForMatrixSyncStoreWithCursor({ + accountId, + context, + stateDir: context.gatewayStateDir, + timeoutMs: context.timeoutMs, + userId: account.userId, + }); + await context.restartGatewayAfterStateMutation( + async () => { + await rm(syncStore.pathname, { force: true }); + }, + { + timeoutMs: context.timeoutMs, + waitAccountId: accountId, + }, + ); + await context.waitGatewayAccountReady?.(accountId, { + timeoutMs: context.timeoutMs, + }); + driver = await createMatrixQaDriverPersistentClient( + context, + "matrix-e2ee-sync-state-loss-crypto-intact", + ); const token = buildMatrixQaToken("MATRIX_QA_E2EE_SYNC_LOSS"); const driverStartSince = await driver.prime(); const rawStartSince = await rawDriver.primeRoom(); const driverEventId = await driver.sendTextMessage({ - body: buildMentionPrompt(context.sutUserId, token), - mentionUserIds: [context.sutUserId], + body: buildMentionPrompt(account.userId, token), + mentionUserIds: [account.userId], roomId, }); const decrypted = await driver.waitForRoomEvent({ predicate: (event) => isMatrixQaExactMarkerReply(event, { roomId, - sutUserId: context.sutUserId, + sutUserId: account.userId, token, }), roomId, @@ -1377,7 +1446,7 @@ export async function runMatrixQaE2eeSyncStateLossCryptoIntactScenario( observedEvents: context.observedEvents, predicate: (event) => event.roomId === roomId && - event.sender === context.sutUserId && + event.sender === account.userId && event.type === "m.room.encrypted", roomId, since: rawStartSince, @@ -1401,7 +1470,24 @@ export async function runMatrixQaE2eeSyncStateLossCryptoIntactScenario( ].join("\n"), }; } finally { - await driver.stop().catch(() => undefined); + await driver?.stop().catch(() => undefined); + if (gatewayAccountReplaced) { + await context + .restartGatewayAfterStateMutation( + async () => { + await replaceMatrixQaGatewayMatrixAccount({ + accountConfig: originalAccountConfig, + accountId: restoreAccountId, + configPath, + }); + }, + { + timeoutMs: context.timeoutMs, + waitAccountId: restoreAccountId, + }, + ) + .catch(() => undefined); + } } } @@ -1493,7 +1579,6 @@ export async function runMatrixQaE2eeWrongAccountRecoveryKeyScenario( export async function runMatrixQaE2eeHistoryExistsBackupEmptyScenario( context: MatrixQaScenarioContext, ): Promise { - const driverPassword = requireMatrixQaPassword(context, "driver"); const setup = await prepareMatrixQaDestructiveSetup( context, "matrix-e2ee-history-exists-backup-empty", @@ -1514,8 +1599,8 @@ export async function runMatrixQaE2eeHistoryExistsBackupEmptyScenario( context, deviceName: "OpenClaw Matrix QA Empty Backup", label: "history-exists-backup-empty", - password: driverPassword, - userId: context.driverUserId, + password: setup.ownerPassword, + userId: setup.ownerUserId, }); try { const restored = await waitForMatrixQaNonEmptyCliBackupRestore({ diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-e2ee.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-e2ee.ts index ddc06c0a210..5944f805570 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-e2ee.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-e2ee.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import type { MatrixVerificationSummary } from "@openclaw/matrix/test-api.js"; @@ -33,8 +33,15 @@ import { redactMatrixQaCliOutput, runMatrixQaOpenClawCli, startMatrixQaOpenClawCli, + type MatrixQaCliSession, type MatrixQaCliRunResult, } from "./scenario-runtime-cli.js"; +import { + isMatrixQaPlainRecord, + patchMatrixQaGatewayMatrixAccount, + readMatrixQaGatewayMatrixAccount, + replaceMatrixQaGatewayMatrixAccount, +} from "./scenario-runtime-config.js"; import { assertThreadReplyArtifact, assertTopLevelReplyArtifact, @@ -61,20 +68,51 @@ type MatrixQaCliVerificationStatus = { matchesDecryptionKey?: boolean | null; trusted?: boolean | null; }; + backupVersion?: string | null; crossSigningVerified?: boolean; verified?: boolean; signedByOwner?: boolean; deviceId?: string | null; userId?: string | null; }; +type MatrixQaCliEncryptionSetupStatus = { + accountId?: string; + bootstrap?: { + error?: string; + success?: boolean; + }; + configPath?: string; + encryptionChanged?: boolean; + status?: MatrixQaCliVerificationStatus; + success?: boolean; +}; +type MatrixQaCliAccountAddStatus = { + accountId?: string; + configPath?: string; + encryptionEnabled?: boolean; + verificationBootstrap?: { + attempted?: boolean; + backupVersion?: string | null; + error?: string; + success?: boolean; + }; +}; type MatrixQaCliBackupRestoreStatus = { success?: boolean; backup?: MatrixQaCliVerificationStatus["backup"]; error?: string; }; -function isMatrixQaCliBackupUsable(backup: MatrixQaCliVerificationStatus["backup"]): boolean { - return Boolean(backup?.trusted && backup.matchesDecryptionKey && !backup.keyLoadError); +function isMatrixQaCliBackupUsable( + backup: MatrixQaCliVerificationStatus["backup"], + opts: { allowUntrustedMatchingKey?: boolean } = {}, +): boolean { + return Boolean( + (backup?.trusted || opts.allowUntrustedMatchingKey === true) && + backup?.matchesDecryptionKey && + backup.decryptionKeyCached && + !backup.keyLoadError, + ); } function requireMatrixQaE2eeOutputDir(context: MatrixQaScenarioContext) { @@ -91,8 +129,32 @@ function requireMatrixQaCliRuntimeEnv(context: MatrixQaScenarioContext) { return context.gatewayRuntimeEnv; } -function requireMatrixQaPassword(context: MatrixQaScenarioContext, actor: "driver" | "observer") { - const password = actor === "driver" ? context.driverPassword : context.observerPassword; +function requireMatrixQaGatewayConfigPath(context: MatrixQaScenarioContext) { + const configPath = requireMatrixQaCliRuntimeEnv(context).OPENCLAW_CONFIG_PATH?.trim(); + if (!configPath) { + throw new Error("Matrix CLI QA scenarios require the gateway config path"); + } + return configPath; +} + +function requireMatrixQaRegistrationToken(context: MatrixQaScenarioContext) { + const token = context.registrationToken?.trim(); + if (!token) { + throw new Error("Matrix CLI QA scenarios require the homeserver registration token"); + } + return token; +} + +function requireMatrixQaPassword( + context: MatrixQaScenarioContext, + actor: "driver" | "observer" | "sut", +) { + const password = + actor === "driver" + ? context.driverPassword + : actor === "observer" + ? context.observerPassword + : context.sutPassword; if (!password) { throw new Error(`Matrix E2EE ${actor} password is required for this scenario`); } @@ -249,11 +311,6 @@ function parseMatrixQaCliJsonText(text: string): unknown { function parseMatrixQaCliJson(result: MatrixQaCliRunResult): unknown { const stdout = result.stdout.trim(); const stderr = result.stderr.trim(); - if (stdout && stderr) { - throw new Error( - `${formatMatrixQaCliCommand(result.args)} printed JSON with extra output\nstdout:\n${redactMatrixQaCliOutput(stdout)}\nstderr:\n${redactMatrixQaCliOutput(stderr)}`, - ); - } if (stdout) { try { return parseMatrixQaCliJsonText(stdout); @@ -282,6 +339,101 @@ function parseMatrixQaCliJson(result: MatrixQaCliRunResult): unknown { } } +function buildMatrixQaPluginActivationConfig() { + return { + plugins: { + allow: ["matrix"], + entries: { + matrix: { enabled: true }, + }, + }, + }; +} + +function buildMatrixQaEmptyMatrixCliConfig() { + return { + ...buildMatrixQaPluginActivationConfig(), + channels: { + matrix: { + enabled: true, + accounts: {}, + }, + }, + }; +} + +async function registerMatrixQaCliE2eeAccount(params: { + context: MatrixQaScenarioContext; + deviceName: string; + scenarioId: MatrixQaE2eeScenarioId; +}) { + const localpartSuffix = params.scenarioId + .replace(/^matrix-e2ee-cli-/, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 24); + const account = await createMatrixQaClient({ + baseUrl: params.context.baseUrl, + }).registerWithToken({ + deviceName: params.deviceName, + localpart: `qa-cli-${localpartSuffix}-${randomUUID().replaceAll("-", "").slice(0, 8)}`, + password: `matrix-qa-${randomUUID()}`, + registrationToken: requireMatrixQaRegistrationToken(params.context), + }); + if (!account.deviceId) { + throw new Error( + `Matrix CLI QA registration for ${params.scenarioId} did not return a device id`, + ); + } + return account; +} + +async function registerMatrixQaE2eeScenarioAccount(params: { + context: MatrixQaScenarioContext; + deviceName: string; + localpartPrefix: string; + scenarioId: MatrixQaE2eeScenarioId; +}) { + const localpartSuffix = params.scenarioId + .replace(/^matrix-e2ee-/, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 24); + const account = await createMatrixQaClient({ + baseUrl: params.context.baseUrl, + }).registerWithToken({ + deviceName: params.deviceName, + localpart: `${params.localpartPrefix}-${localpartSuffix}-${randomUUID().replaceAll("-", "").slice(0, 8)}`, + password: `matrix-qa-${randomUUID()}`, + registrationToken: requireMatrixQaRegistrationToken(params.context), + }); + if (!account.deviceId) { + throw new Error( + `Matrix E2EE QA registration for ${params.scenarioId} did not return a device id`, + ); + } + return account; +} + +async function createMatrixQaE2eeCliOwnerClient(params: { + account: Awaited>; + context: MatrixQaScenarioContext; + scenarioId: MatrixQaE2eeScenarioId; +}) { + return await createMatrixQaE2eeScenarioClient({ + accessToken: params.account.accessToken, + actorId: `cli-owner-${randomUUID().slice(0, 8)}`, + baseUrl: params.context.baseUrl, + deviceId: params.account.deviceId, + observedEvents: params.context.observedEvents, + outputDir: requireMatrixQaE2eeOutputDir(params.context), + password: params.account.password, + scenarioId: params.scenarioId, + timeoutMs: params.context.timeoutMs, + userId: params.account.userId, + }); +} + function parseMatrixQaCliSasText( text: string, label: string, @@ -356,7 +508,7 @@ function assertMatrixQaCliSasMatches(params: { function isMatrixQaCliOwnerSelfVerification(params: { cliDeviceId?: string; - driverUserId: string; + ownerUserId: string; requireCompleted?: boolean; requirePending?: boolean; requireSas?: boolean; @@ -367,7 +519,7 @@ function isMatrixQaCliOwnerSelfVerification(params: { if ( !summary.isSelfVerification || summary.initiatedByMe || - summary.otherUserId !== params.driverUserId + summary.otherUserId !== params.ownerUserId ) { return false; } @@ -483,6 +635,111 @@ async function createMatrixQaCliSelfVerificationRuntime(params: { }; } +async function createMatrixQaCliE2eeSetupRuntime(params: { + artifactLabel: string; + context: MatrixQaScenarioContext; + initialConfig?: Record; +}) { + const outputDir = requireMatrixQaE2eeOutputDir(params.context); + const rootDir = await mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-matrix-e2ee-setup-qa-"), + ); + const artifactDir = path.join( + outputDir, + params.artifactLabel, + randomUUID().replaceAll("-", "").slice(0, 12), + ); + const stateDir = path.join(rootDir, "state"); + const configPath = path.join(rootDir, "config.json"); + await chmod(rootDir, 0o700).catch(() => undefined); + await assertMatrixQaPrivatePathMode(rootDir, "Matrix QA CLI temp directory"); + await mkdir(artifactDir, { mode: 0o700, recursive: true }); + await chmod(artifactDir, 0o700).catch(() => undefined); + await assertMatrixQaPrivatePathMode(artifactDir, "Matrix QA CLI artifact directory"); + await mkdir(stateDir, { mode: 0o700, recursive: true }); + await chmod(stateDir, 0o700).catch(() => undefined); + await assertMatrixQaPrivatePathMode(stateDir, "Matrix QA CLI state directory"); + await writeFile( + configPath, + `${JSON.stringify(params.initialConfig ?? buildMatrixQaEmptyMatrixCliConfig(), null, 2)}\n`, + { flag: "wx", mode: 0o600 }, + ); + await assertMatrixQaPrivatePathMode(configPath, "Matrix QA CLI config file"); + const env = { + ...requireMatrixQaCliRuntimeEnv(params.context), + FORCE_COLOR: "0", + NO_COLOR: "1", + OPENCLAW_CONFIG_PATH: configPath, + OPENCLAW_DISABLE_AUTO_UPDATE: "1", + OPENCLAW_STATE_DIR: stateDir, + }; + const run = async (args: string[], timeoutMs = params.context.timeoutMs) => + await runMatrixQaOpenClawCli({ + args, + env, + timeoutMs, + }); + const start = (args: string[], timeoutMs = params.context.timeoutMs) => + startMatrixQaOpenClawCli({ + args, + env, + timeoutMs, + }); + return { + configPath, + dispose: async () => { + await rm(rootDir, { force: true, recursive: true }); + }, + run, + rootDir: artifactDir, + start, + stateDir, + }; +} + +async function createMatrixQaCliGatewayRuntime(params: { + artifactLabel: string; + context: MatrixQaScenarioContext; +}) { + const outputDir = requireMatrixQaE2eeOutputDir(params.context); + const rootDir = await mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-matrix-gateway-cli-qa-"), + ); + const artifactDir = path.join( + outputDir, + params.artifactLabel, + randomUUID().replaceAll("-", "").slice(0, 12), + ); + const pluginStageDir = path.join(rootDir, "plugin-stage"); + await chmod(rootDir, 0o700).catch(() => undefined); + await assertMatrixQaPrivatePathMode(rootDir, "Matrix QA CLI temp directory"); + await mkdir(artifactDir, { mode: 0o700, recursive: true }); + await chmod(artifactDir, 0o700).catch(() => undefined); + await assertMatrixQaPrivatePathMode(artifactDir, "Matrix QA CLI artifact directory"); + await mkdir(pluginStageDir, { mode: 0o700, recursive: true }); + await chmod(pluginStageDir, 0o700).catch(() => undefined); + const env = { + ...requireMatrixQaCliRuntimeEnv(params.context), + FORCE_COLOR: "0", + NO_COLOR: "1", + OPENCLAW_DISABLE_AUTO_UPDATE: "1", + OPENCLAW_PLUGIN_STAGE_DIR: pluginStageDir, + }; + const run = async (args: string[], timeoutMs = params.context.timeoutMs) => + await runMatrixQaOpenClawCli({ + args, + env, + timeoutMs, + }); + return { + dispose: async () => { + await rm(rootDir, { force: true, recursive: true }); + }, + rootDir: artifactDir, + run, + }; +} + function assertMatrixQaSasEmojiMatches(params: { initiator: MatrixVerificationSummary; recipient: MatrixVerificationSummary; @@ -531,10 +788,11 @@ function isMatrixQaE2eeNoticeTriggeredSutReply(params: { async function createMatrixQaE2eeDriverClient( context: MatrixQaScenarioContext, scenarioId: MatrixQaE2eeScenarioId, + opts: { actorId?: "driver" | `driver-${string}` } = {}, ) { return await createMatrixQaE2eeScenarioClient({ accessToken: context.driverAccessToken, - actorId: "driver", + actorId: opts.actorId ?? "driver", baseUrl: context.baseUrl, deviceId: context.driverDeviceId, observedEvents: context.observedEvents, @@ -821,8 +1079,9 @@ async function withMatrixQaE2eeDriver( context: MatrixQaScenarioContext, scenarioId: MatrixQaE2eeScenarioId, run: (client: MatrixQaE2eeScenarioClient) => Promise, + opts: { actorId?: "driver" | `driver-${string}` } = {}, ) { - const client = await createMatrixQaE2eeDriverClient(context, scenarioId); + const client = await createMatrixQaE2eeDriverClient(context, scenarioId, opts); try { return await run(client); } finally { @@ -830,6 +1089,192 @@ async function withMatrixQaE2eeDriver( } } +async function createMatrixQaE2eeRegisteredScenarioClient(params: { + account: Awaited>; + actorId: `driver-${string}`; + context: MatrixQaScenarioContext; + scenarioId: MatrixQaE2eeScenarioId; +}) { + return await createMatrixQaE2eeScenarioClient({ + accessToken: params.account.accessToken, + actorId: params.actorId, + baseUrl: params.context.baseUrl, + deviceId: params.account.deviceId, + observedEvents: params.context.observedEvents, + outputDir: requireMatrixQaE2eeOutputDir(params.context), + password: params.account.password, + scenarioId: params.scenarioId, + timeoutMs: params.context.timeoutMs, + userId: params.account.userId, + }); +} + +async function withMatrixQaIsolatedE2eeDriverRoom( + context: MatrixQaScenarioContext, + scenarioId: MatrixQaE2eeScenarioId, + run: (params: { + client: MatrixQaE2eeScenarioClient; + driverUserId: string; + roomId: string; + roomKey: string; + }) => Promise, +) { + if (!context.restartGatewayAfterStateMutation) { + throw new Error( + "Matrix E2EE isolated driver room scenario requires hard gateway restart support", + ); + } + const accountId = context.sutAccountId ?? "sut"; + const configPath = requireMatrixQaGatewayConfigPath(context); + const accountConfig = await readMatrixQaGatewayMatrixAccount({ + accountId, + configPath, + }); + const originalGroups = isMatrixQaPlainRecord(accountConfig.groups) ? accountConfig.groups : {}; + const originalGroupAllowFrom = Array.isArray(accountConfig.groupAllowFrom) + ? accountConfig.groupAllowFrom + : undefined; + const originalGroupPolicy = accountConfig.groupPolicy; + const driverAccount = await registerMatrixQaE2eeScenarioAccount({ + context, + deviceName: "OpenClaw Matrix QA Isolated E2EE Driver", + localpartPrefix: "qa-e2ee-driver", + scenarioId, + }); + const driverApi = createMatrixQaClient({ + accessToken: driverAccount.accessToken, + baseUrl: context.baseUrl, + }); + const roomKey = buildMatrixQaE2eeScenarioRoomKey(scenarioId); + const roomId = await driverApi.createPrivateRoom({ + encrypted: true, + inviteUserIds: [context.observerUserId, context.sutUserId], + name: `Matrix QA ${scenarioId} Isolated E2EE Room`, + }); + await Promise.all([ + createMatrixQaClient({ + accessToken: context.observerAccessToken, + baseUrl: context.baseUrl, + }).joinRoom(roomId), + createMatrixQaClient({ + accessToken: context.sutAccessToken, + baseUrl: context.baseUrl, + }).joinRoom(roomId), + ]); + + const isolatedGroups = { + [roomId]: { + enabled: true, + requireMention: true, + }, + }; + const applyPatch = async (accountPatch: Record) => { + await context.restartGatewayAfterStateMutation?.( + async () => { + await patchMatrixQaGatewayMatrixAccount({ + accountId, + accountPatch, + configPath, + }); + }, + { + timeoutMs: context.timeoutMs, + waitAccountId: accountId, + }, + ); + }; + + let patchedGateway = false; + let client: MatrixQaE2eeScenarioClient | undefined; + try { + await applyPatch({ + groupAllowFrom: [driverAccount.userId], + groupPolicy: "allowlist", + groups: isolatedGroups, + }); + patchedGateway = true; + const actorId: `driver-${string}` = `driver-${scenarioId + .replace(/^matrix-e2ee-/, "") + .replace(/[^A-Za-z0-9_-]/g, "-") + .slice(0, 28)}`; + client = await createMatrixQaE2eeRegisteredScenarioClient({ + account: driverAccount, + actorId, + context, + scenarioId, + }); + await Promise.all([ + client.waitForJoinedMember({ + roomId, + timeoutMs: context.timeoutMs, + userId: context.sutUserId, + }), + client.waitForJoinedMember({ + roomId, + timeoutMs: context.timeoutMs, + userId: context.observerUserId, + }), + ]); + return await run({ + client, + driverUserId: driverAccount.userId, + roomId, + roomKey, + }); + } finally { + await client?.stop().catch(() => undefined); + if (patchedGateway) { + const restorePatch: Record = { + groupAllowFrom: originalGroupAllowFrom, + groupPolicy: originalGroupPolicy, + groups: originalGroups, + }; + await applyPatch(restorePatch).catch(() => undefined); + } + } +} + +async function runMatrixQaE2eeTopLevelWithClient( + context: MatrixQaScenarioContext, + params: { + client: MatrixQaE2eeScenarioClient; + driverUserId: string; + roomId: string; + roomKey: string; + tokenPrefix: string; + }, +) { + const startSince = await params.client.prime(); + const token = buildMatrixQaToken(params.tokenPrefix); + const body = buildMentionPrompt(context.sutUserId, token); + const driverEventId = await params.client.sendTextMessage({ + body, + mentionUserIds: [context.sutUserId], + roomId: params.roomId, + }); + const matched = await params.client.waitForRoomEvent({ + predicate: (event) => + isMatrixQaExactMarkerReply(event, { + roomId: params.roomId, + sutUserId: context.sutUserId, + token, + }) && event.relatesTo === undefined, + roomId: params.roomId, + timeoutMs: context.timeoutMs, + }); + const reply = buildMatrixE2eeReplyArtifact(matched.event, token); + assertTopLevelReplyArtifact("E2EE reply", reply); + return { + driverEventId, + driverUserId: params.driverUserId, + reply, + roomId: params.roomId, + roomKey: params.roomKey, + since: matched.since ?? startSince, + token, + }; +} + async function runMatrixQaE2eeTopLevelScenario( context: MatrixQaScenarioContext, params: { @@ -839,34 +1284,13 @@ async function runMatrixQaE2eeTopLevelScenario( ) { const { roomId, roomKey } = resolveMatrixQaE2eeScenarioGroupRoom(context, params.scenarioId); return await withMatrixQaE2eeDriver(context, params.scenarioId, async (client) => { - const startSince = await client.prime(); - const token = buildMatrixQaToken(params.tokenPrefix); - const body = buildMentionPrompt(context.sutUserId, token); - const driverEventId = await client.sendTextMessage({ - body, - mentionUserIds: [context.sutUserId], - roomId, - }); - const matched = await client.waitForRoomEvent({ - predicate: (event) => - isMatrixQaExactMarkerReply(event, { - roomId, - sutUserId: context.sutUserId, - token, - }) && event.relatesTo === undefined, - roomId, - timeoutMs: context.timeoutMs, - }); - const reply = buildMatrixE2eeReplyArtifact(matched.event, token); - assertTopLevelReplyArtifact("E2EE reply", reply); - return { - driverEventId, - reply, + return await runMatrixQaE2eeTopLevelWithClient(context, { + client, + driverUserId: context.driverUserId, roomId, roomKey, - since: matched.since ?? startSince, - token, - }; + tokenPrefix: params.tokenPrefix, + }); }); } @@ -1191,234 +1615,1325 @@ export async function runMatrixQaE2eeRecoveryOwnerVerificationRequiredScenario( ); } +function assertMatrixQaCliE2eeStatus( + label: string, + status: MatrixQaCliVerificationStatus, + opts: { allowUntrustedMatchingKey?: boolean } = {}, +) { + if ( + status.verified !== true || + status.crossSigningVerified !== true || + status.signedByOwner !== true || + !isMatrixQaCliBackupUsable(status.backup, opts) + ) { + throw new Error( + `${label} did not leave the CLI account fully verified and backup-usable: ownerVerified=${ + status.verified === true && + status.crossSigningVerified === true && + status.signedByOwner === true + ? "yes" + : "no" + }, backupUsable=${isMatrixQaCliBackupUsable(status.backup, opts) ? "yes" : "no"}${ + status.backup?.keyLoadError ? `, backupError=${status.backup.keyLoadError}` : "" + }`, + ); + } +} + +async function runMatrixQaCliExpectedFailure(params: { + args: string[]; + start: (args: string[], timeoutMs?: number) => MatrixQaCliSession; + timeoutMs: number; +}): Promise { + const session = params.start(params.args, params.timeoutMs); + try { + const result = await session.wait(); + throw new Error( + `${formatMatrixQaCliCommand(params.args)} unexpectedly succeeded with stdout:\n${redactMatrixQaCliOutput( + result.stdout, + )}`, + ); + } catch (error) { + if (error instanceof Error && error.message.includes("unexpectedly succeeded")) { + throw error; + } + const output = session.output(); + if (!output.stdout.trim() && !output.stderr.trim()) { + throw error; + } + return { + args: params.args, + exitCode: 1, + stderr: output.stderr, + stdout: output.stdout, + }; + } finally { + session.kill(); + } +} + +function buildMatrixQaCliE2eeAccountConfig(params: { + accountId: string; + accessToken: string; + baseUrl: string; + deviceId: string; + encryption: boolean; + name: string; + password?: string; + userId: string; +}) { + return { + ...buildMatrixQaPluginActivationConfig(), + channels: { + matrix: { + defaultAccount: params.accountId, + accounts: { + [params.accountId]: { + accessToken: params.accessToken, + deviceId: params.deviceId, + encryption: params.encryption, + homeserver: params.baseUrl, + initialSyncLimit: 1, + name: params.name, + network: { + dangerouslyAllowPrivateNetwork: true, + }, + ...(params.password ? { password: params.password } : {}), + startupVerification: "off", + userId: params.userId, + }, + }, + }, + }, + }; +} + +async function readMatrixQaCliConfig(pathname: string): Promise<{ + channels?: { + matrix?: { + accounts?: Record>; + defaultAccount?: string; + }; + }; +}> { + return JSON.parse(await readFile(pathname, "utf8")) as { + channels?: { + matrix?: { + accounts?: Record>; + defaultAccount?: string; + }; + }; + }; +} + +export async function runMatrixQaE2eeCliAccountAddEnableE2eeScenario( + context: MatrixQaScenarioContext, +): Promise { + const accountId = "cli-add-e2ee"; + const account = await registerMatrixQaCliE2eeAccount({ + context, + deviceName: "OpenClaw Matrix QA CLI Account Add Owner", + scenarioId: "matrix-e2ee-cli-account-add-enable-e2ee", + }); + const cli = await createMatrixQaCliE2eeSetupRuntime({ + artifactLabel: "cli-account-add-enable-e2ee", + context, + }); + try { + const addResult = await cli.run([ + "matrix", + "account", + "add", + "--account", + accountId, + "--name", + "Matrix QA CLI Account Add E2EE", + "--homeserver", + context.baseUrl, + "--user-id", + account.userId, + "--password", + account.password, + "--device-name", + "OpenClaw Matrix QA CLI Account Add E2EE", + "--allow-private-network", + "--enable-e2ee", + "--json", + ]); + const addArtifacts = await writeMatrixQaCliOutputArtifacts({ + label: "account-add-enable-e2ee", + result: addResult, + rootDir: cli.rootDir, + }); + const added = parseMatrixQaCliJson(addResult) as MatrixQaCliAccountAddStatus; + if (added.accountId !== accountId || added.encryptionEnabled !== true) { + throw new Error( + "Matrix CLI account add did not report E2EE enabled for the expected account", + ); + } + if (added.verificationBootstrap?.attempted !== true) { + throw new Error("Matrix CLI account add did not attempt verification bootstrap"); + } + if (added.verificationBootstrap.success !== true) { + throw new Error( + `Matrix CLI account add verification bootstrap failed: ${added.verificationBootstrap.error ?? "unknown error"}`, + ); + } + + const statusResult = await cli.run([ + "matrix", + "verify", + "status", + "--account", + accountId, + "--json", + ]); + const statusArtifacts = await writeMatrixQaCliOutputArtifacts({ + label: "verify-status", + result: statusResult, + rootDir: cli.rootDir, + }); + const status = parseMatrixQaCliJson(statusResult) as MatrixQaCliVerificationStatus; + assertMatrixQaCliE2eeStatus("Matrix CLI account add --enable-e2ee", status); + const cliDeviceId = status.deviceId ?? null; + + return { + artifacts: { + accountId, + backupVersion: added.verificationBootstrap.backupVersion ?? null, + cliDeviceId, + encryptionEnabled: added.encryptionEnabled, + verificationBootstrapAttempted: added.verificationBootstrap.attempted, + verificationBootstrapSuccess: added.verificationBootstrap.success, + }, + details: [ + "Matrix CLI account add --enable-e2ee created an encrypted, verified account", + `account add stdout: ${addArtifacts.stdoutPath}`, + `account add stderr: ${addArtifacts.stderrPath}`, + `verify status stdout: ${statusArtifacts.stdoutPath}`, + `verify status stderr: ${statusArtifacts.stderrPath}`, + `cli device: ${cliDeviceId ?? ""}`, + `cli verified by owner: ${status.verified ? "yes" : "no"}`, + `cli backup usable: ${isMatrixQaCliBackupUsable(status.backup) ? "yes" : "no"}`, + ].join("\n"), + }; + } finally { + await cli.dispose(); + } +} + +export async function runMatrixQaE2eeCliEncryptionSetupScenario( + context: MatrixQaScenarioContext, +): Promise { + const accountId = "cli-encryption-setup"; + const account = await registerMatrixQaCliE2eeAccount({ + context, + deviceName: "OpenClaw Matrix QA CLI Encryption Setup Owner", + scenarioId: "matrix-e2ee-cli-encryption-setup", + }); + const loginClient = createMatrixQaClient({ + baseUrl: context.baseUrl, + }); + const cliDevice = await loginClient.loginWithPassword({ + deviceName: "OpenClaw Matrix QA CLI Encryption Setup Device", + password: account.password, + userId: account.userId, + }); + if (!cliDevice.deviceId) { + throw new Error("Matrix E2EE CLI encryption setup login did not return a device id"); + } + const cli = await createMatrixQaCliE2eeSetupRuntime({ + artifactLabel: "cli-encryption-setup", + context, + initialConfig: buildMatrixQaCliE2eeAccountConfig({ + accountId, + accessToken: cliDevice.accessToken, + baseUrl: context.baseUrl, + deviceId: cliDevice.deviceId, + encryption: false, + name: "Matrix QA CLI Encryption Setup", + password: account.password, + userId: cliDevice.userId, + }), + }); + try { + const setupResult = await cli.run([ + "matrix", + "encryption", + "setup", + "--account", + accountId, + "--json", + ]); + const setupArtifacts = await writeMatrixQaCliOutputArtifacts({ + label: "encryption-setup", + result: setupResult, + rootDir: cli.rootDir, + }); + const setup = parseMatrixQaCliJson(setupResult) as MatrixQaCliEncryptionSetupStatus; + if ( + setup.accountId !== accountId || + setup.success !== true || + setup.encryptionChanged !== true || + setup.bootstrap?.success !== true || + !setup.status + ) { + throw new Error( + `Matrix CLI encryption setup did not report a successful E2EE upgrade: ${setup.bootstrap?.error ?? "unknown error"}`, + ); + } + assertMatrixQaCliE2eeStatus("Matrix CLI encryption setup", setup.status); + + const statusResult = await cli.run([ + "matrix", + "verify", + "status", + "--account", + accountId, + "--json", + ]); + const statusArtifacts = await writeMatrixQaCliOutputArtifacts({ + label: "verify-status", + result: statusResult, + rootDir: cli.rootDir, + }); + const status = parseMatrixQaCliJson(statusResult) as MatrixQaCliVerificationStatus; + assertMatrixQaCliE2eeStatus("Matrix CLI encryption setup status", status); + + return { + artifacts: { + accountId, + cliDeviceId: status.deviceId ?? cliDevice.deviceId, + encryptionChanged: setup.encryptionChanged, + setupSuccess: setup.success, + verificationBootstrapSuccess: setup.bootstrap.success, + }, + details: [ + "Matrix CLI encryption setup upgraded an existing account and bootstrapped verification", + `encryption setup stdout: ${setupArtifacts.stdoutPath}`, + `encryption setup stderr: ${setupArtifacts.stderrPath}`, + `verify status stdout: ${statusArtifacts.stdoutPath}`, + `verify status stderr: ${statusArtifacts.stderrPath}`, + `cli device: ${status.deviceId ?? cliDevice.deviceId}`, + `cli verified by owner: ${status.verified ? "yes" : "no"}`, + `cli backup usable: ${isMatrixQaCliBackupUsable(status.backup) ? "yes" : "no"}`, + ].join("\n"), + }; + } finally { + await cli.dispose(); + } +} + +export async function runMatrixQaE2eeCliEncryptionSetupIdempotentScenario( + context: MatrixQaScenarioContext, +): Promise { + const accountId = "cli-encryption-idempotent"; + const account = await registerMatrixQaCliE2eeAccount({ + context, + deviceName: "OpenClaw Matrix QA CLI Encryption Idempotent Owner", + scenarioId: "matrix-e2ee-cli-encryption-setup-idempotent", + }); + const loginClient = createMatrixQaClient({ + baseUrl: context.baseUrl, + }); + const cliDevice = await loginClient.loginWithPassword({ + deviceName: "OpenClaw Matrix QA CLI Encryption Idempotent Device", + password: account.password, + userId: account.userId, + }); + if (!cliDevice.deviceId) { + throw new Error("Matrix E2EE CLI idempotent setup login did not return a device id"); + } + const cli = await createMatrixQaCliE2eeSetupRuntime({ + artifactLabel: "cli-encryption-setup-idempotent", + context, + initialConfig: buildMatrixQaCliE2eeAccountConfig({ + accountId, + accessToken: cliDevice.accessToken, + baseUrl: context.baseUrl, + deviceId: cliDevice.deviceId, + encryption: true, + name: "Matrix QA CLI Encryption Setup Idempotent", + password: account.password, + userId: cliDevice.userId, + }), + }); + try { + const setupArgs = ["matrix", "encryption", "setup", "--account", accountId, "--json"]; + const firstResult = await cli.run(setupArgs); + const firstArtifacts = await writeMatrixQaCliOutputArtifacts({ + label: "encryption-setup-first", + result: firstResult, + rootDir: cli.rootDir, + }); + const first = parseMatrixQaCliJson(firstResult) as MatrixQaCliEncryptionSetupStatus; + if ( + first.accountId !== accountId || + first.success !== true || + first.encryptionChanged !== false || + first.bootstrap?.success !== true || + !first.status + ) { + throw new Error( + `Matrix CLI encryption setup was not idempotent on first run: ${first.bootstrap?.error ?? "unknown error"}`, + ); + } + assertMatrixQaCliE2eeStatus("Matrix CLI encryption setup idempotent first run", first.status); + + const secondResult = await cli.run(setupArgs); + const secondArtifacts = await writeMatrixQaCliOutputArtifacts({ + label: "encryption-setup-second", + result: secondResult, + rootDir: cli.rootDir, + }); + const second = parseMatrixQaCliJson(secondResult) as MatrixQaCliEncryptionSetupStatus; + if ( + second.accountId !== accountId || + second.success !== true || + second.encryptionChanged !== false || + second.bootstrap?.success !== true || + !second.status + ) { + throw new Error( + `Matrix CLI encryption setup was not idempotent on second run: ${second.bootstrap?.error ?? "unknown error"}`, + ); + } + assertMatrixQaCliE2eeStatus("Matrix CLI encryption setup idempotent second run", second.status); + + return { + artifacts: { + accountId, + cliDeviceId: second.status.deviceId ?? cliDevice.deviceId, + firstEncryptionChanged: first.encryptionChanged, + secondEncryptionChanged: second.encryptionChanged, + setupSuccess: second.success, + verificationBootstrapSuccess: second.bootstrap.success, + }, + details: [ + "Matrix CLI encryption setup stayed idempotent on an already encrypted account", + `first setup stdout: ${firstArtifacts.stdoutPath}`, + `first setup stderr: ${firstArtifacts.stderrPath}`, + `second setup stdout: ${secondArtifacts.stdoutPath}`, + `second setup stderr: ${secondArtifacts.stderrPath}`, + `cli device: ${second.status.deviceId ?? cliDevice.deviceId}`, + `first encryption changed: ${first.encryptionChanged ? "yes" : "no"}`, + `second encryption changed: ${second.encryptionChanged ? "yes" : "no"}`, + ].join("\n"), + }; + } finally { + await cli.dispose(); + } +} + +export async function runMatrixQaE2eeCliEncryptionSetupBootstrapFailureScenario( + context: MatrixQaScenarioContext, +): Promise { + const accountId = "cli-encryption-failure"; + const account = await registerMatrixQaCliE2eeAccount({ + context, + deviceName: "OpenClaw Matrix QA CLI Encryption Failure Owner", + scenarioId: "matrix-e2ee-cli-encryption-setup-bootstrap-failure", + }); + const loginClient = createMatrixQaClient({ + baseUrl: context.baseUrl, + }); + const cliDevice = await loginClient.loginWithPassword({ + deviceName: "OpenClaw Matrix QA CLI Encryption Failure Device", + password: account.password, + userId: account.userId, + }); + if (!cliDevice.deviceId) { + throw new Error("Matrix E2EE CLI bootstrap-failure login did not return a device id"); + } + const proxy = await startMatrixQaFaultProxy({ + targetBaseUrl: context.baseUrl, + rules: [buildRoomKeyBackupUnavailableFaultRule(cliDevice.accessToken)], + }); + const cli = await createMatrixQaCliE2eeSetupRuntime({ + artifactLabel: "cli-encryption-setup-bootstrap-failure", + context, + initialConfig: buildMatrixQaCliE2eeAccountConfig({ + accountId, + accessToken: cliDevice.accessToken, + baseUrl: proxy.baseUrl, + deviceId: cliDevice.deviceId, + encryption: false, + name: "Matrix QA CLI Encryption Setup Bootstrap Failure", + password: account.password, + userId: cliDevice.userId, + }), + }); + try { + const failed = await runMatrixQaCliExpectedFailure({ + args: ["matrix", "encryption", "setup", "--account", accountId, "--json"], + start: cli.start, + timeoutMs: context.timeoutMs, + }); + const artifacts = await writeMatrixQaCliOutputArtifacts({ + label: "encryption-setup-bootstrap-failure", + result: failed, + rootDir: cli.rootDir, + }); + const payload = parseMatrixQaCliJson(failed) as MatrixQaCliEncryptionSetupStatus; + if (payload.success !== false && payload.bootstrap?.success !== false) { + throw new Error("Matrix CLI encryption setup failure did not report unsuccessful bootstrap"); + } + const faultHits = proxy.hits(); + if (faultHits.length === 0) { + throw new Error("Matrix CLI encryption setup bootstrap-failure proxy was not exercised"); + } + const bootstrapError = payload.bootstrap?.error ?? ""; + if (!bootstrapError.toLowerCase().includes("room key backup")) { + throw new Error( + `Matrix CLI encryption setup failed for an unexpected reason: ${bootstrapError}`, + ); + } + + return { + artifacts: { + accountId, + bootstrapErrorPreview: bootstrapError.slice(0, 240), + bootstrapSuccess: false, + cliDeviceId: cliDevice.deviceId, + faultedEndpoint: faultHits[0]?.path, + faultHitCount: faultHits.length, + faultRuleId: MATRIX_QA_ROOM_KEY_BACKUP_FAULT_RULE_ID, + }, + details: [ + "Matrix CLI encryption setup surfaced a bootstrap failure from a faulted room-key backup endpoint", + `failure stdout: ${artifacts.stdoutPath}`, + `failure stderr: ${artifacts.stderrPath}`, + `fault hits: ${faultHits.length}`, + `fault endpoint: ${faultHits[0]?.path ?? ""}`, + `bootstrap error: ${bootstrapError}`, + ].join("\n"), + }; + } finally { + await Promise.all([cli.dispose(), proxy.stop().catch(() => undefined)]); + } +} + +export async function runMatrixQaE2eeCliRecoveryKeySetupScenario( + context: MatrixQaScenarioContext, +): Promise { + const accountId = "cli-recovery-key-setup"; + const account = await registerMatrixQaCliE2eeAccount({ + context, + deviceName: "OpenClaw Matrix QA CLI Recovery Key Owner", + scenarioId: "matrix-e2ee-cli-recovery-key-setup", + }); + const owner = await createMatrixQaE2eeCliOwnerClient({ + account, + context, + scenarioId: "matrix-e2ee-cli-recovery-key-setup", + }); + const loginClient = createMatrixQaClient({ + baseUrl: context.baseUrl, + }); + const ready = await ensureMatrixQaE2eeOwnDeviceVerified({ + client: owner, + label: "driver", + }); + const encodedRecoveryKey = ready.recoveryKey?.encodedPrivateKey?.trim(); + if (!encodedRecoveryKey) { + await owner.stop().catch(() => undefined); + throw new Error("Matrix E2EE CLI recovery-key setup did not expose a recovery key"); + } + const cliDevice = await loginClient.loginWithPassword({ + deviceName: "OpenClaw Matrix QA CLI Recovery Key Setup Device", + password: account.password, + userId: account.userId, + }); + if (!cliDevice.deviceId) { + await owner.stop().catch(() => undefined); + throw new Error("Matrix E2EE CLI recovery-key setup login did not return a device id"); + } + const cli = await createMatrixQaCliE2eeSetupRuntime({ + artifactLabel: "cli-recovery-key-setup", + context, + initialConfig: buildMatrixQaCliE2eeAccountConfig({ + accountId, + accessToken: cliDevice.accessToken, + baseUrl: context.baseUrl, + deviceId: cliDevice.deviceId, + encryption: false, + name: "Matrix QA CLI Recovery Key Setup", + password: account.password, + userId: cliDevice.userId, + }), + }); + try { + const setupResult = await cli.run([ + "matrix", + "encryption", + "setup", + "--account", + accountId, + "--recovery-key", + encodedRecoveryKey, + "--json", + ]); + const setupArtifacts = await writeMatrixQaCliOutputArtifacts({ + label: "recovery-key-setup", + result: setupResult, + rootDir: cli.rootDir, + }); + const setup = parseMatrixQaCliJson(setupResult) as MatrixQaCliEncryptionSetupStatus; + if ( + setup.accountId !== accountId || + setup.success !== true || + setup.encryptionChanged !== true || + setup.bootstrap?.success !== true || + !setup.status + ) { + throw new Error( + `Matrix CLI recovery-key encryption setup did not succeed: ${setup.bootstrap?.error ?? "unknown error"}`, + ); + } + assertMatrixQaCliE2eeStatus("Matrix CLI recovery-key encryption setup", setup.status, { + allowUntrustedMatchingKey: true, + }); + + return { + artifacts: { + accountId, + backupVersion: setup.status.backupVersion ?? ready.verification.backupVersion ?? null, + cliDeviceId: setup.status.deviceId ?? cliDevice.deviceId, + encryptionChanged: setup.encryptionChanged, + recoveryKeyId: ready.recoveryKey?.keyId ?? null, + recoveryKeyStored: true, + setupSuccess: setup.success, + verificationBootstrapSuccess: setup.bootstrap.success, + }, + details: [ + "Matrix CLI encryption setup accepted a recovery key on a second device", + `recovery setup stdout: ${setupArtifacts.stdoutPath}`, + `recovery setup stderr: ${setupArtifacts.stderrPath}`, + `owner backup version: ${ready.verification.backupVersion ?? ""}`, + `recovery key id: ${ready.recoveryKey?.keyId ?? ""}`, + `cli device: ${setup.status.deviceId ?? cliDevice.deviceId}`, + `cli verified by owner: ${setup.status.verified ? "yes" : "no"}`, + `cli backup usable: ${ + isMatrixQaCliBackupUsable(setup.status.backup, { allowUntrustedMatchingKey: true }) + ? "yes" + : "no" + }`, + ].join("\n"), + }; + } finally { + try { + await owner.stop().catch(() => undefined); + await owner.deleteOwnDevices([cliDevice.deviceId]).catch(() => undefined); + } finally { + await cli.dispose(); + } + } +} + +export async function runMatrixQaE2eeCliRecoveryKeyInvalidScenario( + context: MatrixQaScenarioContext, +): Promise { + const accountId = "cli-invalid-recovery-key"; + const invalidRecoveryKey = "not-a-valid-matrix-recovery-key"; + const account = await registerMatrixQaCliE2eeAccount({ + context, + deviceName: "OpenClaw Matrix QA CLI Invalid Recovery Key Owner", + scenarioId: "matrix-e2ee-cli-recovery-key-invalid", + }); + const owner = await createMatrixQaE2eeCliOwnerClient({ + account, + context, + scenarioId: "matrix-e2ee-cli-recovery-key-invalid", + }); + const ready = await ensureMatrixQaE2eeOwnDeviceVerified({ + client: owner, + label: "cli invalid recovery-key owner", + }); + if (!ready.recoveryKey?.encodedPrivateKey?.trim()) { + await owner.stop().catch(() => undefined); + throw new Error("Matrix E2EE CLI invalid recovery-key setup did not seed secret storage"); + } + const loginClient = createMatrixQaClient({ + baseUrl: context.baseUrl, + }); + const cliDevice = await loginClient.loginWithPassword({ + deviceName: "OpenClaw Matrix QA CLI Invalid Recovery Key Device", + password: account.password, + userId: account.userId, + }); + if (!cliDevice.deviceId) { + await owner.stop().catch(() => undefined); + throw new Error("Matrix E2EE CLI invalid recovery-key login did not return a device id"); + } + const cli = await createMatrixQaCliE2eeSetupRuntime({ + artifactLabel: "cli-recovery-key-invalid", + context, + initialConfig: buildMatrixQaCliE2eeAccountConfig({ + accountId, + accessToken: cliDevice.accessToken, + baseUrl: context.baseUrl, + deviceId: cliDevice.deviceId, + encryption: false, + name: "Matrix QA CLI Invalid Recovery Key", + password: account.password, + userId: cliDevice.userId, + }), + }); + try { + const failed = await runMatrixQaCliExpectedFailure({ + args: [ + "matrix", + "encryption", + "setup", + "--account", + accountId, + "--recovery-key", + invalidRecoveryKey, + "--json", + ], + start: cli.start, + timeoutMs: context.timeoutMs, + }); + const artifacts = await writeMatrixQaCliOutputArtifacts({ + label: "recovery-key-invalid", + result: failed, + rootDir: cli.rootDir, + }); + const payload = parseMatrixQaCliJson(failed) as MatrixQaCliEncryptionSetupStatus & { + error?: string; + }; + if (payload.success !== false && payload.bootstrap?.success !== false) { + throw new Error("Matrix CLI invalid recovery-key setup did not report failure"); + } + const failure = payload.bootstrap?.error ?? payload.error ?? ""; + if (!/recovery|secret|key/i.test(failure)) { + throw new Error( + `Matrix CLI invalid recovery-key setup failed for an unexpected reason: ${failure}`, + ); + } + if (failed.stdout.includes(invalidRecoveryKey) || failed.stderr.includes(invalidRecoveryKey)) { + throw new Error("Matrix CLI invalid recovery-key output leaked the recovery key"); + } + + return { + artifacts: { + accountId, + bootstrapErrorPreview: failure.slice(0, 240), + bootstrapSuccess: false, + cliDeviceId: cliDevice.deviceId, + encryptionChanged: payload.encryptionChanged, + recoveryKeyAccepted: false, + recoveryKeyRejected: true, + setupSuccess: false, + }, + details: [ + "Matrix CLI encryption setup rejected an invalid recovery key without leaking it", + `failure stdout: ${artifacts.stdoutPath}`, + `failure stderr: ${artifacts.stderrPath}`, + `cli device: ${cliDevice.deviceId}`, + `failure: ${failure}`, + ].join("\n"), + }; + } finally { + try { + await owner.stop().catch(() => undefined); + await owner.deleteOwnDevices([cliDevice.deviceId]).catch(() => undefined); + } finally { + await cli.dispose(); + } + } +} + +export async function runMatrixQaE2eeCliEncryptionSetupMultiAccountScenario( + context: MatrixQaScenarioContext, +): Promise { + const accountId = "cli-multi-target"; + const decoyAccountId = "cli-multi-decoy"; + const account = await registerMatrixQaCliE2eeAccount({ + context, + deviceName: "OpenClaw Matrix QA CLI Multi Account Owner", + scenarioId: "matrix-e2ee-cli-encryption-setup-multi-account", + }); + const loginClient = createMatrixQaClient({ + baseUrl: context.baseUrl, + }); + const cliDevice = await loginClient.loginWithPassword({ + deviceName: "OpenClaw Matrix QA CLI Multi Account Target Device", + password: account.password, + userId: account.userId, + }); + if (!cliDevice.deviceId) { + throw new Error("Matrix E2EE CLI multi-account setup login did not return a device id"); + } + const cli = await createMatrixQaCliE2eeSetupRuntime({ + artifactLabel: "cli-encryption-setup-multi-account", + context, + initialConfig: { + ...buildMatrixQaPluginActivationConfig(), + channels: { + matrix: { + defaultAccount: decoyAccountId, + accounts: { + [decoyAccountId]: { + accessToken: "decoy-token", + deviceId: "DECOYDEVICE", + encryption: false, + homeserver: context.baseUrl, + initialSyncLimit: 1, + name: "Matrix QA CLI Multi Account Decoy", + startupVerification: "off", + userId: "@decoy:matrix-qa.test", + }, + [accountId]: { + accessToken: cliDevice.accessToken, + deviceId: cliDevice.deviceId, + encryption: false, + homeserver: context.baseUrl, + initialSyncLimit: 1, + name: "Matrix QA CLI Multi Account Target", + network: { + dangerouslyAllowPrivateNetwork: true, + }, + password: account.password, + startupVerification: "off", + userId: cliDevice.userId, + }, + }, + }, + }, + }, + }); + try { + const setupResult = await cli.run([ + "matrix", + "encryption", + "setup", + "--account", + accountId, + "--json", + ]); + const setupArtifacts = await writeMatrixQaCliOutputArtifacts({ + label: "encryption-setup-multi-account", + result: setupResult, + rootDir: cli.rootDir, + }); + const setup = parseMatrixQaCliJson(setupResult) as MatrixQaCliEncryptionSetupStatus; + if ( + setup.accountId !== accountId || + setup.success !== true || + setup.encryptionChanged !== true || + setup.bootstrap?.success !== true || + !setup.status + ) { + throw new Error( + `Matrix CLI multi-account encryption setup did not target the requested account: ${setup.bootstrap?.error ?? "unknown error"}`, + ); + } + assertMatrixQaCliE2eeStatus("Matrix CLI multi-account encryption setup", setup.status); + + const config = await readMatrixQaCliConfig(cli.configPath); + const matrix = config.channels?.matrix; + const target = matrix?.accounts?.[accountId]; + const decoy = matrix?.accounts?.[decoyAccountId]; + const defaultAccountPreserved = matrix?.defaultAccount === decoyAccountId; + const decoyAccountPreserved = + decoy?.encryption === false && + decoy?.accessToken === "decoy-token" && + decoy?.deviceId === "DECOYDEVICE"; + if (!defaultAccountPreserved) { + throw new Error("Matrix CLI multi-account setup changed the default account"); + } + if (!decoyAccountPreserved) { + throw new Error("Matrix CLI multi-account setup mutated the decoy account"); + } + if (target?.encryption !== true) { + throw new Error("Matrix CLI multi-account setup did not enable encryption on the target"); + } + + return { + artifacts: { + accountId, + cliDeviceId: setup.status.deviceId ?? cliDevice.deviceId, + decoyAccountPreserved, + defaultAccountPreserved, + encryptionChanged: setup.encryptionChanged, + setupSuccess: setup.success, + verificationBootstrapSuccess: setup.bootstrap.success, + }, + details: [ + "Matrix CLI encryption setup changed only the requested account in a multi-account config", + `setup stdout: ${setupArtifacts.stdoutPath}`, + `setup stderr: ${setupArtifacts.stderrPath}`, + `default account preserved: ${defaultAccountPreserved ? "yes" : "no"}`, + `decoy account preserved: ${decoyAccountPreserved ? "yes" : "no"}`, + `cli device: ${setup.status.deviceId ?? cliDevice.deviceId}`, + ].join("\n"), + }; + } finally { + await cli.dispose(); + } +} + +export async function runMatrixQaE2eeCliSetupThenGatewayReplyScenario( + context: MatrixQaScenarioContext, +): Promise { + if (!context.restartGatewayAfterStateMutation) { + throw new Error( + "Matrix CLI setup gateway reply scenario requires hard gateway restart support", + ); + } + const gatewayConfigPath = requireMatrixQaGatewayConfigPath(context); + const accountId = "cli-setup-gateway"; + const scenarioId = "matrix-e2ee-cli-setup-then-gateway-reply"; + const roomKey = buildMatrixQaE2eeScenarioRoomKey(scenarioId); + const account = await registerMatrixQaCliE2eeAccount({ + context, + deviceName: "OpenClaw Matrix QA CLI Setup Gateway", + scenarioId, + }); + const driverAccount = await registerMatrixQaCliE2eeAccount({ + context, + deviceName: "OpenClaw Matrix QA CLI Setup Driver", + scenarioId, + }); + const driverApi = createMatrixQaClient({ + accessToken: driverAccount.accessToken, + baseUrl: context.baseUrl, + }); + const gatewayApi = createMatrixQaClient({ + accessToken: account.accessToken, + baseUrl: context.baseUrl, + }); + const roomId = await driverApi.createPrivateRoom({ + encrypted: true, + inviteUserIds: [account.userId], + name: "Matrix QA CLI Setup Gateway E2EE", + }); + await gatewayApi.joinRoom(roomId); + + const accountConfig = { + accessToken: account.accessToken, + deviceId: account.deviceId, + dm: { + allowFrom: [driverAccount.userId], + enabled: true, + policy: "allowlist", + sessionScope: "per-room", + threadReplies: "inbound", + }, + enabled: true, + encryption: false, + groupAllowFrom: [driverAccount.userId], + groupPolicy: "allowlist", + groups: { + [roomId]: { + enabled: true, + requireMention: true, + }, + }, + homeserver: context.baseUrl, + initialSyncLimit: 1, + name: "Matrix QA CLI Setup Gateway", + network: { + dangerouslyAllowPrivateNetwork: true, + }, + password: account.password, + startupVerification: "off", + threadReplies: "inbound", + userId: account.userId, + }; + await context.restartGatewayAfterStateMutation( + async () => { + await replaceMatrixQaGatewayMatrixAccount({ + accountConfig, + accountId, + configPath: gatewayConfigPath, + }); + }, + { + timeoutMs: context.timeoutMs, + waitAccountId: accountId, + }, + ); + await context.waitGatewayAccountReady?.(accountId, { + timeoutMs: context.timeoutMs, + }); + const cli = await createMatrixQaCliGatewayRuntime({ + artifactLabel: "cli-setup-then-gateway-reply", + context, + }); + try { + const setupResult = await cli.run([ + "matrix", + "encryption", + "setup", + "--account", + accountId, + "--json", + ]); + const setupArtifacts = await writeMatrixQaCliOutputArtifacts({ + label: "encryption-setup", + result: setupResult, + rootDir: cli.rootDir, + }); + const setup = parseMatrixQaCliJson(setupResult) as MatrixQaCliEncryptionSetupStatus; + if ( + setup.accountId !== accountId || + setup.success !== true || + setup.bootstrap?.success !== true + ) { + throw new Error( + `Matrix CLI gateway account setup did not succeed: ${setup.bootstrap?.error ?? "unknown error"}`, + ); + } + if (setup.status) { + assertMatrixQaCliE2eeStatus("Matrix CLI gateway account setup", setup.status); + } + await context.restartGatewayAfterStateMutation( + async () => { + await patchMatrixQaGatewayMatrixAccount({ + accountPatch: { + encryption: true, + password: account.password, + }, + accountId, + configPath: gatewayConfigPath, + }); + }, + { + timeoutMs: context.timeoutMs, + waitAccountId: accountId, + }, + ); + await context.waitGatewayAccountReady?.(accountId, { + timeoutMs: context.timeoutMs, + }); + const driverClient = await createMatrixQaE2eeScenarioClient({ + accessToken: driverAccount.accessToken, + actorId: `driver-cli-setup-gateway-${randomUUID().slice(0, 8)}`, + baseUrl: context.baseUrl, + deviceId: driverAccount.deviceId, + observedEvents: context.observedEvents, + outputDir: requireMatrixQaE2eeOutputDir(context), + password: driverAccount.password, + scenarioId, + timeoutMs: context.timeoutMs, + userId: driverAccount.userId, + }); + const replied = await (async () => { + try { + await ensureMatrixQaE2eeOwnDeviceVerified({ + client: driverClient, + label: "Matrix CLI setup scenario driver", + }); + await driverClient.waitForJoinedMember({ + roomId, + timeoutMs: context.timeoutMs, + userId: account.userId, + }); + await driverClient.prime(); + const token = buildMatrixQaToken("MATRIX_QA_E2EE_CLI_GATEWAY"); + const driverEventId = await driverClient.sendTextMessage({ + body: buildMentionPrompt(account.userId, token), + mentionUserIds: [account.userId], + roomId, + }); + const matched = await driverClient.waitForRoomEvent({ + predicate: (event) => + isMatrixQaExactMarkerReply(event, { + roomId, + sutUserId: account.userId, + token, + }) && event.relatesTo === undefined, + roomId, + timeoutMs: context.timeoutMs, + }); + const reply = buildMatrixE2eeReplyArtifact(matched.event, token); + assertTopLevelReplyArtifact("gateway reply", reply); + return { + driverEventId, + reply, + }; + } finally { + await driverClient.stop(); + } + })(); + + return { + artifacts: { + accountId, + cliDeviceId: setup.status?.deviceId ?? account.deviceId ?? null, + driverUserId: driverAccount.userId, + encryptionChanged: setup.encryptionChanged, + gatewayReply: replied.reply, + gatewayUserId: account.userId, + roomKey, + roomId, + setupSuccess: setup.success, + verificationBootstrapSuccess: setup.bootstrap.success, + }, + details: [ + "Matrix CLI encryption setup left the gateway able to reply in an encrypted room", + `setup stdout: ${setupArtifacts.stdoutPath}`, + `setup stderr: ${setupArtifacts.stderrPath}`, + `driver user: ${driverAccount.userId}`, + `gateway user: ${account.userId}`, + `encrypted room key: ${roomKey}`, + `encrypted room id: ${roomId}`, + `driver event: ${replied.driverEventId}`, + ...buildMatrixReplyDetails("gateway reply", replied.reply), + ].join("\n"), + }; + } finally { + await cli.dispose(); + } +} + export async function runMatrixQaE2eeCliSelfVerificationScenario( context: MatrixQaScenarioContext, ): Promise { - const driverPassword = requireMatrixQaPassword(context, "driver"); const accountId = "cli"; - return await withMatrixQaE2eeDriver( + const account = await registerMatrixQaCliE2eeAccount({ context, - "matrix-e2ee-cli-self-verification", - async (owner) => { - const ownerReady = await ensureMatrixQaE2eeOwnDeviceVerified({ - client: owner, - label: "driver", - }); - const encodedRecoveryKey = ownerReady.recoveryKey?.encodedPrivateKey?.trim(); - if (!encodedRecoveryKey) { - throw new Error("Matrix E2EE self-verification scenario did not expose a recovery key"); - } - const loginClient = createMatrixQaClient({ - baseUrl: context.baseUrl, - }); - const cliDevice = await loginClient.loginWithPassword({ - deviceName: "OpenClaw Matrix QA CLI Self Verification Device", - password: driverPassword, - userId: context.driverUserId, - }); - if (!cliDevice.deviceId) { - throw new Error("Matrix E2EE CLI verification login did not return a device id"); - } + deviceName: "OpenClaw Matrix QA CLI Self Verification Owner", + scenarioId: "matrix-e2ee-cli-self-verification", + }); + const owner = await createMatrixQaE2eeCliOwnerClient({ + account, + context, + scenarioId: "matrix-e2ee-cli-self-verification", + }); + try { + const ownerReady = await ensureMatrixQaE2eeOwnDeviceVerified({ + client: owner, + label: "CLI self-verification owner", + }); + const encodedRecoveryKey = ownerReady.recoveryKey?.encodedPrivateKey?.trim(); + if (!encodedRecoveryKey) { + throw new Error("Matrix E2EE self-verification scenario did not expose a recovery key"); + } + const loginClient = createMatrixQaClient({ + baseUrl: context.baseUrl, + }); + const cliDevice = await loginClient.loginWithPassword({ + deviceName: "OpenClaw Matrix QA CLI Self Verification Device", + password: account.password, + userId: account.userId, + }); + if (!cliDevice.deviceId) { + throw new Error("Matrix E2EE CLI verification login did not return a device id"); + } - const cli = await createMatrixQaCliSelfVerificationRuntime({ - accountId, - accessToken: cliDevice.accessToken, - context, - deviceId: cliDevice.deviceId, - userId: cliDevice.userId, + const cli = await createMatrixQaCliSelfVerificationRuntime({ + accountId, + accessToken: cliDevice.accessToken, + context, + deviceId: cliDevice.deviceId, + userId: cliDevice.userId, + }); + try { + const restoreResult = await cli.run( + [ + "matrix", + "verify", + "backup", + "restore", + "--account", + accountId, + "--recovery-key-stdin", + "--json", + ], + context.timeoutMs, + `${encodedRecoveryKey}\n`, + ); + const restoreArtifacts = await writeMatrixQaCliOutputArtifacts({ + label: "verify-backup-restore", + result: restoreResult, + rootDir: cli.rootDir, }); - try { - const restoreResult = await cli.run( - [ - "matrix", - "verify", - "backup", - "restore", - "--account", - accountId, - "--recovery-key-stdin", - "--json", - ], - context.timeoutMs, - `${encodedRecoveryKey}\n`, + const restored = parseMatrixQaCliJson(restoreResult) as MatrixQaCliBackupRestoreStatus; + if ( + restored.success !== true || + restored.backup?.decryptionKeyCached !== true || + restored.backup?.matchesDecryptionKey !== true || + restored.backup?.keyLoadError + ) { + throw new Error( + `Matrix CLI recovery key did not load matching room-key backup material before self-verification: ${ + restored.error ?? restored.backup?.keyLoadError ?? "unknown backup state" + }`, ); - const restoreArtifacts = await writeMatrixQaCliOutputArtifacts({ - label: "verify-backup-restore", - result: restoreResult, + } + const session = cli.start( + [ + "matrix", + "verify", + "self", + "--account", + accountId, + "--timeout-ms", + String(context.timeoutMs), + ], + context.timeoutMs * 2, + ); + try { + const requestOutput = await session.waitForOutput( + (output) => output.text.includes("Accept this verification request"), + "self-verification request guidance", + context.timeoutMs, + ); + const cliTransactionId = parseMatrixQaCliSummaryField(requestOutput.text, "Transaction id"); + const ownerRequested = await waitForMatrixQaVerificationSummary({ + client: owner, + label: "owner received CLI self-verification request", + predicate: (summary) => + isMatrixQaCliOwnerSelfVerification({ + cliDeviceId: cliTransactionId ? undefined : cliDevice.deviceId, + ownerUserId: account.userId, + requirePending: true, + summary, + transactionId: cliTransactionId ?? undefined, + }), + timeoutMs: context.timeoutMs, + }); + if (ownerRequested.canAccept) { + await owner.acceptVerification(ownerRequested.id); + } + + const sasOutput = await session.waitForOutput( + (output) => /^SAS (?:emoji|decimals):/m.test(output.text), + "SAS emoji or decimals", + context.timeoutMs, + ); + const cliSas = parseMatrixQaCliSasText( + sasOutput.text, + "interactive openclaw matrix verify self", + ); + const ownerSas = await waitForMatrixQaVerificationSummary({ + client: owner, + label: "owner SAS for CLI self-verification", + predicate: (summary) => + isMatrixQaCliOwnerSelfVerification({ + cliDeviceId: cliTransactionId ? undefined : cliDevice.deviceId, + ownerUserId: account.userId, + requireSas: true, + summary, + transactionId: cliTransactionId ?? undefined, + }), + timeoutMs: context.timeoutMs, + }); + const sasArtifact = assertMatrixQaCliSasMatches({ + cliSas, + owner: ownerSas, + }); + const ownerConfirm = owner.confirmVerificationSas(ownerSas.id); + await session.writeStdin("yes\n"); + session.endStdin(); + await ownerConfirm; + const completedCli = await session.wait(); + const selfVerificationArtifacts = await writeMatrixQaCliOutputArtifacts({ + label: "verify-self", + result: completedCli, rootDir: cli.rootDir, }); - const restored = parseMatrixQaCliJson(restoreResult) as MatrixQaCliBackupRestoreStatus; + if (!/^Device verified by owner:\s*yes$/m.test(completedCli.stdout)) { + throw new Error( + "Interactive Matrix CLI self-verification did not report final device verification", + ); + } + if (!/^Cross-signing verified:\s*yes$/m.test(completedCli.stdout)) { + throw new Error( + "Interactive Matrix CLI self-verification did not report full Matrix identity trust", + ); + } + const completedOwner = await waitForMatrixQaVerificationSummary({ + client: owner, + label: "owner completed CLI self-verification", + predicate: (summary) => + isMatrixQaCliOwnerSelfVerification({ + cliDeviceId: cliTransactionId ? undefined : cliDevice.deviceId, + ownerUserId: account.userId, + requireCompleted: true, + summary, + transactionId: cliTransactionId ?? undefined, + }), + timeoutMs: context.timeoutMs, + }); + const cliVerificationId = + completedCli.stdout.match(/^Verification id:\s*(\S+)/m)?.[1] ?? "interactive-cli"; + const statusResult = await cli.run([ + "matrix", + "verify", + "status", + "--account", + accountId, + "--json", + ]); + const statusArtifacts = await writeMatrixQaCliOutputArtifacts({ + label: "verify-status", + result: statusResult, + rootDir: cli.rootDir, + }); + const status = parseMatrixQaCliJson(statusResult) as MatrixQaCliVerificationStatus; if ( - restored.success !== true || - restored.backup?.decryptionKeyCached !== true || - restored.backup?.matchesDecryptionKey !== true || - restored.backup?.keyLoadError + status.verified !== true || + status.crossSigningVerified !== true || + status.signedByOwner !== true || + status.backup?.trusted !== true || + status.backup?.matchesDecryptionKey !== true || + status.backup?.keyLoadError ) { throw new Error( - `Matrix CLI recovery key did not load matching room-key backup material before self-verification: ${ - restored.error ?? restored.backup?.keyLoadError ?? "unknown backup state" + `Matrix CLI device was not fully usable after SAS completion: ownerVerified=${ + status.verified === true && + status.crossSigningVerified === true && + status.signedByOwner === true + ? "yes" + : "no" + }, backupUsable=${isMatrixQaCliBackupUsable(status.backup) ? "yes" : "no"}${ + status.backup?.keyLoadError ? `, backupError=${status.backup.keyLoadError}` : "" }`, ); } - const session = cli.start(["matrix", "verify", "self", "--account", accountId]); - try { - const requestOutput = await session.waitForOutput( - (output) => output.text.includes("Accept this verification request"), - "self-verification request guidance", - context.timeoutMs, - ); - const cliTransactionId = parseMatrixQaCliSummaryField( - requestOutput.text, - "Transaction id", - ); - const ownerRequested = await waitForMatrixQaVerificationSummary({ - client: owner, - label: "owner received CLI self-verification request", - predicate: (summary) => - isMatrixQaCliOwnerSelfVerification({ - cliDeviceId: cliTransactionId ? undefined : cliDevice.deviceId, - driverUserId: context.driverUserId, - requirePending: true, - summary, - transactionId: cliTransactionId ?? undefined, - }), - timeoutMs: context.timeoutMs, - }); - if (ownerRequested.canAccept) { - await owner.acceptVerification(ownerRequested.id); - } - - const sasOutput = await session.waitForOutput( - (output) => /^SAS (?:emoji|decimals):/m.test(output.text), - "SAS emoji or decimals", - context.timeoutMs, - ); - const cliSas = parseMatrixQaCliSasText( - sasOutput.text, - "interactive openclaw matrix verify self", - ); - const ownerSas = await waitForMatrixQaVerificationSummary({ - client: owner, - label: "owner SAS for CLI self-verification", - predicate: (summary) => - isMatrixQaCliOwnerSelfVerification({ - cliDeviceId: cliTransactionId ? undefined : cliDevice.deviceId, - driverUserId: context.driverUserId, - requireSas: true, - summary, - transactionId: cliTransactionId ?? undefined, - }), - timeoutMs: context.timeoutMs, - }); - const sasArtifact = assertMatrixQaCliSasMatches({ - cliSas, - owner: ownerSas, - }); - await owner.confirmVerificationSas(ownerSas.id); - await session.writeStdin("yes\n"); - session.endStdin(); - const completedCli = await session.wait(); - const selfVerificationArtifacts = await writeMatrixQaCliOutputArtifacts({ - label: "verify-self", - result: completedCli, - rootDir: cli.rootDir, - }); - if (!/^Device verified by owner:\s*yes$/m.test(completedCli.stdout)) { - throw new Error( - "Interactive Matrix CLI self-verification did not report final device verification", - ); - } - if (!/^Cross-signing verified:\s*yes$/m.test(completedCli.stdout)) { - throw new Error( - "Interactive Matrix CLI self-verification did not report full Matrix identity trust", - ); - } - const completedOwner = await waitForMatrixQaVerificationSummary({ - client: owner, - label: "owner completed CLI self-verification", - predicate: (summary) => - isMatrixQaCliOwnerSelfVerification({ - cliDeviceId: cliTransactionId ? undefined : cliDevice.deviceId, - driverUserId: context.driverUserId, - requireCompleted: true, - summary, - transactionId: cliTransactionId ?? undefined, - }), - timeoutMs: context.timeoutMs, - }); - const cliVerificationId = - completedCli.stdout.match(/^Verification id:\s*(\S+)/m)?.[1] ?? "interactive-cli"; - const statusResult = await cli.run([ - "matrix", - "verify", - "status", - "--account", - accountId, - "--json", - ]); - const statusArtifacts = await writeMatrixQaCliOutputArtifacts({ - label: "verify-status", - result: statusResult, - rootDir: cli.rootDir, - }); - const status = parseMatrixQaCliJson(statusResult) as MatrixQaCliVerificationStatus; - if ( - status.verified !== true || - status.crossSigningVerified !== true || - status.signedByOwner !== true || - status.backup?.trusted !== true || - status.backup?.matchesDecryptionKey !== true || - status.backup?.keyLoadError - ) { - throw new Error( - `Matrix CLI device was not fully usable after SAS completion: ownerVerified=${ - status.verified === true && - status.crossSigningVerified === true && - status.signedByOwner === true - ? "yes" - : "no" - }, backupUsable=${isMatrixQaCliBackupUsable(status.backup) ? "yes" : "no"}${ - status.backup?.keyLoadError ? `, backupError=${status.backup.keyLoadError}` : "" - }`, - ); - } - return { - artifacts: { - completedVerificationIds: [cliVerificationId, completedOwner.id], - currentDeviceId: status.deviceId ?? cliDevice.deviceId, - ...(cliSas.kind === "emoji" ? { sasEmoji: sasArtifact } : {}), - secondaryDeviceId: cliDevice.deviceId, - }, - details: [ - "Matrix CLI self-verification established full Matrix identity trust through interactive openclaw matrix verify self", - "cli secret config cleaned after run: yes", - `cli backup restore stdout: ${restoreArtifacts.stdoutPath}`, - `cli backup restore stderr: ${restoreArtifacts.stderrPath}`, - `cli verify self stdout: ${selfVerificationArtifacts.stdoutPath}`, - `cli verify self stderr: ${selfVerificationArtifacts.stderrPath}`, - `cli verify status stdout: ${statusArtifacts.stdoutPath}`, - `cli verify status stderr: ${statusArtifacts.stderrPath}`, - `cli device: ${cliDevice.deviceId}`, - `cli verification id: ${cliVerificationId}`, - `owner-side verification id: ${completedOwner.id}`, - `transaction: ${completedOwner.transactionId ?? ""}`, - `cli verified by owner: ${status.verified ? "yes" : "no"}`, - `cli cross-signing verified: ${status.crossSigningVerified ? "yes" : "no"}`, - `cli backup usable: ${isMatrixQaCliBackupUsable(status.backup) ? "yes" : "no"}`, - ].join("\n"), - }; - } finally { - session.kill(); - } + return { + artifacts: { + completedVerificationIds: [cliVerificationId, completedOwner.id], + currentDeviceId: status.deviceId ?? cliDevice.deviceId, + ...(cliSas.kind === "emoji" ? { sasEmoji: sasArtifact } : {}), + secondaryDeviceId: cliDevice.deviceId, + }, + details: [ + "Matrix CLI self-verification established full Matrix identity trust through interactive openclaw matrix verify self", + "cli secret config cleaned after run: yes", + `cli backup restore stdout: ${restoreArtifacts.stdoutPath}`, + `cli backup restore stderr: ${restoreArtifacts.stderrPath}`, + `cli verify self stdout: ${selfVerificationArtifacts.stdoutPath}`, + `cli verify self stderr: ${selfVerificationArtifacts.stderrPath}`, + `cli verify status stdout: ${statusArtifacts.stdoutPath}`, + `cli verify status stderr: ${statusArtifacts.stderrPath}`, + `cli device: ${cliDevice.deviceId}`, + `cli verification id: ${cliVerificationId}`, + `owner-side verification id: ${completedOwner.id}`, + `transaction: ${completedOwner.transactionId ?? ""}`, + `cli verified by owner: ${status.verified ? "yes" : "no"}`, + `cli cross-signing verified: ${status.crossSigningVerified ? "yes" : "no"}`, + `cli backup usable: ${isMatrixQaCliBackupUsable(status.backup) ? "yes" : "no"}`, + ].join("\n"), + }; } finally { - try { - await cli.dispose(); - } finally { - await owner.deleteOwnDevices([cliDevice.deviceId]).catch(() => undefined); - } + session.kill(); } - }, - ); + } finally { + try { + await cli.dispose(); + } finally { + await owner.stop().catch(() => undefined); + await owner.deleteOwnDevices([cliDevice.deviceId]).catch(() => undefined); + } + } + } finally { + await owner.stop().catch(() => undefined); + } } export async function runMatrixQaE2eeDeviceSasVerificationScenario( @@ -1712,34 +3227,49 @@ export async function runMatrixQaE2eeRestartResumeScenario( if (!context.restartGateway) { throw new Error("Matrix E2EE restart scenario requires gateway restart support"); } - const first = await runMatrixQaE2eeTopLevelScenario(context, { - scenarioId: "matrix-e2ee-restart-resume", - tokenPrefix: "MATRIX_QA_E2EE_BEFORE_RESTART", - }); - await context.restartGateway(); - const recovered = await runMatrixQaE2eeTopLevelScenario(context, { - scenarioId: "matrix-e2ee-restart-resume", - tokenPrefix: "MATRIX_QA_E2EE_AFTER_RESTART", - }); - return { - artifacts: { - firstDriverEventId: first.driverEventId, - firstReply: first.reply, - recoveredDriverEventId: recovered.driverEventId, - recoveredReply: recovered.reply, - restartSignal: "gateway-restart", - roomKey: recovered.roomKey, - roomId: recovered.roomId, + const restartGateway = context.restartGateway; + return await withMatrixQaIsolatedE2eeDriverRoom( + context, + "matrix-e2ee-restart-resume", + async ({ client, driverUserId, roomId, roomKey }) => { + const first = await runMatrixQaE2eeTopLevelWithClient(context, { + client, + driverUserId, + roomId, + roomKey, + tokenPrefix: "MATRIX_QA_E2EE_BEFORE_RESTART", + }); + await restartGateway(); + const recovered = await runMatrixQaE2eeTopLevelWithClient(context, { + client, + driverUserId, + roomId, + roomKey, + tokenPrefix: "MATRIX_QA_E2EE_AFTER_RESTART", + }); + return { + artifacts: { + driverUserId, + firstDriverEventId: first.driverEventId, + firstReply: first.reply, + recoveredDriverEventId: recovered.driverEventId, + recoveredReply: recovered.reply, + restartSignal: "gateway-restart", + roomKey: recovered.roomKey, + roomId: recovered.roomId, + }, + details: [ + `encrypted room key: ${recovered.roomKey}`, + `encrypted room id: ${recovered.roomId}`, + `isolated driver user: ${driverUserId}`, + `pre-restart event: ${first.driverEventId}`, + ...buildMatrixReplyDetails("pre-restart reply", first.reply), + `post-restart event: ${recovered.driverEventId}`, + ...buildMatrixReplyDetails("post-restart reply", recovered.reply), + ].join("\n"), + }; }, - details: [ - `encrypted room key: ${recovered.roomKey}`, - `encrypted room id: ${recovered.roomId}`, - `pre-restart event: ${first.driverEventId}`, - ...buildMatrixReplyDetails("pre-restart reply", first.reply), - `post-restart event: ${recovered.driverEventId}`, - ...buildMatrixReplyDetails("post-restart reply", recovered.reply), - ].join("\n"), - }; + ); } export async function runMatrixQaE2eeVerificationNoticeNoTriggerScenario( @@ -1802,96 +3332,111 @@ export async function runMatrixQaE2eeVerificationNoticeNoTriggerScenario( export async function runMatrixQaE2eeArtifactRedactionScenario( context: MatrixQaScenarioContext, ): Promise { - const result = await runMatrixQaE2eeTopLevelScenario(context, { - scenarioId: "matrix-e2ee-artifact-redaction", - tokenPrefix: "MATRIX_QA_E2EE_REDACT", - }); - const leaked = context.observedEvents.some( - (event) => - event.roomId === result.roomId && - (event.body?.includes(result.token) || event.formattedBody?.includes(result.token)), - ); - if (!leaked) { - throw new Error("Matrix E2EE redaction scenario did not observe decrypted content in memory"); - } - return { - artifacts: { - driverEventId: result.driverEventId, - reply: result.reply, - roomKey: result.roomKey, - roomId: result.roomId, + return await withMatrixQaIsolatedE2eeDriverRoom( + context, + "matrix-e2ee-artifact-redaction", + async ({ client, driverUserId, roomId, roomKey }) => { + const result = await runMatrixQaE2eeTopLevelWithClient(context, { + client, + driverUserId, + roomId, + roomKey, + tokenPrefix: "MATRIX_QA_E2EE_REDACT", + }); + const leaked = context.observedEvents.some( + (event) => + event.roomId === result.roomId && + (event.body?.includes(result.token) || event.formattedBody?.includes(result.token)), + ); + if (!leaked) { + throw new Error( + "Matrix E2EE redaction scenario did not observe decrypted content in memory", + ); + } + return { + artifacts: { + driverEventId: result.driverEventId, + driverUserId, + reply: result.reply, + roomKey: result.roomKey, + roomId: result.roomId, + }, + details: [ + "decrypted E2EE payload reached in-memory assertions only", + "observed-event artifacts redact body/formatted_body unless OPENCLAW_QA_MATRIX_CAPTURE_CONTENT=1", + `encrypted room id: ${result.roomId}`, + `isolated driver user: ${driverUserId}`, + ...buildMatrixReplyDetails("E2EE reply", result.reply), + ].join("\n"), + }; }, - details: [ - "decrypted E2EE payload reached in-memory assertions only", - "observed-event artifacts redact body/formatted_body unless OPENCLAW_QA_MATRIX_CAPTURE_CONTENT=1", - `encrypted room id: ${result.roomId}`, - ...buildMatrixReplyDetails("E2EE reply", result.reply), - ].join("\n"), - }; + ); } export async function runMatrixQaE2eeMediaImageScenario( context: MatrixQaScenarioContext, ): Promise { - const { roomId, roomKey } = resolveMatrixQaE2eeScenarioGroupRoom( + return await withMatrixQaIsolatedE2eeDriverRoom( context, "matrix-e2ee-media-image", - ); - return await withMatrixQaE2eeDriver(context, "matrix-e2ee-media-image", async (client) => { - const startSince = await client.prime(); - const triggerBody = buildMatrixQaImageUnderstandingPrompt(context.sutUserId); - const driverEventId = await client.sendImageMessage({ - body: triggerBody, - buffer: createMatrixQaSplitColorImagePng(), - contentType: "image/png", - fileName: MATRIX_QA_IMAGE_ATTACHMENT_FILENAME, - mentionUserIds: [context.sutUserId], - roomId, - }); - const attachmentEvent = await client.waitForRoomEvent({ - predicate: (event) => - event.roomId === roomId && - event.eventId === driverEventId && - event.sender === context.driverUserId && - event.attachment?.kind === "image" && - event.attachment.caption === triggerBody, - roomId, - timeoutMs: context.timeoutMs, - }); - const matched = await client.waitForRoomEvent({ - predicate: (event) => - event.roomId === roomId && - event.sender === context.sutUserId && - event.type === "m.room.message" && - event.relatesTo === undefined && - hasMatrixQaExpectedColorReply(event.body), - roomId, - timeoutMs: context.timeoutMs, - }); - const reply: MatrixQaReplyArtifact = { - eventId: matched.event.eventId, - mentions: matched.event.mentions, - relatesTo: matched.event.relatesTo, - sender: matched.event.sender, - }; - return { - artifacts: { - attachmentFilename: MATRIX_QA_IMAGE_ATTACHMENT_FILENAME, - driverEventId, - reply, - roomKey, + async ({ client, driverUserId, roomId, roomKey }) => { + const startSince = await client.prime(); + const triggerBody = buildMatrixQaImageUnderstandingPrompt(context.sutUserId); + const driverEventId = await client.sendImageMessage({ + body: triggerBody, + buffer: createMatrixQaSplitColorImagePng(), + contentType: "image/png", + fileName: MATRIX_QA_IMAGE_ATTACHMENT_FILENAME, + mentionUserIds: [context.sutUserId], roomId, - }, - details: [ - `encrypted room key: ${roomKey}`, - `encrypted room id: ${roomId}`, - `driver encrypted image event: ${driverEventId}`, - `driver encrypted image filename: ${MATRIX_QA_IMAGE_ATTACHMENT_FILENAME}`, - `driver encrypted image since: ${attachmentEvent.since ?? startSince ?? ""}`, - ...buildMatrixReplyDetails("E2EE image reply", reply), - ].join("\n"), - }; - }); + }); + const attachmentEvent = await client.waitForRoomEvent({ + predicate: (event) => + event.roomId === roomId && + event.eventId === driverEventId && + event.sender === driverUserId && + event.attachment?.kind === "image" && + event.attachment.caption === triggerBody, + roomId, + timeoutMs: context.timeoutMs, + }); + const matched = await client.waitForRoomEvent({ + predicate: (event) => + event.roomId === roomId && + event.sender === context.sutUserId && + event.type === "m.room.message" && + event.relatesTo === undefined && + hasMatrixQaExpectedColorReply(event.body), + roomId, + timeoutMs: context.timeoutMs, + }); + const reply: MatrixQaReplyArtifact = { + eventId: matched.event.eventId, + mentions: matched.event.mentions, + relatesTo: matched.event.relatesTo, + sender: matched.event.sender, + }; + return { + artifacts: { + attachmentFilename: MATRIX_QA_IMAGE_ATTACHMENT_FILENAME, + driverEventId, + driverUserId, + reply, + roomKey, + roomId, + }, + details: [ + `encrypted room key: ${roomKey}`, + `encrypted room id: ${roomId}`, + `isolated driver user: ${driverUserId}`, + `driver encrypted image event: ${driverEventId}`, + `driver encrypted image filename: ${MATRIX_QA_IMAGE_ATTACHMENT_FILENAME}`, + `driver encrypted image since: ${attachmentEvent.since ?? startSince ?? ""}`, + ...buildMatrixReplyDetails("E2EE image reply", reply), + ].join("\n"), + }; + }, + ); } export async function runMatrixQaE2eeKeyBootstrapFailureScenario( diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-shared.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-shared.ts index f18a9b89dd5..926c9fa7833 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-shared.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-shared.ts @@ -30,9 +30,11 @@ export type MatrixQaScenarioContext = { gatewayRuntimeEnv?: NodeJS.ProcessEnv; gatewayStateDir?: string; outputDir?: string; + registrationToken?: string; restartGateway?: () => Promise; restartGatewayAfterStateMutation?: ( mutateState: (context: { stateDir: string }) => Promise, + opts?: { timeoutMs?: number; waitAccountId?: string }, ) => Promise; restartGatewayWithQueuedMessage?: (queueMessage: () => Promise) => Promise; roomId: string; @@ -50,6 +52,7 @@ export type MatrixQaScenarioContext = { patch: Record, opts?: { restartDelayMs?: number }, ) => Promise; + waitGatewayAccountReady?: (accountId: string, opts?: { timeoutMs?: number }) => Promise; }; export const NO_REPLY_WINDOW_MS = 8_000; diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-state-files.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-state-files.ts index 34e6d0da834..848e5bec00f 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-state-files.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-state-files.ts @@ -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) { diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts index ea54dd2fa82..2e7012c9cf3 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts @@ -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": diff --git a/extensions/qa-matrix/src/runners/contract/scenario-types.ts b/extensions/qa-matrix/src/runners/contract/scenario-types.ts index 53932bad3a8..1e1215c3b57 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-types.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-types.ts @@ -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; diff --git a/extensions/qa-matrix/src/runners/contract/scenarios.test.ts b/extensions/qa-matrix/src/runners/contract/scenarios.test.ts index fd828b6ee51..8a8ed497fa3 100644 --- a/extensions/qa-matrix/src/runners/contract/scenarios.test.ts +++ b/extensions/qa-matrix/src/runners/contract/scenarios.test.ts @@ -69,6 +69,7 @@ function matrixQaScenarioContext(): MatrixQaScenarioContext { observedEvents: [], observerAccessToken: "observer-token", observerUserId: "@observer:matrix-qa.test", + registrationToken: "registration-token", roomId: "!main:matrix-qa.test", restartGateway: undefined, syncState: {}, @@ -83,6 +84,41 @@ function matrixQaScenarioContext(): MatrixQaScenarioContext { }; } +function mockMatrixQaCliAccount(params: { + accessToken: string; + deviceId: string; + localpart?: string; + password?: string; + userId?: string; +}) { + const password = params.password ?? "cli-password"; + const userId = params.userId ?? "@cli:matrix-qa.test"; + const account = { + accessToken: params.accessToken, + deviceId: params.deviceId, + localpart: params.localpart ?? "qa-cli-test", + password, + userId, + }; + const registerWithToken = vi.fn().mockResolvedValue(account); + const loginWithPassword = vi.fn().mockResolvedValue(account); + const inviteUserToRoom = vi.fn().mockResolvedValue({ eventId: "$invite" }); + const joinRoom = vi.fn().mockResolvedValue({ roomId: "!joined:matrix-qa.test" }); + createMatrixQaClient.mockReturnValue({ + inviteUserToRoom, + joinRoom, + loginWithPassword, + registerWithToken, + }); + return { + account, + inviteUserToRoom, + joinRoom, + loginWithPassword, + registerWithToken, + }; +} + async function writeTestJsonFile(pathname: string, value: unknown) { await writeFile(pathname, `${JSON.stringify(value, null, 2)}\n`); } @@ -168,6 +204,14 @@ describe("matrix live qa scenarios", () => { "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", @@ -177,6 +221,7 @@ describe("matrix live qa scenarios", () => { "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-history-exists-backup-empty", "matrix-e2ee-device-sas-verification", @@ -231,6 +276,30 @@ describe("matrix live qa scenarios", () => { 150_000, ); expect(scenarios.get("matrix-e2ee-media-image")?.timeoutMs).toBeGreaterThanOrEqual(180_000); + expect( + scenarios.get("matrix-e2ee-cli-account-add-enable-e2ee")?.timeoutMs, + ).toBeGreaterThanOrEqual(120_000); + expect(scenarios.get("matrix-e2ee-cli-encryption-setup")?.timeoutMs).toBeGreaterThanOrEqual( + 120_000, + ); + expect( + scenarios.get("matrix-e2ee-cli-encryption-setup-idempotent")?.timeoutMs, + ).toBeGreaterThanOrEqual(120_000); + expect( + scenarios.get("matrix-e2ee-cli-encryption-setup-bootstrap-failure")?.timeoutMs, + ).toBeGreaterThanOrEqual(120_000); + expect(scenarios.get("matrix-e2ee-cli-recovery-key-setup")?.timeoutMs).toBeGreaterThanOrEqual( + 120_000, + ); + expect(scenarios.get("matrix-e2ee-cli-recovery-key-invalid")?.timeoutMs).toBeGreaterThanOrEqual( + 120_000, + ); + expect( + scenarios.get("matrix-e2ee-cli-encryption-setup-multi-account")?.timeoutMs, + ).toBeGreaterThanOrEqual(120_000); + expect( + scenarios.get("matrix-e2ee-cli-setup-then-gateway-reply")?.timeoutMs, + ).toBeGreaterThanOrEqual(180_000); }); it("keeps the Matrix subagent room policy compatible with leaf child sessions", () => { @@ -1199,6 +1268,499 @@ describe("matrix live qa scenarios", () => { } }); + it("configures a fresh encrypted room before sync-state-loss recovery", async () => { + const stateRoot = await mkdtemp(path.join(os.tmpdir(), "matrix-sync-loss-")); + try { + const callOrder: string[] = []; + const gatewayConfigPath = path.join(stateRoot, "gateway-config.json"); + const originalGroups = { + "!previous:matrix-qa.test": { + enabled: true, + requireMention: true, + }, + }; + const accountDir = path.join( + stateRoot, + "matrix", + "accounts", + "sync-state-loss-gateway", + "server", + "token", + ); + const syncStorePath = path.join(accountDir, "bot-storage.json"); + await mkdir(accountDir, { recursive: true }); + await writeTestJsonFile(gatewayConfigPath, { + channels: { + matrix: { + accounts: { + sut: { + accessToken: "sut-token", + deviceId: "SUT", + enabled: true, + groups: originalGroups, + homeserver: "http://127.0.0.1:28008/", + password: "sut-password", + userId: "@sut:matrix-qa.test", + }, + }, + defaultAccount: "sut", + }, + }, + }); + await writeTestJsonFile(path.join(accountDir, "storage-meta.json"), { + accountId: "sync-state-loss-gateway", + userId: "@sync-gateway:matrix-qa.test", + }); + await writeTestJsonFile(syncStorePath, matrixSyncStoreFixture("sut-sync-before-loss")); + + const registerWithToken = vi.fn().mockResolvedValue({ + accessToken: "sync-gateway-token", + deviceId: "SYNCGATEWAY", + localpart: "qa-destructive-sync-state-loss", + password: "sync-gateway-password", + userId: "@sync-gateway:matrix-qa.test", + }); + const createPrivateRoom = vi.fn(async () => { + callOrder.push("create-room"); + return "!recovery:matrix-qa.test"; + }); + const primeRoom = vi.fn().mockResolvedValue("raw-driver-sync-start"); + const rawWaitForRoomEvent = vi.fn().mockResolvedValue({ + event: { + eventId: "$sut-encrypted-reply", + roomId: "!recovery:matrix-qa.test", + sender: "@sync-gateway:matrix-qa.test", + type: "m.room.encrypted", + }, + since: "raw-driver-sync-after-reply", + }); + const observerJoinRoom = vi.fn(async () => { + callOrder.push("observer-join"); + return "!recovery:matrix-qa.test"; + }); + const sutJoinRoom = vi.fn(async () => { + callOrder.push("sut-join"); + return "!recovery:matrix-qa.test"; + }); + createMatrixQaClient + .mockReturnValueOnce({ registerWithToken }) + .mockReturnValueOnce({ + createPrivateRoom, + primeRoom, + waitForRoomEvent: rawWaitForRoomEvent, + }) + .mockReturnValueOnce({ joinRoom: observerJoinRoom }) + .mockReturnValueOnce({ joinRoom: sutJoinRoom }); + + const sendTextMessage = vi.fn().mockResolvedValue("$driver-trigger"); + const waitForRoomEvent = vi.fn().mockImplementation(async () => { + const token = String(sendTextMessage.mock.calls[0]?.[0]?.body).replace( + "@sync-gateway:matrix-qa.test reply with only this exact marker: ", + "", + ); + return { + event: { + body: token, + eventId: "$sut-decrypted-reply", + kind: "message", + roomId: "!recovery:matrix-qa.test", + sender: "@sync-gateway:matrix-qa.test", + type: "m.room.message", + }, + }; + }); + const stop = vi.fn().mockResolvedValue(undefined); + createMatrixQaE2eeScenarioClient.mockResolvedValue({ + prime: vi.fn().mockResolvedValue("e2ee-driver-sync-start"), + sendTextMessage, + stop, + waitForRoomEvent, + }); + const hardRestartAccounts: Array<{ + accounts: Record; userId?: string }>; + defaultAccount?: string; + }> = []; + const waitGatewayAccountReady = vi.fn().mockResolvedValue(undefined); + + const scenario = MATRIX_QA_SCENARIOS.find( + (entry) => entry.id === "matrix-e2ee-sync-state-loss-crypto-intact", + ); + expect(scenario).toBeDefined(); + + await expect( + runMatrixQaScenario(scenario!, { + ...matrixQaScenarioContext(), + driverDeviceId: "DRIVER", + gatewayRuntimeEnv: { + OPENCLAW_CONFIG_PATH: gatewayConfigPath, + PATH: process.env.PATH, + }, + gatewayStateDir: stateRoot, + observerDeviceId: "OBSERVER", + outputDir: stateRoot, + restartGatewayAfterStateMutation: async (mutateState) => { + callOrder.push("hard-restart"); + await mutateState({ stateDir: stateRoot }); + const config = JSON.parse(await readFile(gatewayConfigPath, "utf8")) as { + channels: { + matrix: { + accounts: Record; userId?: string }>; + defaultAccount?: string; + }; + }; + }; + hardRestartAccounts.push({ + accounts: config.channels.matrix.accounts, + defaultAccount: config.channels.matrix.defaultAccount, + }); + }, + sutAccountId: "sut", + sutDeviceId: "SUT", + waitGatewayAccountReady, + }), + ).resolves.toMatchObject({ + artifacts: { + deletedSyncStorePath: syncStorePath, + driverEventId: "$driver-trigger", + replyEventId: "$sut-decrypted-reply", + roomKey: "e2ee-sync-state-loss-crypto-intact-recovery", + }, + }); + + await expect(stat(syncStorePath)).rejects.toThrow(); + expect(registerWithToken).toHaveBeenCalledWith( + expect.objectContaining({ + registrationToken: "registration-token", + }), + ); + expect(createPrivateRoom).toHaveBeenCalledWith({ + encrypted: true, + inviteUserIds: ["@observer:matrix-qa.test", "@sync-gateway:matrix-qa.test"], + name: "Matrix QA E2EE Sync State Loss Recovery Room", + }); + expect(observerJoinRoom).toHaveBeenCalledWith("!recovery:matrix-qa.test"); + expect(sutJoinRoom).toHaveBeenCalledWith("!recovery:matrix-qa.test"); + expect(hardRestartAccounts).toEqual([ + { + accounts: { + "sync-state-loss-gateway": expect.objectContaining({ + groups: { + "!recovery:matrix-qa.test": { + enabled: true, + requireMention: true, + }, + }, + userId: "@sync-gateway:matrix-qa.test", + }), + }, + defaultAccount: "sync-state-loss-gateway", + }, + { + accounts: { + "sync-state-loss-gateway": expect.objectContaining({ + groups: { + "!recovery:matrix-qa.test": { + enabled: true, + requireMention: true, + }, + }, + userId: "@sync-gateway:matrix-qa.test", + }), + }, + defaultAccount: "sync-state-loss-gateway", + }, + { + accounts: { + sut: expect.objectContaining({ + groups: originalGroups, + userId: "@sut:matrix-qa.test", + }), + }, + defaultAccount: "sut", + }, + ]); + expect(callOrder).toEqual([ + "create-room", + "observer-join", + "sut-join", + "hard-restart", + "hard-restart", + "hard-restart", + ]); + expect(waitGatewayAccountReady).toHaveBeenCalledWith("sync-state-loss-gateway", { + timeoutMs: 8_000, + }); + expect(sendTextMessage).toHaveBeenCalledWith({ + body: expect.stringContaining( + "@sync-gateway:matrix-qa.test reply with only this exact marker:", + ), + mentionUserIds: ["@sync-gateway:matrix-qa.test"], + roomId: "!recovery:matrix-qa.test", + }); + expect(rawWaitForRoomEvent).toHaveBeenCalledWith( + expect.objectContaining({ + roomId: "!recovery:matrix-qa.test", + since: "raw-driver-sync-start", + }), + ); + const finalConfig = JSON.parse(await readFile(gatewayConfigPath, "utf8")) as { + channels: { + matrix: { + accounts: Record }>; + defaultAccount?: string; + }; + }; + }; + expect(finalConfig.channels.matrix.defaultAccount).toBe("sut"); + expect(Object.keys(finalConfig.channels.matrix.accounts)).toEqual(["sut"]); + expect(finalConfig.channels.matrix.accounts.sut?.groups).toEqual(originalGroups); + } finally { + await rm(stateRoot, { recursive: true, force: true }); + } + }); + + it("isolates E2EE restart-resume gateway groups and restores them after the scenario", async () => { + const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-e2ee-restart-isolation-")); + try { + const gatewayConfigPath = path.join(outputDir, "gateway-config.json"); + const originalGroups = { + "!artifact:matrix-qa.test": { + enabled: true, + requireMention: true, + }, + "!dynamic-recovery:matrix-qa.test": { + enabled: true, + requireMention: true, + }, + "!main:matrix-qa.test": { + enabled: true, + requireMention: true, + }, + "!restart:matrix-qa.test": { + enabled: true, + requireMention: true, + }, + }; + await writeTestJsonFile(gatewayConfigPath, { + channels: { + matrix: { + accounts: { + sut: { + groupAllowFrom: ["@driver:matrix-qa.test"], + groupPolicy: "allowlist", + groups: originalGroups, + }, + }, + }, + }, + }); + + const callOrder: string[] = []; + const registerWithToken = vi.fn().mockResolvedValue({ + accessToken: "isolated-driver-token", + deviceId: "ISOLATEDDRIVER", + localpart: "qa-e2ee-driver-restart", + password: "isolated-driver-password", + userId: "@isolated-driver:matrix-qa.test", + }); + const createPrivateRoom = vi.fn(async () => { + callOrder.push("create-room"); + return "!isolated-restart:matrix-qa.test"; + }); + const observerJoinRoom = vi.fn(async () => { + callOrder.push("observer-join"); + return "!isolated-restart:matrix-qa.test"; + }); + const sutJoinRoom = vi.fn(async () => { + callOrder.push("sut-join"); + return "!isolated-restart:matrix-qa.test"; + }); + createMatrixQaClient + .mockReturnValueOnce({ registerWithToken }) + .mockReturnValueOnce({ createPrivateRoom }) + .mockReturnValueOnce({ joinRoom: observerJoinRoom }) + .mockReturnValueOnce({ joinRoom: sutJoinRoom }); + + const sendTextMessage = vi.fn().mockImplementation(async ({ body }) => { + if (String(body).includes("MATRIX_QA_E2EE_BEFORE_RESTART")) { + const isolatedConfig = JSON.parse(await readFile(gatewayConfigPath, "utf8")) as { + channels: { + matrix: { + accounts: { + sut: { + groupAllowFrom: string[]; + groupPolicy: string; + groups: Record; + }; + }; + }; + }; + }; + expect(Object.keys(isolatedConfig.channels.matrix.accounts.sut.groups)).toEqual([ + "!isolated-restart:matrix-qa.test", + ]); + expect(isolatedConfig.channels.matrix.accounts.sut.groupAllowFrom).toEqual([ + "@isolated-driver:matrix-qa.test", + ]); + expect(isolatedConfig.channels.matrix.accounts.sut.groupPolicy).toBe("allowlist"); + callOrder.push("send:before"); + return "$before-trigger"; + } + callOrder.push("send:after"); + return "$after-trigger"; + }); + const waitForRoomEvent = vi.fn().mockImplementation(async (params) => { + const body = String(sendTextMessage.mock.calls.at(-1)?.[0]?.body ?? ""); + const token = body.replace("@sut:matrix-qa.test reply with only this exact marker: ", ""); + return { + event: { + body: token, + eventId: token.includes("BEFORE") ? "$before-reply" : "$after-reply", + kind: "message", + roomId: params.roomId, + sender: "@sut:matrix-qa.test", + type: "m.room.message", + }, + since: `${params.roomId}:reply`, + }; + }); + const stop = vi.fn().mockResolvedValue(undefined); + createMatrixQaE2eeScenarioClient.mockResolvedValue({ + prime: vi.fn().mockResolvedValue("driver-sync-start"), + sendTextMessage, + stop, + waitForJoinedMember: vi.fn().mockResolvedValue(undefined), + waitForRoomEvent, + }); + const restartGateway = vi.fn(async () => { + callOrder.push("restart"); + }); + const restartGatewayAfterStateMutation = vi.fn(async (mutateState) => { + callOrder.push("hard-restart"); + await mutateState({ stateDir: outputDir }); + }); + const waitGatewayAccountReady = vi.fn().mockResolvedValue(undefined); + + const scenario = MATRIX_QA_SCENARIOS.find( + (entry) => entry.id === "matrix-e2ee-restart-resume", + ); + expect(scenario).toBeDefined(); + + await expect( + runMatrixQaScenario(scenario!, { + ...matrixQaScenarioContext(), + gatewayRuntimeEnv: { + OPENCLAW_CONFIG_PATH: gatewayConfigPath, + PATH: process.env.PATH, + }, + outputDir, + restartGateway, + restartGatewayAfterStateMutation, + sutAccountId: "sut", + topology: { + defaultRoomId: "!main:matrix-qa.test", + defaultRoomKey: "main", + rooms: [ + { + key: "main", + kind: "group", + memberRoles: ["driver", "observer", "sut"], + memberUserIds: [ + "@driver:matrix-qa.test", + "@observer:matrix-qa.test", + "@sut:matrix-qa.test", + ], + name: "Main", + requireMention: true, + roomId: "!main:matrix-qa.test", + }, + { + encrypted: true, + key: matrixQaE2eeRoomKey("matrix-e2ee-restart-resume"), + kind: "group", + memberRoles: ["driver", "observer", "sut"], + memberUserIds: [ + "@driver:matrix-qa.test", + "@observer:matrix-qa.test", + "@sut:matrix-qa.test", + ], + name: "Restart", + requireMention: true, + roomId: "!restart:matrix-qa.test", + }, + ], + }, + waitGatewayAccountReady, + }), + ).resolves.toMatchObject({ + artifacts: { + driverUserId: "@isolated-driver:matrix-qa.test", + firstDriverEventId: "$before-trigger", + recoveredDriverEventId: "$after-trigger", + roomId: "!isolated-restart:matrix-qa.test", + }, + }); + + const restoredConfig = JSON.parse(await readFile(gatewayConfigPath, "utf8")) as { + channels: { + matrix: { + accounts: { + sut: { + groupAllowFrom: string[]; + groupPolicy: string; + groups: Record; + }; + }; + }; + }; + }; + expect(restoredConfig.channels.matrix.accounts.sut.groups).toEqual(originalGroups); + expect(restoredConfig.channels.matrix.accounts.sut.groupAllowFrom).toEqual([ + "@driver:matrix-qa.test", + ]); + expect(restoredConfig.channels.matrix.accounts.sut.groupPolicy).toBe("allowlist"); + expect(callOrder).toEqual([ + "create-room", + "observer-join", + "sut-join", + "hard-restart", + "send:before", + "restart", + "send:after", + "hard-restart", + ]); + expect(restartGatewayAfterStateMutation).toHaveBeenCalledTimes(2); + expect(restartGatewayAfterStateMutation).toHaveBeenNthCalledWith(1, expect.any(Function), { + timeoutMs: 8_000, + waitAccountId: "sut", + }); + expect(restartGatewayAfterStateMutation).toHaveBeenNthCalledWith(2, expect.any(Function), { + timeoutMs: 8_000, + waitAccountId: "sut", + }); + expect(waitGatewayAccountReady).not.toHaveBeenCalled(); + expect(stop).toHaveBeenCalledTimes(1); + expect(createPrivateRoom).toHaveBeenCalledWith({ + encrypted: true, + inviteUserIds: ["@observer:matrix-qa.test", "@sut:matrix-qa.test"], + name: "Matrix QA matrix-e2ee-restart-resume Isolated E2EE Room", + }); + expect(observerJoinRoom).toHaveBeenCalledWith("!isolated-restart:matrix-qa.test"); + expect(sutJoinRoom).toHaveBeenCalledWith("!isolated-restart:matrix-qa.test"); + expect(createMatrixQaE2eeScenarioClient).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: "isolated-driver-token", + actorId: "driver-restart-resume", + deviceId: "ISOLATEDDRIVER", + password: "isolated-driver-password", + userId: "@isolated-driver:matrix-qa.test", + }), + ); + } finally { + await rm(outputDir, { recursive: true, force: true }); + } + }); + it("runs the DM scenario against the provisioned DM room without a mention", async () => { const primeRoom = vi.fn().mockResolvedValue("driver-sync-start"); const sendTextMessage = vi.fn().mockResolvedValue("$dm-trigger"); @@ -2995,6 +3557,20 @@ describe("matrix live qa scenarios", () => { const confirmVerificationSas = vi.fn().mockResolvedValue(undefined); const deleteOwnDevices = vi.fn().mockResolvedValue(undefined); const stop = vi.fn().mockResolvedValue(undefined); + const cliOwnerAccount = { + accessToken: "cli-owner-token", + deviceId: "OWNERDEVICE", + localpart: "qa-cli-self-verification", + password: "cli-owner-password", + userId: "@cli-owner:matrix-qa.test", + }; + const registerWithToken = vi.fn().mockResolvedValue(cliOwnerAccount); + const loginWithPassword = vi.fn().mockResolvedValue({ + accessToken: "cli-token", + deviceId: "CLIDEVICE", + password: "cli-owner-password", + userId: "@cli-owner:matrix-qa.test", + }); const bootstrapOwnDeviceVerification = vi.fn().mockResolvedValue({ crossSigning: { published: true, @@ -3017,7 +3593,7 @@ describe("matrix live qa scenarios", () => { hasReciprocateQr: false, methods: ["m.sas.v1"], otherDeviceId: "CLIDEVICE", - otherUserId: "@driver:matrix-qa.test", + otherUserId: "@cli-owner:matrix-qa.test", pending: true, phase: 2, phaseName: "ready", @@ -3066,12 +3642,8 @@ describe("matrix live qa scenarios", () => { }, ]); createMatrixQaClient.mockReturnValue({ - loginWithPassword: vi.fn().mockResolvedValue({ - accessToken: "cli-token", - deviceId: "CLIDEVICE", - password: "driver-password", - userId: "@driver:matrix-qa.test", - }), + loginWithPassword, + registerWithToken, }); createMatrixQaE2eeScenarioClient.mockResolvedValueOnce({ acceptVerification, @@ -3155,7 +3727,7 @@ describe("matrix live qa scenarios", () => { crossSigningVerified: true, deviceId: "CLIDEVICE", signedByOwner: true, - userId: "@driver:matrix-qa.test", + userId: "@cli-owner:matrix-qa.test", verified: true, }), }; @@ -3213,12 +3785,35 @@ describe("matrix live qa scenarios", () => { "self", "--account", "cli", + "--timeout-ms", + "8000", ]); + expect(startMatrixQaOpenClawCli.mock.calls[0]?.[0].timeoutMs).toBe(16_000); expect(waitForOutput).toHaveBeenCalledTimes(2); expect(writeStdin).toHaveBeenCalledWith("yes\n"); expect(endStdin).toHaveBeenCalledTimes(1); expect(wait).toHaveBeenCalledTimes(1); expect(kill).toHaveBeenCalledTimes(1); + expect(registerWithToken).toHaveBeenCalledWith({ + deviceName: "OpenClaw Matrix QA CLI Self Verification Owner", + localpart: expect.stringMatching(/^qa-cli-self-verification-[a-f0-9]{8}$/), + password: expect.stringMatching(/^matrix-qa-/), + registrationToken: "registration-token", + }); + expect(loginWithPassword).toHaveBeenCalledWith({ + deviceName: "OpenClaw Matrix QA CLI Self Verification Device", + password: "cli-owner-password", + userId: "@cli-owner:matrix-qa.test", + }); + expect(createMatrixQaE2eeScenarioClient).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: "cli-owner-token", + deviceId: "OWNERDEVICE", + password: "cli-owner-password", + scenarioId: "matrix-e2ee-cli-self-verification", + userId: "@cli-owner:matrix-qa.test", + }), + ); expect(runMatrixQaOpenClawCli).toHaveBeenCalledTimes(2); expect(runMatrixQaOpenClawCli.mock.calls.map(([params]) => params.args)).toEqual([ [ @@ -3246,7 +3841,7 @@ describe("matrix live qa scenarios", () => { pluginAllow: expect.arrayContaining(["matrix"]), pluginEnabled: true, startupVerification: "off", - userId: "@driver:matrix-qa.test", + userId: "@cli-owner:matrix-qa.test", }); await expect(readFile(configPath, "utf8")).rejects.toThrow(); await expect(readdir(String(cliEnv?.OPENCLAW_STATE_DIR))).rejects.toThrow(); @@ -3283,6 +3878,1241 @@ describe("matrix live qa scenarios", () => { } }); + it("runs Matrix account add --enable-e2ee through the CLI QA scenario", async () => { + const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-cli-account-add-e2ee-")); + try { + const { registerWithToken } = mockMatrixQaCliAccount({ + accessToken: "cli-add-owner-token", + deviceId: "CLIADDOWNER", + password: "cli-add-password", + userId: "@cli-add:matrix-qa.test", + }); + runMatrixQaOpenClawCli.mockImplementation(async ({ args, env }) => { + if (env.OPENCLAW_CONFIG_PATH) { + const initialConfig = JSON.parse( + await readFile(String(env.OPENCLAW_CONFIG_PATH), "utf8"), + ) as { + channels?: { matrix?: { enabled?: boolean; accounts?: Record } }; + plugins?: { allow?: string[]; entries?: { matrix?: unknown } }; + }; + expect(initialConfig.channels?.matrix?.enabled).toBe(true); + expect(initialConfig.channels?.matrix?.accounts).toEqual({}); + expect(initialConfig.plugins?.allow).toContain("matrix"); + expect(initialConfig.plugins?.entries?.matrix).toEqual({ enabled: true }); + } + const joined = args.join(" "); + if (joined.includes("matrix account add")) { + return { + args, + exitCode: 0, + stderr: "", + stdout: JSON.stringify({ + accountId: "cli-add-e2ee", + encryptionEnabled: true, + verificationBootstrap: { + attempted: true, + backupVersion: "backup-v1", + success: true, + }, + }), + }; + } + if (joined === "matrix verify status --account cli-add-e2ee --json") { + return { + args, + exitCode: 0, + stderr: "", + stdout: JSON.stringify({ + backup: { + decryptionKeyCached: true, + keyLoadError: null, + matchesDecryptionKey: true, + trusted: true, + }, + crossSigningVerified: true, + deviceId: "CLIADDDEVICE", + signedByOwner: true, + userId: "@driver:matrix-qa.test", + verified: true, + }), + }; + } + throw new Error(`unexpected CLI command: ${joined}`); + }); + + const scenario = MATRIX_QA_SCENARIOS.find( + (entry) => entry.id === "matrix-e2ee-cli-account-add-enable-e2ee", + ); + expect(scenario).toBeDefined(); + + await expect( + runMatrixQaScenario(scenario!, { + ...matrixQaScenarioContext(), + driverDeviceId: "DRIVERDEVICE", + driverPassword: "driver-password", + gatewayRuntimeEnv: { + OPENCLAW_CONFIG_PATH: "/tmp/gateway-config.json", + OPENCLAW_STATE_DIR: "/tmp/gateway-state", + PATH: process.env.PATH, + }, + outputDir, + }), + ).resolves.toMatchObject({ + artifacts: { + accountId: "cli-add-e2ee", + backupVersion: "backup-v1", + cliDeviceId: "CLIADDDEVICE", + encryptionEnabled: true, + verificationBootstrapAttempted: true, + verificationBootstrapSuccess: true, + }, + }); + + expect(runMatrixQaOpenClawCli.mock.calls.map(([params]) => params.args)).toEqual([ + [ + "matrix", + "account", + "add", + "--account", + "cli-add-e2ee", + "--name", + "Matrix QA CLI Account Add E2EE", + "--homeserver", + "http://127.0.0.1:28008/", + "--user-id", + "@cli-add:matrix-qa.test", + "--password", + "cli-add-password", + "--device-name", + "OpenClaw Matrix QA CLI Account Add E2EE", + "--allow-private-network", + "--enable-e2ee", + "--json", + ], + ["matrix", "verify", "status", "--account", "cli-add-e2ee", "--json"], + ]); + expect(registerWithToken).toHaveBeenCalledWith( + expect.objectContaining({ + deviceName: "OpenClaw Matrix QA CLI Account Add Owner", + registrationToken: "registration-token", + }), + ); + const [cliRunDir] = await readdir(path.join(outputDir, "cli-account-add-enable-e2ee")); + const cliArtifactDir = path.join(outputDir, "cli-account-add-enable-e2ee", cliRunDir ?? ""); + await expect( + readFile(path.join(cliArtifactDir, "account-add-enable-e2ee.stdout.txt"), "utf8"), + ).resolves.toContain('"encryptionEnabled":true'); + await expect( + readFile(path.join(cliArtifactDir, "verify-status.stdout.txt"), "utf8"), + ).resolves.toContain('"verified":true'); + } finally { + await rm(outputDir, { force: true, recursive: true }); + } + }); + + it("runs Matrix encryption setup through the CLI QA scenario", async () => { + const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-cli-encryption-setup-")); + try { + const { loginWithPassword, registerWithToken } = mockMatrixQaCliAccount({ + accessToken: "cli-setup-token", + deviceId: "CLISETUPDEVICE", + password: "cli-setup-password", + userId: "@cli-setup:matrix-qa.test", + }); + let initialAccountConfig: Record | null = null; + runMatrixQaOpenClawCli.mockImplementation(async ({ args, env }) => { + if (!initialAccountConfig && env.OPENCLAW_CONFIG_PATH) { + const initialConfig = JSON.parse( + await readFile(String(env.OPENCLAW_CONFIG_PATH), "utf8"), + ) as { + channels?: { + matrix?: { + accounts?: Record>; + }; + }; + }; + initialAccountConfig = + initialConfig.channels?.matrix?.accounts?.["cli-encryption-setup"] ?? null; + } + const joined = args.join(" "); + if (joined === "matrix encryption setup --account cli-encryption-setup --json") { + return { + args, + exitCode: 0, + stderr: "", + stdout: JSON.stringify({ + accountId: "cli-encryption-setup", + bootstrap: { + success: true, + }, + encryptionChanged: true, + status: { + backup: { + decryptionKeyCached: true, + keyLoadError: null, + matchesDecryptionKey: true, + trusted: true, + }, + crossSigningVerified: true, + deviceId: "CLISETUPDEVICE", + signedByOwner: true, + userId: "@driver:matrix-qa.test", + verified: true, + }, + success: true, + }), + }; + } + if (joined === "matrix verify status --account cli-encryption-setup --json") { + return { + args, + exitCode: 0, + stderr: "", + stdout: JSON.stringify({ + backup: { + decryptionKeyCached: true, + keyLoadError: null, + matchesDecryptionKey: true, + trusted: true, + }, + crossSigningVerified: true, + deviceId: "CLISETUPDEVICE", + signedByOwner: true, + userId: "@driver:matrix-qa.test", + verified: true, + }), + }; + } + throw new Error(`unexpected CLI command: ${joined}`); + }); + + const scenario = MATRIX_QA_SCENARIOS.find( + (entry) => entry.id === "matrix-e2ee-cli-encryption-setup", + ); + expect(scenario).toBeDefined(); + + await expect( + runMatrixQaScenario(scenario!, { + ...matrixQaScenarioContext(), + driverDeviceId: "DRIVERDEVICE", + driverPassword: "driver-password", + gatewayRuntimeEnv: { + OPENCLAW_CONFIG_PATH: "/tmp/gateway-config.json", + OPENCLAW_STATE_DIR: "/tmp/gateway-state", + PATH: process.env.PATH, + }, + outputDir, + }), + ).resolves.toMatchObject({ + artifacts: { + accountId: "cli-encryption-setup", + cliDeviceId: "CLISETUPDEVICE", + encryptionChanged: true, + setupSuccess: true, + verificationBootstrapSuccess: true, + }, + }); + + expect(initialAccountConfig).toMatchObject({ + accessToken: "cli-setup-token", + deviceId: "CLISETUPDEVICE", + encryption: false, + homeserver: "http://127.0.0.1:28008/", + password: "cli-setup-password", + startupVerification: "off", + userId: "@cli-setup:matrix-qa.test", + }); + expect(runMatrixQaOpenClawCli.mock.calls.map(([params]) => params.args)).toEqual([ + ["matrix", "encryption", "setup", "--account", "cli-encryption-setup", "--json"], + ["matrix", "verify", "status", "--account", "cli-encryption-setup", "--json"], + ]); + expect(registerWithToken).toHaveBeenCalledWith( + expect.objectContaining({ + deviceName: "OpenClaw Matrix QA CLI Encryption Setup Owner", + registrationToken: "registration-token", + }), + ); + expect(loginWithPassword).toHaveBeenCalledWith( + expect.objectContaining({ + password: "cli-setup-password", + userId: "@cli-setup:matrix-qa.test", + }), + ); + const [cliRunDir] = await readdir(path.join(outputDir, "cli-encryption-setup")); + const cliArtifactDir = path.join(outputDir, "cli-encryption-setup", cliRunDir ?? ""); + await expect( + readFile(path.join(cliArtifactDir, "encryption-setup.stdout.txt"), "utf8"), + ).resolves.toContain('"encryptionChanged":true'); + await expect( + readFile(path.join(cliArtifactDir, "verify-status.stdout.txt"), "utf8"), + ).resolves.toContain('"verified":true'); + } finally { + await rm(outputDir, { force: true, recursive: true }); + } + }); + + it("runs Matrix encryption setup idempotency through the CLI QA scenario", async () => { + const outputDir = await mkdtemp( + path.join(os.tmpdir(), "matrix-cli-encryption-setup-idempotent-"), + ); + try { + const { loginWithPassword, registerWithToken } = mockMatrixQaCliAccount({ + accessToken: "cli-idempotent-token", + deviceId: "CLIIDEMPOTENTDEVICE", + password: "cli-idempotent-password", + userId: "@cli-idempotent:matrix-qa.test", + }); + let initialAccountConfig: Record | null = null; + runMatrixQaOpenClawCli.mockImplementation(async ({ args, env }) => { + if (!initialAccountConfig && env.OPENCLAW_CONFIG_PATH) { + const initialConfig = JSON.parse( + await readFile(String(env.OPENCLAW_CONFIG_PATH), "utf8"), + ) as { + channels?: { + matrix?: { + accounts?: Record>; + }; + }; + }; + initialAccountConfig = + initialConfig.channels?.matrix?.accounts?.["cli-encryption-idempotent"] ?? null; + } + const joined = args.join(" "); + if (joined === "matrix encryption setup --account cli-encryption-idempotent --json") { + return { + args, + exitCode: 0, + stderr: "", + stdout: JSON.stringify({ + accountId: "cli-encryption-idempotent", + bootstrap: { + success: true, + }, + encryptionChanged: false, + status: { + backup: { + decryptionKeyCached: true, + keyLoadError: null, + matchesDecryptionKey: true, + trusted: true, + }, + crossSigningVerified: true, + deviceId: "CLIIDEMPOTENTDEVICE", + signedByOwner: true, + userId: "@driver:matrix-qa.test", + verified: true, + }, + success: true, + }), + }; + } + throw new Error(`unexpected CLI command: ${joined}`); + }); + + const scenario = MATRIX_QA_SCENARIOS.find( + (entry) => entry.id === "matrix-e2ee-cli-encryption-setup-idempotent", + ); + expect(scenario).toBeDefined(); + + await expect( + runMatrixQaScenario(scenario!, { + ...matrixQaScenarioContext(), + driverDeviceId: "DRIVERDEVICE", + driverPassword: "driver-password", + gatewayRuntimeEnv: { + OPENCLAW_CONFIG_PATH: "/tmp/gateway-config.json", + OPENCLAW_STATE_DIR: "/tmp/gateway-state", + PATH: process.env.PATH, + }, + outputDir, + }), + ).resolves.toMatchObject({ + artifacts: { + accountId: "cli-encryption-idempotent", + cliDeviceId: "CLIIDEMPOTENTDEVICE", + firstEncryptionChanged: false, + secondEncryptionChanged: false, + setupSuccess: true, + verificationBootstrapSuccess: true, + }, + }); + + expect(initialAccountConfig).toMatchObject({ + accessToken: "cli-idempotent-token", + deviceId: "CLIIDEMPOTENTDEVICE", + encryption: true, + homeserver: "http://127.0.0.1:28008/", + password: "cli-idempotent-password", + startupVerification: "off", + userId: "@cli-idempotent:matrix-qa.test", + }); + expect(runMatrixQaOpenClawCli.mock.calls.map(([params]) => params.args)).toEqual([ + ["matrix", "encryption", "setup", "--account", "cli-encryption-idempotent", "--json"], + ["matrix", "encryption", "setup", "--account", "cli-encryption-idempotent", "--json"], + ]); + expect(registerWithToken).toHaveBeenCalledWith( + expect.objectContaining({ + deviceName: "OpenClaw Matrix QA CLI Encryption Idempotent Owner", + registrationToken: "registration-token", + }), + ); + expect(loginWithPassword).toHaveBeenCalledWith( + expect.objectContaining({ + password: "cli-idempotent-password", + userId: "@cli-idempotent:matrix-qa.test", + }), + ); + const [cliRunDir] = await readdir(path.join(outputDir, "cli-encryption-setup-idempotent")); + const cliArtifactDir = path.join( + outputDir, + "cli-encryption-setup-idempotent", + cliRunDir ?? "", + ); + await expect( + readFile(path.join(cliArtifactDir, "encryption-setup-first.stdout.txt"), "utf8"), + ).resolves.toContain('"encryptionChanged":false'); + await expect( + readFile(path.join(cliArtifactDir, "encryption-setup-second.stdout.txt"), "utf8"), + ).resolves.toContain('"verified":true'); + } finally { + await rm(outputDir, { force: true, recursive: true }); + } + }); + + it("runs Matrix encryption setup bootstrap failure through the CLI QA scenario", async () => { + const outputDir = await mkdtemp( + path.join(os.tmpdir(), "matrix-cli-encryption-setup-bootstrap-failure-"), + ); + try { + const proxyStop = vi.fn().mockResolvedValue(undefined); + const hits = vi.fn().mockReturnValue([ + { + bearerToken: "cli-failure-token", + method: "GET", + path: "/_matrix/client/v3/room_keys/version", + ruleId: "room-key-backup-version-unavailable", + }, + ]); + const { loginWithPassword, registerWithToken } = mockMatrixQaCliAccount({ + accessToken: "cli-failure-token", + deviceId: "CLIFAILUREDEVICE", + password: "cli-failure-password", + userId: "@cli-failure:matrix-qa.test", + }); + startMatrixQaFaultProxy.mockResolvedValue({ + baseUrl: "http://127.0.0.1:39878", + hits, + stop: proxyStop, + }); + const output = vi.fn(() => ({ + stderr: "", + stdout: JSON.stringify({ + accountId: "cli-encryption-failure", + bootstrap: { + error: "Matrix room key backup is still missing after bootstrap", + success: false, + }, + encryptionChanged: true, + success: false, + }), + })); + const wait = vi + .fn() + .mockRejectedValue(new Error("openclaw matrix encryption setup exited 1")); + const kill = vi.fn(); + startMatrixQaOpenClawCli.mockReturnValue({ + args: ["matrix", "encryption", "setup", "--account", "cli-encryption-failure", "--json"], + kill, + output, + wait, + waitForOutput: vi.fn(), + writeStdin: vi.fn(), + }); + + const scenario = MATRIX_QA_SCENARIOS.find( + (entry) => entry.id === "matrix-e2ee-cli-encryption-setup-bootstrap-failure", + ); + expect(scenario).toBeDefined(); + + await expect( + runMatrixQaScenario(scenario!, { + ...matrixQaScenarioContext(), + driverDeviceId: "DRIVERDEVICE", + driverPassword: "driver-password", + gatewayRuntimeEnv: { + OPENCLAW_CONFIG_PATH: "/tmp/gateway-config.json", + OPENCLAW_STATE_DIR: "/tmp/gateway-state", + PATH: process.env.PATH, + }, + outputDir, + }), + ).resolves.toMatchObject({ + artifacts: { + accountId: "cli-encryption-failure", + bootstrapSuccess: false, + cliDeviceId: "CLIFAILUREDEVICE", + faultedEndpoint: "/_matrix/client/v3/room_keys/version", + faultHitCount: 1, + faultRuleId: "room-key-backup-version-unavailable", + }, + }); + + const proxyArgs = startMatrixQaFaultProxy.mock.calls[0]?.[0]; + expect(proxyArgs).toBeDefined(); + if (!proxyArgs) { + throw new Error("expected Matrix QA fault proxy to start"); + } + const [faultRule] = proxyArgs.rules; + expect(faultRule).toBeDefined(); + if (!faultRule) { + throw new Error("expected Matrix QA fault proxy rule"); + } + expect(proxyArgs.targetBaseUrl).toBe("http://127.0.0.1:28008/"); + expect( + faultRule.match({ + bearerToken: "cli-failure-token", + headers: {}, + method: "GET", + path: "/_matrix/client/v3/room_keys/version", + search: "", + }), + ).toBe(true); + expect(startMatrixQaOpenClawCli.mock.calls[0]?.[0].args).toEqual([ + "matrix", + "encryption", + "setup", + "--account", + "cli-encryption-failure", + "--json", + ]); + expect(startMatrixQaOpenClawCli.mock.calls[0]?.[0].env.OPENCLAW_CONFIG_PATH).toContain( + "openclaw-matrix-e2ee-setup-qa-", + ); + expect(output).toHaveBeenCalledTimes(1); + expect(wait).toHaveBeenCalledTimes(1); + expect(kill).toHaveBeenCalledTimes(1); + expect(registerWithToken).toHaveBeenCalledWith( + expect.objectContaining({ + deviceName: "OpenClaw Matrix QA CLI Encryption Failure Owner", + registrationToken: "registration-token", + }), + ); + expect(loginWithPassword).toHaveBeenCalledWith( + expect.objectContaining({ + password: "cli-failure-password", + userId: "@cli-failure:matrix-qa.test", + }), + ); + expect(proxyStop).toHaveBeenCalledTimes(1); + const [cliRunDir] = await readdir( + path.join(outputDir, "cli-encryption-setup-bootstrap-failure"), + ); + const cliArtifactDir = path.join( + outputDir, + "cli-encryption-setup-bootstrap-failure", + cliRunDir ?? "", + ); + await expect( + readFile( + path.join(cliArtifactDir, "encryption-setup-bootstrap-failure.stdout.txt"), + "utf8", + ), + ).resolves.toContain('"success":false'); + } finally { + await rm(outputDir, { force: true, recursive: true }); + } + }); + + it("runs Matrix recovery-key setup through the CLI QA scenario", async () => { + const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-cli-recovery-key-setup-")); + try { + const deleteOwnDevices = vi.fn().mockResolvedValue(undefined); + const stop = vi.fn().mockResolvedValue(undefined); + const bootstrapOwnDeviceVerification = vi.fn().mockResolvedValue({ + crossSigning: { + published: true, + }, + success: true, + verification: { + backupVersion: "backup-v1", + crossSigningVerified: true, + recoveryKeyId: "SSSS", + recoveryKeyStored: true, + signedByOwner: true, + verified: true, + }, + }); + createMatrixQaE2eeScenarioClient.mockResolvedValueOnce({ + bootstrapOwnDeviceVerification, + deleteOwnDevices, + getRecoveryKey: vi.fn().mockResolvedValue({ + encodedPrivateKey: "encoded-recovery-key", + keyId: "SSSS", + }), + stop, + }); + const { loginWithPassword, registerWithToken } = mockMatrixQaCliAccount({ + accessToken: "cli-recovery-token", + deviceId: "CLIRECOVERYDEVICE", + password: "cli-recovery-password", + userId: "@cli-recovery:matrix-qa.test", + }); + let initialAccountConfig: Record | null = null; + runMatrixQaOpenClawCli.mockImplementation(async ({ args, env }) => { + if (!initialAccountConfig && env.OPENCLAW_CONFIG_PATH) { + const initialConfig = JSON.parse( + await readFile(String(env.OPENCLAW_CONFIG_PATH), "utf8"), + ) as { + channels?: { + matrix?: { + accounts?: Record>; + }; + }; + }; + initialAccountConfig = + initialConfig.channels?.matrix?.accounts?.["cli-recovery-key-setup"] ?? null; + } + const joined = args.join(" "); + if ( + joined === + "matrix encryption setup --account cli-recovery-key-setup --recovery-key encoded-recovery-key --json" + ) { + return { + args, + exitCode: 0, + stderr: "", + stdout: JSON.stringify({ + accountId: "cli-recovery-key-setup", + bootstrap: { + success: true, + }, + encryptionChanged: true, + status: { + backup: { + decryptionKeyCached: true, + keyLoadError: null, + matchesDecryptionKey: true, + trusted: true, + }, + backupVersion: "backup-v1", + crossSigningVerified: true, + deviceId: "CLIRECOVERYDEVICE", + signedByOwner: true, + userId: "@driver:matrix-qa.test", + verified: true, + }, + success: true, + }), + }; + } + throw new Error(`unexpected CLI command: ${joined}`); + }); + + const scenario = MATRIX_QA_SCENARIOS.find( + (entry) => entry.id === "matrix-e2ee-cli-recovery-key-setup", + ); + expect(scenario).toBeDefined(); + + await expect( + runMatrixQaScenario(scenario!, { + ...matrixQaScenarioContext(), + driverDeviceId: "DRIVERDEVICE", + driverPassword: "driver-password", + gatewayRuntimeEnv: { + OPENCLAW_CONFIG_PATH: "/tmp/gateway-config.json", + OPENCLAW_STATE_DIR: "/tmp/gateway-state", + PATH: process.env.PATH, + }, + outputDir, + }), + ).resolves.toMatchObject({ + artifacts: { + accountId: "cli-recovery-key-setup", + backupVersion: "backup-v1", + cliDeviceId: "CLIRECOVERYDEVICE", + encryptionChanged: true, + recoveryKeyId: "SSSS", + recoveryKeyStored: true, + setupSuccess: true, + verificationBootstrapSuccess: true, + }, + }); + + expect(initialAccountConfig).toMatchObject({ + accessToken: "cli-recovery-token", + deviceId: "CLIRECOVERYDEVICE", + encryption: false, + homeserver: "http://127.0.0.1:28008/", + password: "cli-recovery-password", + startupVerification: "off", + userId: "@cli-recovery:matrix-qa.test", + }); + expect(bootstrapOwnDeviceVerification).toHaveBeenCalledWith({ + allowAutomaticCrossSigningReset: false, + }); + expect(runMatrixQaOpenClawCli.mock.calls.map(([params]) => params.args)).toEqual([ + [ + "matrix", + "encryption", + "setup", + "--account", + "cli-recovery-key-setup", + "--recovery-key", + "encoded-recovery-key", + "--json", + ], + ]); + expect(registerWithToken).toHaveBeenCalledWith( + expect.objectContaining({ + deviceName: "OpenClaw Matrix QA CLI Recovery Key Owner", + registrationToken: "registration-token", + }), + ); + expect(loginWithPassword).toHaveBeenCalledWith( + expect.objectContaining({ + password: "cli-recovery-password", + userId: "@cli-recovery:matrix-qa.test", + }), + ); + expect(deleteOwnDevices).toHaveBeenCalledWith(["CLIRECOVERYDEVICE"]); + expect(stop).toHaveBeenCalledTimes(1); + const [cliRunDir] = await readdir(path.join(outputDir, "cli-recovery-key-setup")); + const cliArtifactDir = path.join(outputDir, "cli-recovery-key-setup", cliRunDir ?? ""); + await expect( + readFile(path.join(cliArtifactDir, "recovery-key-setup.stdout.txt"), "utf8"), + ).resolves.toContain('"backupVersion":"backup-v1"'); + } finally { + await rm(outputDir, { force: true, recursive: true }); + } + }); + + it("runs Matrix invalid recovery-key setup through the CLI QA scenario", async () => { + const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-cli-recovery-key-invalid-")); + try { + const deleteOwnDevices = vi.fn().mockResolvedValue(undefined); + const stop = vi.fn().mockResolvedValue(undefined); + const { loginWithPassword, registerWithToken } = mockMatrixQaCliAccount({ + accessToken: "cli-invalid-token", + deviceId: "CLIINVALIDDEVICE", + password: "cli-invalid-password", + userId: "@cli-invalid:matrix-qa.test", + }); + createMatrixQaE2eeScenarioClient.mockResolvedValueOnce({ + bootstrapOwnDeviceVerification: vi.fn().mockResolvedValue({ + crossSigning: { + published: true, + }, + success: true, + verification: { + backupVersion: "backup-v1", + crossSigningVerified: true, + recoveryKeyStored: true, + signedByOwner: true, + verified: true, + }, + }), + deleteOwnDevices, + getRecoveryKey: vi.fn().mockResolvedValue({ + encodedPrivateKey: "valid-recovery-key", + keyId: "SSSS", + }), + stop, + }); + const output = vi.fn(() => ({ + stderr: "", + stdout: JSON.stringify({ + accountId: "cli-invalid-recovery-key", + bootstrap: { + error: "Matrix recovery key could not unlock secret storage", + success: false, + }, + encryptionChanged: true, + success: false, + }), + })); + const wait = vi + .fn() + .mockRejectedValue(new Error("openclaw matrix encryption setup exited 1")); + const kill = vi.fn(); + startMatrixQaOpenClawCli.mockReturnValue({ + args: [ + "matrix", + "encryption", + "setup", + "--account", + "cli-invalid-recovery-key", + "--recovery-key", + "not-a-valid-matrix-recovery-key", + "--json", + ], + kill, + output, + wait, + waitForOutput: vi.fn(), + writeStdin: vi.fn(), + }); + + const scenario = MATRIX_QA_SCENARIOS.find( + (entry) => entry.id === "matrix-e2ee-cli-recovery-key-invalid", + ); + expect(scenario).toBeDefined(); + + await expect( + runMatrixQaScenario(scenario!, { + ...matrixQaScenarioContext(), + driverDeviceId: "DRIVERDEVICE", + driverPassword: "driver-password", + gatewayRuntimeEnv: { + OPENCLAW_CONFIG_PATH: "/tmp/gateway-config.json", + OPENCLAW_STATE_DIR: "/tmp/gateway-state", + PATH: process.env.PATH, + }, + outputDir, + }), + ).resolves.toMatchObject({ + artifacts: { + accountId: "cli-invalid-recovery-key", + bootstrapSuccess: false, + cliDeviceId: "CLIINVALIDDEVICE", + encryptionChanged: true, + recoveryKeyAccepted: false, + recoveryKeyRejected: true, + setupSuccess: false, + }, + }); + + expect(startMatrixQaOpenClawCli.mock.calls[0]?.[0].args).toEqual([ + "matrix", + "encryption", + "setup", + "--account", + "cli-invalid-recovery-key", + "--recovery-key", + "not-a-valid-matrix-recovery-key", + "--json", + ]); + expect(output).toHaveBeenCalledTimes(1); + expect(wait).toHaveBeenCalledTimes(1); + expect(kill).toHaveBeenCalledTimes(1); + expect(registerWithToken).toHaveBeenCalledWith( + expect.objectContaining({ + deviceName: "OpenClaw Matrix QA CLI Invalid Recovery Key Owner", + registrationToken: "registration-token", + }), + ); + expect(loginWithPassword).toHaveBeenCalledWith( + expect.objectContaining({ + password: "cli-invalid-password", + userId: "@cli-invalid:matrix-qa.test", + }), + ); + expect(deleteOwnDevices).toHaveBeenCalledWith(["CLIINVALIDDEVICE"]); + expect(stop).toHaveBeenCalledTimes(1); + const [cliRunDir] = await readdir(path.join(outputDir, "cli-recovery-key-invalid")); + const cliArtifactDir = path.join(outputDir, "cli-recovery-key-invalid", cliRunDir ?? ""); + await expect( + readFile(path.join(cliArtifactDir, "recovery-key-invalid.stdout.txt"), "utf8"), + ).resolves.not.toContain("not-a-valid-matrix-recovery-key"); + } finally { + await rm(outputDir, { force: true, recursive: true }); + } + }); + + it("runs Matrix multi-account encryption setup through the CLI QA scenario", async () => { + const outputDir = await mkdtemp( + path.join(os.tmpdir(), "matrix-cli-encryption-setup-multi-account-"), + ); + try { + const { loginWithPassword, registerWithToken } = mockMatrixQaCliAccount({ + accessToken: "cli-multi-token", + deviceId: "CLIMULTIDEVICE", + password: "cli-multi-password", + userId: "@cli-multi:matrix-qa.test", + }); + runMatrixQaOpenClawCli.mockImplementation(async ({ args, env }) => { + const configPath = String(env.OPENCLAW_CONFIG_PATH); + const config = JSON.parse(await readFile(configPath, "utf8")) as { + channels: { + matrix: { + accounts: Record>; + defaultAccount: string; + }; + }; + }; + expect(config.channels.matrix.defaultAccount).toBe("cli-multi-decoy"); + expect(config.channels.matrix.accounts["cli-multi-decoy"]?.encryption).toBe(false); + config.channels.matrix.accounts["cli-multi-target"] = { + ...config.channels.matrix.accounts["cli-multi-target"], + encryption: true, + }; + await writeTestJsonFile(configPath, config); + const joined = args.join(" "); + if (joined === "matrix encryption setup --account cli-multi-target --json") { + return { + args, + exitCode: 0, + stderr: "", + stdout: JSON.stringify({ + accountId: "cli-multi-target", + bootstrap: { + success: true, + }, + encryptionChanged: true, + status: { + backup: { + decryptionKeyCached: true, + keyLoadError: null, + matchesDecryptionKey: true, + trusted: true, + }, + crossSigningVerified: true, + deviceId: "CLIMULTIDEVICE", + signedByOwner: true, + userId: "@driver:matrix-qa.test", + verified: true, + }, + success: true, + }), + }; + } + throw new Error(`unexpected CLI command: ${joined}`); + }); + + const scenario = MATRIX_QA_SCENARIOS.find( + (entry) => entry.id === "matrix-e2ee-cli-encryption-setup-multi-account", + ); + expect(scenario).toBeDefined(); + + await expect( + runMatrixQaScenario(scenario!, { + ...matrixQaScenarioContext(), + driverDeviceId: "DRIVERDEVICE", + driverPassword: "driver-password", + gatewayRuntimeEnv: { + OPENCLAW_CONFIG_PATH: "/tmp/gateway-config.json", + OPENCLAW_STATE_DIR: "/tmp/gateway-state", + PATH: process.env.PATH, + }, + outputDir, + }), + ).resolves.toMatchObject({ + artifacts: { + accountId: "cli-multi-target", + cliDeviceId: "CLIMULTIDEVICE", + decoyAccountPreserved: true, + defaultAccountPreserved: true, + encryptionChanged: true, + setupSuccess: true, + verificationBootstrapSuccess: true, + }, + }); + + expect(runMatrixQaOpenClawCli.mock.calls.map(([params]) => params.args)).toEqual([ + ["matrix", "encryption", "setup", "--account", "cli-multi-target", "--json"], + ]); + expect(registerWithToken).toHaveBeenCalledWith( + expect.objectContaining({ + deviceName: "OpenClaw Matrix QA CLI Multi Account Owner", + registrationToken: "registration-token", + }), + ); + expect(loginWithPassword).toHaveBeenCalledWith( + expect.objectContaining({ + password: "cli-multi-password", + userId: "@cli-multi:matrix-qa.test", + }), + ); + const [cliRunDir] = await readdir(path.join(outputDir, "cli-encryption-setup-multi-account")); + const cliArtifactDir = path.join( + outputDir, + "cli-encryption-setup-multi-account", + cliRunDir ?? "", + ); + await expect( + readFile(path.join(cliArtifactDir, "encryption-setup-multi-account.stdout.txt"), "utf8"), + ).resolves.toContain('"accountId":"cli-multi-target"'); + } finally { + await rm(outputDir, { force: true, recursive: true }); + } + }); + + it("runs Matrix CLI setup then gateway encrypted reply through the QA scenario", async () => { + const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-cli-setup-gateway-reply-")); + const gatewayConfigPath = path.join(outputDir, "gateway-config.json"); + try { + await writeTestJsonFile(gatewayConfigPath, { + channels: { + matrix: { + defaultAccount: "sut", + accounts: { + sut: { + accessToken: "sut-token", + enabled: true, + homeserver: "http://127.0.0.1:28008", + userId: "@sut:matrix-qa.test", + }, + }, + }, + }, + }); + const gatewayAccount = { + accessToken: "cli-gateway-token", + deviceId: "CLIGATEWAYDEVICE", + localpart: "qa-cli-gateway", + password: "cli-gateway-password", + userId: "@cli-gateway:matrix-qa.test", + }; + const driverAccount = { + accessToken: "cli-driver-token", + deviceId: "CLIDRIVERDEVICE", + localpart: "qa-cli-driver", + password: "cli-driver-password", + userId: "@cli-driver:matrix-qa.test", + }; + const registerWithToken = vi + .fn() + .mockResolvedValueOnce(gatewayAccount) + .mockResolvedValueOnce(driverAccount); + const createPrivateRoom = vi.fn().mockResolvedValue("!isolated-e2ee:matrix-qa.test"); + const joinRoom = vi.fn().mockResolvedValue({ roomId: "!isolated-e2ee:matrix-qa.test" }); + createMatrixQaClient.mockImplementation(({ accessToken } = {}) => { + if (!accessToken) { + return { registerWithToken }; + } + if (accessToken === gatewayAccount.accessToken) { + return { joinRoom }; + } + if (accessToken === driverAccount.accessToken) { + return { createPrivateRoom }; + } + throw new Error(`unexpected Matrix QA client token: ${String(accessToken)}`); + }); + let replyToken = ""; + const driverStop = vi.fn().mockResolvedValue(undefined); + const driverClient = { + bootstrapOwnDeviceVerification: vi.fn().mockResolvedValue({ + crossSigning: { published: true }, + success: true, + verification: { + backupVersion: "1", + crossSigningVerified: true, + recoveryKeyStored: true, + signedByOwner: true, + verified: true, + }, + }), + getRecoveryKey: vi.fn().mockResolvedValue({ + encodedPrivateKey: "driver-recovery-key", + keyId: "driver-recovery-key-id", + }), + prime: vi.fn().mockResolvedValue("s1"), + resetRoomKeyBackup: vi.fn().mockResolvedValue({ success: true }), + sendTextMessage: vi.fn(async ({ body }) => { + replyToken = String(body).match(/MATRIX_QA_E2EE_CLI_GATEWAY_[A-Z0-9]+/)?.[0] ?? ""; + return "$driver-event"; + }), + stop: driverStop, + waitForJoinedMember: vi.fn().mockResolvedValue(undefined), + waitForRoomEvent: vi.fn(async ({ predicate }) => { + const event = { + body: replyToken, + eventId: "$gateway-reply", + kind: "message", + roomId: "!isolated-e2ee:matrix-qa.test", + sender: "@cli-gateway:matrix-qa.test", + type: "m.room.message", + }; + expect(predicate(event)).toBe(true); + return { event, since: "s2" }; + }), + }; + createMatrixQaE2eeScenarioClient.mockResolvedValueOnce(driverClient); + runMatrixQaOpenClawCli.mockImplementation(async ({ args, env }) => { + const joined = args.join(" "); + if (joined === "matrix encryption setup --account cli-setup-gateway --json") { + const configPath = String(env.OPENCLAW_CONFIG_PATH); + const config = JSON.parse(await readFile(configPath, "utf8")) as { + channels: { + matrix: { + accounts: Record>; + defaultAccount: string; + }; + }; + }; + expect(config.channels.matrix.defaultAccount).toBe("cli-setup-gateway"); + expect(config.channels.matrix.accounts["cli-setup-gateway"]?.encryption).toBe(false); + config.channels.matrix.accounts["cli-setup-gateway"] = { + ...config.channels.matrix.accounts["cli-setup-gateway"], + encryption: true, + setupBootstrapMarker: "preserved", + }; + await writeTestJsonFile(configPath, config); + return { + args, + exitCode: 0, + stderr: "", + stdout: JSON.stringify({ + accountId: "cli-setup-gateway", + bootstrap: { + success: true, + }, + encryptionChanged: false, + status: { + backup: { + decryptionKeyCached: true, + keyLoadError: null, + matchesDecryptionKey: true, + trusted: true, + }, + crossSigningVerified: true, + deviceId: "CLIGATEWAYDEVICE", + signedByOwner: true, + userId: "@cli-gateway:matrix-qa.test", + verified: true, + }, + success: true, + }), + }; + } + throw new Error(`unexpected CLI command: ${joined}`); + }); + const patchGatewayConfig = vi.fn().mockResolvedValue(undefined); + const restartGatewayAfterStateMutation = vi.fn(async (mutateState) => { + await mutateState({ stateDir: path.join(outputDir, "state") }); + }); + const waitGatewayAccountReady = vi.fn().mockResolvedValue(undefined); + + const scenario = MATRIX_QA_SCENARIOS.find( + (entry) => entry.id === "matrix-e2ee-cli-setup-then-gateway-reply", + ); + expect(scenario).toBeDefined(); + + await expect( + runMatrixQaScenario(scenario!, { + ...matrixQaScenarioContext(), + driverDeviceId: "DRIVERDEVICE", + driverPassword: "driver-password", + gatewayRuntimeEnv: { + OPENCLAW_CONFIG_PATH: gatewayConfigPath, + OPENCLAW_STATE_DIR: "/tmp/gateway-state", + PATH: process.env.PATH, + }, + outputDir, + patchGatewayConfig, + restartGatewayAfterStateMutation, + waitGatewayAccountReady, + sutAccountId: "sut", + sutDeviceId: "SUTDEVICE", + sutPassword: "sut-password", + topology: { + defaultRoomId: "!main:matrix-qa.test", + defaultRoomKey: "main", + rooms: [ + { + encrypted: true, + key: matrixQaE2eeRoomKey("matrix-e2ee-cli-setup-then-gateway-reply"), + kind: "group", + memberRoles: ["driver", "observer", "sut"], + memberUserIds: [ + "@driver:matrix-qa.test", + "@observer:matrix-qa.test", + "@sut:matrix-qa.test", + ], + name: "E2EE", + requireMention: true, + roomId: "!e2ee:matrix-qa.test", + }, + ], + }, + }), + ).resolves.toMatchObject({ + artifacts: { + accountId: "cli-setup-gateway", + cliDeviceId: "CLIGATEWAYDEVICE", + driverUserId: "@cli-driver:matrix-qa.test", + gatewayReply: { + eventId: "$gateway-reply", + tokenMatched: true, + }, + gatewayUserId: "@cli-gateway:matrix-qa.test", + roomId: "!isolated-e2ee:matrix-qa.test", + setupSuccess: true, + verificationBootstrapSuccess: true, + }, + }); + const finalGatewayConfig = JSON.parse(await readFile(gatewayConfigPath, "utf8")) as { + channels: { + matrix: { + accounts: Record>; + defaultAccount: string; + }; + }; + }; + expect(finalGatewayConfig.channels.matrix.defaultAccount).toBe("cli-setup-gateway"); + expect(Object.keys(finalGatewayConfig.channels.matrix.accounts)).toEqual([ + "cli-setup-gateway", + ]); + expect(finalGatewayConfig.channels.matrix.accounts["cli-setup-gateway"]).toMatchObject({ + encryption: true, + setupBootstrapMarker: "preserved", + }); + + expect(runMatrixQaOpenClawCli.mock.calls.map(([params]) => params.args)).toEqual([ + ["matrix", "encryption", "setup", "--account", "cli-setup-gateway", "--json"], + ]); + expect(registerWithToken).toHaveBeenCalledWith( + expect.objectContaining({ + deviceName: "OpenClaw Matrix QA CLI Setup Gateway", + registrationToken: "registration-token", + }), + ); + expect(registerWithToken).toHaveBeenCalledWith( + expect.objectContaining({ + deviceName: "OpenClaw Matrix QA CLI Setup Driver", + registrationToken: "registration-token", + }), + ); + expect(createPrivateRoom).toHaveBeenCalledWith({ + encrypted: true, + inviteUserIds: ["@cli-gateway:matrix-qa.test"], + name: "Matrix QA CLI Setup Gateway E2EE", + }); + expect(joinRoom).toHaveBeenCalledWith("!isolated-e2ee:matrix-qa.test"); + expect(patchGatewayConfig).not.toHaveBeenCalled(); + expect(restartGatewayAfterStateMutation).toHaveBeenCalledTimes(2); + expect(driverClient.sendTextMessage).toHaveBeenCalledWith( + expect.objectContaining({ + mentionUserIds: ["@cli-gateway:matrix-qa.test"], + roomId: "!isolated-e2ee:matrix-qa.test", + }), + ); + expect(driverClient.waitForJoinedMember).toHaveBeenCalledWith({ + roomId: "!isolated-e2ee:matrix-qa.test", + timeoutMs: 8_000, + userId: "@cli-gateway:matrix-qa.test", + }); + expect(createMatrixQaE2eeScenarioClient).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: "cli-driver-token", + deviceId: "CLIDRIVERDEVICE", + userId: "@cli-driver:matrix-qa.test", + }), + ); + expect(waitGatewayAccountReady).toHaveBeenCalledWith("cli-setup-gateway", { + timeoutMs: 8_000, + }); + expect(waitGatewayAccountReady).toHaveBeenCalledTimes(2); + expect(driverStop).toHaveBeenCalledTimes(1); + const [cliRunDir] = await readdir(path.join(outputDir, "cli-setup-then-gateway-reply")); + const cliArtifactDir = path.join(outputDir, "cli-setup-then-gateway-reply", cliRunDir ?? ""); + await expect( + readFile(path.join(cliArtifactDir, "encryption-setup.stdout.txt"), "utf8"), + ).resolves.toContain('"accountId":"cli-setup-gateway"'); + } finally { + await rm(outputDir, { force: true, recursive: true }); + } + }); + it("runs Matrix E2EE bootstrap failure through a real faulted homeserver endpoint", async () => { const stop = vi.fn().mockResolvedValue(undefined); const hits = vi.fn().mockReturnValue([ diff --git a/extensions/qa-matrix/src/substrate/e2ee-client.test.ts b/extensions/qa-matrix/src/substrate/e2ee-client.test.ts index 077309b2ff3..3f59e3e82c0 100644 --- a/extensions/qa-matrix/src/substrate/e2ee-client.test.ts +++ b/extensions/qa-matrix/src/substrate/e2ee-client.test.ts @@ -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); }); }); diff --git a/extensions/qa-matrix/src/substrate/e2ee-client.ts b/extensions/qa-matrix/src/substrate/e2ee-client.ts index dc940df5f7c..1e142133bd6 100644 --- a/extensions/qa-matrix/src/substrate/e2ee-client.ts +++ b/extensions/qa-matrix/src/substrate/e2ee-client.ts @@ -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; bootstrapOwnDeviceVerification(params?: { @@ -111,6 +129,7 @@ export type MatrixQaE2eeScenarioClient = { roomId: string; timeoutMs: number; }): Promise; + waitForJoinedMember(params: { roomId: string; timeoutMs: number; userId: string }): Promise; 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(); + const observedEventsById = new Map(); 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, };