From 8bb18804d002795e60250f00d3d4b5766222701e Mon Sep 17 00:00:00 2001 From: "Johnny.Wang" Date: Fri, 13 Mar 2026 15:21:23 +0800 Subject: [PATCH] Fix sandbox workspace owner permissions --- platform/app/config.py | 1 + platform/app/worker/sandbox.py | 28 ++++++++++++- platform/tests/test_sandbox_env_contract.py | 44 +++++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/platform/app/config.py b/platform/app/config.py index 11d2e33..8e0a8f1 100644 --- a/platform/app/config.py +++ b/platform/app/config.py @@ -83,6 +83,7 @@ class Settings(BaseSettings): SANDBOX_AGENT_PORT: int = 9090 SANDBOX_READONLY_ROOT: bool = True SANDBOX_MAX_CONCURRENT: int = 16 + SANDBOX_RUN_AS_WORKSPACE_OWNER: bool = True SANDBOX_WORKSPACE_BASE_DIR: str = "/tmp/silicon_agent/tasks" SANDBOX_FALLBACK_MODE: str = "graceful" SANDBOX_MEMORY_MIB: int = 4096 # BoxLite memory limit (MiB); Docker uses SANDBOX_MEMORY diff --git a/platform/app/worker/sandbox.py b/platform/app/worker/sandbox.py index 85842c6..f84a703 100644 --- a/platform/app/worker/sandbox.py +++ b/platform/app/worker/sandbox.py @@ -144,12 +144,23 @@ def _failed( error_code="workspace_not_found", error_message=f"Sandbox workspace path does not exist or is not a directory: {workspace}", ) + workspace_uid: int | None = None + workspace_gid: int | None = None + if settings.SANDBOX_RUN_AS_WORKSPACE_OWNER: + try: + stat_result = workspace_path.stat() + workspace_uid = stat_result.st_uid + workspace_gid = stat_result.st_gid + except OSError: + logger.warning("Failed to stat sandbox workspace owner for %s", workspace, exc_info=True) docker_cmd = self._build_docker_run_cmd( container_name=container_name, image=resolved_image, workspace=workspace, task_id=task_id, + workspace_uid=workspace_uid, + workspace_gid=workspace_gid, ) logger.info("Creating sandbox container: %s (image=%s)", container_name, resolved_image) @@ -324,10 +335,19 @@ async def execute_stage( streamed=True, ) except httpx.HTTPStatusError as e: + # NOTE: do NOT read e.response.text here — on a streaming response + # the body has not been buffered and accessing .text raises + # ResponseNotRead, which would mask the original status code. + status_code = e.response.status_code + logger.error( + "Sandbox HTTP %d from %s (check container logs for root cause)", + status_code, + info.container_name, + ) return SandboxResult( error=( - f"Sandbox HTTP {e.response.status_code} from " - f"{info.container_name}: {e.response.text[:500]}" + f"Sandbox HTTP {status_code} from {info.container_name} " + f"(see container logs for details)" ), streamed=True, ) @@ -390,6 +410,8 @@ def _build_docker_run_cmd( image: str, workspace: str, task_id: str, + workspace_uid: int | None = None, + workspace_gid: int | None = None, ) -> list[str]: """Build the docker run command with security constraints.""" parts = [ @@ -412,6 +434,8 @@ def _build_docker_run_cmd( "--mount", f"type=bind,src={workspace},dst=/workspace", ] + if settings.SANDBOX_RUN_AS_WORKSPACE_OWNER and workspace_uid is not None and workspace_gid is not None: + parts.extend(["--user", f"{workspace_uid}:{workspace_gid}"]) capture_model_api_raw = bool(settings.SANDBOX_DUMP_MODEL_API_RESPONSE) container_raw_log_path: str | None = None if capture_model_api_raw: diff --git a/platform/tests/test_sandbox_env_contract.py b/platform/tests/test_sandbox_env_contract.py index 113edc4..a4782b9 100644 --- a/platform/tests/test_sandbox_env_contract.py +++ b/platform/tests/test_sandbox_env_contract.py @@ -24,6 +24,14 @@ def _extract_mounts_from_docker_cmd(tokens: list[str]) -> list[str]: return mounts +def _extract_user_from_docker_cmd(tokens: list[str]) -> str | None: + for idx, token in enumerate(tokens): + if token != "--user" or idx + 1 >= len(tokens): + continue + return tokens[idx + 1] + return None + + def test_build_docker_run_cmd_includes_skillkit_compat_env(monkeypatch, tmp_path): from app.worker import sandbox as sandbox_mod @@ -90,6 +98,42 @@ def test_build_docker_run_cmd_disables_raw_model_dump_when_config_off(monkeypatc assert not any(mount.endswith("dst=/model_api_logs") for mount in mounts) +def test_build_docker_run_cmd_uses_workspace_owner_by_default(monkeypatch): + from app.worker import sandbox as sandbox_mod + + monkeypatch.setattr(sandbox_mod.settings, "SANDBOX_RUN_AS_WORKSPACE_OWNER", True, raising=False) + + backend = DockerSandboxBackend() + cmd = backend._build_docker_run_cmd( + container_name="sbx-test", + image="sandbox-image:latest", + workspace="/tmp/workspace", + task_id="task-123", + workspace_uid=1234, + workspace_gid=2345, + ) + + assert _extract_user_from_docker_cmd(cmd) == "1234:2345" + + +def test_build_docker_run_cmd_skips_workspace_owner_when_disabled(monkeypatch): + from app.worker import sandbox as sandbox_mod + + monkeypatch.setattr(sandbox_mod.settings, "SANDBOX_RUN_AS_WORKSPACE_OWNER", False, raising=False) + + backend = DockerSandboxBackend() + cmd = backend._build_docker_run_cmd( + container_name="sbx-test", + image="sandbox-image:latest", + workspace="/tmp/workspace", + task_id="task-123", + workspace_uid=1234, + workspace_gid=2345, + ) + + assert _extract_user_from_docker_cmd(cmd) is None + + def test_coding_sandbox_image_provides_java_toolchain(): dockerfile_path = Path(__file__).resolve().parents[1] / "sandbox" / "Dockerfile.coding" content = dockerfile_path.read_text(encoding="utf-8")