diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index abbdc1513..80342bdf4 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -109,6 +109,9 @@ jobs: - name: opencode-env dockerfile: envs/opencode_env/server/Dockerfile context: envs/opencode_env + - name: opencode-sandbox + dockerfile: envs/opencode_env/sandbox/hf_image/Dockerfile + context: envs/opencode_env - name: openapp-env dockerfile: envs/openapp_env/server/Dockerfile context: envs/openapp_env diff --git a/docs/source/environments/opencode.md b/docs/source/environments/opencode.md index 11bf6aeaf..b936ea3e4 100644 --- a/docs/source/environments/opencode.md +++ b/docs/source/environments/opencode.md @@ -127,6 +127,32 @@ turns = session.fetch_proxy_trace() # per-turn (tokens, logprobs) session.close() ``` +#### Sandbox backends + +`E2BSandboxBackend` is the default. To run the agent in a **Hugging Face sandbox** +instead, swap the backend (`huggingface_hub>=1.22` ships with the package): + +```python +from opencode_env.sandbox import HFSandboxBackend + +factory = OpenCodeSessionFactory( + config=OpenCodeConfig(..., sandbox_home="/root"), # HF sandbox execs as root + sandbox_backend=HFSandboxBackend(image="python:3.12"), + mode="transparent_proxy", +) +``` + +`image="python:3.12"` cold-installs opencode + the proxy deps on every rollout. For faster +rollouts use the pre-baked image (opencode + proxy deps already installed), built by CI from +`sandbox/hf_image/Dockerfile`: + +```python +sandbox_backend=HFSandboxBackend(image="ghcr.io/huggingface/openenv-opencode-sandbox:latest") +``` + +Any backend satisfying the `SandboxBackend` / `SandboxHandle` / `BgJob` protocols in +`opencode_env.sandbox.base` can be plugged in the same way. + ## Building the Docker Image The Dockerfile lives at `server/Dockerfile`. Use the `openenv` CLI from diff --git a/envs/opencode_env/README.md b/envs/opencode_env/README.md index 3321d158b..36ec3afb6 100644 --- a/envs/opencode_env/README.md +++ b/envs/opencode_env/README.md @@ -140,6 +140,32 @@ turns = session.fetch_proxy_trace() # per-turn (tokens, logprobs) session.close() ``` +#### Sandbox backends + +`E2BSandboxBackend` is the default. To run the agent in a **Hugging Face sandbox** +instead, swap the backend (`huggingface_hub>=1.22` ships with the package): + +```python +from opencode_env.sandbox import HFSandboxBackend + +factory = OpenCodeSessionFactory( + config=OpenCodeConfig(..., sandbox_home="/root"), # HF sandbox execs as root + sandbox_backend=HFSandboxBackend(image="python:3.12"), + mode="transparent_proxy", +) +``` + +`image="python:3.12"` cold-installs opencode + the proxy deps on every rollout. For faster +rollouts use the pre-baked image (opencode + proxy deps already installed), built by CI from +`sandbox/hf_image/Dockerfile`: + +```python +sandbox_backend=HFSandboxBackend(image="ghcr.io/huggingface/openenv-opencode-sandbox:latest") +``` + +Any backend satisfying the `SandboxBackend` / `SandboxHandle` / `BgJob` protocols in +`opencode_env.sandbox.base` can be plugged in the same way. + ## Building the Docker Image The Dockerfile lives at `server/Dockerfile`. Use the `openenv` CLI from diff --git a/envs/opencode_env/harness.py b/envs/opencode_env/harness.py index da4410dd4..a87017e9c 100644 --- a/envs/opencode_env/harness.py +++ b/envs/opencode_env/harness.py @@ -14,7 +14,7 @@ - ``mode="black_box"`` — opencode talks directly to ``config.base_url``. No proxy, no logprob capture. Use for smoke tests / SFT / eval. - - ``mode="transparent_proxy"`` (default) — an in-sandbox FastAPI proxy + - ``mode="transparent_proxy"`` — an in-sandbox FastAPI proxy sits between opencode and the upstream LLM. It injects ``logprobs=true`` on every request and writes per-turn ``(messages, completion_tokens, per_token_logps)`` to ``proxy_trace.jsonl`` for GRPO consumption. @@ -49,21 +49,22 @@ build_opencode_json, build_run_cmd, instruction_path, + opencode_bin_path, opencode_config_path, + proxy_dir, + proxy_log_path, + proxy_source_path, + proxy_trace_path, system_prompt_path, ) from .sandbox.base import BgJob, SandboxBackend, SandboxHandle from .task import OpenCodeTask -# Inside-sandbox proxy paths (Mode B). +# Mode B proxy port. In-sandbox paths derive from config.sandbox_home (opencode_runtime). _PROXY_PORT = 7000 -_PROXY_TRACE_PATH = "/home/user/logs/agent/proxy_trace.jsonl" -_PROXY_LOG_PATH = "/home/user/logs/agent/proxy.log" -# Where the proxy source lives on disk (in this repo). Uploaded into the -# sandbox at /home/user/proxy/interception.py before each rollout, unless -# the sandbox was created from a template that already has it baked in. +# Local proxy source, uploaded to proxy_source_path(config) unless already baked in. _PROXY_SOURCE_PATH = Path(__file__).parent / "sandbox" / "interception.py" @@ -240,53 +241,57 @@ def create( or getattr(getattr(sandbox, "raw", None), "sandbox_id", "?") ) _log.info("factory.create: sandbox=%s — bootstrapping…", sid) + # Any failure past here (bootstrap/proxy/agent) must tear the sandbox down. try: self._bootstrap_sandbox(sandbox, oc_task) - except Exception as exc: - _log.error("factory.create: bootstrap failed: %r", exc) - sandbox.kill() - raise - base_url_override: str | None = None - proxy_trace_path: str | None = None - proxy_bg_job: BgJob | None = None - if self._mode == "transparent_proxy": - _log.info( - "factory.create: starting interception proxy on :%d → %s", - _PROXY_PORT, self._config.base_url, - ) - proxy_bg_job, base_url_override, proxy_trace_path = self._start_proxy( - sandbox - ) - _log.info("factory.create: proxy up at %s", base_url_override) - # Rewrite opencode.json so opencode points at the proxy. Force - # ``openai_compatible`` so opencode hits ``/v1/chat/completions`` - # (which the proxy serves) rather than provider-specific paths. - from .config import OpenCodeConfig as _OCC - - proxy_cfg = _OCC( - **{ - **self._config.model_dump(), - "provider": "openai_compatible", - "base_url": base_url_override, - } - ) - sandbox.write_text( - opencode_config_path(self._config), - build_opencode_json(proxy_cfg), - ) + base_url_override: str | None = None + proxy_trace_path: str | None = None + proxy_bg_job: BgJob | None = None + if self._mode == "transparent_proxy": + _log.info( + "factory.create: starting interception proxy on :%d → %s", + _PROXY_PORT, self._config.base_url, + ) + proxy_bg_job, base_url_override, proxy_trace_path = self._start_proxy( + sandbox + ) + _log.info("factory.create: proxy up at %s", base_url_override) + # Rewrite opencode.json so opencode points at the proxy. Force + # ``openai_compatible`` so opencode hits ``/v1/chat/completions`` + # (which the proxy serves) rather than provider-specific paths. + from .config import OpenCodeConfig as _OCC + + proxy_cfg = _OCC( + **{ + **self._config.model_dump(), + "provider": "openai_compatible", + "base_url": base_url_override, + } + ) + sandbox.write_text( + opencode_config_path(self._config), + build_opencode_json(proxy_cfg), + ) - session = OpenCodeSession( - sandbox=sandbox, - config=self._config, - task=oc_task, - verifier=self._verifier, - base_url_override=base_url_override, - proxy_trace_path=proxy_trace_path, - proxy_bg_job=proxy_bg_job, - ) - session.start_agent() - return session + session = OpenCodeSession( + sandbox=sandbox, + config=self._config, + task=oc_task, + verifier=self._verifier, + base_url_override=base_url_override, + proxy_trace_path=proxy_trace_path, + proxy_bg_job=proxy_bg_job, + ) + session.start_agent() + return session + except Exception as exc: + _log.error("factory.create: setup failed, killing sandbox: %r", exc) + try: + sandbox.kill() # best-effort: don't let a cleanup failure mask the root cause + except Exception: + _log.exception("factory.create: sandbox.kill() during cleanup also failed") + raise # ------------------------------------------------------------------ def _wait_for_sandbox_ready( @@ -369,7 +374,7 @@ def _opencode_already_installed(self, sandbox: SandboxHandle) -> bool: """ try: r = sandbox.exec( - "/home/user/.opencode/bin/opencode --version", + f"{opencode_bin_path(self._config)} --version", timeout=10, ) return r.exit_code == 0 @@ -440,16 +445,14 @@ def _start_proxy( Skips the pip install + source-upload steps when the prebaked template already has them in place. """ - proxy_already_present = sandbox.exists( - "/home/user/proxy/interception.py" - ) + proxy_already_present = sandbox.exists(proxy_source_path(self._config)) if not proxy_already_present: # Install proxy deps (idempotent on retries). self._exec_with_retry( sandbox, - "pip install --quiet 'fastapi>=0.104' 'uvicorn[standard]>=0.24' " - "'httpx>=0.27' 2>&1 | tail -20", + "set -o pipefail && pip install --quiet 'fastapi>=0.104' " + "'uvicorn[standard]>=0.24' 'httpx>=0.27' 2>&1 | tail -20", timeout=180, attempts=3, backoff_s=2.0, @@ -457,10 +460,10 @@ def _start_proxy( ) # Upload the proxy module into the sandbox. sandbox.write_text( - "/home/user/proxy/interception.py", + proxy_source_path(self._config), _PROXY_SOURCE_PATH.read_text(), ) - sandbox.write_text("/home/user/proxy/__init__.py", "") + sandbox.write_text(f"{proxy_dir(self._config)}/__init__.py", "") proxy_args = [ "python", @@ -468,7 +471,7 @@ def _start_proxy( "--upstream-url", self._config.base_url, "--trace", - _PROXY_TRACE_PATH, + proxy_trace_path(self._config), "--port", str(_PROXY_PORT), "--top-logprobs", @@ -487,9 +490,9 @@ def _start_proxy( quoted_proxy_args = " ".join(shlex.quote(arg) for arg in proxy_args) proxy_cmd = ( - "cd /home/user/proxy && " + f"cd {shlex.quote(proxy_dir(self._config))} && " f"{quoted_proxy_args} " - f"> {shlex.quote(_PROXY_LOG_PATH)} 2>&1" + f"> {shlex.quote(proxy_log_path(self._config))} 2>&1" ) proxy_env = {"OPENCODE_UPSTREAM_API_KEY": self._config.api_key} proxy_job = sandbox.start_bg(proxy_cmd, envs=proxy_env) @@ -511,7 +514,7 @@ def _start_proxy( else: log = "" try: - log = sandbox.read_text(_PROXY_LOG_PATH) + log = sandbox.read_text(proxy_log_path(self._config)) except Exception: pass proxy_job.kill() @@ -521,7 +524,7 @@ def _start_proxy( ) base_url_override = f"http://127.0.0.1:{_PROXY_PORT}/v1" - return proxy_job, base_url_override, _PROXY_TRACE_PATH + return proxy_job, base_url_override, proxy_trace_path(self._config) __all__ = [ diff --git a/envs/opencode_env/opencode_runtime.py b/envs/opencode_env/opencode_runtime.py index 07fd5322d..142176c8b 100644 --- a/envs/opencode_env/opencode_runtime.py +++ b/envs/opencode_env/opencode_runtime.py @@ -43,6 +43,26 @@ def workdir_path(config: OpenCodeConfig) -> str: return f"{config.sandbox_home}/workdir" +def opencode_bin_path(config: OpenCodeConfig) -> str: + return f"{config.sandbox_home}/.opencode/bin/opencode" + + +def proxy_dir(config: OpenCodeConfig) -> str: + return f"{config.sandbox_home}/proxy" + + +def proxy_source_path(config: OpenCodeConfig) -> str: + return f"{proxy_dir(config)}/interception.py" + + +def proxy_trace_path(config: OpenCodeConfig) -> str: + return f"{config.sandbox_home}/logs/agent/proxy_trace.jsonl" + + +def proxy_log_path(config: OpenCodeConfig) -> str: + return f"{config.sandbox_home}/logs/agent/proxy.log" + + def build_opencode_json(config: OpenCodeConfig) -> str: """Return the serialized ``opencode.json`` the sandbox should install. diff --git a/envs/opencode_env/pyproject.toml b/envs/opencode_env/pyproject.toml index 6a2be5ff0..6da6ad754 100644 --- a/envs/opencode_env/pyproject.toml +++ b/envs/opencode_env/pyproject.toml @@ -29,6 +29,8 @@ dependencies = [ # OpenCode harness primitive — sandbox + proxy + agent driver "httpx>=0.27.0", "e2b>=1.0.0", + # >=1.22 ships huggingface_hub.Sandbox (HFSandboxBackend); core only floors 0.20. + "huggingface_hub>=1.22", ] [project.optional-dependencies] diff --git a/envs/opencode_env/sandbox/__init__.py b/envs/opencode_env/sandbox/__init__.py index 321f81547..d87c6728d 100644 --- a/envs/opencode_env/sandbox/__init__.py +++ b/envs/opencode_env/sandbox/__init__.py @@ -41,6 +41,29 @@ def __init__(self, *_args, **_kwargs): E2BBgJob = E2BSandboxBackend = E2BSandboxHandle = _RequiresE2B # type: ignore[assignment] +try: + from .hf import HFBgJob, HFSandboxBackend, HFSandboxHandle # noqa: F401 +except ImportError as _hf_err: # pragma: no cover + + class _RequiresHFSandbox: + """Stub raised when a recent enough ``huggingface_hub`` (with ``Sandbox``) is missing. + + Lets the package import cleanly so unit tests, ``openenv validate``, and the + docs build can run. Actually constructing one of these classes raises a clear + ImportError. ``Sandbox`` ships in ``huggingface_hub>=1.22``. + """ + + _hf_import_error = _hf_err + + def __init__(self, *_args, **_kwargs): + raise ImportError( + "huggingface_hub>=1.22 (with `Sandbox`) is required to use " + "HFSandboxBackend; upgrade via `pip install -U huggingface_hub`. " + f"Original import error: {self._hf_import_error}" + ) + + HFBgJob = HFSandboxBackend = HFSandboxHandle = _RequiresHFSandbox # type: ignore[assignment] + __all__ = [ "BgJob", @@ -50,4 +73,7 @@ def __init__(self, *_args, **_kwargs): "E2BBgJob", "E2BSandboxBackend", "E2BSandboxHandle", + "HFBgJob", + "HFSandboxBackend", + "HFSandboxHandle", ] diff --git a/envs/opencode_env/sandbox/hf.py b/envs/opencode_env/sandbox/hf.py new file mode 100644 index 000000000..2e6001130 --- /dev/null +++ b/envs/opencode_env/sandbox/hf.py @@ -0,0 +1,173 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Hugging Face implementation of :class:`SandboxBackend` (``huggingface_hub.Sandbox``, >=1.22).""" + +from __future__ import annotations + +import time +from pathlib import PurePosixPath + +from huggingface_hub import Sandbox + +from .base import BgJob, ExecResult, SandboxHandle + + +_WAIT_POLL_INTERVAL_S = 0.5 + + +class HFBgJob: + """Satisfies :class:`BgJob`. The SDK has no blocking wait(), so poll ``processes()``.""" + + def __init__(self, sandbox: Sandbox, process) -> None: + self._sandbox = sandbox + self._process = process + + @property + def pid(self) -> int: + return self._process.pid + + def wait(self, timeout: float | None = None) -> int: + deadline = None if timeout is None else time.monotonic() + timeout + while True: + proc = next((p for p in self._sandbox.processes() if p.pid == self.pid), None) + # Finished processes stay listed (running=False); a vanished pid means + # the sandbox was torn down mid-run, not a clean exit. + if proc is None: + raise RuntimeError(f"process {self.pid} vanished (sandbox torn down?)") + if not proc.running: + return int(proc.exit_code) if proc.exit_code is not None else 0 + if deadline is not None: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"Background command did not exit within {timeout}s") + time.sleep(min(_WAIT_POLL_INTERVAL_S, remaining)) + else: + time.sleep(_WAIT_POLL_INTERVAL_S) + + def kill(self) -> None: + try: + self._process.kill() # idempotent server-side + except Exception: + pass + + +class HFSandboxHandle: + """Wraps a live ``huggingface_hub.Sandbox`` to satisfy :class:`SandboxHandle`.""" + + def __init__(self, sandbox: Sandbox) -> None: + self._sbx = sandbox + + @property + def sandbox_id(self) -> str: + return self._sbx.id + + @property + def raw(self) -> Sandbox: + """Escape hatch for callers that need the underlying SDK object.""" + return self._sbx + + def exec( + self, + cmd: str, + *, + envs: dict[str, str] | None = None, + cwd: str | None = None, + timeout: float | None = 60, + ) -> ExecResult: + # check=False: surface non-zero exits as a result instead of raising + result = self._sbx.run( + ["bash", "-lc", cmd], + env=envs, + cwd=cwd, + timeout=timeout, + check=False, + ) + # A timed-out command is killed with exit_code unset; surface it as a + # non-zero exit instead of a false success. The SDK may return None for + # stdout/stderr, so coerce to str (ExecResult / downstream expect str). + if result.timed_out: + return ExecResult( + exit_code=124, + stdout=result.stdout or "", + stderr=(result.stderr or "") + f"\n[timed out after {timeout}s]", + ) + return ExecResult( + exit_code=int(result.exit_code) if result.exit_code is not None else 0, + stdout=result.stdout or "", + stderr=result.stderr or "", + ) + + def start_bg( + self, + cmd: str, + *, + envs: dict[str, str] | None = None, + cwd: str | None = None, + ) -> BgJob: + process = self._sbx.run( + ["bash", "-lc", cmd], + env=envs, + cwd=cwd, + background=True, + ) + return HFBgJob(self._sbx, process) + + def write_text(self, path: str, content: str) -> None: + parent = str(PurePosixPath(path).parent) + if parent not in ("", "/", "."): # "." == relative path with no parent dir to create + self._sbx.files.mkdir(parent) + self._sbx.files.write(path, content) + + def read_text(self, path: str) -> str: + return self._sbx.files.read_text(path) + + def exists(self, path: str) -> bool: + return self._sbx.files.exists(path) + + def kill(self) -> None: + # kill() tears the sandbox down; close() would only drop the client (it keeps idling) + self._sbx.kill() + + +class HFSandboxBackend: + """Creates Hugging Face sandboxes for OpenCode rollouts. + + ``image`` may be a plain base (e.g. ``python:3.12``), in which case opencode + and the proxy deps are cold-installed per rollout, or a pre-baked image that + already ships them. + """ + + def __init__( + self, + *, + image: str, + flavor: str = "cpu-basic", + forward_hf_token: bool = False, + sandbox_kwargs: dict | None = None, + ) -> None: + self._image = image + self._flavor = flavor + self._forward_hf_token = forward_hf_token + self._sandbox_kwargs = sandbox_kwargs or {} + + def create( + self, + *, + timeout_s: int = 900, + envs: dict[str, str] | None = None, + metadata: dict[str, str] | None = None, + ) -> SandboxHandle: + # metadata: accepted for protocol parity; Sandbox.create has no such param + sbx = Sandbox.create( + image=self._image, + flavor=self._flavor, + idle_timeout=timeout_s, + env=envs, + forward_hf_token=self._forward_hf_token, + **self._sandbox_kwargs, + ) + return HFSandboxHandle(sbx) diff --git a/envs/opencode_env/sandbox/hf_image/Dockerfile b/envs/opencode_env/sandbox/hf_image/Dockerfile new file mode 100644 index 000000000..fd6a1d124 --- /dev/null +++ b/envs/opencode_env/sandbox/hf_image/Dockerfile @@ -0,0 +1,30 @@ +# Pre-baked image for HFSandboxBackend: opencode + proxy deps installed ahead of +# time so rollouts skip the cold install. Use with OpenCodeConfig(sandbox_home="/root"). +# +# Build context = envs/opencode_env: +# docker build -f sandbox/hf_image/Dockerfile -t /opencode-rl:latest . +# docker push /opencode-rl:latest + +FROM python:3.12 + +# opencode's installer is fetched with curl; the base image doesn't guarantee it. +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Proxy deps that interception.py imports. +RUN pip install --no-cache-dir "fastapi>=0.104" "uvicorn[standard]>=0.24" "httpx>=0.27" + +RUN curl -fsSL https://opencode.ai/install | bash \ + && /root/.opencode/bin/opencode --version + +# Directory layout the harness expects, plus the pre-copied proxy source. +RUN mkdir -p /root/.config/opencode \ + /root/logs/agent \ + /root/logs/verifier \ + /root/task \ + /root/workdir \ + /root/proxy +COPY sandbox/interception.py /root/proxy/interception.py + +WORKDIR /root/workdir diff --git a/envs/opencode_env/server/gradio_ui.py b/envs/opencode_env/server/gradio_ui.py index 9869237ba..a7a816aa7 100644 --- a/envs/opencode_env/server/gradio_ui.py +++ b/envs/opencode_env/server/gradio_ui.py @@ -144,7 +144,7 @@ def _command_rows(items: list[dict[str, Any]]) -> list[list[str]]: cmd if len(cmd) <= 80 else cmd[:77] + "...", str(it.get("exit_code", "")), f"{it.get('duration_s', 0):.2f}s", - (it.get("stderr") or "").splitlines()[-1][:80] if it.get("exit_code") else "", + next(reversed((it.get("stderr") or "").splitlines()), "")[:80] if it.get("exit_code") else "", ] ) return rows diff --git a/envs/opencode_env/server/opencode_environment.py b/envs/opencode_env/server/opencode_environment.py index 07f0d69ed..2d7831a73 100644 --- a/envs/opencode_env/server/opencode_environment.py +++ b/envs/opencode_env/server/opencode_environment.py @@ -53,6 +53,8 @@ PROXY_LOG = f"{HOME}/logs/agent/proxy.log" AGENT_LOG = f"{HOME}/logs/agent/opencode.jsonl" VERIFY_TIMEOUT_S = 120 +# Setup can pip-install / download, which easily exceeds the verify budget. +SETUP_TIMEOUT_S = 300 class OpenCodeEnvironment(MCPEnvironment): @@ -77,12 +79,18 @@ def __init__(self) -> None: RolloutTurn, ) - from opencode_env import ( - E2BSandboxBackend, - OpenCodeConfig, - OpenCodeSessionFactory, - OpenCodeTask, - ) + # Import the primitive from its defining modules (not the top-level + # package, which re-exports the client) to keep server/client separate. + try: + from ..config import OpenCodeConfig + from ..harness import OpenCodeSessionFactory + from ..sandbox import E2BSandboxBackend + from ..task import OpenCodeTask + except ImportError: # pragma: no cover + from config import OpenCodeConfig # type: ignore + from harness import OpenCodeSessionFactory # type: ignore + from sandbox import E2BSandboxBackend # type: ignore + from task import OpenCodeTask # type: ignore self._CommandResult = CommandResult self._RolloutResult = RolloutResult @@ -152,7 +160,7 @@ def run_rollout( if not (base_url and api_key and model): raise ValueError( "must provide either ``endpoint`` (one of " - f"{ENDPOINT_KINDS}) or all of base_url + api_key + model" + f"{', '.join(ENDPOINT_KINDS)}) or all of base_url + api_key + model" ) if not instruction: raise ValueError("instruction is required") @@ -342,7 +350,7 @@ def _emit(msg: str) -> None: # call. for i, cmd in enumerate(setup, 1): _emit(f"setup [{i}/{len(setup)}]: {cmd[:80]}") - cr = self._exec_command(session.sandbox, cmd) + cr = self._exec_command(session.sandbox, cmd, timeout=SETUP_TIMEOUT_S) result.setup_results.append(cr) if cr.exit_code != 0: result.error = ( @@ -366,23 +374,23 @@ def _emit(msg: str) -> None: result.error = f"agent timeout: {exc}" _emit(f"agent TIMEOUT: {exc}") - # Run verify commands one at a time, capture each. - verify_passed = 0 - for i, cmd in enumerate(verify, 1): - _emit(f"verify [{i}/{len(verify)}]: {cmd[:80]}") - cr = self._exec_command(session.sandbox, cmd) - result.verify_results.append(cr) - if cr.exit_code == 0: - verify_passed += 1 - - # Reward: explicit reward.txt wins; else passed/total of verify. - override = self._read_reward(session.sandbox) - if override is not None: - result.reward = override - elif verify: - result.reward = verify_passed / len(verify) - else: - result.reward = None + # Verify + reward only when the run is clean; a failed setup or + # agent leaves a half-prepared sandbox, so reward stays None. + if result.error is None: + verify_passed = 0 + for i, cmd in enumerate(verify, 1): + _emit(f"verify [{i}/{len(verify)}]: {cmd[:80]}") + cr = self._exec_command(session.sandbox, cmd) + result.verify_results.append(cr) + if cr.exit_code == 0: + verify_passed += 1 + + # Explicit reward.txt wins; else passed/total of verify. + override = self._read_reward(session.sandbox) + if override is not None: + result.reward = override + elif verify: + result.reward = verify_passed / len(verify) # Collect filesystem + proxy trace. _emit("collecting workdir files + proxy trace + logs") @@ -422,10 +430,10 @@ def _emit(msg: str) -> None: # ── Helpers ──────────────────────────────────────────────────────────── - def _exec_command(self, sandbox: Any, cmd: str) -> Any: + def _exec_command(self, sandbox: Any, cmd: str, timeout: int = VERIFY_TIMEOUT_S) -> Any: t = time.time() try: - r = sandbox.exec(cmd, timeout=VERIFY_TIMEOUT_S) + r = sandbox.exec(cmd, timeout=timeout) return self._CommandResult( cmd=cmd, exit_code=int(r.exit_code), diff --git a/envs/opencode_env/uv.lock b/envs/opencode_env/uv.lock index f464713fa..82e3528f9 100644 --- a/envs/opencode_env/uv.lock +++ b/envs/opencode_env/uv.lock @@ -466,14 +466,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.3" +version = "8.4.2" source = { registry = "https://pypi.registries.huggingface.tech/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] @@ -937,34 +937,26 @@ wheels = [ [[package]] name = "hf-xet" -version = "1.4.3" -source = { registry = "https://pypi.registries.huggingface.tech/" } -sdist = { url = "https://files.pythonhosted.org/packages/53/92/ec9ad04d0b5728dca387a45af7bc98fbb0d73b2118759f5f6038b61a57e8/hf_xet-1.4.3.tar.gz", hash = "sha256:8ddedb73c8c08928c793df2f3401ec26f95be7f7e516a7bee2fbb546f6676113", size = 670477, upload-time = "2026-03-31T22:40:07.874Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/43/724d307b34e353da0abd476e02f72f735cdd2bc86082dee1b32ea0bfee1d/hf_xet-1.4.3-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:7551659ba4f1e1074e9623996f28c3873682530aee0a846b7f2f066239228144", size = 3800935, upload-time = "2026-03-31T22:39:49.618Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d2/8bee5996b699262edb87dbb54118d287c0e1b2fc78af7cdc41857ba5e3c4/hf_xet-1.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:bee693ada985e7045997f05f081d0e12c4c08bd7626dc397f8a7c487e6c04f7f", size = 3558942, upload-time = "2026-03-31T22:39:47.938Z" }, - { url = "https://files.pythonhosted.org/packages/c3/a1/e993d09cbe251196fb60812b09a58901c468127b7259d2bf0f68bf6088eb/hf_xet-1.4.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21644b404bb0100fe3857892f752c4d09642586fd988e61501c95bbf44b393a3", size = 4207657, upload-time = "2026-03-31T22:39:39.69Z" }, - { url = "https://files.pythonhosted.org/packages/64/44/9eb6d21e5c34c63e5e399803a6932fa983cabdf47c0ecbcfe7ea97684b8c/hf_xet-1.4.3-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:987f09cfe418237812896a6736b81b1af02a3a6dcb4b4944425c4c4fca7a7cf8", size = 3986765, upload-time = "2026-03-31T22:39:37.936Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7b/8ad6f16fdb82f5f7284a34b5ec48645bd575bdcd2f6f0d1644775909c486/hf_xet-1.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:60cf7fc43a99da0a853345cf86d23738c03983ee5249613a6305d3e57a5dca74", size = 4188162, upload-time = "2026-03-31T22:39:58.382Z" }, - { url = "https://files.pythonhosted.org/packages/1b/c4/39d6e136cbeea9ca5a23aad4b33024319222adbdc059ebcda5fc7d9d5ff4/hf_xet-1.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2815a49a7a59f3e2edf0cf113ae88e8cb2ca2a221bf353fb60c609584f4884d4", size = 4424525, upload-time = "2026-03-31T22:40:00.225Z" }, - { url = "https://files.pythonhosted.org/packages/46/f2/adc32dae6bdbc367853118b9878139ac869419a4ae7ba07185dc31251b76/hf_xet-1.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:42ee323265f1e6a81b0e11094564fb7f7e0ec75b5105ffd91ae63f403a11931b", size = 3671610, upload-time = "2026-03-31T22:40:10.42Z" }, - { url = "https://files.pythonhosted.org/packages/e2/19/25d897dcc3f81953e0c2cde9ec186c7a0fee413eb0c9a7a9130d87d94d3a/hf_xet-1.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:27c976ba60079fb8217f485b9c5c7fcd21c90b0367753805f87cb9f3cdc4418a", size = 3528529, upload-time = "2026-03-31T22:40:09.106Z" }, - { url = "https://files.pythonhosted.org/packages/ec/36/3e8f85ca9fe09b8de2b2e10c63b3b3353d7dda88a0b3d426dffbe7b8313b/hf_xet-1.4.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5251d5ece3a81815bae9abab41cf7ddb7bcb8f56411bce0827f4a3071c92fdc6", size = 3801019, upload-time = "2026-03-31T22:39:56.651Z" }, - { url = "https://files.pythonhosted.org/packages/b5/9c/defb6cb1de28bccb7bd8d95f6e60f72a3d3fa4cb3d0329c26fb9a488bfe7/hf_xet-1.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1feb0f3abeacee143367c326a128a2e2b60868ec12a36c225afb1d6c5a05e6d2", size = 3558746, upload-time = "2026-03-31T22:39:54.766Z" }, - { url = "https://files.pythonhosted.org/packages/c1/bd/8d001191893178ff8e826e46ad5299446e62b93cd164e17b0ffea08832ec/hf_xet-1.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8b301fc150290ca90b4fccd079829b84bb4786747584ae08b94b4577d82fb791", size = 4207692, upload-time = "2026-03-31T22:39:46.246Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/6790b402803250e9936435613d3a78b9aaeee7973439f0918848dde58309/hf_xet-1.4.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d972fbe95ddc0d3c0fc49b31a8a69f47db35c1e3699bf316421705741aab6653", size = 3986281, upload-time = "2026-03-31T22:39:44.648Z" }, - { url = "https://files.pythonhosted.org/packages/51/56/ea62552fe53db652a9099eda600b032d75554d0e86c12a73824bfedef88b/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c5b48db1ee344a805a1b9bd2cda9b6b65fe77ed3787bd6e87ad5521141d317cd", size = 4187414, upload-time = "2026-03-31T22:40:04.951Z" }, - { url = "https://files.pythonhosted.org/packages/7d/f5/bc1456d4638061bea997e6d2db60a1a613d7b200e0755965ec312dc1ef79/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:22bdc1f5fb8b15bf2831440b91d1c9bbceeb7e10c81a12e8d75889996a5c9da8", size = 4424368, upload-time = "2026-03-31T22:40:06.347Z" }, - { url = "https://files.pythonhosted.org/packages/e4/76/ab597bae87e1f06d18d3ecb8ed7f0d3c9a37037fc32ce76233d369273c64/hf_xet-1.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:0392c79b7cf48418cd61478c1a925246cf10639f4cd9d94368d8ca1e8df9ea07", size = 3672280, upload-time = "2026-03-31T22:40:16.401Z" }, - { url = "https://files.pythonhosted.org/packages/62/05/2e462d34e23a09a74d73785dbed71cc5dbad82a72eee2ad60a72a554155d/hf_xet-1.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:681c92a07796325778a79d76c67011764ecc9042a8c3579332b61b63ae512075", size = 3528945, upload-time = "2026-03-31T22:40:14.995Z" }, - { url = "https://files.pythonhosted.org/packages/ac/9f/9c23e4a447b8f83120798f9279d0297a4d1360bdbf59ef49ebec78fe2545/hf_xet-1.4.3-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d0da85329eaf196e03e90b84c2d0aca53bd4573d097a75f99609e80775f98025", size = 3805048, upload-time = "2026-03-31T22:39:53.105Z" }, - { url = "https://files.pythonhosted.org/packages/0b/f8/7aacb8e5f4a7899d39c787b5984e912e6c18b11be136ef13947d7a66d265/hf_xet-1.4.3-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e23717ce4186b265f69afa66e6f0069fe7efbf331546f5c313d00e123dc84583", size = 3562178, upload-time = "2026-03-31T22:39:51.295Z" }, - { url = "https://files.pythonhosted.org/packages/df/9a/a24b26dc8a65f0ecc0fe5be981a19e61e7ca963b85e062c083f3a9100529/hf_xet-1.4.3-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc360b70c815bf340ed56c7b8c63aacf11762a4b099b2fe2c9bd6d6068668c08", size = 4212320, upload-time = "2026-03-31T22:39:42.922Z" }, - { url = "https://files.pythonhosted.org/packages/53/60/46d493db155d2ee2801b71fb1b0fd67696359047fdd8caee2c914cc50c79/hf_xet-1.4.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:39f2d2e9654cd9b4319885733993807aab6de9dfbd34c42f0b78338d6617421f", size = 3991546, upload-time = "2026-03-31T22:39:41.335Z" }, - { url = "https://files.pythonhosted.org/packages/bc/f5/067363e1c96c6b17256910830d1b54099d06287e10f4ec6ec4e7e08371fc/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:49ad8a8cead2b56051aa84d7fce3e1335efe68df3cf6c058f22a65513885baac", size = 4193200, upload-time = "2026-03-31T22:40:01.936Z" }, - { url = "https://files.pythonhosted.org/packages/42/4b/53951592882d9c23080c7644542fda34a3813104e9e11fa1a7d82d419cb8/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7716d62015477a70ea272d2d68cd7cad140f61c52ee452e133e139abfe2c17ba", size = 4429392, upload-time = "2026-03-31T22:40:03.492Z" }, - { url = "https://files.pythonhosted.org/packages/8a/21/75a6c175b4e79662ad8e62f46a40ce341d8d6b206b06b4320d07d55b188c/hf_xet-1.4.3-cp37-abi3-win_amd64.whl", hash = "sha256:6b591fcad34e272a5b02607485e4f2a1334aebf1bc6d16ce8eb1eb8978ac2021", size = 3677359, upload-time = "2026-03-31T22:40:13.619Z" }, - { url = "https://files.pythonhosted.org/packages/8a/7c/44314ecd0e89f8b2b51c9d9e5e7a60a9c1c82024ac471d415860557d3cd8/hf_xet-1.4.3-cp37-abi3-win_arm64.whl", hash = "sha256:7c2c7e20bcfcc946dc67187c203463f5e932e395845d098cc2a93f5b67ca0b47", size = 3533664, upload-time = "2026-03-31T22:40:12.152Z" }, +version = "1.5.2" +source = { registry = "https://pypi.registries.huggingface.tech/" } +sdist = { url = "https://files.pythonhosted.org/packages/63/39/67be8d71f900d9a55761b6022821d6679fb56c64f1b6063d5af2c2606727/hf_xet-1.5.2.tar.gz", hash = "sha256:73044bd31bae33c984af832d19c752a0dffb67518fee9ddbd91d616e1101cf47", size = 903674, upload-time = "2026-07-16T17:29:56.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/be/525eabac5d1736b679c39e342ecd4292534012546a2d18f0043c8e3b6021/hf_xet-1.5.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4a5ecb9cda8512ba2aa8ee5d37c87a1422992165892d653098c7b90247481c3b", size = 4064284, upload-time = "2026-07-16T17:29:29.907Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3f/699749dd78442480eda4e4fca494284b0e3542e4063cc37654d5fdc929e6/hf_xet-1.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8764488197c1d7b1378c8438c18d2eea902e150dbca0b0f0d2d32603fb9b5576", size = 3828537, upload-time = "2026-07-16T17:29:31.549Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/2658ac0a5b9f4664ca27ce31bd015044fe9dea50ed455fb5197aba819c11/hf_xet-1.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d7446f72abbf7e01ca5ff131786bc2e74a56393462c17a6bf1e303fbab81db4", size = 4417133, upload-time = "2026-07-16T17:29:33.391Z" }, + { url = "https://files.pythonhosted.org/packages/d9/58/8343f3cb63c8fa058d576136df3871550f7d5214a8f048a7ea2eab6ac906/hf_xet-1.5.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:580e59e29bf37aece1f2b68537de1e3fb04f43a23d910dcf6f128280b5bfbba4", size = 4212613, upload-time = "2026-07-16T17:29:34.989Z" }, + { url = "https://files.pythonhosted.org/packages/0c/33/a968f4e4535037b36941ec00714625fb60e026302407e7e26ca9f3e65f4e/hf_xet-1.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bee28c619622d36968056532fd49cf2b35ca75099b1d616c31a618a893491380", size = 4412710, upload-time = "2026-07-16T17:29:36.646Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/9e33981173dbaf194ba0015202b02d467b624d44d4eba89e1bf06c0d2995/hf_xet-1.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e396ab0faf6298199ad7a95305c3ca8498cb825978a6485be6d00587ee4ec577", size = 4628455, upload-time = "2026-07-16T17:29:38.352Z" }, + { url = "https://files.pythonhosted.org/packages/e9/4b/cc682832de4264a03880a2d1b5ec3e1fab3bf307f508817250baafdb9996/hf_xet-1.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fd3add255549e8ef58fa35b2e42dc016961c050600444e7d77d030ba6b57120e", size = 3979044, upload-time = "2026-07-16T17:29:40.329Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/b2cdf2a0fb39a08af3222b96092a36bd3b40c54123eef07de4422e870971/hf_xet-1.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d6f9c58549407b84b9a5383afd68db0acc42345326a3159990b36a5ca8a20e4e", size = 3808037, upload-time = "2026-07-16T17:29:42.357Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/2b70603c7552db82baeb2623e2336898304a17328845151be4fe1f48d420/hf_xet-1.5.2-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f922b8f5fb84f1dd3d7ab7a1316354a1bca9b1c73ecfc19c76e51a2a49d29799", size = 4033760, upload-time = "2026-07-16T17:29:43.884Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/b097a86a1e4a6098f3a79382643ab09d5733d87ccc864877ad1e12b49b70/hf_xet-1.5.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:045f84440c55cdeb659cf1a1dd48c77bcd0d2e93632e2fea8f2c3bdee79f38ed", size = 3841438, upload-time = "2026-07-16T17:29:45.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/35/db860aa3a0780660324a506ad4b3d322ddc6ecbba4b9340aed0942cbf21c/hf_xet-1.5.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db78c39c83d6279daddc98e2238f373ab8980685556d42472b4ec51abcf03e8c", size = 4428006, upload-time = "2026-07-16T17:29:46.996Z" }, + { url = "https://files.pythonhosted.org/packages/af/6b/832dd980af4b0c3ae0660e309285f2ffcdff2faa38129390dbb47aa4a3f9/hf_xet-1.5.2-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7db73c810500c54c6760be8c39d4b2e476974de85424c50063efc22fdda13025", size = 4221099, upload-time = "2026-07-16T17:29:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/9e/05/ae50f0d34e3254e6c3e208beb2519f6b8673016fc4b3643badaf6450d186/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6395cfe3c9cbead4f16b31808b0e67eac428b66c656f856e99636adaddea878f", size = 4420766, upload-time = "2026-07-16T17:29:50.092Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/c050bc2743a2bcd68928bfee157b08681667a164a24ec95fbfcfcd717e08/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cde8cd167126bb6109b2ceb19b844433a4988643e8f3e01dd9dd0e4a34535097", size = 4636716, upload-time = "2026-07-16T17:29:51.62Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/68b01c5c2edb56ac9a67b3d076ffddcb90867abaee923923eb34e7a14e76/hf_xet-1.5.2-cp38-abi3-win_amd64.whl", hash = "sha256:ecf63d1cb69a9a7319910f8f83fcf9b46e7a32dfcf4b8f8eeddb55f647306e65", size = 3988373, upload-time = "2026-07-16T17:29:53.395Z" }, + { url = "https://files.pythonhosted.org/packages/39/c6/988383e9dc17294d536fcbcd6fd16eed882e411ad16c954984a53e47b09c/hf_xet-1.5.2-cp38-abi3-win_arm64.whl", hash = "sha256:1da28519496eb7c8094c11e4d25509b4a468457a0302d58136099db2fd9a671d", size = 3816957, upload-time = "2026-07-16T17:29:54.991Z" }, ] [[package]] @@ -1049,9 +1041,10 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.12.2" +version = "1.24.0" source = { registry = "https://pypi.registries.huggingface.tech/" } dependencies = [ + { name = "click" }, { name = "filelock" }, { name = "fsspec" }, { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, @@ -1059,12 +1052,11 @@ dependencies = [ { name = "packaging" }, { name = "pyyaml" }, { name = "tqdm" }, - { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3e/9f/3fda8b014db3ae239addc9b48b35c2cf7d318950b430712f34a2473ef81d/huggingface_hub-1.12.2.tar.gz", hash = "sha256:282c4999e641c89affdc4c02c265eddea944c1390dc19e89dac8ad3ae76dbdaf", size = 763393, upload-time = "2026-04-29T09:45:09.202Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/9b/d3bb4e7d792835daf34dd7091bbc7d7b4e0437d9388f1ea7239cce49f478/huggingface_hub-1.24.0.tar.gz", hash = "sha256:18431ff4daae0749aa9ba102fc952e314c98e1d30ebdec5319d85ca0a83e1ae5", size = 921848, upload-time = "2026-07-17T09:54:01.022Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/c1/1fa4162f6dd53259daf2ad31385273341821fa0acce164cd03971937a60e/huggingface_hub-1.12.2-py3-none-any.whl", hash = "sha256:7968e897fdbc6343c871c240d87d4434efe0ad9f80d57daa1cc5678c6d148529", size = 647757, upload-time = "2026-04-29T09:45:07.63Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c3/aeaaf3911d2529614be18d1c8b5496afc185560e76568063d517283318af/huggingface_hub-1.24.0-py3-none-any.whl", hash = "sha256:6ed4120a84a6beec900640aa7e346bd766a6b7341e41526fef5dc8bd81fb7d59", size = 771904, upload-time = "2026-07-17T09:53:59.106Z" }, ] [[package]] @@ -1699,6 +1691,7 @@ dependencies = [ { name = "fastmcp" }, { name = "gradio" }, { name = "httpx" }, + { name = "huggingface-hub" }, { name = "openenv" }, { name = "pydantic" }, { name = "requests" }, @@ -1719,6 +1712,7 @@ requires-dist = [ { name = "fastmcp", specifier = ">=2.0.0" }, { name = "gradio", specifier = ">=6.0.0" }, { name = "httpx", specifier = ">=0.27.0" }, + { name = "huggingface-hub", specifier = ">=1.22" }, { name = "openenv", specifier = ">=0.3.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, diff --git a/tests/envs/test_opencode_factory_lifecycle.py b/tests/envs/test_opencode_factory_lifecycle.py new file mode 100644 index 000000000..edae965c3 --- /dev/null +++ b/tests/envs/test_opencode_factory_lifecycle.py @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: BSD-3-Clause + +"""OpenCodeSessionFactory.create() must tear the sandbox down on any post-provision failure.""" + +from __future__ import annotations + +import os +import sys + +import pytest + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +for _p in (_REPO_ROOT, os.path.join(_REPO_ROOT, "envs")): + if _p not in sys.path: + sys.path.insert(0, _p) + +from opencode_env.config import OpenCodeConfig # noqa: E402 +from opencode_env.harness import OpenCodeSessionFactory # noqa: E402 + + +class _FakeSandbox: + def __init__(self): + self.sandbox_id = "fake" + self.killed = False + + def kill(self): + self.killed = True + + +class _FakeBackend: + def __init__(self, sandbox): + self._sandbox = sandbox + + def create(self, **kwargs): + return self._sandbox + + +def _factory(sandbox): + return OpenCodeSessionFactory( + config=OpenCodeConfig(base_url="http://localhost:8000/v1"), + sandbox_backend=_FakeBackend(sandbox), + ) + + +def test_create_kills_sandbox_when_bootstrap_fails(monkeypatch): + sandbox = _FakeSandbox() + factory = _factory(sandbox) + monkeypatch.setattr( + factory, + "_bootstrap_sandbox", + lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")), + ) + with pytest.raises(RuntimeError, match="boom"): + factory.create("write a function") + assert sandbox.killed is True + + +def test_create_preserves_root_cause_if_kill_also_fails(monkeypatch): + class _KillRaises(_FakeSandbox): + def kill(self): + raise RuntimeError("kill failed") + + sandbox = _KillRaises() + factory = _factory(sandbox) + monkeypatch.setattr( + factory, + "_bootstrap_sandbox", + lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")), + ) + # The original bootstrap failure must surface, not the cleanup error. + with pytest.raises(RuntimeError, match="boom"): + factory.create("write a function") diff --git a/tests/envs/test_opencode_hf_sandbox.py b/tests/envs/test_opencode_hf_sandbox.py new file mode 100644 index 000000000..ef09d425c --- /dev/null +++ b/tests/envs/test_opencode_hf_sandbox.py @@ -0,0 +1,276 @@ +# SPDX-License-Identifier: BSD-3-Clause + +"""Unit tests for ``HFBgJob.wait()`` polling. + +Pure logic, no network and no real Hugging Face sandbox: ``HFBgJob`` takes a +sandbox object and a process object, so both are faked here. ``hf.py`` does +``from huggingface_hub import Sandbox`` at import time; if this ``huggingface_hub`` +predates ``Sandbox`` (added in ``>=1.22``) we stub the attribute so the pure +``HFBgJob`` logic is still testable. +""" + +from __future__ import annotations + +import os +import sys + +import pytest + +# Make ``envs/`` importable when running from the repository root. +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +for _p in (_REPO_ROOT, os.path.join(_REPO_ROOT, "envs")): + if _p not in sys.path: + sys.path.insert(0, _p) + +import huggingface_hub # noqa: E402 + +if not hasattr(huggingface_hub, "Sandbox"): # pragma: no cover - only on old hub + huggingface_hub.Sandbox = type("Sandbox", (), {}) # type: ignore[attr-defined] + +import opencode_env.sandbox.hf as hf_mod # noqa: E402 +from opencode_env.sandbox.hf import HFBgJob # noqa: E402 + + +class _FakeProcess: + def __init__( + self, pid: int, running: bool = True, exit_code: int | None = None + ) -> None: + self.pid = pid + self.running = running + self.exit_code = exit_code + self.killed = False + + def kill(self) -> None: + self.killed = True + + +class _FakeSandbox: + """``.processes()`` returns the current snapshot list (a method, matching the real SDK).""" + + def __init__(self, procs: list) -> None: + self._procs = procs + + def processes(self) -> list: + return list(self._procs) + + +class _FlippingSandbox: + """Reports the process as running for ``flip_after`` polls, then exited.""" + + def __init__(self, proc: _FakeProcess, *, flip_after: int, exit_code: int) -> None: + self._proc = proc + self._flip_after = flip_after + self._exit_code = exit_code + self.poll_count = 0 + + def processes(self) -> list: + self.poll_count += 1 + if self.poll_count >= self._flip_after: + self._proc.running = False + self._proc.exit_code = self._exit_code + return [self._proc] + + +@pytest.fixture(autouse=True) +def _instant_poll(monkeypatch): + # Keep the polling loop instant so timeout tests stay sub-second. + monkeypatch.setattr(hf_mod, "_WAIT_POLL_INTERVAL_S", 0.0) + + +def test_pid_property(): + proc = _FakeProcess(pid=42) + assert HFBgJob(_FakeSandbox([proc]), proc).pid == 42 + + +def test_wait_returns_zero_when_already_exited(): + proc = _FakeProcess(pid=7, running=False, exit_code=0) + assert HFBgJob(_FakeSandbox([proc]), proc).wait(timeout=1.0) == 0 + + +def test_wait_returns_nonzero_exit_code(): + proc = _FakeProcess(pid=7, running=False, exit_code=3) + assert HFBgJob(_FakeSandbox([proc]), proc).wait(timeout=1.0) == 3 + + +def test_wait_returns_zero_when_exit_code_unknown(): + # running=False but exit_code never populated -> treated as 0. + proc = _FakeProcess(pid=7, running=False, exit_code=None) + assert HFBgJob(_FakeSandbox([proc]), proc).wait(timeout=1.0) == 0 + + +def test_wait_polls_until_process_exits(): + proc = _FakeProcess(pid=7, running=True) + sbx = _FlippingSandbox(proc, flip_after=3, exit_code=5) + assert HFBgJob(sbx, proc).wait(timeout=1.0) == 5 + assert sbx.poll_count >= 3 # it actually polled, didn't short-circuit + + +def test_wait_raises_when_pid_disappears(): + # A vanished pid means the sandbox was torn down mid-run, not a clean exit. + proc = _FakeProcess(pid=7, running=True) + with pytest.raises(RuntimeError, match="vanished"): + HFBgJob(_FakeSandbox([]), proc).wait(timeout=1.0) + + +def test_wait_raises_timeout_when_never_exits(): + proc = _FakeProcess(pid=7, running=True) + job = HFBgJob(_FakeSandbox([proc]), proc) # always running + with pytest.raises(TimeoutError): + job.wait(timeout=0.02) + + +def test_wait_none_timeout_polls_without_deadline(monkeypatch): + # timeout=None must not raise; it should keep polling until exit. + proc = _FakeProcess(pid=7, running=True) + sbx = _FlippingSandbox(proc, flip_after=2, exit_code=0) + assert HFBgJob(sbx, proc).wait(timeout=None) == 0 + + +def test_kill_calls_through(): + proc = _FakeProcess(pid=7) + HFBgJob(_FakeSandbox([proc]), proc).kill() + assert proc.killed is True + + +def test_kill_swallows_errors(): + class _RaisingProc(_FakeProcess): + def kill(self) -> None: + raise RuntimeError("boom") + + proc = _RaisingProc(pid=7) + HFBgJob(_FakeSandbox([proc]), proc).kill() # must not raise + + +# -------------------------------------------------------------------------- +# HFSandboxHandle / HFSandboxBackend +# -------------------------------------------------------------------------- + +from opencode_env.sandbox.hf import HFSandboxBackend, HFSandboxHandle # noqa: E402 + + +class _FakeCmdResult: + def __init__(self, exit_code, stdout="", stderr="", timed_out=False): + self.exit_code = exit_code + self.stdout = stdout + self.stderr = stderr + self.timed_out = timed_out + + +class _FakeFiles: + def __init__(self): + self.written = {} + self.made = [] + + def mkdir(self, path): + self.made.append(path) + + def write(self, path, content): + self.written[path] = content + + def read_text(self, path): + return self.written[path] + + def exists(self, path): + return path in self.written + + +class _FakeRealSandbox: + """Stands in for ``huggingface_hub.Sandbox`` (run/files/processes/kill/id).""" + + def __init__(self, result=None): + self.id = "sbx-123" + self.files = _FakeFiles() + self.killed = False + self.calls = [] + self._result = result or _FakeCmdResult(0, stdout="ok") + + def run(self, argv, env=None, cwd=None, timeout=None, check=None, background=False): + self.calls.append( + {"argv": argv, "env": env, "cwd": cwd, "background": background} + ) + if background: + return _FakeProcess(pid=101) + return self._result + + def processes(self): + return [] + + def kill(self): + self.killed = True + + +def test_exec_maps_result(): + sbx = _FakeRealSandbox(_FakeCmdResult(0, stdout="hi", stderr="")) + r = HFSandboxHandle(sbx).exec("echo hi", timeout=5) + assert (r.exit_code, r.stdout) == (0, "hi") + assert sbx.calls[0]["argv"] == ["bash", "-lc", "echo hi"] + + +def test_exec_timeout_surfaces_as_nonzero(): + sbx = _FakeRealSandbox(_FakeCmdResult(None, timed_out=True)) + r = HFSandboxHandle(sbx).exec("sleep 99", timeout=5) + assert r.exit_code == 124 + assert "timed out" in r.stderr + + +def test_exec_none_exit_code_defaults_zero(): + r = HFSandboxHandle(_FakeRealSandbox(_FakeCmdResult(None))).exec("x") + assert r.exit_code == 0 + + +def test_start_bg_returns_bgjob(): + job = HFSandboxHandle(_FakeRealSandbox()).start_bg("sleep 1") + assert isinstance(job, HFBgJob) + assert job.pid == 101 + + +def test_write_text_creates_parent_then_writes(): + sbx = _FakeRealSandbox() + HFSandboxHandle(sbx).write_text("/root/a/b.txt", "data") + assert sbx.files.made == ["/root/a"] + assert sbx.files.written["/root/a/b.txt"] == "data" + + +def test_write_text_skips_mkdir_for_rootless_paths(): + sbx = _FakeRealSandbox() + HFSandboxHandle(sbx).write_text("file.txt", "x") # parent == "." + assert sbx.files.made == [] + + +def test_read_text_and_exists(): + h = HFSandboxHandle(_FakeRealSandbox()) + h.write_text("/root/f", "v") + assert h.read_text("/root/f") == "v" + assert h.exists("/root/f") is True + assert h.exists("/root/missing") is False + + +def test_kill_tears_down_sandbox(): + sbx = _FakeRealSandbox() + HFSandboxHandle(sbx).kill() + assert sbx.killed is True + + +def test_sandbox_id_uses_dot_id(): + assert HFSandboxHandle(_FakeRealSandbox()).sandbox_id == "sbx-123" + + +def test_backend_create_forwards_kwargs(monkeypatch): + captured = {} + + def _fake_create(**kwargs): + captured.update(kwargs) + return _FakeRealSandbox() + + monkeypatch.setattr( + hf_mod.Sandbox, "create", staticmethod(_fake_create), raising=False + ) + backend = HFSandboxBackend( + image="python:3.12", flavor="cpu-basic", forward_hf_token=True + ) + handle = backend.create(timeout_s=600) + assert isinstance(handle, HFSandboxHandle) + assert captured["image"] == "python:3.12" + assert captured["flavor"] == "cpu-basic" + assert captured["idle_timeout"] == 600 + assert captured["forward_hf_token"] is True diff --git a/tests/envs/test_opencode_sandbox_home.py b/tests/envs/test_opencode_sandbox_home.py new file mode 100644 index 000000000..73e38319e --- /dev/null +++ b/tests/envs/test_opencode_sandbox_home.py @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: BSD-3-Clause + +"""The opencode_runtime path helpers derive from ``config.sandbox_home``. + +Regression test for the fix that lets root-based sandbox backends (HF, Docker) work: +the proxy/opencode paths must follow ``sandbox_home`` instead of a hardcoded ``/home/user``. +""" + +from __future__ import annotations + +import os +import sys + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +for _p in (_REPO_ROOT, os.path.join(_REPO_ROOT, "envs")): + if _p not in sys.path: + sys.path.insert(0, _p) + +from opencode_env.config import OpenCodeConfig # noqa: E402 +from opencode_env.opencode_runtime import ( # noqa: E402 + agent_log_path, + opencode_bin_path, + opencode_config_path, + proxy_dir, + proxy_log_path, + proxy_source_path, + proxy_trace_path, + workdir_path, +) + + +def _cfg(sandbox_home=None): + kwargs = {"base_url": "http://localhost:8000/v1"} + if sandbox_home is not None: + kwargs["sandbox_home"] = sandbox_home + return OpenCodeConfig(**kwargs) + + +def test_default_home_is_home_user(): + c = _cfg() # default sandbox_home == /home/user (E2B) + assert proxy_trace_path(c) == "/home/user/logs/agent/proxy_trace.jsonl" + assert proxy_log_path(c) == "/home/user/logs/agent/proxy.log" + assert proxy_dir(c) == "/home/user/proxy" + assert proxy_source_path(c) == "/home/user/proxy/interception.py" + assert opencode_bin_path(c) == "/home/user/.opencode/bin/opencode" + + +def test_root_sandbox_home(): + c = _cfg("/root") # HF sandbox / Docker exec as root + assert proxy_trace_path(c) == "/root/logs/agent/proxy_trace.jsonl" + assert proxy_log_path(c) == "/root/logs/agent/proxy.log" + assert proxy_dir(c) == "/root/proxy" + assert proxy_source_path(c) == "/root/proxy/interception.py" + assert opencode_bin_path(c) == "/root/.opencode/bin/opencode" + + +def test_every_path_helper_sits_under_sandbox_home(): + c = _cfg("/custom/home") + for path in ( + proxy_trace_path(c), + proxy_log_path(c), + proxy_dir(c), + proxy_source_path(c), + opencode_bin_path(c), + agent_log_path(c), + opencode_config_path(c), + workdir_path(c), + ): + assert path.startswith("/custom/home/"), path