feat(sandbox): add thread data mount override for upload sync (#4536)

This commit is contained in:
阿泽 2026-07-28 23:41:14 +08:00 committed by GitHub
parent 2aaf74b0f8
commit 9c7cd4cad3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 108 additions and 6 deletions

View File

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

View File

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

View File

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

View File

@ -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 存储、沙箱内不可见
## 测试示例

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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