From 40a4acbbeda09530060509c6d7002ef17b7e2444 Mon Sep 17 00:00:00 2001 From: Admire <64821731+LittleChenLiya@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:37:44 +0800 Subject: [PATCH] fix(sandbox): Relax upload permissions for aio sandbox sync (#1409) * Relax upload permissions for aio sandbox sync * Harden upload permission sync checks --------- Co-authored-by: Willem Jiang --- backend/app/gateway/routers/uploads.py | 24 ++++++++ backend/tests/test_uploads_router.py | 80 ++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/backend/app/gateway/routers/uploads.py b/backend/app/gateway/routers/uploads.py index 2c61394..27b4941 100644 --- a/backend/app/gateway/routers/uploads.py +++ b/backend/app/gateway/routers/uploads.py @@ -1,6 +1,8 @@ """Upload router for handling file uploads.""" import logging +import os +import stat from fastapi import APIRouter, File, HTTPException, UploadFile from pydantic import BaseModel @@ -33,6 +35,26 @@ class UploadResponse(BaseModel): message: str +def _make_file_sandbox_writable(file_path: os.PathLike[str] | str) -> None: + """Ensure uploaded files remain writable when mounted into non-local sandboxes. + + In AIO sandbox mode, the gateway writes the authoritative host-side file + first, then the sandbox runtime may rewrite the same mounted path. Granting + world-writable access here prevents permission mismatches between the + gateway user and the sandbox runtime user. + """ + file_stat = os.lstat(file_path) + if stat.S_ISLNK(file_stat.st_mode): + logger.warning("Skipping sandbox chmod for symlinked upload path: %s", file_path) + return + + writable_mode = ( + stat.S_IMODE(file_stat.st_mode) | stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH + ) + chmod_kwargs = {"follow_symlinks": False} if os.chmod in os.supports_follow_symlinks else {} + os.chmod(file_path, writable_mode, **chmod_kwargs) + + @router.post("", response_model=UploadResponse) async def upload_files( thread_id: str, @@ -71,6 +93,7 @@ async def upload_files( virtual_path = upload_virtual_path(safe_filename) if sandbox_id != "local": + _make_file_sandbox_writable(file_path) sandbox.update_file(virtual_path, content) file_info = { @@ -90,6 +113,7 @@ async def upload_files( md_virtual_path = upload_virtual_path(md_path.name) if sandbox_id != "local": + _make_file_sandbox_writable(md_path) sandbox.update_file(md_virtual_path, md_path.read_bytes()) file_info["markdown_file"] = md_path.name diff --git a/backend/tests/test_uploads_router.py b/backend/tests/test_uploads_router.py index 048682f..68f0f4d 100644 --- a/backend/tests/test_uploads_router.py +++ b/backend/tests/test_uploads_router.py @@ -1,4 +1,5 @@ import asyncio +import stat from io import BytesIO from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -69,6 +70,85 @@ def test_upload_files_syncs_non_local_sandbox_and_marks_markdown_file(tmp_path): sandbox.update_file.assert_any_call("/mnt/user-data/uploads/report.md", b"converted") +def test_upload_files_makes_non_local_files_sandbox_writable(tmp_path): + thread_uploads_dir = tmp_path / "uploads" + thread_uploads_dir.mkdir(parents=True) + + provider = MagicMock() + provider.acquire.return_value = "aio-1" + sandbox = MagicMock() + provider.get.return_value = sandbox + + async def fake_convert(file_path: Path) -> Path: + md_path = file_path.with_suffix(".md") + md_path.write_text("converted", encoding="utf-8") + return md_path + + with ( + patch.object(uploads, "get_uploads_dir", return_value=thread_uploads_dir), + patch.object(uploads, "ensure_uploads_dir", return_value=thread_uploads_dir), + patch.object(uploads, "get_sandbox_provider", return_value=provider), + patch.object(uploads, "convert_file_to_markdown", AsyncMock(side_effect=fake_convert)), + patch.object(uploads, "_make_file_sandbox_writable") as make_writable, + ): + file = UploadFile(filename="report.pdf", file=BytesIO(b"pdf-bytes")) + result = asyncio.run(uploads.upload_files("thread-aio", files=[file])) + + assert result.success is True + make_writable.assert_any_call(thread_uploads_dir / "report.pdf") + make_writable.assert_any_call(thread_uploads_dir / "report.md") + + +def test_upload_files_does_not_adjust_permissions_for_local_sandbox(tmp_path): + thread_uploads_dir = tmp_path / "uploads" + thread_uploads_dir.mkdir(parents=True) + + provider = MagicMock() + provider.acquire.return_value = "local" + sandbox = MagicMock() + provider.get.return_value = sandbox + + with ( + patch.object(uploads, "get_uploads_dir", return_value=thread_uploads_dir), + patch.object(uploads, "ensure_uploads_dir", return_value=thread_uploads_dir), + patch.object(uploads, "get_sandbox_provider", return_value=provider), + patch.object(uploads, "_make_file_sandbox_writable") as make_writable, + ): + file = UploadFile(filename="notes.txt", file=BytesIO(b"hello uploads")) + result = asyncio.run(uploads.upload_files("thread-local", files=[file])) + + assert result.success is True + make_writable.assert_not_called() + + +def test_make_file_sandbox_writable_adds_write_bits_for_regular_files(tmp_path): + file_path = tmp_path / "report.pdf" + file_path.write_bytes(b"pdf-bytes") + os_chmod_mode = stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH + file_path.chmod(os_chmod_mode) + + uploads._make_file_sandbox_writable(file_path) + + updated_mode = stat.S_IMODE(file_path.stat().st_mode) + assert updated_mode & stat.S_IWUSR + assert updated_mode & stat.S_IWGRP + assert updated_mode & stat.S_IWOTH + + +def test_make_file_sandbox_writable_skips_symlinks(tmp_path): + file_path = tmp_path / "target-link.txt" + file_path.write_text("hello", encoding="utf-8") + symlink_stat = MagicMock(st_mode=stat.S_IFLNK) + + with ( + patch.object(uploads.os, "lstat", return_value=symlink_stat), + patch.object(uploads.os, "chmod") as chmod, + ): + uploads._make_file_sandbox_writable(file_path) + + chmod.assert_not_called() + + def test_upload_files_rejects_dotdot_and_dot_filenames(tmp_path): thread_uploads_dir = tmp_path / "uploads" thread_uploads_dir.mkdir(parents=True)