Conversation
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.
|
@thegovind thanks for this, and the generator side has been working well for us: every dataset we One thing worth knowing, since it did not exist when you opened this PR. OpenEnv#1036 adds Harbor openenv harbor serve --dataset you/click-tasks --llm-url $VLLM
openenv harbor push --repo-id you/click-env --dataset you/click-tasks --llm-url $VLLMThat covers both rows of your table, including the Space case. What you get through it is |
Summary
Repo2RLEnv can already produce Harbor task directories. This PR makes it able to produce the environment that serves them.
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()/stateAPI — deployable as a Docker image or a Hugging Face Space.The task data is not touched.
exportcopies the task directories byte for byte and adds a serving layer around them, so the same dataset still runs underharbor runand under OpenEnv's own genericharbor_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:#1565c0Two ways to reach OpenEnv, and they are complementary:
repo2rlenv export --format openenvharbor_envBoth 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/<id>/...<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:2pxThe package is deliberately thin config, not generated logic.
server/app.pyis: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 spaceInvariants held
reward=Noneplus an explicit error — not a0.0. A fabricated zero is indistinguishable from a real failure and would quietly poison a training run.exec/read/writeare the agent's action space;evaluateandsolveare orchestration. An agent that could grade on demand could end its own episode; one that couldsolvecould read out the answer.resolve_within()and confined to the working directory, so/testsand/solutionare unreachable./logs/verifieris cleared immediately before the verifier runs, so a reward file written by the agent duringexecnever 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-envis a 404 on PyPI, onlyopenenvitself is published. An emitted image cannotpip installit, so binding to it would have coupled Repo2RLEnv releases to an unmerged OpenEnv PR. The emitted package depends only onopenenv(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:#c62828local_onlyandinline_dockerfilerefs may exist on no machine but the one that generated the task, so onlyregistryis 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 emitter —
version = "1.0",[metadata.repo2env],environment/DockerfilewithWORKDIR /workspace,tests/test.shwriting/logs/verifier/reward.txtand endingexit 0— and run through every runtime.solution/solve.sh)harbor run(Harbor 0.20.0)repo2rlenv export --format openenvharbor_env,dockermodeThe decisive check was done on a built container, not in-process:
docker buildon the emitted Dockerfilereset→write_file→evaluatereward-details.jsonarrived intact asobservation.info["reward_details"](F2P/P2P counts,resolved,parse_status), and the working directory resolved to/workspacefrom the image.Tests
tests/test_openenv_export.pyruff check/ruff format --checkclean;mkdocs buildcleanKeeping
openenvoptionalThe default install must stay lean, and
exportitself 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:#f9a825repo2rlenv.openenvresolves its serving names lazily via a module-level__getattr__, so importing the package or the emitter does not importopenenv. There is a test that asserts exactly this by subprocess: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 runsuv 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:#6a1b9asrc/repo2rlenv/openenv/*src/repo2rlenv/emitter/openenv.pysrc/repo2rlenv/cli.pytests/test_openenv_export.pydocs/reference/OPENENV.md,README.md,RELATED_WORK.md,mkdocs.ymluv.lock19 files, +4,099 / −4. Excluding
uv.lockthat is ~2,570 lines, roughly a third of which is tests.Suggested review order
docs/reference/OPENENV.md— the whole design in prose, start heresrc/repo2rlenv/openenv/environment.py— the state machine and the action-boundary invariantssrc/repo2rlenv/emitter/openenv.py— what gets written and why it is thinsrc/repo2rlenv/openenv/reward.py— the forward-never-synthesize ruleDocs
docs/reference/OPENENV.mdpreviously 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 withharbor_env— with the shared point made explicitly: the task data is identical either way.README.mdandRELATED_WORK.mdwere updated to say we now emit OpenEnv rather than only being compatible with it.Known caveats
repo2rlenv[openenv]>=<installed version>, but theopenenvextra 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.exportserves 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.pr_difftasks (emit_harbor_env=False) ship no image and no verifier.exportcounts them, warns, and notes in the Space card that they are scored againstsolution/patch.diffclient-side rather than executed.uv.lockgrows by 1,529 lines. That is uv locking the new extra's transitive tree;uv sync --group dev --frozenstill 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 publishedopenenvpackage and does not require that one to land.