Skip to content

envs/opencode_env: OpenCode coding-agent harness primitive#603

Merged
burtenshaw merged 11 commits into
huggingface:feature/harness-interfacefrom
adithya-s-k:opencode-harness
May 6, 2026
Merged

envs/opencode_env: OpenCode coding-agent harness primitive#603
burtenshaw merged 11 commits into
huggingface:feature/harness-interfacefrom
adithya-s-k:opencode-harness

Conversation

@adithya-s-k

Copy link
Copy Markdown
Collaborator

Stacked on #471.

Adds envs/opencode_env/, a harness primitive that runs the OpenCode
coding agent inside a sandbox against an OpenAI-compatible LLM endpoint
and exposes it via the ResourceSessionFactory / ResourceSession
contracts from openenv.core.harness.

Functionality

Session + factory

  • OpenCodeSession(ResourceSession) implements:
    • initial_messages() -> list[Message] — returns the task instruction
      as the single user message.
    • list_tools() -> list[Tool] — returns []. OpenCode owns its own
      tool loop internally; no tools are exposed to the harness.
    • call_tool(name, arguments) -> ToolResult — returns an error
      ToolResult; direct tool invocation is not supported (by design).
    • verify(transcript, final_state=None) -> VerifyResult — delegates
      to an optional user-supplied verifier callable; defaults to
      VerifyResult(env_reward=None, done=True).
    • close() — kills any background jobs and the sandbox.
  • OpenCodeSessionFactory(ResourceSessionFactory) implements:
    • create(task, seed=None, episode_id=None) -> OpenCodeSession
      creates a sandbox, bootstraps it, optionally starts the proxy,
      optionally starts opencode serve, fires the task, returns the
      session.

Session also exposes agentic-runtime methods (not part of the ABC):
start_agent(), wait_for_completion(timeout_s), abort(),
fetch_trace(), fetch_proxy_trace().

Two drivers

  • driver=\"cli\" — runs opencode run --format json as a background
    shell job. Completion is detected by subprocess exit.
  • driver=\"serve\" — runs opencode serve --port 4096 --hostname 0.0.0.0 as a background job, exposes the port via
    sandbox.get_host(4096), creates a session through POST /session,
    fires the instruction via POST /session/:id/prompt_async.
    Completion is detected by polling GET /session/status for
    {\"type\": \"idle\"}.

Two modes

  • mode=\"black_box\" — opencode talks directly to the caller-supplied
    LLM endpoint.
  • mode=\"transparent_proxy\" — a FastAPI/uvicorn proxy runs on
    localhost:7000 inside the sandbox. The primitive rewrites
    opencode.json so the agent talks to the proxy. The proxy:
    • Forces logprobs=true and top_logprobs=N on every forwarded
      chat-completions request.
    • Caps max_tokens / max_completion_tokens to
      proxy_max_tokens_cap.
    • Injects chat_template_kwargs.enable_thinking when
      proxy_disable_thinking is set.
    • Optionally overrides the forwarded model field.
    • Assembles streaming response chunks (content, tool calls,
      per-token logprobs, reasoning) into a per-turn record and writes
      it to proxy_trace.jsonl.

Interception proxy details

  • Streams responses to opencode unmodified while simultaneously
    accumulating per-turn state.
  • Preserves delta.reasoning from streamed chunks and reassembles it
    as message.reasoning on the final response.
  • Emits one JSON-line per LLM turn: {turn_index, request_body, response_body, completion_tokens, per_token_logps, finish_reason, reasoning, latency_s, timestamp}.

Configuration

  • OpenCodeConfig (Pydantic) fields:
    • LLM endpoint: provider, base_url, api_key, model,
      request_timeout_ms.
    • Opencode CLI: opencode_version, disabled_tools, enabled_tools,
      system_prompt, extra_opencode_json.
    • CLI invocation: run_format, agent_timeout_s, extra_env,
      extra_setup_shell.
    • Sandbox paths: sandbox_home.
    • Proxy tuning: proxy_max_tokens_cap, proxy_top_logprobs,
      proxy_disable_thinking.
  • opencode_runtime.py — pure builder functions called during
    bootstrap: build_opencode_json, build_install_cmd,
    build_run_cmd, build_env_vars, plus path helpers
    (opencode_config_path, instruction_path, agent_log_path,
    system_prompt_path, verifier_reward_path, workdir_path).
  • opencode_client.pyOpenCodeServerClient, a typed httpx wrapper
    over the opencode serve HTTP API: create_session, send_message
    (sync), send_prompt_async, list_messages, get_all_status,
    abort, stream_events (sync and async SSE).

Sandbox abstraction

  • sandbox/base.py defines a SandboxBackend Protocol (create,
    exec, start_bg, write_text, read_text, get_host, kill)
    plus dataclasses (SandboxHandle, BgJob, ExecResult).
  • sandbox/e2b.py provides E2BSandboxBackend, a thin wrapper over
    the E2B Code Interpreter SDK.
  • Any backend implementing the Protocol can be passed to
    OpenCodeSessionFactory(backend=...).

Task payload

  • OpenCodeTask(instruction, setup_shell=None, upload_files={}, metadata={}) is the payload shape factory.create(task) accepts.
  • OpenCodeTask.coerce(value) accepts a bare string, a dict, or an
    existing OpenCodeTask.

Public API

from openenv.envs.opencode_env import (
    OpenCodeConfig,
    OpenCodeSession,
    OpenCodeSessionFactory,
    OpenCodeTask,
    Provider,
    SandboxBackend,
    SandboxHandle,
    E2BSandboxBackend,
    RolloutSummary,
    collect_rollout_summary,
    print_rollout_summary,
)

Tests

envs/opencode_env/tests/, ~40 tests, all runnable offline (no
network, no external secrets):

  • test_config.py — pydantic validation.
  • test_task.pyOpenCodeTask.coerce variants.
  • test_opencode_runtime.py — builder outputs for opencode.json,
    install/run commands, env vars, path helpers.
  • test_interception.py — request/response transformations against a
    fake upstream, streaming-chunk assembly, reasoning preservation.
  • test_opencode_client.py — serve-API client against mocked HTTP.
  • test_sandbox_base.py — Protocol conformance and E2B-key required
    check.
  • test_harness.py — end-to-end factory lifecycle against a mock
    sandbox and mock OpenAI endpoint.

File layout

envs/opencode_env/
├── __init__.py
├── README.md
├── pyproject.toml
├── harness.py
├── config.py
├── opencode_runtime.py
├── opencode_client.py
├── interception.py
├── live_watch.py
├── task.py
├── sandbox/
│   ├── __init__.py
│   ├── base.py
│   └── e2b.py
└── tests/
    ├── test_config.py
    ├── test_task.py
    ├── test_opencode_runtime.py
    ├── test_interception.py
    ├── test_opencode_client.py
    ├── test_sandbox_base.py
    └── test_harness.py

Test plan

  • pytest envs/opencode_env/tests/ -v passes.

Harness primitive for running the OpenCode CLI agent in a sandbox against
any OpenAI-compatible endpoint. Stacked on the PR huggingface#471 harness-runtime
branch; implements the ResourceSession / ResourceSessionFactory contracts.

Modes:
- black_box: opencode talks directly to the configured endpoint. Verified
  end-to-end against real OpenAI (gpt-4o-mini) inside an E2B sandbox; the
  agent produces a correct fizzbuzz and the verifier scores reward=1.0.
- transparent_proxy: a per-session FastAPI proxy runs inside the sandbox,
  forwards /v1/chat/completions to the configured upstream with
  logprobs=true injected, captures per-turn (messages, completion tokens,
  logprobs, finish reason) to a JSON-lines trace, and strips logprobs
  from what opencode sees. Handles both unary and streaming (SSE). Caps
  max_tokens and auto-translates to max_completion_tokens for gpt-5.x/o*
  models.

Components:
- config.py: OpenCodeConfig (generic provider/base_url/api_key/model
  fields supporting OpenAI, Anthropic, and OpenAI-compatible endpoints;
  proxy tuning knobs; sandbox_home override for non-E2B backends).
- opencode_runtime.py: pure builders for opencode.json, install/run
  shell commands, and env vars.
- task.py: OpenCodeTask pydantic model (instruction + optional
  setup_shell + file uploads + opaque metadata); coerces from str/dict.
- sandbox/base.py: SandboxBackend / SandboxHandle / BgJob Protocols.
- sandbox/e2b.py: E2BSandboxBackend with a threaded BgJob wrapper that
  provides timeout support over E2B's CommandHandle.
- harness.py: OpenCodeSession + OpenCodeSessionFactory; in Mode B,
  installs proxy deps, uploads the proxy module, starts it as a bg job
  on localhost:7000, rewrites opencode.json to point at the proxy, and
  forces @ai-sdk/openai-compatible so routing goes through
  /v1/chat/completions.
- interception.py: InterceptionProxy (FastAPI, unary + streaming),
  per-turn trace capture, CLI entry point for sandbox-side execution.

Tests: 37 unit tests plus 3 live integration tests gated on
E2B_API_KEY / OPENAI_API_KEY.

Known limitation: OpenAI's gpt-5.x chat family refuses logprob requests,
so Mode B live validation against OpenAI requires gpt-4o-mini or older.
vLLM (the intended training-time upstream) returns logprobs natively.
End-to-end Mode B now verified against a live vLLM tunnel:
- Qwen/Qwen3.5-4B on 2x A100 (tp=2, 16K ctx) via `vllm serve`
- Cloudflared tunnel exposes the endpoint publicly
- E2B sandbox runs opencode against the tunnel through the in-sandbox proxy
- Proxy captures 4 turns, 36 tokens with real per-token logprobs
- Agent produces correct fizzbuzz.py, verifier scores reward=1.0
- 55.86s total (sandbox + install + 4 LLM turns + verify)

Changes:
- config: add proxy_disable_thinking flag; plumbed through harness -> proxy
  via --disable-thinking CLI arg.
- interception: inject chat_template_kwargs.enable_thinking=false on
  forwarded requests when enabled (Qwen3/Qwen3.5 tokenizer hook); also
  split the request handler into unary + streaming paths (SSE) and
  auto-translate max_tokens -> max_completion_tokens for gpt-5.x/o*
  models.
- sandbox/e2b: E2B's commands.run(background=True) has a default
  server-side timeout=60 that kills long-running opencode bg jobs;
  pass timeout=0 to disable it.
- live_watch: RolloutSummary / collect_rollout_summary /
  print_rollout_summary — post-rollout structured report reading proxy
  trace, opencode event log, and the workdir listing + file contents.
- tests/test_harness_live_vllm: end-to-end Mode B test against a live
  vLLM tunnel (gated on VLLM_TUNNEL_URL + E2B_API_KEY), asserts
  logprobs are captured with shape matching completion tokens.
Fixes the server-mode rollout path. Under load E2B can return sandboxes
that aren't yet accepting commands, curl-install can transiently fail,
and opencode's internal title-generation call can emit a stripped model
id (e.g. ``Qwen3.5-4B`` instead of ``Qwen/Qwen3.5-4B``) that vLLM
rejects with 404. Previously the proxy silently swallowed upstream error
bodies and returned an empty event-stream, which opencode interpreted as
an empty assistant turn.

Changes:
- harness: ``_wait_for_sandbox_ready`` probes ``echo ok`` up to 15x
  before issuing commands.
- harness: ``_exec_with_retry`` wraps install / pip-deps / extra-setup
  with exponential backoff, up to 3 attempts, bailing on deterministic
  errors (non-empty stderr).
- interception: ``ProxyConfig.model_override`` rewrites the ``model``
  field on every forwarded request to the exact upstream id, bypassing
  opencode's provider-prefix quirks. Plumbed through as
  ``--model-override`` on the CLI.
- interception: ``_proxy_streaming`` now inspects upstream status before
  committing to an SSE response — non-2xx returns a JSON error response
  to opencode AND logs the full upstream body to proxy.log, so the
  caller sees the real failure reason.
- harness: ``_start_proxy`` passes ``--model-override`` built from
  ``config.model`` so the upstream always sees the right id.
- harness: proxy deps install now uses ``_exec_with_retry`` too.

Verified via local uvicorn: fizzbuzz + fibonacci tasks both succeed
end-to-end through the server path in 19-20s with reward=1.0, 3-4
productive turns, and real per-token logprobs captured on every turn.
…itive)

