Skip to content

Commit 194156c

Browse files
crtr0alfozan
authored andcommitted
adding support for Box (#145)
1 parent 2a515f0 commit 194156c

9 files changed

Lines changed: 247 additions & 5 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: 3 additions & 3 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,7 +106,7 @@ 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`. |

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/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

tests/sandbox/test_docker.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE
2525
from agents.sandbox.entries import (
2626
AzureBlobMount,
27+
BoxMount,
2728
Dir,
2829
DockerVolumeMountStrategy,
2930
File,
@@ -1613,6 +1614,66 @@ async def test_docker_create_container_mounts_azure_with_rclone_driver(
16131614
]
16141615

16151616

1617+
@pytest.mark.asyncio
1618+
async def test_docker_create_container_mounts_box_with_rclone_driver(
1619+
monkeypatch: pytest.MonkeyPatch,
1620+
) -> None:
1621+
container = _ResumeContainer(status="created")
1622+
docker_client = _FakeCreateDockerClient(container)
1623+
client = DockerSandboxClient(docker_client=cast(object, docker_client))
1624+
manifest = Manifest(
1625+
entries={
1626+
"data": BoxMount(
1627+
path="/Shared/Finance",
1628+
client_id="client-id",
1629+
client_secret="client-secret",
1630+
access_token="access-token",
1631+
root_folder_id="12345",
1632+
impersonate="user-42",
1633+
mount_strategy=DockerVolumeMountStrategy(driver="rclone"),
1634+
read_only=False,
1635+
)
1636+
}
1637+
)
1638+
1639+
monkeypatch.setattr(client, "image_exists", lambda _image: True)
1640+
1641+
created = await client._create_container(DEFAULT_PYTHON_SANDBOX_IMAGE, manifest=manifest)
1642+
1643+
assert created is container
1644+
assert docker_client.containers.calls == [
1645+
{
1646+
"entrypoint": ["tail"],
1647+
"image": DEFAULT_PYTHON_SANDBOX_IMAGE,
1648+
"detach": True,
1649+
"command": ["-f", "/dev/null"],
1650+
"environment": {},
1651+
"mounts": [
1652+
{
1653+
"Target": "/workspace/data",
1654+
"Source": "sandbox_ac6cdb3eb035_workspace_data",
1655+
"Type": "volume",
1656+
"ReadOnly": False,
1657+
"VolumeOptions": {
1658+
"DriverConfig": {
1659+
"Name": "rclone",
1660+
"Options": {
1661+
"type": "box",
1662+
"path": "Shared/Finance",
1663+
"box-client-id": "client-id",
1664+
"box-client-secret": "client-secret",
1665+
"box-access-token": "access-token",
1666+
"box-root-folder-id": "12345",
1667+
"box-impersonate": "user-42",
1668+
},
1669+
}
1670+
},
1671+
}
1672+
],
1673+
}
1674+
]
1675+
1676+
16161677
@pytest.mark.asyncio
16171678
async def test_docker_delete_removes_generated_docker_volumes() -> None:
16181679
session_id = uuid.UUID("12345678-1234-5678-1234-567812345678")

tests/sandbox/test_mounts.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from agents.sandbox import Manifest
1010
from agents.sandbox.entries import (
1111
AzureBlobMount,
12+
BoxMount,
1213
DockerVolumeMountStrategy,
1314
FuseMountPattern,
1415
GCSMount,
@@ -289,6 +290,54 @@ async def test_azure_blob_mount_builds_rclone_runtime_config_without_hidden_patt
289290
assert unmount_config.config_text is None
290291

291292

293+
@pytest.mark.asyncio
294+
async def test_box_mount_builds_rclone_runtime_config_with_box_auth_options() -> None:
295+
session_id = uuid.uuid4()
296+
pattern = RcloneMountPattern(config_file_path=Path("rclone.conf"))
297+
remote_name = pattern.resolve_remote_name(
298+
session_id=session_id.hex,
299+
remote_kind="box",
300+
mount_type="box_mount",
301+
)
302+
session = _MountConfigSession(
303+
session_id=session_id,
304+
config_text=f"[{remote_name}]\ntype = box\n",
305+
)
306+
mount = BoxMount(
307+
path="/Shared/Finance",
308+
client_id="client-id",
309+
client_secret="client-secret",
310+
token='{"access_token":"token"}',
311+
root_folder_id="12345",
312+
impersonate="user-42",
313+
mount_strategy=InContainerMountStrategy(pattern=pattern),
314+
read_only=False,
315+
)
316+
317+
apply_config = await mount.build_in_container_mount_config(
318+
session, pattern, include_config_text=True
319+
)
320+
unmount_config = await mount.build_in_container_mount_config(
321+
session, pattern, include_config_text=False
322+
)
323+
324+
assert isinstance(apply_config, RcloneMountConfig)
325+
assert apply_config.remote_name == remote_name
326+
assert apply_config.remote_path == "Shared/Finance"
327+
assert apply_config.read_only is False
328+
assert apply_config.config_text is not None
329+
assert "type = box" in apply_config.config_text
330+
assert "client_id = client-id" in apply_config.config_text
331+
assert "client_secret = client-secret" in apply_config.config_text
332+
assert 'token = {"access_token":"token"}' in apply_config.config_text
333+
assert "root_folder_id = 12345" in apply_config.config_text
334+
assert "impersonate = user-42" in apply_config.config_text
335+
assert isinstance(unmount_config, RcloneMountConfig)
336+
assert unmount_config.remote_name == remote_name
337+
assert unmount_config.remote_path == "Shared/Finance"
338+
assert unmount_config.config_text is None
339+
340+
292341
@pytest.mark.asyncio
293342
async def test_gcs_mount_uses_runtime_endpoint_override_without_mutating_pattern_options() -> None:
294343
pattern = MountpointMountPattern()

0 commit comments

Comments
 (0)