diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index d8d181cc107..ff1d536b175 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -19,6 +19,7 @@ from hermes_constants import get_hermes_home from types import SimpleNamespace from typing import Any, Dict, List, Optional, Tuple +from utils import normalize_proxy_env_vars try: import anthropic as _anthropic_sdk @@ -308,6 +309,9 @@ def build_anthropic_client(api_key: str, base_url: str = None, timeout: float = "The 'anthropic' package is required for the Anthropic provider. " "Install it with: pip install 'anthropic>=0.39.0'" ) + + normalize_proxy_env_vars() + from httpx import Timeout normalized_base_url = _normalize_base_url_text(base_url) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 50d4d86afb0..4f974a28213 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -48,7 +48,7 @@ from agent.credential_pool import load_pool from hermes_cli.config import get_hermes_home from hermes_constants import OPENROUTER_BASE_URL -from utils import base_url_host_matches, base_url_hostname +from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_vars logger = logging.getLogger(__name__) @@ -1028,6 +1028,8 @@ def _validate_proxy_env_urls() -> None: """ from urllib.parse import urlparse + normalize_proxy_env_vars() + for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy"): value = str(os.environ.get(key) or "").strip() diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 86a867c107d..afb8767124a 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -19,6 +19,8 @@ from abc import ABC, abstractmethod from urllib.parse import urlsplit +from utils import normalize_proxy_url + logger = logging.getLogger(__name__) @@ -159,13 +161,13 @@ def resolve_proxy_url(platform_env_var: str | None = None) -> str | None: if platform_env_var: value = (os.environ.get(platform_env_var) or "").strip() if value: - return value + return normalize_proxy_url(value) for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy"): value = (os.environ.get(key) or "").strip() if value: - return value - return _detect_macos_system_proxy() + return normalize_proxy_url(value) + return normalize_proxy_url(_detect_macos_system_proxy()) def proxy_kwargs_for_bot(proxy_url: str | None) -> dict: diff --git a/run_agent.py b/run_agent.py index 722f7cea4b3..592156b10b3 100644 --- a/run_agent.py +++ b/run_agent.py @@ -124,7 +124,7 @@ convert_scratchpad_to_think, has_incomplete_scratchpad, save_trajectory as _save_trajectory_to_file, ) -from utils import atomic_json_write, base_url_host_matches, base_url_hostname, env_var_enabled +from utils import atomic_json_write, base_url_host_matches, base_url_hostname, env_var_enabled, normalize_proxy_url @@ -187,7 +187,7 @@ def _get_proxy_from_env() -> Optional[str]: "https_proxy", "http_proxy", "all_proxy"): value = os.environ.get(key, "").strip() if value: - return value + return normalize_proxy_url(value) return None diff --git a/tests/agent/test_proxy_and_url_validation.py b/tests/agent/test_proxy_and_url_validation.py index 4fd6138a4d2..7d7268ed1f8 100644 --- a/tests/agent/test_proxy_and_url_validation.py +++ b/tests/agent/test_proxy_and_url_validation.py @@ -6,6 +6,8 @@ """ from __future__ import annotations +import os + import pytest from agent.auxiliary_client import _validate_base_url, _validate_proxy_env_urls @@ -31,6 +33,12 @@ def test_proxy_env_accepts_empty(monkeypatch): _validate_proxy_env_urls() # should not raise +def test_proxy_env_normalizes_socks_alias(monkeypatch): + monkeypatch.setenv("ALL_PROXY", "socks://127.0.0.1:1080/") + _validate_proxy_env_urls() + assert os.environ["ALL_PROXY"] == "socks5://127.0.0.1:1080/" + + @pytest.mark.parametrize("key", [ "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy", diff --git a/tests/gateway/test_proxy_mode.py b/tests/gateway/test_proxy_mode.py index 11180639e8d..e25f226ee92 100644 --- a/tests/gateway/test_proxy_mode.py +++ b/tests/gateway/test_proxy_mode.py @@ -8,6 +8,7 @@ import pytest from gateway.config import Platform, StreamingConfig +from gateway.platforms.base import resolve_proxy_url from gateway.run import GatewayRunner from gateway.session import SessionSource @@ -133,6 +134,15 @@ def test_empty_string_treated_as_unset(self, monkeypatch): assert runner._get_proxy_url() is None +class TestResolveProxyUrl: + def test_normalizes_socks_alias_from_all_proxy(self, monkeypatch): + for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", + "https_proxy", "http_proxy", "all_proxy"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("ALL_PROXY", "socks://127.0.0.1:1080/") + assert resolve_proxy_url() == "socks5://127.0.0.1:1080/" + + class TestRunAgentProxyDispatch: """Test that _run_agent() delegates to proxy when configured.""" diff --git a/tests/run_agent/test_create_openai_client_proxy_env.py b/tests/run_agent/test_create_openai_client_proxy_env.py index 7ac9b7e16e2..9ef8e3dcd13 100644 --- a/tests/run_agent/test_create_openai_client_proxy_env.py +++ b/tests/run_agent/test_create_openai_client_proxy_env.py @@ -67,6 +67,14 @@ def test_get_proxy_from_env_ignores_blank_values(monkeypatch): assert _get_proxy_from_env() == "http://real-proxy:8080" +def test_get_proxy_from_env_normalizes_socks_alias(monkeypatch): + for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", + "https_proxy", "http_proxy", "all_proxy"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("ALL_PROXY", "socks://127.0.0.1:1080/") + assert _get_proxy_from_env() == "socks5://127.0.0.1:1080/" + + @patch("run_agent.OpenAI") def test_create_openai_client_routes_via_proxy_when_env_set(mock_openai, monkeypatch): """With HTTPS_PROXY set, the custom httpx.Client must mount an HTTPProxy pool. diff --git a/utils.py b/utils.py index 6b998e22308..f3d38006d14 100644 --- a/utils.py +++ b/utils.py @@ -197,6 +197,39 @@ def env_bool(key: str, default: bool = False) -> bool: return is_truthy_value(os.getenv(key, ""), default=default) +# ─── Proxy Helpers ──────────────────────────────────────────────────────────── + + +_PROXY_ENV_KEYS = ( + "HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", + "https_proxy", "http_proxy", "all_proxy", +) + + +def normalize_proxy_url(proxy_url: str | None) -> str | None: + """Normalize proxy URLs for httpx/aiohttp compatibility. + + WSL/Clash-style environments often export SOCKS proxies as + ``socks://127.0.0.1:PORT``. httpx rejects that alias and expects the + explicit ``socks5://`` scheme instead. + """ + candidate = str(proxy_url or "").strip() + if not candidate: + return None + if candidate.lower().startswith("socks://"): + return f"socks5://{candidate[len('socks://'):]}" + return candidate + + +def normalize_proxy_env_vars() -> None: + """Rewrite supported proxy env vars to canonical URL forms in-place.""" + for key in _PROXY_ENV_KEYS: + value = os.getenv(key, "") + normalized = normalize_proxy_url(value) + if normalized and normalized != value: + os.environ[key] = normalized + + # ─── URL Parsing Helpers ────────────────────────────────────────────────────── @@ -236,4 +269,3 @@ def base_url_host_matches(base_url: str, domain: str) -> bool: if not domain: return False return hostname == domain or hostname.endswith("." + domain) -