feat(channels): add Matrix as a first-class chat channel (with E2EE)#293
feat(channels): add Matrix as a first-class chat channel (with E2EE)#293anconina wants to merge 220 commits into
Conversation
- Add matrix-js-sdk@41.8.0 (exact pin, no caret) to @comis/channels, the workspace root, and the umbrella comis package. - The npm latest dist-tag currently points at a 41.9.0 release candidate; the stable 41.8.0 line is pinned instead. - Regenerate pnpm-lock.yaml; engines (node >=22) are compatible.
- Assert MatrixChannelEntrySchema defaults (disabled, e2ee on, allowlist mode, autoJoinOnInvite, private-homeserver off, empty allowFrom), full-block parsing, SecretRef credential fields, and strict-object rejection of unknown keys. - Assert the matrix entry is registered on ChannelConfigSchema with the same defaults and nested strict rejection. - Fails on pre-patch code: the schema and its registration do not yet exist.
- Add MatrixChannelEntrySchema (strict object) with token/password/ recovery-key credential fields as SecretRef-or-string, MXID-keyed allowFrom trust, allowMode, autoJoinOnInvite, allowPrivateHomeserver, e2ee, stateDir and per-channel media processing. - Register it on ChannelConfigSchema and export the inferred type. - Regenerate the section-registry parity snapshot (adds the channels.matrix.* rows; additive only).
- Assert a stranded MATRIX_ACCESS_TOKEN env var yields a single KNOWN_PROVIDER_ENV finding named with the matrix provider, not the generic unknown fallthrough. - Fails on pre-patch code: MATRIX_ACCESS_TOKEN matches only the generic _TOKEN catch-all today.
- Add a MATRIX_ACCESS_TOKEN provider pattern to the secrets-audit env scanner, placed before the generic _TOKEN catch-all so first-match wins and a stranded token is flagged with the matrix provider and an actionable finding.
- classifyMatrixError row-by-row: M_UNKNOWN_TOKEN→auth, M_LIMIT_EXCEEDED/429→platform+backoff, M_FORBIDDEN/403→auth, 5xx→platform, absent→internal - errcode arm precedence over HTTP status - secret in cause never leaks into hint
- classifyMatrixError maps M_* errcodes + HTTP status to the closed errorKind union, a retry disposition, and an operator-actionable hint - errcode arm evaluated before status; M_UNKNOWN_TOKEN→auth (token-expiry recovery keys on this), M_LIMIT_EXCEEDED/429→platform retryable, M_FORBIDDEN/403→auth, 5xx→platform, no-signal→internal - M_UNKNOWN_TOKEN hint names channels.matrix.accessToken - void cause guard: a secret-bearing cause is never rendered into the hint - MatrixErrorKind typed via Extract<ErrorKind, ...> for compiler-checked membership in the closed union
- Row-by-row cover of the invite decision: auto-join off never joins (any mode); allowlist joins iff the inviter MXID is listed; an empty allowlist joins nothing (default-closed); open mode joins any invite. - Assert the decision keys on the full inviter MXID, never a display-name-like value or a same-localpart different-homeserver MXID.
- decideInvite({ autoJoinOnInvite, allowMode, allowFrom, inviterMxid })
-> 'join' | 'ignore'; pure, no SDK import, deterministic.
- Default-closed: auto-join off never joins; allowlist joins iff the
inviter's full MXID is listed (empty list joins nothing); open joins any.
- Trust keys on the exact full inviter MXID, never a display name.
- Row-by-row cover of the three gates: drop pre-sync-ready backlog, drop toStartOfTimeline backfill, drop non-message types, and drop any event whose timestamp is not strictly past the persisted watermark. - Assert a live post-watermark message is delivered, plus an explicit forced-re-initial-sync case: replayed old timestamps stay guarded while a genuinely newer event passes.
- shouldDeliverTimelineEvent({ syncReady, toStartOfTimeline, eventType,
eventTs, watermark }) -> boolean; pure, no SDK import, deterministic.
- Delivers only when all three gates pass: sync-ready, live (not
toStartOfTimeline), an m.room.message, and strictly newer than the
persisted watermark — so backlog, backfill, and replayed stale events
are dropped and a forced re-initial-sync stays guarded.
…matrix - validateHomeserverUrl: public delegates ok, private/loopback blocked by default, opt-in permits private with a loud config-posture warn, cloud-metadata ALWAYS blocked even with the opt-in on, non-http(s) rejected - validateMatrixCredentials: names missing homeserverUrl/credential/userId fields and never echoes a secret value - fails pre-patch: the credential-validator module does not yet exist (RED before GREEN)
- validateHomeserverUrl delegates to the audited guard by default; the allowPrivateHomeserver opt-in relaxes ONLY the private/loopback range, still requires http(s), and STILL denies cloud-metadata by reusing the exported blocklist (no metadata SSRF hole on a self-hosted opt-in) - relaxing the range check logs a loud config-posture warning naming the knob - validateMatrixCredentials names any missing homeserverUrl/credential/userId field and never echoes a token or password value
- renderMarkdownToMatrixHtml: bold/italic/code/fenced/link/list/quote/heading plus plaintext-fallback body and outbound HTML-escape + dangerous-scheme guard - sanitizeInboundHtml: strips script/style/iframe with contents, on* handlers, non-http(s)/mxc schemes, and non-allowlisted tag markup; preserves the safe subset
- renderMarkdownToMatrixHtml renders bold/italic/inline+fenced code, safe-scheme links, ordered/unordered lists, blockquotes and headings to org.matrix.custom.html; body carries the plaintext markdown fallback; text nodes are HTML-escaped and non-http(s)/mxc link schemes degrade to plain text - sanitizeInboundHtml removes script/style/iframe/object/embed with contents, strips on* handlers and non-http(s)/mxc href/src schemes, and drops markup for tags outside a safe allowlist (keeping their text); defense-in-depth ahead of the central external-content wrapper
- non-m.room.message maps to null; a text event maps to a schema-valid NormalizedMessage (UUID id, matrix channelType, room id, plaintext body) - senderId is the full MXID from getSender(), spoof-proof against a display name set in content - chatType from the direct-room flag; a fresh UUID per call - inbound formatted_body is sanitized before it reaches metadata; a message with no verifiable sender maps to null
- mapMatrixEventToNormalized returns a NormalizedMessage for m.room.message events and null otherwise; senderId is the full MXID from getSender() (never a display name), and an event with no verifiable sender maps to null - generated UUID id, matrix channelType, room id as channelId, plaintext body as text, systemNowMs timestamp, chatType from the direct-room flag - Matrix event id in metadata.matrixEventId; an inbound formatted_body is run through sanitizeInboundHtml before it is carried into metadata
- /sync resumes from the persisted sync token with an enabled filter - three-gate watermark guard precedes delivery (sync-ready, live, past-watermark) - delivered events advance and persist the watermark - invite gate joins allowlisted inviters and ignores the rest (default-closed) - fails pre-patch: matrix-client module does not exist yet
…ite gate - createMatrixClient composes the pure guards into matrix-js-sdk subscriptions behind an injected client seam (no homeserver needed to test the wiring) - ClientEvent.Sync tracks readiness and persists the advanced batch token - RoomEvent.Timeline runs the three-gate watermark guard before mapping and delivering; delivered events advance and persist the watermark - RoomEvent.MyMembership gates invites on the inviter MXID via decideInvite - /sync resumes from the persisted token via client.store.setSyncToken (41.8.0 has no startClient since-option) plus an enabled lazy-members sync filter - secret-free INFO/DEBUG/ERROR observability on every sync/invite boundary
- mid-run M_UNKNOWN_TOKEN with a re-login seam re-authenticates, persists the fresh token + device id, and resumes syncing - mid-run M_UNKNOWN_TOKEN with no seam emits a loud health signal whose hint names channels.matrix.accessToken and never silently stops - a server-rejected stale since clears the persisted token but retains the watermark so re-entry stays guarded - fails pre-patch: the sync-error recovery branch does not exist yet
…nc token - a sync ERROR carrying M_UNKNOWN_TOKEN re-authenticates via the injected seam when a password login is available, persists the fresh token + device id, and resumes syncing - with no re-login seam it emits a loud health signal and ERROR whose hint names channels.matrix.accessToken, and never silently stops the client - a server-rejected stale since (M_UNKNOWN) clears the persisted sync token but retains the watermark, so the forced re-initial-sync stays backlog-guarded - a reauth-in-flight guard prevents parallel re-logins on repeated sync errors - the token/secret never reaches a log line or the emitted health signal
- re-login yielding no device id still resumes - a failed re-login falls through to the loud dark-token signal - a failed resume after a successful refresh logs an error - an unclassified sync error warns without stopping or signalling
…uilder - matrix-adapter-outbound.test.ts pins the m.text content shape (body + org.matrix.custom.html formatted_body) the pure builder must return - matrix-adapter.test.ts drives a fake matrix-js-sdk client through the ChannelPort lifecycle: homeserver guard before connect, credential precondition, whoami-authenticated start, the MXID speaker gate (default-open when allowFrom is empty; drops a non-allowlisted sender when populated), the m.room.message send shape, and connectionMode polling
Compose the credential/homeserver validation, token/password auth, and the /sync transport into a pull-model ChannelPort: - start() runs the credential precondition and the homeserver SSRF guard before any connection is opened; a blocked homeserver errs without building a client. It then authenticates (whoami-validated) and starts /sync. - inbound: the MXID speaker-trust gate runs on each mapped message before fan-out — default-open when allowFrom is empty (the invite gate is the default-closed boundary), dropping a non-allowlisted sender when the list is populated; both trust decisions key on the full MXID. The request context + traceId are minted here at the ingress boundary. - outbound: sendMessage renders markdown into an m.room.message with a plaintext body and an org.matrix.custom.html formatted_body. - getStatus reports connectionMode polling (stale-exempt). matrix-adapter-outbound.ts is a pure builder over the shared markdown renderer.
Pins channelType matrix, id channel-matrix, the honest capability literals (reactions/edit/delete/fetchHistory/attachments/typing/threads all false, buttons none, maxMessageChars 32768, replyToMetaKey matrixEventId), and that activate()/deactivate() delegate to adapter.start()/stop().
Wrap the Matrix adapter as a ChannelPluginPort. Every capability flag is honest for the plaintext scope — reactions/edit/delete/history/attachments/typing/ threads all false, buttons none — with maxMessageChars 32768 and replyToMetaKey matrixEventId. activate()/deactivate() delegate to the adapter's start()/stop(). Grows the explicit-capabilities roster to 12: the source-walking arch test now finds the matrix own-property literals. The roster edit ships with the plugin it locks so no intermediate commit points the source-walk at a missing file.
…ld count - ChannelConfig is a flat bag whose optional fields are the union of every supported channel's credentials; adding Matrix took it past the audit threshold - acknowledge the width at the declaration, matching the accumulator-bag convention
- the codec and parse helpers throw on absent or malformed media-encryption info and on audited WASM codec failures - every such throw is caught and translated to a Result at the send-path fromPromise boundary, so it never escapes as an unhandled rejection
An encrypted room's raw /messages history yields m.room.encrypted events with no plaintext body, so the body digest never matches — a fully-covered scan then wrongly reports not_sent, which would replay a message that DID land. Assert the reconcile is unresolved (never a false not_sent) for an encrypted room. Fails on the current code (returns not_sent).
A raw /messages read of an encrypted room returns m.room.encrypted events with no plaintext body, so the reconcile body digest can never match and a covered scan wrongly reported not_sent — replaying a message that had already landed and duplicating it. Detect the encrypted room via the authoritative isEncryptionEnabledInRoom (the check the attachment path uses) and return unresolved (park+escalate) before the scan, upholding the port contract that a channel which cannot tell never reports not_sent.
The outward-send ledger digests the agent's raw markdown, but the adapter rewrites mention markup into matrix.to pill links and splits oversized text into multiple events before sending — so a landed send's wire body no longer matches the ledger digest. Assert both a mention-rewritten body and a multi-chunk send that WERE sent reconcile to unresolved, never a false not_sent. Both fail on the current code (return not_sent).
…ruct the sent body The reconcile digest is over the agent's raw markdown, but the wire body is rendered in-adapter (mention markup rewritten to pill links, oversized text split into chunks), so a landed send's body may legitimately fail to match. Reserve not_sent for the reliable case — a covered scan with NO bot-authored in-window event, where the send provably did not land. When the bot authored any in-window event but none matches, return unresolved: one could be that rewritten or chunked send, and a false not_sent would replay it into a duplicate. Updates the two reconcile tests whose in-window-bot-non-match scenario is now unresolved, and adds an explicit no-in-window-bot not_sent case.
…atrix doctor creds check
The creds probe flags an unresolved ${VAR} only on accessToken, so a
password-login or recovery-key ref that fails to resolve slips past the
offline check. Assert both are named as a fail. Fails on the current code
(reports pass).
… in the doctor creds check
The offline creds probe matched an unresolved ${VAR} only on accessToken, so
a password-login or recovery-key ref that failed to resolve passed silently
and only surfaced later at the live reachability probe. Match any of the three
Matrix credential paths (accessToken/password/recoveryKey); the credential
value is still never read — only the ${VAR} name and path appear.
The value-less --matrix-e2ee flag could only be true or absent, so a headless operator had no way to DISABLE the default-on encryption the interactive wizard exposes as a Yes/No toggle. Accept --matrix-e2ee <value> (true or false), coerce and validate the raw Commander string, and reject a non-boolean value early. An omitted flag stays undefined so the daemon default applies. The tests exercise the string-valued flag the pre-patch boolean-only type could not express, so the failing state and the type widening land together.
The Matrix tile requested the logo at fill 000000 (black), which is near invisible against the section's dark background where every other icon uses a light or brand color. Request a white fill. Also drop the hardcoded channel count from the wizard channel-step doc comment so it cannot drift again.
…probe-failure branches Pin that a crypto-enabled install still scans plaintext rooms normally, and that a room-encryption probe which throws yields unresolved (never a false not_sent). Completes branch coverage of the encrypted-room guard.
Conflicts resolved: - packages/channels/package.json, packages/comis/package.json: keep matrix-js-sdk 41.8.0 (this branch) alongside origin's mailparser 3.9.12 patch bump; versions land on origin's 1.0.47 release. - packages/observability/src/trajectory/event-bus-bridge.test.ts: bridge entry-count guard reconciled to 120 (base 117 + channel:decrypt_failed from this branch + mcp:server:connected/connect_failed from origin). - pnpm-lock.yaml: importers reconciled to match; pnpm --lockfile-only confirms the merged lockfile is a no-op (fully consistent).
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
PR description incompletePlease fill in all required sections before this PR can be reviewed. Required sections: Description, Related Issue, Type of Change, Checklist, RED Test Proof. For code changes in See CONTRIBUTING.md for the full contribution bar. |
|
All alerts resolved. Learn more about Socket for GitHub. This PR previously contained dependency changes with security issues that have been resolved, removed, or ignored. |
| } | ||
|
|
||
| // Heading. | ||
| const heading = line.match(/^(#{1,6})\s+(.*)$/); |
|
|
||
| // Remove active/embedding elements together with their contents, then any | ||
| // orphan open/close/void tag of the same name that a nested structure left. | ||
| out = out.replace(DANGEROUS_PAIRED, ""); |
| // Remove active/embedding elements together with their contents, then any | ||
| // orphan open/close/void tag of the same name that a nested structure left. | ||
| out = out.replace(DANGEROUS_PAIRED, ""); | ||
| out = out.replace(DANGEROUS_ORPHAN, ""); |
| out = out.replace(DANGEROUS_ORPHAN, ""); | ||
|
|
||
| // Strip `on*` event-handler attributes (quoted and unquoted forms). | ||
| out = out.replace(/\son\w+\s*=\s*"[^"]*"/gi, ""); |
|
|
||
| // Strip `on*` event-handler attributes (quoted and unquoted forms). | ||
| out = out.replace(/\son\w+\s*=\s*"[^"]*"/gi, ""); | ||
| out = out.replace(/\son\w+\s*=\s*'[^']*'/gi, ""); |
| // Strip `on*` event-handler attributes (quoted and unquoted forms). | ||
| out = out.replace(/\son\w+\s*=\s*"[^"]*"/gi, ""); | ||
| out = out.replace(/\son\w+\s*=\s*'[^']*'/gi, ""); | ||
| out = out.replace(/\son\w+\s*=\s*[^\s>]+/gi, ""); |
| const rewrittenMarkdown = markdown.replace( | ||
| MENTION_MARKUP_RE, | ||
| (whole, name: string, target: string) => { | ||
| if (!MXID_RE.test(target)) return whole; // not a real MXID — leave literal | ||
| if (!seen.has(target)) { | ||
| seen.add(target); | ||
| userIds.push(target); | ||
| } | ||
| return `[${name}](${MATRIX_TO_BASE}${target})`; | ||
| }, | ||
| ); |
Code reviewFound 5 issues:
comis/packages/daemon/src/wiring/setup-media.ts Lines 249 to 254 in 98ca798 comis/packages/skills/src/tools/media/ssrf-fetcher.ts Lines 261 to 270 in 98ca798
comis/packages/core/src/security/secrets-audit.ts Lines 57 to 61 in 98ca798
comis/packages/cli/src/commands/init.ts Lines 209 to 214 in 98ca798 Lines 639 to 645 in 98ca798
comis/packages/daemon/src/wiring/setup-channels-adapters.ts Lines 520 to 523 in 98ca798 comis/docs/reference/environment-variables.mdx Lines 694 to 701 in 98ca798
comis/docs/developer-guide/packages.mdx Lines 22 to 24 in 98ca798 comis/docs/operations/observability.mdx Lines 27 to 29 in 98ca798 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
…ted-origin collection
…ehind allowPrivateHomeserver
…env secrets audit
…matrix secrets in the .env audit
…e2ee headless switch
…sword's SecretRef-only resolution
…channel directories on disk
…eveloper guide and observability docs
Resolved conflicts: - packages/comis/package.json: keep @matrix-org/matrix-sdk-crypto-wasm; take the newer @earendil-works/pi-* 0.80.6 pins to match the other packages. - packages/web/src/api/contracts.generated.*: regenerated via `pnpm contracts:generate` against the merged @comis/core contract registry (218 contracts, 136510B min / 13977B gz) — the drift gate is green. - init.test.ts: init exposes 45 options after adding the matrix init flags. - event-bus-bridge.test.ts: TRAJECTORY_BRIDGE_MAPPING has 123 entries after bridging the matrix channel events. pnpm validate green.
Summary
Adds Matrix as a first-class chat channel, bringing the supported-channel count to 11 (Discord, Telegram, Slack, WhatsApp, iMessage, Signal, IRC, LINE, Email, MS Teams, Matrix). The adapter follows the hexagonal ports+adapters pattern used by every other channel and ships with end-to-end encryption (E2EE) support, media handling, credential validation, a
comis doctorhealth check, and full docs.Built test-first per the repo protocol — the diff is 96
test/ 62feat/ 33fixcommits, with a per-phase deep code review that caught and fixed three correctness blockers the mocks missed (send watermark, dead E2EE-receive path, reconcile double-send).What's included
packages/channels/src/matrix/**) — client/auth, message mapper, formatter, mentions, reactions, media handler, invite policy, activity/state tracking, resolver, and the plugin registration.--matrix-e2eeflag (incl. headlessfalse), and acomis doctorMatrix health/credentials check.channel:decrypt_failedbridged into the trajectory mapping.docs/channels/matrix.mdx, plus config-yaml / env-vars / CLI / data-directory / threat-model / channel-index updates, and the website channel tile + facts (count → 11).Merge with
mainThis branch was just merged up to
origin/main(release 1.0.47). Conflicts resolved:packages/{channels,comis}/package.json— keptmatrix-js-sdk 41.8.0alongside origin'smailparser 3.9.12bump; versions land on 1.0.47.event-bus-bridge.test.tsbridge count guard reconciled to 120 (base 117 +channel:decrypt_failedhere +mcp:server:connected/connect_failedfrom main).pnpm-lock.yaml— reconciled;pnpm --lockfile-onlyconfirms it is a no-op (fully consistent).Test plan
pnpm validate(clean build + cycles + lint:security + coverage) — enforced by the pre-push hook.Notes / follow-ups
matrix_decryptexplainverdict, token+E2EE requiring an explicitdeviceId, ME-03 client-transport hardening.mainhas already shipped 1.0.47, so the Matrix work will fold into a subsequent tagged release — confirm the target version before tagging.