{"repo":"NousResearch/hermes-agent","public_id":"4912","display_label":"Matrix integration bugs and Docker environment fixes","summary":"These issues and pull requests address broken Matrix/Synapse support in Docker images, missing dependencies (libolm, mautrix, shutil), and E2EE setup failures during auto-installation. The work spans bug reports and fixes across Docker image configuration, setup scripts, and Nix packaging.","subsystem":"environments","counts":{"members":12,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":5,"merged_prs":2,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":5271},"resolution":{"state":"partially_resolved","by":"pr-33685"},"freshness":{"state":"verified","last_verified_at":"2026-06-04T04:07:27+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.14303030303030304},"fix_edges":[{"issue_number":3487,"pr_number":5271,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":4339,"pr_number":5271,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":4912,"pr_number":5271,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":5060,"pr_number":5271,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":5134,"pr_number":5271,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":25495,"pr_number":33685,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":4912,"title":"[Bug]: Matrix auto installation broken in docker image","url":null,"state":"closed"},{"kind":"issue","number":5060,"title":"NameError: name 'shutil' is not defined in setup.py when enabling E2EE","url":null,"state":"closed"},{"kind":"issue","number":25495,"title":"[Bug]: Matrix / synapse broken in the official docker image","url":null,"state":"closed"},{"kind":"issue","number":26895,"title":"[Bug]: Mautrix is not included in Nix package","url":null,"state":"closed"},{"kind":"issue","number":30399,"title":"[Bug]: Cannot use Matrix gateway from Docker image","url":null,"state":"closed"},{"kind":"pr","number":4941,"title":"fix(setup): add missing shutil import in matrix-nio auto-install block","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#5271","number":5271},{"kind":"pr","value":"#5136","number":5136}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":4942,"title":"fix(setup): import shutil for Matrix auto-install path","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#5271","number":5271},{"kind":"pr","value":"#5136","number":5136}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":5108,"title":"fix: add missing shutil import in setup.py (fixes #5060)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#5271","number":5271},{"kind":"pr","value":"#5136","number":5136}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":5271,"title":"fix(matrix): comprehensive Matrix reliability — encrypted media, auth recovery, cron E2EE, Synapse compat","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/scheduler.py","gateway/platforms/matrix.py","gateway/platforms/mattermost.py","gateway/run.py","hermes_cli/setup.py","tests/gateway/test_matrix.py","tests/gateway/test_ws_auth_retry.py","tests/hermes_cli/test_setup_matrix_e2ee.py","tools/send_message_tool.py"],"fix_hunks":[{"file":"cron/scheduler.py","hunks":["@@ -74,13 +74,28 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]:\n         return None\n \n     if deliver == \"origin\":\n-        if not origin:\n-            return None\n-        return {\n-            \"platform\": origin[\"platform\"],\n-            \"chat_id\": str(origin[\"chat_id\"]),\n-            \"thread_id\": origin.get(\"thread_id\"),\n-        }\n+        if origin:\n+            return {\n+                \"platform\": origin[\"platform\"],\n+                \"chat_id\": str(origin[\"chat_id\"]),\n+                \"thread_id\": origin.get(\"thread_id\"),\n+            }\n+        # Origin missing (e.g. job created via API/script) — try each\n+        # platform's home channel as a fallback instead of silently dropping.\n+        for platform_name in (\"matrix\", \"telegram\", \"discord\", \"slack\"):\n+            chat_id = os.getenv(f\"{platform_name.upper()}_HOME_CHANNEL\", \"\")\n+            if chat_id:\n+                logger.info(\n+                    \"Job '%s' has deliver=origin but no origin; falling back to %s home channel\",\n+                    job.get(\"name\", job.get(\"id\", \"?\")),\n+                    platform_name,\n+                )\n+                return {\n+                    \"platform\": platform_name,\n+                    \"chat_id\": chat_id,\n+                    \"thread_id\": None,\n+                }\n+        return None\n \n     if \":\" in deliver:\n         platform_name, rest = deliver.split(\":\", 1)","@@ -130,12 +145,14 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]:\n     }\n \n \n-def _deliver_result(job: dict, content: str) -> None:\n+def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> None:\n     \"\"\"\n     Deliver job output to the configured target (origin chat, specific platform, etc.).\n \n-    Uses the standalone platform send functions from send_message_tool so delivery\n-    works whether or not the gateway is running.\n+    When ``adapters`` and ``loop`` are provided (gateway is running), tries to\n+    use the live adapter first — this supports E2EE rooms (e.g. Matrix) where\n+    the standalone HTTP path cannot encrypt.  Falls back to standalone send if\n+    the adapter path fails or is unavailable.\n     \"\"\"\n     target = _resolve_delivery_target(job)\n     if not target:","@@ -206,7 +223,33 @@ def _deliver_result(job: dict, content: str) -> None:\n     else:\n         delivery_content = content\n \n-    # Run the async send in a fresh event loop (safe from any thread)\n+    # Prefer the live adapter when the gateway is running — this supports E2EE\n+    # rooms (e.g. Matrix) where the standalone HTTP path cannot encrypt.\n+    runtime_adapter = (adapters or {}).get(platform)\n+    if runtime_adapter is not None and loop is not None and getattr(loop, \"is_running\", lambda: False)():\n+        send_metadata = {\"thread_id\": thread_id} if thread_id else None\n+        try:\n+            future = asyncio.run_coroutine_threadsafe(\n+                runtime_adapter.send(chat_id, delivery_content, metadata=send_metadata),\n+                loop,\n+            )\n+            send_result = future.result(timeout=60)\n+            if send_result and not getattr(send_result, \"success\", True):\n+                err = getattr(send_result, \"error\", \"unknown\")\n+                logger.warning(\n+                    \"Job '%s': live adapter send to %s:%s failed (%s), falling back to standalone\",\n+                    job[\"id\"], platform_name, chat_id, err,\n+                )\n+            else:\n+                logger.info(\"Job '%s': delivered to %s:%s via live adapter\", job[\"id\"], platform_name, chat_id)\n+                return\n+        except Exception as e:\n+            logger.warning(\n+                \"Job '%s': live adapter delivery to %s:%s failed (%s), falling back to standalone\",\n+                job[\"id\"], platform_name, chat_id, e,\n+            )\n+\n+    # Standalone path: run the async send in a fresh event loop (safe from any thread)\n     coro = _send_to_platform(platform, pconfig, chat_id, delivery_content, thread_id=thread_id)\n     try:\n         result = asyncio.run(coro)","@@ -629,7 +672,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:\n                 logger.debug(\"Job '%s': failed to close SQLite session store: %s\", job_id, e)\n \n \n-def tick(verbose: bool = True) -> int:\n+def tick(verbose: bool = True, adapters=None, loop=None) -> int:\n     \"\"\"\n     Check and run all due jobs.\n     ","@@ -638,6 +681,8 @@ def tick(verbose: bool = True) -> int:\n     \n     Args:\n         verbose: Whether to print status messages\n+        adapters: Optional dict mapping Platform → live adapter (from gateway)\n+        loop: Optional asyncio event loop (from gateway) for live adapter sends\n     \n     Returns:\n         Number of jobs executed (0 if another tick is already running)","@@ -694,7 +739,7 @@ def tick(verbose: bool = True) -> int:\n \n                 if should_deliver:\n                     try:\n-                        _deliver_result(job, deliver_content)\n+                        _deliver_result(job, deliver_content, adapters=adapters, loop=loop)\n                     except Exception as de:\n                         logger.error(\"Delivery failed for job %s: %s\", job[\"id\"], de)\n "]},{"file":"gateway/platforms/matrix.py","hunks":["@@ -273,6 +273,14 @@ async def connect(self) -> bool:\n         client.add_event_callback(self._on_room_message_media, nio.RoomMessageAudio)\n         client.add_event_callback(self._on_room_message_media, nio.RoomMessageVideo)\n         client.add_event_callback(self._on_room_message_media, nio.RoomMessageFile)\n+        for encrypted_media_cls in (\n+            getattr(nio, \"RoomEncryptedImage\", None),\n+            getattr(nio, \"RoomEncryptedAudio\", None),\n+            getattr(nio, \"RoomEncryptedVideo\", None),\n+            getattr(nio, \"RoomEncryptedFile\", None),\n+        ):\n+            if encrypted_media_cls is not None:\n+                client.add_event_callback(self._on_room_message_media, encrypted_media_cls)\n         client.add_event_callback(self._on_invite, nio.InviteMemberEvent)\n \n         # If E2EE: handle encrypted events.","@@ -604,6 +612,7 @@ async def _upload_and_send(\n             io.BytesIO(data),\n             content_type=content_type,\n             filename=filename,\n+            filesize=len(data),\n         )\n         if not isinstance(resp, nio.UploadResponse):\n             err = getattr(resp, \"message\", str(resp))","@@ -683,6 +692,13 @@ async def _sync_loop(self) -> None:\n                 if isinstance(resp, nio.SyncError):\n                     if self._closing:\n                         return\n+                    err_msg = str(getattr(resp, \"message\", resp)).lower()\n+                    if \"m_unknown_token\" in err_msg or \"m_forbidden\" in err_msg or \"401\" in err_msg:\n+                        logger.error(\n+                            \"Matrix: permanent auth error from sync: %s — stopping sync\",\n+                            getattr(resp, \"message\", resp),\n+                        )\n+                        return\n                     logger.warning(\n                         \"Matrix: sync returned %s: %s — retrying in 5s\",\n                         type(resp).__name__,","@@ -697,6 +713,12 @@ async def _sync_loop(self) -> None:\n             except Exception as exc:\n                 if self._closing:\n                     return\n+                # Detect permanent auth/permission failures that will never\n+                # succeed on retry — stop syncing instead of looping forever.\n+                err_str = str(exc).lower()\n+                if \"401\" in err_str or \"403\" in err_str or \"unauthorized\" in err_str or \"forbidden\" in err_str:\n+                    logger.error(\"Matrix: permanent auth error: %s — stopping sync\", exc)\n+                    return\n                 logger.warning(\"Matrix: sync error: %s — retrying in 5s\", exc)\n                 await asyncio.sleep(5)\n ","@@ -1011,47 +1033,132 @@ async def _on_room_message_media(self, room: Any, event: Any) -> None:\n         # Use the MIME type from the event's content info when available,\n         # falling back to category-level MIME types for downstream matching\n         # (gateway/run.py checks startswith(\"image/\"), startswith(\"audio/\"), etc.)\n-        content_info = getattr(event, \"content\", {}) if isinstance(getattr(event, \"content\", None), dict) else {}\n-        event_mimetype = (content_info.get(\"info\") or {}).get(\"mimetype\", \"\")\n+        source_content = getattr(event, \"source\", {}).get(\"content\", {})\n+        if not isinstance(source_content, dict):\n+            source_content = {}\n+        event_content = getattr(event, \"content\", {})\n+        if not isinstance(event_content, dict):\n+            event_content = {}\n+        content_info = event_content.get(\"info\") if isinstance(event_content, dict) else {}\n+        if not isinstance(content_info, dict) or not content_info:\n+            content_info = source_content.get(\"info\", {}) if isinstance(source_content, dict) else {}\n+        event_mimetype = (\n+            (content_info.get(\"mimetype\") if isinstance(content_info, dict) else None)\n+            or getattr(event, \"mimetype\", \"\")\n+            or \"\"\n+        )\n+        # For encrypted media, the URL may be in file.url instead of event.url.\n+        file_content = source_content.get(\"file\", {}) if isinstance(source_content, dict) else {}\n+        if not url and isinstance(file_content, dict):\n+            url = file_content.get(\"url\", \"\") or \"\"\n+            if url and url.startswith(\"mxc://\"):\n+                http_url = self._mxc_to_http(url)\n+\n         media_type = \"application/octet-stream\"\n         msg_type = MessageType.DOCUMENT\n+\n+        # Safely resolve encrypted media classes — they may not exist on older\n+        # nio versions, and in test environments nio may be mocked (MagicMock\n+        # auto-attributes are not valid types for isinstance).\n+        def _safe_isinstance(obj, cls_name):\n+            cls = getattr(nio, cls_name, None)\n+            if cls is None or not isinstance(cls, type):\n+                return False\n+            return isinstance(obj, cls)\n+\n+        is_encrypted_image = _safe_isinstance(event, \"RoomEncryptedImage\")\n+        is_encrypted_audio = _safe_isinstance(event, \"RoomEncryptedAudio\")\n+        is_encrypted_video = _safe_isinstance(event, \"RoomEncryptedVideo\")\n+        is_encrypted_file = _safe_isinstance(event, \"RoomEncryptedFile\")\n+        is_encrypted_media = any((is_encrypted_image, is_encrypted_audio, is_encrypted_video, is_encrypted_file))\n         is_voice_message = False\n-        \n-        if isinstance(event, nio.RoomMessageImage):\n+\n+        if isinstance(event, nio.RoomMessageImage) or is_encrypted_image:\n             msg_type = MessageType.PHOTO\n             media_type = event_mimetype or \"image/png\"\n-        elif isinstance(event, nio.RoomMessageAudio):\n-            # Check for MSC3245 voice flag: org.matrix.msc3245.voice: {}\n-            source_content = getattr(event, \"source\", {}).get(\"content\", {})\n+        elif isinstance(event, nio.RoomMessageAudio) or is_encrypted_audio:\n             if source_content.get(\"org.matrix.msc3245.voice\") is not None:\n                 is_voice_message = True\n                 msg_type = MessageType.VOICE\n             else:\n                 msg_type = MessageType.AUDIO\n             media_type = event_mimetype or \"audio/ogg\"\n-        elif isinstance(event, nio.RoomMessageVideo):\n+        elif isinstance(event, nio.RoomMessageVideo) or is_encrypted_video:\n             msg_type = MessageType.VIDEO\n             media_type = event_mimetype or \"video/mp4\"\n         elif event_mimetype:\n             media_type = event_mimetype\n \n-        # For images, download and cache locally so vision tools can access them.\n-        # Matrix MXC URLs require authentication, so direct URL access fails.\n+        # Cache media locally when downstream tools need a real file path:\n+        # - photos (vision tools can't access MXC URLs)\n+        # - voice messages (transcription tools need local files)\n+        # - any encrypted media (HTTP fallback would point at ciphertext)\n         cached_path = None\n-        if msg_type == MessageType.PHOTO and url:\n+        should_cache_locally = (\n+            msg_type == MessageType.PHOTO or is_voice_message or is_encrypted_media\n+        )\n+        if should_cache_locally and url:\n             try:\n-                ext_map = {\n-                    \"image/jpeg\": \".jpg\", \"image/png\": \".png\",\n-                    \"image/gif\": \".gif\", \"image/webp\": \".webp\",\n-                }\n-                ext = ext_map.get(event_mimetype, \".jpg\")\n-                download_resp = await self._client.download(url)\n-                if isinstance(download_resp, nio.DownloadResponse):\n-                    from gateway.platforms.base import cache_image_from_bytes\n-                    cached_path = cache_image_from_bytes(download_resp.body, ext=ext)\n-                    logger.info(\"[Matrix] Cached user image at %s\", cached_path)\n+                if is_voice_message:\n+                    download_resp = await self._client.download(mxc=url)\n+                else:\n+                    download_resp = await self._client.download(url)\n+                file_bytes = getattr(download_resp, \"body\", None)\n+                if file_bytes is not None:\n+                    if is_encrypted_media:\n+                        from nio.crypto.attachments import decrypt_attachment\n+\n+                        hashes_value = getattr(event, \"hashes\", None)\n+                        if hashes_value is None and isinstance(file_content, dict):\n+                            hashes_value = file_content.get(\"hashes\")\n+                        hash_value = hashes_value.get(\"sha256\") if isinstance(hashes_value, dict) else None\n+\n+                        key_value = getattr(event, \"key\", None)\n+                        if key_value is None and isinstance(file_content, dict):\n+                            key_value = file_content.get(\"key\")\n+                        if isinstance(key_value, dict):\n+                            key_value = key_value.get(\"k\")\n+\n+                        iv_value = getattr(event, \"iv\", None)\n+                        if iv_value is None and isinstance(file_content, dict):\n+                            iv_value = file_content.get(\"iv\")\n+\n+                        if key_value and hash_value and iv_value:\n+                            file_bytes = decrypt_attachment(file_bytes, key_value, hash_value, iv_value)\n+                        else:\n+                            logger.warning(\n+                                \"[Matrix] Encrypted media event missing decryption metadata for %s\",\n+                                event.event_id,\n+                            )\n+                            file_bytes = None\n+\n+                    if file_bytes is not None:\n+                        from gateway.platforms.base import (\n+                            cache_audio_from_bytes,\n+                            cache_document_from_bytes,\n+                            cache_image_from_bytes,\n+                        )\n+\n+                        if msg_type == MessageType.PHOTO:\n+                            ext_map = {\n+                                \"image/jpeg\": \".jpg\",\n+                                \"image/png\": \".png\",\n+                                \"image/gif\": \".gif\",\n+                                \"image/webp\": \".webp\",\n+                            }\n+                            ext = ext_map.get(media_type, \".jpg\")\n+                            cached_path = cache_image_from_bytes(file_bytes, ext=ext)\n+                            logger.info(\"[Matrix] Cached user image at %s\", cached_path)\n+                        elif msg_type in (MessageType.AUDIO, MessageType.VOICE):\n+                            ext = Path(body or (\"voice.ogg\" if is_voice_message else \"audio.ogg\")).suffix or \".ogg\"\n+                            cached_path = cache_audio_from_bytes(file_bytes, ext=ext)\n+                        else:\n+                            filename = body or (\n+                                \"video.mp4\" if msg_type == MessageType.VIDEO else \"document\"\n+                            )\n+                            cached_path = cache_document_from_bytes(file_bytes, filename)\n             except Exception as e:\n-                logger.warning(\"[Matrix] Failed to cache image: %s\", e)\n+                logger.warning(\"[Matrix] Failed to cache media: %s\", e)\n \n         is_dm = self._dm_rooms.get(room.room_id, False)\n         if not is_dm and room.member_count == 2:","@@ -1059,7 +1166,6 @@ async def _on_room_message_media(self, room: Any, event: Any) -> None:\n         chat_type = \"dm\" if is_dm else \"group\"\n \n         # Thread/reply detection.\n-        source_content = getattr(event, \"source\", {}).get(\"content\", {})\n         relates_to = source_content.get(\"m.relates_to\", {})\n         thread_id = None\n         if relates_to.get(\"rel_type\") == \"m.thread\":","@@ -1089,31 +1195,6 @@ async def _on_room_message_media(self, room: Any, event: Any) -> None:\n                 thread_id = event.event_id\n                 self._track_thread(thread_id)\n \n-        # For voice messages, cache audio locally for transcription tools.\n-        # Use the authenticated nio client to download (Matrix requires auth for media).\n-        media_urls = [http_url] if http_url else None\n-        media_types = [media_type] if http_url else None\n-        \n-        if is_voice_message and url and url.startswith(\"mxc://\"):\n-            try:\n-                import nio\n-                from gateway.platforms.base import cache_audio_from_bytes\n-                \n-                resp = await self._client.download(mxc=url)\n-                if isinstance(resp, nio.MemoryDownloadResponse):\n-                    # Extract extension from mimetype or default to .ogg\n-                    ext = \".ogg\"\n-                    if media_type and \"/\" in media_type:\n-                        subtype = media_type.split(\"/\")[1]\n-                        ext = f\".{subtype}\" if subtype else \".ogg\"\n-                    local_path = cache_audio_from_bytes(resp.body, ext)\n-                    media_urls = [local_path]\n-                    logger.debug(\"Matrix: cached voice message to %s\", local_path)\n-                else:\n-                    logger.warning(\"Matrix: failed to download voice: %s\", getattr(resp, \"message\", resp))\n-            except Exception as e:\n-                logger.warning(\"Matrix: failed to cache voice message, using HTTP URL: %s\", e)\n-\n         source = self.build_source(\n             chat_id=room.room_id,\n             chat_type=chat_type,","@@ -1122,9 +1203,8 @@ async def _on_room_message_media(self, room: Any, event: Any) -> None:\n             thread_id=thread_id,\n         )\n \n-        # Use cached local path for images (voice messages already handled above).\n-        if cached_path:\n-            media_urls = [cached_path]\n+        allow_http_fallback = bool(http_url) and not is_encrypted_media\n+        media_urls = [cached_path] if cached_path else ([http_url] if allow_http_fallback else None)\n         media_types = [media_type] if media_urls else None\n \n         msg_event = MessageEvent("]},{"file":"gateway/platforms/mattermost.py","hunks":["@@ -513,6 +513,16 @@ async def _ws_loop(self) -> None:\n             except Exception as exc:\n                 if self._closing:\n                     return\n+                # Detect permanent auth/permission failures that will never\n+                # succeed on retry — stop reconnecting instead of looping forever.\n+                import aiohttp\n+                err_str = str(exc).lower()\n+                if isinstance(exc, aiohttp.WSServerHandshakeError) and exc.status in (401, 403):\n+                    logger.error(\"Mattermost WS auth failed (HTTP %d) — stopping reconnect\", exc.status)\n+                    return\n+                if \"401\" in err_str or \"403\" in err_str or \"unauthorized\" in err_str:\n+                    logger.error(\"Mattermost WS permanent error: %s — stopping reconnect\", exc)\n+                    return\n                 logger.warning(\"Mattermost WS error: %s — reconnecting in %.0fs\", exc, delay)\n \n             if self._closing:"]},{"file":"gateway/run.py","hunks":["@@ -2650,8 +2650,20 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):\n         # Enrich document messages with context notes for the agent\n         # -----------------------------------------------------------------\n         if event.media_urls and event.message_type == MessageType.DOCUMENT:\n+            import mimetypes as _mimetypes\n+            _TEXT_EXTENSIONS = {\".txt\", \".md\", \".csv\", \".log\", \".json\", \".xml\", \".yaml\", \".yml\", \".toml\", \".ini\", \".cfg\"}\n             for i, path in enumerate(event.media_urls):\n                 mtype = event.media_types[i] if i < len(event.media_types) else \"\"\n+                # Fall back to extension-based detection when MIME type is unreliable.\n+                if mtype in (\"\", \"application/octet-stream\"):\n+                    import os as _os2\n+                    _ext = _os2.path.splitext(path)[1].lower()\n+                    if _ext in _TEXT_EXTENSIONS:\n+                        mtype = \"text/plain\"\n+                    else:\n+                        guessed, _ = _mimetypes.guess_type(path)\n+                        if guessed:\n+                            mtype = guessed\n                 if not (mtype.startswith(\"application/\") or mtype.startswith(\"text/\")):\n                     continue\n                 # Extract display filename by stripping the doc_{uuid12}_ prefix","@@ -6815,13 +6827,16 @@ async def _notify_long_running():\n         return response\n \n \n-def _start_cron_ticker(stop_event: threading.Event, adapters=None, interval: int = 60):\n+def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, interval: int = 60):\n     \"\"\"\n     Background thread that ticks the cron scheduler at a regular interval.\n     \n     Runs inside the gateway process so cronjobs fire automatically without\n     needing a separate `hermes cron daemon` or system cron entry.\n \n+    When ``adapters`` and ``loop`` are provided, passes them through to the\n+    cron delivery path so live adapters can be used for E2EE rooms.\n+\n     Also refreshes the channel directory every 5 minutes and prunes the\n     image/audio/document cache once per hour.\n     \"\"\"","@@ -6835,7 +6850,7 @@ def _start_cron_ticker(stop_event: threading.Event, adapters=None, interval: int\n     tick_count = 0\n     while not stop_event.is_set():\n         try:\n-            cron_tick(verbose=False)\n+            cron_tick(verbose=False, adapters=adapters, loop=loop)\n         except Exception as e:\n             logger.debug(\"Cron tick error: %s\", e)\n ","@@ -7021,12 +7036,13 @@ def signal_handler():\n     write_pid_file()\n     atexit.register(remove_pid_file)\n     \n-    # Start background cron ticker so scheduled jobs fire automatically\n+    # Start background cron ticker so scheduled jobs fire automatically.\n+    # Pass the event loop so cron delivery can use live adapters (E2EE support).\n     cron_stop = threading.Event()\n     cron_thread = threading.Thread(\n         target=_start_cron_ticker,\n         args=(cron_stop,),\n-        kwargs={\"adapters\": runner.adapters},\n+        kwargs={\"adapters\": runner.adapters, \"loop\": asyncio.get_running_loop()},\n         daemon=True,\n         name=\"cron-ticker\",\n     )"]},{"file":"hermes_cli/setup.py","hunks":["@@ -14,6 +14,7 @@\n import importlib.util\n import logging\n import os\n+import shutil\n import sys\n from pathlib import Path\n from typing import Optional, Dict, Any"]},{"file":"tests/gateway/test_matrix.py","hunks":["@@ -993,3 +993,358 @@ async def test_disconnect_skips_export_when_no_encryption(self):\n         # Should not have tried to export\n         assert not hasattr(fake_client, \"export_keys\") or \\\n                not fake_client.export_keys.called\n+\n+\n+# ---------------------------------------------------------------------------\n+# E2EE: Encrypted media\n+# ---------------------------------------------------------------------------\n+\n+class TestMatrixEncryptedMedia:\n+    @pytest.mark.asyncio\n+    async def test_connect_registers_callbacks_for_encrypted_media_events(self):\n+        from gateway.platforms.matrix import MatrixAdapter\n+\n+        config = PlatformConfig(\n+            enabled=True,\n+            token=\"[redacted:secret-assignment]\",\n+            extra={\n+                \"homeserver\": \"https://matrix.example.org\",\n+                \"user_id\": \"@bot:example.org\",\n+                \"encryption\": True,\n+            },\n+        )\n+        adapter = MatrixAdapter(config)\n+\n+        class FakeWhoamiResponse:\n+            def __init__(self, user_id, device_id):\n+                self.user_id = user_id\n+                self.device_id = device_id\n+\n+        class FakeSyncResponse:\n+            def __init__(self):\n+                self.rooms = MagicMock(join={})\n+\n+        class FakeRoomMessageText: ...\n+        class FakeRoomMessageImage: ...\n+        class FakeRoomMessageAudio: ...\n+        class FakeRoomMessageVideo: ...\n+        class FakeRoomMessageFile: ...\n+        class FakeRoomEncryptedImage: ...\n+        class FakeRoomEncryptedAudio: ...\n+        class FakeRoomEncryptedVideo: ...\n+        class FakeRoomEncryptedFile: ...\n+        class FakeInviteMemberEvent: ...\n+        class FakeMegolmEvent: ...\n+\n+        fake_client = MagicMock()\n+        fake_client.whoami = AsyncMock(return_value=FakeWhoamiResponse(\"@bot:example.org\", \"DEV123\"))\n+        fake_client.sync = AsyncMock(return_value=FakeSyncResponse())\n+        fake_client.keys_upload = AsyncMock()\n+        fake_client.keys_query = AsyncMock()\n+        fake_client.keys_claim = AsyncMock()\n+        fake_client.send_to_device_messages = AsyncMock(return_value=[])\n+        fake_client.get_users_for_key_claiming = MagicMock(return_value={})\n+        fake_client.close = AsyncMock()\n+        fake_client.add_event_callback = MagicMock()\n+        fake_client.rooms = {}\n+        fake_client.account_data = {}\n+        fake_client.olm = object()\n+        fake_client.should_upload_keys = False\n+        fake_client.should_query_keys = False\n+        fake_client.should_claim_keys = False\n+        fake_client.restore_login = MagicMock(side_effect=lambda u, d, t: None)\n+\n+        fake_nio = MagicMock()\n+        fake_nio.AsyncClient = MagicMock(return_value=fake_client)\n+        fake_nio.WhoamiResponse = FakeWhoamiResponse\n+        fake_nio.SyncResponse = FakeSyncResponse\n+        fake_nio.LoginResponse = type(\"LoginResponse\", (), {})\n+        fake_nio.RoomMessageText = FakeRoomMessageText\n+        fake_nio.RoomMessageImage = FakeRoomMessageImage\n+        fake_nio.RoomMessageAudio = FakeRoomMessageAudio\n+        fake_nio.RoomMessageVideo = FakeRoomMessageVideo\n+        fake_nio.RoomMessageFile = FakeRoomMessageFile\n+        fake_nio.RoomEncryptedImage = FakeRoomEncryptedImage\n+        fake_nio.RoomEncryptedAudio = FakeRoomEncryptedAudio\n+        fake_nio.RoomEncryptedVideo = FakeRoomEncryptedVideo\n+        fake_nio.RoomEncryptedFile = FakeRoomEncryptedFile\n+        fake_nio.InviteMemberEvent = FakeInviteMemberEvent\n+        fake_nio.MegolmEvent = FakeMegolmEvent\n+\n+        with patch.dict(\"sys.modules\", {\"nio\": fake_nio}):\n+            with patch.object(adapter, \"_refresh_dm_cache\", AsyncMock()):\n+                with patch.object(adapter, \"_sync_loop\", AsyncMock(return_value=None)):\n+                    assert await adapter.connect() is True\n+\n+        callback_classes = [call.args[1] for call in fake_client.add_event_callback.call_args_list]\n+        assert FakeRoomEncryptedImage in callback_classes\n+        assert FakeRoomEncryptedAudio in callback_classes\n+        assert FakeRoomEncryptedVideo in callback_classes\n+        assert FakeRoomEncryptedFile in callback_classes\n+\n+        await adapter.disconnect()\n+\n+    @pytest.mark.asyncio\n+    async def test_on_room_message_media_decrypts_encrypted_image_and_passes_local_path(self):\n+        from nio.crypto.attachments import encrypt_attachment\n+\n+        adapter = _make_adapter()\n+        adapter._user_id = \"@bot:example.org\"\n+        adapter._startup_ts = 0.0\n+        adapter._dm_rooms = {}\n+        adapter.handle_message = AsyncMock()\n+\n+        plaintext = b\"\\x89PNG\\r\\n\\x1a\\n\" + b\"\\x00\" * 32\n+        ciphertext, keys = encrypt_attachment(plaintext)\n+\n+        class FakeRoomEncryptedImage:\n+            def __init__(self):\n+                self.sender = \"@alice:example.org\"\n+                self.event_id = \"$img1\"\n+                self.server_timestamp = 0\n+                self.body = \"screenshot.png\"\n+                self.url = \"mxc://example.org/media123\"\n+                self.key = keys[\"key\"][\"k\"]\n+                self.hashes = keys[\"hashes\"]\n+                self.iv = keys[\"iv\"]\n+                self.mimetype = \"image/png\"\n+                self.source = {\n+                    \"content\": {\n+                        \"body\": \"screenshot.png\",\n+                        \"info\": {\"mimetype\": \"image/png\"},\n+                        \"file\": {\n+                            \"url\": self.url,\n+                            \"key\": keys[\"key\"],\n+                            \"hashes\": keys[\"hashes\"],\n+                            \"iv\": keys[\"iv\"],\n+                        },\n+                    }\n+                }\n+\n+        class FakeDownloadResponse:\n+            def __init__(self, body):\n+                self.body = body\n+\n+        fake_client = MagicMock()\n+        fake_client.download = AsyncMock(return_value=FakeDownloadResponse(ciphertext))\n+        adapter._client = fake_client\n+\n+        fake_nio = MagicMock()\n+        fake_nio.RoomMessageImage = type(\"RoomMessageImage\", (), {})\n+        fake_nio.RoomMessageAudio = type(\"RoomMessageAudio\", (), {})\n+        fake_nio.RoomMessageVideo = type(\"RoomMessageVideo\", (), {})\n+        fake_nio.RoomMessageFile = type(\"RoomMessageFile\", (), {})\n+        fake_nio.RoomEncryptedImage = FakeRoomEncryptedImage\n+        fake_nio.RoomEncryptedAudio = type(\"RoomEncryptedAudio\", (), {})\n+        fake_nio.RoomEncryptedVideo = type(\"RoomEncryptedVideo\", (), {})\n+        fake_nio.RoomEncryptedFile = type(\"RoomEncryptedFile\", (), {})\n+\n+        room = MagicMock(room_id=\"!room:example.org\", member_count=2, users={})\n+        event = FakeRoomEncryptedImage()\n+\n+        with patch.dict(\"sys.modules\", {\"nio\": fake_nio}):\n+            with patch(\"gateway.platforms.base.cache_image_from_bytes\", return_value=\"/tmp/cached-image.png\") as cache_mock:\n+                await adapter._on_room_message_media(room, event)\n+\n+        cache_mock.assert_called_once_with(plaintext, ext=\".png\")\n+        msg_event = adapter.handle_message.await_args.args[0]\n+        assert msg_event.message_type.name == \"PHOTO\"\n+        assert msg_event.media_urls == [\"/tmp/cached-image.png\"]\n+        assert msg_event.media_types == [\"image/png\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_on_room_message_media_decrypts_encrypted_voice_and_caches_audio(self):\n+        from nio.crypto.attachments import encrypt_attachment\n+\n+        adapter = _make_adapter()\n+        adapter._user_id = \"@bot:example.org\"\n+        adapter._startup_ts = 0.0\n+        adapter._dm_rooms = {}\n+        adapter.handle_message = AsyncMock()\n+\n+        plaintext = b\"OggS\" + b\"\\x00\" * 32\n+        ciphertext, keys = encrypt_attachment(plaintext)\n+\n+        class FakeRoomEncryptedAudio:\n+            def __init__(self):\n+                self.sender = \"@alice:example.org\"\n+                self.event_id = \"$voice1\"\n+                self.server_timestamp = 0\n+                self.body = \"voice.ogg\"\n+                self.url = \"mxc://example.org/voice123\"\n+                self.key = keys[\"key\"][\"k\"]\n+                self.hashes = keys[\"hashes\"]\n+                self.iv = keys[\"iv\"]\n+                self.mimetype = \"audio/ogg\"\n+                self.source = {\n+                    \"content\": {\n+                        \"body\": \"voice.ogg\",\n+                        \"info\": {\"mimetype\": \"audio/ogg\"},\n+                        \"org.matrix.msc3245.voice\": {},\n+                        \"file\": {\n+                            \"url\": self.url,\n+                            \"key\": keys[\"key\"],\n+                            \"hashes\": keys[\"hashes\"],\n+                            \"iv\": keys[\"iv\"],\n+                        },\n+                    }\n+                }\n+\n+        class FakeDownloadResponse:\n+            def __init__(self, body):\n+                self.body = body\n+\n+        fake_client = MagicMock()\n+        fake_client.download = AsyncMock(return_value=FakeDownloadResponse(ciphertext))\n+        adapter._client = fake_client\n+\n+        fake_nio = MagicMock()\n+        fake_nio.RoomMessageImage = type(\"RoomMessageImage\", (), {})\n+        fake_nio.RoomMessageAudio = type(\"RoomMessageAudio\", (), {})\n+        fake_nio.RoomMessageVideo = type(\"RoomMessageVideo\", (), {})\n+        fake_nio.RoomMessageFile = type(\"RoomMessageFile\", (), {})\n+        fake_nio.RoomEncryptedImage = type(\"RoomEncryptedImage\", (), {})\n+        fake_nio.RoomEncryptedAudio = FakeRoomEncryptedAudio\n+        fake_nio.RoomEncryptedVideo = type(\"RoomEncryptedVideo\", (), {})\n+        fake_nio.RoomEncryptedFile = type(\"RoomEncryptedFile\", (), {})\n+\n+        room = MagicMock(room_id=\"!room:example.org\", member_count=2, users={})\n+        event = FakeRoomEncryptedAudio()\n+\n+        with patch.dict(\"sys.modules\", {\"nio\": fake_nio}):\n+            with patch(\"gateway.platforms.base.cache_audio_from_bytes\", return_value=\"/tmp/cached-voice.ogg\") as cache_mock:\n+                await adapter._on_room_message_media(room, event)\n+\n+        cache_mock.assert_called_once_with(plaintext, ext=\".ogg\")\n+        msg_event = adapter.handle_message.await_args.args[0]\n+        assert msg_event.message_type.name == \"VOICE\"\n+        assert msg_event.media_urls == [\"/tmp/cached-voice.ogg\"]\n+        assert msg_event.media_types == [\"audio/ogg\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_on_room_message_media_decrypts_encrypted_file_and_caches_document(self):\n+        from nio.crypto.attachments import encrypt_attachment\n+\n+        adapter = _make_adapter()\n+        adapter._user_id = \"@bot:example.org\"\n+        adapter._startup_ts = 0.0\n+        adapter._dm_rooms = {}\n+        adapter.handle_message = AsyncMock()\n+\n+        plaintext = b\"hello from encrypted document\"\n+        ciphertext, keys = encrypt_attachment(plaintext)\n+\n+        class FakeRoomEncryptedFile:\n+            def __init__(self):\n+                self.sender = \"@alice:example.org\"\n+                self.event_id = \"$file1\"\n+                self.server_timestamp = 0\n+                self.body = \"notes.txt\"\n+                self.url = \"mxc://example.org/file123\"\n+                self.key = keys[\"key\"]\n+                self.hashes = keys[\"hashes\"]\n+                self.iv = keys[\"iv\"]\n+                self.mimetype = \"text/plain\"\n+                self.source = {\n+                    \"content\": {\n+                        \"body\": \"notes.txt\",\n+                        \"info\": {\"mimetype\": \"text/plain\"},\n+                        \"file\": {\n+                            \"url\": self.url,\n+                            \"key\": keys[\"key\"],\n+                            \"hashes\": keys[\"hashes\"],\n+                            \"iv\": keys[\"iv\"],\n+                        },\n+                    }\n+                }\n+\n+        class FakeDownloadResponse:\n+            def __init__(self, body):\n+                self.body = body\n+\n+        fake_client = MagicMock()\n+        fake_client.download = AsyncMock(return_value=FakeDownloadResponse(ciphertext))\n+        adapter._client = fake_client\n+\n+        fake_nio = MagicMock()\n+        fake_nio.RoomMessageImage = type(\"RoomMessageImage\", (), {})\n+        fake_nio.RoomMessageAudio = type(\"RoomMessageAudio\", (), {})\n+        fake_nio.RoomMessageVideo = type(\"RoomMessageVideo\", (), {})\n+        fake_nio.RoomMessageFile = type(\"RoomMessageFile\", (), {})\n+        fake_nio.RoomEncryptedImage = type(\"RoomEncryptedImage\", (), {})\n+        fake_nio.RoomEncryptedAudio = type(\"RoomEncryptedAudio\", (), {})\n+        fake_nio.RoomEncryptedVideo = type(\"RoomEncryptedVideo\", (), {})\n+        fake_nio.RoomEncryptedFile = FakeRoomEncryptedFile\n+\n+        room = MagicMock(room_id=\"!room:example.org\", member_count=2, users={})\n+        event = FakeRoomEncryptedFile()\n+\n+        with patch.dict(\"sys.modules\", {\"nio\": fake_nio}):\n+            with patch(\"gateway.platforms.base.cache_document_from_bytes\", return_value=\"/tmp/cached-notes.txt\") as cache_mock:\n+                await adapter._on_room_message_media(room, event)\n+\n+        cache_mock.assert_called_once_with(plaintext, \"notes.txt\")\n+        msg_event = adapter.handle_message.await_args.args[0]\n+        assert msg_event.message_type.name == \"DOCUMENT\"\n+        assert msg_event.media_urls == [\"/tmp/cached-notes.txt\"]\n+        assert msg_event.media_types == [\"text/plain\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_on_room_message_media_does_not_emit_ciphertext_url_when_encrypted_media_decryption_fails(self):\n+        adapter = _make_adapter()\n+        adapter._user_id = \"@bot:example.org\"\n+        adapter._startup_ts = 0.0\n+        adapter._dm_rooms = {}\n+        adapter.handle_message = AsyncMock()\n+\n+        class FakeRoomEncryptedImage:\n+            def __init__(self):\n+                self.sender = \"@alice:example.org\"\n+                self.event_id = \"$img2\"\n+                self.server_timestamp = 0\n+                self.body = \"broken.png\"\n+                self.url = \"mxc://example.org/media999\"\n+                self.key = {\"k\": \"broken\"}\n+                self.hashes = {\"sha256\": \"broken\"}\n+                self.iv = \"broken\"\n+                self.mimetype = \"image/png\"\n+                self.source = {\n+                    \"content\": {\n+                        \"body\": \"broken.png\",\n+                        \"info\": {\"mimetype\": \"image/png\"},\n+                        \"file\": {\n+                            \"url\": self.url,\n+                            \"key\": self.key,\n+                            \"hashes\": self.hashes,\n+                            \"iv\": self.iv,\n+                        },\n+                    }\n+                }\n+\n+        class FakeDownloadResponse:\n+            def __init__(self, body):\n+                self.body = body\n+\n+        fake_client = MagicMock()\n+        fake_client.download = AsyncMock(return_value=FakeDownloadResponse(b\"ciphertext\"))\n+        adapter._client = fake_client\n+\n+        fake_nio = MagicMock()\n+        fake_nio.RoomMessageImage = type(\"RoomMessageImage\", (), {})\n+        fake_nio.RoomMessageAudio = type(\"RoomMessageAudio\", (), {})\n+        fake_nio.RoomMessageVideo = type(\"RoomMessageVideo\", (), {})\n+        fake_nio.RoomMessageFile = type(\"RoomMessageFile\", (), {})\n+        fake_nio.RoomEncryptedImage = FakeRoomEncryptedImage\n+        fake_nio.RoomEncryptedAudio = type(\"RoomEncryptedAudio\", (), {})\n+        fake_nio.RoomEncryptedVideo = type(\"RoomEncryptedVideo\", (), {})\n+        fake_nio.RoomEncryptedFile = type(\"RoomEncryptedFile\", (), {})\n+\n+        room = MagicMock(room_id=\"!room:example.org\", member_count=2, users={})\n+        event = FakeRoomEncryptedImage()\n+\n+        with patch.dict(\"sys.modules\", {\"nio\": fake_nio}):\n+            await adapter._on_room_message_media(room, event)\n+\n+        msg_event = adapter.handle_message.await_args.args[0]\n+        assert not msg_event.media_urls\n+        assert not msg_event.media_types"]},{"file":"tests/gateway/test_ws_auth_retry.py","hunks":["@@ -0,0 +1,216 @@\n+\"\"\"Tests for auth-aware retry in Mattermost WS and Matrix sync loops.\n+\n+Both Mattermost's _ws_loop and Matrix's _sync_loop previously caught all\n+exceptions with a broad ``except Exception`` and retried forever. Permanent\n+auth failures (401, 403, M_UNKNOWN_TOKEN) would loop indefinitely instead\n+of stopping. These tests verify that auth errors now stop the reconnect.\n+\"\"\"\n+\n+import asyncio\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+import pytest\n+\n+\n+# ---------------------------------------------------------------------------\n+# Mattermost: _ws_loop auth-aware retry\n+# ---------------------------------------------------------------------------\n+\n+class TestMattermostWSAuthRetry:\n+    \"\"\"gateway/platforms/mattermost.py — _ws_loop()\"\"\"\n+\n+    def test_401_handshake_stops_reconnect(self):\n+        \"\"\"A WSServerHandshakeError with status 401 should stop the loop.\"\"\"\n+        import aiohttp\n+\n+        exc = aiohttp.WSServerHandshakeError(\n+            request_info=MagicMock(),\n+            history=(),\n+            status=401,\n+            message=\"Unauthorized\",\n+            headers=MagicMock(),\n+        )\n+\n+        from gateway.platforms.mattermost import MattermostAdapter\n+        adapter = MattermostAdapter.__new__(MattermostAdapter)\n+        adapter._closing = False\n+\n+        call_count = 0\n+\n+        async def fake_connect():\n+            nonlocal call_count\n+            call_count += 1\n+            raise exc\n+\n+        adapter._ws_connect_and_listen = fake_connect\n+\n+        asyncio.run(adapter._ws_loop())\n+\n+        # Should have attempted once and stopped, not retried\n+        assert call_count == 1\n+\n+    def test_403_handshake_stops_reconnect(self):\n+        \"\"\"A WSServerHandshakeError with status 403 should stop the loop.\"\"\"\n+        import aiohttp\n+\n+        exc = aiohttp.WSServerHandshakeError(\n+            request_info=MagicMock(),\n+            history=(),\n+            status=403,\n+            message=\"Forbidden\",\n+            headers=MagicMock(),\n+        )\n+\n+        from gateway.platforms.mattermost import MattermostAdapter\n+        adapter = MattermostAdapter.__new__(MattermostAdapter)\n+        adapter._closing = False\n+\n+        call_count = 0\n+\n+        async def fake_connect():\n+            nonlocal call_count\n+            call_count += 1\n+            raise exc\n+\n+        adapter._ws_connect_and_listen = fake_connect\n+\n+        asyncio.run(adapter._ws_loop())\n+        assert call_count == 1\n+\n+    def test_transient_error_retries(self):\n+        \"\"\"A transient ConnectionError should retry (not stop immediately).\"\"\"\n+        from gateway.platforms.mattermost import MattermostAdapter\n+        adapter = MattermostAdapter.__new__(MattermostAdapter)\n+        adapter._closing = False\n+\n+        call_count = 0\n+\n+        async def fake_connect():\n+            nonlocal call_count\n+            call_count += 1\n+            if call_count >= 2:\n+                # Stop the loop after 2 attempts\n+                adapter._closing = True\n+                return\n+            raise ConnectionError(\"connection reset\")\n+\n+        adapter._ws_connect_and_listen = fake_connect\n+\n+        async def run():\n+            with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n+                await adapter._ws_loop()\n+\n+        asyncio.run(run())\n+\n+        # Should have retried at least once\n+        assert call_count >= 2\n+\n+\n+# ---------------------------------------------------------------------------\n+# Matrix: _sync_loop auth-aware retry\n+# ---------------------------------------------------------------------------\n+\n+class TestMatrixSyncAuthRetry:\n+    \"\"\"gateway/platforms/matrix.py — _sync_loop()\"\"\"\n+\n+    def test_unknown_token_sync_error_stops_loop(self):\n+        \"\"\"A SyncError with M_UNKNOWN_TOKEN should stop syncing.\"\"\"\n+        import types\n+        nio_mock = types.ModuleType(\"nio\")\n+\n+        class SyncError:\n+            def __init__(self, message):\n+                self.message = message\n+\n+        nio_mock.SyncError = SyncError\n+\n+        from gateway.platforms.matrix import MatrixAdapter\n+        adapter = MatrixAdapter.__new__(MatrixAdapter)\n+        adapter._closing = False\n+\n+        sync_count = 0\n+\n+        async def fake_sync(timeout=30000):\n+            nonlocal sync_count\n+            sync_count += 1\n+            return SyncError(\"M_UNKNOWN_TOKEN: Invalid access token\")\n+\n+        adapter._client = MagicMock()\n+        adapter._client.sync = fake_sync\n+\n+        async def run():\n+            import sys\n+            sys.modules[\"nio\"] = nio_mock\n+            try:\n+                await adapter._sync_loop()\n+            finally:\n+                del sys.modules[\"nio\"]\n+\n+        asyncio.run(run())\n+        assert sync_count == 1\n+\n+    def test_exception_with_401_stops_loop(self):\n+        \"\"\"An exception containing '401' should stop syncing.\"\"\"\n+        from gateway.platforms.matrix import MatrixAdapter\n+        adapter = MatrixAdapter.__new__(MatrixAdapter)\n+        adapter._closing = False\n+\n+        call_count = 0\n+\n+        async def fake_sync(timeout=30000):\n+            nonlocal call_count\n+            call_count += 1\n+            raise RuntimeError(\"HTTP 401 Unauthorized\")\n+\n+        adapter._client = MagicMock()\n+        adapter._client.sync = fake_sync\n+\n+        async def run():\n+            import types\n+            nio_mock = types.ModuleType(\"nio\")\n+            nio_mock.SyncError = type(\"SyncError\", (), {})\n+\n+            import sys\n+            sys.modules[\"nio\"] = nio_mock\n+            try:\n+                await adapter._sync_loop()\n+            finally:\n+                del sys.modules[\"nio\"]\n+\n+        asyncio.run(run())\n+        assert call_count == 1\n+\n+    def test_transient_error_retries(self):\n+        \"\"\"A transient error should retry (not stop immediately).\"\"\"\n+        from gateway.platforms.matrix import MatrixAdapter\n+        adapter = MatrixAdapter.__new__(MatrixAdapter)\n+        adapter._closing = False\n+\n+        call_count = 0\n+\n+        async def fake_sync(timeout=30000):\n+            nonlocal call_count\n+            call_count += 1\n+            if call_count >= 2:\n+                adapter._closing = True\n+                return MagicMock()  # Normal response\n+            raise ConnectionError(\"network timeout\")\n+\n+        adapter._client = MagicMock()\n+        adapter._client.sync = fake_sync\n+\n+        async def run():\n+            import types\n+            nio_mock = types.ModuleType(\"nio\")\n+            nio_mock.SyncError = type(\"SyncError\", (), {})\n+\n+            import sys\n+            sys.modules[\"nio\"] = nio_mock\n+            try:\n+                with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n+                    await adapter._sync_loop()\n+            finally:\n+                del sys.modules[\"nio\"]\n+\n+        asyncio.run(run())\n+        assert call_count >= 2"]},{"file":"tests/hermes_cli/test_setup_matrix_e2ee.py","hunks":["@@ -0,0 +1,31 @@\n+\"\"\"Test that setup.py has shutil available for Matrix E2EE auto-install.\"\"\"\n+import ast\n+\n+import pytest\n+\n+\n+def _parse_setup_imports():\n+    \"\"\"Parse setup.py and return top-level import names.\"\"\"\n+    with open(\"hermes_cli/setup.py\") as f:\n+        tree = ast.parse(f.read())\n+    names = set()\n+    for node in ast.walk(tree):\n+        if isinstance(node, ast.Import):\n+            for alias in node.names:\n+                names.add(alias.name)\n+        elif isinstance(node, ast.ImportFrom):\n+            for alias in node.names:\n+                names.add(alias.name)\n+    return names\n+\n+\n+class TestSetupShutilImport:\n+    def test_shutil_imported_at_module_level(self):\n+        \"\"\"shutil must be imported at module level so setup_gateway can use it\n+        for the matrix-nio auto-install path (line ~2126).\"\"\"\n+        names = _parse_setup_imports()\n+        assert \"shutil\" in names, (\n+            \"shutil is not imported at the top of hermes_cli/setup.py. \"\n+            \"This causes a NameError when the Matrix E2EE auto-install \"\n+            \"tries to call shutil.which('uv').\"\n+        )"]},{"file":"tools/send_message_tool.py","hunks":["@@ -719,7 +719,11 @@ async def _send_mattermost(token, extra, chat_id, message):\n \n \n async def _send_matrix(token, extra, chat_id, message):\n-    \"\"\"Send via Matrix Client-Server API.\"\"\"\n+    \"\"\"Send via Matrix Client-Server API.\n+\n+    Converts markdown to HTML for rich rendering in Matrix clients.\n+    Falls back to plain text if the ``markdown`` library is not installed.\n+    \"\"\"\n     try:\n         import aiohttp\n     except ImportError:","@@ -729,11 +733,24 @@ async def _send_matrix(token, extra, chat_id, message):\n         token = token or os.getenv(\"MATRIX_ACCESS_TOKEN\", \"\")\n         if not homeserver or not token:\n             return {\"error\": \"Matrix not configured (MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN required)\"}\n-        txn_id = f\"hermes_{int(time.time() * 1000)}\"\n+        txn_id = f\"hermes_{int(time.time() * 1000)}_{os.urandom(4).hex()}\"\n         url = f\"{homeserver}/_matrix/client/v3/rooms/{chat_id}/send/m.room.message/{txn_id}\"\n         headers = {\"Authorization\": f\"Bearer {token}\", \"Content-Type\": \"application/json\"}\n+\n+        # Build message payload with optional HTML formatted_body.\n+        payload = {\"msgtype\": \"m.text\", \"body\": message}\n+        try:\n+            import markdown as _md\n+            html = _md.markdown(message, extensions=[\"fenced_code\", \"tables\"])\n+            # Convert h1-h6 to bold for Element X compatibility.\n+            html = re.sub(r\"<h[1-6]>(.*?)</h[1-6]>\", r\"<strong>\\1</strong>\", html)\n+            payload[\"format\"] = \"org.matrix.custom.html\"\n+            payload[\"formatted_body\"] = html\n+        except ImportError:\n+            pass\n+\n         async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:\n-            async with session.put(url, headers=headers, json={\"msgtype\": \"m.text\", \"body\": message}) as resp:\n+            async with session.put(url, headers=headers, json=payload) as resp:\n                 if resp.status not in (200, 201):\n                     body = await resp.text()\n                     return {\"error\": f\"Matrix API error ({resp.status}): {body}\"}\n"]}]},{"kind":"pr","number":5936,"title":"fix(setup): graceful fallback when matrix-nio[e2e] install fails","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#7518","number":7518}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":27795,"title":"fix(docker): add libolm-dev so matrix lazy-install can build python-olm","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#33685","number":33685},{"kind":"pr","value":"#27795","number":27795},{"kind":"pr","value":"#4977","number":4977},{"kind":"pr","value":"#27507","number":27507},{"kind":"pr","value":"#33685","number":33685},{"kind":"pr","value":"#25495","number":25495}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":33685,"title":"fix(docker): add libolm-dev so matrix lazy-install can build python-olm","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["Dockerfile"]}],"trust":{"default_tier":"verified","includes_suggested":false}}