Skip to content

Commit fe40066

Browse files
committed
feat(hf): add dedicated sandbox runtime mode
1 parent 4d1f3d9 commit fe40066

5 files changed

Lines changed: 410 additions & 27 deletions

File tree

docs/source/guides/runtime-providers.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ is a one-line change.
1414
| `UVProvider` | Local process via `uv` (no container) | core ||
1515
| `DaytonaProvider` | Daytona cloud sandboxes | `pip install openenv[daytona]` ||
1616
| `ACASandboxProvider` | Azure Container Apps Sandboxes | `pip install openenv[aca]` ||
17+
| `HFSandboxProvider` | Hugging Face Sandboxes | `pip install openenv[hf-sandbox]` ||
1718
| `ModalProvider` | Modal sandboxes | `pip install openenv[modal]` ||
1819
| `KubernetesProvider` | Kubernetes cluster | core | 🚧 planned |
1920

@@ -159,6 +160,26 @@ from openenv.core.containers.runtime import DockerSwarmProvider
159160
provider = DockerSwarmProvider()
160161
```
161162

163+
### HFSandboxProvider
164+
165+
Runs a registry image in a Hugging Face Sandbox. Pooled mode is the default and
166+
is appropriate for trusted same-user rollout fan-out. Dedicated mode allocates
167+
one VM per sandbox and is suited to untrusted or GPU workloads.
168+
169+
```python
170+
from openenv.core.containers.runtime.hf_sandbox_provider import HFSandboxProvider
171+
172+
provider = HFSandboxProvider(
173+
image="hf.co/spaces/openenv/coding_env",
174+
mode="dedicated",
175+
)
176+
base_url = provider.start_container()
177+
provider.wait_for_ready(base_url)
178+
```
179+
180+
Plain `env_vars` are visible as Job environment metadata. Use the dedicated
181+
mode's `secrets=` argument for encrypted runtime secrets.
182+
162183
### KubernetesProvider
163184

164185
🚧 Not yet implemented. The class exists as a placeholder for the planned

docs/source/reference/core.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,4 +240,6 @@ For a high-level explanation of how MCP-backed environments move through `step()
240240

241241
[[autodoc]] openenv.core.containers.runtime.aca_provider.ACASandboxProvider
242242

