From 45b0d8cab5fd81adfd4e7b3a874e5c3c3d5003dd Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:56:29 -0700 Subject: [PATCH] feat(gateway): one gateway.trust_env key controls aiohttp proxy-env honoring at every adapter site (#48820 bug 3) Every gateway/plugin platform adapter hard-coded aiohttp.ClientSession(trust_env=True) (~20 sites), so a gateway launched by a Windows Scheduled Task that inherits a stale HTTP_PROXY (Clash/V2Ray on 127.0.0.1:7890) looped on 'Cannot connect to host' with no way to opt out short of NO_PROXY hacks per vendor host. - gateway/platforms/base.py: gateway_trust_env() reads gateway.trust_env (default true); resolve_proxy_url() skips generic HTTP(S)_PROXY/ALL_PROXY + macOS system-proxy auto-detect when false (explicit per-platform vars still win). - All aiohttp ClientSession sites in weixin, qqbot, matrix, line, wecom, slack, sms, teams, google_chat now pass trust_env=gateway_trust_env(); mattermost + homeassistant bare sessions gain the same kwarg (intent of #70119 / #56229). - DEFAULT_CONFIG + cli-config.yaml.example + messaging docs. - tests/gateway/test_gateway_trust_env.py: config flip + no-bare-literal sweep. Reported-by: @ranlingfeng (#48820), @frontnopipe-cloud (#76309) Co-authored-by: rcarrata Co-authored-by: Backroads4Me --- cli-config.yaml.example | 7 +++ contributors/emails/TEDLANHAM@GMAIL.COM | 1 + .../emails/rcarratalasanchez@gmail.com | 1 + gateway/platforms/base.py | 29 +++++++++++- gateway/platforms/qqbot/adapter.py | 3 +- gateway/platforms/weixin.py | 13 ++--- hermes_cli/config_defaults.py | 11 +++++ plugins/platforms/google_chat/adapter.py | 3 +- plugins/platforms/homeassistant/adapter.py | 12 +++-- plugins/platforms/line/adapter.py | 11 +++-- plugins/platforms/matrix/adapter.py | 5 +- plugins/platforms/mattermost/adapter.py | 4 +- plugins/platforms/slack/adapter.py | 3 +- plugins/platforms/sms/adapter.py | 5 +- plugins/platforms/teams/adapter.py | 3 +- plugins/platforms/wecom/adapter.py | 3 +- tests/gateway/test_gateway_trust_env.py | 47 +++++++++++++++++++ website/docs/user-guide/messaging/index.md | 18 +++++++ 18 files changed, 153 insertions(+), 26 deletions(-) create mode 100644 contributors/emails/TEDLANHAM@GMAIL.COM create mode 100644 contributors/emails/rcarratalasanchez@gmail.com create mode 100644 tests/gateway/test_gateway_trust_env.py diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 24991f6eaf..848482adfc 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -1205,6 +1205,13 @@ gateway: # if an agent has not unwound. Keep it below the service-manager stop budget. # signal_interrupt_grace_timeout: 1 + # Let platform adapters honor HTTP_PROXY / HTTPS_PROXY / NO_PROXY (and + # SSL_CERT_FILE) from the process environment, plus macOS system-proxy + # auto-detection. Set to false when the gateway inherits a proxy it must not + # use (e.g. a Windows Scheduled Task picking up a local Clash/V2Ray proxy that + # isn't running). Explicit per-platform vars like DISCORD_PROXY still apply. + # trust_env: true + # ============================================================================= # Toolsets # ============================================================================= diff --git a/contributors/emails/TEDLANHAM@GMAIL.COM b/contributors/emails/TEDLANHAM@GMAIL.COM new file mode 100644 index 0000000000..6de0474e18 --- /dev/null +++ b/contributors/emails/TEDLANHAM@GMAIL.COM @@ -0,0 +1 @@ +Backroads4Me diff --git a/contributors/emails/rcarratalasanchez@gmail.com b/contributors/emails/rcarratalasanchez@gmail.com new file mode 100644 index 0000000000..6fffcb4a82 --- /dev/null +++ b/contributors/emails/rcarratalasanchez@gmail.com @@ -0,0 +1 @@ +rcarrata diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 3ad1ea18d4..5cc06ccd2f 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -489,7 +489,8 @@ def resolve_proxy_url( 2. macOS system proxy via ``scutil --proxy`` (auto-detect) Returns *None* if no proxy is found, or if NO_PROXY/no_proxy matches one - of ``target_hosts``. + of ``target_hosts``. Steps 1-2 are skipped when ``gateway.trust_env`` is + false in config.yaml (see :func:`gateway_trust_env`). """ if platform_env_var: value = (os.environ.get(platform_env_var) or "").strip() @@ -497,6 +498,10 @@ def resolve_proxy_url( if should_bypass_proxy(target_hosts): return None return normalize_proxy_url(value) + if not gateway_trust_env(): + # gateway.trust_env: false — ignore inherited generic proxy env and + # system proxy; only the explicit per-platform var above is honored. + return None for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy"): value = (os.environ.get(key) or "").strip() @@ -540,6 +545,28 @@ def proxy_kwargs_for_bot(proxy_url: str | None) -> dict: return {"proxy": proxy_url} +def gateway_trust_env() -> bool: + """Return the ``trust_env`` value every gateway ``aiohttp.ClientSession`` uses. + + Reads ``gateway.trust_env`` from config.yaml (default ``True``: honor + ``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``NO_PROXY`` / ``SSL_CERT_FILE`` from the + process environment). Set it to ``false`` when the gateway inherits a + proxy env it should not use — e.g. a Windows Scheduled Task picking up a + Clash/V2Ray ``HTTP_PROXY`` the interactive shell never sees (#48820). + One knob for all platform adapters; fail-open to the default if config + is unreadable. + """ + try: + from hermes_cli.config import load_config_readonly as _load_config + gw = (_load_config() or {}).get("gateway") or {} + except Exception: + return True + value = gw.get("trust_env", True) if isinstance(gw, dict) else True + if isinstance(value, str): + return value.strip().lower() not in {"0", "false", "no", "off"} + return bool(value) if value is not None else True + + def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]: """Build kwargs for standalone ``aiohttp.ClientSession`` with proxy. diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index d84ab46014..b8a9470817 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -62,6 +62,7 @@ except ImportError: from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( + gateway_trust_env, BasePlatformAdapter, MessageEvent, MessageType, @@ -496,7 +497,7 @@ class QQAdapter(BasePlatformAdapter): # Honor WSL proxy env for QQ WebSocket. Hermes upgrades overwrite this # local patch, so QQ can regress to direct-connect timeouts after update. - self._session = aiohttp.ClientSession(trust_env=True) + self._session = aiohttp.ClientSession(trust_env=gateway_trust_env()) ws_proxy = ( os.getenv("WSS_PROXY") or os.getenv("wss_proxy") diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index 8c2b18b765..ccf610fc7a 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -58,6 +58,7 @@ except ImportError: # pragma: no cover - dependency gate from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator, greedy_pack_blocks from gateway.platforms.base import ( + gateway_trust_env, BasePlatformAdapter, MessageEvent, MessageType, @@ -141,7 +142,7 @@ def _make_ssl_connector() -> Optional["aiohttp.TCPConnector"]: some system CA stores (notably Homebrew's OpenSSL on macOS Apple Silicon). When ``certifi`` is installed, use its Mozilla CA bundle to guarantee verification. Otherwise fall back to aiohttp's default (which honors - ``SSL_CERT_FILE`` env var via ``trust_env=True``). + ``SSL_CERT_FILE`` env var when ``gateway.trust_env`` is on). Uses a tight ``keepalive_timeout=2`` (default aiohttp: 30s) so idle connections drain promptly behind proxies like Cloudflare Warp that @@ -1048,7 +1049,7 @@ async def qr_login( if not AIOHTTP_AVAILABLE: raise RuntimeError("aiohttp is required for Weixin QR login") - async with aiohttp.ClientSession(trust_env=True, connector=_make_ssl_connector()) as session: + async with aiohttp.ClientSession(trust_env=gateway_trust_env(), connector=_make_ssl_connector()) as session: try: qr_resp = await _api_get( session, @@ -1318,13 +1319,13 @@ class WeixinAdapter(BasePlatformAdapter): except Exception as exc: logger.debug("[%s] Token lock unavailable (non-fatal): %s", self.name, exc) - self._poll_session = aiohttp.ClientSession(trust_env=True, connector=_make_ssl_connector()) + self._poll_session = aiohttp.ClientSession(trust_env=gateway_trust_env(), connector=_make_ssl_connector()) # Disable aiohttp's built-in ClientTimeout (total=None) to prevent # "Timeout context manager should be used inside a task" errors when # send() is invoked via asyncio.run_coroutine_threadsafe() from cron. # Timeout is managed externally via asyncio.wait_for() in _api_post/_api_get. _no_aiohttp_timeout = aiohttp.ClientTimeout(total=None, connect=None, sock_connect=None, sock_read=None) - self._send_session = aiohttp.ClientSession(trust_env=True, connector=_make_ssl_connector(), timeout=_no_aiohttp_timeout) + self._send_session = aiohttp.ClientSession(trust_env=gateway_trust_env(), connector=_make_ssl_connector(), timeout=_no_aiohttp_timeout) self._token_store.restore(self._account_id) self._poll_task = asyncio.create_task(self._poll_loop(), name="weixin-poll") self._mark_connected() @@ -1452,7 +1453,7 @@ class WeixinAdapter(BasePlatformAdapter): return old = self._poll_session self._poll_session = aiohttp.ClientSession( - trust_env=True, connector=_make_ssl_connector() + trust_env=gateway_trust_env(), connector=_make_ssl_connector() ) if old is not None and not old.closed: try: @@ -2407,7 +2408,7 @@ async def send_weixin_direct( "context_token_used": bool(context_token), } - async with aiohttp.ClientSession(trust_env=True, connector=_make_ssl_connector()) as session: + async with aiohttp.ClientSession(trust_env=gateway_trust_env(), connector=_make_ssl_connector()) as session: adapter = WeixinAdapter( PlatformConfig( enabled=True, diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 9e00626692..a43ac5b09d 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -3364,6 +3364,17 @@ DEFAULT_CONFIG = { # adapter. ``0`` disables the cap. Default 128 MiB. "max_inbound_media_bytes": 134217728, + # Whether gateway platform adapters let aiohttp read proxy settings + # (HTTP_PROXY / HTTPS_PROXY / NO_PROXY, plus SSL_CERT_FILE) from the + # process environment, and whether generic proxy env / the macOS + # system proxy are auto-detected for adapter clients. Set to false + # when the gateway inherits a proxy it must not use — e.g. a Windows + # Scheduled Task picking up a Clash/V2Ray HTTP_PROXY the interactive + # shell never sees, producing "Cannot connect to host 127.0.0.1:7890" + # poll loops (#48820). Explicit per-platform vars (DISCORD_PROXY, + # TELEGRAM_PROXY, ...) are still honored. One knob for every adapter. + "trust_env": True, + # When false (default), any file path the agent emits is delivered # as a native attachment as long as it isn't under the credential / # system-path denylist (/etc, /proc, ~/.ssh, ~/.aws, ~/.hermes/.env, diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index 41c9b65500..e123bb2177 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -184,6 +184,7 @@ from gateway.config import Platform, PlatformConfig Platform("google_chat") from gateway.platforms.helpers import MessageDeduplicator from gateway.platforms.base import ( + gateway_trust_env, BasePlatformAdapter, MessageEvent, MessageType, @@ -3655,7 +3656,7 @@ async def _standalone_send( return {"error": "Google Chat standalone send: aiohttp not installed"} try: - async with _aiohttp.ClientSession(timeout=_aiohttp.ClientTimeout(total=30.0), trust_env=True) as session: + async with _aiohttp.ClientSession(timeout=_aiohttp.ClientTimeout(total=30.0), trust_env=gateway_trust_env()) as session: async with session.post( url, json=body, diff --git a/plugins/platforms/homeassistant/adapter.py b/plugins/platforms/homeassistant/adapter.py index 37a7397d4b..bfdd136cdf 100644 --- a/plugins/platforms/homeassistant/adapter.py +++ b/plugins/platforms/homeassistant/adapter.py @@ -30,6 +30,7 @@ except ImportError: from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( + gateway_trust_env, BasePlatformAdapter, MessageEvent, MessageType, @@ -141,7 +142,8 @@ class HomeAssistantAdapter(BasePlatformAdapter): # Dedicated REST session for send() calls self._rest_session = aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=30) + timeout=aiohttp.ClientTimeout(total=30), + trust_env=gateway_trust_env(), ) # Warn if no event filters are configured @@ -171,7 +173,8 @@ class HomeAssistantAdapter(BasePlatformAdapter): ws_url = f"{ws_url}/api/websocket" self._session = aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=30) + timeout=aiohttp.ClientTimeout(total=30), + trust_env=gateway_trust_env(), ) self._ws = await self._session.ws_connect(ws_url, heartbeat=30, timeout=30) @@ -447,7 +450,7 @@ class HomeAssistantAdapter(BasePlatformAdapter): body = await resp.text() return SendResult(success=False, error=f"HTTP {resp.status}: {body}") else: - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession(trust_env=gateway_trust_env()) as session: async with session.post( url, headers=headers, @@ -532,7 +535,8 @@ async def _standalone_send( try: async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=30) + timeout=aiohttp.ClientTimeout(total=30), + trust_env=gateway_trust_env(), ) as session: async with session.post(url, headers=headers, json=payload) as resp: if resp.status not in {200, 201}: diff --git a/plugins/platforms/line/adapter.py b/plugins/platforms/line/adapter.py index b8d3ae10cd..1150556b4e 100644 --- a/plugins/platforms/line/adapter.py +++ b/plugins/platforms/line/adapter.py @@ -113,6 +113,7 @@ logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- from gateway.platforms.base import ( + gateway_trust_env, BasePlatformAdapter, MessageEvent, MessageType, @@ -514,7 +515,7 @@ class _LineClient: async def reply(self, reply_token: str, messages: List[Dict[str, Any]]) -> None: import aiohttp timeout = aiohttp.ClientTimeout(total=self._timeout) - async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + async with aiohttp.ClientSession(timeout=timeout, trust_env=gateway_trust_env()) as session: async with session.post( LINE_REPLY_URL, headers=self._headers, @@ -527,7 +528,7 @@ class _LineClient: async def push(self, chat_id: str, messages: List[Dict[str, Any]]) -> None: import aiohttp timeout = aiohttp.ClientTimeout(total=self._timeout) - async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + async with aiohttp.ClientSession(timeout=timeout, trust_env=gateway_trust_env()) as session: async with session.post( LINE_PUSH_URL, headers=self._headers, @@ -546,7 +547,7 @@ class _LineClient: clamped = max(5, min(60, (seconds // 5) * 5 or 5)) try: timeout = aiohttp.ClientTimeout(total=5.0) - async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + async with aiohttp.ClientSession(timeout=timeout, trust_env=gateway_trust_env()) as session: await session.post( LINE_LOADING_URL, headers=self._headers, @@ -560,7 +561,7 @@ class _LineClient: import aiohttp url = LINE_CONTENT_URL_FMT.format(message_id=message_id) timeout = aiohttp.ClientTimeout(total=30.0) - async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + async with aiohttp.ClientSession(timeout=timeout, trust_env=gateway_trust_env()) as session: async with session.get(url, headers={"Authorization": f"Bearer {self._token}"}) as resp: if resp.status >= 400: raise RuntimeError(f"LINE content {resp.status}") @@ -571,7 +572,7 @@ class _LineClient: import aiohttp timeout = aiohttp.ClientTimeout(total=10.0) try: - async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + async with aiohttp.ClientSession(timeout=timeout, trust_env=gateway_trust_env()) as session: async with session.get(LINE_BOT_INFO_URL, headers=self._headers) as resp: if resp.status >= 400: return None diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 3268fd9d19..d6a6861bf6 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -128,6 +128,7 @@ except ImportError: from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( + gateway_trust_env, BasePlatformAdapter, MessageEvent, MessageType, @@ -763,7 +764,7 @@ def _create_matrix_session(proxy_url: str | None): import aiohttp if not proxy_url: - return aiohttp.ClientSession(trust_env=True) + return aiohttp.ClientSession(trust_env=gateway_trust_env()) if proxy_url.split("://")[0].lower().startswith("socks"): try: @@ -778,7 +779,7 @@ def _create_matrix_session(proxy_url: str | None): "Run: pip install aiohttp-socks", proxy_url, ) - return aiohttp.ClientSession(trust_env=True) + return aiohttp.ClientSession(trust_env=gateway_trust_env()) return aiohttp.ClientSession(proxy=proxy_url) diff --git a/plugins/platforms/mattermost/adapter.py b/plugins/platforms/mattermost/adapter.py index 6962fbf615..a33e810473 100644 --- a/plugins/platforms/mattermost/adapter.py +++ b/plugins/platforms/mattermost/adapter.py @@ -24,6 +24,7 @@ from typing import Any, Dict, List, Optional, Tuple from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator from gateway.platforms.base import ( + gateway_trust_env, BasePlatformAdapter, MessageEvent, MessageType, @@ -316,7 +317,8 @@ class MattermostAdapter(BasePlatformAdapter): return False self._session = aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=30) + timeout=aiohttp.ClientTimeout(total=30), + trust_env=gateway_trust_env(), ) self._closing = False diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index cd8e237d3b..d883f338ca 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -43,6 +43,7 @@ from agent.secret_scope import UnscopedSecretError, get_secret from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator from gateway.platforms.base import ( + gateway_trust_env, BasePlatformAdapter, MessageEvent, MessageType, @@ -1825,7 +1826,7 @@ class SlackAdapter(BasePlatformAdapter): "Slack's ephemeral reply limit.]_" ) try: - async with aiohttp.ClientSession(trust_env=True) as session: + async with aiohttp.ClientSession(trust_env=gateway_trust_env()) as session: for idx, chunk in enumerate(chunks): payload = { "response_type": "ephemeral", diff --git a/plugins/platforms/sms/adapter.py b/plugins/platforms/sms/adapter.py index 37db336e7a..8d2592bc7b 100644 --- a/plugins/platforms/sms/adapter.py +++ b/plugins/platforms/sms/adapter.py @@ -29,6 +29,7 @@ from typing import Any, Dict, Optional from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( + gateway_trust_env, BasePlatformAdapter, MessageEvent, MessageType, @@ -156,7 +157,7 @@ class SmsAdapter(BasePlatformAdapter): await site.start() self._http_session = aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=30), - trust_env=True, + trust_env=gateway_trust_env(), ) self._running = True @@ -200,7 +201,7 @@ class SmsAdapter(BasePlatformAdapter): session = self._http_session or aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=30), - trust_env=True, + trust_env=gateway_trust_env(), ) try: for chunk in chunks: diff --git a/plugins/platforms/teams/adapter.py b/plugins/platforms/teams/adapter.py index f6b357208f..172d89d946 100644 --- a/plugins/platforms/teams/adapter.py +++ b/plugins/platforms/teams/adapter.py @@ -103,6 +103,7 @@ TextBlock = None # type: ignore[assignment,misc] from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator from gateway.platforms.base import ( + gateway_trust_env, BasePlatformAdapter, MessageEvent, MessageType, @@ -641,7 +642,7 @@ async def _standalone_send( # Per-request timeouts so a slow STS endpoint cannot starve the # subsequent activity POST of its budget. per_request_timeout = _aiohttp.ClientTimeout(total=15.0) - async with _aiohttp.ClientSession(trust_env=True) as session: + async with _aiohttp.ClientSession(trust_env=gateway_trust_env()) as session: async with session.post( token_url, data={ diff --git a/plugins/platforms/wecom/adapter.py b/plugins/platforms/wecom/adapter.py index c26d4a8350..c52af3dae6 100644 --- a/plugins/platforms/wecom/adapter.py +++ b/plugins/platforms/wecom/adapter.py @@ -63,6 +63,7 @@ except ImportError: from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator from gateway.platforms.base import ( + gateway_trust_env, BasePlatformAdapter, MessageEvent, MessageType, @@ -723,7 +724,7 @@ class WeComAdapter(BasePlatformAdapter): except ImportError: _ssl_ctx = _ssl.create_default_context() _connector = aiohttp.TCPConnector(ssl=_ssl_ctx) - self._session = aiohttp.ClientSession(trust_env=True, connector=_connector) + self._session = aiohttp.ClientSession(trust_env=gateway_trust_env(), connector=_connector) self._ws = await self._session.ws_connect( self._ws_url, heartbeat=HEARTBEAT_INTERVAL_SECONDS * 2, diff --git a/tests/gateway/test_gateway_trust_env.py b/tests/gateway/test_gateway_trust_env.py new file mode 100644 index 0000000000..78965ee66b --- /dev/null +++ b/tests/gateway/test_gateway_trust_env.py @@ -0,0 +1,47 @@ +"""gateway.trust_env — one config key controls aiohttp proxy-env honoring at every adapter site (#48820).""" +import re +from pathlib import Path + +import pytest + +from gateway.platforms import base as gw_base + +REPO = Path(__file__).resolve().parents[2] +_ADAPTER_FILES = sorted( + list((REPO / "gateway" / "platforms").rglob("*.py")) + + list((REPO / "plugins" / "platforms").rglob("*.py")) +) + + +def _write_config(tmp_path, monkeypatch, body: str) -> None: + # load_config caches on (path, mtime) — a fresh tmp HERMES_HOME per test is a fresh cache key. + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "config.yaml").write_text(body) + + +@pytest.mark.parametrize( + "yaml_body, expected", + [("gateway:\n trust_env: false\n", False), ("gateway:\n trust_env: true\n", True), ("{}\n", True)], +) +def test_gateway_trust_env_reads_config(tmp_path, monkeypatch, yaml_body, expected): + """gateway.trust_env in config.yaml drives the shared helper; absent → True (default).""" + _write_config(tmp_path, monkeypatch, yaml_body) + assert gw_base.gateway_trust_env() is expected + # The generic-proxy discovery path is gated by the same knob; explicit per-platform vars are not. + monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:7890") + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("no_proxy", raising=False) + assert (gw_base.resolve_proxy_url() is not None) is expected + monkeypatch.setenv("X_PLATFORM_PROXY", "http://127.0.0.1:1080") + assert gw_base.resolve_proxy_url("X_PLATFORM_PROXY") == "http://127.0.0.1:1080" + + +def test_no_bare_trust_env_literal_in_adapters(): + """Every aiohttp session in gateway/ + plugins/platforms/ must go through gateway_trust_env().""" + bare = re.compile(r"trust_env\s*=\s*(True|False)\b") + offenders = [] + for path in _ADAPTER_FILES: + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if bare.search(line) and "httpx" not in line: + offenders.append(f"{path.relative_to(REPO)}:{lineno}: {line.strip()}") + assert not offenders, "hard-coded aiohttp trust_env literal(s); use gateway_trust_env():\n" + "\n".join(offenders) diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index f8984fed5a..72fc4288eb 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -703,6 +703,24 @@ or set platforms.weixin.enabled: true to turn it back on. Omitting the `enabled` key entirely keeps the env-only behaviour: credentials present → adapter starts. +### Ignoring an inherited proxy (`gateway.trust_env`) + +By default every platform adapter honors `HTTP_PROXY` / `HTTPS_PROXY` / +`NO_PROXY` (and `SSL_CERT_FILE`) from the gateway's environment, and +auto-detects the macOS system proxy. A gateway started by a Windows Scheduled +Task or a service manager can inherit a proxy the interactive shell never +sees — a local Clash/V2Ray listener that isn't running yet — and log +`Cannot connect to host 127.0.0.1:7890` on every poll. Turn the inherited +proxy off for all adapters at once: + +```yaml title="~/.hermes/config.yaml" +gateway: + trust_env: false +``` + +Explicit per-platform proxy variables (`DISCORD_PROXY`, `TELEGRAM_PROXY`, +`MATRIX_PROXY`, ...) are still honored. Restart the gateway after changing it. + ### Automatic circuit breaker Each adapter is wrapped in a circuit breaker. Repeated retryable failures (network blips, rate-limit replies, 5xx upstream responses, websocket disconnects) cause the breaker to trip — the adapter is auto-paused, an operator notification is sent to the home channel of another live platform when one is configured, and a structured log line is emitted.