Skip to content

Emit OpenEnv environments as a second target alongside Harbor - #87

Open
thegovind wants to merge 9 commits into
huggingface:mainfrom
thegovind:feat/openenv-export
Open

thegovind wants to merge 9 commits into
huggingface:mainfrom
thegovind:feat/openenv-export

Conversation

@thegovind

Copy link
Copy Markdown

Summary

Repo2RLEnv can already produce Harbor task directories. This PR makes it able to produce the environment that serves them.

repo2rlenv generate --repo pallets/click --pipeline pr_runtime --out ./tasks
repo2rlenv export --format openenv ./tasks --out ./click-env    # <- new
docker build -t click-env ./click-env
docker run --rm -p 8000:8000 -v /var/run/docker.sock:/var/run/docker.sock click-env

The tagline is "turn any repository into an RL environment". Until now the pipeline ended at a directory of files that something else had to run. It now ends at a running environment speaking OpenEnv's reset() / step() / state API — deployable as a Docker image or a Hugging Face Space.

The task data is not touched. export copies the task directories byte for byte and adds a serving layer around them, so the same dataset still runs under harbor run and under OpenEnv's own generic harbor_env. This is packaging, not conversion.


Where this sits

flowchart LR
    R["Repo2RLEnv<br/>synthesis pipelines"]
    T["Harbor task directory<br/>task.toml · instruction.md<br/>environment/ · tests/ · solution/"]

    H["harbor run<br/><i>batch evaluation</i>"]
    X["repo2rlenv export --format openenv<br/><b>NEW</b> — deployable env package"]
    HE["OpenEnv harbor_env<br/><i>generic Harbor runtime</i>"]

    TR["Trainer / RL loop<br/>reset · step · state"]

    R -->|emits| T
    T --> H
    T -->|"wraps, does not convert"| X
    T --> HE
    X --> TR
    HE --> TR

    style X fill:#c8e6c9,stroke:#2e7d32,stroke-width:3px
    style T fill:#e3f2fd,stroke:#1565c0
Loading

Two ways to reach OpenEnv, and they are complementary:

What it is Use it when
repo2rlenv export --format openenv We emit a deployable environment package around your dataset You want a standalone image or an HF Space, with no OpenEnv checkout involved
OpenEnv's harbor_env OpenEnv's own generic runtime for any Harbor task dir You already work in an OpenEnv checkout, or mix our tasks with other Harbor producers

Both forward the reward from the task's own tests/test.sh. Neither rewrites the format.


What the export produces

flowchart TB
    subgraph PKG["./click-env  — the emitted package"]
        direction TB
        Y["openenv.yaml<br/><i>OpenEnv manifest</i>"]
        D["Dockerfile<br/><i>builds the server image</i>"]
        RM["README.md<br/><i>HF Space card, sdk: docker</i>"]
        P["pyproject.toml"]
        S["server/app.py<br/><b>2 lines</b> over build_app()"]
        TK["tasks/&lt;id&gt;/...<br/><i>copied verbatim</i>"]
    end

    LIB["repo2rlenv.openenv<br/><b>the runtime — versioned + tested in this library</b>"]

    S -->|imports| LIB

    style S fill:#fff9c4,stroke:#f9a825
    style TK fill:#e3f2fd,stroke:#1565c0
    style LIB fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
Loading

The package is deliberately thin config, not generated logic. server/app.py is:

from repo2rlenv.openenv import build_app

app = build_app()

The runtime lives in repo2rlenv.openenv, so it is linted, tested and upgraded like the rest of the library rather than frozen into whatever someone exported six months ago.


An episode

sequenceDiagram
    participant TR as Trainer <br/>(orchestration)
    participant E as Repo2RLEnvEnvironment
    participant C as Task container
    participant V as tests/test.sh

    TR->>E: reset(task_id=...)
    E->>C: pull pushed image, or build environment/Dockerfile
    E-->>TR: Observation(instruction=instruction.md)

    loop agent works
        TR->>E: step(exec / read / write)
        E->>C: run in /workspace
        C-->>E: output
    end

    TR->>E: step(evaluate)
    E->>C: clear /logs/verifier, stage tests/ -> /tests
    E->>V: bash /tests/test.sh
    V-->>C: reward.txt + reward-details.json
    E-->>TR: Observation(reward=0.83, done=True)

    Note over E,V: reward is FORWARDED, never recomputed
    Note over TR,E: evaluate + solve are orchestration-only,<br/>never in the agent's action space
