From 2079e1ca1b3ba2c189f76a2333974a08b1e2f564 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 23 Apr 2026 00:04:06 -0400 Subject: [PATCH] fix: harden Matrix CLI verification artifacts --- extensions/matrix/src/cli.test.ts | 62 +++- extensions/matrix/src/cli.ts | 101 ++++-- .../runners/contract/scenario-runtime-e2ee.ts | 340 +++++++++--------- .../src/runners/contract/scenarios.test.ts | 30 +- 4 files changed, 319 insertions(+), 214 deletions(-) diff --git a/extensions/matrix/src/cli.test.ts b/extensions/matrix/src/cli.test.ts index 144e8d8e17d..1911ba989eb 100644 --- a/extensions/matrix/src/cli.test.ts +++ b/extensions/matrix/src/cli.test.ts @@ -288,10 +288,10 @@ describe("matrix CLI verification commands", () => { expect(consoleLogMock).toHaveBeenCalledWith("Device verified by owner: no"); expect(consoleLogMock).toHaveBeenCalledWith("Backup: active and trusted on this device"); expect(consoleLogMock).toHaveBeenCalledWith( - "- Recovery key can unlock the room-key backup, but full Matrix identity trust is still incomplete. Run 'openclaw matrix verify self' and follow the prompts from another Matrix client.", + "- Recovery key can unlock the room-key backup, but full Matrix identity trust is still incomplete. Run openclaw matrix verify self and follow the prompts from another Matrix client.", ); expect(consoleLogMock).toHaveBeenCalledWith( - "- If you intend to replace the current cross-signing identity, run 'openclaw matrix verify bootstrap --recovery-key --force-reset-cross-signing'.", + "- If you intend to replace the current cross-signing identity, run openclaw matrix verify bootstrap --recovery-key '' --force-reset-cross-signing.", ); }); @@ -378,13 +378,13 @@ describe("matrix CLI verification commands", () => { "- Accept the verification request in another Matrix client for this account.", ); expect(consoleLogMock).toHaveBeenCalledWith( - "- Then run 'openclaw matrix verify start txn-1 --account ops' to start SAS verification.", + "- Then run openclaw matrix verify start txn-1 --account ops to start SAS verification.", ); expect(consoleLogMock).toHaveBeenCalledWith( - "- Run 'openclaw matrix verify sas txn-1 --account ops' to display the SAS emoji or decimals.", + "- Run openclaw matrix verify sas txn-1 --account ops to display the SAS emoji or decimals.", ); expect(consoleLogMock).toHaveBeenCalledWith( - "- When the SAS matches, run 'openclaw matrix verify confirm-sas txn-1 --account ops'.", + "- When the SAS matches, run openclaw matrix verify confirm-sas txn-1 --account ops.", ); }); @@ -420,19 +420,19 @@ describe("matrix CLI verification commands", () => { listMatrixVerificationsMock.mockResolvedValue([ mockMatrixVerificationSummary({ id: "self-\u001B[31m1", - transactionId: "txn-\n1", - otherUserId: "@bot\u001B[2J:example.org", - otherDeviceId: "PHONE\r123", + transactionId: "txn-\n\u009B31m1", + otherUserId: "@bot\u001B[2J\u009Dspoof\u0007:example.org", + otherDeviceId: "PHONE\r\u009B2J123", phaseName: "started\u001B[0m", - methods: ["m.sas.v1\nspoof"], + methods: ["m.sas.v1\n\u009B31mspoof"], chosenMethod: "m.sas.v1\u001B[1m", sas: { emoji: [ - ["🐶", "Dog\u001B[31m"], - ["🐱", "Cat\nspoof"], + ["🐶", "Dog\u001B[31m\u009B2J"], + ["🐱", "Cat\n\u009B31mspoof"], ], }, - error: "Remote\u001B[31m cancelled\nforged", + error: "Remote\u001B[31m cancelled\n\u009B31mforged", }), ]); const program = buildProgram(); @@ -450,6 +450,30 @@ describe("matrix CLI verification commands", () => { expect(consoleLogMock).toHaveBeenCalledWith("Verification error: Remote cancelledforged"); }); + it("shell-quotes Matrix verification ids in follow-up command guidance", async () => { + requestMatrixVerificationMock.mockResolvedValue( + mockMatrixVerificationSummary({ + id: "self-verify-1", + transactionId: "txn-'$(touch /tmp/pwn)", + }), + ); + const program = buildProgram(); + + await program.parseAsync(["matrix", "verify", "request", "--own-user"], { + from: "user", + }); + + expect(consoleLogMock).toHaveBeenCalledWith( + "- Then run openclaw matrix verify start 'txn-'\\''$(touch /tmp/pwn)' to start SAS verification.", + ); + expect(consoleLogMock).toHaveBeenCalledWith( + "- Run openclaw matrix verify sas 'txn-'\\''$(touch /tmp/pwn)' to display the SAS emoji or decimals.", + ); + expect(consoleLogMock).toHaveBeenCalledWith( + "- When the SAS matches, run openclaw matrix verify confirm-sas 'txn-'\\''$(touch /tmp/pwn)'.", + ); + }); + it("shows Matrix SAS diagnostics and confirm/mismatch guidance", async () => { getMatrixVerificationSasMock.mockResolvedValue({ decimal: [1234, 5678, 9012], @@ -464,10 +488,10 @@ describe("matrix CLI verification commands", () => { }); expect(consoleLogMock).toHaveBeenCalledWith("SAS decimals: 1234 5678 9012"); expect(consoleLogMock).toHaveBeenCalledWith( - "- If they match, run 'openclaw matrix verify confirm-sas self-1'.", + "- If they match, run openclaw matrix verify confirm-sas self-1.", ); expect(consoleLogMock).toHaveBeenCalledWith( - "- If they do not match, run 'openclaw matrix verify mismatch-sas self-1'.", + "- If they do not match, run openclaw matrix verify mismatch-sas self-1.", ); }); @@ -483,7 +507,7 @@ describe("matrix CLI verification commands", () => { await program.parseAsync(["matrix", "verify", "accept", "verification-1"], { from: "user" }); expect(consoleLogMock).toHaveBeenCalledWith( - "- Run 'openclaw matrix verify start txn-stable' to start SAS verification.", + "- Run openclaw matrix verify start txn-stable to start SAS verification.", ); }); @@ -1261,7 +1285,7 @@ describe("matrix CLI verification commands", () => { "Backup issue: backup decryption key is not loaded on this device (secret storage did not return a key)", ); expect(console.log).toHaveBeenCalledWith( - "- Backup key is not loaded on this device. Run 'openclaw matrix verify backup restore' to load it and restore old room keys.", + "- Backup key is not loaded on this device. Run openclaw matrix verify backup restore to load it and restore old room keys.", ); expect(console.log).not.toHaveBeenCalledWith( "- Backup is present but not trusted for this device. Re-run 'openclaw matrix verify device '.", @@ -1328,7 +1352,7 @@ describe("matrix CLI verification commands", () => { await program.parseAsync(["matrix", "verify", "status"], { from: "user" }); expect(console.log).toHaveBeenCalledWith( - "- If you want a fresh backup baseline and accept losing unrecoverable history, run 'openclaw matrix verify backup reset --yes'. This may also repair secret storage so the new backup key can be loaded after restart.", + "- If you want a fresh backup baseline and accept losing unrecoverable history, run openclaw matrix verify backup reset --yes. This may also repair secret storage so the new backup key can be loaded after restart.", ); }); @@ -1406,10 +1430,10 @@ describe("matrix CLI verification commands", () => { }); expect(console.log).toHaveBeenCalledWith("Account: assistant"); expect(console.log).toHaveBeenCalledWith( - "- Run 'openclaw matrix verify device --account assistant' to verify this device.", + "- Run openclaw matrix verify device '' --account assistant to verify this device.", ); expect(console.log).toHaveBeenCalledWith( - "- Run 'openclaw matrix verify bootstrap --account assistant' to create a room key backup.", + "- Run openclaw matrix verify bootstrap --account assistant to create a room key backup.", ); }); diff --git a/extensions/matrix/src/cli.ts b/extensions/matrix/src/cli.ts index bf2823e13db..9f5d633d9a2 100644 --- a/extensions/matrix/src/cli.ts +++ b/extensions/matrix/src/cli.ts @@ -120,9 +120,23 @@ function resolveMatrixCliAccountContext(accountId?: string): { } function formatMatrixCliCommand(command: string, accountId?: string): string { + return formatMatrixCliCommandParts(command.split(" "), accountId); +} + +function formatMatrixCliCommandParts(parts: string[], accountId?: string): string { const normalizedAccountId = normalizeAccountId(accountId); - const suffix = normalizedAccountId === "default" ? "" : ` --account ${normalizedAccountId}`; - return `openclaw matrix ${command}${suffix}`; + const command = ["openclaw", "matrix", ...parts]; + if (normalizedAccountId !== "default") { + command.push("--account", normalizedAccountId); + } + return command.map(formatMatrixCliShellArg).join(" "); +} + +function formatMatrixCliShellArg(value: string): string { + if (/^[A-Za-z0-9_./:=@%+-]+$/.test(value)) { + return value; + } + return `'${value.replaceAll("'", "'\\''")}'`; } function printMatrixOwnDevices( @@ -649,6 +663,43 @@ function sanitizeMatrixCliText(value: string): string { let withoutAnsi = ""; for (let index = 0; index < value.length; index++) { const code = value.charCodeAt(index); + if (code === 0x9b) { + index++; + while (index < value.length && !isAnsiFinalByte(value.charCodeAt(index))) { + index++; + } + continue; + } + if (code === 0x9d) { + index++; + while (index < value.length) { + const current = value.charCodeAt(index); + if (current === 0x07 || current === 0x9c) { + break; + } + if (current === 0x1b && value[index + 1] === "\\") { + index++; + break; + } + index++; + } + continue; + } + if (code === 0x90 || code === 0x9e || code === 0x9f) { + index++; + while (index < value.length) { + const current = value.charCodeAt(index); + if (current === 0x07 || current === 0x9c) { + break; + } + if (current === 0x1b && value[index + 1] === "\\") { + index++; + break; + } + index++; + } + continue; + } if (code !== 0x1b) { withoutAnsi += value[index]; continue; @@ -683,13 +734,23 @@ function sanitizeMatrixCliText(value: string): string { let sanitized = ""; for (const character of withoutAnsi) { const code = character.charCodeAt(0); - if ((code >= 0x20 && code !== 0x7f) || code > 0x7f) { + if (!isUnsafeMatrixCliTerminalCode(code)) { sanitized += character; } } return sanitized; } +function isUnsafeMatrixCliTerminalCode(code: number): boolean { + return ( + code < 0x20 || + code === 0x7f || + (code >= 0x80 && code <= 0x9f) || + (code >= 0x202a && code <= 0x202e) || + (code >= 0x2066 && code <= 0x2069) + ); +} + function isAnsiFinalByte(code: number): boolean { return code >= 0x40 && code <= 0x7e; } @@ -759,8 +820,8 @@ function printMatrixVerificationSas(sas: MatrixCliVerificationSas): void { function printMatrixVerificationSasGuidance(requestId: string, accountId?: string): void { printGuidance([ `Compare the emoji or decimals with the other Matrix client.`, - `If they match, run '${formatMatrixCliCommand(`verify confirm-sas ${requestId}`, accountId)}'.`, - `If they do not match, run '${formatMatrixCliCommand(`verify mismatch-sas ${requestId}`, accountId)}'.`, + `If they match, run ${formatMatrixCliCommandParts(["verify", "confirm-sas", requestId], accountId)}.`, + `If they do not match, run ${formatMatrixCliCommandParts(["verify", "mismatch-sas", requestId], accountId)}.`, ]); } @@ -785,9 +846,9 @@ async function promptMatrixVerificationSasMatch(): Promise { function printMatrixVerificationRequestGuidance(requestId: string, accountId?: string): void { printGuidance([ `Accept the verification request in another Matrix client for this account.`, - `Then run '${formatMatrixCliCommand(`verify start ${requestId}`, accountId)}' to start SAS verification.`, - `Run '${formatMatrixCliCommand(`verify sas ${requestId}`, accountId)}' to display the SAS emoji or decimals.`, - `When the SAS matches, run '${formatMatrixCliCommand(`verify confirm-sas ${requestId}`, accountId)}'.`, + `Then run ${formatMatrixCliCommandParts(["verify", "start", requestId], accountId)} to start SAS verification.`, + `Run ${formatMatrixCliCommandParts(["verify", "sas", requestId], accountId)} to display the SAS emoji or decimals.`, + `When the SAS matches, run ${formatMatrixCliCommandParts(["verify", "confirm-sas", requestId], accountId)}.`, ]); } @@ -876,20 +937,20 @@ function buildVerificationGuidance( if (!status.verified) { if (status.recoveryKeyAccepted === true && status.backupUsable === true) { nextSteps.add( - `Recovery key can unlock the room-key backup, but full Matrix identity trust is still incomplete. Run '${formatMatrixCliCommand("verify self", accountId)}' and follow the prompts from another Matrix client.`, + `Recovery key can unlock the room-key backup, but full Matrix identity trust is still incomplete. Run ${formatMatrixCliCommand("verify self", accountId)} and follow the prompts from another Matrix client.`, ); nextSteps.add( - `If you intend to replace the current cross-signing identity, run '${formatMatrixCliCommand("verify bootstrap --recovery-key --force-reset-cross-signing", accountId)}'.`, + `If you intend to replace the current cross-signing identity, run ${formatMatrixCliCommand("verify bootstrap --recovery-key --force-reset-cross-signing", accountId)}.`, ); } else { nextSteps.add( - `Run '${formatMatrixCliCommand("verify device ", accountId)}' to verify this device.`, + `Run ${formatMatrixCliCommand("verify device ", accountId)} to verify this device.`, ); } } if (backupIssue.code === "missing-server-backup") { nextSteps.add( - `Run '${formatMatrixCliCommand("verify bootstrap", accountId)}' to create a room key backup.`, + `Run ${formatMatrixCliCommand("verify bootstrap", accountId)} to create a room key backup.`, ); } else if ( backupIssue.code === "key-load-failed" || @@ -898,30 +959,30 @@ function buildVerificationGuidance( ) { if (status.recoveryKeyStored) { nextSteps.add( - `Backup key is not loaded on this device. Run '${formatMatrixCliCommand("verify backup restore", accountId)}' to load it and restore old room keys.`, + `Backup key is not loaded on this device. Run ${formatMatrixCliCommand("verify backup restore", accountId)} to load it and restore old room keys.`, ); } else { nextSteps.add( - `Store a recovery key with '${formatMatrixCliCommand("verify device ", accountId)}', then run '${formatMatrixCliCommand("verify backup restore", accountId)}'.`, + `Store a recovery key with ${formatMatrixCliCommand("verify device ", accountId)}, then run ${formatMatrixCliCommand("verify backup restore", accountId)}.`, ); } } else if (backupIssue.code === "key-mismatch") { nextSteps.add( - `Backup key mismatch on this device. Re-run '${formatMatrixCliCommand("verify device ", accountId)}' with the matching recovery key.`, + `Backup key mismatch on this device. Re-run ${formatMatrixCliCommand("verify device ", accountId)} with the matching recovery key.`, ); nextSteps.add( - `If you want a fresh backup baseline and accept losing unrecoverable history, run '${formatMatrixCliCommand("verify backup reset --yes", accountId)}'. This may also repair secret storage so the new backup key can be loaded after restart.`, + `If you want a fresh backup baseline and accept losing unrecoverable history, run ${formatMatrixCliCommand("verify backup reset --yes", accountId)}. This may also repair secret storage so the new backup key can be loaded after restart.`, ); } else if (backupIssue.code === "untrusted-signature") { nextSteps.add( - `Backup trust chain is not verified on this device. Re-run '${formatMatrixCliCommand("verify device ", accountId)}' if you have the correct recovery key.`, + `Backup trust chain is not verified on this device. Re-run ${formatMatrixCliCommand("verify device ", accountId)} if you have the correct recovery key.`, ); nextSteps.add( - `If you want a fresh backup baseline and accept losing unrecoverable history, run '${formatMatrixCliCommand("verify backup reset --yes", accountId)}'. This may also repair secret storage so the new backup key can be loaded after restart.`, + `If you want a fresh backup baseline and accept losing unrecoverable history, run ${formatMatrixCliCommand("verify backup reset --yes", accountId)}. This may also repair secret storage so the new backup key can be loaded after restart.`, ); } else if (backupIssue.code === "indeterminate") { nextSteps.add( - `Run '${formatMatrixCliCommand("verify status --verbose", accountId)}' to inspect backup trust diagnostics.`, + `Run ${formatMatrixCliCommand("verify status --verbose", accountId)} to inspect backup trust diagnostics.`, ); } if (status.pendingVerifications > 0) { @@ -1287,7 +1348,7 @@ export function registerMatrixCli(params: { program: Command }): void { run: async (accountId, cfg) => await acceptMatrixVerification(id, { accountId, cfg }), afterText: (summary, accountId) => { printGuidance([ - `Run '${formatMatrixCliCommand(`verify start ${formatMatrixVerificationCommandId(summary)}`, accountId)}' to start SAS verification.`, + `Run ${formatMatrixCliCommandParts(["verify", "start", formatMatrixVerificationCommandId(summary)], accountId)} to start SAS verification.`, ]); }, errorPrefix: "Verification accept failed", 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 0335d01fb6d..d2ed617a23d 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,6 @@ import { randomUUID } from "node:crypto"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import type { MatrixVerificationSummary } from "@openclaw/matrix/test-api.js"; @@ -304,8 +305,8 @@ async function writeMatrixQaCliOutputArtifacts(params: { const stdoutPath = path.join(params.rootDir, `${prefix}.stdout.txt`); const stderrPath = path.join(params.rootDir, `${prefix}.stderr.txt`); await Promise.all([ - writeFile(stdoutPath, params.result.stdout), - writeFile(stderrPath, params.result.stderr), + writeFile(stdoutPath, params.result.stdout, { mode: 0o600 }), + writeFile(stderrPath, params.result.stderr, { mode: 0o600 }), ]); return { stderrPath, stdoutPath }; } @@ -376,13 +377,15 @@ async function createMatrixQaCliSelfVerificationRuntime(params: { userId: string; }) { const outputDir = requireMatrixQaE2eeOutputDir(params.context); - const rootDir = path.join( + const rootDir = await mkdtemp(path.join(tmpdir(), "openclaw-matrix-cli-qa-")); + const artifactDir = path.join( outputDir, "cli-self-verification", randomUUID().replaceAll("-", "").slice(0, 12), ); const stateDir = path.join(rootDir, "state"); const configPath = path.join(rootDir, "config.json"); + await mkdir(artifactDir, { recursive: true }); await mkdir(stateDir, { recursive: true }); await writeFile( configPath, @@ -436,8 +439,11 @@ async function createMatrixQaCliSelfVerificationRuntime(params: { }); return { configPath, + dispose: async () => { + await rm(rootDir, { force: true, recursive: true }); + }, run, - rootDir, + rootDir: artifactDir, start, stateDir, }; @@ -1175,181 +1181,187 @@ export async function runMatrixQaE2eeCliSelfVerificationScenario( deviceId: cliDevice.deviceId, userId: cliDevice.userId, }); - const restoreResult = await cli.run([ - "matrix", - "verify", - "backup", - "restore", - "--account", - accountId, - "--recovery-key", - encodedRecoveryKey, - "--json", - ]); - const restoreArtifacts = await writeMatrixQaCliOutputArtifacts({ - label: "verify-backup-restore", - result: restoreResult, - rootDir: cli.rootDir, - }); - 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 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 session.writeStdin("yes\n"); - await owner.confirmVerificationSas(ownerSas.id); - 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([ + const restoreResult = await cli.run([ "matrix", "verify", - "status", + "backup", + "restore", "--account", accountId, + "--recovery-key", + encodedRecoveryKey, "--json", ]); - const statusArtifacts = await writeMatrixQaCliOutputArtifacts({ - label: "verify-status", - result: statusResult, + const restoreArtifacts = await writeMatrixQaCliOutputArtifacts({ + label: "verify-backup-restore", + result: restoreResult, rootDir: cli.rootDir, }); - const status = parseMatrixQaCliJson(statusResult) as MatrixQaCliVerificationStatus; + const restored = parseMatrixQaCliJson(restoreResult) as MatrixQaCliBackupRestoreStatus; if ( - status.verified !== true || - status.crossSigningVerified !== true || - status.signedByOwner !== true || - status.backup?.trusted !== true || - status.backup?.matchesDecryptionKey !== true || - status.backup?.keyLoadError + restored.success !== true || + restored.backup?.decryptionKeyCached !== true || + restored.backup?.matchesDecryptionKey !== true || + restored.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}` : "" + `Matrix CLI recovery key did not load matching room-key backup material before self-verification: ${ + restored.error ?? restored.backup?.keyLoadError ?? "unknown backup state" }`, ); } - 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 config path: ${cli.configPath}`, - `cli state dir: ${cli.stateDir}`, - `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"), - }; + 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 session.writeStdin("yes\n"); + await owner.confirmVerificationSas(ownerSas.id); + 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(); + } } finally { - session.kill(); + await cli.dispose(); } }, ); diff --git a/extensions/qa-matrix/src/runners/contract/scenarios.test.ts b/extensions/qa-matrix/src/runners/contract/scenarios.test.ts index 24fb0a24027..805a33a124f 100644 --- a/extensions/qa-matrix/src/runners/contract/scenarios.test.ts +++ b/extensions/qa-matrix/src/runners/contract/scenarios.test.ts @@ -3013,7 +3013,20 @@ describe("matrix live qa scenarios", () => { waitForOutput, writeStdin, }); - runMatrixQaOpenClawCli.mockImplementation(async ({ args }) => { + let cliAccountConfigDuringRun: Record | null = null; + runMatrixQaOpenClawCli.mockImplementation(async ({ args, env }) => { + if (!cliAccountConfigDuringRun && env.OPENCLAW_CONFIG_PATH) { + const cliConfig = JSON.parse( + await readFile(String(env.OPENCLAW_CONFIG_PATH), "utf8"), + ) as { + channels?: { + matrix?: { + accounts?: Record>; + }; + }; + }; + cliAccountConfigDuringRun = cliConfig.channels?.matrix?.accounts?.cli ?? null; + } const joined = args.join(" "); if (joined === "matrix verify status --account cli --json") { return { @@ -3111,17 +3124,10 @@ describe("matrix live qa scenarios", () => { ["matrix", "verify", "status", "--account", "cli", "--json"], ]); const cliEnv = startMatrixQaOpenClawCli.mock.calls[0]?.[0].env; - expect(cliEnv?.OPENCLAW_STATE_DIR).toContain("cli-self-verification"); - expect(cliEnv?.OPENCLAW_CONFIG_PATH).toContain("cli-self-verification"); + expect(cliEnv?.OPENCLAW_STATE_DIR).toContain("openclaw-matrix-cli-qa-"); + expect(cliEnv?.OPENCLAW_CONFIG_PATH).toContain("openclaw-matrix-cli-qa-"); const configPath = String(cliEnv?.OPENCLAW_CONFIG_PATH); - const cliConfig = JSON.parse(await readFile(configPath, "utf8")) as { - channels?: { - matrix?: { - accounts?: Record>; - }; - }; - }; - expect(cliConfig.channels?.matrix?.accounts?.cli).toMatchObject({ + expect(cliAccountConfigDuringRun).toMatchObject({ accessToken: "cli-token", deviceId: "CLIDEVICE", encryption: true, @@ -3129,6 +3135,8 @@ describe("matrix live qa scenarios", () => { startupVerification: "off", userId: "@driver:matrix-qa.test", }); + await expect(readFile(configPath, "utf8")).rejects.toThrow(); + await expect(readdir(String(cliEnv?.OPENCLAW_STATE_DIR))).rejects.toThrow(); expect(acceptVerification).toHaveBeenCalledWith("owner-request"); expect(confirmVerificationSas).toHaveBeenCalledWith("owner-request"); expect(deleteOwnDevices).not.toHaveBeenCalled();