mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-30 09:56:05 +00:00
feat(sandbox): add thread data mount override for upload sync (#4536)
This commit is contained in:
parent
2aaf74b0f8
commit
9c7cd4cad3
@ -922,6 +922,14 @@ After each run, DeerFlow records a workspace change summary for the run-owned `w
|
||||
|
||||
With `AioSandboxProvider`, shell execution runs inside isolated containers. With `LocalSandboxProvider`, file tools still map to per-thread directories on the host, but host `bash` is disabled by default because it is not a secure isolation boundary. Re-enable host bash only for fully trusted local workflows. Host bash commands have a wall-clock timeout, and long-lived processes should be started in the background with output redirected to a workspace log.
|
||||
|
||||
`AioSandboxProvider` normally detects thread-data mounts from its backend: local
|
||||
containers use the mounted gateway directories, while remote/provisioner
|
||||
sandboxes receive uploaded files through explicit synchronization. Deployments
|
||||
where both sides are guaranteed to share the same thread user-data directories
|
||||
can set `sandbox.thread_data_mounts: true` to skip that per-upload sandbox
|
||||
acquire and sync. Leave the field unset for automatic detection; setting it
|
||||
incorrectly can make uploaded files unavailable inside the sandbox.
|
||||
|
||||
This is the difference between a chatbot with tool access and an agent with an actual execution environment.
|
||||
|
||||
```
|
||||
|
||||
@ -558,7 +558,7 @@ that cannot tell sibling branches apart.
|
||||
**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. 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.
|
||||
- `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`). `uses_thread_data_mounts` defaults to backend detection (`LocalContainerBackend=True`, remote/provisioner backends=False), while the optional `sandbox.thread_data_mounts` boolean takes precedence for deployments that guarantee the Gateway and sandbox share the same thread user-data directories. Setting it `true` skips upload-time sandbox acquire/sync; a false positive leaves uploads unavailable to the sandbox. 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.
|
||||
@ -1279,6 +1279,7 @@ Multi-file upload with automatic document conversion:
|
||||
- Duplicate filenames in a single upload request are auto-renamed with `_N` suffixes so later files do not truncate earlier files
|
||||
- Gateway HTTP uploads stage bytes as `.upload-*.part` files and atomically replace the destination only after size validation. These staging files are hidden from upload listings, agent upload context, and sandbox listing/search tools, and swept on Gateway startup if a hard crash leaves one behind.
|
||||
- Gateway HTTP upload/list/delete handlers offload filesystem work through `deerflow.utils.file_io.run_file_io`, a dedicated ContextVar-preserving file IO executor. Non-mounted sandbox uploads acquire sandboxes with `SandboxProvider.acquire_async()` and offload `read_bytes()` plus `sandbox.update_file()` together.
|
||||
- Mounted upload paths skip both sandbox acquisition and per-file synchronization. For AIO remote/provisioner deployments this requires an explicit, accurate `sandbox.thread_data_mounts: true`; omission preserves backend auto-detection.
|
||||
- Agent receives uploaded file list via `UploadsMiddleware`
|
||||
|
||||
See [docs/FILE_UPLOAD.md](docs/FILE_UPLOAD.md) for details.
|
||||
|
||||
@ -413,6 +413,25 @@ sandbox:
|
||||
|
||||
When using Docker development (`make docker-start`), DeerFlow starts the `provisioner` service only if this provisioner mode is configured. In local or plain Docker sandbox modes, `provisioner` is skipped.
|
||||
|
||||
Remote/provisioner backends default to explicit file synchronization because
|
||||
DeerFlow cannot infer whether their `/mnt/user-data` mount points reference the
|
||||
same storage as the Gateway. When the deployment guarantees that both sides use
|
||||
the same thread user-data directories, opt out of that extra transfer:
|
||||
|
||||
```yaml
|
||||
sandbox:
|
||||
use: deerflow.community.aio_sandbox:AioSandboxProvider
|
||||
provisioner_url: http://provisioner:8002
|
||||
thread_data_mounts: true
|
||||
```
|
||||
|
||||
Leave `thread_data_mounts` unset to retain backend auto-detection. Set it to
|
||||
`false` to force explicit synchronization even for a local container backend.
|
||||
Only set it to `true` after verifying the Gateway's
|
||||
`users/{user_id}/threads/{thread_id}/user-data` directory and the sandbox's
|
||||
`/mnt/user-data` are the same storage; a false positive skips synchronization
|
||||
and makes newly uploaded files unavailable inside the sandbox.
|
||||
|
||||
See [Provisioner Setup Guide](../../docker/provisioner/README.md) for detailed configuration, prerequisites, and troubleshooting.
|
||||
|
||||
**E2B Cloud Sandbox** (runs sandbox code in [E2B](https://e2b.dev) cloud micro-VMs):
|
||||
|
||||
@ -154,7 +154,9 @@ read_file(path="/mnt/user-data/uploads/document.md")
|
||||
上传流程采用“线程目录优先”策略:
|
||||
- 先写入 `backend/.deer-flow/threads/{thread_id}/user-data/uploads/` 作为权威存储
|
||||
- 本地沙箱(`sandbox_id=local`)直接使用线程目录内容
|
||||
- 非本地沙箱会额外同步到 `/mnt/user-data/uploads/*`,确保运行时可见
|
||||
- 默认情况下,非本地沙箱通过 `acquire_async` 获取后,再额外同步到 `/mnt/user-data/uploads/*`,确保运行时可见
|
||||
- 如果 Gateway 与远端沙箱保证挂载同一份线程 user-data(例如正确对齐的共享 PVC、NFS 或 hostPath),可设置 `sandbox.thread_data_mounts: true`;上传路由会跳过 sandbox acquire 和逐文件同步
|
||||
- 不确定挂载关系时应省略该配置并保留自动检测。错误地设为 `true` 会导致文件只存在于 Gateway 存储、沙箱内不可见
|
||||
|
||||
## 测试示例
|
||||
|
||||
|
||||
@ -167,6 +167,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
|
||||
container_prefix: deer-flow-sandbox
|
||||
idle_timeout: 600 # Idle timeout in seconds (0 to disable)
|
||||
replicas: 3 # Max concurrent sandbox containers (LRU eviction when exceeded)
|
||||
thread_data_mounts: null # null = backend auto-detection
|
||||
mounts: # Volume mounts for local containers
|
||||
- host_path: /path/on/host
|
||||
container_path: /path/in/container
|
||||
@ -255,8 +256,12 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
|
||||
|
||||
Local container backends bind-mount the thread data directories, so files
|
||||
written by the gateway are already visible when the sandbox starts.
|
||||
Remote backends may require explicit file sync.
|
||||
Remote backends may require explicit file sync. Operators can override
|
||||
this detection when gateway and remote sandboxes share the same storage.
|
||||
"""
|
||||
override = self._config.get("thread_data_mounts")
|
||||
if override is not None:
|
||||
return override
|
||||
return isinstance(self._backend, LocalContainerBackend)
|
||||
|
||||
# ── Factory methods ──────────────────────────────────────────────────
|
||||
@ -302,6 +307,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
|
||||
"idle_timeout": idle_timeout if idle_timeout is not None else DEFAULT_IDLE_TIMEOUT,
|
||||
"replicas": replicas if replicas is not None else DEFAULT_REPLICAS,
|
||||
"mounts": sandbox_config.mounts or [],
|
||||
"thread_data_mounts": getattr(sandbox_config, "thread_data_mounts", None),
|
||||
"environment": self._resolve_env_vars(sandbox_config.environment or {}),
|
||||
"ownership": getattr(sandbox_config, "ownership", None),
|
||||
# A redis stream bridge means the deployment is multi-instance, which
|
||||
|
||||
@ -88,6 +88,8 @@ class SandboxConfig(BaseModel):
|
||||
port: Base port for sandbox containers (default: 8080)
|
||||
container_prefix: Prefix for container names (default: deer-flow-sandbox)
|
||||
mounts: List of volume mounts to share directories with the container
|
||||
thread_data_mounts: Override whether thread data is already visible to
|
||||
the sandbox through shared mounts. Omit to auto-detect from the backend.
|
||||
|
||||
AioSandboxProvider and E2BSandboxProvider shared options:
|
||||
ownership: Cross-instance sandbox ownership store (memory | redis). Multi-instance
|
||||
@ -153,6 +155,10 @@ class SandboxConfig(BaseModel):
|
||||
default_factory=list,
|
||||
description="List of volume mounts to share directories between host and container",
|
||||
)
|
||||
thread_data_mounts: bool | None = Field(
|
||||
default=None,
|
||||
description=("AioSandboxProvider: override whether /mnt/user-data is already visible through shared mounts. Omitted uses backend auto-detection; true skips explicit upload synchronization; false forces it."),
|
||||
)
|
||||
environment: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="Environment variables to inject into the sandbox container. Values starting with $ will be resolved from host environment variables.",
|
||||
|
||||
@ -11,6 +11,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from deerflow.config.paths import Paths, join_host_path
|
||||
from deerflow.config.sandbox_config import SandboxConfig
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
|
||||
_LEGACY_COLLIDING_IDENTITIES = (
|
||||
@ -18,6 +19,48 @@ _LEGACY_COLLIDING_IDENTITIES = (
|
||||
("user-94361", "thread-94361"),
|
||||
)
|
||||
|
||||
# ── thread-data mount configuration ─────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sandbox_overrides", "expected"),
|
||||
[
|
||||
({}, None),
|
||||
({"thread_data_mounts": True}, True),
|
||||
({"thread_data_mounts": False}, False),
|
||||
],
|
||||
)
|
||||
def test_load_config_preserves_thread_data_mounts_override(sandbox_overrides, expected, monkeypatch):
|
||||
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
||||
sandbox_config = SandboxConfig(
|
||||
use="deerflow.community.aio_sandbox:AioSandboxProvider",
|
||||
**sandbox_overrides,
|
||||
)
|
||||
app_config = SimpleNamespace(sandbox=sandbox_config, stream_bridge=None)
|
||||
monkeypatch.setattr(aio_mod, "get_app_config", lambda: app_config)
|
||||
provider = aio_mod.AioSandboxProvider.__new__(aio_mod.AioSandboxProvider)
|
||||
|
||||
assert provider._load_config()["thread_data_mounts"] is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("backend_is_local", "override", "expected"),
|
||||
[
|
||||
(True, None, True),
|
||||
(False, None, False),
|
||||
(True, False, False),
|
||||
(False, True, True),
|
||||
],
|
||||
)
|
||||
def test_thread_data_mounts_override_precedes_backend_detection(backend_is_local, override, expected):
|
||||
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
||||
provider = aio_mod.AioSandboxProvider.__new__(aio_mod.AioSandboxProvider)
|
||||
provider._config = {} if override is None else {"thread_data_mounts": override}
|
||||
provider._backend = object.__new__(aio_mod.LocalContainerBackend) if backend_is_local else object()
|
||||
|
||||
assert provider.uses_thread_data_mounts is expected
|
||||
|
||||
|
||||
# ── ensure_thread_dirs ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@ -138,6 +138,7 @@ def test_upload_files_skips_acquire_when_thread_data_is_mounted(tmp_path):
|
||||
|
||||
provider = MagicMock()
|
||||
provider.uses_thread_data_mounts = True
|
||||
provider.acquire_async = AsyncMock()
|
||||
|
||||
with (
|
||||
patch.object(uploads, "get_uploads_dir", return_value=thread_uploads_dir),
|
||||
@ -150,6 +151,7 @@ def test_upload_files_skips_acquire_when_thread_data_is_mounted(tmp_path):
|
||||
assert result.success is True
|
||||
assert (thread_uploads_dir / "notes.txt").read_bytes() == b"hello uploads"
|
||||
provider.acquire.assert_not_called()
|
||||
provider.acquire_async.assert_not_awaited()
|
||||
provider.get.assert_not_called()
|
||||
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
# ============================================================================
|
||||
# Bump this number when the config schema changes.
|
||||
# Run `make config-upgrade` to merge new fields into your local config.yaml.
|
||||
config_version: 30
|
||||
config_version: 31
|
||||
|
||||
# ============================================================================
|
||||
# Logging
|
||||
@ -1253,6 +1253,15 @@ sandbox:
|
||||
# # Optional: Prefix for container names (default: deer-flow-sandbox)
|
||||
# # container_prefix: deer-flow-sandbox
|
||||
#
|
||||
# # Optional: Override whether the sandbox already sees the gateway's
|
||||
# # thread workspace/uploads/outputs through shared mounts.
|
||||
# # Omit this field to auto-detect from the backend (local containers: true;
|
||||
# # remote/provisioner backends: false). Set true only when the deployment
|
||||
# # guarantees both sides use the same thread user-data directories, such as
|
||||
# # a correctly aligned shared PVC, NFS volume, hostPath, or bind mount.
|
||||
# # true skips per-upload sandbox acquire/sync; false forces explicit sync.
|
||||
# # thread_data_mounts: true
|
||||
#
|
||||
# # Optional: Additional mount directories from host to container
|
||||
# # NOTE: Skills directory is automatically mounted from skills.path to skills.container_path
|
||||
# # mounts:
|
||||
|
||||
@ -124,7 +124,7 @@ they resolve from the `secrets` map):
|
||||
|
||||
```yaml
|
||||
config: |
|
||||
config_version: 30
|
||||
config_version: 31
|
||||
models:
|
||||
- name: gpt-4
|
||||
use: langchain_openai:ChatOpenAI
|
||||
|
||||
@ -240,7 +240,7 @@ ingress:
|
||||
# -- DeerFlow config.yaml content. Secrets MUST stay as $VAR references — never
|
||||
# inline literal secret values here. The default enables provisioner sandbox.
|
||||
config: |
|
||||
config_version: 30
|
||||
config_version: 31
|
||||
log_level: info
|
||||
|
||||
models: []
|
||||
|
||||
@ -84,6 +84,12 @@ Create a new sandbox Pod + Service.
|
||||
|
||||
`user_id` is optional for backwards compatibility and defaults to `default`. When `USERDATA_PVC_NAME` is set, the provisioner uses it to isolate PVC-backed user-data directories.
|
||||
|
||||
When the Gateway mounts that same storage at its DeerFlow home and the PVC
|
||||
subpaths align, set `sandbox.thread_data_mounts: true` in the Gateway's
|
||||
`config.yaml` to skip redundant upload-time sandbox acquire/sync. Leave the
|
||||
field unset when using unrelated storage or when the mount relationship is
|
||||
uncertain.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user