Adds the infrastructure for driving opencode via its HTTP server instead
of the CLI. This is the Phase 2b foundation — fine-grained MCP tools in
the consumer server wrap these primitives.

Changes:
- opencode_client.OpenCodeServerClient: typed httpx wrapper over the
  OpenAPI spec at /doc. Sync and async methods for create_session,
  send_message / send_prompt_async, list_messages, get_session,
  get_all_status, abort, plus stream_events / astream_events (SSE) and
  a wait_for_ready helper. Base64 basic-auth when
  OPENCODE_SERVER_PASSWORD is set.
- harness.OpenCodeSession: new ``driver: Literal["cli", "serve"]`` field.
  driver="cli" is today's `opencode run` path. driver="serve" stores
  serve_public_url + serve_client + serve_session_id on the session.
  start_agent() dispatches on driver; wait_for_completion() polls
  /session/:id for idle when driver="serve". New abort() method hits
  /session/:id/abort for cancellation.
- harness.OpenCodeSessionFactory: new ``driver`` constructor arg plumbs
  through to create(). _start_serve() runs opencode serve bound to
  0.0.0.0:4096 as a bg job, probes it internally via curl, then uses
  the sandbox backend's get_host(4096) to build a public URL (E2B
  returns https://4096-<sandbox_id>.e2b.app). Fails fast if the backend
  doesn't support get_host.
- tests/test_opencode_client.py: 7 unit tests covering URL/method/body
  shape, auth header, prompt text extraction, abort bool, limit param,
  wait_for_ready polling, SSE event helpers. Uses httpx MockTransport
  patched via monkeypatch — no live opencode serve needed.
- tests/test_harness.py: _FakeSandbox now responds to the health-probe
  "echo ok" command so the existing factory tests work after the
  Phase-1 reliability layer landed.

Verified: 34 unit tests pass (7 new + 27 existing), driver=cli path
unchanged. End-to-end E2B spike confirms:
  sandbox_id assigned in 0.4s
  opencode install 2.5s
  opencode serve --port 4096 --hostname 0.0.0.0 listening in 1s
  sandbox.get_host(4096) returns https://4096-<id>.e2b.app
  external /doc returns HTTP 200 with OpenAPI spec
  external POST /session returns real session metadata

Next: wire 4 new MCP tools (start_rollout / get_state / abort_rollout /
finalize_rollout) in the consumer env + SSE endpoint for live rollout
events. Ship to HF Space.
Uvicorn+fastapi cold boot inside E2B can take >10s under load; the tight
probe loop was producing false 'proxy did not start within 10s' errors.
60s cap at 0.5s intervals keeps the retry fast while tolerating the slow
path.
…soning

Three audit fixes surfaced by end-to-end testing through the deployed
env server:

1. `_start_serve` now `cd`s into workdir_path(config) before launching
   opencode serve. Without this, the agent writes files to $HOME and
   RolloutResult.workdir_files (reading /home/user/workdir) comes back
   empty — the "rollout succeeded but nothing appeared" symptom.

2. `wait_for_completion` idle check was `status.get("idle")` but
   opencode's /session/status returns `{"type":"idle"}`, not
   `{"idle":true}`. Every serve-driver rollout silently timed out at
   agent_timeout_s. Now checks `status.get("type") == "idle"` and adds
   structured logging on every tick.

3. Interception proxy now preserves `delta.reasoning` on streaming
   chunks and surfaces it as `message.reasoning` on the assembled
   response. HF Router's Qwen3.5 thinking mode returns reasoning as a
   separate field from content; previously it was dropped.

4. `upstream_model` no longer strips the Qwen/ org prefix — full
   `config.model` is forwarded as the model-override so both vLLM
   (served as `Qwen/Qwen3.5-4B`) and HF Router (requires
   `Qwen/<repo>:<provider>`) work.

5. Structured logging at every factory.create phase so operators can
   see exactly which step is stuck (sandbox, bootstrap, proxy, serve,
   wait_for_completion).
…crets

The four live tests (OpenAI / vLLM / mode-B / E2B) required
OPENAI_API_KEY, VLLM_URL, or E2B_API_KEY to execute and were
development-time fixtures rather than CI checks. The core functionality
is already covered offline by test_harness.py (end-to-end factory
lifecycle against a mock sandbox + mock OpenAI endpoint),
test_interception.py (proxy forward + per-turn record assembly),
test_opencode_client.py (serve client over httpx mocks), and
test_sandbox_base.py (E2BSandboxBackend key-required unit).
@meta-cla

meta-cla Bot commented Apr 21, 2026

Copy link
Copy Markdown

Hi @adithya-s-k!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@greptile-apps

greptile-apps Bot commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces envs/opencode_env/, a harness primitive that runs the OpenCode coding agent inside an E2B sandbox against an OpenAI-compatible LLM endpoint, with two drivers (cli/serve) and two modes (black_box/transparent_proxy with logprob capture). The overall structure is clean and well-tested, but there are three P1 issues in harness.py to address before merge:

  • API key in process table (harness.py:594): self._config.api_key is passed directly as a CLI arg to the proxy process, exposing it via ps aux. Use an environment variable instead.
  • Premature idle detection (harness.py:233-241): The stable_ticks >= 12 (6 s) heuristic can misfire on thinking/reasoning models with natural pauses, causing verify() to run on an in-progress session.
  • get_host outside the SandboxHandle Protocol (harness.py:693-699): driver="serve" silently bypasses the Protocol by accessing sandbox.raw.get_host(), breaking custom backends.

ALIGNMENT FLAG: list_tools() unconditionally returns [] and call_tool() always errors because OpenCode owns its own internal tool loop. This circumvents the "MCP as universal standard" principle (RFC 003 / PRINCIPLES.md) — all agent–environment tooling is opaque to the harness with no MCP surface or observability.

  • Principle at stake: MCP as universal standard for agent–environment tool interaction
  • The concern: OpenCode's internal tool loop is entirely opaque to the harness
  • Suggested reviewer: @darktex

Confidence Score: 3/5

Not safe to merge without addressing the API key exposure and the serve-driver idle-detection false-positive.

Three P1 findings in harness.py: a security invariant violation (credential in process table), a correctness bug (premature idle detection for thinking models), and a broken abstraction for custom backends (get_host outside Protocol). These need resolution before merge.

envs/opencode_env/harness.py requires the most attention for all three P1 issues; envs/opencode_env/sandbox/base.py needs a get_host addition to close the protocol gap.

Security Review

  • Credential exposure via process table (harness.py line 594): self._config.api_key is interpolated directly into the proxy shell command as --upstream-api-key <value>, making it visible in ps aux / /proc/<pid>/cmdline within the sandbox. Should be passed via environment variable instead.
  • No other credential leakage, injection, or auth/authz issues identified in the changed files.

Important Files Changed

Filename Overview
envs/opencode_env/harness.py Core session/factory implementation; contains API key exposure in proxy CLI args (P1 security), fragile idle-detection heuristic for serve driver (P1 logic), and get_host protocol bypass (P1 design).
envs/opencode_env/interception.py FastAPI proxy for transparent LLM interception; well-structured streaming accumulation, reasoning preservation, and error surfacing.
envs/opencode_env/sandbox/base.py SandboxHandle/SandboxBackend Protocol definitions; missing get_host method that the serve driver depends on, creating an undocumented protocol extension requirement.
envs/opencode_env/config.py Pydantic config model; clean, well-documented fields with sensible defaults.
envs/opencode_env/opencode_runtime.py Pure builder functions for sandbox bootstrap artifacts; no IO coupling, well-tested.
envs/opencode_env/opencode_client.py Thin typed httpx wrapper over opencode serve HTTP API; straightforward and well-structured.
envs/opencode_env/task.py Simple Pydantic task model with coerce() helper; clean and well-covered by tests.
envs/opencode_env/live_watch.py Post-rollout summary helpers; paths are hardcoded to /home/user/... rather than using config.sandbox_home, which could break non-default sandbox layouts.
envs/opencode_env/sandbox/e2b.py E2B backend implementation; correctly wraps CommandHandle with thread-based timeout, handles CommandExitException cleanly.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant Factory as OpenCodeSessionFactory
    participant Sandbox as SandboxBackend
    participant Proxy as InterceptionProxy (Mode B)
    participant OC as OpenCode CLI/serve
    participant LLM as Upstream LLM

    Caller->>Factory: create(task)
    Factory->>Sandbox: create(timeout_s)
    Factory->>Sandbox: exec(install_cmd)
    Factory->>Sandbox: write_text(opencode.json, instruction.md)

    alt mode=transparent_proxy
        Factory->>Sandbox: start_bg(interception.py)
        Factory->>Sandbox: write_text(opencode.json) rewrite baseURL to proxy
    end

    alt driver=serve
        Factory->>Sandbox: start_bg(opencode serve)
        Factory->>OC: POST /session
        Factory->>OC: POST /session/:id/prompt_async
    else driver=cli
        Factory->>Sandbox: start_bg(opencode run)
    end

    Factory->>Caller: OpenCodeSession

    OC->>Proxy: POST /v1/chat/completions
    Proxy->>LLM: POST /v1/chat/completions (+logprobs)
    LLM-->>Proxy: stream response
    Proxy-->>OC: stream response (logprobs stripped)
    Proxy->>Proxy: write proxy_trace.jsonl

    Caller->>Factory: session.wait_for_completion()
    Note over Factory: CLI: bg_job.wait() / Serve: poll GET /session/status

    Caller->>Factory: session.verify(transcript)
    Caller->>Factory: session.close()
    Factory->>Sandbox: kill()
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: envs/opencode_env/harness.py
Line: 590-596

Comment:
**API key exposed in process table**

`self._config.api_key` is interpolated directly into the shell command as `--upstream-api-key <value>`. Any process listing tool (`ps aux`, `/proc/<pid>/cmdline`) inside or outside the sandbox will expose the upstream API key in plain text. This violates the "No credential exposure" security invariant.

Pass the key via an environment variable instead:
```python
proxy_env = {"UPSTREAM_API_KEY": self._config.api_key}
proxy_cmd = (
    "cd /home/user/proxy && "
    "python interception.py "
    f"--upstream-url {self._config.base_url} "
    "--upstream-api-key $UPSTREAM_API_KEY "  # read from env, not argv
    ...
)
proxy_job = sandbox.start_bg(proxy_cmd, envs=proxy_env)
```
Then update `interception.py`'s CLI parser to fall back to `os.environ.get("UPSTREAM_API_KEY")`.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: envs/opencode_env/harness.py
Line: 231-244

Comment:
**False-idle detection for thinking models**

The `stable_ticks >= 12` heuristic (12 × 0.5 s = 6 s) treats any 6-second gap in new messages as "idle". A reasoning/thinking model (e.g. Qwen3 with `disable_thinking=False`) can pause for tens of seconds between outputting tokens without the session being finished. During these pauses `list_messages` returns the same count, stable_ticks climbs to 12, and `wait_for_completion` returns 0 — causing `verify()` to be called on an in-progress run and potentially reporting incorrect results.

Consider raising the threshold significantly (e.g. `stable_ticks >= 60` → 30 s) or replacing the heuristic with the `GET /event` SSE stream idle signal so completion detection is driven by the server's own state machine rather than a message-count derivative.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: envs/opencode_env/harness.py
Line: 693-699

Comment:
**`get_host` bypasses the SandboxHandle protocol**

`driver="serve"` reaches through `sandbox.raw.get_host()` — a property specific to `E2BSandboxHandle` and absent from the `SandboxHandle` Protocol. Any custom backend that correctly implements the Protocol but does not expose a `.raw` attribute with `get_host` will fail here at runtime with a non-obvious error.

The `SandboxHandle` Protocol should declare a `get_host(port: int) -> str` method (optional, raising `NotImplementedError` by default) so custom backend authors know the contract.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: envs/opencode_env/harness.py
Line: 289-295

Comment:
**`import json` inside a for loop**

`import json as _json` is placed inside the `for` loop body in `fetch_proxy_trace`. Python caches imports so this is not a correctness bug, but it's misleading and would fail a lint pass. Move the import to the top of the file alongside the other standard-library imports.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: envs/opencode_env/harness.py
Line: 707

Comment:
**Module-level import at bottom of file**

`from pathlib import Path` is placed after the class definitions with a `# noqa: E402` suppression. Move this to the top of the file with the other imports.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "test(opencode_env): drop live integratio..." | Re-trigger Greptile