243+
[[autodoc]] openenv.core.containers.runtime.hf_sandbox_provider.HFSandboxProvider
244+
243245
[[autodoc]] openenv.core.containers.runtime.modal_provider.ModalProvider

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ modal = [
5555
inspect = [
5656
"inspect-ai>=0.3.0",
5757
]
58+
hf-sandbox = [
59+
"huggingface_hub>=1.22.0",
60+
]
5861

5962
[project.scripts]
6063
openenv = "openenv.cli.__main__:main"

src/openenv/core/containers/runtime/hf_sandbox_provider.py

Lines changed: 194 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@
1010

1111
import asyncio
1212
import hashlib
13+
import re
1314
import socket
1415
import threading
1516
import time
1617
from contextlib import suppress
17-
from typing import Any
18+
from typing import Any, Literal
1819

1920
import requests
2021
import uvicorn
@@ -27,12 +28,21 @@
2728

2829

2930
_DEFAULT_PORT = 8000
30-
_SERVER_COMMAND = (
31+
_POOLED_SERVER_COMMAND = (
3132
'export SBX_PROXY_DIR="${SBX_PROXY_DIR:-$HOME/.sbx/proxy}"; '
3233
'mkdir -p "$SBX_PROXY_DIR"; '
33-
'nohup server >"$HOME/openenv-server.log" 2>&1 &'
34+
'exec server >"$HOME/openenv-server.log" 2>&1'
3435
)
36+
_DEDICATED_SERVER_COMMAND = (
37+
'unset SBX_PROXY_DIR; exec server >"$HOME/openenv-server.log" 2>&1'
38+
)
39+
# Compatibility alias for callers that imported the old private constant.
40+
_SERVER_COMMAND = _POOLED_SERVER_COMMAND
3541
_MAX_WS_MESSAGE_SIZE = 100 * 1024 * 1024
42+
_MAX_HTTP_RESPONSE_SIZE = 100 * 1024 * 1024
43+
_CREDENTIAL_ENV_NAME = re.compile(
44+
r"(?i)(authorization|credential|password|private.?key|secret|token|api.?key)"
45+
)
3646
_HOP_BY_HOP_HEADERS = {
3747
"connection",
3848
"content-encoding",
@@ -50,6 +60,11 @@
5060
_POOL_LOCK = threading.Lock()
5161

5262

63+
class _NoRedirectWebSocketConnect(ws_connect):
64+
def process_redirect(self, exc: Exception) -> Exception | str:
65+
return exc
66+
67+
5368
def _find_available_port() -> int:
5469
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
5570
sock.bind(("127.0.0.1", 0))
@@ -81,6 +96,22 @@ def _get_sandbox_pool_cls() -> Any:
8196
return SandboxPool
8297

8398

99+
def _get_sandbox_cls() -> Any:
100+
try:
101+
from huggingface_hub import Sandbox
102+
except ImportError as exc:
103+
raise RuntimeError(
104+
"Dedicated HFSandboxProvider execution requires huggingface_hub>=1.22.0. "
105+
"Install it with `pip install 'openenv[hf-sandbox]'`."
106+
) from exc
107+
if not hasattr(Sandbox, "create"):
108+
raise RuntimeError(
109+
"Dedicated HFSandboxProvider execution requires huggingface_hub>=1.22.0. "
110+
"Install it with `pip install 'openenv[hf-sandbox]'`."
111+
)
112+
return Sandbox
113+
114+
84115
def _get_pool(image: str, flavor: str) -> Any:
85116
key = (image, flavor)
86117
with _POOL_LOCK:
@@ -133,23 +164,40 @@ async def proxy_http(path: str, request: Request) -> Response:
133164
data=body,
134165
headers=headers,
135166
timeout=60.0,
136-
allow_redirects=True,
167+
allow_redirects=False,
168+
stream=True,
137169
)
138170
except requests.RequestException:
139171
return Response(
140172
content=b"upstream HF job unreachable",
141173
status_code=502,
142174
)
143-
response_headers = {
144-
key: value
145-
for key, value in upstream.headers.items()
146-
if key.lower() not in _HOP_BY_HOP_HEADERS
147-
}
148-
return Response(
149-
content=upstream.content,
150-
status_code=upstream.status_code,
151-
headers=response_headers,
152-
)
175+
try:
176+
if 300 <= upstream.status_code < 400:
177+
return Response(
178+
content=b"upstream HF job redirects are not allowed",
179+
status_code=502,
180+
)
181+
content = upstream.raw.read(
182+
_MAX_HTTP_RESPONSE_SIZE + 1, decode_content=True
183+
)
184+
if len(content) > _MAX_HTTP_RESPONSE_SIZE:
185+
return Response(
186+
content=b"upstream HF job response exceeded proxy limit",
187+
status_code=502,
188+
)
189+
response_headers = {
190+
key: value
191+
for key, value in upstream.headers.items()
192+
if key.lower() not in _HOP_BY_HOP_HEADERS
193+
}
194+
return Response(
195+
content=content,
196+
status_code=upstream.status_code,
197+
headers=response_headers,
198+
)
199+
finally:
200+
upstream.close()
153201

154202
@app.websocket("/{path:path}")
155203
async def proxy_websocket(path: str, websocket: WebSocket) -> None:
@@ -202,7 +250,7 @@ async def _connect_upstream_websocket(
202250
last_error: Exception | None = None
203251
for _ in range(5):
204252
try:
205-
return await ws_connect(
253+
return await _NoRedirectWebSocketConnect(
206254
target,
207255
additional_headers=headers,
208256
max_size=_MAX_WS_MESSAGE_SIZE,
@@ -243,14 +291,87 @@ def __init__(
243291
*,
244292
image: str,
245293
env_vars: dict[str, str] | None = None,
294+
secrets: dict[str, str] | None = None,
246295
flavor: str = "cpu-basic",
296+
mode: Literal["pooled", "dedicated"] = "pooled",
247297
):
298+
if mode not in {"pooled", "dedicated"}:
299+
raise ValueError("HFSandboxProvider mode must be 'pooled' or 'dedicated'")
300+
if mode == "pooled" and secrets:
301+
raise ValueError("Encrypted secrets require dedicated HF sandbox mode")
248302
self.image = image
249-
self.env_vars = env_vars
303+
self._env_vars = dict(env_vars) if env_vars is not None else None
304+
self._secrets = dict(secrets) if secrets is not None else None
250305
self.flavor = flavor
306+
self._mode = mode
307+
self._official_certification = False
308+
self._official_image: str | None = None
309+
self._official_flavor: str | None = None
251310
self._sandbox: Any = None
311+
self._server_process: Any = None
252312
self._proxy: _LocalAuthProxy | None = None
253313

314+
@property
315+
def mode(self) -> Literal["pooled", "dedicated"]:
316+
return self._mode
317+
318+
@property
319+
def env_vars(self) -> dict[str, str] | None:
320+
return dict(self._env_vars) if self._env_vars is not None else None
321+
322+
@property
323+
def secrets(self) -> dict[str, str] | None:
324+
return dict(self._secrets) if self._secrets is not None else None
325+
326+
@property
327+
def isolation_mode(self) -> Literal["pooled", "dedicated", "unknown"]:
328+
if self._sandbox is None:
329+
return self._mode
330+
try:
331+
host_id = self._sandbox.host_id
332+
except (AttributeError, RuntimeError):
333+
return "unknown"
334+
return "dedicated" if host_id is None else "pooled"
335+
336+
def _lock_for_official_certification(self) -> None:
337+
"""Freeze the settings that cross the official runner trust boundary."""
338+
if self._sandbox is not None:
339+
raise RuntimeError(
340+
"Official certification must validate and lock the provider "
341+
"before a Hugging Face sandbox is started."
342+
)
343+
self._official_certification = True
344+
self._official_image = self.image
345+
self._official_flavor = self.flavor
346+
347+
def _verify_official_runtime_isolation(self) -> None:
348+
try:
349+
host_id = self._sandbox.host_id
350+
except (AttributeError, RuntimeError) as exc:
351+
raise RuntimeError(
352+
"Official certification could not verify dedicated isolation "
353+
"for the created Hugging Face sandbox."
354+
) from exc
355+
if host_id is not None:
356+
raise RuntimeError(
357+
"Official certification requires a dedicated Hugging Face sandbox; "
358+
"the created sandbox is attached to a shared host."
359+
)
360+
361+
def _create_sandbox(self, *, image: str, env_vars: dict[str, str] | None) -> Any:
362+
if self.mode == "dedicated":
363+
Sandbox = _get_sandbox_cls()
364+
create_kwargs: dict[str, Any] = {
365+
"image": image,
366+
"flavor": self.flavor,
367+
"env": env_vars,
368+
}
369+
if self._secrets is not None:
370+
create_kwargs["secrets"] = dict(self._secrets)
371+
return Sandbox.create(**create_kwargs)
372+
pool = _get_pool(image, self.flavor)
373+
return pool.create(env=env_vars)
374+
254375
def start_container(
255376
self,
256377
image: str | None = None,
@@ -271,47 +392,93 @@ def start_container(
271392
f"HFSandboxProvider only supports port {_DEFAULT_PORT} (got {port})."
272393
)
273394

395+
if self._official_certification:
396+
if (
397+
self.image != self._official_image
398+
or self.flavor != self._official_flavor
399+
or (image is not None and image != self._official_image)
400+
):
401+
raise RuntimeError(
402+
"Official certification does not allow image or flavor overrides after "
403+
"the provider trust check."
404+
)
405+
if env_vars is not None:
406+
raise RuntimeError(
407+
"Official certification does not allow environment overrides "
408+
"after the provider trust check."
409+
)
410+
credential_names = sorted(
411+
name
412+
for name in self._env_vars or {}
413+
if _CREDENTIAL_ENV_NAME.search(name)
414+
)
415+
if credential_names or self._secrets:
416+
raise RuntimeError(
417+
"Official subject sandboxes cannot receive coordinator credentials."
418+
)
419+
274420
effective_image = self.image if image is None else image
275-
effective_env = self.env_vars if env_vars is None else env_vars
276-
pool = _get_pool(effective_image, self.flavor)
277-
self._sandbox = pool.create(env=effective_env)
421+
effective_env = self._env_vars if env_vars is None else env_vars
422+
self._sandbox = self._create_sandbox(
423+
image=effective_image, env_vars=effective_env
424+
)
278425
try:
426+
if self._official_certification:
427+
self._verify_official_runtime_isolation()
279428
if not hasattr(self._sandbox, "proxy_url_for") or not hasattr(
280429
self._sandbox, "proxy_headers"
281430
):
282431
raise RuntimeError(
283432
"HFSandboxProvider requires a huggingface_hub version with "
284433
"Sandbox.proxy_url_for and Sandbox.proxy_headers support."
285434
)
286-
self._sandbox.run(
287-
_SERVER_COMMAND,
435+
self._server_process = self._sandbox.run(
436+
(
437+
_DEDICATED_SERVER_COMMAND
438+
if self.mode == "dedicated"
439+
else _POOLED_SERVER_COMMAND
440+
),
288441
shell=True,
442+
background=True,
289443
)
290444
self._proxy = _LocalAuthProxy(
291445
target_url=self._sandbox.proxy_url_for(_DEFAULT_PORT, "/"),
292446
headers=self._sandbox.proxy_headers,
293447
)
294448
return self._proxy.start()
295449
except Exception:
296-
self.stop_container()
450+
with suppress(Exception):
451+
self.stop_container()
297452
raise
298453

299454
def stop_container(self) -> None:
455+
proxy_error: Exception | None = None
300456
if self._proxy is not None:
301-
self._proxy.stop()
302-
self._proxy = None
457+
try:
458+
self._proxy.stop()
459+
except Exception as exc:
460+
proxy_error = exc
461+
finally:
462+
self._proxy = None
303463
if self._sandbox is not None:
304464
sandbox = self._sandbox
465+
sandbox.kill()
466+
if getattr(sandbox, "_killed", None) is not True:
467+
raise RuntimeError(
468+
"Hugging Face did not confirm termination of the sandbox; "
469+
"the provider retained its handle so cleanup can be retried."
470+
)
305471
self._sandbox = None
306-
with suppress(Exception):
307-
sandbox.kill()
472+
self._server_process = None
473+
if proxy_error is not None:
474+
raise proxy_error
308475

309476
def wait_for_ready(self, base_url: str, timeout_s: float = 120.0) -> None:
310477
deadline = time.time() + timeout_s
311478
health_url = f"{base_url}/health"
312479
while time.time() < deadline:
313480
try:
314-
response = requests.get(health_url, timeout=5.0)
481+
response = requests.get(health_url, timeout=5.0, allow_redirects=False)
315482
if response.status_code == 200:
316483
return
317484
except requests.exceptions.RequestException:

0 commit comments

Comments
 (0)