Skip to content

Commit 0d8fc85

Browse files
committed
feat(sprites): add SpritesCloudBucketMountStrategy for cloud-bucket mounts
Adds an rclone-backed mount strategy for AWS S3 / Cloudflare R2 / Google Cloud Storage / Azure Blob / Box mounts on Sprites sandboxes. Mirrors the E2B / Daytona / Runloop pattern: lazy-installs ``rclone`` and the ``fuse`` package via ``sudo -n apt-get`` if they aren't preinstalled in the sprite image, writes a per-session rclone config, runs ``rclone mount`` in daemon mode, and tears down via ``fusermount -u`` on session/snapshot stop. Sprite VMs run as the unprivileged ``sprite`` user with passwordless sudo; ``SpritesSandboxSession.exec`` rejects ``user=`` kwargs, so the install path prefixes ``sudo -n`` rather than escalating through the framework. Two SDK-side workarounds for current platform behavior: 1. **Stdout sentinels for tool detection.** sprite-env's WS control protocol currently ships ``op.complete`` with no ``exitCode`` field, so ``ExecResult.ok()`` always reports success. The strategy's detection commands wrap the conditional in ``if … then echo __SPRITES_PRESENT__; else echo __SPRITES_MISSING__; fi`` so stdout drives the decision instead of the (currently unreliable) exit code. The platform-side fix is tracked in sprite-env#446; once that lands, ``ExecResult.ok()`` becomes accurate again, and the stdout-sentinel approach remains valid (it's strictly more robust than exit-code checks anyway). 2. **Post-mount dir-cache warmup.** ``rclone mount --daemon`` forks and the parent returns immediately; FUSE's first ``readdir`` on the mountpoint root then races the daemon's first remote listing fetch and can briefly observe an empty directory. ``_verify_mount_active`` uses ``mountpoint -q`` (with a 5s poll) to confirm the bind landed, then issues a throw-away ``ls`` to prime rclone's dir cache before handing control back to the caller. Discriminator: ``"sprites_cloud_bucket"``. Registered through the polymorphic ``MountStrategyBase`` registry. Compat-guard parametrize entries pin the public export and the discriminator string. Twenty unit tests in ``tests/extensions/test_sandbox_sprites.py`` cover the rclone-installed path, the lazy-install path, the silent-no-op recovery path, FUSE-support detection, the per-session pattern adjustment, the session-type guard, the post-mount verification (mounted + not-mounted + warmup-ordering), and JSON roundtrip through the manifest and registry. ``docs/sandbox/clients.md`` storage-entries matrix updated to show ✓ for S3 / R2 / GCS / Azure / Box on Sprites. Verified end-to-end against a Tigris (S3-compatible) read-only bucket.
1 parent 3ac4af8 commit 0d8fc85

7 files changed

Lines changed: 702 additions & 5 deletions

File tree

docs/sandbox/clients.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ Hosted sandbox clients expose provider-specific mount strategies. Choose the bac
114114
| `DaytonaSandboxClient` | Supports rclone-backed cloud storage mounts with `DaytonaCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. |
115115
| `E2BSandboxClient` | Supports rclone-backed cloud storage mounts with `E2BCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. |
116116
| `RunloopSandboxClient` | Supports rclone-backed cloud storage mounts with `RunloopCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. |
117-
| `SpritesSandboxClient` | No hosted-specific mount strategy is currently exposed. Use manifest files, repos, or other workspace inputs instead. Sprites exposes at most one external HTTP port per sprite (declared as a service in the sprite image); other ports must be reverse-proxied inside the VM. |
117+
| `SpritesSandboxClient` | Supports rclone-backed cloud storage mounts with `SpritesCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. The strategy lazy-installs `rclone` and `fuse` via `sudo apt-get` if the sprite image does not preinstall them. Sprites exposes at most one external HTTP port per sprite (declared as a service in the sprite image); other ports must be reverse-proxied inside the VM. |
118118
| `VercelSandboxClient` | No hosted-specific mount strategy is currently exposed. Use manifest files, repos, or other workspace inputs instead. |
119119