Comment thread envs/opencode_env/harness.py Outdated
Comment on lines +590 to +596
proxy_cmd = (
"cd /home/user/proxy && "
"python interception.py "
f"--upstream-url {self._config.base_url} "
f"--upstream-api-key {self._config.api_key} "
f"--trace {_PROXY_TRACE_PATH} "
f"--port {_PROXY_PORT} "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security API key exposed in process table

self._config.api_key is interpolated directly into the shell command as --upstream-api-key <value>. Any process listing tool (ps aux, /proc/<pid>/cmdline) inside or outside the sandbox will expose the upstream API key in plain text. This violates the "No credential exposure" security invariant.

Pass the key via an environment variable instead:

proxy_env = {"UPSTREAM_API_KEY": self._config.api_key}
proxy_cmd = (
    "cd /home/user/proxy && "
    "python interception.py "
    f"--upstream-url {self._config.base_url} "
    "--upstream-api-key $UPSTREAM_API_KEY "  # read from env, not argv
    ...
)
proxy_job = sandbox.start_bg(proxy_cmd, envs=proxy_env)

Then update interception.py's CLI parser to fall back to os.environ.get("UPSTREAM_API_KEY").

Prompt To Fix With AI
This is a comment left during a code review.
Path: envs/opencode_env/harness.py
Line: 590-596

Comment:
**API key exposed in process table**

`self._config.api_key` is interpolated directly into the shell command as `--upstream-api-key <value>`. Any process listing tool (`ps aux`, `/proc/<pid>/cmdline`) inside or outside the sandbox will expose the upstream API key in plain text. This violates the "No credential exposure" security invariant.

Pass the key via an environment variable instead:
```python
proxy_env = {"UPSTREAM_API_KEY": self._config.api_key}
proxy_cmd = (
    "cd /home/user/proxy && "
    "python interception.py "
    f"--upstream-url {self._config.base_url} "
    "--upstream-api-key $UPSTREAM_API_KEY "  # read from env, not argv
    ...
)
proxy_job = sandbox.start_bg(proxy_cmd, envs=proxy_env)
```
Then update `interception.py`'s CLI parser to fall back to `os.environ.get("UPSTREAM_API_KEY")`.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agreed. This is still valid on the current head: the proxy command still passes the upstream key via --upstream-api-key, so this should stay open. The fix should pass the key through sandbox.start_bg(..., envs={"OPENCODE_UPSTREAM_API_KEY": ...}) and have interception.py read it from the environment instead of argv. I would also handle model/URL shell quoting in the same cleanup pass.

Comment thread envs/opencode_env/harness.py Outdated
Comment on lines +231 to +244
msgs = self.serve_client.list_messages(self.serve_session_id)
n = len(msgs)
if n == last_msg_count:
stable_ticks += 1
# 6s without a new message OR status change — treat as idle.
if stable_ticks >= 12:
_log.info(
"wait_for_completion: stale msgs=%d ticks=%d — treating as idle",
n, stable_ticks,
)
return 0
else:
last_msg_count = n
stable_ticks = 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 False-idle detection for thinking models

The stable_ticks >= 12 heuristic (12 × 0.5 s = 6 s) treats any 6-second gap in new messages as "idle". A reasoning/thinking model (e.g. Qwen3 with disable_thinking=False) can pause for tens of seconds between outputting tokens without the session being finished. During these pauses list_messages returns the same count, stable_ticks climbs to 12, and wait_for_completion returns 0 — causing verify() to be called on an in-progress run and potentially reporting incorrect results.

Consider raising the threshold significantly (e.g. stable_ticks >= 60 → 30 s) or replacing the heuristic with the GET /event SSE stream idle signal so completion detection is driven by the server's own state machine rather than a message-count derivative.

Prompt To Fix With AI
This is a comment left during a code review.
Path: envs/opencode_env/harness.py
Line: 231-244

Comment:
**False-idle detection for thinking models**

The `stable_ticks >= 12` heuristic (12 × 0.5 s = 6 s) treats any 6-second gap in new messages as "idle". A reasoning/thinking model (e.g. Qwen3 with `disable_thinking=False`) can pause for tens of seconds between outputting tokens without the session being finished. During these pauses `list_messages` returns the same count, stable_ticks climbs to 12, and `wait_for_completion` returns 0 — causing `verify()` to be called on an in-progress run and potentially reporting incorrect results.

Consider raising the threshold significantly (e.g. `stable_ticks >= 60` → 30 s) or replacing the heuristic with the `GET /event` SSE stream idle signal so completion detection is driven by the server's own state machine rather than a message-count derivative.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This thread is outdated on the latest branch. The opencode serve path was removed; the harness now uses a single CLI/background-job path and waits on process completion rather than message-count idle detection.

Comment thread envs/opencode_env/harness.py Outdated
Comment on lines +693 to +699
raw = getattr(sandbox, "raw", None)
if raw is None or not hasattr(raw, "get_host"):
raise RuntimeError(
"driver='serve' requires a sandbox backend that exposes "
"get_host(port) (e.g. E2B)."
)
host = raw.get_host(_SERVE_PORT)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 get_host bypasses the SandboxHandle protocol

driver="serve" reaches through sandbox.raw.get_host() — a property specific to E2BSandboxHandle and absent from the SandboxHandle Protocol. Any custom backend that correctly implements the Protocol but does not expose a .raw attribute with get_host will fail here at runtime with a non-obvious error.

The SandboxHandle Protocol should declare a get_host(port: int) -> str method (optional, raising NotImplementedError by default) so custom backend authors know the contract.

Prompt To Fix With AI
This is a comment left during a code review.
Path: envs/opencode_env/harness.py
Line: 693-699

Comment:
**`get_host` bypasses the SandboxHandle protocol**

`driver="serve"` reaches through `sandbox.raw.get_host()` — a property specific to `E2BSandboxHandle` and absent from the `SandboxHandle` Protocol. Any custom backend that correctly implements the Protocol but does not expose a `.raw` attribute with `get_host` will fail here at runtime with a non-obvious error.

The `SandboxHandle` Protocol should declare a `get_host(port: int) -> str` method (optional, raising `NotImplementedError` by default) so custom backend authors know the contract.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is outdated after the latest rewrite. The serve driver was removed, so the harness no longer reaches through sandbox.raw.get_host() for opencode serve exposure.

Comment thread envs/opencode_env/harness.py Outdated
Comment on lines +289 to +295
records: list[dict[str, Any]] = []
for line in content.splitlines():
line = line.strip()
if not line:
continue
import json as _json
records.append(_json.loads(line))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 import json inside a for loop

import json as _json is placed inside the for loop body in fetch_proxy_trace. Python caches imports so this is not a correctness bug, but it's misleading and would fail a lint pass. Move the import to the top of the file alongside the other standard-library imports.

Prompt To Fix With AI
This is a comment left during a code review.
Path: envs/opencode_env/harness.py
Line: 289-295

Comment:
**`import json` inside a for loop**

`import json as _json` is placed inside the `for` loop body in `fetch_proxy_trace`. Python caches imports so this is not a correctness bug, but it's misleading and would fail a lint pass. Move the import to the top of the file alongside the other standard-library imports.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agreed. This is still present on current head in fetch_proxy_trace; the json import should be moved to the module import block and the loop should call json.loads(line) directly.

Comment thread envs/opencode_env/harness.py Outdated
return public_url, client


from pathlib import Path # noqa: E402 (used only inside _start_proxy)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Module-level import at bottom of file

from pathlib import Path is placed after the class definitions with a # noqa: E402 suppression. Move this to the top of the file with the other imports.

Prompt To Fix With AI
This is a comment left during a code review.
Path: envs/opencode_env/harness.py
Line: 707

Comment:
**Module-level import at bottom of file**

`from pathlib import Path` is placed after the class definitions with a `# noqa: E402` suppression. Move this to the top of the file with the other imports.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is fixed on the current head. Path is now imported in the top-level stdlib import block rather than at module bottom.

@Darktex Darktex left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note: This is an automated review by Claude Code, not a human review.


Alignment Review

Automated Checks

  • Lint: manual ruff pass shows one E402 (noqa'd) and one E731 (noqa'd) in the new files; no additional failures.
  • Debug code: FOUND — one print() call in production proxy code (see below).

Tier 1: Fixes Required

  • envs/opencode_env/interception.py (~line 1382) — print(...) left in _proxy_streaming error path ([proxy] turn {turn_idx}: upstream ...). Should use logging.getLogger(__name__).warning(...) to match the rest of the file's logging discipline.
  • envs/opencode_env/harness.py (~line 942) — The api_key is interpolated directly into the shell command string passed to sandbox.start_bg(proxy_cmd). Keys containing shell-special characters (spaces, quotes, $, backticks) will corrupt the command or enable injection. Pass it via the envs= dict and read it from an environment variable in interception.py.
  • envs/opencode_env/harness.py (~line 1055) — from pathlib import Path is at module scope after class definitions (E402, noqa'd). Move to the top of the file per PEP 8 and the project's usort/ruff pipeline.
  • envs/opencode_env/tests/test_harness.py (~line 2957) — pytest.raises(RuntimeError, match="install failed") only accidentally matches the actual "opencode install failed after 3 attempts ...". Tighten the match string to something stable (e.g., "opencode install").

Tier 2: Alignment Discussion

ALIGNMENT FLAG: external verifier callable breaks "rewards inside environment"

  • Principle at stake: "Rewards inside environment" (RFC 002, PRINCIPLES.md). External reward augmentation should use the Transform pipeline.
  • Concern: OpenCodeSessionFactory(verifier=my_verifier) accepts an arbitrary callable from the caller and uses it to produce env_reward, putting reward logic outside the environment. This can harm reproducibility and opens reward-hacking at the orchestration layer.
  • Suggested reviewer: @Darktex

ALIGNMENT FLAG: stacked on unmerged RFC 005 / PR #471

  • Concern: This env imports ResourceSession/ResourceSessionFactory from openenv.core.harness, which does not exist on main yet. Merging before #471 lands will break import/test against main. Please mark the dependency explicitly (e.g., "Depends on #471") rather than relying on description text alone.
  • Suggested reviewer: @Darktex

Summary

  • 4 mechanical issues to fix (debug print, credential injection risk, misplaced import, fragile test match).
  • 2 alignment points for human review.

Automated review by Claude Code | Learn more

@Darktex Darktex left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note: This is an automated review by Claude Code, not a human review.


Alignment Review Report

Automated Checks

  • Lint (pre-existing failures only): PASS for opencode_env — the 5 failing files are in chat_env, repl_env, and textarena_env, all pre-existing and unrelated to this PR.
  • Debug code: FOUND — see Tier 1 below.

Tier 1: Fixes Required

  • envs/opencode_env/harness.py line ~942 — Credential exposed via CLI argument (Security Invariant)

    f"--upstream-api-key {self._config.api_key} "

    The API key is passed as a positional shell argument, making it visible in /proc/<pid>/cmdline and ps aux inside the sandbox. Any process in the sandbox can read it. Fix: pass the key through an environment variable instead (e.g., PROXY_API_KEY) and read it in interception.py's main() via os.environ. The env-var injection path (build_env_vars) already sets OPENAI_API_KEY — the proxy key should follow the same pattern.

  • envs/opencode_env/interception.py line ~1382 — Bare print() in production code path (Debug code invariant)

    print(
        f"[proxy] turn {turn_idx}: upstream {upstream.status_code}: "
        f"{str(error_json)[:400]}",
        flush=True,
    )

    This print() is in a production code path (_proxy_streaming) and fires on every upstream error response during live rollouts. Replace with logging.getLogger(__name__).warning(...) so it respects the caller's log level and format.

  • envs/opencode_env/harness.py line 1055 — Late Path import at module bottom

    from pathlib import Path  # noqa: E402  (used only inside _start_proxy)

    The # noqa: E402 suppresses the lint rule rather than fixing the structure. Move the import to the top of the file with the other stdlib imports.

  • envs/opencode_env/harness.py line ~937 — Shell injection via model field

    model_override_flag = f"--model-override '{upstream_model}' "

    A model value containing a single quote (e.g., openai/foo' && malicious_cmd #) breaks out of the shell argument. Use shlex.quote(upstream_model) or pass the value as an environment variable consumed by the proxy rather than embedding it directly in the command string.

  • Missing openenv.yaml manifest

    Every OpenEnv environment package requires an openenv.yaml per PATTERNS.md. This PR has no manifest file. Add it (even a minimal one) to conform to the expected package structure.


Tier 2: Alignment Discussion

ALIGNMENT FLAG 1: Hard dependency on unmerged PR #471

  • Principle at stake: From PRINCIPLES.md "Be hands-on: Provide ready-to-use implementations, not just specs" and general integration hygiene.
  • The concern: envs/opencode_env/harness.py imports ResourceSession, ResourceSessionFactory, VerifyResult, ToolResult, Message from openenv.core.harness. This module does not exist on main — it lives in open PR #471. The PR description acknowledges this with "Stacked on #471," but the base contracts it depends on are themselves still in review. This PR cannot land until #471 merges and the core contracts it implements are stable.
  • Suggested reviewer: @Darktex

ALIGNMENT FLAG 2: Reward computed outside the environment boundary

  • Principle at stake: PRINCIPLES.md "Rewards inside environment" (RFC 002); INVARIANTS.md §Architectural §3 "Reward computation must stay inside environment boundary."
  • The concern: OpenCodeSession.verify() delegates entirely to a caller-supplied Verifier = Callable[[SandboxHandle, OpenCodeTask], VerifyResult]. The reward function is injected at factory construction time as arbitrary external code — it runs outside the environment's own logic. The question for the reviewer: does a post-hoc sandbox-execution verifier count as "inside the environment boundary" since it runs on the same sandbox object, or is it an invariant violation requiring an RFC discussion?
  • Suggested reviewer: @Darktex

ALIGNMENT FLAG 3: No Docker container — E2B-only sandbox model

  • Principle at stake: INVARIANTS.md §Security §2 "Container isolation — environments run in isolated Docker containers"; PATTERNS.md canonical structure requires server/Dockerfile.
  • The concern: This environment has no Dockerfile, no server/ directory, and no local Docker path. Isolation is provided entirely through E2B's cloud SDK, which is a paid external service. The PR uses SandboxBackend as a Protocol abstraction (so Docker could theoretically be added), but there is no Docker backend provided. The tradeoff may be justified, but should be explicitly called out and approved.
  • Suggested reviewer: @Darktex

ALIGNMENT FLAG 4: serve_client public attribute on OpenCodeSession

  • Principle at stake: INVARIANTS.md §Security §1 "Agents cannot access reset/simulation controls."
  • The concern: OpenCodeSession.serve_client is a public attribute of type OpenCodeServerClient. That client has methods including create_session(), send_prompt_async(), and abort() — these are simulation control operations. The attribute should be prefixed _serve_client (private) to signal it is not part of the public API.
  • Suggested reviewer: @Darktex

Summary

  • 4 mechanical issues to fix (credential exposure via CLI arg, bare print() in production, late import, shell injection via model field) plus 1 missing openenv.yaml.
  • 4 alignment points for human review: stacked-on-unmerged-PR dependency, reward-outside-environment-boundary pattern, no Docker/container isolation, and serve_client public attribute exposure.

The code quality overall is high — test coverage is thorough and the two-driver/two-mode design is clean. The mechanical issues are fixable without design changes. The alignment issues (especially the reward boundary and container invariant) need explicit sign-off before this can land.


Automated review by Claude Code | Learn more

@Darktex Darktex left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note: This is an automated review by Claude Code, not a human review.


Alignment Review Report

Automated Checks

  • Lint: PASS for opencode_env (files not yet on disk; carla_env pre-existing failures are unrelated to this PR)
  • Debug code: FOUND — one live print() in production code path (see Tier 1)

Tier 1: Fixes Required

  • /envs/opencode_env/interception.py:1382print() call in the production streaming error path (_proxy_streaming). This logs upstream error bodies directly to stdout in the running sandbox process. Should be logging.warning(...) or removed; stdout in a sandbox is not the right telemetry channel.

  • /envs/opencode_env/harness.py:942api_key passed as a CLI argument on the proxy command line: f"--upstream-api-key {self._config.api_key}". This exposes the secret in the sandbox process list (ps aux) and in the proxy log file (_PROXY_LOG_PATH), violating the "no credential exposure" security invariant. Pass the key via an environment variable instead (e.g. OPENCODE_UPSTREAM_API_KEY) and read it in main() / ProxyConfig.

  • /envs/opencode_env/harness.py:1055from pathlib import Path is deferred to the bottom of the module (after the class definitions). E402 placement is explicitly noted with a noqa comment, but the cleaner fix is to hoist it to the top-level imports block where it belongs.


Tier 2: Alignment Discussion

ALIGNMENT FLAG: Stacks on an unmerged PR

  • Principle at stake: "Be hands-on — provide ready-to-use implementations" (PRINCIPLES.md); also general merge-stability
  • The concern: harness.py imports from openenv.core.harness (the ResourceSession, ResourceSessionFactory, Message, ToolResult, VerifyResult contracts) that are defined only in PR #471, which is currently open. If this PR is merged before #471 the import path does not exist in main, making the package entirely non-functional at import time. The README acknowledges this but there is no CI guard.
  • Suggested reviewer: @Darktex

ALIGNMENT FLAG: Verifier callable is caller-supplied, injected from outside the environment boundary

  • Principle at stake: "Rewards inside environment" (RFC 002, PRINCIPLES.md)
  • The concern: OpenCodeSessionFactory(verifier=my_verifier) delegates reward computation to an arbitrary callable passed by the trainer/caller. This is structurally equivalent to external reward augmentation and differs from the pattern where reward logic is encapsulated inside the environment. The design rationale — that different tasks need different verifiers — may be sound, but it has not been documented in an RFC. If the intent is that this primitive is task-agnostic plumbing (akin to a Transform), the boundary needs to be explicit.
  • Suggested reviewer: @Darktex

ALIGNMENT FLAG: start_agent(), wait_for_completion(), and abort() are public on the session object

  • Principle at stake: "Agents cannot reset" / dual API boundary (INVARIANTS.md §Security §Architectural)
  • The concern: These methods control simulation lifecycle. They are called by the factory on behalf of the training orchestrator, which is correct. However, they are on the ResourceSession object which is also handed to HarnessAdapter.run_white_box(session, ...). If a harness adapter or downstream code exposes the session reference to the agent being evaluated, the agent could call abort() or observe completion-state. This is the same class of risk as exposing reset() via MCP. The ABC contract in PR #471 (ResourceSession) does not include these methods, so they are extra-protocol additions. The fix would be either to not inherit them from the public ABC or to document explicitly that these must never be forwarded to agents.
  • Suggested reviewer: @Darktex

Summary

  • 3 mechanical issues to fix (1 debug print, 1 credential-in-args, 1 import placement)
  • 3 alignment points for human review (dependency ordering, reward-boundary, lifecycle method exposure)

The overall implementation quality is high — good test coverage, clean Protocol-based sandbox abstraction, well-commented internals. The Tier 1 items are straightforward to fix. The Tier 2 items are design questions that need human sign-off given the newness of both this primitive and the harness contract it builds on.


Automated review by Claude Code | Learn more

@Darktex Darktex left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note: This is an automated review by Claude Code, not a human review.


Summary

This is a well-structured, documented, and tested harness primitive for running OpenCode inside E2B sandboxes. The code quality is high overall. However it has two categories of blocking issues: (1) it hard-depends on openenv.core.harness (PR #471) which does not exist on main, making it un-importable today; and (2) a credential exposure bug where api_key is interpolated into a shell command argument string. There are also several minor quality issues and one alignment flag that needs human sign-off before merge.

Tier 1 — Bugs / Quality

  • envs/opencode_env/harness.py:1-20Hard dependency on unmerged PR #471. from openenv.core.harness import ResourceSession, ResourceSessionFactory, VerifyResult, Message, ToolResult fails with ModuleNotFoundError on current main because openenv.core.harness does not exist yet. This PR must stack on #471 (e.g. via Graphite) and cannot merge before its parent lands.

  • envs/opencode_env/harness.py (proxy_cmd construction, around diff line 942) — Credential exposure in shell command. self._config.api_key is interpolated directly into proxy_cmd as --upstream-api-key {self._config.api_key}. Shell commands can appear in ps aux, audit logs, and E2B execution records. Pass it via an environment variable instead, consistent with the OPENAI_API_KEY pattern already used in build_env_vars.

  • envs/opencode_env/interception.py (@app.on_event("shutdown")) — @app.on_event is deprecated in FastAPI 0.93+ (PendingDeprecationWarning). The declared dependency is fastapi>=0.104. Replace with a @asynccontextmanager lifespan passed to FastAPI(lifespan=...).

  • envs/opencode_env/harness.py (final line, from pathlib import Path) — Module-level import placed after all class definitions with # noqa: E402 to suppress the lint error. Move from pathlib import Path to the top of the file alongside the other stdlib imports.

  • envs/opencode_env/interception.py (error branch inside _proxy_streaming) — Bare print(f"[proxy] turn {turn_idx}: upstream {upstream.status_code}: ..."). Production code should use logging.warning(...) or logging.error(...) instead of print() so operators can route, filter, and suppress log output.

  • No openenv.yaml present. Every env in the repo (echo_env, repl_env, etc.) ships an openenv.yaml runtime descriptor. Add one even if the type is harness rather than space.

  • envs/opencode_env/harness.py module-level constants _PROXY_TRACE_PATH, _PROXY_LOG_PATH, _SERVE_LOG_PATH, _OPENCODE_BIN are hardcoded to /home/user/.... OpenCodeConfig.sandbox_home exists to support non-E2B backends (e.g. Docker-as-root where home is /root). These paths should be derived from config.sandbox_home via helper functions in opencode_runtime.py (matching the pattern used by agent_log_path, workdir_path, etc.), otherwise non-E2B backends silently use the wrong paths.

Tier 2 — Alignment

ALIGNMENT FLAG: User-supplied verifier callable lives outside environment boundary

  • Principle at stake: "Rewards inside environment" (RFC 002, PRINCIPLES.md)
  • The concern: OpenCodeSessionFactory(verifier=my_verifier) accepts an arbitrary callable that runs in the trainer process and returns a reward scalar. RFC 002 says "domain knowledge encapsulated in env, not external." The verifier here is deliberately external — it runs in the caller's process, inspects the sandbox via sandbox.exec(...), and produces the reward. This is a pragmatic choice for a closed-binary harness (the env has no hook point), but it departs from the principle without a documented trade-off. Either (a) add an RFC note or README section explaining why verifier-as-callback is the correct pattern for harness primitives, or (b) get explicit sign-off that this trade-off is intentional.
  • Suggested reviewer: @Darktex

ALIGNMENT FLAG: New contract (ResourceSession/ResourceSessionFactory) diverges from Gymnasium-style API

  • Principle at stake: "Simple Gymnasium-style API" (PRINCIPLES.md); RFC 005 is "In Review" status
  • The concern: This PR introduces a new top-level contract (ResourceSession / ResourceSessionFactory) rather than extending Environment[ActT, ObsT, StateT]. RFC 005 is still marked "In Review." Merging an env that depends on a still-in-review RFC could lock in a contract before the RFC is settled. The dependency structure (this PR stacked on #471) makes the order correct, but the alignment question is: has RFC 005's contract been finalized for the ResourceSession shape?
  • Suggested reviewer: @Darktex

Verdict

Request changes: the openenv.core.harness import dependency on unmerged PR #471 is a hard blocker, and the api_key in shell command is a security invariant violation — both must be fixed before this can merge.


Automated review by Claude Code | Learn more

@sergiopaniego sergiopaniego left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks!!!
Could be nice to add examples as it's common for other envs.
Additionally, you could link to a running remote env (I guess it's https://huggingface.co/spaces/AdithyaSK/opencode-env-rollout)

I think we can do a small round to clean some parts that are draft instead of intended to be part of the final version

Comment thread envs/opencode_env/README.md Outdated
```toml
[project.dependencies]
openenv-opencode_env = {
git = "https://github.com/adithya-s-k/OpenEnv.git",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

should be updated/cleaned (and the rest of the appearances of similar patterns)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This README thread looks outdated after the latest docs rewrite. The current README now uses the deployed Space endpoints and the task/setup/verify terminology rather than the earlier placeholder wording.

Comment thread envs/opencode_env/README.md Outdated

## References

- [OpenEnv PR #471](https://github.com/meta-pytorch/OpenEnv/pull/471) — harness session runtime we stack on

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this doesn't make sense from the perspective of a final user since once this is live, #471 should also be merged

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is outdated in the current README. The final-user docs no longer frame usage around manually depending on PR #471; the branch still remains stacked on #471 for merge ordering, but that should not be part of the final user-facing instructions.

@meta-cla

meta-cla Bot commented Apr 29, 2026

Copy link
Copy Markdown

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Apr 29, 2026
adithya-s-k and others added 3 commits April 29, 2026 19:10
…dpoint catalog

Wraps the existing OpenCode harness primitive in a deployable OpenEnv
environment that can run as an HF Space, exposing a single MCP
``run_rollout`` tool plus a Gradio web UI at /web.

Highlights
----------
- Single MCP tool ``run_rollout`` accepting a uniform Task shape
  (instruction + setup[] + verify[] bash commands), reward = passed_verify
  / total or override via /home/user/logs/verifier/reward.txt.
- Endpoint shorthand catalog (``vllm`` / ``openai`` / ``hf_router``) that
  resolves base_url / api_key / model from env vars + sane defaults.
- In-sandbox FastAPI proxy (``transparent_proxy`` mode) injects
  logprobs=true and captures per-token logprobs for GRPO training.
- Optional ``black_box`` mode skips the proxy for SFT / eval rollouts.
- Pre-baked E2B template (``opencode-rl``) drops sandbox cold start
  from ~2min to ~6s by shipping opencode + proxy deps in the image.
- Streaming Gradio UI: /run handler is a generator that yields a live
  phase log (sandbox boot → setup → agent → verify → collect) so the
  user sees progress instead of a spinner.
- HF Space deployed at AdithyaSK/opencode-env, end-to-end verified
  against vLLM, OpenAI, and HF Router (all 3 reward=1.0 on the
  binary_search smoke task).

Layout
------
  envs/opencode_env/
    {client.py, models.py, __init__.py}            # HTTP client + pydantic
    {config.py, harness.py, opencode_runtime.py,
     task.py}                                      # primitive (CLI-only)
    server/{app.py, opencode_environment.py,
            gradio_ui.py, catalog.py, Dockerfile}  # FastAPI + Gradio + MCP
    sandbox/{base.py, e2b.py, interception.py,
             build_template.py}                    # E2B + proxy + template
    {pyproject.toml, openenv.yaml, uv.lock,
     README.md, .dockerignore, .gitignore}

Removed (CLI-only refactor)
---------------------------
- harness.py: dropped the ``opencode serve`` driver path (~270 LOC).
- Deleted opencode_client.py, live_watch.py, env-local tests/.

CI / tests
----------
- New tests/envs/test_opencode_env.py: 14 unit tests (no E2B, no LLM,
  no network) covering catalog resolution, model serialization, and
  task coercion. Plus one @pytest.mark.integration test that runs
  opencode end-to-end against the deployed Space (skipped by default).
- sandbox/__init__.py: e2b import wrapped in try/except so the package
  loads cleanly without e2b installed (CI-friendly).
- Added opencode-env to .github/workflows/docker-build.yml matrix so
  the image is built and pushed to GHCR alongside other envs.

openenv-core dependency
-----------------------
Currently pinned to the ``opencode-harness`` branch via git because
PyPI's ``openenv-core`` (0.2.x) does not yet ship the
``openenv.core.harness`` module that this env imports. Switch to
``openenv-core[core]>=0.2.2`` once RFC 5 / PR huggingface#471 ships in a
published release. The intended end-state is documented inline in
pyproject.toml.
Minimal end-to-end example: hits the deployed HF Space, runs a
binary_search rollout via the MCP run_rollout tool, prints the
reward + per-turn logprobs + the file the agent produced.

Mirrors the per-env convention in ``examples/`` (echo_mcp_demo.py /
coding_env_inference.py / atari_simple.py etc.). Defaults point at
``https://adithyask-opencode-env.hf.space``; override with
``OPENCODE_ENV_SPACE`` to target a different Space or local container.

Requires ``OPENAI_API_KEY`` in the environment (passed in the
request body, no Space secret required). Swap ``endpoint="openai"``
for ``"vllm"`` or ``"hf_router"`` to exercise the other backends.
@burtenshaw burtenshaw merged commit 1448944 into huggingface:feature/harness-interface May 6, 2026
1 check passed
burtenshaw added a commit that referenced this pull request May 11, 2026
* Add harness session runtime for training and evaluation

* Handle MCP tool errors and lazy-load BrowserGym exports

* Refine experimental harness APIs and reward forwarding

* docs: mention environment_factory in harness tutorial

* test: cover harness review regressions

* feat(harness): rollout collection + openenv collect CLI (#560)

* refactor(harness): move harness.py into harness/ package

Preserves the public API (`from openenv.core.harness import X` keeps
working) while making room for a ``collect`` submodule that layers
synthetic-dataset generation on top of the runtime primitives from
RFC 005 / PR #471. Relative imports in the module are adjusted from
``.client_types`` → ``..client_types``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(harness): add rollout collection module

Introduces ``openenv.core.harness.collect``, a thin layer on top of the
harness runtime from RFC 005 / PR #471 for generating synthetic datasets
from deployed environments:

- ``EpisodeRecord`` — serializable view of one rollout + its verification.
  Uses ``_resolve_env_reward`` so any mismatch between a tool-result
  reward and ``verify.env_reward`` raises, preserving the "rewards in
  env" invariant end-to-end.
- ``RolloutSerializer`` — append-only JSONL writer with a
  ``metadata.json`` sidecar. ``collected_episode_ids()`` enables resume.
- ``CollectRunner.run()`` — orchestrates N episodes: session.create →
  harness.run_white_box → verify → optional rubric filter → serialize.
  Returns a ``CollectResult`` with aggregate stats.
- ``build_model_step(llm_client)`` — adapts any ``LLMClient`` (OpenAI,
  Anthropic, and any OpenAI-compatible endpoint such as vLLM, TGI,
  Ollama, HF Inference, Together, Groq, Fireworks) into a ``ModelStep``
  for the white-box harness.
- ``push_to_hf_hub(output_dir, repo_id)`` — uploads ``results.jsonl``,
  ``metadata.json``, and an auto-generated dataset card to the Hub. The
  card's YAML front-matter tells the HF Dataset Viewer to treat the
  JSONL as ``split=train`` instead of trying to merge it with the
  metadata sidecar.

37 new tests (EpisodeRecord, RolloutSerializer, CollectRunner,
build_model_step, push_to_hf_hub, build_dataset_readme).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(envs/openspiel): add TTT harness session factory

``envs/openspiel_env/harness.py`` exposes an ``OpenSpielSessionFactory``
that wraps ``OpenSpielEnv`` (via ``StepEnvSessionAdapter``) and lets a
harness drive any OpenSpiel game — tic_tac_toe initially — through a
single ``play_move(action_id)`` MCP-style tool.

- Initial prompt renders legal actions plus a human-readable board for
  TTT so the LLM can reason about positions without needing to decode
  the 27-float info_state tensor.
- ``render_tic_tac_toe_board`` decodes the OpenSpiel TTT info_state
  (empty/X/O planes) into a 3x3 grid. Empty cells show their action_id
  so the prompt doubles as an action legend.
- Follows the pattern established by ``envs/browsergym_env/harness.py``
  in PR #471 — no changes to the underlying env, client, or protocol.

Tests cover the board rendering, tool dispatch, reset-kwargs forwarding,
and an end-to-end collect run against a scripted client exercising the
full ``MCPHarnessAdapter`` → ``CollectRunner`` → JSONL pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cli): add openenv collect command

Wraps ``CollectRunner`` + ``OpenSpielSessionFactory`` into a single
command so a deployed OpenEnv environment can produce a dataset with
one call:

    openenv collect openspiel:tic_tac_toe \\
        --base-url https://user-space.hf.space \\
        --output-dir /tmp/ttt-sft-v1 \\
        -n 200 --provider openai --model gpt-5-mini \\
        --push-to-hub user/ttt-sft-v1

Flags in short:
- ``--provider scripted | openai | anthropic`` — teacher selection.
  Scripted picks the first legal action and requires no API key,
  making ``openenv collect`` smoke-testable out of the box.
- ``--llm-endpoint / --llm-port`` — point at any OpenAI-compatible
  endpoint (vLLM, TGI, Ollama, HF Inference, Together, Groq, ...).
- ``--push-to-hub REPO`` — upload the directory as a dataset after
  collect; ``--private``/``--commit-message`` available.
- ``--resume / --no-resume`` — ``CollectRunner`` skips ``episode_ids``
  already serialized on disk.
- ``--keep-losses`` — by default filters rollouts with reward < 0 so
  the output is SFT-ready.

Env dispatch is via ``"family:variant"`` strings (e.g.
``openspiel:tic_tac_toe``). Unknown families raise a typer
``BadParameter`` with the supported set.

7 new CLI tests mocking ``CollectRunner`` + ``OpenSpielEnv`` so the
dispatch logic (scripted vs hosted provider, push-to-hub, filter
defaults, bad ``--env``) is exercised without network.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(harness): add README and runnable examples

- ``src/openenv/core/harness/README.md`` documents the collect module:
  quick-start (CLI + programmatic), output schema, and design notes
  covering the "thin envs" / "rewards in env" / "provider-agnostic
  teacher" choices.
- ``examples/ttt_collect_demo.py`` — scripted teacher against either a
  built-in fake OpenSpiel client or a real deployed server (``--base-url``).
  Runs with zero setup for pipeline smoke-testing.
- ``examples/ttt_collect_with_llm.py`` — provider-agnostic example that
  picks between hosted OpenAI/Anthropic (via ``--provider``) and any
  OpenAI-compatible self-hosted endpoint (via ``--llm-endpoint``), using
  the same collect pipeline unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address collect feedback

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: burtenshaw <ben.burtenshaw@gmail.com>

* fix: address browsergym harness review comments

* fix: format collect files

* envs/opencode_env: OpenCode coding-agent harness primitive (#603)

* feat(opencode_env): OpenCode harness primitive (M1.1-M1.5)

Harness primitive for running the OpenCode CLI agent in a sandbox against
any OpenAI-compatible endpoint. Stacked on the PR #471 harness-runtime
branch; implements the ResourceSession / ResourceSessionFactory contracts.

Modes:
- black_box: opencode talks directly to the configured endpoint. Verified
  end-to-end against real OpenAI (gpt-4o-mini) inside an E2B sandbox; the
  agent produces a correct fizzbuzz and the verifier scores reward=1.0.
- transparent_proxy: a per-session FastAPI proxy runs inside the sandbox,
  forwards /v1/chat/completions to the configured upstream with
  logprobs=true injected, captures per-turn (messages, completion tokens,
  logprobs, finish reason) to a JSON-lines trace, and strips logprobs
  from what opencode sees. Handles both unary and streaming (SSE). Caps
  max_tokens and auto-translates to max_completion_tokens for gpt-5.x/o*
  models.

Components:
- config.py: OpenCodeConfig (generic provider/base_url/api_key/model
  fields supporting OpenAI, Anthropic, and OpenAI-compatible endpoints;
  proxy tuning knobs; sandbox_home override for non-E2B backends).
- opencode_runtime.py: pure builders for opencode.json, install/run
  shell commands, and env vars.
- task.py: OpenCodeTask pydantic model (instruction + optional
  setup_shell + file uploads + opaque metadata); coerces from str/dict.
- sandbox/base.py: SandboxBackend / SandboxHandle / BgJob Protocols.
- sandbox/e2b.py: E2BSandboxBackend with a threaded BgJob wrapper that
  provides timeout support over E2B's CommandHandle.
- harness.py: OpenCodeSession + OpenCodeSessionFactory; in Mode B,
  installs proxy deps, uploads the proxy module, starts it as a bg job
  on localhost:7000, rewrites opencode.json to point at the proxy, and
  forces @ai-sdk/openai-compatible so routing goes through
  /v1/chat/completions.
- interception.py: InterceptionProxy (FastAPI, unary + streaming),
  per-turn trace capture, CLI entry point for sandbox-side execution.

Tests: 37 unit tests plus 3 live integration tests gated on
E2B_API_KEY / OPENAI_API_KEY.

Known limitation: OpenAI's gpt-5.x chat family refuses logprob requests,
so Mode B live validation against OpenAI requires gpt-4o-mini or older.
vLLM (the intended training-time upstream) returns logprobs natively.

* feat(opencode_env): live vLLM validation + post-rollout summary

End-to-end Mode B now verified against a live vLLM tunnel:
- Qwen/Qwen3.5-4B on 2x A100 (tp=2, 16K ctx) via `vllm serve`
- Cloudflared tunnel exposes the endpoint publicly
- E2B sandbox runs opencode against the tunnel through the in-sandbox proxy
- Proxy captures 4 turns, 36 tokens with real per-token logprobs
- Agent produces correct fizzbuzz.py, verifier scores reward=1.0
- 55.86s total (sandbox + install + 4 LLM turns + verify)

Changes:
- config: add proxy_disable_thinking flag; plumbed through harness -> proxy
  via --disable-thinking CLI arg.
- interception: inject chat_template_kwargs.enable_thinking=false on
  forwarded requests when enabled (Qwen3/Qwen3.5 tokenizer hook); also
  split the request handler into unary + streaming paths (SSE) and
  auto-translate max_tokens -> max_completion_tokens for gpt-5.x/o*
  models.
- sandbox/e2b: E2B's commands.run(background=True) has a default
  server-side timeout=60 that kills long-running opencode bg jobs;
  pass timeout=0 to disable it.
- live_watch: RolloutSummary / collect_rollout_summary /
  print_rollout_summary — post-rollout structured report reading proxy
  trace, opencode event log, and the workdir listing + file contents.
- tests/test_harness_live_vllm: end-to-end Mode B test against a live
  vLLM tunnel (gated on VLLM_TUNNEL_URL + E2B_API_KEY), asserts
  logprobs are captured with shape matching completion tokens.

* docs(opencode_env): README with Mode A + Mode B quickstarts and full config

* feat(opencode_env): retries, error surfacing, model override

Fixes the server-mode rollout path. Under load E2B can return sandboxes
that aren't yet accepting commands, curl-install can transiently fail,
and opencode's internal title-generation call can emit a stripped model
id (e.g. ``Qwen3.5-4B`` instead of ``Qwen/Qwen3.5-4B``) that vLLM
rejects with 404. Previously the proxy silently swallowed upstream error
bodies and returned an empty event-stream, which opencode interpreted as
an empty assistant turn.

Changes:
- harness: ``_wait_for_sandbox_ready`` probes ``echo ok`` up to 15x
  before issuing commands.
- harness: ``_exec_with_retry`` wraps install / pip-deps / extra-setup
  with exponential backoff, up to 3 attempts, bailing on deterministic
  errors (non-empty stderr).
- interception: ``ProxyConfig.model_override`` rewrites the ``model``
  field on every forwarded request to the exact upstream id, bypassing
  opencode's provider-prefix quirks. Plumbed through as
  ``--model-override`` on the CLI.
- interception: ``_proxy_streaming`` now inspects upstream status before
  committing to an SSE response — non-2xx returns a JSON error response
  to opencode AND logs the full upstream body to proxy.log, so the
  caller sees the real failure reason.
- harness: ``_start_proxy`` passes ``--model-override`` built from
  ``config.model`` so the upstream always sees the right id.
- harness: proxy deps install now uses ``_exec_with_retry`` too.

Verified via local uvicorn: fizzbuzz + fibonacci tasks both succeed
end-to-end through the server path in 19-20s with reward=1.0, 3-4
productive turns, and real per-token logprobs captured on every turn.

* feat(opencode_env): add serve driver + opencode_client (Phase 2b primitive)

Adds the infrastructure for driving opencode via its HTTP server instead
of the CLI. This is the Phase 2b foundation — fine-grained MCP tools in
the consumer server wrap these primitives.

Changes:
- opencode_client.OpenCodeServerClient: typed httpx wrapper over the
  OpenAPI spec at /doc. Sync and async methods for create_session,
  send_message / send_prompt_async, list_messages, get_session,
  get_all_status, abort, plus stream_events / astream_events (SSE) and
  a wait_for_ready helper. Base64 basic-auth when
  OPENCODE_SERVER_PASSWORD is set.
- harness.OpenCodeSession: new ``driver: Literal["cli", "serve"]`` field.
  driver="cli" is today's `opencode run` path. driver="serve" stores
  serve_public_url + serve_client + serve_session_id on the session.
  start_agent() dispatches on driver; wait_for_completion() polls
  /session/:id for idle when driver="serve". New abort() method hits
  /session/:id/abort for cancellation.
- harness.OpenCodeSessionFactory: new ``driver`` constructor arg plumbs
  through to create(). _start_serve() runs opencode serve bound to
  0.0.0.0:4096 as a bg job, probes it internally via curl, then uses
  the sandbox backend's get_host(4096) to build a public URL (E2B
  returns https://4096-<sandbox_id>.e2b.app). Fails fast if the backend
  doesn't support get_host.
- tests/test_opencode_client.py: 7 unit tests covering URL/method/body
  shape, auth header, prompt text extraction, abort bool, limit param,
  wait_for_ready polling, SSE event helpers. Uses httpx MockTransport
  patched via monkeypatch — no live opencode serve needed.
- tests/test_harness.py: _FakeSandbox now responds to the health-probe
  "echo ok" command so the existing factory tests work after the
  Phase-1 reliability layer landed.

Verified: 34 unit tests pass (7 new + 27 existing), driver=cli path
unchanged. End-to-end E2B spike confirms:
  sandbox_id assigned in 0.4s
  opencode install 2.5s
  opencode serve --port 4096 --hostname 0.0.0.0 listening in 1s
  sandbox.get_host(4096) returns https://4096-<id>.e2b.app
  external /doc returns HTTP 200 with OpenAPI spec
  external POST /session returns real session metadata

Next: wire 4 new MCP tools (start_rollout / get_state / abort_rollout /
finalize_rollout) in the consumer env + SSE endpoint for live rollout
events. Ship to HF Space.

* fix(opencode_env): bump proxy-start wait from 10s to 60s

Uvicorn+fastapi cold boot inside E2B can take >10s under load; the tight
probe loop was producing false 'proxy did not start within 10s' errors.
60s cap at 0.5s intervals keeps the retry fast while tolerating the slow
path.

* fix(opencode_env): cwd into workdir, fix idle detection, preserve reasoning

Three audit fixes surfaced by end-to-end testing through the deployed
env server:

1. `_start_serve` now `cd`s into workdir_path(config) before launching
   opencode serve. Without this, the agent writes files to $HOME and
   RolloutResult.workdir_files (reading /home/user/workdir) comes back
   empty — the "rollout succeeded but nothing appeared" symptom.

2. `wait_for_completion` idle check was `status.get("idle")` but
   opencode's /session/status returns `{"type":"idle"}`, not
   `{"idle":true}`. Every serve-driver rollout silently timed out at
   agent_timeout_s. Now checks `status.get("type") == "idle"` and adds
   structured logging on every tick.

3. Interception proxy now preserves `delta.reasoning` on streaming
   chunks and surfaces it as `message.reasoning` on the assembled
   response. HF Router's Qwen3.5 thinking mode returns reasoning as a
   separate field from content; previously it was dropped.

4. `upstream_model` no longer strips the Qwen/ org prefix — full
   `config.model` is forwarded as the model-override so both vLLM
   (served as `Qwen/Qwen3.5-4B`) and HF Router (requires
   `Qwen/<repo>:<provider>`) work.

5. Structured logging at every factory.create phase so operators can
   see exactly which step is stuck (sandbox, bootstrap, proxy, serve,
   wait_for_completion).

* test(opencode_env): drop live integration tests requiring external secrets

The four live tests (OpenAI / vLLM / mode-B / E2B) required
OPENAI_API_KEY, VLLM_URL, or E2B_API_KEY to execute and were
development-time fixtures rather than CI checks. The core functionality
is already covered offline by test_harness.py (end-to-end factory
lifecycle against a mock sandbox + mock OpenAI endpoint),
test_interception.py (proxy forward + per-turn record assembly),
test_opencode_client.py (serve client over httpx mocks), and
test_sandbox_base.py (E2BSandboxBackend key-required unit).

* feat(opencode_env): deployable env with HF Space, Gradio UI, and 3-endpoint catalog

Wraps the existing OpenCode harness primitive in a deployable OpenEnv
environment that can run as an HF Space, exposing a single MCP
``run_rollout`` tool plus a Gradio web UI at /web.

Highlights
----------
- Single MCP tool ``run_rollout`` accepting a uniform Task shape
  (instruction + setup[] + verify[] bash commands), reward = passed_verify
  / total or override via /home/user/logs/verifier/reward.txt.
- Endpoint shorthand catalog (``vllm`` / ``openai`` / ``hf_router``) that
  resolves base_url / api_key / model from env vars + sane defaults.
- In-sandbox FastAPI proxy (``transparent_proxy`` mode) injects
  logprobs=true and captures per-token logprobs for GRPO training.
- Optional ``black_box`` mode skips the proxy for SFT / eval rollouts.
- Pre-baked E2B template (``opencode-rl``) drops sandbox cold start
  from ~2min to ~6s by shipping opencode + proxy deps in the image.
- Streaming Gradio UI: /run handler is a generator that yields a live
  phase log (sandbox boot → setup → agent → verify → collect) so the
  user sees progress instead of a spinner.
- HF Space deployed at AdithyaSK/opencode-env, end-to-end verified
  against vLLM, OpenAI, and HF Router (all 3 reward=1.0 on the
  binary_search smoke task).

Layout
------
  envs/opencode_env/
    {client.py, models.py, __init__.py}            # HTTP client + pydantic
    {config.py, harness.py, opencode_runtime.py,
     task.py}                                      # primitive (CLI-only)
    server/{app.py, opencode_environment.py,
            gradio_ui.py, catalog.py, Dockerfile}  # FastAPI + Gradio + MCP
    sandbox/{base.py, e2b.py, interception.py,
             build_template.py}                    # E2B + proxy + template
    {pyproject.toml, openenv.yaml, uv.lock,
     README.md, .dockerignore, .gitignore}

Removed (CLI-only refactor)
---------------------------
- harness.py: dropped the ``opencode serve`` driver path (~270 LOC).
- Deleted opencode_client.py, live_watch.py, env-local tests/.

CI / tests
----------
- New tests/envs/test_opencode_env.py: 14 unit tests (no E2B, no LLM,
  no network) covering catalog resolution, model serialization, and
  task coercion. Plus one @pytest.mark.integration test that runs
  opencode end-to-end against the deployed Space (skipped by default).
- sandbox/__init__.py: e2b import wrapped in try/except so the package
  loads cleanly without e2b installed (CI-friendly).
- Added opencode-env to .github/workflows/docker-build.yml matrix so
  the image is built and pushed to GHCR alongside other envs.

openenv-core dependency
-----------------------
Currently pinned to the ``opencode-harness`` branch via git because
PyPI's ``openenv-core`` (0.2.x) does not yet ship the
``openenv.core.harness`` module that this env imports. Switch to
``openenv-core[core]>=0.2.2`` once RFC 5 / PR #471 ships in a
published release. The intended end-state is documented inline in
pyproject.toml.

* docs(opencode_env): add examples/opencode_env_simple.py

Minimal end-to-end example: hits the deployed HF Space, runs a
binary_search rollout via the MCP run_rollout tool, prints the
reward + per-turn logprobs + the file the agent produced.

Mirrors the per-env convention in ``examples/`` (echo_mcp_demo.py /
coding_env_inference.py / atari_simple.py etc.). Defaults point at
``https://adithyask-opencode-env.hf.space``; override with
``OPENCODE_ENV_SPACE`` to target a different Space or local container.

Requires ``OPENAI_API_KEY`` in the environment (passed in the
request body, no Space secret required). Swap ``endpoint="openai"``
for ``"vllm"`` or ``"hf_router"`` to exercise the other backends.

* fix: secure opencode proxy command

---------

Co-authored-by: burtenshaw <ben.burtenshaw@gmail.com>

* fix: address ci docs and formatting

* feat(tutorials): add SFT warm-up tutorial with reasoning_gym collect support (#636)

* feat(collect): add reasoning_gym support + --dataset-config + --system-prompt

Extends `openenv collect` to support reasoning_gym environments:
- Register ReasoningGymSessionFactory in _build_session_factory
- Add --dataset-config option (JSON string) for env-specific config
- Add --system-prompt option to override the default system prompt
- Add ReasoningGymSessionFactory in envs/reasoning_gym_env/harness.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(harness): generate random seed per episode when none provided

reasoning_gym server requires seed when dataset_name is specified.
CollectRunner never passes a seed, so generate one per episode to
ensure variety across collected rollouts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(llm_client): use max_completion_tokens for OpenAI client

Newer OpenAI models (gpt-5-mini, o1, o3) reject max_tokens and require
max_completion_tokens instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(llm_client): use max_completion_tokens only for OpenAI API, not self-hosted

Newer OpenAI models require max_completion_tokens; self-hosted OpenAI-compatible
endpoints (vLLM, Ollama, TGI) only support max_tokens. Add use_max_completion_tokens
flag to OpenAIClient, enabled automatically by create_llm_client for the openai
provider and left off for self-hosted endpoints via --llm-endpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(llm_client): omit temperature for OpenAI API newer models

gpt-5-mini and other newer OpenAI models only accept the default
temperature (1) and reject any explicit value. Omit temperature
entirely when use_max_completion_tokens is set; self-hosted endpoints
are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(collect): add rich progress bar to CollectRunner

Shows episode count, collected count, running avg reward, and elapsed
time during collection — previously the loop ran silently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(tutorials): add SFT warm-up tutorial with end-to-end validated notebook

Adds docs/source/tutorials/sft-warmup.md and examples/sft_warmup.ipynb.
Tutorial collects rollouts via CollectRunner Python API, pushes to Hub,
filters by reward, and fine-tunes Qwen3-1.7B with SFTTrainer. Validated
end-to-end: 0% → 64% format compliance, 4% → 60% accuracy on chain_sum.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(tutorials,collect): address PR review findings

- Scope use_max_completion_tokens to models that require it (gpt-5-mini,
  o1, o3, o4-mini) — was incorrectly applied to all OpenAI models,
  breaking gpt-4o and other standard models
- Remove duplicate YOUR_HF_USERNAME re-declaration in tutorial section 10
- Add missing asyncio import in tutorial section 10 code block
- Fix prose: max_seq_length → max_length (correct SFTConfig field name)
- Fix usort import order in collect.py and harness/collect.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(collect,tutorials): address Greptile review findings

- Move _MAX_COMPLETION_TOKENS_PREFIXES to module level as frozenset
- Use prefix matching for versioned model names (o1-2024-12-17, etc.)
- Replace asyncio.run() with await in tutorial section 10 (Jupyter compat)
- Use json.dumps for tool_call text in to_qwen3_messages (safe escaping)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: burtenshaw <ben.burtenshaw@gmail.com>

* fix: address harness review feedback

* fix: format harness lint

* fix: address harness review comments

---------

Co-authored-by: Sergio Paniego Blanco <sergiopaniegoblanco@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Adithya S K <adithyaskolavi@gmail.com>
burtenshaw added a commit that referenced this pull request May 11, 2026
* feat(opencode_env): OpenCode coding-agent harness environment

Re-introduces the OpenCode env that was developed in PR #603 (merged into
the stacked feature/harness-interface branch and dropped before that
branch landed on main). With openenv-core 0.3.0 now published with the
harness runtime, this lands cleanly on main without the stack dependency.

Environment
- envs/opencode_env/: deployable env wrapping the OpenCode CLI agent in
  an E2B sandbox against any OpenAI-compatible LLM endpoint.
  - Single MCP tool run_rollout (instruction + setup[] + verify[] bash;
    reward = passed_verify / total or override via reward.txt).
  - Endpoint shorthand catalog (vllm / openai / hf_router) resolves
    base_url / api_key / model from env vars.
  - transparent_proxy mode runs an in-sandbox FastAPI proxy that
    injects logprobs=true on forwarded /v1/chat/completions, captures
    per-turn logprobs to a JSON-lines trace (for GRPO), and strips
    logprobs from what opencode sees.
  - black_box mode skips the proxy for SFT / eval rollouts.
  - Pre-baked E2B template support drops sandbox cold start ~2min -> ~6s.
  - Streaming Gradio UI at /web with live phase log.
  - Implements ResourceSession / ResourceSessionFactory from
    openenv.core.harness.

Pinned to openenv-core[core]>=0.3.0 (no more git-ref pin to a fork).

Tests
- tests/envs/test_opencode_env.py: 14 offline unit tests covering
  catalog resolution, model serialization, task coercion. No E2B / LLM
  / network. Plus one integration test gated on a deployed Space.

Example
- examples/opencode_env_simple.py: end-to-end binary_search rollout
  against the deployed HF Space, prints reward + per-turn logprobs.

CI
- .github/workflows/docker-build.yml: adds opencode-env to the matrix
  so the image is built and pushed to GHCR alongside other envs.

* fix: address opencode env ci lint

---------

Co-authored-by: burtenshaw <ben.burtenshaw@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants