From 2e5c8da25729e4cef061242eb6578aa8697cecfa Mon Sep 17 00:00:00 2001 From: March-77 <132431415+March-77@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:47:39 +0800 Subject: [PATCH] fix(sandbox): bypass proxies for local AIO traffic (#4444) * fix(sandbox): bypass proxies for local AIO traffic * fix(sandbox): classify public IPv6 proxy targets --- README.md | 5 ++ backend/AGENTS.md | 2 +- backend/docs/CONFIGURATION.md | 6 ++ .../community/aio_sandbox/aio_sandbox.py | 13 +++- .../deerflow/community/aio_sandbox/backend.py | 46 +++++++++--- backend/tests/test_aio_sandbox.py | 41 +++++++++++ backend/tests/test_aio_sandbox_readiness.py | 70 +++++++++++++++++-- 7 files changed, 167 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 87dace770..14d98e3e0 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,11 @@ make docker-start # Start services (auto-detects sandbox mode from config.yaml Docker builds use the upstream `uv` registry by default. If you need faster mirrors in restricted networks, export `UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple` and `NPM_REGISTRY=https://registry.npmmirror.com` before running `make docker-init` or `make docker-start`. +Local AIO sandbox control traffic is always direct: loopback/private addresses, +single-label cluster hosts, and Docker/Podman internal hostnames do not inherit +`HTTP_PROXY` or `HTTPS_PROXY`. External sandbox FQDNs and public IPs still +honor environment proxy settings. + Backend processes automatically pick up `config.yaml` changes on the next config access, so model metadata updates do not require a manual restart during development. > [!TIP] diff --git a/backend/AGENTS.md b/backend/AGENTS.md index bb857bb13..174646b3f 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -530,7 +530,7 @@ copying a raw checkpoint because delta state is not self-contained in one tuple. **Environment policy** (`sandbox/env_policy.py`): `execute_command` no longer inherits the full `os.environ`. `build_sandbox_env()` scrubs secret-looking names (`*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASS*`/`*CREDENTIAL*`) from the inherited environment before layering injected request secrets on top, so platform credentials (e.g. `OPENAI_API_KEY`) never leak into skill subprocesses. Benign vars (`PATH`, `HOME`, `LANG`, `VIRTUAL_ENV`, ...) are preserved. **Implementations**: - `LocalSandboxProvider` - Local filesystem execution. `acquire(thread_id)` returns a per-thread `LocalSandbox` (id `local:{thread_id}`) whose `path_mappings` resolve `/mnt/user-data/{workspace,uploads,outputs}` and `/mnt/acp-workspace` to that thread's host directories, so the public `Sandbox` API honours the `/mnt/user-data` contract uniformly with AIO. `acquire()` / `acquire(None)` keeps the legacy generic singleton (id `local`) for callers without a thread context. Per-thread sandboxes are held in an LRU cache (default 256 entries) guarded by a `threading.Lock`. Legacy global-custom mounts are gated by the same user-scoped skill discovery rule used for prompt/list visibility; providers must not infer visibility from raw directory presence alone. -- `AioSandboxProvider` (`packages/harness/deerflow/community/`) - Docker-based isolation. Active-cache and warm-pool entries are checked with the backend during acquire/reuse; definitively dead containers are dropped from all in-process maps so the thread can discover or create a fresh sandbox instead of reusing a stale client. Backend health-check failures are treated as unknown, not dead; local discovery likewise treats an unverifiable container as not adoptable and falls through to create rather than failing acquire. `get()` remains an in-memory lookup for event-loop-safe tool paths — it never touches the ownership store (that would be blocking IO on the event loop); ownership is published on acquire/reclaim and refreshed off the event loop by the dedicated renewal thread (`_renew_owned_leases`). Legacy global-custom mounts follow the same shared visibility helper as local and remote providers. +- `AioSandboxProvider` (`packages/harness/deerflow/community/`) - Docker-based isolation. Active-cache and warm-pool entries are checked with the backend during acquire/reuse; definitively dead containers are dropped from all in-process maps so the thread can discover or create a fresh sandbox instead of reusing a stale client. Backend health-check failures are treated as unknown, not dead; local discovery likewise treats an unverifiable container as not adoptable and falls through to create rather than failing acquire. `get()` remains an in-memory lookup for event-loop-safe tool paths — it never touches the ownership store (that would be blocking IO on the event loop); ownership is published on acquire/reclaim and refreshed off the event loop by the dedicated renewal thread (`_renew_owned_leases`). Legacy global-custom mounts follow the same shared visibility helper as local and remote providers. Readiness probes and `agent_sandbox` clients classify loopback/private IPs, single-label cluster hosts, and Docker/Podman internal hostnames as direct control-plane destinations and set `trust_env=False`; external FQDNs and public IPs retain environment proxy support. - `E2BSandboxProvider` (`packages/harness/deerflow/community/e2b_sandbox/`) provides E2B remote isolation. Acquire and release share a per-user and thread lock. The provider lock does not cover remote IO. `burst_limit` adds capacity only for the `burst` policy. diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 17d23a5a3..e90355195 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -505,6 +505,12 @@ When you configure `sandbox.mounts`, DeerFlow exposes those `container_path` val For bare-metal Docker sandbox runs that use localhost, DeerFlow binds the sandbox HTTP port to `127.0.0.1` by default so it is not exposed on every host interface. Docker-outside-of-Docker deployments that connect through `host.docker.internal` keep the broad legacy bind for compatibility. Set `DEER_FLOW_SANDBOX_BIND_HOST` explicitly if your deployment needs a different bind address. +Sandbox control-plane HTTP calls to loopback/private IPs, single-label cluster +hosts, and Docker/Podman internal hostnames bypass `HTTP_PROXY`/`HTTPS_PROXY` +inside the client. This prevents an inherited proxy from returning a misleading +502 for a healthy local sandbox. Externally hosted sandbox FQDNs and public IPs +continue to use the normal environment proxy configuration. + ### Building a Custom AIO Sandbox Image `AioSandboxProvider` talks to the sandbox container through the `agent-sandbox` SDK. The Dockerfile for the default `enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest` image is not part of this repository; DeerFlow treats that image as an upstream AIO sandbox runtime. diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py index 0e07b00a9..5ed2b4bd3 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py @@ -5,6 +5,7 @@ import shlex import threading import uuid +import httpx from agent_sandbox import Sandbox as AioSandboxClient from agent_sandbox.core.api_error import ApiError @@ -12,6 +13,8 @@ from deerflow.config.paths import VIRTUAL_PATH_PREFIX from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env from deerflow.sandbox.search import GrepMatch, path_matches, should_ignore_path, truncate_line +from .backend import sandbox_http_trust_env + logger = logging.getLogger(__name__) _MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024 # 100 MB @@ -50,7 +53,15 @@ class AioSandbox(Sandbox): """ super().__init__(id) self._base_url = base_url - self._client = AioSandboxClient(base_url=base_url, timeout=600) + if sandbox_http_trust_env(base_url): + self._client = AioSandboxClient(base_url=base_url, timeout=600) + else: + direct_client = httpx.Client(timeout=600, follow_redirects=True, trust_env=False) + self._client = AioSandboxClient( + base_url=base_url, + timeout=600, + httpx_client=direct_client, + ) self._home_dir = home_dir self._lock = threading.Lock() self._closed = False diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/backend.py b/backend/packages/harness/deerflow/community/aio_sandbox/backend.py index 28e614890..c16a42920 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/backend.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/backend.py @@ -3,9 +3,11 @@ from __future__ import annotations import asyncio +import ipaddress import logging import time from abc import ABC, abstractmethod +from urllib.parse import urlparse import httpx import requests @@ -15,6 +17,30 @@ from .sandbox_info import SandboxInfo logger = logging.getLogger(__name__) +def sandbox_http_trust_env(sandbox_url: str) -> bool: + """Whether HTTP clients for *sandbox_url* should inherit proxy settings. + + Local Docker, DooD, and Kubernetes sandbox endpoints are control-plane + connections, not internet traffic. Sending them through ``HTTP_PROXY`` can + produce a misleading proxy-generated 502 even though the sandbox container + is healthy (#3441). External fully-qualified hosts retain normal environment + proxy behavior. + """ + try: + hostname = (urlparse(sandbox_url).hostname or "").rstrip(".").lower() + except ValueError: + return True + if not hostname: + return True + if hostname == "localhost" or hostname.endswith(".localhost") or hostname.endswith(".docker.internal") or hostname.endswith(".containers.internal"): + return False + try: + address = ipaddress.ip_address(hostname) + except ValueError: + return "." in hostname + return not (address.is_loopback or address.is_private or address.is_link_local) + + def wait_for_sandbox_ready(sandbox_url: str, timeout: int = 30) -> bool: """Poll sandbox health endpoint until ready or timeout. @@ -26,14 +52,16 @@ def wait_for_sandbox_ready(sandbox_url: str, timeout: int = 30) -> bool: True if sandbox is ready, False otherwise. """ start_time = time.time() - while time.time() - start_time < timeout: - try: - response = requests.get(f"{sandbox_url}/v1/sandbox", timeout=5) - if response.status_code == 200: - return True - except requests.exceptions.RequestException: - pass - time.sleep(1) + with requests.Session() as session: + session.trust_env = sandbox_http_trust_env(sandbox_url) + while time.time() - start_time < timeout: + try: + response = session.get(f"{sandbox_url}/v1/sandbox", timeout=5) + if response.status_code == 200: + return True + except requests.exceptions.RequestException: + pass + time.sleep(1) return False @@ -47,7 +75,7 @@ async def wait_for_sandbox_ready_async(sandbox_url: str, timeout: int = 30, poll loop = asyncio.get_running_loop() deadline = loop.time() + timeout - async with httpx.AsyncClient(timeout=5) as client: + async with httpx.AsyncClient(timeout=5, trust_env=sandbox_http_trust_env(sandbox_url)) as client: while True: remaining = deadline - loop.time() if remaining <= 0: diff --git a/backend/tests/test_aio_sandbox.py b/backend/tests/test_aio_sandbox.py index 9102c23b8..7aa5aed47 100644 --- a/backend/tests/test_aio_sandbox.py +++ b/backend/tests/test_aio_sandbox.py @@ -7,6 +7,47 @@ from unittest.mock import MagicMock, patch import pytest +def test_local_sandbox_client_bypasses_environment_proxy(): + """Local sandbox API calls must not inherit HTTP_PROXY (#3441).""" + from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox + + sentinel_httpx = MagicMock() + with ( + patch("deerflow.community.aio_sandbox.aio_sandbox.httpx.Client", return_value=sentinel_httpx) as client_cls, + patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient") as sdk_cls, + ): + AioSandbox(id="test-sandbox", base_url="http://host.docker.internal:8080") + + client_cls.assert_called_once_with(timeout=600, follow_redirects=True, trust_env=False) + sdk_cls.assert_called_once_with( + base_url="http://host.docker.internal:8080", + timeout=600, + httpx_client=sentinel_httpx, + ) + + +@pytest.mark.parametrize( + "base_url", + [ + "https://sandbox.example.com", + "http://8.8.8.8:8080", + "http://[2606:4700:4700::1111]:8080", + ], +) +def test_external_sandbox_client_keeps_environment_proxy_support(base_url: str): + """Externally hosted sandbox URLs retain the SDK's default proxy behavior.""" + from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox + + with ( + patch("deerflow.community.aio_sandbox.aio_sandbox.httpx.Client") as client_cls, + patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient") as sdk_cls, + ): + AioSandbox(id="test-sandbox", base_url=base_url) + + client_cls.assert_not_called() + sdk_cls.assert_called_once_with(base_url=base_url, timeout=600) + + @pytest.fixture() def sandbox(): """Create an AioSandbox with a mocked client.""" diff --git a/backend/tests/test_aio_sandbox_readiness.py b/backend/tests/test_aio_sandbox_readiness.py index 1560bbab3..2accb7268 100644 --- a/backend/tests/test_aio_sandbox_readiness.py +++ b/backend/tests/test_aio_sandbox_readiness.py @@ -8,11 +8,20 @@ from deerflow.community.aio_sandbox import backend as readiness class _FakeAsyncClient: - def __init__(self, *, responses: list[object], calls: list[str], timeout: float, request_timeouts: list[float] | None = None) -> None: + def __init__( + self, + *, + responses: list[object], + calls: list[str], + timeout: float, + request_timeouts: list[float] | None = None, + trust_env: bool = True, + ) -> None: self._responses = responses self._calls = calls self._timeout = timeout self._request_timeouts = request_timeouts + self.trust_env = trust_env async def __aenter__(self) -> _FakeAsyncClient: return self @@ -41,17 +50,65 @@ class _FakeLoop: return value +@pytest.mark.parametrize( + ("sandbox_url", "expected"), + [ + ("http://localhost:8080", False), + ("http://127.0.0.1:8080", False), + ("http://[::1]:8080", False), + ("http://host.docker.internal:8080", False), + ("http://host.containers.internal:8080", False), + ("http://k3s:30001", False), + ("http://10.0.0.8:8080", False), + ("http://8.8.8.8:8080", True), + ("http://[2606:4700:4700::1111]:8080", True), + ("https://sandbox.example.com", True), + ], +) +def test_sandbox_http_trust_env_only_uses_proxy_for_external_urls(sandbox_url: str, expected: bool) -> None: + assert readiness.sandbox_http_trust_env(sandbox_url) is expected + + +def test_wait_for_sandbox_ready_bypasses_environment_proxy_for_docker_host(monkeypatch: pytest.MonkeyPatch) -> None: + sessions: list[object] = [] + + class FakeSession: + trust_env = True + + def __enter__(self): + sessions.append(self) + return self + + def __exit__(self, *_exc_info) -> None: + return None + + def get(self, url: str, *, timeout: float): + assert url == "http://host.docker.internal:8080/v1/sandbox" + assert timeout == 5 + return SimpleNamespace(status_code=200) + + monkeypatch.setattr(readiness.requests, "Session", FakeSession) + + assert readiness.wait_for_sandbox_ready("http://host.docker.internal:8080", timeout=1) is True + assert len(sessions) == 1 + assert sessions[0].trust_env is False + + @pytest.mark.anyio async def test_wait_for_sandbox_ready_async_uses_nonblocking_polling(monkeypatch: pytest.MonkeyPatch) -> None: calls: list[str] = [] sleeps: list[float] = [] + clients: list[_FakeAsyncClient] = [] - def fake_client(*, timeout: float): - return _FakeAsyncClient( + def fake_client(*, timeout: float, trust_env: bool): + client = _FakeAsyncClient( responses=[SimpleNamespace(status_code=503), SimpleNamespace(status_code=200)], calls=calls, timeout=timeout, + trust_env=trust_env, ) + clients.append(client) + return client async def fake_sleep(delay: float) -> None: sleeps.append(delay) @@ -65,6 +122,7 @@ async def test_wait_for_sandbox_ready_async_uses_nonblocking_polling(monkeypatch assert calls == ["http://sandbox/v1/sandbox", "http://sandbox/v1/sandbox"] assert sleeps == [0.05] + assert clients[0].trust_env is False @pytest.mark.anyio @@ -72,11 +130,12 @@ async def test_wait_for_sandbox_ready_async_retries_request_errors(monkeypatch: calls: list[str] = [] sleeps: list[float] = [] - def fake_client(*, timeout: float): + def fake_client(*, timeout: float, trust_env: bool): return _FakeAsyncClient( responses=[readiness.httpx.ConnectError("not ready"), SimpleNamespace(status_code=200)], calls=calls, timeout=timeout, + trust_env=trust_env, ) async def fake_sleep(delay: float) -> None: @@ -97,12 +156,13 @@ async def test_wait_for_sandbox_ready_async_clamps_request_and_sleep_to_deadline request_timeouts: list[float] = [] sleeps: list[float] = [] - def fake_client(*, timeout: float): + def fake_client(*, timeout: float, trust_env: bool): return _FakeAsyncClient( responses=[SimpleNamespace(status_code=503)], calls=calls, timeout=timeout, request_timeouts=request_timeouts, + trust_env=trust_env, ) async def fake_sleep(delay: float) -> None: