1010
1111import asyncio
1212import hashlib
13+ import re
1314import socket
1415import threading
1516import time
1617from contextlib import suppress
17- from typing import Any
18+ from typing import Any , Literal
1819
1920import requests
2021import uvicorn
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" ,
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+
5368def _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+
84115def _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