diff --git a/extensions/matrix-js/LEGACY_MATRIX_PARITY_GAP_AUDIT.md b/extensions/matrix-js/LEGACY_MATRIX_PARITY_GAP_AUDIT.md new file mode 100644 index 00000000000..40f8d6edc71 --- /dev/null +++ b/extensions/matrix-js/LEGACY_MATRIX_PARITY_GAP_AUDIT.md @@ -0,0 +1,120 @@ +# Legacy Matrix Parity Gap Audit + +Audit date: February 23, 2026 + +Scope: + +- Baseline spec: `/Users/gumadeiras/openclaw/extensions/matrix-js/LEGACY_MATRIX_PARITY_SPEC.md` +- Compared implementations: + - Legacy: `/Users/gumadeiras/openclaw/extensions/matrix` + - New: `/Users/gumadeiras/openclaw/extensions/matrix-js` + +Method: + +- Static code comparison and targeted file inspection. +- Runtime validation executed for matrix-js test suites and project build. + +Status legend: + +- `PASS (static)` = code-level parity confirmed. +- `NEEDS UPDATING` = concrete parity/coexistence gap found. +- `UNVERIFIED (runtime)` = requires executing tests/integration flows. + +## Summary + +- Overall feature parity with legacy behavior: strong at code level. +- Previously identified dual-plugin coexistence blockers are resolved in code. +- Matrix-js regression tests pass (`27` files, `112` tests). +- Full repository build passes after the matrix-js namespace/storage changes. +- Remaining runtime validation gap: explicit side-by-side legacy `matrix` + `matrix-js` integration run. + +## Coexistence Gaps (Current Status) + +1. `PASS (static)`: Channel identity is consistent as `matrix-js` across metadata and runtime registration. + +- Evidence: + - `/Users/gumadeiras/openclaw/extensions/matrix-js/index.ts:7` + - `/Users/gumadeiras/openclaw/extensions/matrix-js/openclaw.plugin.json:2` + - `/Users/gumadeiras/openclaw/extensions/matrix-js/src/channel.ts:41` + - `/Users/gumadeiras/openclaw/extensions/matrix-js/src/channel.ts:99` + +2. `PASS (static)`: Config namespace is consistently `channels.matrix-js`. + +- Evidence: + - `/Users/gumadeiras/openclaw/extensions/matrix-js/src/channel.ts:116` + - `/Users/gumadeiras/openclaw/extensions/matrix-js/src/channel.ts:125` + - `/Users/gumadeiras/openclaw/extensions/matrix-js/src/channel.ts:319` + - `/Users/gumadeiras/openclaw/extensions/matrix-js/src/onboarding.ts:17` + - `/Users/gumadeiras/openclaw/extensions/matrix-js/src/onboarding.ts:174` + - `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/send/client.ts:22` + - `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/client/config.ts:125` + +3. `PASS (static)`: Outbound/inbound channel tags and routing context emit `matrix-js`. + +- Evidence: + - `/Users/gumadeiras/openclaw/extensions/matrix-js/src/outbound.ts:20` + - `/Users/gumadeiras/openclaw/extensions/matrix-js/src/outbound.ts:36` + - `/Users/gumadeiras/openclaw/extensions/matrix-js/src/outbound.ts:49` + - `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/send.ts:55` + - `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/handler.ts:496` + - `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/handler.ts:509` + +4. `PASS (static)`: Matrix-js now uses isolated storage namespace/prefixes. + +- Evidence: + - `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/credentials.ts:31` + - `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/client/storage.ts:42` + - `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/sdk/idb-persistence.ts:127` + - `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/client/create-client.ts:43` + +## Parity Matrix (Spec Section 16, Pre-Filled) + +| Check | Status | Evidence | +| ---------------------------------------------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Config schema keys and defaults are equivalent | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix/src/config-schema.ts` vs `/Users/gumadeiras/openclaw/extensions/matrix-js/src/config-schema.ts` (no semantic diffs) | +| Auth precedence (config/env/token/cache/password/register) matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/client/config.ts` | +| Bun runtime rejection behavior matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/client/runtime.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/index.ts` | +| Startup/shutdown lifecycle and status updates match legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/channel.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/index.ts` | +| DM detection heuristics match legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/direct.ts` | +| DM/group allowlist + pairing flow matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/handler.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/allowlist.ts` | +| Mention detection (`m.mentions`, formatted_body links, regex) matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/mentions.ts` | +| Control-command authorization gate behavior matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/handler.ts` | +| Inbound poll normalization matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/poll-types.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/handler.ts` | +| Inbound location normalization matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/location.ts` | +| Inbound media download/decrypt/size-limit behavior matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/media.ts` | +| Reply dispatch + typing + ack reaction + read receipts match legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/handler.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/replies.ts` | +| Thread handling (`threadReplies`) matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/threads.ts` | +| `replyToMode` handling for single/multi reply flows matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/monitor/replies.ts` | +| Outbound text chunking, markdown, and formatting behavior matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/send.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/send/formatting.ts` | +| Outbound media encryption/voice/thumbnail/duration behavior matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/send/media.ts` | +| Outbound poll payload behavior matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/send.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/poll-types.ts` | +| Action gating and action semantics match legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/actions.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/tool-actions.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/actions/*` | +| Verification action flow and summary semantics match legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/actions/verification.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/sdk/verification-manager.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/sdk/crypto-facade.ts` | +| Directory live lookup + target resolution ambiguity handling matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/directory-live.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/resolve-targets.ts` | +| Probe/status reporting fields match legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/probe.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/channel.ts` | +| Storage layout and credential persistence semantics match legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/client/storage.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/credentials.ts` | +| HTTP hardening and decrypt retry behavior matches legacy | PASS (static) | `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/sdk/http-client.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/sdk/decrypt-bridge.ts`, `/Users/gumadeiras/openclaw/extensions/matrix-js/src/matrix/sdk.ts` | + +## Runtime Validation Status + +- `PASS (runtime)`: matrix-js regression run succeeded via `pnpm test extensions/matrix-js/src` (`27` files, `112` tests). +- `PASS (runtime)`: build/type pipeline succeeded via `pnpm build`. +- `UNVERIFIED (runtime)`: side-by-side load of legacy `matrix` plus `matrix-js` with independent config. + +Recommended commands for final coexistence sign-off: + +```bash +pnpm test extensions/matrix/src +pnpm test extensions/matrix-js/src +pnpm build +``` + +## Suggested Next Fix Batch + +1. Add explicit coexistence integration tests: + +- Load both legacy `matrix` and `matrix-js` in one runtime with independent config + pairing state. + +2. Validate state migration behavior (if required by product decision): + +- Decide whether `matrix-js` should intentionally read legacy `channels.matrix`/`credentials/matrix` during transition or stay fully isolated. diff --git a/extensions/matrix-js/LEGACY_MATRIX_PARITY_SPEC.md b/extensions/matrix-js/LEGACY_MATRIX_PARITY_SPEC.md new file mode 100644 index 00000000000..75b1ff25a7b --- /dev/null +++ b/extensions/matrix-js/LEGACY_MATRIX_PARITY_SPEC.md @@ -0,0 +1,397 @@ +# Legacy Matrix Plugin Parity Spec + +This document defines the expected behavior of the **legacy Matrix plugin** (`extensions/matrix`) so the new **matrix-js plugin** (`extensions/matrix-js`) can be verified for feature parity. + +## 1. Scope + +- Legacy source of truth: + - `extensions/matrix/index.ts` + - `extensions/matrix/src/channel.ts` + - `extensions/matrix/src/**/*.ts` +- New implementation under test: + - `extensions/matrix-js/**` +- Goal: matrix-js should preserve user-visible and operator-visible behavior unless explicitly changed. + +## 2. Parity Levels + +- `MUST`: required parity for GA. +- `SHOULD`: desirable parity; acceptable temporary delta if documented. +- `NICE`: optional parity. + +## 3. Channel + Plugin Contract (MUST) + +- Plugin id remains `matrix`; channel id exposed to runtime is `matrix` in legacy. +- Channel metadata parity: + - label/selection/docs path/blurb/order/quickstart allowFrom behavior. +- Channel capabilities parity: + - `chatTypes`: direct, group, thread + - `polls`: true + - `reactions`: true + - `threads`: true + - `media`: true +- Reload behavior parity: + - config prefixes include `channels.matrix`. +- Pairing behavior parity: + - pairing id label, allow-entry normalization, approval notification message behavior. + +## 4. Configuration Contract (MUST) + +Legacy schema lives in `extensions/matrix/src/config-schema.ts` and `extensions/matrix/src/types.ts`. + +### 4.1 Core fields + +- `enabled?: boolean` +- Auth: `homeserver`, `userId`, `accessToken`, `password`, `register`, `deviceId`, `deviceName` +- Sync/runtime: `initialSyncLimit`, `encryption` +- Access control: + - `allowlistOnly` + - `groupPolicy`: `open|allowlist|disabled` + - `groupAllowFrom` + - `dm.policy`: `pairing|allowlist|open|disabled` + - `dm.allowFrom` +- Room policy: + - `groups` (preferred) and `rooms` (legacy alias) + - room fields: `enabled`, `allow`, `requireMention`, `tools`, `autoReply`, `users`, `skills`, `systemPrompt` +- Reply/thread behavior: + - `replyToMode`: `off|first|all` + - `threadReplies`: `off|inbound|always` +- Output shaping: + - `markdown`, `textChunkLimit`, `chunkMode`, `responsePrefix` +- Media + invites: + - `mediaMaxMb` + - `autoJoin`: `always|allowlist|off` + - `autoJoinAllowlist` +- Action gates: + - `actions.reactions|messages|pins|memberInfo|channelInfo|verification` + +### 4.2 Defaults and effective behavior + +- DM default policy: `pairing`. +- Group mention default: mention required in rooms unless room override allows auto-reply. +- `replyToMode` default: `off`. +- `threadReplies` default: `inbound`. +- `autoJoin` default: `always`. +- Legacy global hard text max remains 4000 chars per chunk for matrix sends/replies. +- When `allowlistOnly=true`, policies are effectively tightened: + - group `open` behaves as `allowlist` + - DM policy behaves as `allowlist` unless explicitly disabled. + +## 5. Account Model + Resolution (MUST) + +- Account listing/resolution behavior in `extensions/matrix/src/matrix/accounts.ts`: + - supports top-level single account fallback (`default` account semantics). + - supports per-account map and normalized account IDs. + - per-account config deep-merges known nested sections (`dm`, `actions`) over base config. +- Account configured state logic parity: + - configured when homeserver exists and one of: + - access token + - userId+password + - matching stored credentials. + +## 6. Auth + Client Bootstrap (MUST) + +Legacy auth behavior in `extensions/matrix/src/matrix/client/config.ts`: + +- Config/env resolution precedence: + - config values override env values. + - env vars: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN`, `MATRIX_PASSWORD`, `MATRIX_REGISTER`, `MATRIX_DEVICE_ID`, `MATRIX_DEVICE_NAME`. +- Token-first behavior: + - with access token, `whoami` resolves missing `userId` and/or `deviceId`. +- Credential cache behavior: + - reuses cached credentials when config matches homeserver+user (or homeserver-only token flow). + - updates `lastUsedAt` when reused. +- Password login behavior: + - login with `m.login.password` when no token. +- Register mode behavior: + - if login fails and `register=true`, attempts registration and then login-equivalent token flow. + - registration mode prepares backup snapshot and finalizes config by turning off `register` and removing stale inline token. +- Bun runtime must be rejected (Node required). + +## 7. Runtime/Connection Lifecycle (MUST) + +- Gateway startup path (`channel.ts` + `monitor/index.ts`) must: + - resolve auth, + - resolve shared client, + - attach monitor handlers, + - start sync, + - report runtime status fields. +- Shutdown behavior: + - client is stopped on abort, + - active client reference cleared. +- Startup lock behavior: + - startup import race is serialized via lock in `channel.ts`. + +## 8. Inbound Event Processing (MUST) + +Legacy handler logic: `extensions/matrix/src/matrix/monitor/handler.ts`. + +### 8.1 Event eligibility + +- Processes: + - `m.room.message` + - poll start events (`m.poll.start` + MSC aliases) + - location events (`m.location` and location msgtype) +- Ignores: + - redacted events + - self-sent events + - old pre-startup events + - edit relation events (`m.replace`) + - encrypted raw payloads (expects decrypted bridge events) + +### 8.2 DM/group detection + +- DM detection chain (`monitor/direct.ts`): + - `m.direct` cache, + - member-count heuristic (2 users), + - `is_direct` member-state fallback. + +### 8.3 Access control + allowlists + +- DM policy behavior: + - `disabled`: no DM processing. + - `open`: process all DMs. + - `allowlist`: process only matching allowlist. + - `pairing`: create pairing request/code for unauthorized sender and send approval instructions. +- Group policy behavior: + - `disabled`: ignore rooms. + - `allowlist`: room must exist in allowlisted rooms map (or wildcard) and pass optional sender constraints. + - `open`: allow rooms, still mention-gated by default. +- Group sender gating: + - room-level `users` allowlist if configured. + - `groupAllowFrom` fallback when room users list not set. + +### 8.4 Mention + command gate behavior + +- Mention detection parity: + - `m.mentions.user_ids` + - `m.mentions.room` + - `formatted_body` matrix.to links (plain and URL-encoded) + - mention regex patterns from core mention config +- Default room behavior requires mention unless room policy overrides. +- Control command bypass behavior: + - unauthorized control commands are dropped in group contexts. + +### 8.5 Input normalization + +- Poll start events converted to normalized text payload. +- Location events converted to normalized location text + context fields. +- mxc media downloaded (and decrypted when file payload present) with max-byte enforcement. + +### 8.6 Context/session/routing + +- Builds context with matrix-specific fields: + - From/To/SessionKey/MessageSid/ReplyToId/MessageThreadId/MediaPath/etc. +- Resolves per-agent route via core routing. +- Persists inbound session metadata and updates last-route for DM contexts. + +### 8.7 Reply delivery + +- Typing indicators start/stop around reply dispatch. +- Reply prefix/model-selection behavior uses core reply options. +- Room-level `skills` filter and `systemPrompt` are applied. +- Reply delivery semantics: + - `replyToMode` controls how often replyTo is used (`off|first|all`). + - thread target suppresses plain replyTo fallback. + - chunking and markdown-table conversion parity required. + +### 8.8 Side effects + +- Optional ack reaction based on `messages.ackReaction` + scope rules. +- Read receipt sent for inbound event IDs. +- System event enqueued after successful reply. + +## 9. Outbound Sending Contract (MUST) + +Legacy send behavior: `extensions/matrix/src/matrix/send.ts` and `send/*`. + +### 9.1 Text + +- Requires text or media; empty text without media is error. +- Resolves target IDs from `matrix:/room:/channel:/user:/@user/#alias` forms. +- Markdown tables converted via core table mode. +- Markdown converted to Matrix HTML formatting. +- Chunking respects configured limit but hard-caps at 4000. +- Thread relation behavior: + - `threadId` -> `m.thread` relation. + - otherwise optional reply relation. + +### 9.2 Media + +- Loads media via core media loader with size limits. +- Upload behavior: + - encrypts media in encrypted rooms when crypto available. + - otherwise plain upload. +- Includes metadata: + - mimetype/size/duration, + - image dimensions/thumbnail when available. +- Voice behavior: + - if `audioAsVoice=true` and compatible audio, send as voice payload (`org.matrix.msc3245.voice`). +- Caption/follow-up behavior: + - first chunk is caption, + - remaining text chunks become follow-up messages. + +### 9.3 Polls + +- Supports `sendPoll` with MSC3381 payload (`m.poll.start`) + fallback text. +- Supports thread relation for polls when thread ID present. + +### 9.4 Reactions + receipts + typing + +- Supports sending reactions (`m.reaction` annotation). +- Supports typing state and read receipts. + +## 10. Tool/Action Contract (MUST) + +Legacy action adapter: `src/actions.ts`, `src/tool-actions.ts`, `src/matrix/actions/*`. + +### 10.1 Action availability gates + +- Baseline actions include `send` and poll path support. +- Optional gated actions: + - reactions: `react`, `reactions` + - messages: `read`, `edit`, `delete` + - pins: `pin`, `unpin`, `list-pins` + - member info: `member-info` + - channel info: `channel-info` + - verification: `permissions` (only with encryption enabled + gate enabled) + +### 10.2 Action semantics + +- Send/edit/delete/read messages behavior parity: + - edit uses `m.replace` + `m.new_content` conventions. + - read uses `/rooms/{room}/messages` with before/after pagination tokens. +- Reaction semantics parity: + - list aggregates count per emoji and unique users. + - remove only current-user reactions (optional emoji filter). +- Pin semantics parity: + - state event `m.room.pinned_events` update/read. + - list includes resolvable summarized events. +- Member info semantics parity: + - profile display name/avatar available, + - membership/power currently returned as `null` placeholders. +- Room info semantics parity: + - includes name/topic/canonicalAlias/memberCount where retrievable. +- Verification semantics parity: + - status/list/request/accept/cancel/start/generate-qr/scan-qr/sas/confirm/mismatch/confirm-qr flows. + +## 11. Directory + Target Resolution (MUST) + +### 11.1 Live directory + +- Peer lookup uses Matrix user directory search endpoint. +- Group lookup behavior: + - alias input (`#...`) resolves via directory API, + - room ID input (`!...`) is accepted directly, + - otherwise scans joined rooms by room name. + +### 11.2 Resolver behavior + +- User resolver rules: + - full user IDs resolve directly, + - otherwise requires exact unique match from live directory. +- Group resolver rules: + - prefers exact match; otherwise first candidate with note. +- Room config key normalization behavior: + - supports `matrix:`/`room:`/`channel:` prefixes and canonical IDs. + +## 12. Status + Probing (MUST) + +- Probe behavior (`matrix/probe.ts`): + - validates homeserver + token, + - initializes client, + - resolves user via client and returns elapsed time/status. +- Channel status snapshot includes: + - configured/baseUrl/running/last start-stop/error/probe/last probe/inbound/outbound fields. + +## 13. Storage + Security + E2EE (MUST) + +### 13.1 Credential/state paths + +- Credentials persisted in state dir under `credentials/matrix`. +- Per-account credential filename semantics preserved. +- Matrix storage paths include account key + homeserver key + user key + token hash. +- Legacy storage migration behavior preserved. + +### 13.2 HTTP hardening + +- Matrix HTTP client behavior parity: + - blocks unexpected absolute endpoints, + - blocks cross-protocol redirects, + - strips auth headers on cross-origin redirect, + - supports request timeout. + +### 13.3 Encryption + +- Rust crypto initialization and bootstrap behavior preserved. +- Decryption bridge behavior preserved: + - encrypted event handling, + - failed decrypt retries, + - retry caps and signal-driven retry. +- Recovery key behavior preserved: + - persisted securely (0600), + - reused for secret storage callbacks, + - handles default key rebind and recreation when needed. + +## 14. Onboarding UX Contract (SHOULD) + +Legacy onboarding (`src/onboarding.ts`) should remain equivalent: + +- checks matrix SDK availability and offers install flow, +- supports env-detected quick setup, +- supports token/password/register auth choice, +- validates homeserver URL and user ID format, +- supports DM policy and allowFrom prompt with user resolution, +- supports optional group policy and group room selection. + +## 15. Known Legacy Quirks To Track (NEEDS UPDATING) + +These should be explicitly reviewed during parity auditing (either preserve intentionally or fix intentionally): + +- `supportsAction`/`poll` behavior in action adapter is non-obvious and should be validated end-to-end. +- Some account-aware callsites pass `accountId` through paths where underlying helpers may not consistently consume it. +- Legacy room/member info actions include placeholder/null fields (`altAliases`, `membership`, `powerLevel`). + +## 16. Parity Test Matrix + +Use this checklist while validating `extensions/matrix-js`: + +- [ ] Config schema keys and defaults are equivalent. +- [ ] Auth precedence (config/env/token/cache/password/register) matches legacy. +- [ ] Bun runtime rejection behavior matches legacy. +- [ ] Startup/shutdown lifecycle and status updates match legacy. +- [ ] DM detection heuristics match legacy. +- [ ] DM/group allowlist + pairing flow matches legacy. +- [ ] Mention detection (`m.mentions`, formatted_body links, regex) matches legacy. +- [ ] Control-command authorization gate behavior matches legacy. +- [ ] Inbound poll normalization matches legacy. +- [ ] Inbound location normalization matches legacy. +- [ ] Inbound media download/decrypt/size-limit behavior matches legacy. +- [ ] Reply dispatch + typing + ack reaction + read receipts match legacy. +- [ ] Thread handling (`threadReplies`) matches legacy. +- [ ] `replyToMode` handling for single/multi reply flows matches legacy. +- [ ] Outbound text chunking, markdown, and formatting behavior matches legacy. +- [ ] Outbound media encryption/voice/thumbnail/duration behavior matches legacy. +- [ ] Outbound poll payload behavior matches legacy. +- [ ] Action gating and action semantics match legacy. +- [ ] Verification action flow and summary semantics match legacy. +- [ ] Directory live lookup + target resolution ambiguity handling matches legacy. +- [ ] Probe/status reporting fields match legacy. +- [ ] Storage layout and credential persistence semantics match legacy. +- [ ] HTTP hardening and decrypt retry behavior matches legacy. + +## 17. Minimum Regression Commands + +Run at least: + +```bash +pnpm vitest extensions/matrix/src/**/*.test.ts +pnpm vitest extensions/matrix-js/src/**/*.test.ts +pnpm build +``` + +If behavior differs intentionally, document the delta under this spec with: + +- reason, +- user impact, +- migration note, +- tests proving new intended behavior. diff --git a/extensions/matrix-js/scripts/live-basic-send.ts b/extensions/matrix-js/scripts/live-basic-send.ts new file mode 100644 index 00000000000..65230c642b3 --- /dev/null +++ b/extensions/matrix-js/scripts/live-basic-send.ts @@ -0,0 +1,104 @@ +import { sendMatrixMessage } from "../src/matrix/actions.js"; +import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js"; +import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js"; + +async function main() { + const base = resolveLiveHarnessConfig(); + const pluginCfg = installLiveHarnessRuntime(base); + + const auth = await resolveMatrixAuth({ cfg: pluginCfg as never }); + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + password: auth.password, + deviceId: auth.deviceId, + encryption: false, + }); + + const targetUserId = process.argv[2]?.trim() || "@gumadeiras:matrix.gumadeiras.com"; + const stamp = new Date().toISOString(); + + try { + const dmRoomCreate = (await client.doRequest( + "POST", + "/_matrix/client/v3/createRoom", + undefined, + { + is_direct: true, + invite: [targetUserId], + preset: "trusted_private_chat", + name: `OpenClaw DM Test ${stamp}`, + topic: "matrix-js basic DM messaging test", + }, + )) as { room_id?: string }; + + const dmRoomId = dmRoomCreate.room_id?.trim() ?? ""; + if (!dmRoomId) { + throw new Error("Failed to create DM room"); + } + + const currentDirect = ((await client.getAccountData("m.direct").catch(() => ({}))) ?? + {}) as Record; + const existing = Array.isArray(currentDirect[targetUserId]) ? currentDirect[targetUserId] : []; + await client.setAccountData("m.direct", { + ...currentDirect, + [targetUserId]: [dmRoomId, ...existing.filter((id) => id !== dmRoomId)], + }); + + const dmByUserTarget = await sendMatrixMessage( + targetUserId, + `Matrix-js basic DM test (user target) ${stamp}`, + { client }, + ); + const dmByRoomTarget = await sendMatrixMessage( + dmRoomId, + `Matrix-js basic DM test (room target) ${stamp}`, + { client }, + ); + + const roomCreate = (await client.doRequest("POST", "/_matrix/client/v3/createRoom", undefined, { + invite: [targetUserId], + preset: "private_chat", + name: `OpenClaw Room Test ${stamp}`, + topic: "matrix-js basic room messaging test", + })) as { room_id?: string }; + + const roomId = roomCreate.room_id?.trim() ?? ""; + if (!roomId) { + throw new Error("Failed to create room chat room"); + } + + const roomSend = await sendMatrixMessage(roomId, `Matrix-js basic room test ${stamp}`, { + client, + }); + + process.stdout.write( + `${JSON.stringify( + { + homeserver: base.homeserver, + senderUserId: base.userId, + targetUserId, + dm: { + roomId: dmRoomId, + userTargetMessageId: dmByUserTarget.messageId, + roomTargetMessageId: dmByRoomTarget.messageId, + }, + room: { + roomId, + messageId: roomSend.messageId, + }, + }, + null, + 2, + )}\n`, + ); + } finally { + client.stop(); + } +} + +main().catch((err) => { + process.stderr.write(`BASIC_SEND_ERROR: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); +}); diff --git a/extensions/matrix-js/scripts/live-common.ts b/extensions/matrix-js/scripts/live-common.ts new file mode 100644 index 00000000000..6c9e9cf7dcf --- /dev/null +++ b/extensions/matrix-js/scripts/live-common.ts @@ -0,0 +1,145 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { setMatrixRuntime } from "../src/runtime.js"; + +type EnvMap = Record; + +function loadEnvFile(filePath: string): EnvMap { + const out: EnvMap = {}; + if (!fs.existsSync(filePath)) { + return out; + } + const raw = fs.readFileSync(filePath, "utf8"); + for (const lineRaw of raw.split(/\r?\n/)) { + const line = lineRaw.trim(); + if (!line || line.startsWith("#")) { + continue; + } + const idx = line.indexOf("="); + if (idx <= 0) { + continue; + } + const key = line.slice(0, idx).trim(); + let value = line.slice(idx + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + out[key] = value; + } + return out; +} + +function normalizeHomeserver(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return ""; + } + return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; +} + +function chunkText(text: string, limit: number): string[] { + if (!text) { + return []; + } + if (text.length <= limit) { + return [text]; + } + const out: string[] = []; + for (let i = 0; i < text.length; i += limit) { + out.push(text.slice(i, i + limit)); + } + return out; +} + +export type LiveHarnessConfig = { + homeserver: string; + userId: string; + password: string; +}; + +export function resolveLiveHarnessConfig(): LiveHarnessConfig { + const envFromFile = loadEnvFile(path.join(os.homedir(), ".openclaw", ".env")); + const homeserver = normalizeHomeserver( + process.env.MATRIX_HOMESERVER ?? envFromFile.MATRIX_HOMESERVER ?? "", + ); + const userId = process.env.MATRIX_USER_ID ?? envFromFile.MATRIX_USER_ID ?? ""; + const password = process.env.MATRIX_PASSWORD ?? envFromFile.MATRIX_PASSWORD ?? ""; + + if (!homeserver || !userId || !password) { + throw new Error("Missing MATRIX_HOMESERVER / MATRIX_USER_ID / MATRIX_PASSWORD"); + } + + return { + homeserver, + userId, + password, + }; +} + +export function installLiveHarnessRuntime(cfg: LiveHarnessConfig): { + channels: { + "matrix-js": { + homeserver: string; + userId: string; + password: string; + encryption: false; + }; + }; +} { + const pluginCfg = { + channels: { + "matrix-js": { + homeserver: cfg.homeserver, + userId: cfg.userId, + password: cfg.password, + encryption: false as const, + }, + }, + }; + + setMatrixRuntime({ + config: { + loadConfig: () => pluginCfg, + }, + state: { + resolveStateDir: () => path.join(os.homedir(), ".openclaw", "matrix-js-live-harness-state"), + }, + channel: { + text: { + resolveMarkdownTableMode: () => "off", + convertMarkdownTables: (text: string) => text, + resolveTextChunkLimit: () => 4000, + resolveChunkMode: () => "off", + chunkMarkdownTextWithMode: (text: string, limit: number) => chunkText(text, limit), + }, + }, + media: { + mediaKindFromMime: (mime: string) => { + const value = (mime || "").toLowerCase(); + if (value.startsWith("image/")) { + return "image"; + } + if (value.startsWith("audio/")) { + return "audio"; + } + if (value.startsWith("video/")) { + return "video"; + } + return "document"; + }, + isVoiceCompatibleAudio: () => false, + loadWebMedia: async () => ({ + buffer: Buffer.from("matrix-js harness media payload\n", "utf8"), + contentType: "text/plain", + fileName: "matrix-js-harness.txt", + kind: "document" as const, + }), + }, + } as never); + + return pluginCfg; +} diff --git a/extensions/matrix-js/scripts/live-cross-signing-probe.ts b/extensions/matrix-js/scripts/live-cross-signing-probe.ts new file mode 100644 index 00000000000..65095f37f53 --- /dev/null +++ b/extensions/matrix-js/scripts/live-cross-signing-probe.ts @@ -0,0 +1,126 @@ +import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js"; +import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js"; + +type MatrixCryptoProbe = { + isCrossSigningReady?: () => Promise; + userHasCrossSigningKeys?: (userId?: string, downloadUncached?: boolean) => Promise; + bootstrapCrossSigning?: (opts: { + setupNewCrossSigning?: boolean; + authUploadDeviceSigningKeys?: ( + makeRequest: (authData: Record | null) => Promise, + ) => Promise; + }) => Promise; +}; + +async function main() { + const base = resolveLiveHarnessConfig(); + const cfg = installLiveHarnessRuntime(base); + (cfg.channels["matrix-js"] as { encryption: boolean }).encryption = true; + + const auth = await resolveMatrixAuth({ cfg: cfg as never }); + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + password: auth.password, + deviceId: auth.deviceId, + encryption: true, + }); + const initCrypto = (client as unknown as { initializeCryptoIfNeeded?: () => Promise }) + .initializeCryptoIfNeeded; + if (typeof initCrypto === "function") { + await initCrypto.call(client); + } + + const inner = (client as unknown as { client?: { getCrypto?: () => unknown } }).client; + const crypto = (inner?.getCrypto?.() ?? null) as MatrixCryptoProbe | null; + const userId = auth.userId; + const password = auth.password; + + const out: Record = { + userId, + hasCrypto: Boolean(crypto), + readyBefore: null, + hasKeysBefore: null, + bootstrap: "skipped", + readyAfter: null, + hasKeysAfter: null, + queryHasMaster: null, + queryHasSelfSigning: null, + queryHasUserSigning: null, + }; + + if (!crypto || !crypto.bootstrapCrossSigning) { + process.stdout.write(`${JSON.stringify(out, null, 2)}\n`); + return; + } + + if (typeof crypto.isCrossSigningReady === "function") { + out.readyBefore = await crypto.isCrossSigningReady().catch((err) => `error:${String(err)}`); + } + if (typeof crypto.userHasCrossSigningKeys === "function") { + out.hasKeysBefore = await crypto + .userHasCrossSigningKeys(userId, true) + .catch((err) => `error:${String(err)}`); + } + + const authUploadDeviceSigningKeys = async ( + makeRequest: (authData: Record | null) => Promise, + ): Promise => { + try { + return await makeRequest(null); + } catch { + try { + return await makeRequest({ type: "m.login.dummy" }); + } catch { + if (!password?.trim()) { + throw new Error("Missing password for m.login.password fallback"); + } + return await makeRequest({ + type: "m.login.password", + identifier: { type: "m.id.user", user: userId }, + password, + }); + } + } + }; + + try { + await crypto.bootstrapCrossSigning({ authUploadDeviceSigningKeys }); + out.bootstrap = "ok"; + } catch (err) { + out.bootstrap = "error"; + out.bootstrapError = err instanceof Error ? err.message : String(err); + } + + if (typeof crypto.isCrossSigningReady === "function") { + out.readyAfter = await crypto.isCrossSigningReady().catch((err) => `error:${String(err)}`); + } + if (typeof crypto.userHasCrossSigningKeys === "function") { + out.hasKeysAfter = await crypto + .userHasCrossSigningKeys(userId, true) + .catch((err) => `error:${String(err)}`); + } + + const query = (await client.doRequest("POST", "/_matrix/client/v3/keys/query", undefined, { + device_keys: { [userId]: [] }, + })) as { + master_keys?: Record; + self_signing_keys?: Record; + user_signing_keys?: Record; + }; + + out.queryHasMaster = Boolean(query.master_keys?.[userId]); + out.queryHasSelfSigning = Boolean(query.self_signing_keys?.[userId]); + out.queryHasUserSigning = Boolean(query.user_signing_keys?.[userId]); + + process.stdout.write(`${JSON.stringify(out, null, 2)}\n`); + client.stop(); +} + +main().catch((err) => { + process.stderr.write( + `CROSS_SIGNING_PROBE_ERROR: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); +}); diff --git a/extensions/matrix-js/scripts/live-e2ee-bootstrap.ts b/extensions/matrix-js/scripts/live-e2ee-bootstrap.ts new file mode 100644 index 00000000000..c52e7f922fa --- /dev/null +++ b/extensions/matrix-js/scripts/live-e2ee-bootstrap.ts @@ -0,0 +1,28 @@ +import { bootstrapMatrixVerification } from "../src/matrix/actions/verification.js"; +import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js"; + +async function main() { + const recoveryKeyArg = process.argv[2]; + const forceResetCrossSigning = process.argv.includes("--force-reset-cross-signing"); + + const base = resolveLiveHarnessConfig(); + const pluginCfg = installLiveHarnessRuntime(base); + (pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true; + + const result = await bootstrapMatrixVerification({ + recoveryKey: recoveryKeyArg?.trim() || undefined, + forceResetCrossSigning, + }); + + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + if (!result.success) { + process.exitCode = 1; + } +} + +main().catch((err) => { + process.stderr.write( + `E2EE_BOOTSTRAP_ERROR: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); +}); diff --git a/extensions/matrix-js/scripts/live-e2ee-room-state.ts b/extensions/matrix-js/scripts/live-e2ee-room-state.ts new file mode 100644 index 00000000000..af71d30be77 --- /dev/null +++ b/extensions/matrix-js/scripts/live-e2ee-room-state.ts @@ -0,0 +1,65 @@ +import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js"; +import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js"; + +async function main() { + const roomId = process.argv[2]?.trim(); + const eventId = process.argv[3]?.trim(); + + if (!roomId) { + throw new Error( + "Usage: node --import tsx extensions/matrix-js/scripts/live-e2ee-room-state.ts [eventId]", + ); + } + + const base = resolveLiveHarnessConfig(); + const pluginCfg = installLiveHarnessRuntime(base); + (pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true; + + const auth = await resolveMatrixAuth({ cfg: pluginCfg as never }); + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + password: auth.password, + deviceId: auth.deviceId, + encryption: false, + }); + + try { + const encryptionState = (await client.doRequest( + "GET", + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.encryption/`, + )) as { algorithm?: string; rotation_period_ms?: number; rotation_period_msgs?: number }; + + let eventType: string | null = null; + if (eventId) { + const event = (await client.doRequest( + "GET", + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`, + )) as { type?: string }; + eventType = event.type ?? null; + } + + process.stdout.write( + `${JSON.stringify( + { + roomId, + encryptionState, + eventId: eventId ?? null, + eventType, + }, + null, + 2, + )}\n`, + ); + } finally { + client.stop(); + } +} + +main().catch((err) => { + process.stderr.write( + `E2EE_ROOM_STATE_ERROR: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); +}); diff --git a/extensions/matrix-js/scripts/live-e2ee-send-room.ts b/extensions/matrix-js/scripts/live-e2ee-send-room.ts new file mode 100644 index 00000000000..f11510da077 --- /dev/null +++ b/extensions/matrix-js/scripts/live-e2ee-send-room.ts @@ -0,0 +1,99 @@ +import { sendMatrixMessage } from "../src/matrix/actions.js"; +import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js"; +import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js"; + +async function delay(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function main() { + const roomId = process.argv[2]?.trim(); + const useFullBootstrap = process.argv.includes("--full-bootstrap"); + const startupTimeoutMs = 45_000; + const settleMsRaw = Number.parseInt(process.argv[3] ?? "4000", 10); + const settleMs = Number.isFinite(settleMsRaw) && settleMsRaw >= 0 ? settleMsRaw : 4000; + + if (!roomId) { + throw new Error( + "Usage: node --import tsx extensions/matrix-js/scripts/live-e2ee-send-room.ts [settleMs] [--full-bootstrap]", + ); + } + + const base = resolveLiveHarnessConfig(); + const pluginCfg = installLiveHarnessRuntime(base); + (pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true; + + const auth = await resolveMatrixAuth({ cfg: pluginCfg as never }); + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + password: auth.password, + deviceId: auth.deviceId, + encryption: true, + }); + + const stamp = new Date().toISOString(); + + try { + if (!useFullBootstrap) { + const bootstrapper = ( + client as unknown as { cryptoBootstrapper?: { bootstrap?: () => Promise } } + ).cryptoBootstrapper; + if (bootstrapper?.bootstrap) { + bootstrapper.bootstrap = async () => {}; + } + } + + await Promise.race([ + client.start(), + new Promise((_, reject) => { + setTimeout(() => { + reject( + new Error( + `Matrix client start timed out after ${startupTimeoutMs}ms (fullBootstrap=${useFullBootstrap})`, + ), + ); + }, startupTimeoutMs); + }), + ]); + + if (settleMs > 0) { + await delay(settleMs); + } + + const sent = await sendMatrixMessage( + roomId, + `Matrix-js E2EE existing-room test ${stamp} (settleMs=${settleMs})`, + { client }, + ); + + const event = (await client.doRequest( + "GET", + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(sent.messageId)}`, + )) as { type?: string }; + + process.stdout.write( + `${JSON.stringify( + { + roomId, + messageId: sent.messageId, + storedEventType: event.type ?? null, + fullBootstrap: useFullBootstrap, + settleMs, + }, + null, + 2, + )}\n`, + ); + } finally { + client.stop(); + } +} + +main().catch((err) => { + process.stderr.write( + `E2EE_SEND_ROOM_ERROR: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); +}); diff --git a/extensions/matrix-js/scripts/live-e2ee-send.ts b/extensions/matrix-js/scripts/live-e2ee-send.ts new file mode 100644 index 00000000000..ec8494d4296 --- /dev/null +++ b/extensions/matrix-js/scripts/live-e2ee-send.ts @@ -0,0 +1,169 @@ +import { sendMatrixMessage } from "../src/matrix/actions.js"; +import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js"; +import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js"; + +const MEGOLM_ALG = "m.megolm.v1.aes-sha2"; + +type MatrixEventLike = { + type?: string; +}; + +async function main() { + const targetUserId = process.argv[2]?.trim() || "@gumadeiras:matrix.gumadeiras.com"; + const useFullBootstrap = process.argv.includes("--full-bootstrap"); + const startupTimeoutMs = 45_000; + const base = resolveLiveHarnessConfig(); + const pluginCfg = installLiveHarnessRuntime(base); + + // Enable encryption for this run only. + (pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true; + + const auth = await resolveMatrixAuth({ cfg: pluginCfg as never }); + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + password: auth.password, + deviceId: auth.deviceId, + encryption: true, + }); + + const stamp = new Date().toISOString(); + + try { + if (!useFullBootstrap) { + const bootstrapper = ( + client as unknown as { cryptoBootstrapper?: { bootstrap?: () => Promise } } + ).cryptoBootstrapper; + if (bootstrapper?.bootstrap) { + bootstrapper.bootstrap = async () => {}; + } + } + + await Promise.race([ + client.start(), + new Promise((_, reject) => { + setTimeout(() => { + reject( + new Error( + `Matrix client start timed out after ${startupTimeoutMs}ms (fullBootstrap=${useFullBootstrap})`, + ), + ); + }, startupTimeoutMs); + }), + ]); + + const dmRoomCreate = (await client.doRequest( + "POST", + "/_matrix/client/v3/createRoom", + undefined, + { + is_direct: true, + invite: [targetUserId], + preset: "trusted_private_chat", + name: `OpenClaw E2EE DM ${stamp}`, + topic: "matrix-js E2EE DM test", + initial_state: [ + { + type: "m.room.encryption", + state_key: "", + content: { + algorithm: MEGOLM_ALG, + }, + }, + ], + }, + )) as { room_id?: string }; + + const dmRoomId = dmRoomCreate.room_id?.trim() ?? ""; + if (!dmRoomId) { + throw new Error("Failed to create encrypted DM room"); + } + + const currentDirect = ((await client.getAccountData("m.direct").catch(() => ({}))) ?? + {}) as Record; + const existing = Array.isArray(currentDirect[targetUserId]) ? currentDirect[targetUserId] : []; + await client.setAccountData("m.direct", { + ...currentDirect, + [targetUserId]: [dmRoomId, ...existing.filter((id) => id !== dmRoomId)], + }); + + const dmSend = await sendMatrixMessage( + dmRoomId, + `Matrix-js E2EE DM test ${stamp}\nPlease reply here so I can validate decrypt/read.`, + { + client, + }, + ); + + const roomCreate = (await client.doRequest("POST", "/_matrix/client/v3/createRoom", undefined, { + invite: [targetUserId], + preset: "private_chat", + name: `OpenClaw E2EE Room ${stamp}`, + topic: "matrix-js E2EE room test", + initial_state: [ + { + type: "m.room.encryption", + state_key: "", + content: { + algorithm: MEGOLM_ALG, + }, + }, + ], + })) as { room_id?: string }; + + const roomId = roomCreate.room_id?.trim() ?? ""; + if (!roomId) { + throw new Error("Failed to create encrypted room chat"); + } + + const roomSend = await sendMatrixMessage( + roomId, + `Matrix-js E2EE room test ${stamp}\nPlease reply here too.`, + { + client, + }, + ); + + const dmRaw = (await client.doRequest( + "GET", + `/_matrix/client/v3/rooms/${encodeURIComponent(dmRoomId)}/event/${encodeURIComponent(dmSend.messageId)}`, + )) as MatrixEventLike; + + const roomRaw = (await client.doRequest( + "GET", + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(roomSend.messageId)}`, + )) as MatrixEventLike; + + process.stdout.write( + `${JSON.stringify( + { + homeserver: base.homeserver, + senderUserId: base.userId, + targetUserId, + encryptionAlgorithm: MEGOLM_ALG, + fullBootstrap: useFullBootstrap, + dm: { + roomId: dmRoomId, + messageId: dmSend.messageId, + storedEventType: dmRaw.type ?? null, + }, + room: { + roomId, + messageId: roomSend.messageId, + storedEventType: roomRaw.type ?? null, + }, + }, + null, + 2, + )}\n`, + ); + } finally { + client.stop(); + } +} + +main().catch((err) => { + process.stderr.write(`E2EE_SEND_ERROR: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); +}); diff --git a/extensions/matrix-js/scripts/live-e2ee-status.ts b/extensions/matrix-js/scripts/live-e2ee-status.ts new file mode 100644 index 00000000000..0096da3b78b --- /dev/null +++ b/extensions/matrix-js/scripts/live-e2ee-status.ts @@ -0,0 +1,57 @@ +import { + getMatrixEncryptionStatus, + getMatrixVerificationStatus, + verifyMatrixRecoveryKey, +} from "../src/matrix/actions.js"; +import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js"; + +async function main() { + const includeRecoveryKey = process.argv.includes("--include-recovery-key"); + const verifyStoredRecoveryKey = process.argv.includes("--verify-stored-recovery-key"); + + const base = resolveLiveHarnessConfig(); + const pluginCfg = installLiveHarnessRuntime(base); + (pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true; + + const verification = await getMatrixVerificationStatus({ + includeRecoveryKey, + }); + const encryption = await getMatrixEncryptionStatus({ + includeRecoveryKey, + }); + + let recoveryVerificationResult: unknown = null; + if (verifyStoredRecoveryKey) { + const key = + verification && typeof verification === "object" && "recoveryKey" in verification + ? (verification as { recoveryKey?: string | null }).recoveryKey + : null; + if (key?.trim()) { + recoveryVerificationResult = await verifyMatrixRecoveryKey(key); + } else { + recoveryVerificationResult = { + success: false, + error: "No stored recovery key returned (use --include-recovery-key)", + }; + } + } + + process.stdout.write( + `${JSON.stringify( + { + homeserver: base.homeserver, + userId: base.userId, + verification, + encryption, + recoveryVerificationResult, + }, + null, + 2, + )}\n`, + ); +} + +main().catch((err) => { + process.stderr.write(`E2EE_STATUS_ERROR: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); +}); diff --git a/extensions/matrix-js/scripts/live-e2ee-wait-reply.ts b/extensions/matrix-js/scripts/live-e2ee-wait-reply.ts new file mode 100644 index 00000000000..8fa814d2fce --- /dev/null +++ b/extensions/matrix-js/scripts/live-e2ee-wait-reply.ts @@ -0,0 +1,122 @@ +import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js"; +import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js"; + +type MatrixRawEvent = { + event_id?: string; + type?: string; + sender?: string; + room_id?: string; + origin_server_ts?: number; + content?: { + body?: string; + msgtype?: string; + }; +}; + +async function main() { + const roomId = process.argv[2]?.trim(); + const targetUserId = process.argv[3]?.trim() || "@gumadeiras:matrix.gumadeiras.com"; + const timeoutSecRaw = Number.parseInt(process.argv[4] ?? "120", 10); + const timeoutMs = + (Number.isFinite(timeoutSecRaw) && timeoutSecRaw > 0 ? timeoutSecRaw : 120) * 1000; + const useFullBootstrap = process.argv.includes("--full-bootstrap"); + const startupTimeoutMs = 45_000; + + if (!roomId) { + throw new Error( + "Usage: node --import tsx extensions/matrix-js/scripts/live-e2ee-wait-reply.ts [targetUserId] [timeoutSec] [--full-bootstrap]", + ); + } + + const base = resolveLiveHarnessConfig(); + const pluginCfg = installLiveHarnessRuntime(base); + (pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true; + + const auth = await resolveMatrixAuth({ cfg: pluginCfg as never }); + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + password: auth.password, + deviceId: auth.deviceId, + encryption: true, + }); + + try { + if (!useFullBootstrap) { + const bootstrapper = ( + client as unknown as { cryptoBootstrapper?: { bootstrap?: () => Promise } } + ).cryptoBootstrapper; + if (bootstrapper?.bootstrap) { + bootstrapper.bootstrap = async () => {}; + } + } + + await Promise.race([ + client.start(), + new Promise((_, reject) => { + setTimeout(() => { + reject( + new Error( + `Matrix client start timed out after ${startupTimeoutMs}ms (fullBootstrap=${useFullBootstrap})`, + ), + ); + }, startupTimeoutMs); + }), + ]); + + const found = await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(null); + }, timeoutMs); + + client.on("room.message", (eventRoomId, event) => { + const rid = String(eventRoomId || ""); + const raw = event as MatrixRawEvent; + if (rid !== roomId) { + return; + } + if ((raw.sender ?? "").trim() !== targetUserId) { + return; + } + if ((raw.type ?? "").trim() !== "m.room.message") { + return; + } + clearTimeout(timer); + resolve(raw); + }); + }); + + process.stdout.write( + `${JSON.stringify( + { + roomId, + targetUserId, + timeoutMs, + found: Boolean(found), + message: found + ? { + eventId: found.event_id ?? null, + type: found.type ?? null, + sender: found.sender ?? null, + timestamp: found.origin_server_ts ?? null, + text: found.content?.body ?? null, + msgtype: found.content?.msgtype ?? null, + } + : null, + }, + null, + 2, + )}\n`, + ); + } finally { + client.stop(); + } +} + +main().catch((err) => { + process.stderr.write( + `E2EE_WAIT_REPLY_ERROR: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); +}); diff --git a/extensions/matrix-js/scripts/live-read-room.ts b/extensions/matrix-js/scripts/live-read-room.ts new file mode 100644 index 00000000000..23612895aaa --- /dev/null +++ b/extensions/matrix-js/scripts/live-read-room.ts @@ -0,0 +1,64 @@ +import { readMatrixMessages } from "../src/matrix/actions.js"; +import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js"; +import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js"; + +function toMessageText(msg: { + text: string | null; + body?: string | null; + fallbackBody?: string | null; +}): string { + return msg.text ?? msg.body ?? msg.fallbackBody ?? ""; +} + +async function main() { + const roomId = process.argv[2]?.trim(); + if (!roomId) { + throw new Error("Usage: bun extensions/matrix-js/scripts/live-read-room.ts [limit]"); + } + + const requestedLimit = Number.parseInt(process.argv[3] ?? "30", 10); + const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? requestedLimit : 30; + + const base = resolveLiveHarnessConfig(); + const pluginCfg = installLiveHarnessRuntime(base); + const auth = await resolveMatrixAuth({ cfg: pluginCfg as never }); + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + password: auth.password, + deviceId: auth.deviceId, + encryption: false, + }); + + try { + const result = await readMatrixMessages(roomId, { client, limit }); + const compact = result.messages.map((msg) => ({ + id: msg.id, + sender: msg.sender, + ts: msg.timestamp, + text: toMessageText(msg), + })); + + process.stdout.write( + `${JSON.stringify( + { + roomId, + count: compact.length, + messages: compact, + nextBatch: result.nextBatch ?? null, + prevBatch: result.prevBatch ?? null, + }, + null, + 2, + )}\n`, + ); + } finally { + client.stop(); + } +} + +main().catch((err) => { + process.stderr.write(`READ_ROOM_ERROR: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); +});