fix(sandbox): bypass proxies for local AIO traffic (#4444)

* fix(sandbox): bypass proxies for local AIO traffic

* fix(sandbox): classify public IPv6 proxy targets
This commit is contained in:
March-77 2026-07-27 07:47:39 +08:00 committed by GitHub
parent 090e80c1dd
commit 2e5c8da257
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 167 additions and 16 deletions

View File

@ -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]

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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:

View File

@ -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."""

View File

@ -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: