mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-28 00:48:07 +00:00
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:
parent
090e80c1dd
commit
2e5c8da257
@ -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]
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user