Loading

Invariants held

  • Rewards are forwarded, never synthesized. A verifier that writes no reward file yields reward=None plus an explicit error — not a 0.0. A fabricated zero is indistinguishable from a real failure and would quietly poison a training run.
  • Agents cannot grade or solve. exec / read / write are the agent's action space; evaluate and solve are orchestration. An agent that could grade on demand could end its own episode; one that could solve could read out the answer.
  • Agents cannot reach the answer. Every agent-supplied path is resolved through resolve_within() and confined to the working directory, so /tests and /solution are unreachable.
  • A planted reward cannot survive. /logs/verifier is cleared immediately before the verifier runs, so a reward file written by the agent during exec never reaches the score.

Why not just reuse OpenEnv's harbor_env?

Fair question — and you still can, that path is documented and unchanged. Two reasons this exists as well:

1. It has to be installable. OpenEnv's env packages are not published — openenv-harbor-env is a 404 on PyPI, only openenv itself is published. An emitted image cannot pip install it, so binding to it would have coupled Repo2RLEnv releases to an unmerged OpenEnv PR. The emitted package depends only on openenv (verified against the published 0.4.1) plus this library.

2. It can do better on our own data. A generic Harbor runtime cannot read [metadata.repo2env]. Ours does:

flowchart TD
    A["reset(task_id)"] --> B{"reproducibility.mode"}
    B -->|"registry"| C["pull image_ref<br/><i>pushed by repo2rlenv push — no build</i>"]
    B -->|"inline_dockerfile<br/>local_only"| D{"environment/Dockerfile?"}
    D -->|yes| E["build it"]
    D -->|no| F["refuse with an explanation<br/><i>text-only pr_diff task —<br/>score against solution/patch.diff</i>"]

    style C fill:#c8e6c9,stroke:#2e7d32
    style F fill:#ffebee,stroke:#c62828
Loading

local_only and inline_dockerfile refs may exist on no machine but the one that generated the task, so only registry is treated as pullable. A pushed dataset therefore starts episodes with no build step at all.


Verification

I did not want to assert interop, so a task was built with Repo2RLEnv's own Harbor emitterversion = "1.0", [metadata.repo2env], environment/Dockerfile with WORKDIR /workspace, tests/test.sh writing /logs/verifier/reward.txt and ending exit 0 — and run through every runtime.

Runtime No-op agent Oracle (solution/solve.sh)
harbor run (Harbor 0.20.0) 0.0 1.0
repo2rlenv export --format openenv 0.0 1.0
OpenEnv harbor_env, docker mode 0.0 1.0

The decisive check was done on a built container, not in-process:

  1. docker build on the emitted Dockerfile
  2. run it with the host Docker socket mounted
  3. drive it over the WebSocket API: resetwrite_fileevaluate
  4. the task went 0.0 → 1.0 through an agent edit, not the oracle

reward-details.json arrived intact as observation.info["reward_details"] (F2P/P2P counts, resolved, parse_status), and the working directory resolved to /workspace from the image.

Tests

  • 33 new tests in tests/test_openenv_export.py
  • Full suite 739 passed, 15 skipped, on Python 3.12, 3.13 and 3.14
  • ruff check / ruff format --check clean; mkdocs build clean

Keeping openenv optional

The default install must stay lean, and export itself must not require the OpenEnv stack.

flowchart LR
    subgraph CORE["pip install repo2rlenv"]
        DS["openenv/dataset.py"]
        RW["openenv/reward.py"]
        EM["emitter/openenv.py"]
    end

    subgraph EXTRA["pip install 'repo2rlenv[openenv]'"]
        MO["models · client · app"]
        SB["sandbox"]
        EN["environment"]
    end

    EM -->|reads tasks via| DS
    MO -.->|"lazy, PEP 562 __getattr__"| CORE

    style CORE fill:#e8f5e9,stroke:#2e7d32
    style EXTRA fill:#fff9c4,stroke:#f9a825
