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`. 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. 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] > [!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. **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**: **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. - `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. - `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 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. 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. 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 ### 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. `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 threading
import uuid import uuid
import httpx
from agent_sandbox import Sandbox as AioSandboxClient from agent_sandbox import Sandbox as AioSandboxClient
from agent_sandbox.core.api_error import ApiError 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.sandbox import Sandbox, _validate_extra_env
from deerflow.sandbox.search import GrepMatch, path_matches, should_ignore_path, truncate_line from deerflow.sandbox.search import GrepMatch, path_matches, should_ignore_path, truncate_line
from .backend import sandbox_http_trust_env
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024 # 100 MB _MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024 # 100 MB
@ -50,7 +53,15 @@ class AioSandbox(Sandbox):
""" """
super().__init__(id) super().__init__(id)
self._base_url = base_url 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._home_dir = home_dir
self._lock = threading.Lock() self._lock = threading.Lock()
self._closed = False self._closed = False

View File

@ -3,9 +3,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import ipaddress
import logging import logging
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from urllib.parse import urlparse
import httpx import httpx
import requests import requests
@ -15,6 +17,30 @@ from .sandbox_info import SandboxInfo
logger = logging.getLogger(__name__) 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: def wait_for_sandbox_ready(sandbox_url: str, timeout: int = 30) -> bool:
"""Poll sandbox health endpoint until ready or timeout. """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. True if sandbox is ready, False otherwise.
""" """
start_time = time.time() start_time = time.time()
while time.time() - start_time < timeout: with requests.Session() as session:
try: session.trust_env = sandbox_http_trust_env(sandbox_url)
response = requests.get(f"{sandbox_url}/v1/sandbox", timeout=5) while time.time() - start_time < timeout:
if response.status_code == 200: try:
return True response = session.get(f"{sandbox_url}/v1/sandbox", timeout=5)
except requests.exceptions.RequestException: if response.status_code == 200:
pass return True
time.sleep(1) except requests.exceptions.RequestException:
pass
time.sleep(1)
return False 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() loop = asyncio.get_running_loop()
deadline = loop.time() + timeout 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: while True:
remaining = deadline - loop.time() remaining = deadline - loop.time()
if remaining <= 0: if remaining <= 0:

View File

@ -7,6 +7,47 @@ from unittest.mock import MagicMock, patch
import pytest 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() @pytest.fixture()
def sandbox(): def sandbox():
"""Create an AioSandbox with a mocked client.""" """Create an AioSandbox with a mocked client."""

View File

@ -8,11 +8,20 @@ from deerflow.community.aio_sandbox import backend as readiness
class _FakeAsyncClient: 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._responses = responses
self._calls = calls self._calls = calls
self._timeout = timeout self._timeout = timeout
self._request_timeouts = request_timeouts self._request_timeouts = request_timeouts
self.trust_env = trust_env
async def __aenter__(self) -> _FakeAsyncClient: async def __aenter__(self) -> _FakeAsyncClient:
return self return self
@ -41,17 +50,65 @@ class _FakeLoop:
return value 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 @pytest.mark.anyio
async def test_wait_for_sandbox_ready_async_uses_nonblocking_polling(monkeypatch: pytest.MonkeyPatch) -> None: async def test_wait_for_sandbox_ready_async_uses_nonblocking_polling(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[str] = [] calls: list[str] = []
sleeps: list[float] = [] sleeps: list[float] = []
clients: list[_FakeAsyncClient] = []
def fake_client(*, timeout: float): def fake_client(*, timeout: float, trust_env: bool):
return _FakeAsyncClient( client = _FakeAsyncClient(
responses=[SimpleNamespace(status_code=503), SimpleNamespace(status_code=200)], responses=[SimpleNamespace(status_code=503), SimpleNamespace(status_code=200)],
calls=calls, calls=calls,
timeout=timeout, timeout=timeout,
trust_env=trust_env,
) )
clients.append(client)
return client
async def fake_sleep(delay: float) -> None: async def fake_sleep(delay: float) -> None:
sleeps.append(delay) 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 calls == ["http://sandbox/v1/sandbox", "http://sandbox/v1/sandbox"]
assert sleeps == [0.05] assert sleeps == [0.05]
assert clients[0].trust_env is False
@pytest.mark.anyio @pytest.mark.anyio
@ -72,11 +130,12 @@ async def test_wait_for_sandbox_ready_async_retries_request_errors(monkeypatch:
calls: list[str] = [] calls: list[str] = []
sleeps: list[float] = [] sleeps: list[float] = []
def fake_client(*, timeout: float): def fake_client(*, timeout: float, trust_env: bool):
return _FakeAsyncClient( return _FakeAsyncClient(
responses=[readiness.httpx.ConnectError("not ready"), SimpleNamespace(status_code=200)], responses=[readiness.httpx.ConnectError("not ready"), SimpleNamespace(status_code=200)],
calls=calls, calls=calls,
timeout=timeout, timeout=timeout,
trust_env=trust_env,
) )
async def fake_sleep(delay: float) -> None: 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] = [] request_timeouts: list[float] = []
sleeps: list[float] = [] sleeps: list[float] = []
def fake_client(*, timeout: float): def fake_client(*, timeout: float, trust_env: bool):
return _FakeAsyncClient( return _FakeAsyncClient(
responses=[SimpleNamespace(status_code=503)], responses=[SimpleNamespace(status_code=503)],
calls=calls, calls=calls,
timeout=timeout, timeout=timeout,
request_timeouts=request_timeouts, request_timeouts=request_timeouts,
trust_env=trust_env,
) )
async def fake_sleep(delay: float) -> None: async def fake_sleep(delay: float) -> None: