Skip to content

Commit 4c5112c

Browse files
alfozancrtr0
andauthored
feat: add BoxMount support (#2988)
### Summary This pull request adds Box as an rclone-backed sandbox mount provider. - Adds `BoxMount` with Docker rclone volume-driver support and in-container `RcloneMountPattern` config generation. - Wires Box into the sandbox entry exports and provider docs. - Updates rclone-backed sandbox extension wording for Daytona, E2B, and Runloop. - Adds Docker and rclone mount config tests for Box auth/path options. ### Test plan - `bash .agents/skills/code-change-verification/scripts/run.sh` *(format and lint passed; typecheck failed on pre-existing local ignored `tests/local` symlink files and unrelated Temporal example dependency typing)* - `uv run pyright --project pyrightconfig.json src/agents/sandbox src/agents/extensions/sandbox tests/sandbox/test_mounts.py tests/sandbox/test_docker.py` - `uv run mypy src/agents/sandbox src/agents/extensions/sandbox tests/sandbox/test_mounts.py tests/sandbox/test_docker.py` - `uv run pytest -q tests/sandbox/test_mounts.py tests/sandbox/test_docker.py` ### Issue number N/A ### Checks - [x] I've added new tests (if relevant) - [x] I've added/updated the relevant documentation - [x] I've run `make lint` and `make format` - [x] I've made sure tests pass Co-authored-by: Carter Rabasa <carter.rabasa@gmail.com>
1 parent 106ef05 commit 4c5112c

13 files changed

Lines changed: 268 additions & 25 deletions

File tree

docs/ref/sandbox/entries.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@
1414
- R2Mount
1515
- S3Mount
1616
- S3FilesMount
17+
- BoxMount

docs/sandbox/clients.md

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,11 @@ Generic local/container strategies:
7070

7171
| Strategy or pattern | Use it when | Notes |
7272
| --- | --- | --- |
73-
| `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. |
73+
| `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. |
7474
| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | The image has `mount-s3` and you want Mountpoint-style S3 or S3-compatible access. | Supports `S3Mount` and `GCSMount`. |
7575
| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | The image has `blobfuse2` and FUSE support. | Supports `AzureBlobMount`. |
7676
| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | The image has `mount.s3files` and can reach an existing S3 Files mount target. | Supports `S3FilesMount`. |
77-
| `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`. |
77+
| `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`. |
7878

7979
</div>
8080

@@ -106,13 +106,13 @@ Hosted sandbox clients expose provider-specific mount strategies. Choose the bac
106106

107107
| Backend | Mount notes |
108108
| --- | --- |
109-
| Docker | Supports `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `S3FilesMount` with local strategies such as `InContainerMountStrategy` and `DockerVolumeMountStrategy`. |
109+
| Docker | Supports `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`, and `S3FilesMount` with local strategies such as `InContainerMountStrategy` and `DockerVolumeMountStrategy`. |
110110
| `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. |
111111
| `CloudflareSandboxClient` | Supports Cloudflare bucket mounts with `CloudflareBucketMountStrategy` on `S3Mount`, `R2Mount`, and HMAC-authenticated `GCSMount`. |
112112
| `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`. |
113-
| `DaytonaSandboxClient` | Supports cloud bucket mounts with `DaytonaCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, and `AzureBlobMount`. |
114-
| `E2BSandboxClient` | Supports cloud bucket mounts with `E2BCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, and `AzureBlobMount`. |
115-
| `RunloopSandboxClient` | Supports cloud bucket mounts with `RunloopCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, and `AzureBlobMount`. |
113+
| `DaytonaSandboxClient` | Supports rclone-backed cloud storage mounts with `DaytonaCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. |
114+
| `E2BSandboxClient` | Supports rclone-backed cloud storage mounts with `E2BCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. |
115+
| `RunloopSandboxClient` | Supports rclone-backed cloud storage mounts with `RunloopCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. |
116116
| `VercelSandboxClient` | No hosted-specific mount strategy is currently exposed. Use manifest files, repos, or other workspace inputs instead. |
117117

118118
</div>
@@ -121,16 +121,16 @@ The table below summarizes which remote storage entries each backend can mount d
121121

122122
<div class="sandbox-nowrap-first-column-table" markdown="1">
123123

124-
| Backend | AWS S3 | Cloudflare R2 | GCS | Azure Blob Storage | S3 Files |
125-
| --- | --- | --- | --- | --- | --- |
126-
| Docker ||||||
127-
| `ModalSandboxClient` |||| - | - |
128-
| `CloudflareSandboxClient` |||| - | - |
129-
| `BlaxelSandboxClient` |||| - | - |
130-
| `DaytonaSandboxClient` ||||| - |
131-
| `E2BSandboxClient` ||||| - |
132-
| `RunloopSandboxClient` ||||| - |
133-
| `VercelSandboxClient` | - | - | - | - | - |
124+
| Backend | AWS S3 | Cloudflare R2 | GCS | Azure Blob Storage | Box | S3 Files |
125+
| --- | --- | --- | --- | --- | --- | --- |
126+
| Docker |||||||
127+
| `ModalSandboxClient` |||| - | - | - |
128+
| `CloudflareSandboxClient` |||| - | - | - |
129+
| `BlaxelSandboxClient` |||| - | - | - |
130+
| `DaytonaSandboxClient` ||||| | - |
131+
| `E2BSandboxClient` ||||| | - |
132+
| `RunloopSandboxClient` ||||| | - |
133+
| `VercelSandboxClient` | - | - | - | - | - | - |
134134

135135
</div>
136136

docs/sandbox/guide.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ Use manifest entries for the material the agent needs before work begins:
229229
| `File`, `Dir` | Small synthetic inputs, helper files, or output directories. |
230230
| `LocalFile`, `LocalDir` | Host files or directories that should be materialized into the sandbox. |
231231
| `GitRepo` | A repository that should be fetched into the workspace. |
232-
| mounts such as `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `S3FilesMount` | External storage that should appear inside the sandbox. |
232+
| mounts such as `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `BoxMount`, `S3FilesMount` | External storage that should appear inside the sandbox. |
233233

234234
</div>
235235

src/agents/extensions/sandbox/daytona/mounts.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
:class:`InContainerMountStrategy` that ensures ``rclone`` is installed inside
55
the sandbox before delegating to :class:`RcloneMountPattern`.
66
7-
Supports S3, R2, GCS, and Azure Blob mounts through a single code path.
7+
Supports S3, R2, GCS, Azure Blob, and Box mounts through a single code path.
88
"""
99

1010
from __future__ import annotations
@@ -161,12 +161,12 @@ def _assert_daytona_session(session: BaseSandboxSession) -> None:
161161

162162

163163
class DaytonaCloudBucketMountStrategy(MountStrategyBase):
164-
"""Mount cloud buckets in Daytona sandboxes via rclone.
164+
"""Mount rclone-backed cloud storage in Daytona sandboxes.
165165
166166
Wraps :class:`InContainerMountStrategy` with automatic ``rclone``
167-
provisioning. Use with any provider mount (``S3Mount``, ``R2Mount``,
168-
``GCSMount``, ``AzureBlobMount``) and let the generic framework handle
169-
config generation and mount execution.
167+
provisioning. Use with any rclone-backed provider mount (``S3Mount``,
168+
``R2Mount``, ``GCSMount``, ``AzureBlobMount``, ``BoxMount``) and let the
169+
generic framework handle config generation and mount execution.
170170
171171
Usage::
172172

src/agents/extensions/sandbox/e2b/mounts.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ def _assert_e2b_session(session: BaseSandboxSession) -> None:
126126

127127

128128
class E2BCloudBucketMountStrategy(MountStrategyBase):
129-
"""Mount cloud buckets in E2B sandboxes via rclone."""
129+
"""Mount rclone-backed cloud storage in E2B sandboxes."""
130130

131131
type: Literal["e2b_cloud_bucket"] = "e2b_cloud_bucket"
132132
pattern: RcloneMountPattern = RcloneMountPattern(mode="fuse")

src/agents/extensions/sandbox/runloop/mounts.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ def _assert_runloop_session(session: BaseSandboxSession) -> None:
171171

172172

173173
class RunloopCloudBucketMountStrategy(MountStrategyBase):
174-
"""Mount cloud buckets in Runloop sandboxes via rclone."""
174+
"""Mount rclone-backed cloud storage in Runloop sandboxes."""
175175

176176
type: Literal["runloop_cloud_bucket"] = "runloop_cloud_bucket"
177177
pattern: RcloneMountPattern = RcloneMountPattern(mode="fuse")

src/agents/sandbox/entries/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .base import BaseEntry, resolve_workspace_path
55
from .mounts import (
66
AzureBlobMount,
7+
BoxMount,
78
DockerVolumeMountStrategy,
89
FuseMountPattern,
910
GCSMount,
@@ -24,6 +25,7 @@
2425
__all__ = [
2526
"AzureBlobMount",
2627
"BaseEntry",
28+
"BoxMount",
2729
"Dir",
2830
"File",
2931
"DockerVolumeMountStrategy",

src/agents/sandbox/entries/mounts/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@
1515
RcloneMountPattern,
1616
S3FilesMountPattern,
1717
)
18-
from .providers import AzureBlobMount, GCSMount, R2Mount, S3FilesMount, S3Mount
18+
from .providers import AzureBlobMount, BoxMount, GCSMount, R2Mount, S3FilesMount, S3Mount
1919

2020
__all__ = [
2121
"AzureBlobMount",
22+
"BoxMount",
2223
"FuseMountPattern",
2324
"GCSMount",
2425
"DockerVolumeMountStrategy",

src/agents/sandbox/entries/mounts/providers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from .azure_blob import AzureBlobMount
4+
from .box import BoxMount
45
from .gcs import GCSMount
56
from .r2 import R2Mount
67
from .s3 import S3Mount
@@ -12,4 +13,5 @@
1213
"R2Mount",
1314
"S3Mount",
1415
"S3FilesMount",
16+
"BoxMount",
1517
]
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
from __future__ import annotations
2+
3+
import builtins
4+
from typing import TYPE_CHECKING, Literal
5+
6+
from ....errors import MountConfigError
7+
from ..base import DockerVolumeMountStrategy
8+
from ..patterns import MountPattern, MountPatternConfig, RcloneMountPattern
9+
from .base import _ConfiguredMount
10+
11+
if TYPE_CHECKING:
12+
from ....session.base_sandbox_session import BaseSandboxSession
13+
14+
15+
class BoxMount(_ConfiguredMount):
16+
"""Mount a Box folder using rclone.
17+
18+
See Box's JWT setup guide (https://developer.box.com/guides/authentication/jwt/jwt-setup/)
19+
and rclone's Box guide (https://rclone.org/box/). Non-interactive mounts require
20+
a minted `token` or `access_token`.
21+
"""
22+
23+
type: Literal["box_mount"] = "box_mount"
24+
path: str | None = None
25+
client_id: str | None = None
26+
client_secret: str | None = None
27+
access_token: str | None = None
28+
token: str | None = None
29+
box_config_file: str | None = None
30+
config_credentials: str | None = None
31+
box_sub_type: Literal["user", "enterprise"] = "user"
32+
root_folder_id: str | None = None
33+
impersonate: str | None = None
34+
owned_by: str | None = None
35+
36+
def supported_in_container_patterns(self) -> tuple[builtins.type[MountPattern], ...]:
37+
return (RcloneMountPattern,)
38+
39+
def supported_docker_volume_drivers(self) -> frozenset[str]:
40+
return frozenset({"rclone"})
41+
42+
def build_docker_volume_driver_config(
43+
self,
44+
strategy: DockerVolumeMountStrategy,
45+
) -> tuple[str, dict[str, str], bool]:
46+
options: dict[str, str] = {"type": "box", "path": self._remote_path()}
47+
if self.client_id is not None:
48+
options["box-client-id"] = self.client_id
49+
if self.client_secret is not None:
50+
options["box-client-secret"] = self.client_secret
51+
if self.access_token is not None:
52+
options["box-access-token"] = self.access_token
53+
if self.token is not None:
54+
options["box-token"] = self.token
55+
if self.box_config_file is not None:
56+
options["box-box-config-file"] = self.box_config_file
57+
if self.config_credentials is not None:
58+
options["box-config-credentials"] = self.config_credentials
59+
if self.box_sub_type != "user":
60+
options["box-box-sub-type"] = self.box_sub_type
61+
if self.root_folder_id is not None:
62+
options["box-root-folder-id"] = self.root_folder_id
63+
if self.impersonate is not None:
64+
options["box-impersonate"] = self.impersonate
65+
if self.owned_by is not None:
66+
options["box-owned-by"] = self.owned_by
67+
return strategy.driver, options | strategy.driver_options, self.read_only
68+
69+
async def build_in_container_mount_config(
70+
self,
71+
session: BaseSandboxSession,
72+
pattern: MountPattern,
73+
*,
74+
include_config_text: bool,
75+
) -> MountPatternConfig:
76+
if isinstance(pattern, RcloneMountPattern):
77+
return await self._build_rclone_config(
78+
session=session,
79+
pattern=pattern,
80+
remote_kind="box",
81+
remote_path=self._remote_path(),
82+
required_lines=self._rclone_required_lines(
83+
pattern.resolve_remote_name(
84+
session_id=self._require_session_id_hex(session, self.type),
85+
remote_kind="box",
86+
mount_type=self.type,
87+
)
88+
),
89+
include_config_text=include_config_text,
90+
)
91+
raise MountConfigError(
92+
message="invalid mount_pattern type",
93+
context={"type": self.type},
94+
)
95+
96+
def _remote_path(self) -> str:
97+
if self.path is None:
98+
return ""
99+
return self.path.lstrip("/")
100+
101+
def _rclone_required_lines(self, remote_name: str) -> list[str]:
102+
lines = [
103+
f"[{remote_name}]",
104+
"type = box",
105+
]
106+
if self.client_id is not None:
107+
lines.append(f"client_id = {self.client_id}")
108+
if self.client_secret is not None:
109+
lines.append(f"client_secret = {self.client_secret}")
110+
if self.access_token is not None:
111+
lines.append(f"access_token = {self.access_token}")
112+
if self.token is not None:
113+
lines.append(f"token = {self.token}")
114+
if self.box_config_file is not None:
115+
lines.append(f"box_config_file = {self.box_config_file}")
116+
if self.config_credentials is not None:
117+
lines.append(f"config_credentials = {self.config_credentials}")
118+
if self.box_sub_type != "user":
119+
lines.append(f"box_sub_type = {self.box_sub_type}")
120+
if self.root_folder_id is not None:
121+
lines.append(f"root_folder_id = {self.root_folder_id}")
122+
if self.impersonate is not None:
123+
lines.append(f"impersonate = {self.impersonate}")
124+
if self.owned_by is not None:
125+
lines.append(f"owned_by = {self.owned_by}")
126+
return lines

0 commit comments

Comments
 (0)