diff --git a/docs/ref/sandbox/entries.md b/docs/ref/sandbox/entries.md index 47f59d9fc0..f8ddb0a11f 100644 --- a/docs/ref/sandbox/entries.md +++ b/docs/ref/sandbox/entries.md @@ -14,3 +14,4 @@ - R2Mount - S3Mount - S3FilesMount + - BoxMount diff --git a/docs/sandbox/clients.md b/docs/sandbox/clients.md index 683e8bc47b..bd21da63d3 100644 --- a/docs/sandbox/clients.md +++ b/docs/sandbox/clients.md @@ -70,11 +70,11 @@ Generic local/container strategies: | Strategy or pattern | Use it when | Notes | | --- | --- | --- | -| `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | The sandbox image can run `rclone`. | Supports S3, GCS, R2, and Azure Blob. `RcloneMountPattern` can run in `fuse` mode or `nfs` mode. | +| `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | The sandbox image can run `rclone`. | Supports S3, GCS, R2, Azure Blob, and Box. `RcloneMountPattern` can run in `fuse` mode or `nfs` mode. | | `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | The image has `mount-s3` and you want Mountpoint-style S3 or S3-compatible access. | Supports `S3Mount` and `GCSMount`. | | `InContainerMountStrategy(pattern=FuseMountPattern(...))` | The image has `blobfuse2` and FUSE support. | Supports `AzureBlobMount`. | | `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | The image has `mount.s3files` and can reach an existing S3 Files mount target. | Supports `S3FilesMount`. | -| `DockerVolumeMountStrategy(driver=...)` | Docker should attach a volume-driver-backed mount before the container starts. | Docker-only. S3, GCS, R2, and Azure Blob support `rclone`; S3 and GCS also support `mountpoint`. | +| `DockerVolumeMountStrategy(driver=...)` | Docker should attach a volume-driver-backed mount before the container starts. | Docker-only. S3, GCS, R2, Azure Blob, and Box support `rclone`; S3 and GCS also support `mountpoint`. | @@ -106,13 +106,13 @@ Hosted sandbox clients expose provider-specific mount strategies. Choose the bac | Backend | Mount notes | | --- | --- | -| Docker | Supports `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `S3FilesMount` with local strategies such as `InContainerMountStrategy` and `DockerVolumeMountStrategy`. | +| Docker | Supports `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`, and `S3FilesMount` with local strategies such as `InContainerMountStrategy` and `DockerVolumeMountStrategy`. | | `ModalSandboxClient` | Supports Modal cloud bucket mounts with `ModalCloudBucketMountStrategy` on `S3Mount`, `R2Mount`, and HMAC-authenticated `GCSMount`. You can use inline credentials or a named Modal Secret. | | `CloudflareSandboxClient` | Supports Cloudflare bucket mounts with `CloudflareBucketMountStrategy` on `S3Mount`, `R2Mount`, and HMAC-authenticated `GCSMount`. | | `BlaxelSandboxClient` | Supports cloud bucket mounts with `BlaxelCloudBucketMountStrategy` on `S3Mount`, `R2Mount`, and `GCSMount`. Also supports persistent Blaxel Drives with `BlaxelDriveMount` and `BlaxelDriveMountStrategy` from `agents.extensions.sandbox.blaxel`. | -| `DaytonaSandboxClient` | Supports cloud bucket mounts with `DaytonaCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, and `AzureBlobMount`. | -| `E2BSandboxClient` | Supports cloud bucket mounts with `E2BCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, and `AzureBlobMount`. | -| `RunloopSandboxClient` | Supports cloud bucket mounts with `RunloopCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, and `AzureBlobMount`. | +| `DaytonaSandboxClient` | Supports rclone-backed cloud storage mounts with `DaytonaCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. | +| `E2BSandboxClient` | Supports rclone-backed cloud storage mounts with `E2BCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. | +| `RunloopSandboxClient` | Supports rclone-backed cloud storage mounts with `RunloopCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. | | `VercelSandboxClient` | No hosted-specific mount strategy is currently exposed. Use manifest files, repos, or other workspace inputs instead. | @@ -121,16 +121,16 @@ The table below summarizes which remote storage entries each backend can mount d
-| Backend | AWS S3 | Cloudflare R2 | GCS | Azure Blob Storage | S3 Files | -| --- | --- | --- | --- | --- | --- | -| Docker | ✓ | ✓ | ✓ | ✓ | ✓ | -| `ModalSandboxClient` | ✓ | ✓ | ✓ | - | - | -| `CloudflareSandboxClient` | ✓ | ✓ | ✓ | - | - | -| `BlaxelSandboxClient` | ✓ | ✓ | ✓ | - | - | -| `DaytonaSandboxClient` | ✓ | ✓ | ✓ | ✓ | - | -| `E2BSandboxClient` | ✓ | ✓ | ✓ | ✓ | - | -| `RunloopSandboxClient` | ✓ | ✓ | ✓ | ✓ | - | -| `VercelSandboxClient` | - | - | - | - | - | +| Backend | AWS S3 | Cloudflare R2 | GCS | Azure Blob Storage | Box | S3 Files | +| --- | --- | --- | --- | --- | --- | --- | +| Docker | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| `ModalSandboxClient` | ✓ | ✓ | ✓ | - | - | - | +| `CloudflareSandboxClient` | ✓ | ✓ | ✓ | - | - | - | +| `BlaxelSandboxClient` | ✓ | ✓ | ✓ | - | - | - | +| `DaytonaSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | +| `E2BSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | +| `RunloopSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | +| `VercelSandboxClient` | - | - | - | - | - | - |
diff --git a/docs/sandbox/guide.md b/docs/sandbox/guide.md index fb444e0e3d..7e08c6a103 100644 --- a/docs/sandbox/guide.md +++ b/docs/sandbox/guide.md @@ -229,7 +229,7 @@ Use manifest entries for the material the agent needs before work begins: | `File`, `Dir` | Small synthetic inputs, helper files, or output directories. | | `LocalFile`, `LocalDir` | Host files or directories that should be materialized into the sandbox. | | `GitRepo` | A repository that should be fetched into the workspace. | -| mounts such as `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `S3FilesMount` | External storage that should appear inside the sandbox. | +| mounts such as `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`, `S3FilesMount` | External storage that should appear inside the sandbox. | diff --git a/src/agents/extensions/sandbox/daytona/mounts.py b/src/agents/extensions/sandbox/daytona/mounts.py index 2d8fc25926..038473e70e 100644 --- a/src/agents/extensions/sandbox/daytona/mounts.py +++ b/src/agents/extensions/sandbox/daytona/mounts.py @@ -4,7 +4,7 @@ :class:`InContainerMountStrategy` that ensures ``rclone`` is installed inside the sandbox before delegating to :class:`RcloneMountPattern`. -Supports S3, R2, GCS, and Azure Blob mounts through a single code path. +Supports S3, R2, GCS, Azure Blob, and Box mounts through a single code path. """ from __future__ import annotations @@ -161,12 +161,12 @@ def _assert_daytona_session(session: BaseSandboxSession) -> None: class DaytonaCloudBucketMountStrategy(MountStrategyBase): - """Mount cloud buckets in Daytona sandboxes via rclone. + """Mount rclone-backed cloud storage in Daytona sandboxes. Wraps :class:`InContainerMountStrategy` with automatic ``rclone`` - provisioning. Use with any provider mount (``S3Mount``, ``R2Mount``, - ``GCSMount``, ``AzureBlobMount``) and let the generic framework handle - config generation and mount execution. + provisioning. Use with any rclone-backed provider mount (``S3Mount``, + ``R2Mount``, ``GCSMount``, ``AzureBlobMount``, ``BoxMount``) and let the + generic framework handle config generation and mount execution. Usage:: diff --git a/src/agents/extensions/sandbox/e2b/mounts.py b/src/agents/extensions/sandbox/e2b/mounts.py index 5b552028af..3e37eda803 100644 --- a/src/agents/extensions/sandbox/e2b/mounts.py +++ b/src/agents/extensions/sandbox/e2b/mounts.py @@ -126,7 +126,7 @@ def _assert_e2b_session(session: BaseSandboxSession) -> None: class E2BCloudBucketMountStrategy(MountStrategyBase): - """Mount cloud buckets in E2B sandboxes via rclone.""" + """Mount rclone-backed cloud storage in E2B sandboxes.""" type: Literal["e2b_cloud_bucket"] = "e2b_cloud_bucket" pattern: RcloneMountPattern = RcloneMountPattern(mode="fuse") diff --git a/src/agents/extensions/sandbox/runloop/mounts.py b/src/agents/extensions/sandbox/runloop/mounts.py index d55fa04856..4c1daec892 100644 --- a/src/agents/extensions/sandbox/runloop/mounts.py +++ b/src/agents/extensions/sandbox/runloop/mounts.py @@ -171,7 +171,7 @@ def _assert_runloop_session(session: BaseSandboxSession) -> None: class RunloopCloudBucketMountStrategy(MountStrategyBase): - """Mount cloud buckets in Runloop sandboxes via rclone.""" + """Mount rclone-backed cloud storage in Runloop sandboxes.""" type: Literal["runloop_cloud_bucket"] = "runloop_cloud_bucket" pattern: RcloneMountPattern = RcloneMountPattern(mode="fuse") diff --git a/src/agents/sandbox/entries/__init__.py b/src/agents/sandbox/entries/__init__.py index 23b05431cc..a08f6b796d 100644 --- a/src/agents/sandbox/entries/__init__.py +++ b/src/agents/sandbox/entries/__init__.py @@ -4,6 +4,7 @@ from .base import BaseEntry, resolve_workspace_path from .mounts import ( AzureBlobMount, + BoxMount, DockerVolumeMountStrategy, FuseMountPattern, GCSMount, @@ -24,6 +25,7 @@ __all__ = [ "AzureBlobMount", "BaseEntry", + "BoxMount", "Dir", "File", "DockerVolumeMountStrategy", diff --git a/src/agents/sandbox/entries/mounts/__init__.py b/src/agents/sandbox/entries/mounts/__init__.py index 61e2dc868a..4c9c5e2a66 100644 --- a/src/agents/sandbox/entries/mounts/__init__.py +++ b/src/agents/sandbox/entries/mounts/__init__.py @@ -15,10 +15,11 @@ RcloneMountPattern, S3FilesMountPattern, ) -from .providers import AzureBlobMount, GCSMount, R2Mount, S3FilesMount, S3Mount +from .providers import AzureBlobMount, BoxMount, GCSMount, R2Mount, S3FilesMount, S3Mount __all__ = [ "AzureBlobMount", + "BoxMount", "FuseMountPattern", "GCSMount", "DockerVolumeMountStrategy", diff --git a/src/agents/sandbox/entries/mounts/providers/__init__.py b/src/agents/sandbox/entries/mounts/providers/__init__.py index b5155d3c47..22f46a5623 100644 --- a/src/agents/sandbox/entries/mounts/providers/__init__.py +++ b/src/agents/sandbox/entries/mounts/providers/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations from .azure_blob import AzureBlobMount +from .box import BoxMount from .gcs import GCSMount from .r2 import R2Mount from .s3 import S3Mount @@ -12,4 +13,5 @@ "R2Mount", "S3Mount", "S3FilesMount", + "BoxMount", ] diff --git a/src/agents/sandbox/entries/mounts/providers/box.py b/src/agents/sandbox/entries/mounts/providers/box.py new file mode 100644 index 0000000000..444129159e --- /dev/null +++ b/src/agents/sandbox/entries/mounts/providers/box.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import builtins +from typing import TYPE_CHECKING, Literal + +from ....errors import MountConfigError +from ..base import DockerVolumeMountStrategy +from ..patterns import MountPattern, MountPatternConfig, RcloneMountPattern +from .base import _ConfiguredMount + +if TYPE_CHECKING: + from ....session.base_sandbox_session import BaseSandboxSession + + +class BoxMount(_ConfiguredMount): + """Mount a Box folder using rclone. + + See Box's JWT setup guide (https://developer.box.com/guides/authentication/jwt/jwt-setup/) + and rclone's Box guide (https://rclone.org/box/). Non-interactive mounts require + a minted `token` or `access_token`. + """ + + type: Literal["box_mount"] = "box_mount" + path: str | None = None + client_id: str | None = None + client_secret: str | None = None + access_token: str | None = None + token: str | None = None + box_config_file: str | None = None + config_credentials: str | None = None + box_sub_type: Literal["user", "enterprise"] = "user" + root_folder_id: str | None = None + impersonate: str | None = None + owned_by: str | None = None + + def supported_in_container_patterns(self) -> tuple[builtins.type[MountPattern], ...]: + return (RcloneMountPattern,) + + def supported_docker_volume_drivers(self) -> frozenset[str]: + return frozenset({"rclone"}) + + def build_docker_volume_driver_config( + self, + strategy: DockerVolumeMountStrategy, + ) -> tuple[str, dict[str, str], bool]: + options: dict[str, str] = {"type": "box", "path": self._remote_path()} + if self.client_id is not None: + options["box-client-id"] = self.client_id + if self.client_secret is not None: + options["box-client-secret"] = self.client_secret + if self.access_token is not None: + options["box-access-token"] = self.access_token + if self.token is not None: + options["box-token"] = self.token + if self.box_config_file is not None: + options["box-box-config-file"] = self.box_config_file + if self.config_credentials is not None: + options["box-config-credentials"] = self.config_credentials + if self.box_sub_type != "user": + options["box-box-sub-type"] = self.box_sub_type + if self.root_folder_id is not None: + options["box-root-folder-id"] = self.root_folder_id + if self.impersonate is not None: + options["box-impersonate"] = self.impersonate + if self.owned_by is not None: + options["box-owned-by"] = self.owned_by + return strategy.driver, options | strategy.driver_options, self.read_only + + async def build_in_container_mount_config( + self, + session: BaseSandboxSession, + pattern: MountPattern, + *, + include_config_text: bool, + ) -> MountPatternConfig: + if isinstance(pattern, RcloneMountPattern): + return await self._build_rclone_config( + session=session, + pattern=pattern, + remote_kind="box", + remote_path=self._remote_path(), + required_lines=self._rclone_required_lines( + pattern.resolve_remote_name( + session_id=self._require_session_id_hex(session, self.type), + remote_kind="box", + mount_type=self.type, + ) + ), + include_config_text=include_config_text, + ) + raise MountConfigError( + message="invalid mount_pattern type", + context={"type": self.type}, + ) + + def _remote_path(self) -> str: + if self.path is None: + return "" + return self.path.lstrip("/") + + def _rclone_required_lines(self, remote_name: str) -> list[str]: + lines = [ + f"[{remote_name}]", + "type = box", + ] + if self.client_id is not None: + lines.append(f"client_id = {self.client_id}") + if self.client_secret is not None: + lines.append(f"client_secret = {self.client_secret}") + if self.access_token is not None: + lines.append(f"access_token = {self.access_token}") + if self.token is not None: + lines.append(f"token = {self.token}") + if self.box_config_file is not None: + lines.append(f"box_config_file = {self.box_config_file}") + if self.config_credentials is not None: + lines.append(f"config_credentials = {self.config_credentials}") + if self.box_sub_type != "user": + lines.append(f"box_sub_type = {self.box_sub_type}") + if self.root_folder_id is not None: + lines.append(f"root_folder_id = {self.root_folder_id}") + if self.impersonate is not None: + lines.append(f"impersonate = {self.impersonate}") + if self.owned_by is not None: + lines.append(f"owned_by = {self.owned_by}") + return lines diff --git a/tests/sandbox/test_compatibility_guards.py b/tests/sandbox/test_compatibility_guards.py index e3ecba3349..ccf71f0441 100644 --- a/tests/sandbox/test_compatibility_guards.py +++ b/tests/sandbox/test_compatibility_guards.py @@ -127,6 +127,7 @@ def test_core_sandbox_public_export_surface_is_stable() -> None: "agents.sandbox.entries": { "AzureBlobMount", "BaseEntry", + "BoxMount", "Dir", "File", "DockerVolumeMountStrategy", diff --git a/tests/sandbox/test_docker.py b/tests/sandbox/test_docker.py index d57ce2e00a..52701274eb 100644 --- a/tests/sandbox/test_docker.py +++ b/tests/sandbox/test_docker.py @@ -24,6 +24,7 @@ from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE from agents.sandbox.entries import ( AzureBlobMount, + BoxMount, Dir, DockerVolumeMountStrategy, File, @@ -1613,6 +1614,66 @@ async def test_docker_create_container_mounts_azure_with_rclone_driver( ] +@pytest.mark.asyncio +async def test_docker_create_container_mounts_box_with_rclone_driver( + monkeypatch: pytest.MonkeyPatch, +) -> None: + container = _ResumeContainer(status="created") + docker_client = _FakeCreateDockerClient(container) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + manifest = Manifest( + entries={ + "data": BoxMount( + path="/Shared/Finance", + client_id="client-id", + client_secret="client-secret", + access_token="access-token", + root_folder_id="12345", + impersonate="user-42", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + read_only=False, + ) + } + ) + + monkeypatch.setattr(client, "image_exists", lambda _image: True) + + created = await client._create_container(DEFAULT_PYTHON_SANDBOX_IMAGE, manifest=manifest) + + assert created is container + assert docker_client.containers.calls == [ + { + "entrypoint": ["tail"], + "image": DEFAULT_PYTHON_SANDBOX_IMAGE, + "detach": True, + "command": ["-f", "/dev/null"], + "environment": {}, + "mounts": [ + { + "Target": "/workspace/data", + "Source": "sandbox_ac6cdb3eb035_workspace_data", + "Type": "volume", + "ReadOnly": False, + "VolumeOptions": { + "DriverConfig": { + "Name": "rclone", + "Options": { + "type": "box", + "path": "Shared/Finance", + "box-client-id": "client-id", + "box-client-secret": "client-secret", + "box-access-token": "access-token", + "box-root-folder-id": "12345", + "box-impersonate": "user-42", + }, + } + }, + } + ], + } + ] + + @pytest.mark.asyncio async def test_docker_delete_removes_generated_docker_volumes() -> None: session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") diff --git a/tests/sandbox/test_mounts.py b/tests/sandbox/test_mounts.py index 255ccff788..da1ddbe46a 100644 --- a/tests/sandbox/test_mounts.py +++ b/tests/sandbox/test_mounts.py @@ -9,6 +9,7 @@ from agents.sandbox import Manifest from agents.sandbox.entries import ( AzureBlobMount, + BoxMount, DockerVolumeMountStrategy, FuseMountPattern, GCSMount, @@ -289,6 +290,54 @@ async def test_azure_blob_mount_builds_rclone_runtime_config_without_hidden_patt assert unmount_config.config_text is None +@pytest.mark.asyncio +async def test_box_mount_builds_rclone_runtime_config_with_box_auth_options() -> None: + session_id = uuid.uuid4() + pattern = RcloneMountPattern(config_file_path=Path("rclone.conf")) + remote_name = pattern.resolve_remote_name( + session_id=session_id.hex, + remote_kind="box", + mount_type="box_mount", + ) + session = _MountConfigSession( + session_id=session_id, + config_text=f"[{remote_name}]\ntype = box\n", + ) + mount = BoxMount( + path="/Shared/Finance", + client_id="client-id", + client_secret="client-secret", + token='{"access_token":"token"}', + root_folder_id="12345", + impersonate="user-42", + mount_strategy=InContainerMountStrategy(pattern=pattern), + read_only=False, + ) + + apply_config = await mount.build_in_container_mount_config( + session, pattern, include_config_text=True + ) + unmount_config = await mount.build_in_container_mount_config( + session, pattern, include_config_text=False + ) + + assert isinstance(apply_config, RcloneMountConfig) + assert apply_config.remote_name == remote_name + assert apply_config.remote_path == "Shared/Finance" + assert apply_config.read_only is False + assert apply_config.config_text is not None + assert "type = box" in apply_config.config_text + assert "client_id = client-id" in apply_config.config_text + assert "client_secret = client-secret" in apply_config.config_text + assert 'token = {"access_token":"token"}' in apply_config.config_text + assert "root_folder_id = 12345" in apply_config.config_text + assert "impersonate = user-42" in apply_config.config_text + assert isinstance(unmount_config, RcloneMountConfig) + assert unmount_config.remote_name == remote_name + assert unmount_config.remote_path == "Shared/Finance" + assert unmount_config.config_text is None + + @pytest.mark.asyncio async def test_gcs_mount_uses_runtime_endpoint_override_without_mutating_pattern_options() -> None: pattern = MountpointMountPattern()