120120
</div>
@@ -132,7 +132,7 @@ The table below summarizes which remote storage entries each backend can mount d
132132
| `DaytonaSandboxClient` |||||| - |
133133
| `E2BSandboxClient` |||||| - |
134134
| `RunloopSandboxClient` |||||| - |
135-
| `SpritesSandboxClient` | - | - | - | - | - | - |
135+
| `SpritesSandboxClient` | | | | | | - |
136136
| `VercelSandboxClient` | - | - | - | - | - | - |
137137

138138
</div>

examples/sandbox/extensions/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,12 @@ at most one external HTTP port per sprite — declare it as a service with
368368
`--http-port` in the sprite image, then reference it via
369369
`SpritesSandboxClientOptions(exposed_ports=(<port>,))`.
370370

371+
For cloud-bucket mounts, attach `SpritesCloudBucketMountStrategy` from
372+
`agents.extensions.sandbox.sprites` to any rclone-compatible mount type
373+
(`S3Mount`, `R2Mount`, `GCSMount`, `AzureBlobMount`, `BoxMount`). The strategy
374+
lazy-installs `rclone` and the `fuse` package via `sudo apt-get` on first use
375+
if the sprite image does not preinstall them.
376+
371377
## Blaxel
372378

373379
### Setup

src/agents/extensions/sandbox/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,10 @@
113113
from .sprites import (
114114
DEFAULT_SPRITES_API_URL as DEFAULT_SPRITES_API_URL,
115115
DEFAULT_SPRITES_CONTEXT_PATH as DEFAULT_SPRITES_CONTEXT_PATH,
116-
DEFAULT_SPRITES_WAIT_FOR_RUNNING_TIMEOUT_S as DEFAULT_SPRITES_WAIT_FOR_RUNNING_TIMEOUT_S,
116+
DEFAULT_SPRITES_WAIT_FOR_RUNNING_TIMEOUT_S as DEFAULT_SPRITES_WAIT_FOR_RUNNING_TIMEOUT_S, # noqa: E501
117117
DEFAULT_SPRITES_WORKSPACE_ROOT as DEFAULT_SPRITES_WORKSPACE_ROOT,
118118
SpritesCheckpoints as SpritesCheckpoints,
119+
SpritesCloudBucketMountStrategy as SpritesCloudBucketMountStrategy,
119120
SpritesPlatformContext as SpritesPlatformContext,
120121
SpritesSandboxClient as SpritesSandboxClient,
121122
SpritesSandboxClientOptions as SpritesSandboxClientOptions,
@@ -235,6 +236,7 @@
235236
"DEFAULT_SPRITES_WAIT_FOR_RUNNING_TIMEOUT_S",
236237
"DEFAULT_SPRITES_WORKSPACE_ROOT",
237238
"SpritesCheckpoints",
239+
"SpritesCloudBucketMountStrategy",
238240
"SpritesPlatformContext",
239241
"SpritesSandboxClient",
240242
"SpritesSandboxClientOptions",

src/agents/extensions/sandbox/sprites/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
UrlVisibility,
99
clear_platform_context_cache,
1010
)
11+
from .mounts import SpritesCloudBucketMountStrategy
1112
from .sandbox import (
1213
DEFAULT_SPRITES_API_URL,
1314
DEFAULT_SPRITES_WAIT_FOR_RUNNING_TIMEOUT_S,
@@ -24,6 +25,7 @@
2425
"DEFAULT_SPRITES_WAIT_FOR_RUNNING_TIMEOUT_S",
2526
"DEFAULT_SPRITES_WORKSPACE_ROOT",
2627
"SpritesCheckpoints",
28+
"SpritesCloudBucketMountStrategy",
2729
"SpritesPlatformContext",
2830
"SpritesSandboxClient",
2931
"SpritesSandboxClientOptions",
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
"""Mount strategy for Sprites sandboxes."""
2+
3+
from __future__ import annotations
4+
5+
import shlex
6+
from pathlib import Path
7+
from typing import Literal
8+
9+
from ....sandbox.entries.mounts.base import InContainerMountStrategy, Mount, MountStrategyBase
10+
from ....sandbox.entries.mounts.patterns import RcloneMountPattern
11+
from ....sandbox.errors import MountConfigError
12+
from ....sandbox.materialization import MaterializedFile
13+
from ....sandbox.session.base_sandbox_session import BaseSandboxSession
14+
15+
# Sprite VMs run as the unprivileged ``sprite`` user with passwordless sudo.
16+
# ``SpritesSandboxSession.exec`` rejects ``user=`` kwargs, so we prefix privileged
17+
# commands with ``sudo -n`` instead of escalating through the framework.
18+
_SUDO = "sudo -n"
19+
_APT = (
20+
f"{_SUDO} env DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get -o Dpkg::Use-Pty=0"
21+
)
22+
23+
# Detection commands echo a sentinel into stdout based on the *local* shell's
24+
# evaluation of the conditional. We rely on stdout instead of ``ExecResult.ok()``
25+
# because the sprite-env WS control protocol currently drops exec exit codes
26+
# (the OP_COMPLETE envelope ships ``{"ok": true}`` with no exit-code field, so
27+
# the Python client defaults to 0 for every command). Stdout sentinels are
28+
# also more robust against tools that exit non-zero on benign warnings.
29+
_PRESENT = "__SPRITES_PRESENT__"
30+
_MISSING = "__SPRITES_MISSING__"
31+
_MOUNTED = "__SPRITES_MOUNTED__"
32+
_NOT_MOUNTED = "__SPRITES_NOT_MOUNTED__"
33+
34+
35+
def _detect_cmd(condition: str) -> str:
36+
"""Return a shell snippet that prints _PRESENT or _MISSING based on `condition`."""
37+
38+
return f"if {condition}; then echo {_PRESENT}; else echo {_MISSING}; fi"
39+
40+
41+
_RCLONE_CHECK = _detect_cmd("command -v rclone >/dev/null 2>&1 || test -x /usr/local/bin/rclone")
42+
_FUSERMOUNT_CHECK = _detect_cmd(
43+
"command -v fusermount3 >/dev/null 2>&1 || command -v fusermount >/dev/null 2>&1"
44+
)
45+
_FUSE_KERNEL_CHECK = _detect_cmd("test -c /dev/fuse && grep -qw fuse /proc/filesystems")
46+
_APT_CHECK = _detect_cmd("command -v apt-get >/dev/null 2>&1")
47+
_INSTALL_RCLONE_COMMANDS = (
48+
f"{_APT} update -qq",
49+
f"{_APT} install -y -qq curl unzip ca-certificates fuse",
50+
f"curl -fsSL https://rclone.org/install.sh | {_SUDO} bash",
51+
)
52+
# fuse package brings ``fusermount`` along — install it together with rclone
53+
# so the FUSE-mode mount path works out-of-the-box on stock sprite images.
54+
_INSTALL_FUSE_COMMANDS = (
55+
f"{_APT} update -qq",
56+
f"{_APT} install -y -qq fuse",
57+
)
58+
_FUSE_ALLOW_OTHER = (
59+
f"{_SUDO} chmod a+rw /dev/fuse && "
60+
f"{_SUDO} touch /etc/fuse.conf && "
61+
"(grep -qxF user_allow_other /etc/fuse.conf || "
62+
f"printf '\\nuser_allow_other\\n' | {_SUDO} tee -a /etc/fuse.conf >/dev/null)"
63+
)
64+
65+
66+
def _stdout_says(result: object, sentinel: str) -> bool:
67+
stdout = getattr(result, "stdout", b"") or b""
68+
return sentinel.encode("ascii") in stdout
69+
70+
71+
async def _ensure_fuse_support(session: BaseSandboxSession) -> None:
72+
kernel = await session.exec("sh", "-lc", _FUSE_KERNEL_CHECK, shell=False)
73+
if not _stdout_says(kernel, _PRESENT):
74+
raise MountConfigError(
75+
message="Sprites cloud bucket mounts require FUSE support in the kernel",
76+
context={"missing": "fuse"},
77+
)
78+
79+
fusermount = await session.exec("sh", "-lc", _FUSERMOUNT_CHECK, shell=False)
80+
if not _stdout_says(fusermount, _PRESENT):
81+
apt = await session.exec("sh", "-lc", _APT_CHECK, shell=False)
82+
if not _stdout_says(apt, _PRESENT):
83+
raise MountConfigError(
84+
message="fusermount is not installed and apt-get is unavailable; "
85+
"preinstall the fuse package",
86+
context={"package": "fuse"},
87+
)
88+
for command in _INSTALL_FUSE_COMMANDS:
89+
await session.exec("sh", "-lc", command, shell=False, timeout=300)
90+
recheck = await session.exec("sh", "-lc", _FUSERMOUNT_CHECK, shell=False)
91+
if not _stdout_says(recheck, _PRESENT):
92+
raise MountConfigError(
93+
message="fuse install attempt completed but fusermount is still not on PATH",
94+
context={"package": "fuse"},
95+
)
96+
97+
# /dev/fuse must be accessible to the unprivileged user and ``user_allow_other``
98+
# has to be enabled for ``--allow-other``. Failures here would be surfaced by
99+
# the rclone mount itself; we don't gate on this exec's exit code because the
100+
# control-WS protocol drops it.
101+
await session.exec("sh", "-lc", _FUSE_ALLOW_OTHER, shell=False, timeout=30)
102+
103+
104+
async def _ensure_rclone(session: BaseSandboxSession) -> None:
105+
rclone = await session.exec("sh", "-lc", _RCLONE_CHECK, shell=False)
106+
if _stdout_says(rclone, _PRESENT):
107+
return
108+
109+
apt = await session.exec("sh", "-lc", _APT_CHECK, shell=False)
110+
if not _stdout_says(apt, _PRESENT):
111+
raise MountConfigError(
112+
message="rclone is not installed and apt-get is unavailable; preinstall rclone",
113+
context={"package": "rclone"},
114+
)
115+
116+
for command in _INSTALL_RCLONE_COMMANDS:
117+
await session.exec("sh", "-lc", command, shell=False, timeout=300)
118+
119+
rclone = await session.exec("sh", "-lc", _RCLONE_CHECK, shell=False)
120+
if not _stdout_says(rclone, _PRESENT):
121+
raise MountConfigError(
122+
message="rclone install attempt completed but rclone is still not on PATH",
123+
context={"package": "rclone"},
124+
)
125+
126+
127+
async def _verify_mount_active(session: BaseSandboxSession, mount_path: Path) -> None:
128+
"""Confirm ``mount_path`` is a live mountpoint after activation.
129+
130+
Without reliable exit codes from the platform we can't detect a failed
131+
rclone mount via ``rclone mount``'s return value. Probe the kernel's view
132+
of the path instead: ``mountpoint -q`` returns 0 iff the path is a mount
133+
boundary. The shell wraps the conditional and emits a stdout sentinel so
134+
the verification is transport-independent. ``rclone mount --daemon`` forks
135+
and the parent returns immediately, so we poll briefly to give the daemon
136+
time to bind.
137+
"""
138+
139+
quoted = shlex.quote(str(mount_path))
140+
probe_cmd = (
141+
f"for _ in 1 2 3 4 5 6 7 8 9 10; do "
142+
f"if mountpoint -q {quoted}; then echo {_MOUNTED}; exit 0; fi; "
143+
"sleep 0.5; "
144+
f"done; echo {_NOT_MOUNTED}"
145+
)
146+
probe = await session.exec("sh", "-lc", probe_cmd, shell=False, timeout=30)
147+
if not _stdout_says(probe, _MOUNTED):
148+
raise MountConfigError(
149+
message="rclone mount completed but the path is not a live mountpoint",
150+
context={"path": str(mount_path)},
151+
)
152+
153+
# Force rclone to materialize the root directory listing before we hand
154+
# control back to the caller. Without this, the next ``readdir`` from the
155+
# agent races the daemon's first listing fetch and can briefly observe an
156+
# empty directory. The exit code is irrelevant here — we just want the
157+
# side effect of priming rclone's dir cache.
158+
await session.exec("sh", "-lc", f"ls {quoted} >/dev/null 2>&1", shell=False, timeout=15)
159+
160+
161+
async def _default_user_ids(session: BaseSandboxSession) -> tuple[str, str] | None:
162+
result = await session.exec("sh", "-lc", "id -u; id -g", shell=False, timeout=30)
163+
if not result.ok():
164+
return None
165+
166+
lines = result.stdout.decode("utf-8", errors="replace").splitlines()
167+
if len(lines) < 2 or not lines[0].isdigit() or not lines[1].isdigit():
168+
return None
169+
return lines[0], lines[1]
170+
171+
172+
def _append_option(args: list[str], option: str, *values: str) -> None:
173+
if option not in args:
174+
args.extend([option, *values])
175+
176+
177+
async def _rclone_pattern_for_session(
178+
session: BaseSandboxSession,
179+
pattern: RcloneMountPattern,
180+
) -> RcloneMountPattern:
181+
if pattern.mode != "fuse":
182+
return pattern
183+
184+
extra_args = list(pattern.extra_args)
185+
_append_option(extra_args, "--allow-other")
186+
user_ids = await _default_user_ids(session)
187+
if user_ids is not None:
188+
uid, gid = user_ids
189+
_append_option(extra_args, "--uid", uid)
190+
_append_option(extra_args, "--gid", gid)
191+
192+
return pattern.model_copy(update={"extra_args": extra_args})
193+
194+
195+
def _assert_sprites_session(session: BaseSandboxSession) -> None:
196+
if type(session).__name__ != "SpritesSandboxSession":
197+
raise MountConfigError(
198+
message="sprites cloud bucket mounts require a SpritesSandboxSession",
199+
context={"session_type": type(session).__name__},
200+
)
201+
202+
203+
class SpritesCloudBucketMountStrategy(MountStrategyBase):
204+
"""Mount rclone-backed cloud storage in Sprites sandboxes."""
205+
206+
type: Literal["sprites_cloud_bucket"] = "sprites_cloud_bucket"
207+
pattern: RcloneMountPattern = RcloneMountPattern(mode="fuse")
208+
209+
def _delegate(self) -> InContainerMountStrategy:
210+
return InContainerMountStrategy(pattern=self.pattern)
211+
212+
async def _delegate_for_session(self, session: BaseSandboxSession) -> InContainerMountStrategy:
213+
return InContainerMountStrategy(
214+
pattern=await _rclone_pattern_for_session(session, self.pattern)
215+
)
216+
217+
def validate_mount(self, mount: Mount) -> None:
218+
self._delegate().validate_mount(mount)
219+
220+
async def activate(
221+
self,
222+
mount: Mount,
223+
session: BaseSandboxSession,
224+
dest: Path,
225+
base_dir: Path,
226+
) -> list[MaterializedFile]:
227+
_assert_sprites_session(session)
228+
if self.pattern.mode == "fuse":
229+
await _ensure_fuse_support(session)
230+
await _ensure_rclone(session)
231+
delegate = await self._delegate_for_session(session)
232+
files = await delegate.activate(mount, session, dest, base_dir)
233+
if self.pattern.mode == "fuse":
234+
mount_path = mount._resolve_mount_path(session, dest)
235+
await _verify_mount_active(session, mount_path)
236+
return files
237+
238+
async def deactivate(
239+
self,
240+
mount: Mount,
241+
session: BaseSandboxSession,
242+
dest: Path,
243+
base_dir: Path,
244+
) -> None:
245+
_assert_sprites_session(session)
246+
await self._delegate().deactivate(mount, session, dest, base_dir)
247+
248+
async def teardown_for_snapshot(
249+
self,
250+
mount: Mount,
251+
session: BaseSandboxSession,
252+
path: Path,
253+
) -> None:
254+
_assert_sprites_session(session)
255+
await self._delegate().teardown_for_snapshot(mount, session, path)
256+
257+
async def restore_after_snapshot(
258+
self,
259+
mount: Mount,
260+
session: BaseSandboxSession,
261+
path: Path,
262+
) -> None:
263+
_assert_sprites_session(session)
264+
if self.pattern.mode == "fuse":
265+
await _ensure_fuse_support(session)
266+
await _ensure_rclone(session)
267+
delegate = await self._delegate_for_session(session)
268+
await delegate.restore_after_snapshot(mount, session, path)
269+
270+
def build_docker_volume_driver_config(
271+
self,
272+
mount: Mount,
273+
) -> tuple[str, dict[str, str], bool] | None:
274+
return None
275+
276+
277+
__all__ = [
278+
"SpritesCloudBucketMountStrategy",
279+
]

0 commit comments

Comments
 (0)