feat(v1): Harbor separate verifier environments - #2152
Conversation
ApprovabilityVerdict: Needs human review This PR introduces a significant new feature (separate verifier environments) with new configuration options, new grading workflows, and retry logic. Additionally, there is an unresolved bug report about teardown failures potentially discarding valid scores. The scope and unresolved issue warrant human review. You can customize Macroscope's approvability policy. Learn more. |
56a8704 to
d3ee132
Compare
Three runtime primitives an isolated grading box needs, none of which have a caller yet. `provision_runtime` is the start/stop context manager `Agent.provision` already had inline, lifted so a taskset can provision a box without an agent seat. `Agent.provision` now delegates to it, so "start() inside the try" lives in one place. `stop_confirmed` / `teardown_confirmed` are the strict counterpart to `stop` / `teardown`. Teardown is best-effort on purpose: a leaked container is a billing problem, and failing a rollout over one would be worse. But a caller that provisions a second box needs the first one *gone* first — an agent can leave a background process running past its final turn, and a grading box that comes up alongside it is not isolated from it. Docker asks the daemon whether the container is still listed rather than trusting a suppressed `rm --force`; Prime raises on a failed delete instead of logging it. Runtimes that can't prove removal inherit a `NotImplementedError`. `read_bounded` truncates in the box via `head -c` rather than after the transfer, for reading a scoring input whose size we can't assume. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`RolloutRun` has always handed `task.score` the agent's own runtime, so a taskset's grader runs where the agent had root. That is reachable: an agent-planted `/testbed/conftest.py` with a `pytest_runtest_makereport` hook forcing `passed` scores 1.0 with no fix applied (verified on r2e-gym). Hiding the grading script only moves the target — pytest's plugin surface alone offers conftest, sitecustomize, ini files, and the venv's own entry points. `Task.scoring_runtime` returns a context manager to score inside, or None to keep today's behaviour. The task owns whatever has to survive the move, via collect/restore while its own box is still up. Three deliberate choices in the rollout: - Resolved in `open()`, before the box boots, so a task that scores elsewhere but borrowed its runtime or has shared tool servers fails before anything is provisioned rather than after a full agent run. - Harness and task scoring serialize instead of gathering. The harness's metrics describe the agent's session, so they are read off the agent's box before the task's context manager can tear it down. - Provisioning happens inside the scoring deadline. A grading box that can't be reached in time raises a scoring timeout; it must never become reward 0, which would look like a failed attempt. Harness cleanup is skipped when the runtime is already stopped, since reaching a second box means the first one is gone. should not exist. That holds for the agentic judge, which composes seats it already owns. It does not extend to a taskset's own programmatic grader: `task.score` runs inside the rollout, where no env can reach it. No caller yet — Harbor is the first, in the next commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Harbor tasks can declare that their verifier runs in its own container (`[verifier].environment_mode`, `[verifier.environment]`). We ignored the first and rejected the second, so a task that asked for isolation was graded in the box the agent had just had root in. Harbor resolves the verifier's environment as `[verifier.environment]` if declared, else a deep copy of `[environment]`. That gives three cases, and only the third is a problem: - mode-only separate → the task's own image, so a second box is all it takes; - a declared environment with a `docker_image` → pull that instead; - a declared environment *without* one → Harbor builds the verifier image from `tests/Dockerfile`. Verifiers pulls and never builds, so this is rejected with a message saying to build and push the image and name the ref. `ignore_dockerfile` remains the one escape hatch, and now warns, because falling back to the agent's image runs the verifier somewhere the task never declared. Only declared artifacts cross over, which is Harbor's own model (`Trial._run_separate_verifier` uploads artifacts and nothing else) — so #2144's `collect`/`restore` and its 32 MB budget carry this unchanged. Artifacts are restored before `tests/` is staged, and `/tests` is wiped first: an artifact entry pointing into `/tests` would otherwise hand the agent the grader's own scripts, and a fresh container of the task image can ship a stale `/tests` of its own. `[[verifier.collect]]` keeps working here. The hooks run in `finalize`, in the agent's box, producing exactly the files that then travel — the two compose, so unlike #2067 there is no need to reject them. Two smaller changes: - The verifier's declared network mode is enforced rather than warned about, via the `allow`/`block` policy the runtimes already have. `allow_internet = false` means the grader really has no network, which is the point of declaring it. - A failed `tests/` staging now raises instead of leaving `test.sh` missing and scoring 0. A false zero reads as a failed attempt. `--taskset.ignore-separate-verifier` forces shared grading when a sandbox per task is too expensive. Design and prior art: xeophon in #2067, rewritten against #2144. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Harbor's separate verifiers write `/logs/verifier/reward.json`, not `reward.txt`. Without reading it every separate-mode task would score 0 — a false failure, indistinguishable from a real one. Two shapes, both of Harbor's: a bare number, or an object of numbers where a `reward` key is the scalar and any others are extra metrics that v1 records per key. Anything else — a string value, a bool, a list, malformed JSON, no file — falls through to `reward.txt`, so shared mode behaves exactly as before. Read through `read_bounded`: a grading input has no size we can assume. Checked against 14 payload shapes with a stubbed runtime (scratch script, not committed, per AGENTS.md). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
d3ee132 to
7377697
Compare
mikasenghaas
left a comment
There was a problem hiding this comment.
bunch of stuff in here that is not harbor related no?
There was a problem hiding this comment.
wait also, we dont use agentic judge env for this right but reward funcs? we should defo use our multi-agent machinery for this kinda stuff
edit: @xeophon tells me we do. can we add a section in the pr desc which shows how to wire up and eval config for a harbor taskset w/ agentic judge through agentic judge env and results on actually running this e2e
Resolves conflicts with main's independently-landed reward.json support: - rollout.py: keep the separate-scoring branch, timeouts now via RolloutTimeouts - harbor/taskset.py: adopt main's REWARD_JSON_ADAPTER (pydantic strict, no inf/nan) and keyed-reward metrics recording; keep the PR's bounded read, staging split, and separate-verifier machinery; drop the hand-rolled _finite_number parser superseded by the adapter Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review feedback on the separate-verifier PR: - test.sh now runs by absolute path in the runtime's configured workdir instead of behind a hardcoded cd /tests. Harbor's exec cwd is the environment's workdir, and real tasks (e.g. terminal-bench-2) grade the agent's work at $PWD — grading them in /tests scores the wrong tree. - The reward-file cleanup moves into _stage_tests, which raises on failure: chained before test.sh with &&, a planted reward file the agent made unremovable would skip the tests but still be read as the score. - parse_task and parse_verifier_environment share one task_resources helper instead of two copies of the Harbor-resource conversion. - policy_base is now network_base; in this repo 'policy' means the model. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review feedback: one read API instead of two. read grows an optional max_bytes that enforces the cap inside the box before transfer; runtimes now implement _read (the whole-file primitive) and inherit the capped path. Docker and Prime teardown_confirmed gain docstrings tying them to the base contract a separate grading box relies on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
reward.json's keyed form records non-reward keys as metrics (main's behavior since #2224), not as separate rewards. The tests/Dockerfile shortcoming now says what is actually missing: only Harbor's local image build — a pre-built, pullable separate verifier image is supported. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review consensus: map Harbor's separate-verifier pattern onto the env layer (the isolated-judge shape) instead of a Task hook the rollout special-cases. - HarborEnv, the taskset's own default env: solver seat plus a verifier seat for tasks declaring [verifier].environment_mode = "separate". The seat provisions a fresh box resolved from minted task data (image, workdir, resources, network policy — the same compilation the solver's box gets), restores the solver's collected artifacts, stages tests/, grades, and finalize records the verifier's rewards onto the solver's trace. Placement defaults to the solver's runtime policy; --env.verifier.runtime.* overrides. - noop harness: a seat that runs no program and never calls the model, for rollouts whose work is entirely task hooks. - HarborVerifierTask: minted per episode from the solver's trace; the verifier box is task data, not hand-derived runtime config. - HarborTask.solved fails closed under any other env instead of silently grading a separate-verifier task in the agent's box. - Dropped: Task.scoring_runtime, the rollout's serial-scoring branch, and stop_confirmed/teardown_confirmed — under the agreed threat model the guarantee is that the grader never executes in a box the agent controlled, which the fresh box alone provides; nothing called the confirmed-teardown pair anymore. Verified against live Docker: guard fires outside the env; solver leg records no reward and collects artifacts; verifier leg grades in a fresh box (declared artifact travelled, undeclared leftover did not), reads reward.json, records extra keys as metrics, runs zero model turns; the declared-image case boots the declared image; shared-mode grading unchanged. 910-test suite green; ruff/ty clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-up: no verifier seat, no noop harness — the verifier is a deterministic script, so the env grades it directly instead of dressing it as an agent rollout. - HarborEnv.finalize provisions the grading box (provision_runtime), restores the solver's collected artifacts, stages tests/, runs the verifier, and records its rewards — extra reward.json keys as metrics — onto the solver's trace. - verifier_runtime on HarborEnvConfig places the grading box (None derives it from the solver's runtime policy); verifier_retries retries the infrastructure around grading without re-running the solve. - HarborVerifierTask collapses to verifier_box_data — the minted task data the grading runtime resolves from — plus the existing staging and grading methods on HarborTask. - The noop harness is deleted; run() still resolves the verifier box before the solve so an impossible pairing costs nothing. Same Docker smoke as before, now through the real HarborEnv.finalize: guard fires outside the env, marked solver leg records nothing, artifact travels while the undeclared leftover does not, keyed reward lands on the solver's trace, declared verifier image boots. Suite green; ruff/ty clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| # Artifacts first, tests second: an artifact entry pointing | ||
| # into /tests must not survive staging, which wipes and | ||
| # rebuilds that directory. | ||
| await restore(box, solution.state.artifacts) |
There was a problem hiding this comment.
btw at some point i think we could have smth like Runtime.restore(artifacts: Artifacts), prob implemented using upload primitives in the base runtime but fine for now
- verifier_retries rejects negative values (ge=0) instead of asserting after zero attempts - the capped read goes through a temp file, not a pipe: head | base64 exits 0 on a missing path, so the capped and uncapped reads disagreed on failure — both raise now - the scoring deadline covers provisioning and grading but no longer the box's teardown, which could run out the clock and discard a score already in hand - HarborEnv.run raises on a non-Harbor task instead of quietly running it Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 59b9bfd. Configure here.
| scores = await grader._graded(box, solution) | ||
| return scores | ||
| except Exception as e: # noqa: BLE001 - each attempt's failure is retried | ||
| last = e |
There was a problem hiding this comment.
Teardown failure discards graded score
Medium Severity
return scores still exits through AsyncExitStack, so a raising stop()/teardown is caught by the broad retry except and discards a score already in hand. The scoring timeout was moved off teardown, but teardown exceptions still retrigger a full reprovision and regrade, and can fail the episode despite a successful grade.
Reviewed by Cursor Bugbot for commit 59b9bfd. Configure here.


