From 83e908bfe31ec4150762c1dd9cdf921eea3d8ff3 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Thu, 6 Aug 2026 20:22:10 +0800 Subject: [PATCH] fix: mount upload conversions read-only --- .../aio_sandbox/aio_sandbox_provider.py | 7 +++++ .../sandbox/local/local_sandbox_provider.py | 10 ++++++- backend/tests/test_aio_sandbox_provider.py | 16 +++++++++++ .../test_local_sandbox_provider_mounts.py | 28 +++++++++++++++++++ .../2026-08-06-upload-review-remediation.md | 12 ++++---- 5 files changed, 66 insertions(+), 7 deletions(-) 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 cd7d4ab97..b29ff8a3e 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 @@ -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), diff --git a/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py b/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py index 36ef8d7f6..fcc90b9b8 100644 --- a/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py +++ b/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py @@ -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)), diff --git a/backend/tests/test_aio_sandbox_provider.py b/backend/tests/test_aio_sandbox_provider.py index 3fabd9757..d85140df0 100644 --- a/backend/tests/test_aio_sandbox_provider.py +++ b/backend/tests/test_aio_sandbox_provider.py @@ -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" diff --git a/backend/tests/test_local_sandbox_provider_mounts.py b/backend/tests/test_local_sandbox_provider_mounts.py index a0093537e..0cc243fe9 100644 --- a/backend/tests/test_local_sandbox_provider_mounts.py +++ b/backend/tests/test_local_sandbox_provider_mounts.py @@ -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 diff --git a/docs/superpowers/plans/2026-08-06-upload-review-remediation.md b/docs/superpowers/plans/2026-08-06-upload-review-remediation.md index e867be285..4582bc333 100644 --- a/docs/superpowers/plans/2026-08-06-upload-review-remediation.md +++ b/docs/superpowers/plans/2026-08-06-upload-review-remediation.md @@ -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 `..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**