Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a5af5cb
Add HFSandboxBackend for the OpenCode harness
sergiopaniego Jul 21, 2026
451480a
Honor config.sandbox_home for proxy/opencode paths
sergiopaniego Jul 21, 2026
70a061f
Tear down sandbox on create() failure; HF backend docs + extra
sergiopaniego Jul 21, 2026
0702e0f
Make huggingface_hub>=1.22 a plain dependency (drop [hf] extra)
sergiopaniego Jul 21, 2026
8cf9941
Trim internal/comparative comments to code-explaining ones
sergiopaniego Jul 21, 2026
d749de5
Fix CI: ruff-format the test, sync opencode env docs stub
sergiopaniego Jul 21, 2026
764e02e
Fix Dockerfile COPY path (interception.py lives under sandbox/)
sergiopaniego Jul 21, 2026
97c8ce0
Update opencode_env uv.lock for huggingface_hub>=1.22
sergiopaniego Jul 21, 2026
00f5485
Test that opencode path helpers derive from config.sandbox_home
sergiopaniego Jul 22, 2026
4e26cb6
Address review: best-effort cleanup, relative write_text, Dockerfile …
sergiopaniego Jul 22, 2026
f9fb122
Merge branch 'main' into opencode-hf-sandbox-backend
sergiopaniego Jul 22, 2026
2eab5d7
Build the pre-baked opencode-sandbox image in CI (ghcr) + document it
sergiopaniego Jul 22, 2026
114f419
Keep server/client separation in opencode_env server
sergiopaniego Jul 22, 2026
86bd067
Include E2B backend import in the standalone-import fallback
sergiopaniego Jul 22, 2026
968485d
Clarify HFSandboxBackend image docstring (cold-install supported)
sergiopaniego Jul 22, 2026
acf3143
Fix HF sandbox false-success on timeout and torn-down sandbox; cover …
sergiopaniego Jul 22, 2026
880ce5e
Fix mode docstring: transparent_proxy is not the default (black_box is)
sergiopaniego Jul 22, 2026
75f8824
Address review: skip verify/reward on failed setup; pipefail proxy-de…
sergiopaniego Jul 22, 2026
1c46658
Longer setup timeout than verify; coerce SDK None stdout/stderr to str
sergiopaniego Jul 22, 2026
edfab8a
Merge remote-tracking branch 'origin/main' into pr-998-hf-sandbox
burtenshaw Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions docs/source/environments/opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions envs/opencode_env/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 63 additions & 60 deletions envs/opencode_env/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down Expand Up @@ -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:
Comment on lines +244 to 245

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

black_box is the intended default (safe minimal path; the GRPO/training path passes transparent_proxy explicitly). The misleading (default) annotation on transparent_proxy in the module docstring was removed in 880ce5e8.

self._bootstrap_sandbox(sandbox, oc_task)
Comment on lines +244 to 246

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 4e26cb65tests/envs/test_opencode_factory_lifecycle.py asserts create() kills the sandbox when setup fails, and that the root cause survives if kill() also raises.

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
Comment on lines 288 to 294

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4e26cb65 — the cleanup sandbox.kill() is now wrapped in try/except, so a cleanup failure can't mask the original setup exception.


# ------------------------------------------------------------------
def _wait_for_sandbox_ready(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -440,9 +445,7 @@ 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).
Expand All @@ -457,18 +460,18 @@ 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",
"interception.py",
"--upstream-url",
self._config.base_url,
"--trace",
_PROXY_TRACE_PATH,
proxy_trace_path(self._config),
"--port",
str(_PROXY_PORT),
"--top-logprobs",
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -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__ = [
Expand Down
20 changes: 20 additions & 0 deletions envs/opencode_env/opencode_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions envs/opencode_env/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
cursor[bot] marked this conversation as resolved.
]

[project.optional-dependencies]
Expand Down
26 changes: 26 additions & 0 deletions envs/opencode_env/sandbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -50,4 +73,7 @@ def __init__(self, *_args, **_kwargs):
"E2BBgJob",
"E2BSandboxBackend",
"E2BSandboxHandle",
"HFBgJob",
"HFSandboxBackend",
"HFSandboxHandle",
]
Loading
Loading