diff --git a/CHANGELOG.md b/CHANGELOG.md index 35e9e64c83e..82104e2c440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -543,6 +543,7 @@ Docs: https://docs.openclaw.ai - Slack: route native stream fallback replies through the normal chunked sender so long buffered Slack Connect responses are not dropped or duplicated. (#71124) Thanks @martingarramon. - WhatsApp: transcribe accepted voice notes before agent dispatch while keeping spoken transcripts out of command authorization. (#64120) Thanks @rogerdigital. - Plugins/CLI: expose channel plugin CLI descriptors during discovery-mode plugin loads so snapshot registries keep channel commands visible without activating full runtimes. (#71309) Thanks @gumadeiras. +- Matrix: separate recovery-key, backup, and owner-trust diagnostics during E2EE recovery, add recovery-key rotation for backup reset, and cover destructive backup restore paths in QA. (#71311) Thanks @gumadeiras. - WhatsApp: deliver media generated by tool-result replies while still suppressing text-only tool chatter. (#60968) Thanks @adaclaw. - Config/agents: accept `agents.list[].contextTokens` in strict config validation so per-agent overrides survive hot reload, letting `/status` reflect the configured model window instead of the 200k fallback. Fixes #70692. (#71247) Thanks @statxc. - Heartbeat: include async exec completion details in heartbeat prompts so command-finished notifications relay the actual output. (#71213) Thanks @GodsBoy. diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index e1160ef8b8f..e4257fcbf3f 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -398,6 +398,12 @@ Restore room keys from server backup: openclaw matrix verify backup restore ``` +If the backup key is not already loaded on disk, pass the Matrix recovery key: + +```bash +openclaw matrix verify backup restore --recovery-key "" +``` + Interactive self-verification flow: ```bash @@ -480,6 +486,8 @@ openclaw matrix verify status ``` Add `--account ` to target a named account. This can also recreate secret storage if the current backup secret cannot be loaded safely. + Add `--rotate-recovery-key` only when you intentionally want the old recovery + key to stop unlocking the fresh backup baseline. @@ -501,6 +509,34 @@ openclaw matrix verify status + + If `verify status` says the current device is no longer listed on the + homeserver, create a new OpenClaw Matrix device. For password login: + +```bash +openclaw matrix account add \ + --account assistant \ + --homeserver https://matrix.example.org \ + --user-id '@assistant:example.org' \ + --password '' \ + --device-name OpenClaw-Gateway +``` + + For token auth, create a fresh access token in your Matrix client or admin UI, + then update OpenClaw: + +```bash +openclaw matrix account add \ + --account assistant \ + --homeserver https://matrix.example.org \ + --access-token '' +``` + + Replace `assistant` with the account ID from the failed command, or omit + `--account` for the default account. + + + Old OpenClaw-managed devices can accumulate. List and prune: diff --git a/docs/install/migrating-matrix.md b/docs/install/migrating-matrix.md index 27e1dc98c67..3c580a73aae 100644 --- a/docs/install/migrating-matrix.md +++ b/docs/install/migrating-matrix.md @@ -93,16 +93,20 @@ If your old installation had local-only encrypted history that was never backed openclaw matrix verify backup status ``` -5. If OpenClaw tells you a recovery key is needed, run: +5. Put the recovery key for the Matrix account you are repairing in an account-specific environment variable. For a single default account, `MATRIX_RECOVERY_KEY` is fine. For multiple accounts, use one variable per account, for example `MATRIX_RECOVERY_KEY_ASSISTANT`, and add `--account assistant` to the command. + +6. If OpenClaw tells you a recovery key is needed, run the command for the matching account: ```bash - openclaw matrix verify backup restore --recovery-key "" + printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify backup restore --recovery-key-stdin + printf '%s\n' "$MATRIX_RECOVERY_KEY_ASSISTANT" | openclaw matrix verify backup restore --recovery-key-stdin --account assistant ``` -6. If this device is still unverified, run: +7. If this device is still unverified, run the command for the matching account: ```bash - openclaw matrix verify device "" + printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify device --recovery-key-stdin + printf '%s\n' "$MATRIX_RECOVERY_KEY_ASSISTANT" | openclaw matrix verify device --recovery-key-stdin --account assistant ``` If the recovery key is accepted and backup is usable, but `Cross-signing verified` @@ -116,13 +120,13 @@ If your old installation had local-only encrypted history that was never backed and type `yes` only when they match. The command exits successfully only after `Cross-signing verified` becomes `yes`. -7. If you are intentionally abandoning unrecoverable old history and want a fresh backup baseline for future messages, run: +8. If you are intentionally abandoning unrecoverable old history and want a fresh backup baseline for future messages, run: ```bash openclaw matrix verify backup reset --yes ``` -8. If no server-side key backup exists yet, create one for future recoveries: +9. If no server-side key backup exists yet, create one for future recoveries: ```bash openclaw matrix verify bootstrap @@ -242,15 +246,15 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins - Meaning: some old room keys existed only in the old local store and had never been uploaded to Matrix backup. - What to do: expect some old encrypted history to remain unavailable unless you can recover those keys manually from another verified client. -`Legacy Matrix encrypted state for account "..." has backed-up room keys, but no local backup decryption key was found. Ask the operator to run "openclaw matrix verify backup restore --recovery-key " after upgrade if they have the recovery key.` +`Legacy Matrix encrypted state for account "..." has backed-up room keys, but no local backup decryption key was found. Ask the operator to run "openclaw matrix verify backup restore --recovery-key-stdin" after upgrade if they have the recovery key.` - Meaning: backup exists, but OpenClaw could not recover the recovery key automatically. -- What to do: run `openclaw matrix verify backup restore --recovery-key ""`. +- What to do: run `printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify backup restore --recovery-key-stdin`. `Failed inspecting legacy Matrix encrypted state for account "..." (...): ...` - Meaning: OpenClaw found the old encrypted store, but it could not inspect it safely enough to prepare recovery. -- What to do: rerun `openclaw doctor --fix`. If it repeats, keep the old state directory intact and recover using another verified Matrix client plus `openclaw matrix verify backup restore --recovery-key ""`. +- What to do: rerun `openclaw doctor --fix`. If it repeats, keep the old state directory intact and recover using another verified Matrix client plus `printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify backup restore --recovery-key-stdin`. `Legacy Matrix backup key was found for account "...", but .../recovery-key.json already contains a different recovery key. Leaving the existing file unchanged.` @@ -265,39 +269,39 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins `matrix: failed restoring room keys from legacy encrypted-state backup: ...` - Meaning: the new plugin attempted restore but Matrix returned an error. -- What to do: run `openclaw matrix verify backup status`, then retry with `openclaw matrix verify backup restore --recovery-key ""` if needed. +- What to do: run `openclaw matrix verify backup status`, then retry with `printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify backup restore --recovery-key-stdin` if needed. ### Manual recovery messages `Backup key is not loaded on this device. Run 'openclaw matrix verify backup restore' to load it and restore old room keys.` - Meaning: OpenClaw knows you should have a backup key, but it is not active on this device. -- What to do: run `openclaw matrix verify backup restore`, or pass `--recovery-key` if needed. +- What to do: run `openclaw matrix verify backup restore`, or set `MATRIX_RECOVERY_KEY` and run `printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify backup restore --recovery-key-stdin` if needed. -`Store a recovery key with 'openclaw matrix verify device ', then run 'openclaw matrix verify backup restore'.` +`Store a recovery key with 'openclaw matrix verify device --recovery-key-stdin', then run 'openclaw matrix verify backup restore'.` - Meaning: this device does not currently have the recovery key stored. -- What to do: verify the device with your recovery key first, then restore the backup. +- What to do: set `MATRIX_RECOVERY_KEY`, run `printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify device --recovery-key-stdin`, then restore the backup. -`Backup key mismatch on this device. Re-run 'openclaw matrix verify device ' with the matching recovery key.` +`Backup key mismatch on this device. Re-run 'openclaw matrix verify device --recovery-key-stdin' with the matching recovery key.` - Meaning: the stored key does not match the active Matrix backup. -- What to do: rerun `openclaw matrix verify device ""` with the correct key. +- What to do: set `MATRIX_RECOVERY_KEY` to the correct key and run `printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify device --recovery-key-stdin`. If you accept losing unrecoverable old encrypted history, you can instead reset the current backup baseline with `openclaw matrix verify backup reset --yes`. When the stored backup secret is broken, that reset may also recreate secret storage so the new backup key can load correctly after restart. -`Backup trust chain is not verified on this device. Re-run 'openclaw matrix verify device '.` +`Backup trust chain is not verified on this device. Re-run 'openclaw matrix verify device --recovery-key-stdin'.` - Meaning: the backup exists, but this device does not trust the cross-signing chain strongly enough yet. -- What to do: rerun `openclaw matrix verify device ""`. +- What to do: set `MATRIX_RECOVERY_KEY` and run `printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify device --recovery-key-stdin`. `Matrix recovery key is required` - Meaning: you tried a recovery step without supplying a recovery key when one was required. -- What to do: rerun the command with your recovery key. +- What to do: rerun the command with `--recovery-key-stdin`, for example `printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify device --recovery-key-stdin`. `Invalid Matrix recovery key: ...` @@ -313,7 +317,7 @@ new backup key can load correctly after restart. - What to do: run `openclaw matrix verify self`, accept the request in another Matrix client, compare the SAS, and type `yes` only when it matches. The command waits for full Matrix identity trust before reporting success. Use - `openclaw matrix verify bootstrap --recovery-key "" --force-reset-cross-signing` + `printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify bootstrap --recovery-key-stdin --force-reset-cross-signing` only when you intentionally want to replace the current cross-signing identity. `Matrix key backup is not active on this device after loading from secret storage.` @@ -321,10 +325,10 @@ new backup key can load correctly after restart. - Meaning: secret storage did not produce an active backup session on this device. - What to do: verify the device first, then recheck with `openclaw matrix verify backup status`. -`Matrix crypto backend cannot load backup keys from secret storage. Verify this device with 'openclaw matrix verify device ' first.` +`Matrix crypto backend cannot load backup keys from secret storage. Verify this device with 'openclaw matrix verify device --recovery-key-stdin' first.` - Meaning: this device cannot restore from secret storage until device verification is complete. -- What to do: run `openclaw matrix verify device ""` first. +- What to do: run `printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify device --recovery-key-stdin` first. ### Custom plugin install messages @@ -340,7 +344,7 @@ Run these checks in order: ```bash openclaw matrix verify status --verbose openclaw matrix verify backup status --verbose -openclaw matrix verify backup restore --recovery-key "" --verbose +printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify backup restore --recovery-key-stdin --verbose ``` If the backup restores successfully but some old rooms are still missing history, those missing keys were probably never backed up by the previous plugin. diff --git a/extensions/matrix/src/cli.test.ts b/extensions/matrix/src/cli.test.ts index 06f2e422cdc..3eef5ab5e90 100644 --- a/extensions/matrix/src/cli.test.ts +++ b/extensions/matrix/src/cli.test.ts @@ -34,6 +34,15 @@ const consoleLogMock = vi.fn(); const consoleErrorMock = vi.fn(); const stdoutWriteMock = vi.fn(); +function mockRecoveryKeyStdin(value: string): void { + vi.spyOn(process.stdin, Symbol.asyncIterator).mockReturnValue( + (async function* (): AsyncGenerator { + yield Buffer.from(value); + return undefined; + })(), + ); +} + vi.mock("./matrix/actions/verification.js", () => ({ acceptMatrixVerification: (...args: unknown[]) => acceptMatrixVerificationMock(...args), bootstrapMatrixVerification: (...args: unknown[]) => bootstrapMatrixVerificationMock(...args), @@ -288,10 +297,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, accept the request in another verified Matrix client, and confirm the SAS only if it matches.", ); 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 the shown printf pipeline with the Matrix recovery key env var for this account: printf '%s\\n' \"$MATRIX_RECOVERY_KEY\" | openclaw matrix verify bootstrap --recovery-key-stdin --force-reset-cross-signing.", ); }); @@ -748,6 +757,35 @@ describe("matrix CLI verification commands", () => { expect(process.exitCode).toBe(1); }); + it("reads backup restore recovery key from stdin", async () => { + restoreMatrixRoomKeyBackupMock.mockResolvedValue({ + success: true, + backupVersion: "1", + imported: 1, + total: 1, + loadedFromSecretStorage: false, + backup: { + serverVersion: "1", + activeVersion: "1", + trusted: true, + matchesDecryptionKey: true, + decryptionKeyCached: true, + }, + }); + mockRecoveryKeyStdin("stdin-recovery-key\n"); + const program = buildProgram(); + + await program.parseAsync(["matrix", "verify", "backup", "restore", "--recovery-key-stdin"], { + from: "user", + }); + + expect(restoreMatrixRoomKeyBackupMock).toHaveBeenCalledWith( + expect.objectContaining({ + recoveryKey: "stdin-recovery-key", + }), + ); + }); + it("sets non-zero exit code for backup reset failures in JSON mode", async () => { resetMatrixRoomKeyBackupMock.mockResolvedValue({ success: false, @@ -1432,13 +1470,50 @@ 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. If restore still cannot load the key, run the shown printf pipeline with the Matrix recovery key env var for this account: printf '%s\\n' \"$MATRIX_RECOVERY_KEY\" | openclaw matrix verify backup restore --recovery-key-stdin.", ); expect(console.log).not.toHaveBeenCalledWith( "- Backup is present but not trusted for this device. Re-run 'openclaw matrix verify device '.", ); }); + it("fails status with re-login guidance when the current Matrix device is missing on the server", async () => { + getMatrixVerificationStatusMock.mockResolvedValue({ + encryptionEnabled: true, + verified: false, + localVerified: true, + crossSigningVerified: false, + signedByOwner: false, + userId: "@bot:example.org", + deviceId: "DEVICE123", + serverDeviceKnown: false, + backupVersion: null, + backup: { + serverVersion: null, + activeVersion: null, + trusted: null, + matchesDecryptionKey: null, + decryptionKeyCached: true, + keyLoadAttempted: false, + keyLoadError: null, + }, + recoveryKeyStored: true, + recoveryKeyCreatedAt: "2026-02-25T20:10:11.000Z", + pendingVerifications: 0, + }); + const program = buildProgram(); + + await program.parseAsync(["matrix", "verify", "status"], { from: "user" }); + + expect(process.exitCode).toBe(1); + expect(console.log).toHaveBeenCalledWith( + "Device issue: current Matrix device is missing from the homeserver device list", + ); + expect(console.log).toHaveBeenCalledWith( + "- This Matrix device is no longer listed on the homeserver. Create a new OpenClaw Matrix device with openclaw matrix account add --homeserver '' --user-id '<@user:server>' --password '' --device-name OpenClaw-Gateway. If you use token auth, create a fresh Matrix access token in your Matrix client or admin UI, then run openclaw matrix account add --homeserver '' --access-token ''.", + ); + }); + it("includes key load failure details in status output", async () => { getMatrixVerificationStatusMock.mockResolvedValue({ encryptionEnabled: true, @@ -1499,7 +1574,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. Add --rotate-recovery-key only when the old recovery key should stop unlocking the fresh backup.", ); }); @@ -1513,7 +1588,7 @@ describe("matrix CLI verification commands", () => { expect(process.exitCode).toBe(1); expect(resetMatrixRoomKeyBackupMock).not.toHaveBeenCalled(); expect(console.error).toHaveBeenCalledWith( - "Backup reset failed: Refusing to reset Matrix room-key backup without --yes", + "Backup reset failed: Refusing to reset Matrix room-key backup without --yes. If you accept losing unrecoverable history, re-run openclaw matrix verify backup reset --yes.", ); }); @@ -1527,6 +1602,7 @@ describe("matrix CLI verification commands", () => { expect(resetMatrixRoomKeyBackupMock).toHaveBeenCalledWith({ accountId: "default", cfg: {}, + rotateRecoveryKey: false, }); expect(console.log).toHaveBeenCalledWith("Reset success: yes"); expect(console.log).toHaveBeenCalledWith("Previous backup version: 1"); @@ -1535,6 +1611,23 @@ describe("matrix CLI verification commands", () => { expect(console.log).toHaveBeenCalledWith("Backup: active and trusted on this device"); }); + it("passes recovery-key rotation through backup reset", async () => { + const program = buildProgram(); + + await program.parseAsync( + ["matrix", "verify", "backup", "reset", "--yes", "--rotate-recovery-key"], + { + from: "user", + }, + ); + + expect(resetMatrixRoomKeyBackupMock).toHaveBeenCalledWith({ + accountId: "default", + cfg: {}, + rotateRecoveryKey: true, + }); + }); + it("prints resolved account-aware guidance when a named Matrix account is selected implicitly", async () => { resolveMatrixAuthContextMock.mockImplementation( ({ cfg, accountId }: { cfg: unknown; accountId?: string | null }) => ({ @@ -1577,7 +1670,7 @@ 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 the shown printf pipeline with the Matrix recovery key env var for this account: printf '%s\\n' \"$MATRIX_RECOVERY_KEY_ASSISTANT\" | openclaw matrix verify device --recovery-key-stdin --account assistant. If you do not have the recovery key but still have another verified Matrix client, run openclaw matrix verify self --account assistant instead.", ); expect(console.log).toHaveBeenCalledWith( "- 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 c67e0990c11..9c3d3f4d6ad 100644 --- a/extensions/matrix/src/cli.ts +++ b/extensions/matrix/src/cli.ts @@ -74,6 +74,44 @@ function markCliFailure(): void { process.exitCode = 1; } +async function readMatrixCliRecoveryKeyFromStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); + } + const recoveryKey = Buffer.concat(chunks).toString("utf8").trim(); + if (!recoveryKey) { + throw new Error("Matrix recovery key was requested from stdin, but stdin was empty."); + } + return recoveryKey; +} + +async function resolveMatrixCliRecoveryKeyInput(options: { + recoveryKey?: string; + recoveryKeyStdin?: boolean; +}): Promise { + if (options.recoveryKey && options.recoveryKeyStdin === true) { + throw new Error("Use either --recovery-key or --recovery-key-stdin, not both."); + } + if (options.recoveryKeyStdin === true) { + return await readMatrixCliRecoveryKeyFromStdin(); + } + return options.recoveryKey; +} + +async function requireMatrixCliRecoveryKeyInput(options: { + recoveryKey?: string; + recoveryKeyStdin?: boolean; +}): Promise { + const recoveryKey = await resolveMatrixCliRecoveryKeyInput(options); + if (!recoveryKey) { + throw new Error( + "Matrix recovery key is required. Pass --recovery-key-stdin to read it from stdin.", + ); + } + return recoveryKey; +} + function toErrorMessage(err: unknown): string { return formatMatrixErrorMessage(err); } @@ -123,6 +161,15 @@ function formatMatrixCliCommand(command: string, accountId?: string): string { return formatMatrixCliCommandParts(command.split(" "), accountId); } +function formatMatrixCliRecoveryKeyStdinCommand(command: string, accountId?: string): string { + const normalizedAccountId = normalizeAccountId(accountId); + const envName = + normalizedAccountId === "default" + ? "MATRIX_RECOVERY_KEY" + : `MATRIX_RECOVERY_KEY_${normalizedAccountId.replace(/[^A-Za-z0-9]/g, "_").toUpperCase()}`; + return `printf '%s\\n' "$${envName}" | ${formatMatrixCliCommand(command, accountId)}`; +} + function formatMatrixCliCommandParts(parts: string[], accountId?: string): string { const normalizedAccountId = normalizeAccountId(accountId); const command = ["openclaw", "matrix", ...parts]; @@ -490,6 +537,7 @@ type MatrixCliCommandConfig = { shouldFail?: (result: TResult) => boolean; errorPrefix: string; onJsonError?: (message: string) => unknown; + onTextError?: (message: string) => void; }; async function runMatrixCliCommand( @@ -512,6 +560,7 @@ async function runMatrixCliCommand( printJson(config.onJsonError ? config.onJsonError(message) : { error: message }); } else { console.error(`${config.errorPrefix}: ${formatMatrixCliText(message)}`); + config.onTextError?.(message); } markCliFailure(); } finally { @@ -539,6 +588,7 @@ type MatrixCliVerificationStatus = { signedByOwner: boolean; backupVersion: string | null; backup?: MatrixCliBackupStatus; + serverDeviceKnown?: boolean | null; recoveryKeyStored: boolean; recoveryKeyCreatedAt: string | null; pendingVerifications: number; @@ -1023,6 +1073,12 @@ async function runMatrixCliSelfVerificationCommand( } console.log("Self-verification complete."); }, + onTextError: () => { + printGuidance([ + `Run ${formatMatrixCliCommand("verify self", accountId)} again and accept the request in another verified Matrix client for this account.`, + `Then run ${formatMatrixCliCommand("verify status --verbose", accountId)} to confirm Cross-signing verified: yes and Signed by owner: yes.`, + ]); + }, errorPrefix: "Self-verification failed", }); } @@ -1031,6 +1087,14 @@ function printVerificationGuidance(status: MatrixCliVerificationStatus, accountI printGuidance(buildVerificationGuidance(status, accountId)); } +function printBackupGuidance( + backup: MatrixCliBackupStatus, + accountId?: string, + options: { recoveryKeyStored?: boolean } = {}, +): void { + printGuidance(buildBackupGuidance(backup, accountId, options)); +} + function printBackupSummary(backup: MatrixCliBackupStatus): void { const issue = resolveMatrixRoomKeyBackupIssue(backup); console.log(`Backup: ${issue.summary}`); @@ -1044,22 +1108,46 @@ function buildVerificationGuidance( accountId?: string, ): string[] { const backup = resolveBackupStatus(status); - const backupIssue = resolveMatrixRoomKeyBackupIssue(backup); const nextSteps = new Set(); 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)}, accept the request in another verified Matrix client, and confirm the SAS only if it matches.`, ); 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 the shown printf pipeline with the Matrix recovery key env var for this account: ${formatMatrixCliRecoveryKeyStdinCommand("verify bootstrap --recovery-key-stdin --force-reset-cross-signing", accountId)}.`, ); } else { nextSteps.add( - `Run ${formatMatrixCliCommand("verify device ", accountId)} to verify this device.`, + `Run the shown printf pipeline with the Matrix recovery key env var for this account: ${formatMatrixCliRecoveryKeyStdinCommand("verify device --recovery-key-stdin", accountId)}. If you do not have the recovery key but still have another verified Matrix client, run ${formatMatrixCliCommand("verify self", accountId)} instead.`, ); } } + if (status.serverDeviceKnown === false) { + nextSteps.add( + `This Matrix device is no longer listed on the homeserver. Create a new OpenClaw Matrix device with ${formatMatrixCliCommand("account add --homeserver --user-id <@user:server> --password --device-name OpenClaw-Gateway", accountId)}. If you use token auth, create a fresh Matrix access token in your Matrix client or admin UI, then run ${formatMatrixCliCommand("account add --homeserver --access-token ", accountId)}.`, + ); + } + for (const step of buildBackupGuidance(backup, accountId, { + recoveryKeyStored: status.recoveryKeyStored, + })) { + nextSteps.add(step); + } + if (status.pendingVerifications > 0) { + nextSteps.add( + `Review pending verification requests with ${formatMatrixCliCommand("verify list", accountId)}. Complete each active request with ${formatMatrixCliCommand("verify sas ", accountId)} and ${formatMatrixCliCommand("verify confirm-sas ", accountId)}, or cancel stale requests with ${formatMatrixCliCommand("verify cancel ", accountId)}.`, + ); + } + return Array.from(nextSteps); +} + +function buildBackupGuidance( + backup: MatrixCliBackupStatus, + accountId?: string, + options: { recoveryKeyStored?: boolean } = {}, +): string[] { + const backupIssue = resolveMatrixRoomKeyBackupIssue(backup); + const nextSteps = new Set(); if (backupIssue.code === "missing-server-backup") { nextSteps.add( `Run ${formatMatrixCliCommand("verify bootstrap", accountId)} to create a room key backup.`, @@ -1069,37 +1157,37 @@ function buildVerificationGuidance( backupIssue.code === "key-not-loaded" || backupIssue.code === "inactive" ) { - if (status.recoveryKeyStored) { + if (options.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. If restore still cannot load the key, run the shown printf pipeline with the Matrix recovery key env var for this account: ${formatMatrixCliRecoveryKeyStdinCommand("verify backup restore --recovery-key-stdin", accountId)}.`, ); } else { nextSteps.add( - `Store a recovery key with ${formatMatrixCliCommand("verify device ", accountId)}, then run ${formatMatrixCliCommand("verify backup restore", accountId)}.`, + `Run the shown printf pipeline with the Matrix recovery key env var for this account: ${formatMatrixCliRecoveryKeyStdinCommand("verify backup restore --recovery-key-stdin", accountId)} to load the server backup and store the key for future restores.`, ); } } 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. Run the shown printf pipeline with the active server backup recovery key env var for this account: ${formatMatrixCliRecoveryKeyStdinCommand("verify backup restore --recovery-key-stdin", accountId)}.`, ); 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)}. Add --rotate-recovery-key only when the old recovery key should stop unlocking the fresh backup.`, ); } 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. Run the shown printf pipeline with the correct recovery key env var for this account: ${formatMatrixCliRecoveryKeyStdinCommand("verify device --recovery-key-stdin", accountId)}.`, ); 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 device identity trust remains incomplete after that, run ${formatMatrixCliCommand("verify self", accountId)} from another verified Matrix client.`, + ); + nextSteps.add( + `If you want a fresh backup baseline and accept losing unrecoverable history, run ${formatMatrixCliCommand("verify backup reset --yes", accountId)}. Add --rotate-recovery-key only when the old recovery key should stop unlocking the fresh backup.`, ); } else if (backupIssue.code === "indeterminate") { nextSteps.add( `Run ${formatMatrixCliCommand("verify status --verbose", accountId)} to inspect backup trust diagnostics.`, ); } - if (status.pendingVerifications > 0) { - nextSteps.add(`Complete ${status.pendingVerifications} pending verification request(s).`); - } return Array.from(nextSteps); } @@ -1119,6 +1207,9 @@ function printVerificationStatus( accountId?: string, ): void { console.log(`Verified by owner: ${status.verified ? "yes" : "no"}`); + if (status.serverDeviceKnown === false) { + console.log("Device issue: current Matrix device is missing from the homeserver device list"); + } const backup = resolveBackupStatus(status); const backupIssue = resolveMatrixRoomKeyBackupIssue(backup); printVerificationBackupSummary(status); @@ -1128,6 +1219,9 @@ function printVerificationStatus( if (verbose) { console.log("Diagnostics:"); printVerificationIdentity(status); + if (status.serverDeviceKnown !== undefined) { + console.log(`Device present on server: ${yesNoUnknown(status.serverDeviceKnown ?? null)}`); + } printVerificationTrustDiagnostics(status); printVerificationBackupStatus(status); console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`); @@ -1649,6 +1743,7 @@ export function registerMatrixCli(params: { program: Command }): void { printAccountLabel(accountId); printVerificationStatus(status, verbose, accountId); }, + shouldFail: (status) => status.serverDeviceKnown === false, errorPrefix: "Error", }); }, @@ -1674,6 +1769,7 @@ export function registerMatrixCli(params: { program: Command }): void { if (verbose) { printBackupStatus(status); } + printBackupGuidance(status, accountId); }, errorPrefix: "Backup status failed", }); @@ -1686,19 +1782,32 @@ export function registerMatrixCli(params: { program: Command }): void { ) .option("--account ", "Account ID (for multi-account setups)") .option("--yes", "Confirm destructive backup reset", false) + .option("--rotate-recovery-key", "Create a new Matrix recovery key for the fresh backup") .option("--verbose", "Show detailed diagnostics") .option("--json", "Output as JSON") .action( - async (options: { account?: string; yes?: boolean; verbose?: boolean; json?: boolean }) => { + async (options: { + account?: string; + yes?: boolean; + rotateRecoveryKey?: boolean; + verbose?: boolean; + json?: boolean; + }) => { const { accountId, cfg } = resolveMatrixCliAccountContext(options.account); await runMatrixCliCommand({ verbose: options.verbose === true, json: options.json === true, run: async () => { if (options.yes !== true) { - throw new Error("Refusing to reset Matrix room-key backup without --yes"); + throw new Error( + `Refusing to reset Matrix room-key backup without --yes. If you accept losing unrecoverable history, re-run ${formatMatrixCliCommand("verify backup reset --yes", accountId)}.`, + ); } - return await resetMatrixRoomKeyBackup({ accountId, cfg }); + return await resetMatrixRoomKeyBackup({ + accountId, + cfg, + rotateRecoveryKey: options.rotateRecoveryKey === true, + }); }, onText: (result, verbose) => { printAccountLabel(accountId); @@ -1720,6 +1829,7 @@ export function registerMatrixCli(params: { program: Command }): void { printTimestamp("Reset at", result.resetAt); printBackupStatus(result.backup); } + printBackupGuidance(result.backup, accountId); }, shouldFail: (result) => !result.success, errorPrefix: "Backup reset failed", @@ -1732,13 +1842,18 @@ export function registerMatrixCli(params: { program: Command }): void { .command("restore") .description("Restore encrypted room keys from server backup") .option("--account ", "Account ID (for multi-account setups)") - .option("--recovery-key ", "Optional recovery key to load before restoring") + .option( + "--recovery-key ", + "Optional recovery key to load before restoring (prefer --recovery-key-stdin)", + ) + .option("--recovery-key-stdin", "Read the Matrix recovery key from stdin") .option("--verbose", "Show detailed diagnostics") .option("--json", "Output as JSON") .action( async (options: { account?: string; recoveryKey?: string; + recoveryKeyStdin?: boolean; verbose?: boolean; json?: boolean; }) => { @@ -1750,7 +1865,7 @@ export function registerMatrixCli(params: { program: Command }): void { await restoreMatrixRoomKeyBackup({ accountId, cfg, - recoveryKey: options.recoveryKey, + recoveryKey: await resolveMatrixCliRecoveryKeyInput(options), }), onText: (result, verbose) => { printAccountLabel(accountId); @@ -1768,6 +1883,9 @@ export function registerMatrixCli(params: { program: Command }): void { printTimestamp("Restored at", result.restoredAt); printBackupStatus(result.backup); } + printBackupGuidance(result.backup, accountId, { + recoveryKeyStored: result.loadedFromSecretStorage, + }); }, shouldFail: (result) => !result.success, errorPrefix: "Backup restore failed", @@ -1780,7 +1898,11 @@ export function registerMatrixCli(params: { program: Command }): void { .command("bootstrap") .description("Bootstrap Matrix cross-signing and device verification state") .option("--account ", "Account ID (for multi-account setups)") - .option("--recovery-key ", "Recovery key to apply before bootstrap") + .option( + "--recovery-key ", + "Recovery key to apply before bootstrap (prefer --recovery-key-stdin)", + ) + .option("--recovery-key-stdin", "Read the Matrix recovery key from stdin") .option("--force-reset-cross-signing", "Force reset cross-signing identity before bootstrap") .option("--verbose", "Show detailed diagnostics") .option("--json", "Output as JSON") @@ -1788,6 +1910,7 @@ export function registerMatrixCli(params: { program: Command }): void { async (options: { account?: string; recoveryKey?: string; + recoveryKeyStdin?: boolean; forceResetCrossSigning?: boolean; verbose?: boolean; json?: boolean; @@ -1800,7 +1923,7 @@ export function registerMatrixCli(params: { program: Command }): void { await bootstrapMatrixVerification({ accountId, cfg, - recoveryKey: options.recoveryKey, + recoveryKey: await resolveMatrixCliRecoveryKeyInput(options), forceResetCrossSigning: options.forceResetCrossSigning === true, }), onText: (result, verbose) => { @@ -1841,18 +1964,34 @@ export function registerMatrixCli(params: { program: Command }): void { ); verify - .command("device ") + .command("device [key]") .description("Verify device using a Matrix recovery key") .option("--account ", "Account ID (for multi-account setups)") + .option("--recovery-key-stdin", "Read the Matrix recovery key from stdin") .option("--verbose", "Show detailed diagnostics") .option("--json", "Output as JSON") .action( - async (key: string, options: { account?: string; verbose?: boolean; json?: boolean }) => { + async ( + key: string | undefined, + options: { + account?: string; + recoveryKeyStdin?: boolean; + verbose?: boolean; + json?: boolean; + }, + ) => { const { accountId, cfg } = resolveMatrixCliAccountContext(options.account); await runMatrixCliCommand({ verbose: options.verbose === true, json: options.json === true, - run: async () => await verifyMatrixRecoveryKey(key, { accountId, cfg }), + run: async () => + await verifyMatrixRecoveryKey( + await requireMatrixCliRecoveryKeyInput({ + recoveryKey: key, + recoveryKeyStdin: options.recoveryKeyStdin, + }), + { accountId, cfg }, + ), onText: (result, verbose) => { printAccountLabel(accountId); if (!result.success) { diff --git a/extensions/matrix/src/group-mentions.test.ts b/extensions/matrix/src/group-mentions.test.ts new file mode 100644 index 00000000000..e6c6fc0799d --- /dev/null +++ b/extensions/matrix/src/group-mentions.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { resolveMatrixGroupToolPolicy } from "./group-mentions.js"; + +describe("Matrix group policy", () => { + it("resolves room tool policy from the case-preserved Matrix room id", () => { + const policy = resolveMatrixGroupToolPolicy({ + accountId: "default", + cfg: { + channels: { + matrix: { + accounts: { + default: { + groups: { + "!RoomABC:example.org": { + tools: { allow: ["sessions_spawn"] }, + }, + }, + }, + }, + }, + }, + }, + groupId: "!roomabc:example.org", + groupChannel: "!RoomABC:example.org", + }); + + expect(policy).toEqual({ allow: ["sessions_spawn"] }); + }); +}); diff --git a/extensions/matrix/src/matrix/actions/verification.test.ts b/extensions/matrix/src/matrix/actions/verification.test.ts index 8665221240f..9e087e2de50 100644 --- a/extensions/matrix/src/matrix/actions/verification.test.ts +++ b/extensions/matrix/src/matrix/actions/verification.test.ts @@ -35,6 +35,7 @@ let listMatrixVerifications: typeof import("./verification.js").listMatrixVerifi let getMatrixEncryptionStatus: typeof import("./verification.js").getMatrixEncryptionStatus; let getMatrixRoomKeyBackupStatus: typeof import("./verification.js").getMatrixRoomKeyBackupStatus; let getMatrixVerificationStatus: typeof import("./verification.js").getMatrixVerificationStatus; +let restoreMatrixRoomKeyBackup: typeof import("./verification.js").restoreMatrixRoomKeyBackup; let runMatrixSelfVerification: typeof import("./verification.js").runMatrixSelfVerification; let startMatrixVerification: typeof import("./verification.js").startMatrixVerification; @@ -45,6 +46,7 @@ describe("matrix verification actions", () => { getMatrixRoomKeyBackupStatus, getMatrixVerificationStatus, listMatrixVerifications, + restoreMatrixRoomKeyBackup, runMatrixSelfVerification, startMatrixVerification, } = await import("./verification.js")); @@ -262,6 +264,24 @@ describe("matrix verification actions", () => { expect(withStartedActionClientMock).not.toHaveBeenCalled(); }); + it("restores room-key backup without startup crypto auto-repair", async () => { + const restoreRoomKeyBackup = vi.fn(async () => ({ + success: true, + imported: 1, + total: 1, + })); + withResolvedActionClientMock.mockImplementation(async (_opts, run) => { + return await run({ restoreRoomKeyBackup }); + }); + + const restored = await restoreMatrixRoomKeyBackup({ recoveryKey: " key " }); + + expect(restored).toMatchObject({ success: true }); + expect(restoreRoomKeyBackup).toHaveBeenCalledWith({ recoveryKey: "key" }); + expect(withResolvedActionClientMock).toHaveBeenCalledTimes(1); + expect(withStartedActionClientMock).not.toHaveBeenCalled(); + }); + it("rehydrates DM verification requests before follow-up actions", async () => { const tracked = { completed: false, diff --git a/extensions/matrix/src/matrix/actions/verification.ts b/extensions/matrix/src/matrix/actions/verification.ts index 3f1dee60622..9f7eb51d929 100644 --- a/extensions/matrix/src/matrix/actions/verification.ts +++ b/extensions/matrix/src/matrix/actions/verification.ts @@ -521,7 +521,7 @@ export async function restoreMatrixRoomKeyBackup( recoveryKey?: string; } = {}, ) { - return await withStartedActionClient( + return await withResolvedActionClient( opts, async (client) => await client.restoreRoomKeyBackup({ @@ -530,8 +530,16 @@ export async function restoreMatrixRoomKeyBackup( ); } -export async function resetMatrixRoomKeyBackup(opts: MatrixActionClientOpts = {}) { - return await withStartedActionClient(opts, async (client) => await client.resetRoomKeyBackup()); +export async function resetMatrixRoomKeyBackup( + opts: MatrixActionClientOpts & { rotateRecoveryKey?: boolean } = {}, +) { + return await withStartedActionClient( + opts, + async (client) => + await client.resetRoomKeyBackup({ + rotateRecoveryKey: opts.rotateRecoveryKey, + }), + ); } export async function bootstrapMatrixVerification( diff --git a/extensions/matrix/src/matrix/client/logging.test.ts b/extensions/matrix/src/matrix/client/logging.test.ts new file mode 100644 index 00000000000..6e827c1ed7f --- /dev/null +++ b/extensions/matrix/src/matrix/client/logging.test.ts @@ -0,0 +1,50 @@ +import { logger as matrixJsSdkRootLogger } from "matrix-js-sdk/lib/logger.js"; +import { describe, expect, it, vi } from "vitest"; +import { ensureMatrixSdkLoggingConfigured, setMatrixSdkLogMode } from "./logging.js"; + +type MatrixJsSdkTestLogger = typeof matrixJsSdkRootLogger & { + getLevel?: () => number | string; + levels: { WARN: number }; + methodFactory?: unknown; + rebuild?: () => void; + setLevel?: (level: number | string, persist?: boolean) => void; +}; + +describe("Matrix SDK logging", () => { + it("restores the Matrix JS SDK global logger level after quiet mode", () => { + const logger = matrixJsSdkRootLogger as MatrixJsSdkTestLogger; + const originalLevel = logger.getLevel?.(); + const originalMethodFactory = logger.methodFactory; + try { + logger.setLevel?.("warn", false); + ensureMatrixSdkLoggingConfigured(); + setMatrixSdkLogMode("quiet"); + setMatrixSdkLogMode("default"); + + expect(logger.getLevel?.()).toBe(logger.levels.WARN); + expect(logger.methodFactory).toBe(originalMethodFactory); + } finally { + if (typeof originalLevel === "string" || typeof originalLevel === "number") { + logger.setLevel?.(originalLevel, false); + } + logger.methodFactory = originalMethodFactory; + logger.rebuild?.(); + setMatrixSdkLogMode("default"); + } + }); + + it("quiets the Matrix JS SDK global logger for JSON-safe CLI commands", () => { + const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => undefined); + try { + ensureMatrixSdkLoggingConfigured(); + setMatrixSdkLogMode("quiet"); + + matrixJsSdkRootLogger.getChild("[MatrixRTCSession test]").debug("noisy diagnostic"); + + expect(debugSpy).not.toHaveBeenCalled(); + } finally { + setMatrixSdkLogMode("default"); + debugSpy.mockRestore(); + } + }); +}); diff --git a/extensions/matrix/src/matrix/client/logging.ts b/extensions/matrix/src/matrix/client/logging.ts index 386ca295eb6..4cd419cbf67 100644 --- a/extensions/matrix/src/matrix/client/logging.ts +++ b/extensions/matrix/src/matrix/client/logging.ts @@ -1,8 +1,10 @@ +import { logger as matrixJsSdkRootLogger } from "matrix-js-sdk/lib/logger.js"; import { ConsoleLogger, LogService, setMatrixConsoleLogging } from "../sdk/logger.js"; let matrixSdkLoggingConfigured = false; let matrixSdkLogMode: "default" | "quiet" = "default"; const matrixSdkBaseLogger = new ConsoleLogger(); +let matrixJsSdkRootLoggerSnapshot: MatrixJsSdkRootLoggerSnapshot | null = null; type MatrixJsSdkLogger = { trace: (...messageOrObject: unknown[]) => void; @@ -13,6 +15,18 @@ type MatrixJsSdkLogger = { getChild: (namespace: string) => MatrixJsSdkLogger; }; +type MatrixJsSdkLoglevelLogger = { + getLevel?: () => number | string; + methodFactory?: unknown; + rebuild?: () => void; + setLevel?: (level: number | string, persist?: boolean) => void; +}; + +type MatrixJsSdkRootLoggerSnapshot = { + level?: number | string; + methodFactory?: unknown; +}; + function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean { if (!module.includes("MatrixHttpClient")) { return false; @@ -50,6 +64,7 @@ export function createMatrixJsSdkClientLogger(prefix = "matrix"): MatrixJsSdkLog function applyMatrixSdkLogger(): void { if (matrixSdkLogMode === "quiet") { + setMatrixJsSdkRootLoggerLevel("silent"); LogService.setLogger({ trace: () => {}, debug: () => {}, @@ -60,6 +75,7 @@ function applyMatrixSdkLogger(): void { return; } + setMatrixJsSdkRootLoggerLevel("debug"); LogService.setLogger({ trace: (module, ...messageOrObject) => matrixSdkBaseLogger.trace(module, ...messageOrObject), debug: (module, ...messageOrObject) => matrixSdkBaseLogger.debug(module, ...messageOrObject), @@ -74,6 +90,26 @@ function applyMatrixSdkLogger(): void { }); } +function setMatrixJsSdkRootLoggerLevel(level: "debug" | "silent"): void { + const logger = matrixJsSdkRootLogger as unknown as MatrixJsSdkLoglevelLogger; + matrixJsSdkRootLoggerSnapshot ??= { + level: logger.getLevel?.(), + methodFactory: logger.methodFactory, + }; + if (level === "silent") { + logger.methodFactory = () => () => undefined; + logger.setLevel?.("silent", false); + logger.rebuild?.(); + return; + } + logger.methodFactory = matrixJsSdkRootLoggerSnapshot.methodFactory; + const previousLevel = matrixJsSdkRootLoggerSnapshot.level; + if (typeof previousLevel === "string" || typeof previousLevel === "number") { + logger.setLevel?.(previousLevel, false); + } + logger.rebuild?.(); +} + function createMatrixJsSdkLoggerInstance(prefix: string): MatrixJsSdkLogger { const log = (method: keyof ConsoleLogger, ...messageOrObject: unknown[]): void => { if (matrixSdkLogMode === "quiet") { diff --git a/extensions/matrix/src/matrix/deps.ts b/extensions/matrix/src/matrix/deps.ts index 9bf3216ae7e..a43aab8df2c 100644 --- a/extensions/matrix/src/matrix/deps.ts +++ b/extensions/matrix/src/matrix/deps.ts @@ -56,6 +56,8 @@ type CommandResult = { stderr: string; }; +let defaultMatrixCryptoRuntimeEnsurePromise: Promise | null = null; + async function runFixedCommandWithTimeout(params: { argv: string[]; cwd: string; @@ -149,6 +151,25 @@ function isMissingMatrixCryptoRuntimeError(error: unknown): boolean { export async function ensureMatrixCryptoRuntime( params: MatrixCryptoRuntimeDeps = {}, ): Promise { + const usesDefaultRuntime = + !params.requireFn && !params.runCommand && !params.resolveFn && !params.nodeExecutable; + if (usesDefaultRuntime && defaultMatrixCryptoRuntimeEnsurePromise) { + await defaultMatrixCryptoRuntimeEnsurePromise; + return; + } + const ensurePromise = ensureMatrixCryptoRuntimeOnce(params); + if (!usesDefaultRuntime) { + await ensurePromise; + return; + } + defaultMatrixCryptoRuntimeEnsurePromise = ensurePromise.catch((error: unknown) => { + defaultMatrixCryptoRuntimeEnsurePromise = null; + throw error; + }); + await defaultMatrixCryptoRuntimeEnsurePromise; +} + +async function ensureMatrixCryptoRuntimeOnce(params: MatrixCryptoRuntimeDeps): Promise { const requireFn = params.requireFn ?? defaultRequireFn; try { requireFn("@matrix-org/matrix-sdk-crypto-nodejs"); diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index 1c6d0ba245f..adde565fd98 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -1260,11 +1260,11 @@ describe("matrix monitor handler pairing account scope", () => { const finalized = vi.mocked(finalizeInboundContext).mock.calls.at(-1)?.[0]; expect(finalized).toEqual( expect.objectContaining({ + GroupChannel: "!room:example.org", GroupSubject: "Ops Room", GroupId: "!room:example.org", }), ); - expect(finalized).not.toHaveProperty("GroupChannel"); }); it("routes bound Matrix threads to the target session key", async () => { diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 867808306cb..175841c4348 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -1298,6 +1298,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""), GroupSubject: isRoom ? (roomName ?? roomId) : undefined, GroupId: isRoom ? roomId : undefined, + GroupChannel: isRoom ? roomId : undefined, GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined, Provider: "matrix" as const, Surface: "matrix" as const, diff --git a/extensions/matrix/src/matrix/monitor/startup.test.ts b/extensions/matrix/src/matrix/monitor/startup.test.ts index 6f97912eefc..493d3139b26 100644 --- a/extensions/matrix/src/matrix/monitor/startup.test.ts +++ b/extensions/matrix/src/matrix/monitor/startup.test.ts @@ -34,6 +34,7 @@ function createVerificationStatus( keyLoadError: null, }, ...overrides, + serverDeviceKnown: overrides.serverDeviceKnown ?? true, }; } diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index fecaa83b828..5afa07b896e 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -143,6 +143,7 @@ type MatrixJsClientStub = { sendStateEvent: ReturnType; redactEvent: ReturnType; getProfileInfo: ReturnType; + getDevices: ReturnType; joinRoom: ReturnType; mxcUrlToHttp: ReturnType; uploadContent: ReturnType; @@ -178,6 +179,9 @@ function createMatrixJsClientStub(): MatrixJsClientStub { client.sendStateEvent = vi.fn(async () => ({ event_id: "$state" })); client.redactEvent = vi.fn(async () => ({ event_id: "$redact" })); client.getProfileInfo = vi.fn(async () => ({})); + client.getDevices = vi.fn(async () => ({ + devices: [{ device_id: "DEVICE123", display_name: "OpenClaw" }], + })); client.joinRoom = vi.fn(async () => ({})); client.mxcUrlToHttp = vi.fn(() => null); client.uploadContent = vi.fn(async () => ({ content_uri: "mxc://example/file" })); @@ -1337,6 +1341,7 @@ describe("MatrixClient crypto bootstrapping", () => { recoveryKeyCreatedAt: null, recoveryKeyId: null, backupVersion: null, + serverDeviceKnown: true, backup: { serverVersion: null, activeVersion: null, @@ -1547,6 +1552,61 @@ describe("MatrixClient crypto bootstrapping", () => { expect(status.verified).toBe(true); expect(status.userId).toBe("@bot:example.org"); expect(status.deviceId).toBe("DEVICE123"); + expect(status.serverDeviceKnown).toBe(true); + }); + + it("reports when the current Matrix device is missing from the homeserver device list", async () => { + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + matrixJsClient.getDevices = vi.fn(async () => ({ + devices: [{ device_id: "OTHERDEVICE" }], + })); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + getDeviceVerificationStatus: vi.fn(async () => null), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", { + encryption: true, + }); + await client.start(); + + const status = await client.getOwnDeviceVerificationStatus(); + expect(status.deviceId).toBe("DEVICE123"); + expect(status.serverDeviceKnown).toBe(false); + }); + + it("keeps verification diagnostics when the homeserver device list cannot be read", async () => { + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + matrixJsClient.getDevices = vi.fn(async () => { + throw new Error("device list unavailable"); + }); + 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.verified).toBe(true); + expect(status.backup).toBeDefined(); + expect(status.serverDeviceKnown).toBeNull(); }); it("does not treat local-only trust as Matrix identity trust", async () => { @@ -2473,6 +2533,60 @@ describe("MatrixClient crypto bootstrapping", () => { expect(checkKeyBackupAndEnable).toHaveBeenCalledTimes(1); }); + it("rotates the recovery key when resetting room-key backup with rotation requested", async () => { + const checkKeyBackupAndEnable = vi.fn(async () => {}); + const bootstrapSecretStorage = vi.fn( + async (opts?: { createSecretStorageKey?: () => Promise }) => { + await opts?.createSecretStorageKey?.(); + }, + ); + const createRecoveryKeyFromPassphrase = vi.fn(async () => ({ + keyId: "ROTATED", + keyInfo: { name: "Rotated recovery key" }, + privateKey: new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)), + encodedPrivateKey: "rotated-key", + })); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapSecretStorage, + checkKeyBackupAndEnable, + createRecoveryKeyFromPassphrase, + getActiveSessionBackupVersion: vi.fn(async () => "21870"), + getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + getSecretStorageStatus: vi.fn(async () => ({ ready: true, defaultKeyId: "OLD" })), + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "21870", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", { + encryption: true, + }); + vi.spyOn(client, "doRequest").mockImplementation(async (method, endpoint) => { + if (method === "GET" && endpoint.includes("/room_keys/version")) { + return { version: "21869" }; + } + return {}; + }); + + const result = await client.resetRoomKeyBackup({ rotateRecoveryKey: true }); + + expect(result.success).toBe(true); + expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1); + expect(bootstrapSecretStorage).toHaveBeenCalledWith( + expect.objectContaining({ + setupNewKeyBackup: true, + setupNewSecretStorage: true, + }), + ); + }); + it("reloads the new backup decryption key after reset when the old cached key mismatches", async () => { const checkKeyBackupAndEnable = vi.fn(async () => {}); const bootstrapSecretStorage = vi.fn(async () => {}); diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index 36e197098a6..dc54714985b 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -83,6 +83,7 @@ export type MatrixOwnDeviceVerificationStatus = { recoveryKeyId: string | null; backupVersion: string | null; backup: MatrixRoomKeyBackupStatus; + serverDeviceKnown: boolean | null; }; export type MatrixDeviceVerificationStatus = { @@ -183,6 +184,10 @@ export type MatrixOwnDeviceInfo = { current: boolean; }; +export type MatrixRoomKeyBackupResetOptions = { + rotateRecoveryKey?: boolean; +}; + export type MatrixOwnDeviceDeleteResult = { currentDeviceId: string | null; deletedDeviceIds: string[]; @@ -1122,8 +1127,14 @@ export class MatrixClient { const recoveryKey = this.recoveryKeyStore.getRecoveryKeySummary(); const userId = this.client.getUserId() ?? this.selfUserId ?? null; const deviceId = this.client.getDeviceId()?.trim() || null; - const backup = await this.getRoomKeyBackupStatus(); - const deviceVerification = await this.getDeviceVerificationStatus(userId, deviceId); + const [backup, deviceVerification, ownDevices] = await Promise.all([ + this.getRoomKeyBackupStatus(), + this.getDeviceVerificationStatus(userId, deviceId), + this.listOwnDevices().catch(() => null), + ]); + const serverDeviceKnown = deviceId + ? (ownDevices?.some((device) => device.deviceId === deviceId) ?? null) + : null; return { ...deviceVerification, @@ -1133,6 +1144,7 @@ export class MatrixClient { recoveryKeyId: recoveryKey?.keyId ?? null, backupVersion: backup.serverVersion, backup, + serverDeviceKnown, }; } @@ -1263,9 +1275,13 @@ export class MatrixClient { !stagedRecoveryKeyConfirmedBySecretStorage && !backupUsableBeforeStagedRecovery && backupUsable; + const storedRecoveryKeyMatches = + this.recoveryKeyStore.getRecoveryKeySummary()?.encodedPrivateKey?.trim() === + trimmedRecoveryKey; const stagedRecoveryKeyValidated = - stagedRecoveryKeyUsed && - (stagedRecoveryKeyConfirmedBySecretStorage || stagedRecoveryKeyUnlockedBackup); + (stagedRecoveryKeyUsed && + (stagedRecoveryKeyConfirmedBySecretStorage || stagedRecoveryKeyUnlockedBackup)) || + (storedRecoveryKeyMatches && backupUsable); const recoveryKeyAccepted = stagedRecoveryKeyValidated && (status.verified || backupUsable); if (!status.verified) { if (backupUsable && stagedRecoveryKeyValidated) { @@ -1405,7 +1421,9 @@ export class MatrixClient { } } - async resetRoomKeyBackup(): Promise { + async resetRoomKeyBackup( + options: MatrixRoomKeyBackupResetOptions = {}, + ): Promise { let previousVersion: string | null = null; let deletedVersion: string | null = null; const fail = async (error: string): Promise => { @@ -1436,7 +1454,8 @@ export class MatrixClient { // focused on durable secret-storage health instead of the broader backup status flow, // and still catches stale SSSS/recovery-key state even when the server backup is gone. const forceNewSecretStorage = - await this.shouldForceSecretStorageRecreationForBackupReset(crypto); + options.rotateRecoveryKey === true || + (await this.shouldForceSecretStorageRecreationForBackupReset(crypto)); try { if (previousVersion) { @@ -1458,6 +1477,7 @@ export class MatrixClient { // Force SSSS recreation when the existing SSSS key is broken (bad MAC), so // the new backup key is written into a fresh SSSS consistent with recovery_key.json. forceNewSecretStorage, + forceNewRecoveryKey: options.rotateRecoveryKey === true, // Also allow recreation if bootstrapSecretStorage itself surfaces a repairable // error (e.g. bad MAC from a different SSSS entry). allowSecretStorageRecreateWithoutRecoveryKey: true, diff --git a/extensions/matrix/src/matrix/sdk/crypto-facade.ts b/extensions/matrix/src/matrix/sdk/crypto-facade.ts index f5bbfefee0d..cf6579cc9b4 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-facade.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-facade.ts @@ -1,3 +1,4 @@ +import { ensureMatrixCryptoRuntime } from "../deps.js"; import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js"; import type { EncryptedFile } from "./types.js"; import type { @@ -69,10 +70,19 @@ let matrixCryptoNodeRuntimePromise: Promise | null = nu async function loadMatrixCryptoNodeRuntime(): Promise { // Keep the native crypto package out of the main CLI startup graph. - matrixCryptoNodeRuntimePromise ??= import("./crypto-node.runtime.js"); + matrixCryptoNodeRuntimePromise ??= import("./crypto-node.runtime.js").catch((error: unknown) => { + matrixCryptoNodeRuntimePromise = null; + throw error; + }); return await matrixCryptoNodeRuntimePromise; } +async function loadMatrixCryptoNodeBindings() { + await ensureMatrixCryptoRuntime(); + const runtime = await loadMatrixCryptoNodeRuntime(); + return runtime.loadMatrixCryptoNodeBindings(); +} + function trackInProgressToDeviceVerifications(deps: { client: MatrixCryptoFacadeClient; verificationManager: MatrixVerificationManager; @@ -133,7 +143,7 @@ export function createMatrixCryptoFacade(deps: { encryptMedia: async ( buffer: Buffer, ): Promise<{ buffer: Buffer; file: Omit }> => { - const { Attachment } = await loadMatrixCryptoNodeRuntime(); + const { Attachment } = await loadMatrixCryptoNodeBindings(); const encrypted = Attachment.encrypt(new Uint8Array(buffer)); const mediaInfoJson = encrypted.mediaEncryptionInfo; if (!mediaInfoJson) { @@ -154,7 +164,7 @@ export function createMatrixCryptoFacade(deps: { file: EncryptedFile, opts?: { maxBytes?: number; readIdleTimeoutMs?: number }, ): Promise => { - const { Attachment, EncryptedAttachment } = await loadMatrixCryptoNodeRuntime(); + const { Attachment, EncryptedAttachment } = await loadMatrixCryptoNodeBindings(); const encrypted = await deps.downloadContent(file.url, opts); const metadata: EncryptedFile = { url: file.url, diff --git a/extensions/matrix/src/matrix/sdk/crypto-node.runtime.test.ts b/extensions/matrix/src/matrix/sdk/crypto-node.runtime.test.ts index 92d175467c8..9371f2b237c 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-node.runtime.test.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-node.runtime.test.ts @@ -21,7 +21,9 @@ describe("crypto-node runtime bundling", () => { expect(bundled).toContain('from "node:module"'); expect(bundled).toContain("createRequire(import.meta.url)"); - expect(bundled).toMatch(/require\d*\("@matrix-org\/matrix-sdk-crypto-nodejs"\)/); + expect(bundled).toMatch( + /function loadMatrixCryptoNodeBindings\(\) \{[\s\S]*require\d*\("@matrix-org\/matrix-sdk-crypto-nodejs"\)/, + ); expect(bundled).not.toContain('from "@matrix-org/matrix-sdk-crypto-nodejs"'); }); }); diff --git a/extensions/matrix/src/matrix/sdk/crypto-node.runtime.ts b/extensions/matrix/src/matrix/sdk/crypto-node.runtime.ts index d99ce692699..3b3703301bb 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-node.runtime.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-node.runtime.ts @@ -3,7 +3,15 @@ import { createRequire } from "node:module"; // Load via createRequire so the CJS package gets __dirname (its index.js // uses __dirname to locate platform-specific native .node bindings). const require = createRequire(import.meta.url); -const { Attachment, EncryptedAttachment } = - require("@matrix-org/matrix-sdk-crypto-nodejs") as typeof import("@matrix-org/matrix-sdk-crypto-nodejs"); +type MatrixCryptoNodePackage = typeof import("@matrix-org/matrix-sdk-crypto-nodejs"); -export { Attachment, EncryptedAttachment }; +export type MatrixCryptoNodeBindings = Pick< + MatrixCryptoNodePackage, + "Attachment" | "EncryptedAttachment" +>; + +export function loadMatrixCryptoNodeBindings(): MatrixCryptoNodeBindings { + const { Attachment, EncryptedAttachment } = + require("@matrix-org/matrix-sdk-crypto-nodejs") as MatrixCryptoNodePackage; + return { Attachment, EncryptedAttachment }; +} diff --git a/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts b/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts index 3a3c850a9cf..d851f1e6ae1 100644 --- a/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts +++ b/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts @@ -382,4 +382,57 @@ describe("MatrixRecoveryKeyStore", () => { expect(persisted.keyId).toBe("OLD"); expect(persisted.encodedPrivateKey).toBe(storedEncoded); }); + + it("generates a fresh recovery key when secret storage is explicitly rotated", async () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + const oldEncoded = encodeRecoveryKey( + new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)), + ); + fs.writeFileSync( + recoveryKeyPath, + JSON.stringify({ + version: 1, + createdAt: "2026-03-12T00:00:00.000Z", + keyId: "OLD", + encodedPrivateKey: oldEncoded, + privateKeyBase64: Buffer.from( + new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)), + ).toString("base64"), + }), + "utf8", + ); + + const freshEncoded = encodeRecoveryKey( + new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 101)), + ) as string; + const bootstrapSecretStorage = createBootstrapSecretStorageMock(); + const createRecoveryKeyFromPassphrase = vi.fn(async () => + createGeneratedRecoveryKey({ + keyId: "NEW", + name: "Fresh key", + bytes: Array.from({ length: 32 }, (_, i) => i + 101), + encodedPrivateKey: freshEncoded, + }), + ); + const crypto = createRecoveryKeyCrypto({ + bootstrapSecretStorage, + createRecoveryKeyFromPassphrase, + status: { ready: true, defaultKeyId: "OLD" }, + }); + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + + await store.bootstrapSecretStorageWithRecoveryKey(crypto, { + forceNewRecoveryKey: true, + forceNewSecretStorage: true, + }); + + const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as { + keyId?: string; + encodedPrivateKey?: string; + }; + expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1); + expect(persisted.keyId).toBe("NEW"); + expect(persisted.encodedPrivateKey).toBe(freshEncoded); + expect(persisted.encodedPrivateKey).not.toBe(oldEncoded); + }); }); diff --git a/extensions/matrix/src/matrix/sdk/recovery-key-store.ts b/extensions/matrix/src/matrix/sdk/recovery-key-store.ts index 0e045e10785..a147014df71 100644 --- a/extensions/matrix/src/matrix/sdk/recovery-key-store.ts +++ b/extensions/matrix/src/matrix/sdk/recovery-key-store.ts @@ -228,6 +228,7 @@ export class MatrixRecoveryKeyStore { setupNewKeyBackup?: boolean; allowSecretStorageRecreateWithoutRecoveryKey?: boolean; forceNewSecretStorage?: boolean; + forceNewRecoveryKey?: boolean; } = {}, ): Promise { let status: MatrixSecretStorageStatus | null = null; @@ -247,7 +248,8 @@ export class MatrixRecoveryKeyStore { let generatedRecoveryKey = false; const storedRecovery = this.loadStoredRecoveryKey(); const stagedRecovery = this.stagedRecoveryKey; - const sourceRecovery = stagedRecovery ?? storedRecovery; + const sourceRecovery = + options.forceNewRecoveryKey === true ? null : (stagedRecovery ?? storedRecovery); let recoveryKey: MatrixGeneratedSecretStorageKey | null = sourceRecovery ? { keyInfo: sourceRecovery.keyInfo, diff --git a/extensions/matrix/src/matrix/subagent-hooks.test.ts b/extensions/matrix/src/matrix/subagent-hooks.test.ts index ea17f39811f..98d7b8fc85a 100644 --- a/extensions/matrix/src/matrix/subagent-hooks.test.ts +++ b/extensions/matrix/src/matrix/subagent-hooks.test.ts @@ -9,6 +9,7 @@ import { registerMatrixSubagentHooks } from "../../subagent-hooks-api.js"; // Hoisted stubs referenced in vi.mock factories below const bindMock = vi.hoisted(() => vi.fn()); const unbindMock = vi.hoisted(() => vi.fn()); +const getCapabilitiesMock = vi.hoisted(() => vi.fn()); const getManagerMock = vi.hoisted(() => vi.fn()); const listAllBindingsMock = vi.hoisted(() => vi.fn((): any[] => [])); const listBindingsForAccountMock = vi.hoisted(() => vi.fn((): any[] => [])); @@ -17,7 +18,11 @@ const resolveMatrixBaseConfigMock = vi.hoisted(() => vi.fn((): any => ({}))); const findMatrixAccountConfigMock = vi.hoisted(() => vi.fn((): any => undefined)); vi.mock("openclaw/plugin-sdk/conversation-binding-runtime", () => ({ - getSessionBindingService: () => ({ bind: bindMock, unbind: unbindMock }), + getSessionBindingService: () => ({ + bind: bindMock, + getCapabilities: getCapabilitiesMock, + unbind: unbindMock, + }), })); vi.mock("./account-config.js", () => ({ @@ -81,6 +86,7 @@ function makeSpawnEvent( describe("handleMatrixSubagentSpawning", () => { beforeEach(() => { bindMock.mockReset(); + getCapabilitiesMock.mockReset(); getManagerMock.mockReset(); resolveMatrixBaseConfigMock.mockReset(); findMatrixAccountConfigMock.mockReset(); @@ -89,7 +95,12 @@ describe("handleMatrixSubagentSpawning", () => { threadBindings: { enabled: true, spawnSubagentSessions: true }, }); findMatrixAccountConfigMock.mockReturnValue(undefined); - // Default: manager exists + getCapabilitiesMock.mockReturnValue({ + adapterAvailable: true, + bindSupported: true, + placements: ["current", "child"], + unbindSupported: true, + }); getManagerMock.mockReturnValue({ persist: vi.fn() }); // Default: bind resolves ok bindMock.mockResolvedValue({ @@ -188,15 +199,21 @@ describe("handleMatrixSubagentSpawning", () => { ); }); - it("returns error when no binding manager is available for the account", async () => { - getManagerMock.mockReturnValue(null); + it("returns error when no binding adapter is available for the account", async () => { + getCapabilitiesMock.mockReturnValue({ + adapterAvailable: false, + bindSupported: false, + placements: [], + unbindSupported: false, + }); const result = await handleMatrixSubagentSpawning(fakeApi, makeSpawnEvent()); expect(result).toEqual( expect.objectContaining({ status: "error", - error: expect.stringContaining("No Matrix thread binding manager"), + error: expect.stringContaining("No Matrix session binding adapter"), }), ); + expect(bindMock).not.toHaveBeenCalled(); }); it("calls bind with the resolved room id and returns ok", async () => { @@ -255,7 +272,10 @@ describe("handleMatrixSubagentSpawning", () => { }, }); await handleMatrixSubagentSpawning(fakeApi, makeSpawnEvent({ accountId: undefined as never })); - expect(getManagerMock).toHaveBeenCalledWith("default"); + expect(getCapabilitiesMock).toHaveBeenCalledWith({ + channel: "matrix", + accountId: "default", + }); expect(bindMock).toHaveBeenCalledWith( expect.objectContaining({ conversation: expect.objectContaining({ accountId: "default" }), @@ -295,6 +315,7 @@ describe("handleMatrixSubagentSpawning", () => { describe("matrix subagent hook registration", () => { beforeEach(() => { bindMock.mockReset(); + getCapabilitiesMock.mockReset(); getManagerMock.mockReset(); resolveMatrixBaseConfigMock.mockReset(); findMatrixAccountConfigMock.mockReset(); @@ -304,6 +325,12 @@ describe("matrix subagent hook registration", () => { threadBindings: { enabled: true, spawnSubagentSessions: true }, }); findMatrixAccountConfigMock.mockReturnValue(undefined); + getCapabilitiesMock.mockReturnValue({ + adapterAvailable: true, + bindSupported: true, + placements: ["current", "child"], + unbindSupported: true, + }); getManagerMock.mockReturnValue({ persist: vi.fn() }); bindMock.mockResolvedValue({ conversation: { @@ -752,6 +779,7 @@ describe("concurrent spawns across accounts", () => { beforeEach(() => { bindMock.mockReset(); + getCapabilitiesMock.mockReset(); getManagerMock.mockReset(); resolveMatrixBaseConfigMock.mockReset(); findMatrixAccountConfigMock.mockReset(); @@ -759,6 +787,12 @@ describe("concurrent spawns across accounts", () => { threadBindings: { enabled: true, spawnSubagentSessions: true }, }); findMatrixAccountConfigMock.mockReturnValue(undefined); + getCapabilitiesMock.mockReturnValue({ + adapterAvailable: true, + bindSupported: true, + placements: ["current", "child"], + unbindSupported: true, + }); getManagerMock.mockReturnValue({ persist: vi.fn() }); }); diff --git a/extensions/matrix/src/matrix/subagent-hooks.ts b/extensions/matrix/src/matrix/subagent-hooks.ts index 4f31620ee32..22eecc9cc68 100644 --- a/extensions/matrix/src/matrix/subagent-hooks.ts +++ b/extensions/matrix/src/matrix/subagent-hooks.ts @@ -167,14 +167,18 @@ export async function handleMatrixSubagentSpawning( }; } - // Verify the thread binding manager is running for this account. The manager - // holds the captured Matrix client the SessionBindingAdapter needs to send - // the intro message that bootstraps the thread. - const manager = getMatrixThreadBindingManager(accountId); - if (!manager) { + const bindingService = getSessionBindingService(); + const capabilities = bindingService.getCapabilities({ channel: "matrix", accountId }); + if (!capabilities.adapterAvailable || !capabilities.bindSupported) { return { status: "error", - error: `No Matrix thread binding manager available for account "${accountId}". Is the Matrix channel running?`, + error: `No Matrix session binding adapter available for account "${accountId}". Is the Matrix channel running?`, + }; + } + if (!capabilities.placements.includes("child")) { + return { + status: "error", + error: `Matrix session binding adapter for account "${accountId}" does not support child thread bindings.`, }; } @@ -186,7 +190,7 @@ export async function handleMatrixSubagentSpawning( // // We do NOT call setBindingRecord here — the adapter's bind() handles // record creation, thread creation, and persistence atomically. - const binding = await getSessionBindingService().bind({ + const binding = await bindingService.bind({ targetSessionKey: event.childSessionKey, targetKind: "subagent", conversation: { 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 90566f7dc31..e6f1d529c8c 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.test.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.test.ts @@ -73,7 +73,7 @@ const THREAD_SUBAGENT_TOOL_ERROR = "thread=true requested but thread delivery is unavailable in this test harness."; function threadSubagentTask(token: string) { - return `Reply exactly \`${token}\`. This is the marker.`; + return `Finish with exactly ${token}.`; } function explicitSessionsSpawnPrompt(token: string) { @@ -707,7 +707,7 @@ describe("qa mock openai server", () => { }); }); - it("surfaces sessions_spawn tool errors instead of echoing child-task markers", async () => { + it("surfaces sessions_spawn tool errors instead of echoing child-task tokens", async () => { const server = await startMockServer(); const body = await expectResponsesJson<{ @@ -743,6 +743,61 @@ describe("qa mock openai server", () => { expect(text).not.toContain(THREAD_SUBAGENT_CHILD_ERROR_TOKEN); }); + it("does not echo child-task tokens after sessions_spawn accepts the request", async () => { + const server = await startMockServer(); + const childToken = "QA_SUBAGENT_CHILD_ACCEPTED"; + + const body = await expectResponsesJson<{ + output?: Array<{ content?: Array<{ text?: string }> }>; + }>(server, { + stream: false, + tools: [SESSIONS_SPAWN_TOOL], + input: [ + makeUserInput(explicitSessionsSpawnPrompt(childToken)), + { + type: "function_call", + name: "sessions_spawn", + arguments: JSON.stringify({ + task: threadSubagentTask(childToken), + label: "qa-thread-subagent", + thread: true, + mode: "session", + runTimeoutSeconds: 30, + }), + }, + { + type: "function_call_output", + output: JSON.stringify({ + status: "accepted", + threadRootEventId: "$thread-root", + }), + }, + ], + }); + + const text = body.output?.[0]?.content?.[0]?.text ?? ""; + expect(text).toContain("Protocol note"); + expect(text).not.toContain(childToken); + }); + + it("lets child subagent prompts finish with an exact token", async () => { + const server = await startMockServer(); + const childToken = "QA_SUBAGENT_CHILD_DIRECT"; + + await expect( + expectResponsesJson<{ output?: Array<{ content?: Array<{ text?: string }> }> }>(server, { + stream: false, + input: [makeUserInput(threadSubagentTask(childToken))], + }), + ).resolves.toMatchObject({ + output: [ + { + content: [{ text: childToken }], + }, + ], + }); + }); + it("plans memory tools and serves mock image generations", async () => { const server = await startQaMockOpenAiServer({ host: "127.0.0.1", @@ -1445,6 +1500,96 @@ describe("qa mock openai server", () => { ]); }); + it("recognizes OpenAI-compatible image_url parts as image inputs", 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, + model: "mock-openai/gpt-5.4", + input: [ + { + role: "user", + content: [ + { type: "input_text", text: "Image understanding check: what do you see?" }, + { + type: "image_url", + image_url: { + url: `data:image/png;base64,${QA_IMAGE_PNG_BASE64}`, + }, + }, + ], + }, + ], + }), + }); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + output?: Array<{ content?: Array<{ text?: string }> }>; + }; + const text = payload.output?.[0]?.content?.[0]?.text ?? ""; + expect(text.toLowerCase()).toContain("red"); + expect(text.toLowerCase()).toContain("blue"); + + const debug = await fetch(`${server.baseUrl}/debug/last-request`); + expect(debug.status).toBe(200); + expect(await debug.json()).toMatchObject({ + imageInputCount: 1, + }); + }); + + it("handles deeply nested image input shapes without recursive traversal failure", async () => { + const server = await startQaMockOpenAiServer({ + host: "127.0.0.1", + port: 0, + }); + cleanups.push(async () => { + await server.stop(); + }); + + let content: unknown = { + type: "input_image", + source: { + type: "base64", + mime_type: "image/png", + data: QA_IMAGE_PNG_BASE64, + }, + }; + for (let index = 0; index < 4_000; index += 1) { + content = [{ type: "input_text", text: "nested" }, content]; + } + + const response = await fetch(`${server.baseUrl}/v1/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + stream: false, + model: "mock-openai/gpt-5.4", + input: [ + { + role: "user", + content, + }, + ], + }), + }); + expect(response.status).toBe(200); + + const debug = await fetch(`${server.baseUrl}/debug/last-request`); + expect(debug.status).toBe(200); + expect(await debug.json()).toMatchObject({ + imageInputCount: 1, + }); + }); + it("describes reattached generated images in the roundtrip flow", 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 2224f8177c5..233f99dba2f 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.ts @@ -338,21 +338,33 @@ function extractAllRequestTexts(input: ResponsesInputItem[], body: Record(); + const stack = [value]; let count = 0; - for (const item of input) { - if (!Array.isArray(item.content)) { + let visited = 0; + while (stack.length > 0 && visited < 50_000) { + visited += 1; + const current = stack.pop(); + if (Array.isArray(current)) { + for (const entry of current) { + stack.push(entry); + } continue; } - for (const entry of item.content) { - if ( - entry && - typeof entry === "object" && - (entry as { type?: unknown }).type === "input_image" - ) { - count += 1; - } + if (!current || typeof current !== "object") { + continue; } + if (seen.has(current)) { + continue; + } + seen.add(current); + const record = current as Record; + const type = typeof record.type === "string" ? record.type : ""; + if (type === "input_image" || type === "image" || type === "image_url" || type === "media") { + count += 1; + } + stack.push(record.content, record.image_url, record.source); } return count; } @@ -522,6 +534,14 @@ function extractExactReplyDirective(text: string) { return extractLastCapture(text, /reply(?: with)? exactly:\s*([^\n]+)/i); } +function extractFinishExactlyDirective(text: string) { + const backtickedMatch = extractLastCapture(text, /finish with exactly\s+`([^`]+)`/i); + if (backtickedMatch) { + return backtickedMatch; + } + return extractLastCapture(text, /finish with exactly\s+([^\s`.,;:!?]+)/i); +} + function extractExactMarkerDirective(text: string) { const backtickedMatch = extractLastCapture(text, /exact marker:\s*`([^`]+)`/i); if (backtickedMatch) { @@ -648,6 +668,8 @@ 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 imageInputCount = countImageInputs(input); @@ -811,6 +833,9 @@ function buildAssistantText( const snippet = toolOutput.replace(/\s+/g, " ").trim().slice(0, 220); return `Protocol note: I reviewed the requested material. Evidence snippet: ${snippet || "no content"}`; } + if (finishExactlyDirective) { + return finishExactlyDirective; + } if (prompt) { return `Protocol note: acknowledged. Continue with the QA scenario plan and report worked, failed, and blocked items.`; } diff --git a/extensions/qa-matrix/src/runners/contract/runtime.ts b/extensions/qa-matrix/src/runners/contract/runtime.ts index 9f820c39f48..eaf5ad806a1 100644 --- a/extensions/qa-matrix/src/runners/contract/runtime.ts +++ b/extensions/qa-matrix/src/runners/contract/runtime.ts @@ -751,6 +751,7 @@ export async function runMatrixQaLive(params: { observerDeviceId: provisioning.observer.deviceId, observerPassword: provisioning.observer.password, observerUserId: provisioning.observer.userId, + gatewayRuntimeEnv: scenarioGateway.harness.gateway.runtimeEnv, gatewayStateDir: scenarioGateway.harness.gateway.runtimeEnv?.OPENCLAW_STATE_DIR, outputDir, restartGateway: async () => { diff --git a/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts b/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts index e5b0be77060..616fac0fd7a 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts @@ -60,6 +60,17 @@ export type MatrixQaScenarioId = | "matrix-e2ee-recovery-key-lifecycle" | "matrix-e2ee-recovery-owner-verification-required" | "matrix-e2ee-cli-self-verification" + | "matrix-e2ee-state-loss-external-recovery-key" + | "matrix-e2ee-state-loss-stored-recovery-key" + | "matrix-e2ee-state-loss-no-recovery-key" + | "matrix-e2ee-stale-recovery-key-after-backup-reset" + | "matrix-e2ee-server-backup-deleted-local-state-intact" + | "matrix-e2ee-server-backup-deleted-local-reupload-restores" + | "matrix-e2ee-corrupt-crypto-idb-snapshot" + | "matrix-e2ee-server-device-deleted-local-state-intact" + | "matrix-e2ee-sync-state-loss-crypto-intact" + | "matrix-e2ee-wrong-account-recovery-key" + | "matrix-e2ee-history-exists-backup-empty" | "matrix-e2ee-device-sas-verification" | "matrix-e2ee-qr-verification" | "matrix-e2ee-stale-device-hygiene" @@ -258,7 +269,7 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [ groupsByKey: { [MATRIX_QA_MAIN_ROOM_KEY]: { tools: { - allow: ["sessions_spawn"], + allow: ["sessions_spawn", "sessions_yield"], }, }, }, @@ -589,6 +600,106 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [ }), configOverrides: MATRIX_QA_E2EE_CONFIG, }, + { + id: "matrix-e2ee-state-loss-external-recovery-key", + timeoutMs: 180_000, + title: "Matrix E2EE total state loss restores backup with an external recovery key", + topology: buildMatrixQaE2eeScenarioTopology({ + scenarioId: "matrix-e2ee-state-loss-external-recovery-key", + name: "Matrix QA E2EE State Loss External Key Room", + }), + configOverrides: MATRIX_QA_E2EE_CONFIG, + }, + { + id: "matrix-e2ee-state-loss-stored-recovery-key", + timeoutMs: 180_000, + title: "Matrix E2EE crypto state loss restores backup from a surviving recovery key", + topology: buildMatrixQaE2eeScenarioTopology({ + scenarioId: "matrix-e2ee-state-loss-stored-recovery-key", + name: "Matrix QA E2EE State Loss Stored Key Room", + }), + configOverrides: MATRIX_QA_E2EE_CONFIG, + }, + { + id: "matrix-e2ee-state-loss-no-recovery-key", + timeoutMs: 120_000, + title: "Matrix E2EE total state loss without a recovery key fails closed", + topology: buildMatrixQaE2eeScenarioTopology({ + scenarioId: "matrix-e2ee-state-loss-no-recovery-key", + name: "Matrix QA E2EE State Loss No Key Room", + }), + configOverrides: MATRIX_QA_E2EE_CONFIG, + }, + { + id: "matrix-e2ee-stale-recovery-key-after-backup-reset", + timeoutMs: 180_000, + title: "Matrix E2EE stale recovery key is rejected after server backup reset", + topology: buildMatrixQaE2eeScenarioTopology({ + scenarioId: "matrix-e2ee-stale-recovery-key-after-backup-reset", + name: "Matrix QA E2EE Stale Recovery Key Room", + }), + configOverrides: MATRIX_QA_E2EE_CONFIG, + }, + { + id: "matrix-e2ee-server-backup-deleted-local-state-intact", + timeoutMs: 120_000, + title: "Matrix E2EE local crypto survives server backup deletion", + topology: buildMatrixQaE2eeScenarioTopology({ + scenarioId: "matrix-e2ee-server-backup-deleted-local-state-intact", + name: "Matrix QA E2EE Server Backup Deleted Room", + }), + configOverrides: MATRIX_QA_E2EE_CONFIG, + }, + { + id: "matrix-e2ee-server-backup-deleted-local-reupload-restores", + timeoutMs: 180_000, + title: "Matrix E2EE local keys re-upload after server backup deletion", + topology: buildMatrixQaE2eeScenarioTopology({ + scenarioId: "matrix-e2ee-server-backup-deleted-local-reupload-restores", + name: "Matrix QA E2EE Server Backup Reupload Room", + }), + configOverrides: MATRIX_QA_E2EE_CONFIG, + }, + { + id: "matrix-e2ee-corrupt-crypto-idb-snapshot", + timeoutMs: 180_000, + title: "Matrix E2EE corrupt crypto snapshot repairs through backup restore", + topology: buildMatrixQaE2eeScenarioTopology({ + scenarioId: "matrix-e2ee-corrupt-crypto-idb-snapshot", + name: "Matrix QA E2EE Corrupt IDB Snapshot Room", + }), + configOverrides: MATRIX_QA_E2EE_CONFIG, + }, + { + id: "matrix-e2ee-server-device-deleted-local-state-intact", + timeoutMs: 120_000, + title: "Matrix E2EE server-side device deletion invalidates surviving local state", + topology: buildMatrixQaE2eeScenarioTopology({ + scenarioId: "matrix-e2ee-server-device-deleted-local-state-intact", + name: "Matrix QA E2EE Server Device Deleted Room", + }), + configOverrides: MATRIX_QA_E2EE_CONFIG, + }, + { + id: "matrix-e2ee-sync-state-loss-crypto-intact", + timeoutMs: MATRIX_QA_E2EE_REPLY_TIMEOUT_MS, + title: "Matrix E2EE sync cursor loss keeps crypto decryptability intact", + topology: buildMatrixQaE2eeScenarioTopology({ + scenarioId: "matrix-e2ee-sync-state-loss-crypto-intact", + name: "Matrix QA E2EE Sync State Loss Room", + }), + configOverrides: MATRIX_QA_E2EE_CONFIG, + }, + { + id: "matrix-e2ee-history-exists-backup-empty", + timeoutMs: 180_000, + title: "Matrix E2EE backup reset preserves encrypted history via local key re-upload", + topology: buildMatrixQaE2eeScenarioTopology({ + scenarioId: "matrix-e2ee-history-exists-backup-empty", + name: "Matrix QA E2EE Empty Backup Room", + }), + configOverrides: MATRIX_QA_E2EE_CONFIG, + }, { id: "matrix-e2ee-device-sas-verification", timeoutMs: 90_000, @@ -676,6 +787,16 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [ }), configOverrides: MATRIX_QA_E2EE_CONFIG, }, + { + id: "matrix-e2ee-wrong-account-recovery-key", + timeoutMs: 180_000, + title: "Matrix E2EE rejects a recovery key from a different account", + topology: buildMatrixQaE2eeScenarioTopology({ + scenarioId: "matrix-e2ee-wrong-account-recovery-key", + name: "Matrix QA E2EE Wrong Account Key Room", + }), + configOverrides: MATRIX_QA_E2EE_CONFIG, + }, ]; export const MATRIX_QA_STANDARD_SCENARIO_IDS = collectLiveTransportStandardScenarioCoverage({ 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 ae104b6eab8..524301d940d 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 @@ -1,11 +1,13 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; import path from "node:path"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { describe, expect, it } from "vitest"; import { formatMatrixQaCliCommand, redactMatrixQaCliOutput, resolveMatrixQaOpenClawCliEntryPath, + runMatrixQaOpenClawCli, + startMatrixQaOpenClawCli, } from "./scenario-runtime-cli.js"; describe("Matrix QA CLI runtime", () => { @@ -23,6 +25,9 @@ describe("Matrix QA CLI runtime", () => { expect(formatMatrixQaCliCommand(["matrix", "account", "add", "--access-token=token-123"])).toBe( "openclaw matrix account add --access-token=[REDACTED]", ); + expect( + formatMatrixQaCliCommand(["matrix", "verify", "device", "abcdef1234567890ghij", "--json"]), + ).toBe("openclaw matrix verify device [REDACTED] --json"); }); it("redacts Matrix token output before diagnostics and artifacts", () => { @@ -32,7 +37,7 @@ describe("Matrix QA CLI runtime", () => { }); it("prefers the ESM OpenClaw CLI entrypoint when present", async () => { - const root = await mkdtemp(path.join(tmpdir(), "matrix-qa-cli-entry-")); + const root = await mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-entry-")); try { await mkdir(path.join(root, "dist")); await writeFile(path.join(root, "dist", "index.mjs"), ""); @@ -41,4 +46,98 @@ describe("Matrix QA CLI runtime", () => { await rm(root, { force: true, recursive: true }); } }); + + it("can preserve expected non-zero CLI output for negative scenarios", async () => { + const root = await mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-nonzero-"), + ); + try { + await mkdir(path.join(root, "dist")); + await writeFile( + path.join(root, "dist", "index.mjs"), + [ + "process.stdout.write(JSON.stringify({ success: false, error: 'expected failure' }));", + "process.exit(7);", + ].join("\n"), + ); + const result = await runMatrixQaOpenClawCli({ + allowNonZero: true, + args: ["matrix", "verify", "backup", "restore", "--json"], + cwd: root, + env: process.env, + timeoutMs: 5_000, + }); + expect(result.exitCode).toBe(7); + expect(result.stdout).toContain('"success":false'); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + it("can pass stdin to CLI commands", async () => { + const root = await mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-stdin-")); + try { + await mkdir(path.join(root, "dist")); + await writeFile( + path.join(root, "dist", "index.mjs"), + [ + "let input = '';", + "process.stdin.setEncoding('utf8');", + "process.stdin.on('data', (chunk) => { input += chunk; });", + "process.stdin.on('end', () => {", + " process.stdout.write(JSON.stringify({ input: input.trim() }));", + "});", + ].join("\n"), + ); + const result = await runMatrixQaOpenClawCli({ + args: ["matrix", "verify", "backup", "restore", "--recovery-key-stdin", "--json"], + cwd: root, + env: process.env, + stdin: "stdin-recovery-key\n", + timeoutMs: 5_000, + }); + expect(result.stdout).toContain('"input":"stdin-recovery-key"'); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + it("can close stdin after interactive CLI prompts", async () => { + const root = await mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-interactive-"), + ); + try { + await mkdir(path.join(root, "dist")); + await writeFile( + path.join(root, "dist", "index.mjs"), + [ + "let input = '';", + "process.stdin.setEncoding('utf8');", + "process.stdin.on('data', (chunk) => { input += chunk; process.stdout.write('prompt answered\\n'); });", + "process.stdin.on('end', () => {", + " process.stdout.write(JSON.stringify({ input: input.trim(), ended: true }));", + "});", + ].join("\n"), + ); + const session = startMatrixQaOpenClawCli({ + args: ["matrix", "verify", "self"], + cwd: root, + env: process.env, + timeoutMs: 5_000, + }); + await session.writeStdin("yes\n"); + await session.waitForOutput( + (output) => output.text.includes("prompt answered"), + "interactive prompt acknowledgement", + 5_000, + ); + session.endStdin(); + const result = await session.wait(); + + expect(result.stdout).toContain('"input":"yes"'); + expect(result.stdout).toContain('"ended":true'); + } 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 d6eb7edc902..1b01b6a8356 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts @@ -1,9 +1,12 @@ import { spawn as startOpenClawCliProcess } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; +import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; export type MatrixQaCliRunResult = { args: string[]; @@ -14,6 +17,7 @@ export type MatrixQaCliRunResult = { export type MatrixQaCliSession = { args: string[]; + endStdin: () => void; output: () => { stderr: string; stdout: string }; wait: () => Promise; waitForOutput: ( @@ -27,6 +31,10 @@ export type MatrixQaCliSession = { const MATRIX_QA_CLI_SECRET_ARG_FLAGS = new Set(["--access-token", "--password", "--recovery-key"]); +function isMatrixQaCliSecretPositionalArg(args: string[], index: number): boolean { + return args[0] === "matrix" && args[1] === "verify" && args[2] === "device" && index === 3; +} + function redactMatrixQaCliArgs(args: string[]): string[] { return args.map((arg, index) => { const [flag] = arg.split("=", 1); @@ -37,6 +45,9 @@ function redactMatrixQaCliArgs(args: string[]): string[] { if (previous && MATRIX_QA_CLI_SECRET_ARG_FLAGS.has(previous)) { return "[REDACTED]"; } + if (isMatrixQaCliSecretPositionalArg(args, index)) { + return "[REDACTED]"; + } return arg; }); } @@ -81,9 +92,11 @@ function formatMatrixQaCliExitError(result: MatrixQaCliRunResult) { } export function startMatrixQaOpenClawCli(params: { + allowNonZero?: boolean; args: string[]; cwd?: string; env: NodeJS.ProcessEnv; + stdin?: string; timeoutMs: number; }): MatrixQaCliSession { const cwd = params.cwd ?? process.cwd(); @@ -139,6 +152,9 @@ export function startMatrixQaOpenClawCli(params: { child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk))); child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk))); + if (params.stdin !== undefined) { + child.stdin.end(params.stdin); + } child.on("error", (error) => { clearTimeout(timeout); finish( @@ -157,7 +173,7 @@ export function startMatrixQaOpenClawCli(params: { exitCode: exitCode ?? 1, output: readOutput(), }); - if (result.exitCode !== 0) { + if (result.exitCode !== 0 && params.allowNonZero !== true) { finish(result, new Error(formatMatrixQaCliExitError(result))); return; } @@ -166,11 +182,16 @@ export function startMatrixQaOpenClawCli(params: { return { args: params.args, + endStdin: () => { + if (!child.stdin.destroyed) { + child.stdin.end(); + } + }, output: readOutput, wait: async () => await new Promise((resolve, reject) => { if (closed && closeResult) { - if (closeResult.exitCode === 0) { + if (closeResult.exitCode === 0 || params.allowNonZero === true) { resolve(closeResult); } else { reject(new Error(formatMatrixQaCliExitError(closeResult))); @@ -215,10 +236,124 @@ export function startMatrixQaOpenClawCli(params: { } export async function runMatrixQaOpenClawCli(params: { + allowNonZero?: boolean; args: string[]; cwd?: string; env: NodeJS.ProcessEnv; + stdin?: string; timeoutMs: number; }): Promise { return await startMatrixQaOpenClawCli(params).wait(); } + +async function assertMatrixQaPrivatePathMode(pathToCheck: string, label: string) { + if (process.platform === "win32") { + return; + } + const mode = (await stat(pathToCheck)).mode & 0o777; + if ((mode & 0o077) !== 0) { + throw new Error(`${label} permissions are too broad: ${mode.toString(8)}`); + } +} + +export async function createMatrixQaOpenClawCliRuntime(params: { + accountId: string; + accessToken: string; + artifactLabel: string; + baseUrl: string; + deviceId: string; + displayName: string; + outputDir: string; + runtimeEnv: NodeJS.ProcessEnv; + userId: string; +}) { + const rootDir = await mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-matrix-cli-qa-"), + ); + const artifactDir = path.join( + params.outputDir, + params.artifactLabel.replace(/[^A-Za-z0-9_-]/g, "-"), + 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( + { + plugins: { + allow: ["matrix"], + entries: { + matrix: { enabled: true }, + }, + }, + channels: { + matrix: { + defaultAccount: params.accountId, + accounts: { + [params.accountId]: { + accessToken: params.accessToken, + deviceId: params.deviceId, + encryption: true, + homeserver: params.baseUrl, + initialSyncLimit: 0, + name: params.displayName, + network: { + dangerouslyAllowPrivateNetwork: true, + }, + startupVerification: "off", + userId: params.userId, + }, + }, + }, + }, + }, + null, + 2, + )}\n`, + { flag: "wx", mode: 0o600 }, + ); + await assertMatrixQaPrivatePathMode(configPath, "Matrix QA CLI config file"); + const env = { + ...params.runtimeEnv, + FORCE_COLOR: "0", + NO_COLOR: "1", + OPENCLAW_CONFIG_PATH: configPath, + OPENCLAW_DISABLE_AUTO_UPDATE: "1", + OPENCLAW_STATE_DIR: stateDir, + }; + return { + artifactDir, + configPath, + dispose: async () => { + await rm(rootDir, { force: true, recursive: true }); + }, + run: async ( + args: string[], + opts: { allowNonZero?: boolean; stdin?: string; timeoutMs: number }, + ): Promise => + await runMatrixQaOpenClawCli({ + allowNonZero: opts.allowNonZero, + args, + env, + stdin: opts.stdin, + timeoutMs: opts.timeoutMs, + }), + start: (args: string[], opts: { allowNonZero?: boolean; timeoutMs: number }) => + startMatrixQaOpenClawCli({ + allowNonZero: opts.allowNonZero, + args, + env, + timeoutMs: opts.timeoutMs, + }), + stateDir, + }; +} 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 new file mode 100644 index 00000000000..e5507abc82d --- /dev/null +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-e2ee-destructive.ts @@ -0,0 +1,1548 @@ +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, + type MatrixQaE2eeScenarioClient, +} from "../../substrate/e2ee-client.js"; +import { requestMatrixJson } from "../../substrate/request.js"; +import { + buildMatrixQaE2eeScenarioRoomKey, + type MatrixQaE2eeScenarioId, + resolveMatrixQaScenarioRoomId, +} from "./scenario-catalog.js"; +import { + createMatrixQaOpenClawCliRuntime, + formatMatrixQaCliCommand, + redactMatrixQaCliOutput, + type MatrixQaCliRunResult, +} from "./scenario-runtime-cli.js"; +import { + assertTopLevelReplyArtifact, + buildMentionPrompt, + buildMatrixReplyArtifact, + buildMatrixReplyDetails, + buildMatrixQaToken, + createMatrixQaDriverScenarioClient, + isMatrixQaExactMarkerReply, + type MatrixQaScenarioContext, +} from "./scenario-runtime-shared.js"; +import { waitForMatrixSyncStoreWithCursor } from "./scenario-runtime-state-files.js"; +import type { MatrixQaScenarioExecution } from "./scenario-types.js"; + +type MatrixQaCliRuntime = Awaited>; + +type MatrixQaCliBackupStatus = { + backup?: { + decryptionKeyCached?: boolean | null; + keyLoadError?: string | null; + matchesDecryptionKey?: boolean | null; + trusted?: boolean | null; + }; + backupVersion?: string | null; + error?: string; + imported?: number; + loadedFromSecretStorage?: boolean; + success?: boolean; + total?: number; +}; + +type MatrixQaCliVerificationStatus = { + backup?: MatrixQaCliBackupStatus["backup"]; + crossSigningVerified?: boolean; + deviceId?: string | null; + serverDeviceKnown?: boolean | null; + error?: string; + recoveryKeyAccepted?: boolean; + backupUsable?: boolean; + deviceOwnerVerified?: boolean; + recoveryKeyStored?: boolean; + signedByOwner?: boolean; + success?: boolean; + userId?: string | null; + verified?: boolean; +}; + +type MatrixQaDestructiveSetup = { + encodedRecoveryKey: string; + owner: MatrixQaE2eeScenarioClient; + recoveryKeyId: string | null; + roomId: string; + roomKey: string; + seededEventId: string; +}; + +function requireMatrixQaE2eeOutputDir(context: MatrixQaScenarioContext) { + if (!context.outputDir) { + throw new Error("Matrix E2EE destructive QA scenarios require an output directory"); + } + return context.outputDir; +} + +function requireMatrixQaCliRuntimeEnv(context: MatrixQaScenarioContext) { + if (!context.gatewayRuntimeEnv) { + throw new Error( + "Matrix E2EE destructive CLI scenarios require the gateway runtime environment", + ); + } + return context.gatewayRuntimeEnv; +} + +function requireMatrixQaPassword(context: MatrixQaScenarioContext, actor: "driver" | "observer") { + const password = actor === "driver" ? context.driverPassword : context.observerPassword; + if (!password) { + throw new Error(`Matrix E2EE destructive ${actor} password is required`); + } + return password; +} + +function resolveMatrixQaE2eeScenarioGroupRoom( + context: MatrixQaScenarioContext, + scenarioId: MatrixQaE2eeScenarioId, +) { + const roomKey = buildMatrixQaE2eeScenarioRoomKey(scenarioId); + return { + roomKey, + roomId: resolveMatrixQaScenarioRoomId(context, roomKey), + }; +} + +async function createMatrixQaDriverPersistentClient( + context: MatrixQaScenarioContext, + scenarioId: MatrixQaE2eeScenarioId, +) { + return await createMatrixQaE2eeScenarioClient({ + accessToken: context.driverAccessToken, + actorId: "driver", + baseUrl: context.baseUrl, + deviceId: context.driverDeviceId, + observedEvents: context.observedEvents, + outputDir: requireMatrixQaE2eeOutputDir(context), + password: context.driverPassword, + scenarioId, + timeoutMs: context.timeoutMs, + userId: context.driverUserId, + }); +} + +async function ensureMatrixQaOwnerReady(params: { + allowCrossSigningResetOnRepair?: boolean; + client: MatrixQaE2eeScenarioClient; + label: string; +}) { + let bootstrap = await params.client.bootstrapOwnDeviceVerification({ + allowAutomaticCrossSigningReset: false, + }); + if (!bootstrap.success && isMatrixQaRepairableBackupBootstrapError(bootstrap.error)) { + const reset = await params.client.resetRoomKeyBackup(); + if (reset.success) { + bootstrap = await params.client.bootstrapOwnDeviceVerification({ + allowAutomaticCrossSigningReset: false, + }); + } + } + if ( + !bootstrap.success && + params.allowCrossSigningResetOnRepair === true && + isMatrixQaRepairableBackupBootstrapError(bootstrap.error) + ) { + bootstrap = await params.client.bootstrapOwnDeviceVerification({ + forceResetCrossSigning: true, + }); + } + if ( + !bootstrap.success || + !bootstrap.verification.verified || + !bootstrap.verification.crossSigningVerified || + !bootstrap.verification.backupVersion + ) { + throw new Error( + `${params.label} Matrix E2EE bootstrap did not leave identity trust and backup ready: ${ + bootstrap.error ?? "unknown error" + }`, + ); + } + const recoveryKey = await params.client.getRecoveryKey(); + const encodedRecoveryKey = recoveryKey?.encodedPrivateKey?.trim(); + if (!encodedRecoveryKey) { + throw new Error(`${params.label} Matrix E2EE bootstrap did not expose a recovery key`); + } + return { + backupVersion: bootstrap.verification.backupVersion, + encodedRecoveryKey, + recoveryKeyId: recoveryKey?.keyId ?? null, + }; +} + +function isMatrixQaRepairableBackupBootstrapError(error: string | undefined) { + const normalized = error?.toLowerCase() ?? ""; + return ( + normalized.includes("room key backup is not usable") || + normalized.includes("room key backup is missing") || + normalized.includes("no current key backup") || + normalized.includes("m.megolm_backup.v1") || + normalized.includes("backup decryption key could not be loaded") || + normalized.includes("bad mac") + ); +} + +async function prepareMatrixQaDestructiveSetup( + context: MatrixQaScenarioContext, + scenarioId: MatrixQaE2eeScenarioId, +): Promise { + const owner = await createMatrixQaDriverPersistentClient(context, scenarioId); + try { + const ready = await ensureMatrixQaOwnerReady({ client: owner, label: "driver" }); + const { roomId, roomKey } = resolveMatrixQaE2eeScenarioGroupRoom(context, scenarioId); + const seededEventId = await owner.sendTextMessage({ + body: `E2EE destructive restore seed ${randomUUID().slice(0, 8)}`, + roomId, + }); + return { + encodedRecoveryKey: ready.encodedRecoveryKey, + owner, + recoveryKeyId: ready.recoveryKeyId, + roomId, + roomKey, + seededEventId, + }; + } catch (error) { + await owner.stop().catch(() => undefined); + throw error; + } +} + +async function createMatrixQaRecoveryCliRuntime(params: { + accountId: string; + accessToken: string; + context: MatrixQaScenarioContext; + deviceId: string; + label: string; + userId: string; +}) { + return await createMatrixQaOpenClawCliRuntime({ + accountId: params.accountId, + accessToken: params.accessToken, + artifactLabel: params.label, + baseUrl: params.context.baseUrl, + deviceId: params.deviceId, + displayName: `Matrix QA ${params.label}`, + outputDir: requireMatrixQaE2eeOutputDir(params.context), + runtimeEnv: requireMatrixQaCliRuntimeEnv(params.context), + userId: params.userId, + }); +} + +async function loginMatrixQaRecoveryDevice(params: { + context: MatrixQaScenarioContext; + deviceName: string; + userId: string; + password: string; +}): Promise<{ + accessToken: string; + deviceId: string; + password?: string; + userId: string; +}> { + const loginClient = createMatrixQaClient({ baseUrl: params.context.baseUrl }); + const device = await loginClient.loginWithPassword({ + deviceName: params.deviceName, + password: params.password, + userId: params.userId, + }); + if (!device.deviceId) { + throw new Error(`Matrix destructive recovery login did not return a device id`); + } + return { + ...device, + deviceId: device.deviceId, + }; +} + +function parseMatrixQaCliJson(result: MatrixQaCliRunResult): unknown { + const stdout = result.stdout.trim(); + const stderr = result.stderr.trim(); + const payload = stdout || stderr; + if (!payload) { + throw new Error(`${formatMatrixQaCliCommand(result.args)} did not print JSON`); + } + try { + return JSON.parse(payload) as unknown; + } catch (error) { + throw new Error( + `${formatMatrixQaCliCommand(result.args)} printed invalid JSON: ${ + error instanceof Error ? error.message : String(error) + }\n${redactMatrixQaCliOutput(payload)}`, + { cause: error }, + ); + } +} + +async function writeMatrixQaCliArtifacts(params: { + label: string; + result: MatrixQaCliRunResult; + runtime: MatrixQaCliRuntime; +}) { + await mkdir(params.runtime.artifactDir, { mode: 0o700, recursive: true }); + const safe = params.label.replace(/[^A-Za-z0-9_-]/g, "-"); + const stdoutPath = path.join(params.runtime.artifactDir, `${safe}.stdout.txt`); + const stderrPath = path.join(params.runtime.artifactDir, `${safe}.stderr.txt`); + await Promise.all([ + writeFile(stdoutPath, redactMatrixQaCliOutput(params.result.stdout), { mode: 0o600 }), + writeFile(stderrPath, redactMatrixQaCliOutput(params.result.stderr), { mode: 0o600 }), + ]); + return { stderrPath, stdoutPath }; +} + +async function runMatrixQaCliJson(params: { + allowNonZero?: boolean; + args: string[]; + decode?: (payload: unknown) => T; + label: string; + runtime: MatrixQaCliRuntime; + stdin?: string; + timeoutMs: number; +}) { + const result = await params.runtime.run(params.args, { + allowNonZero: params.allowNonZero, + stdin: params.stdin, + timeoutMs: params.timeoutMs, + }); + const artifacts = await writeMatrixQaCliArtifacts({ + label: params.label, + result, + runtime: params.runtime, + }); + const parsed = parseMatrixQaCliJson(result); + return { + artifacts, + payload: params.decode ? params.decode(parsed) : (parsed as T), + result, + }; +} + +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"}`); + } + if (restore.backup?.keyLoadError) { + throw new Error( + `${label} backup restore left a backup key error: ${restore.backup.keyLoadError}`, + ); + } + if (restore.backup?.matchesDecryptionKey !== true) { + throw new Error(`${label} backup restore did not load the matching backup key`); + } +} + +function assertMatrixQaCliBackupRestoreFailed( + restore: MatrixQaCliBackupStatus | MatrixQaCliVerificationStatus, + label: string, +) { + if (restore.success === true) { + throw new Error(`${label} unexpectedly succeeded`); + } + if (!restore.error) { + throw new Error(`${label} failed without an actionable diagnostic`); + } +} + +async function findFilesByName(params: { filename: string; rootDir: string }): Promise { + const matches: string[] = []; + async function visit(dir: string, depth: number): Promise { + if (depth > 10) { + return; + } + let entries: Array<{ + isDirectory(): boolean; + isFile(): boolean; + name: string; + }>; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isFile() && entry.name === params.filename) { + matches.push(entryPath); + } else if (entry.isDirectory()) { + await visit(entryPath, depth + 1); + } + } + } + await visit(params.rootDir, 0); + return matches.toSorted(); +} + +async function findMatrixQaCliAccountRoot(params: { + deviceId: string; + runtime: MatrixQaCliRuntime; + userId: string; +}) { + const metadataPaths = await findFilesByName({ + filename: "storage-meta.json", + rootDir: params.runtime.stateDir, + }); + for (const metadataPath of metadataPaths) { + try { + const metadata = JSON.parse(await readFile(metadataPath, "utf8")) as { + deviceId?: unknown; + userId?: unknown; + }; + if (metadata.userId === params.userId && metadata.deviceId === params.deviceId) { + return path.dirname(metadataPath); + } + } catch { + continue; + } + } + throw new Error(`Matrix CLI account storage root was not created for ${params.userId}`); +} + +async function mutateMatrixQaCliStateLoss(params: { + deviceId: string; + preserveRecoveryKey: boolean; + runtime: MatrixQaCliRuntime; + userId: string; +}) { + const accountRoot = await findMatrixQaCliAccountRoot(params); + const recoveryKeyPath = path.join(accountRoot, "recovery-key.json"); + const preservedRecoveryKeyPath = path.join( + params.runtime.stateDir, + "preserved-recovery-key.json", + ); + let recoveryKeyPreserved = false; + if (params.preserveRecoveryKey) { + await copyFile(recoveryKeyPath, preservedRecoveryKeyPath); + await chmod(preservedRecoveryKeyPath, 0o600).catch(() => undefined); + recoveryKeyPreserved = true; + } + await rm(accountRoot, { force: true, recursive: true }); + if (params.preserveRecoveryKey) { + await mkdir(accountRoot, { recursive: true }); + await copyFile(preservedRecoveryKeyPath, recoveryKeyPath); + } + return { + accountRoot, + recoveryKeyPreserved, + }; +} + +async function corruptMatrixQaCliIdbSnapshot(params: { + deviceId: string; + runtime: MatrixQaCliRuntime; + userId: string; +}) { + const accountRoot = await findMatrixQaCliAccountRoot(params); + const idbSnapshotPath = path.join(accountRoot, "crypto-idb-snapshot.json"); + await stat(idbSnapshotPath); + await writeFile(idbSnapshotPath, "{ this is not valid indexeddb json\n", "utf8"); + return idbSnapshotPath; +} + +async function deleteMatrixQaServerRoomKeyBackup(params: { + accessToken: string; + baseUrl: string; + version: string; +}) { + const response = await requestMatrixJson>({ + accessToken: params.accessToken, + baseUrl: params.baseUrl, + endpoint: `/_matrix/client/v3/room_keys/version/${encodeURIComponent(params.version)}`, + fetchImpl: fetch, + method: "DELETE", + okStatuses: [200, 404], + }); + return response.status; +} + +async function runMatrixQaExternalKeyRestore(params: { + accountId: string; + context: MatrixQaScenarioContext; + deviceName: string; + label: string; + password: string; + userId: string; +}) { + const device = await loginMatrixQaRecoveryDevice({ + context: params.context, + deviceName: params.deviceName, + password: params.password, + userId: params.userId, + }); + const cli = await createMatrixQaRecoveryCliRuntime({ + accountId: params.accountId, + accessToken: device.accessToken, + context: params.context, + deviceId: device.deviceId, + label: params.label, + userId: device.userId, + }); + return { cli, device }; +} + +export async function runMatrixQaE2eeStateLossExternalRecoveryKeyScenario( + context: MatrixQaScenarioContext, +): Promise { + const driverPassword = requireMatrixQaPassword(context, "driver"); + const setup = await prepareMatrixQaDestructiveSetup( + context, + "matrix-e2ee-state-loss-external-recovery-key", + ); + const { cli, device } = await runMatrixQaExternalKeyRestore({ + accountId: "external-key", + context, + deviceName: "OpenClaw Matrix QA External Key Restore", + label: "state-loss-external-recovery-key", + password: driverPassword, + userId: context.driverUserId, + }); + try { + const restored = await runMatrixQaCliJson({ + args: [ + "matrix", + "verify", + "backup", + "restore", + "--account", + "external-key", + "--recovery-key-stdin", + "--json", + ], + label: "restore-with-external-key", + runtime: cli, + stdin: `${setup.encodedRecoveryKey}\n`, + 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", + 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"); + const recoveryKeyCompletedIdentity = + verification.payload.success === true && + verification.payload.recoveryKeyAccepted === true && + verification.payload.deviceOwnerVerified === true && + verification.payload.crossSigningVerified === true; + if (!backupKeyLoaded || (!ownerVerificationRequired && !recoveryKeyCompletedIdentity)) { + 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", + ); + } + return { + artifacts: { + ...(selfVerification + ? { completedVerificationId: selfVerification.completedOwner.id } + : {}), + recoveryDeviceId: device.deviceId, + recoveryKeyId: setup.recoveryKeyId, + restoreImported: restored.payload.imported, + restoreTotal: restored.payload.total, + selfVerificationTransactionId: selfVerification?.transactionId ?? null, + seededEventId: setup.seededEventId, + verificationExitCode: verification.result.exitCode, + }, + details: [ + "deleted Matrix state simulated with a fresh OpenClaw CLI state root", + `encrypted room id: ${setup.roomId}`, + `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"}`, + `device owner verified before self-verification: ${ + verification.payload.deviceOwnerVerified ? "yes" : "no" + }`, + `device owner verified after recovery flow: ${finalStatus.payload.verified ? "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}`, + ].join("\n"), + }; + } finally { + await cli.dispose().catch(() => undefined); + await setup.owner.deleteOwnDevices([device.deviceId]).catch(() => undefined); + await setup.owner.stop().catch(() => undefined); + } +} + +export async function runMatrixQaE2eeStateLossStoredRecoveryKeyScenario( + context: MatrixQaScenarioContext, +): Promise { + const driverPassword = requireMatrixQaPassword(context, "driver"); + const setup = await prepareMatrixQaDestructiveSetup( + context, + "matrix-e2ee-state-loss-stored-recovery-key", + ); + const { cli, device } = await runMatrixQaExternalKeyRestore({ + accountId: "stored-key", + context, + deviceName: "OpenClaw Matrix QA Stored Key Restore", + label: "state-loss-stored-recovery-key", + password: driverPassword, + userId: context.driverUserId, + }); + try { + const initial = await runMatrixQaCliJson({ + args: [ + "matrix", + "verify", + "backup", + "restore", + "--account", + "stored-key", + "--recovery-key-stdin", + "--json", + ], + label: "initial-restore-stores-key", + runtime: cli, + stdin: `${setup.encodedRecoveryKey}\n`, + timeoutMs: context.timeoutMs, + }); + assertMatrixQaCliBackupRestoreSucceeded(initial.payload, "initial stored-key"); + const mutation = await mutateMatrixQaCliStateLoss({ + deviceId: device.deviceId, + preserveRecoveryKey: true, + runtime: cli, + userId: device.userId, + }); + const restored = await runMatrixQaCliJson({ + args: ["matrix", "verify", "backup", "restore", "--account", "stored-key", "--json"], + label: "restore-from-stored-key", + runtime: cli, + timeoutMs: context.timeoutMs, + }); + assertMatrixQaCliBackupRestoreSucceeded(restored.payload, "stored recovery-key"); + const status = await runMatrixQaCliJson({ + args: ["matrix", "verify", "status", "--account", "stored-key", "--json"], + label: "status-after-stored-key-restore", + runtime: cli, + timeoutMs: context.timeoutMs, + }); + if (status.payload.recoveryKeyStored !== true) { + throw new Error("stored recovery-key restore did not keep recovery-key.json usable on disk"); + } + return { + artifacts: { + accountRoot: mutation.accountRoot, + recoveryDeviceId: device.deviceId, + recoveryKeyPreserved: mutation.recoveryKeyPreserved, + restoreImported: restored.payload.imported, + restoreTotal: restored.payload.total, + seededEventId: setup.seededEventId, + }, + details: [ + "Matrix crypto/runtime state was deleted while recovery-key.json survived", + `account root: ${mutation.accountRoot}`, + `restore imported/total: ${restored.payload.imported ?? 0}/${restored.payload.total ?? 0}`, + "restore command supplied recovery key: no", + `recovery key stored after restore: ${status.payload.recoveryKeyStored ? "yes" : "no"}`, + `restore stdout: ${restored.artifacts.stdoutPath}`, + ].join("\n"), + }; + } finally { + await cli.dispose().catch(() => undefined); + await setup.owner.deleteOwnDevices([device.deviceId]).catch(() => undefined); + await setup.owner.stop().catch(() => undefined); + } +} + +export async function runMatrixQaE2eeStateLossNoRecoveryKeyScenario( + context: MatrixQaScenarioContext, +): Promise { + const driverPassword = requireMatrixQaPassword(context, "driver"); + const setup = await prepareMatrixQaDestructiveSetup( + context, + "matrix-e2ee-state-loss-no-recovery-key", + ); + const { cli, device } = await runMatrixQaExternalKeyRestore({ + accountId: "no-key", + context, + deviceName: "OpenClaw Matrix QA No Key Restore", + label: "state-loss-no-recovery-key", + password: driverPassword, + userId: context.driverUserId, + }); + try { + const restored = await runMatrixQaCliJson({ + allowNonZero: true, + args: ["matrix", "verify", "backup", "restore", "--account", "no-key", "--json"], + label: "restore-without-key", + runtime: cli, + timeoutMs: context.timeoutMs, + }); + assertMatrixQaCliBackupRestoreFailed(restored.payload, "no recovery-key restore"); + return { + artifacts: { + recoveryDeviceId: device.deviceId, + restoreError: restored.payload.error, + restoreExitCode: restored.result.exitCode, + seededEventId: setup.seededEventId, + }, + details: [ + "deleted Matrix state with no recovery key failed closed", + `restore exit code: ${restored.result.exitCode}`, + `restore error: ${restored.payload.error}`, + `restore stdout: ${restored.artifacts.stdoutPath}`, + ].join("\n"), + }; + } finally { + await cli.dispose().catch(() => undefined); + await setup.owner.deleteOwnDevices([device.deviceId]).catch(() => undefined); + await setup.owner.stop().catch(() => undefined); + } +} + +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", + ); + const rotated = await setup.owner.resetRoomKeyBackup({ rotateRecoveryKey: true }); + if (!rotated.success) { + await setup.owner.stop().catch(() => undefined); + throw new Error( + `Matrix recovery-key rotation failed before stale-key check: ${rotated.error ?? "unknown"}`, + ); + } + const freshKey = await setup.owner.getRecoveryKey(); + const freshEncodedKey = freshKey?.encodedPrivateKey?.trim(); + if (!freshEncodedKey || freshEncodedKey === setup.encodedRecoveryKey) { + await setup.owner.stop().catch(() => undefined); + throw new Error("Matrix backup reset did not rotate the recovery key for stale-key coverage"); + } + const { cli, device } = await runMatrixQaExternalKeyRestore({ + accountId: "stale-key", + context, + deviceName: "OpenClaw Matrix QA Stale Key Restore", + label: "stale-recovery-key-after-backup-reset", + password: driverPassword, + userId: context.driverUserId, + }); + try { + const restored = await runMatrixQaCliJson({ + allowNonZero: true, + args: [ + "matrix", + "verify", + "backup", + "restore", + "--account", + "stale-key", + "--recovery-key-stdin", + "--json", + ], + label: "restore-with-stale-key", + runtime: cli, + stdin: `${setup.encodedRecoveryKey}\n`, + timeoutMs: context.timeoutMs, + }); + assertMatrixQaCliBackupRestoreFailed(restored.payload, "stale recovery-key restore"); + return { + artifacts: { + backupCreatedVersion: rotated.createdVersion, + backupPreviousVersion: rotated.previousVersion, + recoveryDeviceId: device.deviceId, + rotatedRecoveryKeyId: freshKey?.keyId ?? null, + restoreError: restored.payload.error, + restoreExitCode: restored.result.exitCode, + }, + details: [ + "old recovery key was rejected after cross-signing and backup reset", + `previous backup version: ${rotated.previousVersion ?? ""}`, + `current backup version: ${rotated.createdVersion ?? ""}`, + `restore error: ${restored.payload.error}`, + ].join("\n"), + }; + } finally { + await cli.dispose().catch(() => undefined); + await setup.owner.deleteOwnDevices([device.deviceId]).catch(() => undefined); + await setup.owner.stop().catch(() => undefined); + } +} + +export async function runMatrixQaE2eeServerBackupDeletedLocalStateIntactScenario( + context: MatrixQaScenarioContext, +): Promise { + const setup = await prepareMatrixQaDestructiveSetup( + context, + "matrix-e2ee-server-backup-deleted-local-state-intact", + ); + try { + const before = await setup.owner.restoreRoomKeyBackup({ + recoveryKey: setup.encodedRecoveryKey, + }); + if (!before.success || !before.backupVersion) { + throw new Error(`Matrix backup preflight restore failed: ${before.error ?? "unknown"}`); + } + const deleteStatus = await deleteMatrixQaServerRoomKeyBackup({ + accessToken: context.driverAccessToken, + baseUrl: context.baseUrl, + version: before.backupVersion, + }); + const after = await setup.owner.restoreRoomKeyBackup({ + recoveryKey: setup.encodedRecoveryKey, + }); + if (after.success) { + throw new Error("restore unexpectedly succeeded after server room-key backup deletion"); + } + const localEventId = await setup.owner.sendTextMessage({ + body: `E2EE local crypto still sends after backup deletion ${randomUUID().slice(0, 8)}`, + roomId: setup.roomId, + }); + return { + artifacts: { + backupDeletedHttpStatus: deleteStatus, + deletedBackupVersion: before.backupVersion, + localEventId, + restoreErrorAfterDelete: after.error, + seededEventId: setup.seededEventId, + }, + details: [ + "server room-key backup was deleted while local crypto state stayed intact", + `deleted backup version: ${before.backupVersion}`, + `delete HTTP status: ${deleteStatus}`, + `restore after delete error: ${after.error}`, + `local encrypted send after delete: ${localEventId}`, + ].join("\n"), + }; + } finally { + await setup.owner.resetRoomKeyBackup().catch(() => undefined); + await setup.owner.stop().catch(() => undefined); + } +} + +async function waitForMatrixQaNonEmptyCliBackupRestore(params: { + accountId: string; + cli: MatrixQaCliRuntime; + label: string; + recoveryKey: string; + timeoutMs: number; +}) { + const startedAt = Date.now(); + let last: Awaited>> | null = null; + while (Date.now() - startedAt < params.timeoutMs) { + const remainingMs = params.timeoutMs - (Date.now() - startedAt); + const restored = await runMatrixQaCliJson({ + args: [ + "matrix", + "verify", + "backup", + "restore", + "--account", + params.accountId, + "--recovery-key-stdin", + "--json", + ], + label: params.label, + runtime: params.cli, + stdin: `${params.recoveryKey}\n`, + timeoutMs: Math.max(1, remainingMs), + }); + last = restored; + assertMatrixQaCliBackupRestoreSucceeded(restored.payload, params.label); + if ((restored.payload.total ?? 0) > 0 && (restored.payload.imported ?? 0) > 0) { + return restored; + } + await sleep(500); + } + throw new Error( + `Matrix E2EE CLI restore did not import uploaded room keys before timeout (last imported/total: ${last?.payload.imported ?? 0}/${last?.payload.total ?? 0})`, + ); +} + +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({ + accountId: "backup-reupload", + context, + deviceName: "OpenClaw Matrix QA Backup Reupload Restore", + label: "server-backup-deleted-local-reupload-restores", + password: driverPassword, + userId: context.driverUserId, + }); + try { + const before = await setup.owner.restoreRoomKeyBackup({ + recoveryKey: setup.encodedRecoveryKey, + }); + if (!before.success || !before.backupVersion) { + throw new Error( + `Matrix backup reupload preflight restore failed: ${before.error ?? "unknown"}`, + ); + } + const deleteStatus = await deleteMatrixQaServerRoomKeyBackup({ + accessToken: context.driverAccessToken, + baseUrl: context.baseUrl, + version: before.backupVersion, + }); + const afterDelete = await setup.owner.restoreRoomKeyBackup({ + recoveryKey: setup.encodedRecoveryKey, + }); + if (afterDelete.success) { + throw new Error("restore unexpectedly succeeded after server room-key backup deletion"); + } + const reset = await setup.owner.resetRoomKeyBackup(); + if (!reset.success || !reset.createdVersion) { + throw new Error( + `Matrix backup reset after server deletion failed: ${reset.error ?? "unknown"}`, + ); + } + const restored = await waitForMatrixQaNonEmptyCliBackupRestore({ + accountId: "backup-reupload", + cli, + label: "restore-after-server-backup-reupload", + recoveryKey: setup.encodedRecoveryKey, + timeoutMs: context.timeoutMs, + }); + return { + artifacts: { + backupCreatedVersion: reset.createdVersion, + backupDeletedHttpStatus: deleteStatus, + deletedBackupVersion: before.backupVersion, + recoveryDeviceId: device.deviceId, + restoreErrorAfterDelete: afterDelete.error, + restoreImported: restored.payload.imported, + restoreTotal: restored.payload.total, + seededEventId: setup.seededEventId, + }, + details: [ + "server room-key backup was deleted, then recreated from intact local crypto state", + `deleted backup version: ${before.backupVersion}`, + `delete HTTP status: ${deleteStatus}`, + `fresh backup version: ${reset.createdVersion}`, + `restore after delete error: ${afterDelete.error}`, + `fresh device restored imported/total: ${restored.payload.imported ?? 0}/${restored.payload.total ?? 0}`, + ].join("\n"), + }; + } finally { + await cli.dispose().catch(() => undefined); + await setup.owner.deleteOwnDevices([device.deviceId]).catch(() => undefined); + await setup.owner.stop().catch(() => undefined); + } +} + +export async function runMatrixQaE2eeCorruptCryptoIdbSnapshotScenario( + context: MatrixQaScenarioContext, +): Promise { + const driverPassword = requireMatrixQaPassword(context, "driver"); + const setup = await prepareMatrixQaDestructiveSetup( + context, + "matrix-e2ee-corrupt-crypto-idb-snapshot", + ); + const { cli, device } = await runMatrixQaExternalKeyRestore({ + accountId: "corrupt-idb", + context, + deviceName: "OpenClaw Matrix QA Corrupt IDB Restore", + label: "corrupt-crypto-idb-snapshot", + password: driverPassword, + userId: context.driverUserId, + }); + try { + const initial = await runMatrixQaCliJson({ + args: [ + "matrix", + "verify", + "backup", + "restore", + "--account", + "corrupt-idb", + "--recovery-key-stdin", + "--json", + ], + label: "initial-restore-before-corruption", + runtime: cli, + stdin: `${setup.encodedRecoveryKey}\n`, + timeoutMs: context.timeoutMs, + }); + assertMatrixQaCliBackupRestoreSucceeded(initial.payload, "corrupt-idb initial restore"); + const corruptedPath = await corruptMatrixQaCliIdbSnapshot({ + deviceId: device.deviceId, + runtime: cli, + userId: device.userId, + }); + const repaired = await runMatrixQaCliJson({ + args: [ + "matrix", + "verify", + "backup", + "restore", + "--account", + "corrupt-idb", + "--recovery-key-stdin", + "--json", + ], + label: "restore-after-idb-corruption", + runtime: cli, + stdin: `${setup.encodedRecoveryKey}\n`, + timeoutMs: context.timeoutMs, + }); + assertMatrixQaCliBackupRestoreSucceeded(repaired.payload, "corrupt-idb recovery"); + return { + artifacts: { + corruptedPath, + recoveryDeviceId: device.deviceId, + restoreImported: repaired.payload.imported, + restoreTotal: repaired.payload.total, + }, + details: [ + "corrupted crypto-idb-snapshot.json was repaired by explicit backup restore", + `corrupted path: ${corruptedPath}`, + `restore imported/total: ${repaired.payload.imported ?? 0}/${repaired.payload.total ?? 0}`, + ].join("\n"), + }; + } finally { + await cli.dispose().catch(() => undefined); + await setup.owner.deleteOwnDevices([device.deviceId]).catch(() => undefined); + await setup.owner.stop().catch(() => undefined); + } +} + +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", + ); + const { cli, device } = await runMatrixQaExternalKeyRestore({ + accountId: "deleted-device", + context, + deviceName: "OpenClaw Matrix QA Deleted Device", + label: "server-device-deleted-local-state-intact", + password: driverPassword, + userId: context.driverUserId, + }); + try { + const restored = await runMatrixQaCliJson({ + args: [ + "matrix", + "verify", + "backup", + "restore", + "--account", + "deleted-device", + "--recovery-key-stdin", + "--json", + ], + label: "restore-before-device-delete", + runtime: cli, + stdin: `${setup.encodedRecoveryKey}\n`, + timeoutMs: context.timeoutMs, + }); + assertMatrixQaCliBackupRestoreSucceeded(restored.payload, "deleted-device preflight"); + await setup.owner.deleteOwnDevices([device.deviceId]); + const ownerDevicesAfterDelete = await setup.owner.listOwnDevices(); + const status = await runMatrixQaCliJson({ + allowNonZero: true, + args: ["matrix", "verify", "status", "--account", "deleted-device", "--json"], + label: "status-after-device-delete", + 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) { + throw new Error("deleted device status did not report homeserver device invalidation"); + } + return { + artifacts: { + deletedDeviceId: device.deviceId, + serverDeviceKnown: status.payload.serverDeviceKnown ?? null, + statusError: status.payload.error, + statusExitCode: status.result.exitCode, + }, + details: [ + "server-side device deletion invalidated the surviving local credentials", + `deleted device: ${device.deviceId}`, + `status exit code: ${status.result.exitCode}`, + authInvalidated + ? `status error: ${status.payload.error}` + : `device present on server: ${deviceMissing ? "no" : "yes"}`, + ].join("\n"), + }; + } finally { + await cli.dispose().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( + context, + "matrix-e2ee-sync-state-loss-crypto-intact", + ); + const rawDriver = createMatrixQaDriverScenarioClient(context); + try { + 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], + roomId, + }); + const decrypted = await driver.waitForRoomEvent({ + predicate: (event) => + isMatrixQaExactMarkerReply(event, { + roomId, + sutUserId: context.sutUserId, + token, + }), + roomId, + timeoutMs: context.timeoutMs, + }); + const reply = buildMatrixReplyArtifact(decrypted.event, token); + assertTopLevelReplyArtifact("sync-state loss E2EE reply", reply); + const encrypted = await rawDriver.waitForRoomEvent({ + observedEvents: context.observedEvents, + predicate: (event) => + event.roomId === roomId && + event.sender === context.sutUserId && + event.type === "m.room.encrypted", + roomId, + since: rawStartSince, + timeoutMs: context.timeoutMs, + }); + return { + artifacts: { + deletedSyncStorePath: syncStore.pathname, + driverEventId, + reply, + replyEventId: reply.eventId, + roomKey, + }, + details: [ + "gateway sync cursor was deleted while Matrix crypto state stayed intact", + `deleted sync store: ${syncStore.pathname}`, + `driver event: ${driverEventId}`, + `driver E2EE cursor: ${driverStartSince}`, + `encrypted SUT reply event: ${encrypted.event.eventId}`, + ...buildMatrixReplyDetails("decrypted SUT reply", reply), + ].join("\n"), + }; + } finally { + await driver.stop().catch(() => undefined); + } +} + +export async function runMatrixQaE2eeWrongAccountRecoveryKeyScenario( + context: MatrixQaScenarioContext, +): Promise { + const observerPassword = requireMatrixQaPassword(context, "observer"); + const driverSetup = await prepareMatrixQaDestructiveSetup( + context, + "matrix-e2ee-wrong-account-recovery-key", + ); + const observer = await createMatrixQaE2eeScenarioClient({ + accessToken: context.observerAccessToken, + actorId: `driver-destructive-${randomUUID().slice(0, 8)}`, + baseUrl: context.baseUrl, + deviceId: context.observerDeviceId, + observedEvents: context.observedEvents, + outputDir: requireMatrixQaE2eeOutputDir(context), + password: context.observerPassword, + scenarioId: "matrix-e2ee-wrong-account-recovery-key", + timeoutMs: context.timeoutMs, + userId: context.observerUserId, + }); + try { + await ensureMatrixQaOwnerReady({ + allowCrossSigningResetOnRepair: true, + client: observer, + label: "observer", + }); + let device: Awaited> | undefined; + let cli: Awaited> | undefined; + try { + device = await loginMatrixQaRecoveryDevice({ + context, + deviceName: "OpenClaw Matrix QA Wrong Account Key", + password: observerPassword, + userId: context.observerUserId, + }); + cli = await createMatrixQaRecoveryCliRuntime({ + accountId: "wrong-account", + accessToken: device.accessToken, + context, + deviceId: device.deviceId, + label: "wrong-account-recovery-key", + userId: device.userId, + }); + const restored = await runMatrixQaCliJson({ + allowNonZero: true, + args: [ + "matrix", + "verify", + "backup", + "restore", + "--account", + "wrong-account", + "--recovery-key-stdin", + "--json", + ], + label: "restore-with-wrong-account-key", + runtime: cli, + stdin: `${driverSetup.encodedRecoveryKey}\n`, + timeoutMs: context.timeoutMs, + }); + assertMatrixQaCliBackupRestoreFailed(restored.payload, "wrong-account recovery-key restore"); + return { + artifacts: { + observerRecoveryDeviceId: device.deviceId, + restoreError: restored.payload.error, + restoreExitCode: restored.result.exitCode, + }, + details: [ + "driver recovery key was rejected for observer account backup", + `restore exit code: ${restored.result.exitCode}`, + `restore error: ${restored.payload.error}`, + ].join("\n"), + }; + } finally { + await cli?.dispose().catch(() => undefined); + if (device) { + await observer.deleteOwnDevices([device.deviceId]).catch(() => undefined); + } + } + } finally { + await observer.stop().catch(() => undefined); + await driverSetup.owner.stop().catch(() => undefined); + } +} + +export async function runMatrixQaE2eeHistoryExistsBackupEmptyScenario( + context: MatrixQaScenarioContext, +): Promise { + const driverPassword = requireMatrixQaPassword(context, "driver"); + const setup = await prepareMatrixQaDestructiveSetup( + context, + "matrix-e2ee-history-exists-backup-empty", + ); + const reset = await setup.owner.resetRoomKeyBackup(); + if (!reset.success) { + await setup.owner.stop().catch(() => undefined); + throw new Error(`Matrix empty-backup reset failed: ${reset.error ?? "unknown"}`); + } + const freshKey = await setup.owner.getRecoveryKey(); + const freshEncodedKey = freshKey?.encodedPrivateKey?.trim(); + if (!freshEncodedKey) { + await setup.owner.stop().catch(() => undefined); + throw new Error("Matrix empty-backup reset did not expose a fresh recovery key"); + } + const { cli, device } = await runMatrixQaExternalKeyRestore({ + accountId: "empty-backup", + context, + deviceName: "OpenClaw Matrix QA Empty Backup", + label: "history-exists-backup-empty", + password: driverPassword, + userId: context.driverUserId, + }); + try { + const restored = await waitForMatrixQaNonEmptyCliBackupRestore({ + accountId: "empty-backup", + cli, + label: "restore-reset-backup", + recoveryKey: freshEncodedKey, + timeoutMs: context.timeoutMs, + }); + return { + artifacts: { + backupCreatedVersion: reset.createdVersion, + historyEventId: setup.seededEventId, + recoveryDeviceId: device.deviceId, + restoreImported: restored.payload.imported, + restoreTotal: restored.payload.total, + }, + details: [ + "encrypted history survived a server backup reset through local key re-upload", + `history event: ${setup.seededEventId}`, + `reset backup version: ${reset.createdVersion ?? ""}`, + `restore imported/total: ${restored.payload.imported ?? 0}/${restored.payload.total ?? 0}`, + ].join("\n"), + }; + } finally { + await cli.dispose().catch(() => undefined); + await setup.owner.deleteOwnDevices([device.deviceId]).catch(() => undefined); + await setup.owner.stop().catch(() => undefined); + } +} 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 740203db535..ddc06c0a210 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-e2ee.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-e2ee.ts @@ -140,6 +140,10 @@ function isMatrixQaRepairableBackupBootstrapError(error: string | undefined) { ); } +const MATRIX_QA_PRESERVE_IDENTITY_BOOTSTRAP_OPTIONS = { + allowAutomaticCrossSigningReset: false, +} as const; + async function assertMatrixQaPeerDeviceTrusted(params: { client: MatrixQaE2eeScenarioClient; deviceId: string; @@ -159,15 +163,15 @@ async function ensureMatrixQaE2eeOwnDeviceVerified(params: { client: MatrixQaE2eeScenarioClient; label: string; }) { - let bootstrap = await params.client.bootstrapOwnDeviceVerification({ - forceResetCrossSigning: true, - }); + let bootstrap = await params.client.bootstrapOwnDeviceVerification( + MATRIX_QA_PRESERVE_IDENTITY_BOOTSTRAP_OPTIONS, + ); if (!bootstrap.success && isMatrixQaRepairableBackupBootstrapError(bootstrap.error)) { const reset = await params.client.resetRoomKeyBackup(); if (reset.success) { - bootstrap = await params.client.bootstrapOwnDeviceVerification({ - forceResetCrossSigning: true, - }); + bootstrap = await params.client.bootstrapOwnDeviceVerification( + MATRIX_QA_PRESERVE_IDENTITY_BOOTSTRAP_OPTIONS, + ); } } assertMatrixQaBootstrapSucceeded(params.label, bootstrap); @@ -413,6 +417,12 @@ async function createMatrixQaCliSelfVerificationRuntime(params: { configPath, `${JSON.stringify( { + plugins: { + allow: ["matrix"], + entries: { + matrix: { enabled: true }, + }, + }, channels: { matrix: { defaultAccount: params.accountId, @@ -422,7 +432,7 @@ async function createMatrixQaCliSelfVerificationRuntime(params: { deviceId: params.deviceId, encryption: true, homeserver: params.context.baseUrl, - initialSyncLimit: 1, + initialSyncLimit: 0, name: "Matrix QA CLI self-verification", network: { dangerouslyAllowPrivateNetwork: true, @@ -448,10 +458,11 @@ async function createMatrixQaCliSelfVerificationRuntime(params: { OPENCLAW_DISABLE_AUTO_UPDATE: "1", OPENCLAW_STATE_DIR: stateDir, }; - const run = async (args: string[], timeoutMs = params.context.timeoutMs) => + const run = async (args: string[], timeoutMs = params.context.timeoutMs, stdin?: string) => await runMatrixQaOpenClawCli({ args, env, + stdin, timeoutMs, }); const start = (args: string[], timeoutMs = params.context.timeoutMs) => @@ -1047,6 +1058,18 @@ export async function runMatrixQaE2eeRecoveryKeyLifecycleScenario( `Matrix E2EE room-key backup reset failed: ${reset.error ?? "unknown error"}`, ); } + const resetRecoveryKey = await recoveryClient.getRecoveryKey(); + const resetEncodedRecoveryKey = resetRecoveryKey?.encodedPrivateKey?.trim(); + if (resetEncodedRecoveryKey && resetEncodedRecoveryKey !== encodedRecoveryKey) { + const ownerRecovery = await client.verifyWithRecoveryKey(resetEncodedRecoveryKey); + if (!ownerRecovery.success) { + throw new Error( + `Matrix E2EE owner could not refresh recovery key after backup reset: ${ + ownerRecovery.error ?? "unknown error" + }`, + ); + } + } await recoveryClient.stop(); await client.deleteOwnDevices([recoveryDevice.deviceId]).catch(() => undefined); cleanupRecoveryDevice = false; @@ -1205,17 +1228,20 @@ export async function runMatrixQaE2eeCliSelfVerificationScenario( userId: cliDevice.userId, }); try { - const restoreResult = await cli.run([ - "matrix", - "verify", - "backup", - "restore", - "--account", - accountId, - "--recovery-key", - encodedRecoveryKey, - "--json", - ]); + 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, @@ -1288,8 +1314,9 @@ export async function runMatrixQaE2eeCliSelfVerificationScenario( cliSas, owner: ownerSas, }); - await session.writeStdin("yes\n"); await owner.confirmVerificationSas(ownerSas.id); + await session.writeStdin("yes\n"); + session.endStdin(); const completedCli = await session.wait(); const selfVerificationArtifacts = await writeMatrixQaCliOutputArtifacts({ label: "verify-self", diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-room.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-room.ts index 7f4964bcd3c..ea59f03d923 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-room.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-room.ts @@ -76,10 +76,12 @@ function buildMatrixQaThreadArtifacts(result: MatrixQaThreadScenarioResult) { } function failIfMatrixSubagentThreadHookError(event: MatrixQaObservedEvent) { - if (MATRIX_SUBAGENT_THREAD_HOOK_ERROR_RE.test(event.body ?? "")) { - throw new Error( - `Matrix subagent thread spawn hit missing hook error: ${event.body ?? ""}`, - ); + const body = event.body ?? ""; + if (MATRIX_SUBAGENT_THREAD_HOOK_ERROR_RE.test(body)) { + throw new Error(`Matrix subagent thread spawn hit missing hook error: ${body || ""}`); + } + if (/\bsessions_spawn failed:/i.test(body)) { + throw new Error(`Matrix subagent thread spawn failed: ${body || ""}`); } } @@ -298,9 +300,9 @@ export async function runSubagentThreadSpawnScenario(context: MatrixQaScenarioCo const childToken = buildMatrixQaToken("MATRIX_QA_SUBAGENT_CHILD"); const triggerBody = [ `${context.sutUserId} Call sessions_spawn now for this QA check.`, - `Use task="Reply exactly \`${childToken}\`. This is the marker."`, + `Use task="Finish with exactly ${childToken}."`, "Use label=matrix-thread-subagent thread=true mode=session runTimeoutSeconds=60.", - "Do not answer with the marker yourself.", + "Do not send the child token from this parent session.", ].join(" "); const driverEventId = await client.sendTextMessage({ body: triggerBody, diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts index 481d4c1cf31..ea54dd2fa82 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts @@ -8,6 +8,19 @@ import { runDmSharedSessionNoticeScenario, runDmThreadReplyOverrideScenario, } from "./scenario-runtime-dm.js"; +import { + runMatrixQaE2eeCorruptCryptoIdbSnapshotScenario, + runMatrixQaE2eeHistoryExistsBackupEmptyScenario, + runMatrixQaE2eeServerBackupDeletedLocalStateIntactScenario, + runMatrixQaE2eeServerBackupDeletedLocalReuploadRestoresScenario, + runMatrixQaE2eeServerDeviceDeletedLocalStateIntactScenario, + runMatrixQaE2eeStaleRecoveryKeyAfterBackupResetScenario, + runMatrixQaE2eeStateLossExternalRecoveryKeyScenario, + runMatrixQaE2eeStateLossNoRecoveryKeyScenario, + runMatrixQaE2eeStateLossStoredRecoveryKeyScenario, + runMatrixQaE2eeSyncStateLossCryptoIntactScenario, + runMatrixQaE2eeWrongAccountRecoveryKeyScenario, +} from "./scenario-runtime-e2ee-destructive.js"; import { runMatrixQaE2eeArtifactRedactionScenario, runMatrixQaE2eeBasicReplyScenario, @@ -314,6 +327,28 @@ export async function runMatrixQaScenario( return await runMatrixQaE2eeRecoveryOwnerVerificationRequiredScenario(context); case "matrix-e2ee-cli-self-verification": return await runMatrixQaE2eeCliSelfVerificationScenario(context); + case "matrix-e2ee-state-loss-external-recovery-key": + return await runMatrixQaE2eeStateLossExternalRecoveryKeyScenario(context); + case "matrix-e2ee-state-loss-stored-recovery-key": + return await runMatrixQaE2eeStateLossStoredRecoveryKeyScenario(context); + case "matrix-e2ee-state-loss-no-recovery-key": + return await runMatrixQaE2eeStateLossNoRecoveryKeyScenario(context); + case "matrix-e2ee-stale-recovery-key-after-backup-reset": + return await runMatrixQaE2eeStaleRecoveryKeyAfterBackupResetScenario(context); + case "matrix-e2ee-server-backup-deleted-local-state-intact": + return await runMatrixQaE2eeServerBackupDeletedLocalStateIntactScenario(context); + case "matrix-e2ee-server-backup-deleted-local-reupload-restores": + return await runMatrixQaE2eeServerBackupDeletedLocalReuploadRestoresScenario(context); + case "matrix-e2ee-corrupt-crypto-idb-snapshot": + return await runMatrixQaE2eeCorruptCryptoIdbSnapshotScenario(context); + case "matrix-e2ee-server-device-deleted-local-state-intact": + return await runMatrixQaE2eeServerDeviceDeletedLocalStateIntactScenario(context); + case "matrix-e2ee-sync-state-loss-crypto-intact": + return await runMatrixQaE2eeSyncStateLossCryptoIntactScenario(context); + case "matrix-e2ee-wrong-account-recovery-key": + return await runMatrixQaE2eeWrongAccountRecoveryKeyScenario(context); + case "matrix-e2ee-history-exists-backup-empty": + return await runMatrixQaE2eeHistoryExistsBackupEmptyScenario(context); case "matrix-e2ee-device-sas-verification": return await runMatrixQaE2eeDeviceSasVerificationScenario(context); case "matrix-e2ee-qr-verification": diff --git a/extensions/qa-matrix/src/runners/contract/scenario-types.ts b/extensions/qa-matrix/src/runners/contract/scenario-types.ts index 102a4434cd4..53932bad3a8 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-types.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-types.ts @@ -96,23 +96,49 @@ export type MatrixQaScenarioArtifacts = { bootstrapErrorPreview?: string; bootstrapSuccess?: boolean; backupCreatedVersion?: string | null; + backupDeletedHttpStatus?: number; + backupPreviousVersion?: string | null; backupRestored?: boolean; backupReset?: boolean; + completedVerificationId?: string; completedVerificationIds?: string[]; currentDeviceId?: string | null; + accountRoot?: string; + corruptedPath?: string; + deletedSyncStorePath?: string; deletedDeviceIds?: string[]; + deletedDeviceId?: string; + deletedBackupVersion?: string | null; faultedEndpoint?: string; faultHitCount?: number; faultRuleId?: string; + historyEventId?: string; + observerRecoveryDeviceId?: string; qrBytes?: number; + recoveryDeviceId?: string; + recoveryKeyPreserved?: boolean; recoveryKeyId?: string | null; recoveryKeyStored?: boolean; + rotatedRecoveryKeyId?: string | null; remainingDeviceIds?: string[]; + restoreError?: string; + restoreErrorAfterDelete?: string; + restoreExitCode?: number; + restoreImported?: number; + restoreTotal?: number; sasEmoji?: string[]; secondaryDeviceId?: string; + seededEventId?: string; + replyEventId?: string; + statusError?: string; + statusExitCode?: number; + serverDeviceKnown?: boolean | null; + selfVerificationTransactionId?: string | null; transportInterruption?: string; verificationRoomId?: string; joinedRoomId?: string; + localEventId?: string; + verificationExitCode?: number; }; export type MatrixQaScenarioExecution = { diff --git a/extensions/qa-matrix/src/runners/contract/scenarios.test.ts b/extensions/qa-matrix/src/runners/contract/scenarios.test.ts index 51ccbacb9dc..fd828b6ee51 100644 --- a/extensions/qa-matrix/src/runners/contract/scenarios.test.ts +++ b/extensions/qa-matrix/src/runners/contract/scenarios.test.ts @@ -169,6 +169,16 @@ describe("matrix live qa scenarios", () => { "matrix-e2ee-recovery-key-lifecycle", "matrix-e2ee-recovery-owner-verification-required", "matrix-e2ee-cli-self-verification", + "matrix-e2ee-state-loss-external-recovery-key", + "matrix-e2ee-state-loss-stored-recovery-key", + "matrix-e2ee-state-loss-no-recovery-key", + "matrix-e2ee-stale-recovery-key-after-backup-reset", + "matrix-e2ee-server-backup-deleted-local-state-intact", + "matrix-e2ee-server-backup-deleted-local-reupload-restores", + "matrix-e2ee-corrupt-crypto-idb-snapshot", + "matrix-e2ee-server-device-deleted-local-state-intact", + "matrix-e2ee-sync-state-loss-crypto-intact", + "matrix-e2ee-history-exists-backup-empty", "matrix-e2ee-device-sas-verification", "matrix-e2ee-qr-verification", "matrix-e2ee-stale-device-hygiene", @@ -178,9 +188,29 @@ describe("matrix live qa scenarios", () => { "matrix-e2ee-artifact-redaction", "matrix-e2ee-media-image", "matrix-e2ee-key-bootstrap-failure", + "matrix-e2ee-wrong-account-recovery-key", ]); }); + it("keeps account-mutating E2EE negative coverage at the suite tail", () => { + const scenarioIds = scenarioTesting.findMatrixQaScenarios().map((scenario) => scenario.id); + const destructiveScenarioId = "matrix-e2ee-wrong-account-recovery-key"; + const destructiveIndex = scenarioIds.indexOf(destructiveScenarioId); + + expect(scenarioIds.at(-1)).toBe(destructiveScenarioId); + const protectedScenarioIds = [ + "matrix-e2ee-state-loss-external-recovery-key", + "matrix-e2ee-state-loss-stored-recovery-key", + "matrix-e2ee-device-sas-verification", + "matrix-e2ee-qr-verification", + "matrix-e2ee-dm-sas-verification", + "matrix-e2ee-media-image", + ] satisfies (typeof scenarioIds)[number][]; + for (const scenarioId of protectedScenarioIds) { + expect(destructiveIndex).toBeGreaterThan(scenarioIds.indexOf(scenarioId)); + } + }); + it("uses the repo-wide exact marker prompt shape for Matrix mentions", () => { expect( scenarioTesting.buildMentionPrompt("@sut:matrix-qa.test", "MATRIX_QA_CANARY_TOKEN"), @@ -203,6 +233,17 @@ describe("matrix live qa scenarios", () => { expect(scenarios.get("matrix-e2ee-media-image")?.timeoutMs).toBeGreaterThanOrEqual(180_000); }); + it("keeps the Matrix subagent room policy compatible with leaf child sessions", () => { + const scenario = MATRIX_QA_SCENARIOS.find( + (entry) => entry.id === "matrix-subagent-thread-spawn", + ); + + expect(scenario?.configOverrides?.groupsByKey?.main?.tools?.allow).toEqual([ + "sessions_spawn", + "sessions_yield", + ]); + }); + it("requires Matrix replies to match the exact marker body", () => { expect( scenarioTesting.buildMatrixReplyArtifact( @@ -1311,7 +1352,7 @@ describe("matrix live qa scenarios", () => { })) .mockImplementationOnce(async () => { const childToken = - /task="Reply exactly `([^`]+)`/.exec( + /task="Finish with exactly ([^".]+)\./.exec( String(sendTextMessage.mock.calls[0]?.[0]?.body), )?.[1] ?? "MATRIX_QA_SUBAGENT_CHILD_FIXED"; return { @@ -1458,6 +1499,43 @@ describe("matrix live qa scenarios", () => { expect(waitForRoomEvent).toHaveBeenCalledTimes(1); }); + it("fails the subagent thread spawn scenario on surfaced tool errors", async () => { + const primeRoom = vi.fn().mockResolvedValue("driver-sync-start"); + const sendTextMessage = vi.fn().mockResolvedValue("$subagent-spawn-trigger"); + const waitForRoomEvent = vi.fn().mockImplementationOnce(async (options) => { + const event = { + kind: "message", + roomId: "!main:matrix-qa.test", + eventId: "$sessions-spawn-error", + sender: "@sut:matrix-qa.test", + type: "m.room.message", + body: "Protocol note: sessions_spawn failed: Matrix thread bind failed: no adapter", + } satisfies MatrixQaObservedEvent; + options.predicate(event); + return { + event, + since: "driver-sync-error", + }; + }); + + createMatrixQaClient.mockReturnValue({ + primeRoom, + sendTextMessage, + waitForRoomEvent, + }); + + const scenario = MATRIX_QA_SCENARIOS.find( + (entry) => entry.id === "matrix-subagent-thread-spawn", + ); + expect(scenario).toBeDefined(); + + await expect(runMatrixQaScenario(scenario!, matrixQaScenarioContext())).rejects.toThrow( + "sessions_spawn failed", + ); + + expect(waitForRoomEvent).toHaveBeenCalledTimes(1); + }); + it("captures quiet preview notices before the finalized Matrix reply", async () => { const primeRoom = vi.fn().mockResolvedValue("driver-sync-start"); const sendTextMessage = vi.fn().mockResolvedValue("$quiet-stream-trigger"); @@ -2624,6 +2702,19 @@ describe("matrix live qa scenarios", () => { previousVersion: "backup-v1", success: true, }); + const ownerBootstrapOwnDeviceVerification = vi.fn().mockResolvedValue({ + crossSigning: { + published: true, + }, + success: true, + verification: { + backupVersion: "backup-v1", + crossSigningVerified: true, + recoveryKeyStored: true, + signedByOwner: true, + verified: true, + }, + }); const driverStop = vi.fn().mockResolvedValue(undefined); const recoveryStop = vi.fn().mockResolvedValue(undefined); createMatrixQaClient.mockReturnValue({ @@ -2636,19 +2727,7 @@ describe("matrix live qa scenarios", () => { }); createMatrixQaE2eeScenarioClient .mockResolvedValueOnce({ - bootstrapOwnDeviceVerification: vi.fn().mockResolvedValue({ - crossSigning: { - published: true, - }, - success: true, - verification: { - backupVersion: "backup-v1", - crossSigningVerified: true, - recoveryKeyStored: true, - signedByOwner: true, - verified: true, - }, - }), + bootstrapOwnDeviceVerification: ownerBootstrapOwnDeviceVerification, deleteOwnDevices: vi.fn().mockResolvedValue(undefined), getRecoveryKey: vi.fn().mockResolvedValue({ encodedPrivateKey: "encoded-recovery-key", @@ -2658,6 +2737,10 @@ describe("matrix live qa scenarios", () => { stop: driverStop, }) .mockResolvedValueOnce({ + getRecoveryKey: vi.fn().mockResolvedValue({ + encodedPrivateKey: "encoded-recovery-key", + keyId: "SSSS", + }), resetRoomKeyBackup, restoreRoomKeyBackup, stop: recoveryStop, @@ -2719,6 +2802,9 @@ describe("matrix live qa scenarios", () => { }, }); + expect(ownerBootstrapOwnDeviceVerification).toHaveBeenCalledWith({ + allowAutomaticCrossSigningReset: false, + }); expect(verifyWithRecoveryKey).toHaveBeenCalledWith("encoded-recovery-key"); expect(verifyWithRecoveryKey.mock.invocationCallOrder[0]).toBeLessThan( restoreRoomKeyBackup.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER, @@ -2751,6 +2837,19 @@ describe("matrix live qa scenarios", () => { ruleId: "owner-signature-upload-blocked", }, ]); + const ownerBootstrapOwnDeviceVerification = vi.fn().mockResolvedValue({ + crossSigning: { + published: true, + }, + success: true, + verification: { + backupVersion: "backup-v1", + crossSigningVerified: true, + recoveryKeyStored: true, + signedByOwner: true, + verified: true, + }, + }); startMatrixQaFaultProxy.mockResolvedValue({ baseUrl: "http://127.0.0.1:39877", hits: proxyHits, @@ -2766,19 +2865,7 @@ describe("matrix live qa scenarios", () => { }); createMatrixQaE2eeScenarioClient .mockResolvedValueOnce({ - bootstrapOwnDeviceVerification: vi.fn().mockResolvedValue({ - crossSigning: { - published: true, - }, - success: true, - verification: { - backupVersion: "backup-v1", - crossSigningVerified: true, - recoveryKeyStored: true, - signedByOwner: true, - verified: true, - }, - }), + bootstrapOwnDeviceVerification: ownerBootstrapOwnDeviceVerification, deleteOwnDevices: driverDeleteOwnDevices, getRecoveryKey: vi.fn().mockResolvedValue({ encodedPrivateKey: "encoded-recovery-key", @@ -2889,6 +2976,9 @@ describe("matrix live qa scenarios", () => { scenarioId: "matrix-e2ee-recovery-owner-verification-required", }), ); + expect(ownerBootstrapOwnDeviceVerification).toHaveBeenCalledWith({ + allowAutomaticCrossSigningReset: false, + }); expect(verifyWithRecoveryKey).toHaveBeenCalledWith("encoded-recovery-key"); expect(restoreRoomKeyBackup).toHaveBeenCalledWith({ recoveryKey: "encoded-recovery-key", @@ -3017,8 +3107,10 @@ describe("matrix live qa scenarios", () => { "Verification id: verification-1\nCompleted: yes\nDevice verified by owner: yes\nCross-signing verified: yes\n", }); const kill = vi.fn(); + const endStdin = vi.fn(); startMatrixQaOpenClawCli.mockReturnValue({ args: ["matrix", "verify", "self", "--account", "cli"], + endStdin, kill, output: vi.fn(() => ({ stderr: "", stdout: "" })), wait, @@ -3026,7 +3118,7 @@ describe("matrix live qa scenarios", () => { writeStdin, }); let cliAccountConfigDuringRun: Record | null = null; - runMatrixQaOpenClawCli.mockImplementation(async ({ args, env }) => { + runMatrixQaOpenClawCli.mockImplementation(async ({ args, env, stdin }) => { if (!cliAccountConfigDuringRun && env.OPENCLAW_CONFIG_PATH) { const cliConfig = JSON.parse( await readFile(String(env.OPENCLAW_CONFIG_PATH), "utf8"), @@ -3036,8 +3128,16 @@ describe("matrix live qa scenarios", () => { accounts?: Record>; }; }; + plugins?: { + allow?: string[]; + entries?: Record; + }; + }; + cliAccountConfigDuringRun = { + ...cliConfig.channels?.matrix?.accounts?.cli, + pluginAllow: cliConfig.plugins?.allow, + pluginEnabled: cliConfig.plugins?.entries?.matrix?.enabled, }; - cliAccountConfigDuringRun = cliConfig.channels?.matrix?.accounts?.cli ?? null; } const joined = args.join(" "); if (joined === "matrix verify status --account cli --json") { @@ -3060,10 +3160,8 @@ describe("matrix live qa scenarios", () => { }), }; } - if ( - joined === - "matrix verify backup restore --account cli --recovery-key encoded-recovery-key --json" - ) { + if (joined === "matrix verify backup restore --account cli --recovery-key-stdin --json") { + expect(stdin).toBe("encoded-recovery-key\n"); return { args, exitCode: 0, @@ -3118,6 +3216,7 @@ describe("matrix live qa scenarios", () => { ]); expect(waitForOutput).toHaveBeenCalledTimes(2); expect(writeStdin).toHaveBeenCalledWith("yes\n"); + expect(endStdin).toHaveBeenCalledTimes(1); expect(wait).toHaveBeenCalledTimes(1); expect(kill).toHaveBeenCalledTimes(1); expect(runMatrixQaOpenClawCli).toHaveBeenCalledTimes(2); @@ -3129,12 +3228,12 @@ describe("matrix live qa scenarios", () => { "restore", "--account", "cli", - "--recovery-key", - "encoded-recovery-key", + "--recovery-key-stdin", "--json", ], ["matrix", "verify", "status", "--account", "cli", "--json"], ]); + expect(runMatrixQaOpenClawCli.mock.calls[0]?.[0].stdin).toBe("encoded-recovery-key\n"); const cliEnv = startMatrixQaOpenClawCli.mock.calls[0]?.[0].env; expect(cliEnv?.OPENCLAW_STATE_DIR).toContain("openclaw-matrix-cli-qa-"); expect(cliEnv?.OPENCLAW_CONFIG_PATH).toContain("openclaw-matrix-cli-qa-"); @@ -3144,6 +3243,8 @@ describe("matrix live qa scenarios", () => { deviceId: "CLIDEVICE", encryption: true, homeserver: "http://127.0.0.1:28008/", + pluginAllow: expect.arrayContaining(["matrix"]), + pluginEnabled: true, startupVerification: "off", userId: "@driver:matrix-qa.test", }); @@ -3174,6 +3275,9 @@ describe("matrix live qa scenarios", () => { await expect( readFile(path.join(cliArtifactDir, "verify-status.stdout.txt"), "utf8"), ).resolves.toContain('"crossSigningVerified":true'); + expect(bootstrapOwnDeviceVerification).toHaveBeenCalledWith({ + allowAutomaticCrossSigningReset: false, + }); } finally { await rm(outputDir, { force: true, recursive: true }); } diff --git a/extensions/qa-matrix/src/substrate/e2ee-client.ts b/extensions/qa-matrix/src/substrate/e2ee-client.ts index 5bce56161be..dc940df5f7c 100644 --- a/extensions/qa-matrix/src/substrate/e2ee-client.ts +++ b/extensions/qa-matrix/src/substrate/e2ee-client.ts @@ -73,7 +73,9 @@ export type MatrixQaE2eeScenarioClient = { roomId?: string; userId?: string; }): Promise; - resetRoomKeyBackup(): Promise; + resetRoomKeyBackup(params?: { + rotateRecoveryKey?: boolean; + }): Promise; restoreRoomKeyBackup(params?: { recoveryKey?: string; }): Promise; @@ -301,8 +303,8 @@ export async function createMatrixQaE2eeScenarioClient( async requestVerification(opts) { return await requireCrypto().requestVerification(opts); }, - async resetRoomKeyBackup() { - return await client.resetRoomKeyBackup(); + async resetRoomKeyBackup(params) { + return await client.resetRoomKeyBackup(params); }, async restoreRoomKeyBackup(opts) { return await client.restoreRoomKeyBackup(opts); diff --git a/extensions/qa-matrix/src/substrate/request.ts b/extensions/qa-matrix/src/substrate/request.ts index 8c09505d066..f8990c385ee 100644 --- a/extensions/qa-matrix/src/substrate/request.ts +++ b/extensions/qa-matrix/src/substrate/request.ts @@ -11,7 +11,7 @@ export async function requestMatrixJson(params: { body?: unknown; endpoint: string; fetchImpl: MatrixQaFetchLike; - method: "GET" | "POST" | "PUT"; + method: "DELETE" | "GET" | "POST" | "PUT"; okStatuses?: number[]; query?: Record; timeoutMs?: number; diff --git a/src/plugin-sdk/channel-entry-contract.test.ts b/src/plugin-sdk/channel-entry-contract.test.ts index 12d79e0bc42..429fafee6d5 100644 --- a/src/plugin-sdk/channel-entry-contract.test.ts +++ b/src/plugin-sdk/channel-entry-contract.test.ts @@ -90,7 +90,7 @@ function createBundledChannelEntry(params: { } describe("defineBundledChannelEntry", () => { - it("keeps runtime sidecars out of discovery registration", () => { + it("loads runtime sidecars during discovery registration", () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-entry-runtime-")); tempDirs.push(tempRoot); const runtimeMarker = path.join(tempRoot, "runtime-loaded"); @@ -115,7 +115,7 @@ describe("defineBundledChannelEntry", () => { expect(api.registerChannel).toHaveBeenCalledTimes(1); expect(registerCliMetadata).toHaveBeenCalledWith(api); expect(registerFull).not.toHaveBeenCalled(); - expect(fs.existsSync(runtimeMarker)).toBe(false); + expect(fs.existsSync(runtimeMarker)).toBe(true); }); it("keeps setup-runtime and full registration wired to runtime sidecars", () => { diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index 8674d489f7b..5c732a049ad 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -487,11 +487,11 @@ export function defineBundledChannelEntry({ profile("bundled-register:registerChannel", () => api.registerChannel({ plugin: channelPlugin as ChannelPlugin }), ); + profile("bundled-register:setChannelRuntime", () => setChannelRuntime?.(api.runtime)); if (api.registrationMode === "discovery") { profile("bundled-register:registerCliMetadata", () => registerCliMetadata?.(api)); return; } - profile("bundled-register:setChannelRuntime", () => setChannelRuntime?.(api.runtime)); if (api.registrationMode !== "full") { return; } diff --git a/src/plugin-sdk/core.test.ts b/src/plugin-sdk/core.test.ts index 1e21133393c..c522b97879f 100644 --- a/src/plugin-sdk/core.test.ts +++ b/src/plugin-sdk/core.test.ts @@ -32,7 +32,7 @@ function createApi(registrationMode: PluginRegistrationMode): OpenClawPluginApi } describe("defineChannelPluginEntry", () => { - it("keeps runtime helpers out of discovery registration", () => { + it("wires runtime helpers during discovery registration", () => { const setRuntime = vi.fn<(runtime: PluginRuntime) => void>(); const registerCliMetadata = vi.fn<(api: OpenClawPluginApi) => void>(); const registerFull = vi.fn<(api: OpenClawPluginApi) => void>(); @@ -51,7 +51,7 @@ describe("defineChannelPluginEntry", () => { expect(api.registerChannel).toHaveBeenCalledTimes(1); expect(registerCliMetadata).toHaveBeenCalledTimes(1); - expect(setRuntime).not.toHaveBeenCalled(); + expect(setRuntime).toHaveBeenCalledWith(api.runtime); expect(registerFull).not.toHaveBeenCalled(); }); diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 6d0cd751a5e..937244a73b6 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -504,11 +504,11 @@ export function defineChannelPluginEntry({ return; } api.registerChannel({ plugin: plugin as ChannelPlugin }); + setRuntime?.(api.runtime); if (api.registrationMode === "discovery") { registerCliMetadata?.(api); return; } - setRuntime?.(api.runtime); if (api.registrationMode !== "full") { return; } diff --git a/src/plugins/loader.cli-metadata.test.ts b/src/plugins/loader.cli-metadata.test.ts index 96bc2e7e20a..5496651ca0a 100644 --- a/src/plugins/loader.cli-metadata.test.ts +++ b/src/plugins/loader.cli-metadata.test.ts @@ -1,6 +1,11 @@ import fs from "node:fs"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { + defineBundledChannelEntry, + type OpenClawPluginApi, +} from "../plugin-sdk/channel-entry-contract.js"; import { loadOpenClawPluginCliRegistry, loadOpenClawPlugins } from "./loader.js"; import { cleanupPluginLoaderFixturesForTest, @@ -650,12 +655,93 @@ module.exports = { expect(fs.readFileSync(modeMarker, "utf-8")).toBe("discovery"); expect(fs.existsSync(fullMarker)).toBe(false); - expect(fs.existsSync(runtimeMarker)).toBe(false); + expect(fs.existsSync(runtimeMarker)).toBe(true); expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain( "discovery-cli-metadata-channel", ); }); + it("sets bundled channel runtime before discovery CLI metadata registration", () => { + const pluginDir = makeTempDir(); + const runtimeMarker = path.join(pluginDir, "runtime-set.txt"); + const channelPluginPath = path.join(pluginDir, "channel.cjs"); + const runtimePath = path.join(pluginDir, "runtime.cjs"); + fs.writeFileSync( + channelPluginPath, + `exports.plugin = { + id: "bundled-discovery-cli", + meta: { + id: "bundled-discovery-cli", + label: "Bundled Discovery CLI", + selectionLabel: "Bundled Discovery CLI", + docsPath: "/channels/bundled-discovery-cli", + blurb: "bundled discovery cli", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, +};`, + "utf-8", + ); + fs.writeFileSync( + runtimePath, + `exports.setRuntime = () => { + require("node:fs").writeFileSync(${JSON.stringify(runtimeMarker)}, "loaded", "utf-8"); +};`, + "utf-8", + ); + + const commands: string[] = []; + const channels: string[] = []; + const entry = defineBundledChannelEntry({ + id: "bundled-discovery-cli", + name: "Bundled Discovery CLI", + description: "bundled discovery cli", + importMetaUrl: pathToFileURL(path.join(pluginDir, "index.cjs")).href, + plugin: { + specifier: "./channel.cjs", + exportName: "plugin", + }, + runtime: { + specifier: "./runtime.cjs", + exportName: "setRuntime", + }, + registerCliMetadata(api) { + api.registerCli(() => {}, { + descriptors: [ + { + name: "bundled-discovery-cli", + description: "Bundled discovery CLI metadata", + hasSubcommands: true, + }, + ], + }); + }, + registerFull() { + throw new Error("full registration should not run during discovery"); + }, + }); + + entry.register({ + registrationMode: "discovery", + runtime: {} as OpenClawPluginApi["runtime"], + registerChannel: (registration) => { + const plugin = "plugin" in registration ? registration.plugin : registration; + channels.push(plugin.id); + }, + registerCli: (_register, options) => { + commands.push(...(options?.descriptors ?? []).map((descriptor) => descriptor.name)); + }, + } as OpenClawPluginApi); + + expect(channels).toEqual(["bundled-discovery-cli"]); + expect(fs.existsSync(runtimeMarker)).toBe(true); + expect(commands).toEqual(["bundled-discovery-cli"]); + }); + it("sanitizes plugin CLI descriptor descriptions and rejects unsafe command names", async () => { useNoBundledPlugins(); const unsafeDescription = diff --git a/src/plugins/loader.test-fixtures.ts b/src/plugins/loader.test-fixtures.ts index 0db1be83b50..e692a244133 100644 --- a/src/plugins/loader.test-fixtures.ts +++ b/src/plugins/loader.test-fixtures.ts @@ -56,11 +56,11 @@ export function inlineChannelPluginEntryFactorySource(): string { return; } api.registerChannel({ plugin: options.plugin }); + options.setRuntime?.(api.runtime); if (api.registrationMode === "discovery") { options.registerCliMetadata?.(api); return; } - options.setRuntime?.(api.runtime); if (api.registrationMode !== "full") { return; }