diff --git a/agent/process_bootstrap.py b/agent/process_bootstrap.py index dc7d1b7c52..57745baa56 100644 --- a/agent/process_bootstrap.py +++ b/agent/process_bootstrap.py @@ -15,10 +15,10 @@ import socket import sys import threading import time -import urllib.request from typing import Any, Optional from utils import base_url_hostname, normalize_proxy_url +from agent.proxy_bypass import first_proxy_env_value, should_bypass_proxy _OPENAI_CLS_CACHE = None @@ -288,18 +288,18 @@ class _SafeWriter: def _get_proxy_from_env() -> Optional[str]: """First configured proxy URL from HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (any case), or None.""" - keys = ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy") - return next((normalize_proxy_url(v) for k in keys if (v := os.environ.get(k, "").strip())), None) + value = first_proxy_env_value() + return normalize_proxy_url(value) if value else None def _get_proxy_for_base_url(base_url: Optional[str]) -> Optional[str]: - """Env-configured proxy unless NO_PROXY excludes this base URL.""" + """Env-configured proxy unless NO_PROXY excludes this base URL (same matcher as the + gateway adapters: CIDR, ``*.`` wildcards and host:port entries all count).""" proxy = _get_proxy_from_env() - host = base_url_hostname(base_url) if proxy and base_url else "" - try: - return None if host and urllib.request.proxy_bypass_environment(host) else proxy - except Exception: + if not (proxy and base_url): return proxy + raw = base_url.strip() + return None if should_bypass_proxy(raw if "://" in raw else f"//{raw}") else proxy def _shared_transport_cls(): diff --git a/agent/proxy_bypass.py b/agent/proxy_bypass.py index 4d95d6dbc0..5f241576a0 100644 --- a/agent/proxy_bypass.py +++ b/agent/proxy_bypass.py @@ -26,11 +26,12 @@ def first_proxy_env_value() -> str: def split_host_port(value: str) -> tuple[str, int | None]: - """``(host, port)`` from a URL, ``[v6]:port``, ``host:port`` or bare host; host lowercased.""" + """``(host, port)`` from a URL (scheme optional: ``//host/path``), ``[v6]:port``, + ``host:port`` or bare host; host lowercased.""" raw = str(value or "").strip() if not raw: return "", None - if "://" in raw: + if "://" in raw or raw.startswith("//"): parsed = urlsplit(raw) host, port = parsed.hostname or "", parsed.port elif raw.startswith("[") and "]" in raw: diff --git a/agent/retry_utils.py b/agent/retry_utils.py index 43d478f810..a5e304cadb 100644 --- a/agent/retry_utils.py +++ b/agent/retry_utils.py @@ -67,7 +67,7 @@ def parse_retry_after_seconds(value_or_headers: Any) -> Optional[float]: # Free-text "reset" grammars providers put in error bodies, tried in order. One table so the # conversation loop's error context and the credential pool's cooldown agree on the same wait. _QUOTA_RESET_DELAY_RE = re.compile(r"quotaResetDelay[:\s\"]+(\d+(?:\.\d+)?)(ms|s)", re.IGNORECASE) -# "Resets in 4hr 5min" (OpenCode Go weekly limits), "resets in 2 hours 5 minutes", "resets in 30s". +# "Resets in 4hr 5min" (weekly usage limits), "resets in 2 hours 5 minutes", "resets in 30s". _RESETS_IN_RE = re.compile( r"resets?\s+in\s+" r"(?:(\d+(?:\.\d+)?)\s*(?:h|hr|hrs|hour|hours)\b\s*)?" diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 7d957ce897..df8e736189 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -20,6 +20,7 @@ from abc import ABC, abstractmethod from urllib.parse import urlsplit from utils import normalize_proxy_url +from agent.proxy_bypass import first_proxy_env_value, should_bypass_proxy as _should_bypass_proxy logger = logging.getLogger(__name__) @@ -264,69 +265,10 @@ def _detect_macos_system_proxy() -> str | None: return None -def _split_host_port(value: str) -> tuple[str, int | None]: - """``(host, port)`` from a URL, ``[v6]:port``, ``host:port`` or bare host; host lowercased.""" - raw = str(value or "").strip() - if not raw: - return "", None - if "://" in raw: - parsed = urlsplit(raw) - host, port = parsed.hostname or "", parsed.port - elif raw.startswith("[") and "]" in raw: - host, _, rest = raw[1:].partition("]") - port = int(rest[1:]) if rest.startswith(":") and rest[1:].isdigit() else None - elif raw.count(":") == 1 and raw.rpartition(":")[2].isdigit(): - host, _, port_s = raw.rpartition(":") - port = int(port_s) - else: - host, port = raw.strip("[]"), None - return host.lower().rstrip("."), port - - -def _no_proxy_entries() -> list[str]: - return [ - part.strip() for key in ("NO_PROXY", "no_proxy") - for part in os.environ.get(key, "").split(",") if part.strip()] - - -def _ip_or_none(value: str, parse=ipaddress.ip_address): - """``parse(value)`` or None on ``ValueError`` (``parse`` is ip_address / ip_network).""" - try: - return parse(value) - except ValueError: - return None - - -def _no_proxy_entry_matches(entry: str, host: str, port: int | None = None) -> bool: - token = str(entry or "").strip().lower() - if not token: - return False - if token == "*": - return True - token_host, token_port = _split_host_port(token) - if not token_host or (token_port is not None and (port is None or token_port != port)): - return False - host_ip = _ip_or_none(host) - network = _ip_or_none(token_host, lambda v: ipaddress.ip_network(v, strict=False)) - if network is not None: # CIDR or bare IP literal (a /32 / /128 network) - return host_ip is not None and host_ip in network - if token_host.startswith("*."): - return host.endswith(token_host[1:]) - if token_host.startswith("."): - return host == token_host[1:] or host.endswith(token_host) - return host == token_host or host.endswith(f".{token_host}") - - def should_bypass_proxy(target_hosts: str | list[str] | tuple[str, ...] | set[str] | None) -> bool: """True when NO_PROXY/no_proxy matches at least one target host (exact hosts, domain / wildcard suffixes, IP literals, CIDR ranges, optional host:port entries, ``*``).""" - entries = _no_proxy_entries() - if not entries or not target_hosts: - return False - candidates = [target_hosts] if isinstance(target_hosts, str) else list(target_hosts) - return any( - host and any(_no_proxy_entry_matches(entry, host, port) for entry in entries) - for host, port in map(_split_host_port, map(str, candidates))) + return _should_bypass_proxy(target_hosts) def resolve_proxy_url( @@ -347,8 +289,7 @@ def resolve_proxy_url( if not value: if not gateway_trust_env(): # only the explicit per-platform var is honored return None - keys = ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy") - value = next((v for k in keys if (v := (os.environ.get(k) or "").strip())), "") + value = first_proxy_env_value() proxy = normalize_proxy_url(value or _detect_macos_system_proxy()) return None if proxy and should_bypass_proxy(target_hosts) else proxy @@ -412,21 +353,9 @@ def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]: def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = None) -> bool: - """Return True when ``hostname`` matches a ``NO_PROXY`` entry (comma/whitespace - separated; leading-dot and ``*.`` entries match the apex domain and subdomains).""" - if no_proxy_value is None: - no_proxy_value = os.environ.get("NO_PROXY") or os.environ.get("no_proxy") or "" - lower_hostname = hostname.lower() - for entry in re.split(r"[\s,]+", no_proxy_value.strip()): - normalized = entry.strip().lower() - if not normalized: - continue - if normalized == "*": - return True - normalized = normalized[2:] if normalized.startswith("*.") else normalized.removeprefix(".") - if lower_hostname == normalized or lower_hostname.endswith(f".{normalized}"): - return True - return False + """Return True when ``hostname`` matches a ``NO_PROXY`` entry (``no_proxy_value`` overrides the + environment); same matcher as :func:`should_bypass_proxy`.""" + return _should_bypass_proxy(hostname, no_proxy_value=no_proxy_value) import dataclasses