{"repo":"NousResearch/hermes-agent","public_id":"58376","display_label":"Redact bot tokens from Telegram transport error logs","summary":"Multiple fixes addressing bot token exposure in Telegram transport error messages and logs. Also includes coverage for Fireworks token prefixes in redaction logic.","subsystem":null,"counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":58501},"resolution":{"state":"resolved","by":"pr-65508"},"freshness":{"state":"verified","last_verified_at":"2026-07-16T11:25:55+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":58376,"pr_number":58501,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":58376,"pr_number":65508,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":58463,"pr_number":58501,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":58376,"title":"Telegram transport errors can expose bot tokens","url":null,"state":"closed"},{"kind":"pr","number":58377,"title":"fix(telegram): redact transport error tokens","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"6fcd470d5","number":0},{"kind":"pr","value":"#58501","number":58501}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":58501,"title":"fix(redact): cover Fireworks token prefixes + redact Telegram transport errors (#58466, #58377 salvage)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/redact.py","plugins/platforms/telegram/adapter.py","scripts/release.py","tests/agent/test_redact.py","tests/gateway/test_telegram_rich_messages.py"],"fix_hunks":[{"file":"agent/redact.py","hunks":["@@ -107,7 +107,9 @@\n     r\"brv_[A-Za-z0-9]{10,}\",            # ByteRover API key\n     r\"xai-[A-Za-z0-9]{30,}\",            # xAI (Grok) API key\n     r\"ntn_[A-Za-z0-9]{10,}\",            # Notion internal integration token\n+    r\"fw-[A-Za-z0-9]{30,}\",             # Fireworks AI API key\n     r\"fw_[A-Za-z0-9]{30,}\",             # Fireworks AI API key\n+    r\"fpk_[A-Za-z0-9]{30,}\",            # Fireworks AI project key\n ]\n \n # ENV assignment patterns: KEY=value where KEY contains a secret-like name."]},{"file":"plugins/platforms/telegram/adapter.py","hunks":["@@ -22,6 +22,19 @@\n logger = logging.getLogger(__name__)\n \n \n+def _redact_telegram_error_text(error: object) -> str:\n+    \"\"\"Redact secrets from Telegram transport errors before logging or returning them.\"\"\"\n+    text = \"\" if error is None else str(error)\n+    if not text:\n+        return text\n+    try:\n+        from agent.redact import redact_sensitive_text\n+\n+        return redact_sensitive_text(text, force=True)\n+    except Exception:\n+        return \"<telegram error redacted>\"\n+\n+\n def _consume_abandoned_task(task: asyncio.Task) -> None:\n     \"\"\"Observe a detached task's terminal exception to avoid noisy loop logs.\"\"\"\n     try:","@@ -1609,13 +1622,14 @@ async def _try_send_rich(\n                 _m = _re.search(r\"retry\\s+(?:in\\s+)?(\\d+)\", err_str, _re.IGNORECASE)\n                 if _m:\n                     _retry_after = float(_m.group(1))\n+            safe_error = _redact_telegram_error_text(exc)\n             logger.warning(\n                 \"[%s] sendRichMessage transient failure (no legacy resend): %s\",\n-                self.name, exc,\n+                self.name, safe_error,\n             )\n             return SendResult(\n                 success=False,\n-                error=str(exc),\n+                error=safe_error,\n                 retryable=(is_connect_timeout or not is_timeout),\n                 retry_after=_retry_after,\n             )","@@ -1704,13 +1718,14 @@ async def _try_edit_rich(\n                 _TimedOut = None\n             is_timeout = (_TimedOut and isinstance(exc, _TimedOut)) or \"timed out\" in err_str\n             is_connect_timeout = self._looks_like_connect_timeout(exc)\n+            safe_error = _redact_telegram_error_text(exc)\n             logger.warning(\n                 \"[%s] rich editMessageText transient failure (no legacy resend): %s\",\n-                self.name, exc,\n+                self.name, safe_error,\n             )\n             return SendResult(\n                 success=False,\n-                error=str(exc),\n+                error=safe_error,\n                 retryable=(is_connect_timeout or not is_timeout),\n             )\n         # Telegram won't echo rich content for messages that predate the bot's","@@ -1978,15 +1993,16 @@ async def _handle_polling_network_error(self, error: Exception) -> None:\n                 \"Telegram polling could not reconnect after %d network error retries. \"\n                 \"Restarting gateway.\" % MAX_NETWORK_RETRIES\n             )\n-            logger.error(\"[%s] %s Last error: %s\", self.name, message, error)\n+            logger.error(\"[%s] %s Last error: %s\", self.name, message, _redact_telegram_error_text(error))\n             self._set_fatal_error(\"telegram_network_error\", message, retryable=True)\n             await self._notify_fatal_error()\n             return\n \n         delay = min(BASE_DELAY * (2 ** (attempt - 1)), MAX_DELAY)\n+        safe_error = _redact_telegram_error_text(error)\n         logger.warning(\n             \"[%s] Telegram network error (attempt %d/%d), reconnecting in %ds. Error: %s\",\n-            self.name, attempt, MAX_NETWORK_RETRIES, delay, error,\n+            self.name, attempt, MAX_NETWORK_RETRIES, delay, safe_error,\n         )\n         await asyncio.sleep(delay)\n ","@@ -2058,7 +2074,8 @@ async def _handle_polling_network_error(self, error: Exception) -> None:\n                 self._background_tasks.add(probe)\n                 probe.add_done_callback(self._background_tasks.discard)\n         except Exception as retry_err:\n-            logger.warning(\"[%s] Telegram polling reconnect failed: %s\", self.name, retry_err)\n+            safe_retry_error = _redact_telegram_error_text(retry_err)\n+            logger.warning(\"[%s] Telegram polling reconnect failed: %s\", self.name, safe_retry_error)\n             # start_polling failed — polling is dead and no further error\n             # callbacks will fire, so schedule the next retry ourselves.\n             if not self.has_fatal_error:","@@ -3629,18 +3646,20 @@ async def send(\n                             err_lower = str(send_err).lower()\n                             if \"message to be replied not found\" in err_lower and reply_to_id is not None:\n                                 if private_dm_topic_send:\n+                                    safe_send_error = _redact_telegram_error_text(send_err)\n                                     return SendResult(\n                                         success=False,\n-                                        error=str(send_err),\n+                                        error=safe_send_error,\n                                         retryable=False,\n                                     )\n                                 # Original message was deleted before we\n                                 # could reply. For private-topic fallback\n                                 # sends, message_thread_id is only valid with\n                                 # the reply anchor, so drop both together.\n+                                safe_send_error = _redact_telegram_error_text(send_err)\n                                 logger.warning(\n                                     \"[%s] Reply target deleted, retrying without reply_to: %s\",\n-                                    self.name, send_err,\n+                                    self.name, safe_send_error,\n                                 )\n                                 reply_to_id = None\n                                 if metadata and metadata.get(\"telegram_dm_topic_reply_fallback\"):","@@ -3677,8 +3696,9 @@ async def send(\n                             await self._drain_general_connections_after_pool_timeout()\n                         if _send_attempt < 2:\n                             wait = 2 ** _send_attempt\n+                            safe_send_error = _redact_telegram_error_text(send_err)\n                             logger.warning(\"[%s] Network error on send (attempt %d/3), retrying in %ds: %s\",\n-                                           self.name, _send_attempt + 1, wait, send_err)\n+                                           self.name, _send_attempt + 1, wait, safe_send_error)\n                             await asyncio.sleep(wait)\n                         else:\n                             raise","@@ -3687,12 +3707,13 @@ async def send(\n                         if retry_after is not None or \"retry after\" in str(send_err).lower():\n                             if _send_attempt < 2:\n                                 wait = float(retry_after) if retry_after is not None else 1.0\n+                                safe_send_error = _redact_telegram_error_text(send_err)\n                                 logger.warning(\n                                     \"[%s] Telegram flood control on send (attempt %d/3), retrying in %.1fs: %s\",\n                                     self.name,\n                                     _send_attempt + 1,\n                                     wait,\n-                                    send_err,\n+                                    safe_send_error,\n                                 )\n                                 await asyncio.sleep(wait)\n                                 continue","@@ -3726,7 +3747,8 @@ async def send(\n             )\n             \n         except Exception as e:\n-            logger.error(\"[%s] Failed to send Telegram message: %s\", self.name, e, exc_info=True)\n+            safe_error = _redact_telegram_error_text(e)\n+            logger.error(\"[%s] Failed to send Telegram message: %s\", self.name, safe_error)\n             err_str = str(e).lower()\n             error_kind = classify_send_error(e)\n             # Message too long — content exceeded 4096 chars. Return failure so","@@ -3748,7 +3770,7 @@ async def send(\n             is_pool_timeout = self._looks_like_pool_timeout(e)\n             return SendResult(\n                 success=False,\n-                error=str(e),\n+                error=safe_error,\n                 retryable=(is_connect_timeout or is_pool_timeout or not is_timeout),\n                 error_kind=error_kind,\n             )","@@ -3862,10 +3884,11 @@ async def edit_message(\n                 if \"not modified\" in str(fmt_err).lower():\n                     return SendResult(success=True, message_id=message_id)\n                 # Fallback: strip MarkdownV2 escapes and retry as clean plain text\n+                safe_format_error = _redact_telegram_error_text(fmt_err)\n                 logger.warning(\n                     \"[%s] MarkdownV2 edit failed, falling back to plain text: %s\",\n                     self.name,\n-                    fmt_err,\n+                    safe_format_error,\n                 )\n                 _plain = _strip_mdv2(content) if content else content\n                 await self._bot.edit_message_text(","@@ -3920,11 +3943,12 @@ async def edit_message(\n                     )\n                     return SendResult(success=True, message_id=message_id)\n                 except Exception as retry_err:\n+                    safe_retry_error = _redact_telegram_error_text(retry_err)\n                     logger.error(\n                         \"[%s] Edit retry failed after flood wait: %s\",\n-                        self.name, retry_err,\n+                        self.name, safe_retry_error,\n                     )\n-                    return SendResult(success=False, error=str(retry_err))\n+                    return SendResult(success=False, error=safe_retry_error)\n             # Transient network errors (ConnectError, timeouts, server\n             # disconnects) should not permanently disable progress-message\n             # editing.  Mark the result retryable so the caller knows it","@@ -3945,21 +3969,22 @@ async def edit_message(\n             )\n             _is_transient = any(m in err_str for m in _transient_markers)\n             if _is_transient:\n+                safe_error = _redact_telegram_error_text(e)\n                 logger.warning(\n                     \"[%s] Transient network error editing message %s (will retry): %s\",\n                     self.name,\n                     message_id,\n-                    e,\n+                    safe_error,\n                 )\n-                return SendResult(success=False, error=str(e), retryable=True)\n+                return SendResult(success=False, error=safe_error, retryable=True)\n+            safe_error = _redact_telegram_error_text(e)\n             logger.error(\n                 \"[%s] Failed to edit Telegram message %s: %s\",\n                 self.name,\n                 message_id,\n-                e,\n-                exc_info=True,\n+                safe_error,\n             )\n-            return SendResult(success=False, error=str(e))\n+            return SendResult(success=False, error=safe_error)\n \n     def _truncate_stream_overflow_preview(self, content: str) -> str:\n         \"\"\"Return a one-message preview for oversized streaming edits."]},{"file":"scripts/release.py","hunks":["@@ -45,6 +45,7 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"ooiuuii\",  # PR #58466/#58377 salvage (redact: fireworks fw-/fpk_ prefixes; telegram: redact bot tokens out of transport error strings)\n     \"[redacted:email]\": \"msh01\",  # PR #58250 salvage (telegram: wall-clock init timeout via daemon-thread deadline + abandon the shielded initialize task on timeout so the retry ladder advances instead of hanging on attempt 1/8 under s6 supervision; #58236). Also covers PR #58276 salvage (compression: preserve a real user turn after compaction; #55677).\n     \"[redacted:email]\": \"huanshan5195\",  # PR #57601 salvage (custom-provider: emit reasoning_effort at the live CustomProfile path so GLM-5.2/ARK/vLLM/Ollama endpoints receive it; + \"max\" reasoning level)\n     \"[redacted:email]\": \"infinitycrew39\",  # PR #56431 salvage (honor live vLLM context limits on local endpoints)"]},{"file":"tests/agent/test_redact.py","hunks":["@@ -59,6 +59,22 @@ def test_fal_key(self):\n         result = redact_sensitive_text(\"fal_abc123def456ghi789jkl\")\n         assert \"abc123def456\" not in result\n \n+    def test_fireworks_keys(self):\n+        samples = [\n+            \"fw-\" + \"A\" * 40,\n+            \"fw_\" + \"B\" * 40,\n+            \"fpk_\" + \"C\" * 40,\n+        ]\n+\n+        for token in samples:\n+            result = redact_sensitive_text(f\"provider error {token}\")\n+            assert token not in result\n+            assert \"...\" in result\n+\n+    def test_short_fireworks_like_words_unchanged(self):\n+        text = \"fw-tooshort fw_tooshort fpk_tooshort\"\n+        assert redact_sensitive_text(text) == text\n+\n     def test_notion_internal_integration_token(self):\n         result = redact_sensitive_text(\"ntn_abc123def456ghi789jkl\")\n         assert \"abc123def456\" not in result"]},{"file":"tests/gateway/test_telegram_rich_messages.py","hunks":["@@ -10,6 +10,7 @@\n ``TelegramAdapter`` and wire a mock bot.\n \"\"\"\n \n+import logging\n from types import SimpleNamespace\n from unittest.mock import AsyncMock, MagicMock\n ","@@ -475,6 +476,53 @@ async def test_transient_timeout_is_not_retryable():\n     assert result.retryable is False\n \n \n[redacted:email]\n+async def test_rich_transport_error_redacts_bot_token_even_when_redaction_disabled(monkeypatch):\n+    import agent.redact as redact\n+\n+    monkeypatch.setattr(redact, \"_REDACT_ENABLED\", False)\n+    token = \"[redacted:secret-assignment]\"\n+    adapter = _make_adapter()\n+    adapter._bot.do_api_request = AsyncMock(\n+        side_effect=NetworkError(\n+            f\"Timed out requesting https://api.telegram.org/bot{token}/sendRichMessage\"\n+        )\n+    )\n+\n+    result = await adapter.send(\"12345\", RICH_CONTENT)\n+\n+    assert result.success is False\n+    assert result.error is not None\n+    assert token not in result.error\n+    assert \"bot123456789:***/sendRichMessage\" in result.error\n+    adapter._bot.send_message.assert_not_called()\n+\n+\n[redacted:email]\n+async def test_legacy_send_error_redacts_bot_token_without_traceback(monkeypatch, caplog):\n+    import agent.redact as redact\n+\n+    monkeypatch.setattr(redact, \"_REDACT_ENABLED\", False)\n+    token = \"[redacted:secret-assignment]\"\n+    adapter = _make_adapter({\"rich_messages\": False})\n+    adapter._bot.send_message = AsyncMock(\n+        side_effect=BadRequest(\n+            f\"Bad Request: https://api.telegram.org/bot{token}/sendMessage\"\n+        )\n+    )\n+\n+    with caplog.at_level(logging.ERROR):\n+        result = await adapter.send(\"12345\", \"Plain legacy content.\")\n+\n+    assert result.success is False\n+    assert result.error is not None\n+    assert token not in result.error\n+    assert \"bot123456789:***/sendMessage\" in result.error\n+    assert token not in caplog.text\n+    assert \"bot123456789:***/sendMessage\" in caplog.text\n+    adapter._bot.do_api_request.assert_not_called()\n+\n+\n @pytest.mark.asyncio\n async def test_routing_thread_id_maps_to_message_thread_id():\n     adapter = _make_adapter()","@@ -823,6 +871,32 @@ async def test_finalize_edit_plain_content_stays_legacy():\n     adapter._bot.edit_message_text.assert_awaited()\n \n \n[redacted:email]\n+async def test_legacy_edit_error_logs_redacted_bot_token_without_traceback(monkeypatch, caplog):\n+    import agent.redact as redact\n+\n+    monkeypatch.setattr(redact, \"_REDACT_ENABLED\", False)\n+    token = \"[redacted:secret-assignment]\"\n+    adapter = _make_adapter()\n+    adapter._bot.edit_message_text = AsyncMock(\n+        side_effect=BadRequest(\n+            f\"Bad Request: https://api.telegram.org/bot{token}/editMessageText\"\n+        )\n+    )\n+\n+    with caplog.at_level(logging.WARNING):\n+        result = await adapter.edit_message(\n+            \"12345\", \"555\", \"Just a normal answer.\", finalize=True,\n+        )\n+\n+    assert result.success is False\n+    assert result.error is not None\n+    assert token not in result.error\n+    assert \"bot123456789:***/editMessageText\" in result.error\n+    assert token not in caplog.text\n+    assert \"bot123456789:***/editMessageText\" in caplog.text\n+\n+\n @pytest.mark.asyncio\n async def test_finalize_edit_cjk_rich_content_stays_legacy_to_avoid_tdesktop_garble():\n     adapter = _make_adapter()\n"]}]},{"kind":"pr","number":58594,"title":"fix(telegram): redact bot tokens from transport error logs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65508","number":65508}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":65508,"title":"fix(telegram): redact bot tokens from all remaining transport-error sites (#58594 salvage)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/telegram/adapter.py","tests/gateway/test_telegram_error_redaction.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}