Loading

repo2rlenv.openenv resolves its serving names lazily via a module-level __getattr__, so importing the package or the emitter does not import openenv. There is a test that asserts exactly this by subprocess:

assert "openenv" not in sys.modules   # after importing repo2rlenv.openenv + the emitter

Anything requiring the extra fails with an actionable message rather than a bare ModuleNotFoundError.

CI: the extra is deliberately not added to the dev group — that would bloat the lock and put openenv's dependency tree on the 3.13/3.14 matrix legs. Instead a dedicated job runs uv run --with openenv --with docker pytest tests/test_openenv_export.py, so the runtime invariants genuinely execute in CI instead of silently skipping.


Files

flowchart TB
    subgraph EMIT["src/repo2rlenv/emitter/"]
        H["harbor.py<br/><i>existing — emits tasks</i>"]
        O["openenv.py<br/><b>new — emits the environment</b>"]
    end

    subgraph RT["src/repo2rlenv/openenv/  — new runtime"]
        DS2["dataset.py<br/>read an emitted task + discovery"]
        RW2["reward.py<br/>/logs/verifier contract"]
        SB2["sandbox.py<br/>Docker sandbox"]
        EN2["environment.py<br/>reset · step · state"]
        CL["client.py<br/>trainer-facing"]
        AP["app.py<br/>FastAPI factory"]
    end

    CLI["cli.py<br/>repo2rlenv export"]

    CLI --> O
    O --> DS2
    EN2 --> DS2 & SB2
    SB2 --> RW2

    style O fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
    style RT fill:#f3e5f5,stroke:#6a1b9a
Loading
Area Files Lines
Runtime src/repo2rlenv/openenv/* ~1,455
Emitter src/repo2rlenv/emitter/openenv.py 338
CLI src/repo2rlenv/cli.py +95
Tests tests/test_openenv_export.py 405
Docs docs/reference/OPENENV.md, README.md, RELATED_WORK.md, mkdocs.yml ~233
Lockfile uv.lock +1,529 (uv locks all extras)

19 files, +4,099 / −4. Excluding uv.lock that is ~2,570 lines, roughly a third of which is tests.

Suggested review order

  1. docs/reference/OPENENV.md — the whole design in prose, start here
  2. src/repo2rlenv/openenv/environment.py — the state machine and the action-boundary invariants
  3. src/repo2rlenv/emitter/openenv.py — what gets written and why it is thin
  4. src/repo2rlenv/openenv/reward.py — the forward-never-synthesize rule
  5. Everything else is mechanical

Docs

docs/reference/OPENENV.md previously said Repo2RLEnv datasets run on OpenEnv with "no export step". That is now half the story, so rather than leave it contradicting the new command I rewrote it into two honest paths — export a deployable environment and serve with harbor_env — with the shared point made explicitly: the task data is identical either way.

README.md and RELATED_WORK.md were updated to say we now emit OpenEnv rather than only being compatible with it.


Known caveats

  • The emitted Dockerfile defaults to repo2rlenv[openenv]>=<installed version>, but the openenv extra only exists once a release ships it. Until then, pass --requirement (a git ref or a local wheel) — that is exactly how the container verification above was run. It self-corrects on the next release.
  • A Hugging Face Space cannot run Docker-in-Docker. A Space built from export serves the API but cannot start task containers, so Repo2RLEnv task sets need a Docker-capable host. This is stated in the emitted Space card and in the docs.
  • Text-only pr_diff tasks (emit_harbor_env=False) ship no image and no verifier. export counts them, warns, and notes in the Space card that they are scored against solution/patch.diff client-side rather than executed.
  • uv.lock grows by 1,529 lines. That is uv locking the new extra's transitive tree; uv sync --group dev --frozen still resolves and the default install is unchanged at 62 packages. Verified on 3.12 / 3.13 / 3.14.

Related

Companion PR in OpenEnv adds envs/harbor_env, the generic Harbor runtime referenced above. The two are independent — this PR depends only on the published openenv package and does not require that one to land.

OpenEnv's harbor_env serves Harbor task directories directly, so a dataset
built for `harbor run` is also a training environment — no export step and no
Repo2RLEnv-specific code on either side.

Adds docs/reference/OPENENV.md covering the file-by-file mapping, the
generate → validate → serve → train recipe (including serving straight from a
Hub dataset), how our reward files are read, and which execution mode each
pipeline needs.

The execution-mode table is the part worth reading: every pipeline puts the
repository state inside the image the task carries, so all of them — including
`pr_diff` with the default `emit_harbor_env=True` — need harbor_env's Docker
backend. Only `pr_diff --pipeline-opt emit_harbor_env=False` is unrunnable,
because it emits no environment/ or tests/ at all and is meant to be scored
client-side with `repo2rlenv.reward`.

Verified against Harbor 0.20: a task with our emitted shape (`version = "1.0"`,
[metadata.repo2env], environment/Dockerfile with WORKDIR /workspace, a test.sh
writing /logs/verifier/reward.txt) scores 0.0 for a no-op agent and 1.0 for its
oracle under both `harbor run` and harbor_env's Docker mode.
- Note the exact Harbor version the cross-runtime check ran against (0.20.0).
- Spell out what the OpenEnv side actually observed: workdir resolved to
  /workspace from the image, the scalar read from reward.txt, and
  reward-details.json arriving intact as observation.info["reward_details"].
- The documented --mode docker invocation needs the docker package, which an
  OpenEnv repo-root venv does not carry; pass --with docker so the command
  works as written.
… Harbor

Repo2RLEnv could already produce Harbor task directories; it can now produce the
environment that serves them. `repo2rlenv export --format openenv <dataset>`
writes a deployable OpenEnv environment — Dockerfile, openenv.yaml, Hugging Face
Space card, server shim — around a dataset, so "turn any repository into an RL
environment" ends at a running environment rather than a directory of files.

What lands
----------
- `repo2rlenv.openenv`: the runtime.
    dataset.py      reads an emitted task back off disk + discovery
    reward.py       the /logs/verifier reward-file contract
    sandbox.py      Docker sandbox implementing our filesystem contract
    environment.py  Gymnasium reset/step/state over the three above
    client.py       trainer-facing client
    app.py          FastAPI factory
- `repo2rlenv.emitter.openenv`: writes the deployable package.
- `repo2rlenv export` CLI subcommand.
- `openenv` extra (`pip install 'repo2rlenv[openenv]'`).

Design notes
------------
The tasks are copied verbatim — this adds a serving layer, it does not convert
the data, so the same dataset still runs under `harbor run` and under OpenEnv's
own generic `harbor_env`. The emitted package is deliberately thin: `server/app.py`
is two lines over `repo2rlenv.openenv.build_app`, so the runtime is versioned and
tested in this library rather than generated into someone's output directory.

The runtime reads `[metadata.repo2env.reproducibility]`, which a generic Harbor
runtime cannot: when `mode = "registry"` the image was actually pushed somewhere
pullable, so we pull it instead of rebuilding the Dockerfile. `local_only` and
`inline_dockerfile` refs may exist on no machine but the generating one, so those
still build.

Rewards are forwarded, never recomputed. A verifier that writes no reward file
yields `reward=None` and an explicit error rather than a fabricated 0.0, which
would be indistinguishable from a genuine failure. `exec`/`read`/`write` are the
agent's action space; `evaluate` and `solve` are orchestration-only, and agent
paths are confined to the working directory so a policy cannot reach /tests or
/solution.

`openenv` stays optional: `repo2rlenv.openenv` resolves its serving names lazily
(PEP 562), so reading a dataset and running `export` work on a plain
`pip install repo2rlenv`. A test asserts `openenv` is absent from `sys.modules`
after importing the package and the emitter.

Verification
------------
A task built with our own Harbor emitter (`version = "1.0"`,
`[metadata.repo2env]`, `WORKDIR /workspace`, `test.sh` writing
`/logs/verifier/reward.txt`) scores identically across all three runtimes —
0.0 for a no-op agent, 1.0 for the oracle:

    harbor run (Harbor 0.20.0)              0.0 / 1.0
    repo2rlenv export --format openenv      0.0 / 1.0
    OpenEnv harbor_env, docker mode         0.0 / 1.0

The exported environment was also driven end to end over the WebSocket API
(reset -> write_file -> evaluate), taking the task from 0.0 to 1.0 through an
agent edit rather than the oracle, with reward-details.json arriving intact as
observation.info["reward_details"].

Tests: 33 in tests/test_openenv_export.py; full suite 739 passed, 15 skipped.
A dedicated CI job installs the extra with `uv run --with` so the runtime
invariants actually run instead of skipping, without adding openenv to the lock
or to the 3.13/3.14 matrix legs.
The Verified table now says what was actually run: docker build on the emitted
Dockerfile, run with the host Docker socket mounted, then driven over the
WebSocket API from 0.0 to 1.0 through an agent edit.
…enEnv PR

Automated reviewers went over huggingface/OpenEnv#1018, which carries a
structurally parallel runtime. All four findings applied here too — this repo
just had no bot looking at it.

1. reset() left the previous episode in state
   close() tears the container down first, but _state was only replaced after
   tasks.get() and sandbox.start(), either of which can raise. A failed reset
   reported a task_id, workdir and reward for an episode that was gone. Now
   drops to a clean Repo2RLEnvState before the fallible work.

2. DockerSandbox.upload_dir() archived nested files twice
   rglob("*") yields directories as well as files and tarfile.add() recurses by
   default, so anything nested was added once via its parent and again on its
   own iteration. Passes recursive=False; directory entries are still emitted,
   so nested staging (e.g. the tests/verifier.py + tests/*.json layout our
   pipelines emit as aux_files) keeps working.

3. Degenerate paths resolved to the checkout itself
   Repo2RLEnvAction.path defaults to "", and resolve_within() resolved "", ".",
   "   " and "a/.." to the working directory. For read that meant catting a
   directory; for write it was destructive — the target split into
   ("/", "workspace"), so an empty path would have dropped a regular file over
   the entire checkout. Rejected in resolve_within(), the single chokepoint
   every agent-supplied path passes through.

4. AGENT_ACTIONS / CONTROL_ACTIONS were decorative
   Exported but never referenced, and the test asserting them merely restated
   the same literals, so it would have passed no matter what the server
   handled. ActionType is now composed from AgentActionType | ControlActionType
   rather than restating the five strings, and the test asserts the two sets are
   a partition of the environment's actual handler table.

Verification: each regression test was confirmed to fail against the unfixed
code before being kept. Also re-ran the Docker end-to-end with a task whose
tests/ has a nested subdirectory — the verifier reads tests/helpers/score.py,
proving recursive=False did not break nested staging — and confirmed an empty
write is now rejected with the checkout left intact.

Tests: 39 (6 added). Full suite 739 passed.
A maintainer reviewed huggingface/OpenEnv#1018, which carries a structurally
parallel runtime, and raised three issues. Two apply here in full; the third
(local-mode escapes) does not, since this runtime is Docker-only.

## Task policies were ignored

`dataset.py` now parses `[environment].network_mode` / `cpus` / `memory_mb` /
`gpus` / `workdir` and `[agent].user` / `[verifier].user`, including the
deprecated `allow_internet` spelling.

`_container_limits()` translates them into `containers.run` kwargs: no-network
becomes an isolated network, cpus/memory become real container limits, and each
phase runs as its declared user. What cannot be enforced faithfully is refused
rather than approximated — `allowlist` needs a filtering proxy and `gpus` needs
accelerators, so both point the caller at `harbor run`. An unrecognized
network_mode is a parse error, never a silent downgrade to `public`: quietly
granting a sandboxed task the internet is the exact failure this prevents.

Our pipelines do not emit these fields today, but the format allows them and a
task that declares one was being run unconstrained and scored anyway.

## The episode never terminated, and the action split was decorative

- After `evaluate`, `step()` refused nothing: a later `exec` returned
  `done=False`, contradicting a terminal state already reported to the caller.
  The episode is now terminal until `reset()`.
- `allow_control_actions` (env `REPO2RLENV_ALLOW_CONTROL_ACTIONS=0`) makes the
  server refuse `evaluate`/`solve`, so the agent/orchestration split is enforced
  rather than left to the caller's wiring.

## Two leaks found while verifying the above

- `HARBOR_TESTS_DIR` / `HARBOR_SOLUTION_DIR` / `HARBOR_LOGS_DIR` were exported
  into agent commands, handing a policy the location of the grading logic and
  the answer. `SandboxPaths.as_env(agent_visible=True)` withholds them; the
  verifier and oracle still receive the full set.
- `stage_tests` / `stage_solution` now wipe the destination first. The agent
  shares the container and can pre-create `/tests`; anything planted there
  previously survived staging and was read by the verifier.

Tests: 6 added (45 total). Full suite 740 passed. `tests/test_e2e_public.py::
test_e2e_public_trl` fails on a clean tree too — it queries live GitHub and
TRL's two most recent PRs are currently docs-only.
Follow-up to the policy work: honouring [agent].user broke every task that
declares one. The Harbor directories are created by the image's default
account, so an agent running as a non-root user inherited a working directory
it could not write to. The same applied to [verifier].user and /logs/verifier,
which would have left those tasks unscorable.

DockerSandbox.chown() now hands the working directory and agent log dir to the
agent user at start, and the verifier log and tests dirs to the verifier user
before the verifier runs. Confirmed against a live container in the sibling
OpenEnv PR, where the identical bug was found first.

Tests: 46 (1 added). Full suite 740 passed.
An independent review of the sibling OpenEnv PR found that several of the
earlier hardening fixes were incomplete. The same gaps existed here.

- `Repo2RLEnvState.task_path` carried the host path to the task directory, and
  `state` rides the same socket as `step`. A caller that could act could read
  the path and reach `solution/patch.diff`. The field is gone.

- `read_text()` ran `cat` as the image's default account and `write_text()`
  extracted a root-owned tar through the archive API, so `[agent].user` was
  bypassed by both actions. In the sibling PR this was demonstrated with a
  symlink out of the working directory: `read` returned `/etc/shadow` and
  `write` created a root-owned file under `/root`. Both now run as the declared
  user, with writes going through the shell so the kernel applies permissions.

- `evaluated` was set only after the verifier ran, so a failure during staging
  left `/tests` readable with the episode still running. The episode now
  terminates before anything that can fail.

- Agent-supplied `timeout_s` is capped at the configured ceiling instead of
  overriding it; it arrives on the wire and was unbounded.

Tests: 48 (2 added). Full suite 740 passed.
Cursor and Copilot both flagged this independently on the sibling OpenEnv PR,
and the same hole existed here. `resolve_within()` is a purely lexical check, so
it cannot see a symlink the agent planted with `exec`:

    exec  ln -s /tests link
    read  path="link/test.sh"   -> the grader's source

That defeats the documented confinement of `read`/`write` — the agent could read
the grading logic, and `write` could plant files the verifier then executes.

Agent paths now go through `DockerSandbox.resolve_agent_path()`, which keeps the
lexical check and then canonicalizes inside the container with `readlink -m`.
`-m` rather than `-f` because a `write` may legitimately create directories that
do not exist yet, while symlinks that *do* exist are still resolved — the only
part confinement depends on. The base is canonicalized too, since the working
directory may itself sit behind a link.

Tests: 49 (1 added). Full suite 740 passed.
@adithya-s-k

Copy link
Copy Markdown
Collaborator

@thegovind thanks for this, and the generator side has been working well for us: every dataset we
validated OpenEnv#1036 against came out of Repo2RLEnv, including
AdithyaSK/data_agent_rl_environment_train and AdithyaSK/repo2rlenv-pr-diff.

One thing worth knowing, since it did not exist when you opened this PR. OpenEnv#1036 adds Harbor
support to the OpenEnv CLI itself, so a Harbor task dataset is already servable and already
deployable with no packaging step:

openenv harbor serve --dataset you/click-tasks --llm-url $VLLM
openenv harbor push  --repo-id you/click-env --dataset you/click-tasks --llm-url $VLLM

That covers both rows of your table, including the Space case. What you get through it is
multi-harness training: the same dataset runs under the agents Harbor already installs, on Harbor's
sandbox backends, and a rollout comes back with per turn token ids and logprobs, so the dataset is
usable for training rather than only evaluation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants