From 9bc358387293b9713528fea992d37a63b444481b Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 22 Jun 2026 11:54:16 +0200 Subject: [PATCH 1/5] feat: add hf sandbox provider --- pyproject.toml | 4 +- .../containers/runtime/hf_sandbox_provider.py | 290 ++++++++++++++++++ 2 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 src/openenv/core/containers/runtime/hf_sandbox_provider.py diff --git a/pyproject.toml b/pyproject.toml index 656d2c2bf..5241cca7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "typer>=0.9.0", "rich>=13.0.0", "pyyaml>=6.0", - "huggingface_hub>=0.20.0", + "huggingface_hub>=1.20.1", "openai>=2.7.2", "tomli>=2.3.0", "tomli-w>=1.2.0", @@ -46,7 +46,7 @@ cli = [ "typer>=0.9.0", "rich>=13.0.0", "pyyaml>=6.0", - "huggingface_hub>=0.20.0", + "huggingface_hub>=1.20.1", "openai>=2.7.2", "tomli>=2.3.0", "tomli-w>=1.2.0", diff --git a/src/openenv/core/containers/runtime/hf_sandbox_provider.py b/src/openenv/core/containers/runtime/hf_sandbox_provider.py new file mode 100644 index 000000000..82a533845 --- /dev/null +++ b/src/openenv/core/containers/runtime/hf_sandbox_provider.py @@ -0,0 +1,290 @@ +"""Hugging Face-backed provider for OpenEnv environment servers.""" + +from __future__ import annotations + +import asyncio +import socket +import threading +import time +from contextlib import suppress +from typing import Any, Dict, Optional + +import httpx +import requests +import uvicorn +from fastapi import FastAPI, Request, Response, WebSocket +from huggingface_hub import HfApi +from huggingface_hub.utils import get_token +from starlette.websockets import WebSocketDisconnect +from websockets.exceptions import ConnectionClosed +from websockets.asyncio.client import connect as ws_connect + +from .providers import ContainerProvider + + +_DEFAULT_PORT = 8000 +_DEFAULT_COMMAND = "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000" +_HOP_BY_HOP_HEADERS = { + "connection", + "content-encoding", + "content-length", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +} + + +def _find_available_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + sock.listen(1) + return sock.getsockname()[1] + + +def _job_port_url(job: Any, port: int) -> str | None: + for url in job.status.expose_urls or []: + if f"--{port}." in url: + return str(url) + return None + + +def _to_ws_url(url: str) -> str: + if url.startswith("https://"): + return "wss://" + url[len("https://") :] + if url.startswith("http://"): + return "ws://" + url[len("http://") :] + return url + + +class _LocalAuthProxy: + def __init__(self, *, target_url: str, token: str): + self.target_url = target_url.rstrip("/") + self.token = token + self.port = _find_available_port() + self._server: uvicorn.Server | None = None + self._thread: threading.Thread | None = None + + @property + def base_url(self) -> str: + return f"http://127.0.0.1:{self.port}" + + def start(self) -> str: + app = FastAPI() + + @app.api_route( + "/{path:path}", + methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + ) + async def proxy_http(path: str, request: Request) -> Response: + query = request.url.query + target = f"{self.target_url}/{path}" + if query: + target = f"{target}?{query}" + headers = { + key: value + for key, value in request.headers.items() + if key.lower() not in _HOP_BY_HOP_HEADERS + } + headers["authorization"] = f"Bearer {self.token}" + async with httpx.AsyncClient(follow_redirects=True) as client: + upstream = await client.request( + request.method, + target, + content=await request.body(), + headers=headers, + timeout=60.0, + ) + response_headers = { + key: value + for key, value in upstream.headers.items() + if key.lower() not in _HOP_BY_HOP_HEADERS + } + return Response( + content=upstream.content, + status_code=upstream.status_code, + headers=response_headers, + ) + + @app.websocket("/{path:path}") + async def proxy_websocket(path: str, websocket: WebSocket) -> None: + query = websocket.url.query + target = f"{_to_ws_url(self.target_url)}/{path}" + if query: + target = f"{target}?{query}" + await websocket.accept() + async with ws_connect( + target, + additional_headers={"Authorization": f"Bearer {self.token}"}, + ) as upstream: + to_upstream = asyncio.create_task( + self._client_to_upstream(websocket, upstream) + ) + to_client = asyncio.create_task( + self._upstream_to_client(websocket, upstream) + ) + done, pending = await asyncio.wait( + {to_upstream, to_client}, + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + for task in done: + with suppress(ConnectionClosed, WebSocketDisconnect): + task.result() + + config = uvicorn.Config( + app, + host="127.0.0.1", + port=self.port, + log_level="warning", + access_log=False, + ) + self._server = uvicorn.Server(config) + self._thread = threading.Thread(target=self._server.run, daemon=True) + self._thread.start() + while not self._server.started: + if not self._thread.is_alive(): + raise RuntimeError("HF sandbox auth proxy failed to start") + time.sleep(0.05) + return self.base_url + + async def _client_to_upstream(self, websocket: WebSocket, upstream: Any) -> None: + async for message in websocket.iter_text(): + await upstream.send(message) + + async def _upstream_to_client(self, websocket: WebSocket, upstream: Any) -> None: + async for message in upstream: + if isinstance(message, bytes): + await websocket.send_bytes(message) + else: + await websocket.send_text(message) + + def stop(self) -> None: + if self._server is None or self._thread is None: + return + self._server.should_exit = True + self._thread.join(timeout=5.0) + self._server = None + self._thread = None + + +class HFSandboxProvider(ContainerProvider): + """Run an OpenEnv server on Hugging Face infrastructure.""" + + def __init__( + self, + *, + flavor: str = "cpu-basic", + namespace: str | None = None, + token: str | None = None, + command: str = _DEFAULT_COMMAND, + timeout: int | float | str | None = "24h", + startup_timeout_s: float = 120.0, + ): + self.flavor = flavor + self.namespace = namespace + self.token = token + self.command = command + self.timeout = timeout + self.startup_timeout_s = startup_timeout_s + self._api = HfApi(token=token) + self._job: Any = None + self._job_namespace: str | None = None + self._proxy: _LocalAuthProxy | None = None + + def start_container( + self, + image: str, + port: Optional[int] = None, + env_vars: Optional[Dict[str, str]] = None, + **kwargs: Any, + ) -> str: + if kwargs: + unknown = ", ".join(sorted(kwargs)) + raise ValueError(f"Unsupported HFSandboxProvider options: {unknown}") + if self._job is not None: + raise RuntimeError("HFSandboxProvider already has an active job") + + bind_port = port or _DEFAULT_PORT + if bind_port != _DEFAULT_PORT: + raise ValueError( + f"HFSandboxProvider only supports port {_DEFAULT_PORT} " + f"(got {bind_port})." + ) + + effective_token = self.token or get_token() + if not effective_token: + raise ValueError( + "HFSandboxProvider requires a Hugging Face token. " + "Pass token= or run `hf auth login`." + ) + + self._job = self._api.run_job( + image=image, + command=["sh", "-lc", self.command], + env=env_vars, + flavor=self.flavor, + timeout=self.timeout, + labels={"openenv-provider": "hf-sandbox"}, + expose=[bind_port], + namespace=self.namespace, + token=effective_token, + ) + self._job_namespace = self.namespace or self._job.owner.name + target_url = self._wait_for_job_url(bind_port) + self._proxy = _LocalAuthProxy(target_url=target_url, token=effective_token) + return self._proxy.start() + + def _wait_for_job_url(self, port: int) -> str: + deadline = time.time() + self.startup_timeout_s + target_url = _job_port_url(self._job, port) + while target_url is None and time.time() < deadline: + time.sleep(0.5) + self._job = self._api.inspect_job( + job_id=self._job.id, + namespace=self._job_namespace, + token=self.token, + ) + target_url = _job_port_url(self._job, port) + if target_url is None: + raise RuntimeError( + f"HF job did not expose port {port} within " + f"{self.startup_timeout_s:.1f}s" + ) + return target_url + + def stop_container(self) -> None: + if self._proxy is not None: + self._proxy.stop() + self._proxy = None + if self._job is not None: + self._api.cancel_job( + job_id=self._job.id, + namespace=self._job_namespace, + token=self.token, + ) + self._job = None + self._job_namespace = None + + def wait_for_ready(self, base_url: str, timeout_s: float = 120.0) -> None: + deadline = time.time() + timeout_s + health_url = f"{base_url}/health" + while time.time() < deadline: + response = requests.get(health_url, timeout=5.0) + if response.status_code == 200: + return + time.sleep(1.0) + raise TimeoutError( + f"HF sandbox job at {base_url} did not become ready within {timeout_s}s" + ) + + def close(self) -> None: + self.stop_container() + + +__all__ = ["HFSandboxProvider"] From f90f6f947e2c291d4e5cd0046aa9df265a8b9b46 Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Tue, 23 Jun 2026 09:57:52 +0200 Subject: [PATCH 2/5] feat: add hf sandbox example --- examples/hf_sandbox_persistence.py | 103 +++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 examples/hf_sandbox_persistence.py diff --git a/examples/hf_sandbox_persistence.py b/examples/hf_sandbox_persistence.py new file mode 100644 index 000000000..0c5b17610 --- /dev/null +++ b/examples/hf_sandbox_persistence.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Smoke-check persistent state through the HF sandbox provider. + +This launches a tiny stateful WebSocket app on Hugging Face infrastructure, +sets a value over one WebSocket connection, reconnects, and reads it back over +a second connection. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json + +from openenv.core.containers.runtime.hf_sandbox_provider import HFSandboxProvider +from websockets.asyncio.client import connect as ws_connect + + +SERVER_COMMAND = r""" +python -m pip install --quiet --disable-pip-version-check fastapi "uvicorn[standard]" +cat > /tmp/hf_sandbox_persistence_app.py <<'PY' +from fastapi import FastAPI, WebSocket + +app = FastAPI() +state = {} + + +@app.get("/health") +async def health(): + return {"status": "healthy"} + + +@app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + await websocket.accept() + while True: + message = await websocket.receive_json() + if message["op"] == "set": + state[message["name"]] = message["value"] + await websocket.send_json({"ok": True}) + elif message["op"] == "get": + await websocket.send_json({"value": state.get(message["name"])}) +PY +cd /tmp && python -m uvicorn hf_sandbox_persistence_app:app --host 0.0.0.0 --port 8000 +""" + + +def _ws_url(base_url: str) -> str: + if base_url.startswith("https://"): + return "wss://" + base_url[len("https://") :] + if base_url.startswith("http://"): + return "ws://" + base_url[len("http://") :] + return base_url + + +async def check_persistence(base_url: str, name: str, value: str) -> None: + ws_url = f"{_ws_url(base_url)}/ws" + + async with ws_connect(ws_url) as websocket: + await websocket.send(json.dumps({"op": "set", "name": name, "value": value})) + set_response = json.loads(await websocket.recv()) + print(f"set response: {set_response}") + + async with ws_connect(ws_url) as websocket: + await websocket.send(json.dumps({"op": "get", "name": name})) + get_response = json.loads(await websocket.recv()) + print(f"get response: {get_response}") + if get_response.get("value") != value: + raise RuntimeError("HF sandbox did not preserve state across connections") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--image", default="python:3.12-slim") + parser.add_argument("--flavor", default="cpu-basic") + parser.add_argument("--namespace") + parser.add_argument("--name", default="openenv_hf_sandbox_value") + parser.add_argument("--value", default="persisted-across-connections") + parser.add_argument("--startup-timeout-s", type=float, default=300.0) + parser.add_argument("--ready-timeout-s", type=float, default=300.0) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + provider = HFSandboxProvider( + flavor=args.flavor, + namespace=args.namespace, + command=SERVER_COMMAND, + startup_timeout_s=args.startup_timeout_s, + ) + + with provider: + base_url = provider.start_container(args.image) + print(f"provider URL: {base_url}") + provider.wait_for_ready(base_url, timeout_s=args.ready_timeout_s) + asyncio.run(check_persistence(base_url, args.name, args.value)) + + print("HF sandbox persistence check passed") + + +if __name__ == "__main__": + main() From a50fad9d6638aa33e3f5080c6573b07d1770f39f Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Tue, 23 Jun 2026 10:15:00 +0200 Subject: [PATCH 3/5] fix: quiet hf sandbox example --- examples/hf_sandbox_persistence.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/hf_sandbox_persistence.py b/examples/hf_sandbox_persistence.py index 0c5b17610..f11a1c2ba 100644 --- a/examples/hf_sandbox_persistence.py +++ b/examples/hf_sandbox_persistence.py @@ -33,8 +33,7 @@ async def health(): @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): await websocket.accept() - while True: - message = await websocket.receive_json() + async for message in websocket.iter_json(): if message["op"] == "set": state[message["name"]] = message["value"] await websocket.send_json({"ok": True}) From dfb6db4efd810130e327cc9f48940d9ba58cdbf3 Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Tue, 23 Jun 2026 10:32:18 +0200 Subject: [PATCH 4/5] refactor: simplify hf sandbox provider --- examples/hf_sandbox_persistence.py | 50 ++++---------- .../containers/runtime/hf_sandbox_provider.py | 69 ++++++++----------- 2 files changed, 41 insertions(+), 78 deletions(-) diff --git a/examples/hf_sandbox_persistence.py b/examples/hf_sandbox_persistence.py index f11a1c2ba..b92a8a630 100644 --- a/examples/hf_sandbox_persistence.py +++ b/examples/hf_sandbox_persistence.py @@ -8,7 +8,6 @@ from __future__ import annotations -import argparse import asyncio import json @@ -16,6 +15,9 @@ from websockets.asyncio.client import connect as ws_connect +IMAGE = "python:3.12-slim" +STATE_NAME = "openenv_hf_sandbox_value" +STATE_VALUE = "persisted-across-connections" SERVER_COMMAND = r""" python -m pip install --quiet --disable-pip-version-check fastapi "uvicorn[standard]" cat > /tmp/hf_sandbox_persistence_app.py <<'PY' @@ -44,56 +46,32 @@ async def websocket_endpoint(websocket: WebSocket): """ -def _ws_url(base_url: str) -> str: - if base_url.startswith("https://"): - return "wss://" + base_url[len("https://") :] - if base_url.startswith("http://"): - return "ws://" + base_url[len("http://") :] - return base_url - - -async def check_persistence(base_url: str, name: str, value: str) -> None: - ws_url = f"{_ws_url(base_url)}/ws" +async def check_persistence(base_url: str) -> None: + ws_url = f"{base_url.replace('http://', 'ws://', 1)}/ws" async with ws_connect(ws_url) as websocket: - await websocket.send(json.dumps({"op": "set", "name": name, "value": value})) + await websocket.send( + json.dumps({"op": "set", "name": STATE_NAME, "value": STATE_VALUE}) + ) set_response = json.loads(await websocket.recv()) print(f"set response: {set_response}") async with ws_connect(ws_url) as websocket: - await websocket.send(json.dumps({"op": "get", "name": name})) + await websocket.send(json.dumps({"op": "get", "name": STATE_NAME})) get_response = json.loads(await websocket.recv()) print(f"get response: {get_response}") - if get_response.get("value") != value: + if get_response.get("value") != STATE_VALUE: raise RuntimeError("HF sandbox did not preserve state across connections") -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser() - parser.add_argument("--image", default="python:3.12-slim") - parser.add_argument("--flavor", default="cpu-basic") - parser.add_argument("--namespace") - parser.add_argument("--name", default="openenv_hf_sandbox_value") - parser.add_argument("--value", default="persisted-across-connections") - parser.add_argument("--startup-timeout-s", type=float, default=300.0) - parser.add_argument("--ready-timeout-s", type=float, default=300.0) - return parser.parse_args() - - def main() -> None: - args = parse_args() - provider = HFSandboxProvider( - flavor=args.flavor, - namespace=args.namespace, - command=SERVER_COMMAND, - startup_timeout_s=args.startup_timeout_s, - ) + provider = HFSandboxProvider(command=SERVER_COMMAND) with provider: - base_url = provider.start_container(args.image) + base_url = provider.start_container(IMAGE) print(f"provider URL: {base_url}") - provider.wait_for_ready(base_url, timeout_s=args.ready_timeout_s) - asyncio.run(check_persistence(base_url, args.name, args.value)) + provider.wait_for_ready(base_url, timeout_s=300.0) + asyncio.run(check_persistence(base_url)) print("HF sandbox persistence check passed") diff --git a/src/openenv/core/containers/runtime/hf_sandbox_provider.py b/src/openenv/core/containers/runtime/hf_sandbox_provider.py index 82a533845..d787c9fe2 100644 --- a/src/openenv/core/containers/runtime/hf_sandbox_provider.py +++ b/src/openenv/core/containers/runtime/hf_sandbox_provider.py @@ -7,7 +7,7 @@ import threading import time from contextlib import suppress -from typing import Any, Dict, Optional +from typing import Any import httpx import requests @@ -16,14 +16,16 @@ from huggingface_hub import HfApi from huggingface_hub.utils import get_token from starlette.websockets import WebSocketDisconnect -from websockets.exceptions import ConnectionClosed from websockets.asyncio.client import connect as ws_connect +from websockets.exceptions import ConnectionClosed from .providers import ContainerProvider _DEFAULT_PORT = 8000 _DEFAULT_COMMAND = "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000" +_JOB_TIMEOUT = "24h" +_STARTUP_TIMEOUT_S = 120.0 _HOP_BY_HOP_HEADERS = { "connection", "content-encoding", @@ -180,48 +182,33 @@ def __init__( self, *, flavor: str = "cpu-basic", - namespace: str | None = None, - token: str | None = None, command: str = _DEFAULT_COMMAND, - timeout: int | float | str | None = "24h", - startup_timeout_s: float = 120.0, ): self.flavor = flavor - self.namespace = namespace - self.token = token self.command = command - self.timeout = timeout - self.startup_timeout_s = startup_timeout_s - self._api = HfApi(token=token) + self._api = HfApi() + self._token: str | None = None self._job: Any = None - self._job_namespace: str | None = None self._proxy: _LocalAuthProxy | None = None def start_container( self, image: str, - port: Optional[int] = None, - env_vars: Optional[Dict[str, str]] = None, - **kwargs: Any, + port: int | None = None, + env_vars: dict[str, str] | None = None, ) -> str: - if kwargs: - unknown = ", ".join(sorted(kwargs)) - raise ValueError(f"Unsupported HFSandboxProvider options: {unknown}") if self._job is not None: raise RuntimeError("HFSandboxProvider already has an active job") - bind_port = port or _DEFAULT_PORT - if bind_port != _DEFAULT_PORT: + if port not in (None, _DEFAULT_PORT): raise ValueError( - f"HFSandboxProvider only supports port {_DEFAULT_PORT} " - f"(got {bind_port})." + f"HFSandboxProvider only supports port {_DEFAULT_PORT} (got {port})." ) - effective_token = self.token or get_token() + effective_token = get_token() if not effective_token: raise ValueError( - "HFSandboxProvider requires a Hugging Face token. " - "Pass token= or run `hf auth login`." + "HFSandboxProvider requires a Hugging Face token. Run `hf auth login`." ) self._job = self._api.run_job( @@ -229,32 +216,30 @@ def start_container( command=["sh", "-lc", self.command], env=env_vars, flavor=self.flavor, - timeout=self.timeout, - labels={"openenv-provider": "hf-sandbox"}, - expose=[bind_port], - namespace=self.namespace, + timeout=_JOB_TIMEOUT, + expose=[_DEFAULT_PORT], token=effective_token, ) - self._job_namespace = self.namespace or self._job.owner.name - target_url = self._wait_for_job_url(bind_port) + self._token = effective_token + target_url = self._wait_for_job_url() self._proxy = _LocalAuthProxy(target_url=target_url, token=effective_token) return self._proxy.start() - def _wait_for_job_url(self, port: int) -> str: - deadline = time.time() + self.startup_timeout_s - target_url = _job_port_url(self._job, port) + def _wait_for_job_url(self) -> str: + deadline = time.time() + _STARTUP_TIMEOUT_S + target_url = _job_port_url(self._job, _DEFAULT_PORT) while target_url is None and time.time() < deadline: time.sleep(0.5) self._job = self._api.inspect_job( job_id=self._job.id, - namespace=self._job_namespace, - token=self.token, + namespace=self._job.owner.name, + token=self._token, ) - target_url = _job_port_url(self._job, port) + target_url = _job_port_url(self._job, _DEFAULT_PORT) if target_url is None: raise RuntimeError( - f"HF job did not expose port {port} within " - f"{self.startup_timeout_s:.1f}s" + f"HF job did not expose port {_DEFAULT_PORT} within " + f"{_STARTUP_TIMEOUT_S:.1f}s" ) return target_url @@ -265,11 +250,11 @@ def stop_container(self) -> None: if self._job is not None: self._api.cancel_job( job_id=self._job.id, - namespace=self._job_namespace, - token=self.token, + namespace=self._job.owner.name, + token=self._token, ) self._job = None - self._job_namespace = None + self._token = None def wait_for_ready(self, base_url: str, timeout_s: float = 120.0) -> None: deadline = time.time() + timeout_s From 507682cb267c03e17465ef9d64f0c9b7520bedda Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Wed, 24 Jun 2026 11:52:57 +0200 Subject: [PATCH 5/5] refactor: use coding env hf sandbox smoke --- examples/hf_sandbox_coding_env.py | 52 ++++++++++++ examples/hf_sandbox_persistence.py | 80 ------------------- .../containers/runtime/hf_sandbox_provider.py | 12 +-- 3 files changed, 55 insertions(+), 89 deletions(-) create mode 100644 examples/hf_sandbox_coding_env.py delete mode 100644 examples/hf_sandbox_persistence.py diff --git a/examples/hf_sandbox_coding_env.py b/examples/hf_sandbox_coding_env.py new file mode 100644 index 000000000..a6aa3d850 --- /dev/null +++ b/examples/hf_sandbox_coding_env.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Smoke-check a real OpenEnv server through the HF sandbox provider.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from envs.coding_env import CodeAction, CodingEnv +from openenv.core.containers.runtime.hf_sandbox_provider import HFSandboxProvider + + +IMAGE = "hf.co/spaces/openenv/coding_env" + + +def run_code(base_url: str, code: str) -> str: + with CodingEnv(base_url=base_url).sync() as env: + env.reset() + result = env.step(CodeAction(code=code)) + observation = result.observation + if observation.exit_code != 0: + raise RuntimeError(observation.stderr) + return observation.stdout.strip() + + +def main() -> None: + with HFSandboxProvider() as provider: + base_url = provider.start_container(IMAGE) + print(f"provider URL: {base_url}") + provider.wait_for_ready(base_url, timeout_s=300.0) + + first_output = run_code(base_url, "answer = 40 + 2\nprint(answer)") + print(f"first connection output: {first_output!r}") + if first_output != "42": + raise RuntimeError("first HF sandbox connection returned unexpected output") + + second_output = run_code( + base_url, "message = 'second connection ok'\nprint(message)" + ) + print(f"second connection output: {second_output!r}") + if second_output != "second connection ok": + raise RuntimeError( + "second HF sandbox connection returned unexpected output" + ) + + print("HF sandbox coding_env check passed") + + +if __name__ == "__main__": + main() diff --git a/examples/hf_sandbox_persistence.py b/examples/hf_sandbox_persistence.py deleted file mode 100644 index b92a8a630..000000000 --- a/examples/hf_sandbox_persistence.py +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env python3 -"""Smoke-check persistent state through the HF sandbox provider. - -This launches a tiny stateful WebSocket app on Hugging Face infrastructure, -sets a value over one WebSocket connection, reconnects, and reads it back over -a second connection. -""" - -from __future__ import annotations - -import asyncio -import json - -from openenv.core.containers.runtime.hf_sandbox_provider import HFSandboxProvider -from websockets.asyncio.client import connect as ws_connect - - -IMAGE = "python:3.12-slim" -STATE_NAME = "openenv_hf_sandbox_value" -STATE_VALUE = "persisted-across-connections" -SERVER_COMMAND = r""" -python -m pip install --quiet --disable-pip-version-check fastapi "uvicorn[standard]" -cat > /tmp/hf_sandbox_persistence_app.py <<'PY' -from fastapi import FastAPI, WebSocket - -app = FastAPI() -state = {} - - -@app.get("/health") -async def health(): - return {"status": "healthy"} - - -@app.websocket("/ws") -async def websocket_endpoint(websocket: WebSocket): - await websocket.accept() - async for message in websocket.iter_json(): - if message["op"] == "set": - state[message["name"]] = message["value"] - await websocket.send_json({"ok": True}) - elif message["op"] == "get": - await websocket.send_json({"value": state.get(message["name"])}) -PY -cd /tmp && python -m uvicorn hf_sandbox_persistence_app:app --host 0.0.0.0 --port 8000 -""" - - -async def check_persistence(base_url: str) -> None: - ws_url = f"{base_url.replace('http://', 'ws://', 1)}/ws" - - async with ws_connect(ws_url) as websocket: - await websocket.send( - json.dumps({"op": "set", "name": STATE_NAME, "value": STATE_VALUE}) - ) - set_response = json.loads(await websocket.recv()) - print(f"set response: {set_response}") - - async with ws_connect(ws_url) as websocket: - await websocket.send(json.dumps({"op": "get", "name": STATE_NAME})) - get_response = json.loads(await websocket.recv()) - print(f"get response: {get_response}") - if get_response.get("value") != STATE_VALUE: - raise RuntimeError("HF sandbox did not preserve state across connections") - - -def main() -> None: - provider = HFSandboxProvider(command=SERVER_COMMAND) - - with provider: - base_url = provider.start_container(IMAGE) - print(f"provider URL: {base_url}") - provider.wait_for_ready(base_url, timeout_s=300.0) - asyncio.run(check_persistence(base_url)) - - print("HF sandbox persistence check passed") - - -if __name__ == "__main__": - main() diff --git a/src/openenv/core/containers/runtime/hf_sandbox_provider.py b/src/openenv/core/containers/runtime/hf_sandbox_provider.py index d787c9fe2..ef2f142d5 100644 --- a/src/openenv/core/containers/runtime/hf_sandbox_provider.py +++ b/src/openenv/core/containers/runtime/hf_sandbox_provider.py @@ -23,7 +23,7 @@ _DEFAULT_PORT = 8000 -_DEFAULT_COMMAND = "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000" +_SERVER_COMMAND = "server" _JOB_TIMEOUT = "24h" _STARTUP_TIMEOUT_S = 120.0 _HOP_BY_HOP_HEADERS = { @@ -178,14 +178,8 @@ def stop(self) -> None: class HFSandboxProvider(ContainerProvider): """Run an OpenEnv server on Hugging Face infrastructure.""" - def __init__( - self, - *, - flavor: str = "cpu-basic", - command: str = _DEFAULT_COMMAND, - ): + def __init__(self, *, flavor: str = "cpu-basic"): self.flavor = flavor - self.command = command self._api = HfApi() self._token: str | None = None self._job: Any = None @@ -213,7 +207,7 @@ def start_container( self._job = self._api.run_job( image=image, - command=["sh", "-lc", self.command], + command=[_SERVER_COMMAND], env=env_vars, flavor=self.flavor, timeout=_JOB_TIMEOUT,