# NousResearch/hermes-agent · Redact bot tokens from Telegram transport error logs

## Open now

- Nothing open on GitHub.

Multiple fixes addressing bot token exposure in Telegram transport error messages and logs. Also includes coverage for Fireworks token prefixes in redaction logic.

## TL;DR

- 5 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 2 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #58501. Verify it addresses your case.
- **Recommended:** read PR #58501 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-65508`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.40`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #58376: Telegram transport errors can expose bot tokens

## Prior & competing PRs

- **#58501: fix(redact): cover Fireworks token prefixes + redact Telegram transport errors (#58466, #58377 salvage)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `agent/redact.py`

    ```diff
    @@ -107,7 +107,9 @@
         r"brv_[A-Za-z0-9]{10,}",            # ByteRover API key
         r"xai-[A-Za-z0-9]{30,}",            # xAI (Grok) API key
         r"ntn_[A-Za-z0-9]{10,}",            # Notion internal integration token
    +    r"fw-[A-Za-z0-9]{30,}",             # Fireworks AI API key
         r"fw_[A-Za-z0-9]{30,}",             # Fireworks AI API key
    +    r"fpk_[A-Za-z0-9]{30,}",            # Fireworks AI project key
     ]
     
     # ENV assignment patterns: KEY=value where KEY contains a secret-like name.
    ```
  - `plugins/platforms/telegram/adapter.py`

    ```diff
    @@ -22,6 +22,19 @@
     logger = logging.getLogger(__name__)
     
     
    +def _redact_telegram_error_text(error: object) -> str:
    +    """Redact secrets from Telegram transport errors before logging or returning them."""
    +    text = "" if error is None else str(error)
    +    if not text:
    +        return text
    +    try:
    +        from agent.redact import redact_sensitive_text
    +
    +        return redact_sensitive_text(text, force=True)
    +    except Exception:
    +        return "<telegram error redacted>"
    +
    +
     def _consume_abandoned_task(task: asyncio.Task) -> None:
         """Observe a detached task's terminal exception to avoid noisy loop logs."""
         try:
    ```

    ```diff
    @@ -1609,13 +1622,14 @@ async def _try_send_rich(
                     _m = _re.search(r"retry\s+(?:in\s+)?(\d+)", err_str, _re.IGNORECASE)
                     if _m:
                         _retry_after = float(_m.group(1))
    +            safe_error = _redact_telegram_error_text(exc)
                 logger.warning(
                     "[%s] sendRichMessage transient failure (no legacy resend): %s",
    -                self.name, exc,
    +                self.name, safe_error,
                 )
                 return SendResult(
                     success=False,
    -                error=str(exc),
    +                error=safe_error,
                     retryable=(is_connect_timeout or not is_timeout),
                     retry_after=_retry_after,
                 )
    ```

    ```diff
    @@ -1704,13 +1718,14 @@ async def _try_edit_rich(
                     _TimedOut = None
                 is_timeout = (_TimedOut and isinstance(exc, _TimedOut)) or "timed out" in err_str
                 is_connect_timeout = self._looks_like_connect_timeout(exc)
    +            safe_error = _redact_telegram_error_text(exc)
                 logger.warning(
                     "[%s] rich editMessageText transient failure (no legacy resend): %s",
    -                self.name, exc,
    +                self.name, safe_error,
                 )
                 return SendResult(
                     success=False,
    -                error=str(exc),
    +                error=safe_error,
                     retryable=(is_connect_timeout or not is_timeout),
                 )
             # Telegram won't echo rich content for messages that predate the bot's
    ```

    ```diff
    @@ -1978,15 +1993,16 @@ async def _handle_polling_network_error(self, error: Exception) -> None:
                     "Telegram polling could not reconnect after %d network error retries. "
                     "Restarting gateway." % MAX_NETWORK_RETRIES
                 )
    -            logger.error("[%s] %s Last error: %s", self.name, message, error)
    +            logger.error("[%s] %s Last error: %s", self.name, message, _redact_telegram_error_text(error))
                 self._set_fatal_error("telegram_network_error", message, retryable=True)
                 await self._notify_fatal_error()
                 return
     
             delay = min(BASE_DELAY * (2 ** (attempt - 1)), MAX_DELAY)
    +        safe_error = _redact_telegram_error_text(error)
             logger.warning(
                 "[%s] Telegram network error (attempt %d/%d), reconnecting in %ds. Error: %s",
    -            self.name, attempt, MAX_NETWORK_RETRIES, delay, error,
    +            self.name, attempt, MAX_NETWORK_RETRIES, delay, safe_error,
             )
             await asyncio.sleep(delay)
     
    ```

    ```diff
    @@ -2058,7 +2074,8 @@ async def _handle_polling_network_error(self, error: Exception) -> None:
                     self._background_tasks.add(probe)
                     probe.add_done_callback(self._background_tasks.discard)
             except Exception as retry_err:
    -            logger.warning("[%s] Telegram polling reconnect failed: %s", self.name, retry_err)
    +            safe_retry_error = _redact_telegram_error_text(retry_err)
    +            logger.warning("[%s] Telegram polling reconnect failed: %s", self.name, safe_retry_error)
                 # start_polling failed — polling is dead and no further error
                 # callbacks will fire, so schedule the next retry ourselves.
                 if not self.has_fatal_error:
    ```

    ```diff
    @@ -3629,18 +3646,20 @@ async def send(
                                 err_lower = str(send_err).lower()
                                 if "message to be replied not found" in err_lower and reply_to_id is not None:
                                     if private_dm_topic_send:
    +                                    safe_send_error = _redact_telegram_error_text(send_err)
                                         return SendResult(
                                             success=False,
    -                                        error=str(send_err),
    +                                        error=safe_send_error,
                                             retryable=False,
                                         )
                                     # Original message was deleted before we
                                     # could reply. For private-topic fallback
                                     # sends, message_thread_id is only valid with
                                     # the reply anchor, so drop both together.
    +                                safe_send_error = _redact_telegram_error_text(send_err)
                                     logger.warning(
                                         "[%s] Reply target deleted, retrying without reply_to: %s",
    -                                    self.name, send_err,
    +                                    self.name, safe_send_error,
                                     )
                                     reply_to_id = None
                                     if metadata and metadata.get("telegram_dm_topic_reply_fallback"):
    ```

    ```diff
    @@ -3677,8 +3696,9 @@ async def send(
                                 await self._drain_general_connections_after_pool_timeout()
                             if _send_attempt < 2:
                                 wait = 2 ** _send_attempt
    +                            safe_send_error = _redact_telegram_error_text(send_err)
                                 logger.warning("[%s] Network error on send (attempt %d/3), retrying in %ds: %s",
    -                                           self.name, _send_attempt + 1, wait, send_err)
    +                                           self.name, _send_attempt + 1, wait, safe_send_error)
                                 await asyncio.sleep(wait)
                             else:
                                 raise
    ```

    ```diff
    @@ -3687,12 +3707,13 @@ async def send(
                             if retry_after is not None or "retry after" in str(send_err).lower():
                                 if _send_attempt < 2:
                                     wait = float(retry_after) if retry_after is not None else 1.0
    +                                safe_send_error = _redact_telegram_error_text(send_err)
                                     logger.warning(
                                         "[%s] Telegram flood control on send (attempt %d/3), retrying in %.1fs: %s",
                                         self.name,
                                         _send_attempt + 1,
                                         wait,
    -                                    send_err,
    +                                    safe_send_error,
                                     )
                                     await asyncio.sleep(wait)
                                     continue
    ```

    ```diff
    @@ -3726,7 +3747,8 @@ async def send(
                 )
                 
             except Exception as e:
    -            logger.error("[%s] Failed to send Telegram message: %s", self.name, e, exc_info=True)
    +            safe_error = _redact_telegram_error_text(e)
    +            logger.error("[%s] Failed to send Telegram message: %s", self.name, safe_error)
                 err_str = str(e).lower()
                 error_kind = classify_send_error(e)
                 # Message too long — content exceeded 4096 chars. Return failure so
    ```

    ```diff
    @@ -3748,7 +3770,7 @@ async def send(
                 is_pool_timeout = self._looks_like_pool_timeout(e)
                 return SendResult(
                     success=False,
    -                error=str(e),
    +                error=safe_error,
                     retryable=(is_connect_timeout or is_pool_timeout or not is_timeout),
                     error_kind=error_kind,
                 )
    ```

    ```diff
    @@ -3862,10 +3884,11 @@ async def edit_message(
                     if "not modified" in str(fmt_err).lower():
                         return SendResult(success=True, message_id=message_id)
                     # Fallback: strip MarkdownV2 escapes and retry as clean plain text
    +                safe_format_error = _redact_telegram_error_text(fmt_err)
                     logger.warning(
                         "[%s] MarkdownV2 edit failed, falling back to plain text: %s",
                         self.name,
    -                    fmt_err,
    +                    safe_format_error,
                     )
                     _plain = _strip_mdv2(content) if content else content
                     await self._bot.edit_message_text(
    ```

    ```diff
    @@ -3920,11 +3943,12 @@ async def edit_message(
                         )
                         return SendResult(success=True, message_id=message_id)
                     except Exception as retry_err:
    +                    safe_retry_error = _redact_telegram_error_text(retry_err)
                         logger.error(
                             "[%s] Edit retry failed after flood wait: %s",
    -                        self.name, retry_err,
    +                        self.name, safe_retry_error,
                         )
    -                    return SendResult(success=False, error=str(retry_err))
    +                    return SendResult(success=False, error=safe_retry_error)
                 # Transient network errors (ConnectError, timeouts, server
                 # disconnects) should not permanently disable progress-message
                 # editing.  Mark the result retryable so the caller knows it
    ```

    ```diff
    @@ -3945,21 +3969,22 @@ async def edit_message(
                 )
                 _is_transient = any(m in err_str for m in _transient_markers)
                 if _is_transient:
    +                safe_error = _redact_telegram_error_text(e)
                     logger.warning(
                         "[%s] Transient network error editing message %s (will retry): %s",
                         self.name,
                         message_id,
    -                    e,
    +                    safe_error,
                     )
    -                return SendResult(success=False, error=str(e), retryable=True)
    +                return SendResult(success=False, error=safe_error, retryable=True)
    +            safe_error = _redact_telegram_error_text(e)
                 logger.error(
                     "[%s] Failed to edit Telegram message %s: %s",
                     self.name,
                     message_id,
    -                e,
    -                exc_info=True,
    +                safe_error,
                 )
    -            return SendResult(success=False, error=str(e))
    +            return SendResult(success=False, error=safe_error)
     
         def _truncate_stream_overflow_preview(self, content: str) -> str:
             """Return a one-message preview for oversized streaming edits.
    ```
  - `scripts/release.py`

    ```diff
    @@ -45,6 +45,7 @@
     
     # Auto-extracted from noreply emails + manual overrides
     AUTHOR_MAP = {
    +    "[redacted:email]": "ooiuuii",  # PR #58466/#58377 salvage (redact: fireworks fw-/fpk_ prefixes; telegram: redact bot tokens out of transport error strings)
         "[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).
         "[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)
         "[redacted:email]": "infinitycrew39",  # PR #56431 salvage (honor live vLLM context limits on local endpoints)
    ```
  - `tests/agent/test_redact.py`

    ```diff
    @@ -59,6 +59,22 @@ def test_fal_key(self):
             result = redact_sensitive_text("fal_abc123def456ghi789jkl")
             assert "abc123def456" not in result
     
    +    def test_fireworks_keys(self):
    +        samples = [
    +            "fw-" + "A" * 40,
    +            "fw_" + "B" * 40,
    +            "fpk_" + "C" * 40,
    +        ]
    +
    +        for token in samples:
    +            result = redact_sensitive_text(f"provider error {token}")
    +            assert token not in result
    +            assert "..." in result
    +
    +    def test_short_fireworks_like_words_unchanged(self):
    +        text = "fw-tooshort fw_tooshort fpk_tooshort"
    +        assert redact_sensitive_text(text) == text
    +
         def test_notion_internal_integration_token(self):
             result = redact_sensitive_text("ntn_abc123def456ghi789jkl")
             assert "abc123def456" not in result
    ```
  - `tests/gateway/test_telegram_rich_messages.py`

    ```diff
    @@ -10,6 +10,7 @@
     ``TelegramAdapter`` and wire a mock bot.
     """
     
    +import logging
     from types import SimpleNamespace
     from unittest.mock import AsyncMock, MagicMock
     
    ```

    ```diff
    @@ -475,6 +476,53 @@ async def test_transient_timeout_is_not_retryable():
         assert result.retryable is False
     
     
    [redacted:email]
    +async def test_rich_transport_error_redacts_bot_token_even_when_redaction_disabled(monkeypatch):
    +    import agent.redact as redact
    +
    +    monkeypatch.setattr(redact, "_REDACT_ENABLED", False)
    +    token = "[redacted:secret-assignment]"
    +    adapter = _make_adapter()
    +    adapter._bot.do_api_request = AsyncMock(
    +        side_effect=NetworkError(
    +            f"Timed out requesting https://api.telegram.org/bot{token}/sendRichMessage"
    +        )
    +    )
    +
    +    result = await adapter.send("12345", RICH_CONTENT)
    +
    +    assert result.success is False
    +    assert result.error is not None
    +    assert token not in result.error
    +    assert "bot123456789:***/sendRichMessage" in result.error
    +    adapter._bot.send_message.assert_not_called()
    +
    +
    [redacted:email]
    +async def test_legacy_send_error_redacts_bot_token_without_traceback(monkeypatch, caplog):
    +    import agent.redact as redact
    +
    +    monkeypatch.setattr(redact, "_REDACT_ENABLED", False)
    +    token = "[redacted:secret-assignment]"
    +    adapter = _make_adapter({"rich_messages": False})
    +    adapter._bot.send_message = AsyncMock(
    +        side_effect=BadRequest(
    +            f"Bad Request: https://api.telegram.org/bot{token}/sendMessage"
    +        )
    +    )
    +
    +    with caplog.at_level(logging.ERROR):
    +        result = await adapter.send("12345", "Plain legacy content.")
    +
    +    assert result.success is False
    +    assert result.error is not None
    +    assert token not in result.error
    +    assert "bot123456789:***/sendMessage" in result.error
    +    assert token not in caplog.text
    +    assert "bot123456789:***/sendMessage" in caplog.text
    +    adapter._bot.do_api_request.assert_not_called()
    +
    +
     @pytest.mark.asyncio
     async def test_routing_thread_id_maps_to_message_thread_id():
         adapter = _make_adapter()
    ```

    ```diff
    @@ -823,6 +871,32 @@ async def test_finalize_edit_plain_content_stays_legacy():
         adapter._bot.edit_message_text.assert_awaited()
     
     
    [redacted:email]
    +async def test_legacy_edit_error_logs_redacted_bot_token_without_traceback(monkeypatch, caplog):
    +    import agent.redact as redact
    +
    +    monkeypatch.setattr(redact, "_REDACT_ENABLED", False)
    +    token = "[redacted:secret-assignment]"
    +    adapter = _make_adapter()
    +    adapter._bot.edit_message_text = AsyncMock(
    +        side_effect=BadRequest(
    +            f"Bad Request: https://api.telegram.org/bot{token}/editMessageText"
    +        )
    +    )
    +
    +    with caplog.at_level(logging.WARNING):
    +        result = await adapter.edit_message(
    +            "12345", "555", "Just a normal answer.", finalize=True,
    +        )
    +
    +    assert result.success is False
    +    assert result.error is not None
    +    assert token not in result.error
    +    assert "bot123456789:***/editMessageText" in result.error
    +    assert token not in caplog.text
    +    assert "bot123456789:***/editMessageText" in caplog.text
    +
    +
     @pytest.mark.asyncio
     async def test_finalize_edit_cjk_rich_content_stays_legacy_to_avoid_tdesktop_garble():
         adapter = _make_adapter()
    ```
- **#65508: fix(telegram): redact bot tokens from all remaining transport-error sites (#58594 salvage)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `plugins/platforms/telegram/adapter.py`, `tests/gateway/test_telegram_error_redaction.py`
- **#58377: fix(telegram): redact transport error tokens**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `6fcd470d5`, `#58501`
- **#58594: fix(telegram): redact bot tokens from transport error logs**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#65508`

## Fix status

- Merged to main in PR #58501 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-16`.