Wires up Harbor's
[verifier].environment_mode = "separate"and[verifier.environment], which we currently ignore and hard-reject respectively — so a task that asks to be graded away from the agent is graded inside the box the agent just had root in. Design and prior art are @xeophon's (#2067).Why
A grader running in the agent's box is attackable by the thing it grades. Verified on r2e-gym: an agent-planted
/testbed/conftest.pyforcing pytest's results scores reward 1.0 with no fix applied. It never touches the grading script or the tests, so hiding those does nothing — the fix is grading in a different box.How
HarborEnv(tasksets/harbor/env.py), now the taskset's default env:agenttrace, graded in the box it worked in./logs/artifacts/convention dir are collected while its box is alive.finalizethen provisions a fresh box from the task's verifier declaration, restores those artifacts, stagestests/fresh, runs the verifier there, and records its rewards onto the solver's trace (extrareward.jsonkeys become metrics).--env.verifier-runtime.*places the grading box (default: derived from the solver's runtime policy; a network-restricted verifier on Prime needsvm true).--env.verifier-retries(default 2) retries provisioning/grading without re-running the solve; exhausted retries fail the episode — a grading box that can't be reached never reads as reward 0.--taskset.ignore-separate-verifierforces shared grading.Which image the verifier boots follows Harbor:
environment_mode = "separate", no[verifier.environment][verifier.environment]withdocker_image[verifier.environment]withoutdocker_imagetests/Dockerfile; build and push it, orignore_dockerfileAlso in here
test.shruns by absolute path in the configured workdir, like Harbor — real tasks grade the agent's work at$PWD, and the oldcd /testsgraded the wrong treeruntime.read(path, max_bytes=...): bounded reads with the cap enforced inside the box, for grading inputs whose size nothing guarantees (replacesread_bounded)task_resourceshelper instead of two copies of the Harbor resource conversionEarlier revisions did this with a
Task.scoring_runtimehook, then an env verifier seat; review moved it to plain env code — see the threads.Verification
No public dataset exercises this feature (104k cached
task.tomls, Harbor Hub, andlaude-institute/harbor-datasetsdeclare zero separate-mode tasks), so: local fixtures driven through the realHarborEnv.finalizeagainst live Docker. Fails closed outside the env; the declared artifact travels while an undeclared leftover does not; stale/testsis wiped; keyed rewards land on the solver's trace; a declared verifier image is actually used (python 3.12 vs the task's 3.11); shared grading unchanged. Full test suite, ruff, and ty green. Not verified on Prime.🤖 Generated with Claude Code
Note
Medium Risk
Changes eval integrity and sandbox lifecycle (isolated grading, artifact restore, retries); misconfiguration could fail episodes or weaken isolation via
ignore_separate_verifier, but agent auth/payments are untouched.Overview
Harbor tasks with
[verifier].environment_mode = "separate"can now be graded in a second sandbox the agent never used. The new defaultHarborEnvruns the solver as today, collects artifacts in the agent box, then infinalizeprovisions a verifier box (from[verifier.environment]or a copy of[environment]), restores artifacts, wipes and stagestests/, runstest.sh, and writes rewards onto the solver trace.--env.verifier-runtime.*and--env.verifier-retriescontrol placement and infra retries; outside this env, separate-verifier tasks error unless--taskset.ignore-separate-verifierforces shared grading.Parsing no longer rejects separate verifier environments:
VerifierConfig,verifier_box_data, andgraded_elsewhere()skip in-agent scoring when the env owns grading. Declared verifier envs without a pullabledocker_imagefail at load (or warn withignore_dockerfile).Runtime/helpers: shared
provision_runtimecontext manager;Runtime.read(..., max_bytes=...)with in-sandbox cap (backends implement_read). Harbor scoring runsbash /tests/test.shfrom the task workdir, clears stale reward files during staging (fail loud), and caps reward file reads at 1 MiB.Reviewed by Cursor Bugbot for commit 01f0e76. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add separate verifier environment support for Harbor tasks
Task.scoring_runtimehook andprovision_runtimecontext manager in runtimes/init.py manage the verifier box lifecycle; harness scoring runs on the agent box first, then task scoring runs in the verifier box.VerifierConfigfrom Harbor task config (image, resources, workdir, network allowlist,fresh_copyflag) and builds a matching Docker or Prime runtime config for the verifier box.HarborConfiggains anignore_separate_verifierflag to force grading in the agent box regardless of task config.Runtime.readgains an optionalmax_bytescap enforced viahead -cinside the sandbox; reward file reads use this cap.stop_confirmedandteardown_confirmedare added toRuntimeso the rollout can verify the agent box is gone before starting the verifier.TaskErrorif a separate scoring box is requested.Changes since #2152 opened
NoopHarnessandNoopHarnessConfigclasses from theverifiers.v1.harnesses.noopmodule and eliminated their exports from theverifiers.v1.harnessespackage [be6573f]HarborEnvwith direct grading execution during finalize [be6573f]HarborVerifierTaskclass fromverifiers.v1.tasksets.harbor.tasksetmodule [be6573f]verifier_box_datautility function to derive verifier box configuration from task data [be6573f]HarborEnv.finalizemethod [59b9bfd]Runtime.readmethod whenmax_bytesis provided [59b9bfd]verifier_retriesand type checking fortaskparameter [59b9bfd]HarborTask.finalizemethod [01f0e76]Macroscope summarized f29df85.