fix: mount upload conversions read-only

This commit is contained in:
hetaoBackend 2026-08-06 20:22:10 +08:00
parent 920dc247d2
commit 83e908bfe3
5 changed files with 66 additions and 7 deletions

View File

@ -44,6 +44,7 @@ from deerflow.integrations.lark_cli import LARK_CLI_SANDBOX_CONFIG_DIR, LARK_CLI
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.sandbox.sandbox import Sandbox
from deerflow.sandbox.sandbox_provider import SandboxProvider
from deerflow.uploads.layout import UPLOAD_CONVERSIONS_DIRNAME, ensure_conversion_dir
from .aio_sandbox import AioSandbox
from .backend import SandboxBackend, wait_for_sandbox_ready, wait_for_sandbox_ready_async
@ -847,11 +848,17 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
paths = get_paths()
effective_user_id = AioSandboxProvider._effective_acquire_user_id(user_id)
paths.ensure_thread_dirs(thread_id, user_id=effective_user_id)
ensure_conversion_dir(paths.sandbox_uploads_dir(thread_id, user_id=effective_user_id))
return [
(paths.host_sandbox_work_dir(thread_id, user_id=effective_user_id), f"{VIRTUAL_PATH_PREFIX}/workspace", False),
(paths.host_sandbox_uploads_dir(thread_id, user_id=effective_user_id), f"{VIRTUAL_PATH_PREFIX}/uploads", False),
(paths.host_sandbox_outputs_dir(thread_id, user_id=effective_user_id), f"{VIRTUAL_PATH_PREFIX}/outputs", False),
(
join_host_path(paths.host_sandbox_user_data_dir(thread_id, user_id=effective_user_id), UPLOAD_CONVERSIONS_DIRNAME),
f"{VIRTUAL_PATH_PREFIX}/{UPLOAD_CONVERSIONS_DIRNAME}",
True,
),
# ACP workspace: read-only inside the sandbox (lead agent reads results;
# the ACP subprocess writes from the host side, not from within the container).
(paths.host_acp_workspace_dir(thread_id, user_id=effective_user_id), "/mnt/acp-workspace", True),

View File

@ -6,6 +6,7 @@ from pathlib import Path
from deerflow.sandbox.local.local_sandbox import LocalSandbox, PathMapping
from deerflow.sandbox.sandbox import Sandbox
from deerflow.sandbox.sandbox_provider import SandboxProvider
from deerflow.uploads.layout import UPLOAD_CONVERSIONS_DIRNAME, ensure_conversion_dir
logger = logging.getLogger(__name__)
@ -42,7 +43,8 @@ class LocalSandboxProvider(SandboxProvider):
The provider now produces a fresh ``LocalSandbox`` per ``thread_id`` whose
``path_mappings`` include thread-scoped entries for
``/mnt/user-data/{workspace,uploads,outputs}`` and ``/mnt/acp-workspace``,
``/mnt/user-data/{workspace,uploads,outputs}``, the read-only generated
``/mnt/user-data/.upload-conversions`` namespace, and ``/mnt/acp-workspace``,
mirroring how :class:`AioSandboxProvider` bind-mounts those paths into its
docker container. The legacy ``acquire()`` / ``acquire(None)`` call still
returns a generic singleton with id ``"local"`` for callers (and tests)
@ -292,6 +294,7 @@ class LocalSandboxProvider(SandboxProvider):
paths = get_paths()
effective_user_id = LocalSandboxProvider._effective_acquire_user_id(user_id)
paths.ensure_thread_dirs(thread_id, user_id=effective_user_id)
conversion_dir = ensure_conversion_dir(paths.sandbox_uploads_dir(thread_id, user_id=effective_user_id))
mappings = [
# Aggregate parent mapping so ``ls /mnt/user-data`` and other
@ -319,6 +322,11 @@ class LocalSandboxProvider(SandboxProvider):
local_path=str(paths.sandbox_outputs_dir(thread_id, user_id=effective_user_id)),
read_only=False,
),
PathMapping(
container_path=f"{_USER_DATA_VIRTUAL_PREFIX}/{UPLOAD_CONVERSIONS_DIRNAME}",
local_path=str(conversion_dir),
read_only=True,
),
PathMapping(
container_path=_ACP_WORKSPACE_VIRTUAL_PREFIX,
local_path=str(paths.acp_workspace_dir(thread_id, user_id=effective_user_id)),

View File

@ -159,6 +159,20 @@ def test_get_thread_mounts_includes_user_data_dirs(tmp_path, monkeypatch):
assert "/mnt/user-data/outputs" in container_paths
def test_get_thread_mounts_includes_upload_conversions_read_only(tmp_path, monkeypatch):
"""Generated upload conversions must be readable but immutable in AIO."""
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr(aio_mod, "get_effective_user_id", lambda: None)
mounts = aio_mod.AioSandboxProvider._get_thread_mounts("thread-4")
container_paths = {container_path: (host_path, read_only) for host_path, container_path, read_only in mounts}
expected_host = tmp_path / "threads" / "thread-4" / "user-data" / ".upload-conversions"
assert container_paths["/mnt/user-data/.upload-conversions"] == (str(expected_host), True)
assert expected_host.is_dir()
def test_get_thread_mounts_uses_explicit_user_id(tmp_path, monkeypatch):
"""Channel runs must mount the same user bucket used for artifact delivery."""
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
@ -171,6 +185,7 @@ def test_get_thread_mounts_uses_explicit_user_id(tmp_path, monkeypatch):
assert container_paths["/mnt/user-data/workspace"] == str(tmp_path / "users" / "ou-user" / "threads" / "thread-4" / "user-data" / "workspace")
assert container_paths["/mnt/user-data/uploads"] == str(tmp_path / "users" / "ou-user" / "threads" / "thread-4" / "user-data" / "uploads")
assert container_paths["/mnt/user-data/outputs"] == str(tmp_path / "users" / "ou-user" / "threads" / "thread-4" / "user-data" / "outputs")
assert container_paths["/mnt/user-data/.upload-conversions"] == str(tmp_path / "users" / "ou-user" / "threads" / "thread-4" / "user-data" / ".upload-conversions")
def test_get_lark_cli_runtime_mounts_uses_user_auth_dirs(tmp_path, monkeypatch):
@ -298,6 +313,7 @@ def test_get_thread_mounts_preserves_windows_host_path_style(tmp_path, monkeypat
assert container_paths["/mnt/user-data/workspace"] == r"C:\Users\demo\deer-flow\backend\.deer-flow\threads\thread-10\user-data\workspace"
assert container_paths["/mnt/user-data/uploads"] == r"C:\Users\demo\deer-flow\backend\.deer-flow\threads\thread-10\user-data\uploads"
assert container_paths["/mnt/user-data/outputs"] == r"C:\Users\demo\deer-flow\backend\.deer-flow\threads\thread-10\user-data\outputs"
assert container_paths["/mnt/user-data/.upload-conversions"] == r"C:\Users\demo\deer-flow\backend\.deer-flow\threads\thread-10\user-data\.upload-conversions"
assert container_paths["/mnt/acp-workspace"] == r"C:\Users\demo\deer-flow\backend\.deer-flow\threads\thread-10\acp-workspace"

View File

@ -544,6 +544,34 @@ class TestMultipleMounts:
class TestLocalSandboxProviderMounts:
def test_thread_mappings_mount_upload_conversions_read_only(self, tmp_path):
from deerflow.config.paths import Paths
paths = Paths(base_dir=tmp_path / "home")
config = SimpleNamespace(
skills=SimpleNamespace(
container_path="/mnt/skills",
get_skills_path=lambda: tmp_path / "skills",
use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage",
)
)
with (
patch("deerflow.config.get_app_config", return_value=config),
patch("deerflow.config.paths.get_paths", return_value=paths),
):
mappings = LocalSandboxProvider._build_thread_path_mappings("thread-a", user_id="alice")
conversion_mapping = next(mapping for mapping in mappings if mapping.container_path == "/mnt/user-data/.upload-conversions")
assert conversion_mapping.local_path == str(paths.sandbox_user_data_dir("thread-a", user_id="alice") / ".upload-conversions")
assert conversion_mapping.read_only is True
assert Path(conversion_mapping.local_path).is_dir()
sandbox = LocalSandbox("test", mappings)
with pytest.raises(OSError) as exc_info:
sandbox.write_file("/mnt/user-data/.upload-conversions/forbidden.md", "content")
assert exc_info.value.errno == errno.EROFS
def test_thread_mappings_mount_per_user_integration_projections(self, tmp_path):
from deerflow.config.paths import Paths

View File

@ -555,7 +555,7 @@ git commit -m "fix: retain upload leases through adapter sync"
- Modify: `backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py`
- Modify: `backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py`
- Test: `backend/tests/test_local_sandbox_provider_mounts.py`
- Test: `backend/tests/test_aio_sandbox_thread_mounts.py`
- Test: `backend/tests/test_aio_sandbox_provider.py`
**Interfaces:**
- Consumes: `UPLOAD_CONVERSIONS_DIRNAME`, `ensure_conversion_dir`, and existing `join_host_path`.
@ -573,7 +573,7 @@ def test_local_thread_mappings_mount_conversions_read_only(paths_config):
assert Path(conversion.local_path).is_dir()
def test_aio_thread_mounts_include_read_only_conversions(paths_config):
def test_get_thread_mounts_includes_upload_conversions_read_only(tmp_path, monkeypatch):
mounts = AioSandboxProvider._get_thread_mounts("thread-1", user_id="user-1")
assert any(container == "/mnt/user-data/.upload-conversions" and read_only for _, container, read_only in mounts)
```
@ -582,7 +582,7 @@ def test_aio_thread_mounts_include_read_only_conversions(paths_config):
```bash
cd backend
uv run pytest tests/test_local_sandbox_provider_mounts.py tests/test_aio_sandbox_thread_mounts.py -k "conversion" -q
uv run pytest tests/test_local_sandbox_provider_mounts.py tests/test_aio_sandbox_provider.py -k "conversion" -q
```
Expected: neither provider exposes the explicit mapping.
@ -605,13 +605,13 @@ AIO adds:
```bash
cd backend
uv run pytest tests/test_local_sandbox_provider_mounts.py tests/test_aio_sandbox_thread_mounts.py tests/test_local_sandbox_virtual_path_contract.py -q
uv run pytest tests/test_local_sandbox_provider_mounts.py tests/test_aio_sandbox_provider.py tests/test_local_sandbox_virtual_path_contract.py -q
```
- [ ] **Step 5: Commit sandbox visibility**
```bash
git add backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py backend/tests/test_local_sandbox_provider_mounts.py backend/tests/test_aio_sandbox_thread_mounts.py
git add backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py backend/tests/test_local_sandbox_provider_mounts.py backend/tests/test_aio_sandbox_provider.py
git commit -m "fix: mount upload conversions read-only"
```
@ -642,7 +642,7 @@ use `<truncated-primary>.<full-sha256>.md`. State that `.upload-*.part` is reser
```bash
cd backend
uv run pytest tests/test_uploads_manager.py tests/test_upload_conversion.py tests/test_file_conversion.py tests/test_uploads_router.py tests/test_client.py tests/test_channel_file_attachments.py tests/test_dingtalk_channel.py tests/test_wechat_channel.py tests/test_local_sandbox_provider_mounts.py tests/test_aio_sandbox_thread_mounts.py tests/test_uploads_middleware_core_logic.py tests/test_list_uploaded_files_tool.py tests/blocking_io/test_upload_conversion.py tests/blocking_io/test_uploads_router.py tests/blocking_io/test_channels_ingest.py tests/blocking_io/test_feishu_receive_file.py tests/blocking_io/test_dingtalk_receive_file.py -q
uv run pytest tests/test_uploads_manager.py tests/test_upload_conversion.py tests/test_file_conversion.py tests/test_uploads_router.py tests/test_client.py tests/test_channel_file_attachments.py tests/test_dingtalk_channel.py tests/test_wechat_channel.py tests/test_local_sandbox_provider_mounts.py tests/test_aio_sandbox_provider.py tests/test_uploads_middleware_core_logic.py tests/test_list_uploaded_files_tool.py tests/blocking_io/test_upload_conversion.py tests/blocking_io/test_uploads_router.py tests/blocking_io/test_channels_ingest.py tests/blocking_io/test_feishu_receive_file.py tests/blocking_io/test_dingtalk_receive_file.py -q
```
- [ ] **Step 3: Run formatting, lint, and the full backend suite**