From 9c7cd4cad3f46ba7b5a812b17651444fb321f87e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E6=B3=BD?= Date: Tue, 28 Jul 2026 23:41:14 +0800 Subject: [PATCH] feat(sandbox): add thread data mount override for upload sync (#4536) --- README.md | 8 ++++ backend/AGENTS.md | 3 +- backend/docs/CONFIGURATION.md | 19 ++++++++ backend/docs/FILE_UPLOAD.md | 4 +- .../aio_sandbox/aio_sandbox_provider.py | 8 +++- .../harness/deerflow/config/sandbox_config.py | 6 +++ backend/tests/test_aio_sandbox_provider.py | 43 +++++++++++++++++++ backend/tests/test_uploads_router.py | 2 + config.example.yaml | 11 ++++- deploy/helm/deer-flow/README.md | 2 +- deploy/helm/deer-flow/values.yaml | 2 +- docker/provisioner/README.md | 6 +++ 12 files changed, 108 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ece7f1df9..d6e1a8048 100644 --- a/README.md +++ b/README.md @@ -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. ``` diff --git a/backend/AGENTS.md b/backend/AGENTS.md index fc9f80458..a6185f9cd 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -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. diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 99877af54..d128ba043 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -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): diff --git a/backend/docs/FILE_UPLOAD.md b/backend/docs/FILE_UPLOAD.md index 2b15b27e7..3bcd99947 100644 --- a/backend/docs/FILE_UPLOAD.md +++ b/backend/docs/FILE_UPLOAD.md @@ -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 存储、沙箱内不可见 ## 测试示例 diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py index cc35daf84..fe2c56697 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py @@ -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 diff --git a/backend/packages/harness/deerflow/config/sandbox_config.py b/backend/packages/harness/deerflow/config/sandbox_config.py index 2c9982b96..732c47621 100644 --- a/backend/packages/harness/deerflow/config/sandbox_config.py +++ b/backend/packages/harness/deerflow/config/sandbox_config.py @@ -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.", diff --git a/backend/tests/test_aio_sandbox_provider.py b/backend/tests/test_aio_sandbox_provider.py index e5d1be764..3151d8ffd 100644 --- a/backend/tests/test_aio_sandbox_provider.py +++ b/backend/tests/test_aio_sandbox_provider.py @@ -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 ─────────────────────────────────────────────────────── diff --git a/backend/tests/test_uploads_router.py b/backend/tests/test_uploads_router.py index b07c19046..6f72bd0bd 100644 --- a/backend/tests/test_uploads_router.py +++ b/backend/tests/test_uploads_router.py @@ -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() diff --git a/config.example.yaml b/config.example.yaml index 18b9a97a5..74a431074 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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: diff --git a/deploy/helm/deer-flow/README.md b/deploy/helm/deer-flow/README.md index ed216bf3a..c00663c04 100644 --- a/deploy/helm/deer-flow/README.md +++ b/deploy/helm/deer-flow/README.md @@ -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 diff --git a/deploy/helm/deer-flow/values.yaml b/deploy/helm/deer-flow/values.yaml index be78b48c4..209fb7de9 100644 --- a/deploy/helm/deer-flow/values.yaml +++ b/deploy/helm/deer-flow/values.yaml @@ -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: [] diff --git a/docker/provisioner/README.md b/docker/provisioner/README.md index b1974ba0b..aa8213d6d 100644 --- a/docker/provisioner/README.md +++ b/docker/provisioner/README.md @@ -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 {