fix(harbor): require Harbor E2E on PRs, de-flake trajectory_criteria fixture - #187
Conversation
…fixture - Add pull_request to harbor-e2e.yml's triggers and add its job as a required status check in the "Protect main" ruleset, so a new e2e scenario gets PR signal instead of only surfacing on push to main. - The trajectory_criteria fixture's touch done.txt step was reported as a redundant "confirm it exists" action, which a capable agent could skip entirely -- reproduced twice with an identical command_executed failure (reward 0.5) after #186 merged. Reworded the prompt so running touch is the only way to create the file, removing the ambiguity. - harbor_e2e.py now surfaces every criterion's own score/details on any reward mismatch, not only when an unrelated criterion masks a failing one -- the previous ordering hid this exact failure's root cause. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Built a synthetic run directory with a hand-authored TurnRecord carrying a Bash `touch done.txt` CommandTelemetry entry and graded it through the exact `coder-eval evaluate /tests/task.yaml <run_dir> --in-place` path test.sh uses: it matched 1/1 and scored 1.0, so #186's trajectory-hydration path itself is not the bug. The fixture was: the file_exists criterion never checks content, but the prompt still asked the agent to "write the word done into it" after touching the file, and allowed_tools included Write -- both nudged the agent toward creating the file with Write and skipping the shell command this scenario exists to verify was hydrated, which is exactly what happened twice in a row on PR #187's own Harbor E2E run (command_executed matched 0/1). Restricting allowed_tools to Bash only and dropping the pointless content step removes the alternative path entirely. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
trajectory_criteria failed a THIRD time with command_executed still matching 0/1, even after restricting allowed_tools to Bash only (no alternative tool the agent could have used instead). That rules out my prior fixture-wording theory and points at the recorded trajectory itself carrying no matching Bash telemetry through this real pipeline -- something my hand-built synthetic run_dir (which fabricated the telemetry directly) never exercised. Dump /logs/agent/task.json's own iterations[].commands on any reward mismatch, so the next run shows directly whether the agent phase recorded zero Bash commands at all (a real hydration/telemetry bug) versus commands that just didn't match the pattern. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Print-statement diagnostics only show what the script thought to ask for and die with the runner. On any scenario failure, zip its whole export/+jobs/ tree (docker/agent logs, every task.json/trajectory.json, artifacts/ workspaces) and upload it as a build artifact, so a failure like trajectory_criteria's can be inspected directly instead of guessed at. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Root cause of the template_sources Harbor E2E failure (reward 0.0, "No module named pytest"): Sandbox.capture_to -- the docker-WORKDIR-alignment copy-out used by Harbor's CoderEvalAgent (--workspace-dir) -- excludes .venv/node_modules/.npm-prefix from the copy as noise (_WORKSPACE_CAPTURE_IGNORE). The execute phase's venv (with env_packages installed) is real and used successfully during the agent's own run, but is then silently gone by the time grading adopts the captured workspace: Sandbox.adopt() only ever DISCOVERED an existing venv, so a run_command criterion graded against a bare interpreter with none of env_packages installed. Verified by hand-building a run directory and running the exact grading command test.sh uses, and separately by running the real CLI end-to-end locally -- both showed env_packages surviving the config resolution correctly, which ruled out the merge layer and pointed at capture_to's noise-exclusion list instead. adopt() now falls back to _setup_virtualenv/_install_packages (or _install_node_packages) when the expected directory is missing AND env_packages is non-empty, gated on env_packages specifically so the common empty-env_packages default still adopts as a pure no-op. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Real CI reproduced the agent running `touch /work/done.txt` (its own absolute cwd) both times this fixture ran, not the bare relative `touch done.txt` the pattern required -- an entirely reasonable agent choice, not a bug in the agent or the hydration path. Broaden the command_pattern to match a path prefix before done.txt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…budget cap The docs-budget lint failed on a 13-line comment run in Sandbox.adopt() (cap is 8); the full rationale already lives in .claude/notes/isolation.md, so the code comment only needs to summarize and point there. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
uipreliga
left a comment
There was a problem hiding this comment.
Review: coder_eval — pr:187 (6 files) axis:1,2,3,4,5,6,7,8
Scope: pr:187 (6 files) axis:1,2,3,4,5,6,7,8 · branch akshaya/harbor_trajectory_fix · 59d32a2 · 2026-09-17T18:48Z · workflow variant
Change class: complex — adds a new re-provisioning control-flow branch to Sandbox.adopt() (an isolation-sensitive path whose whole design is 'non-materializing steps only'), and changes CI trigger semantics by making a real-Docker/real-model E2E job a required pull_request gate; both need correctness reasoning, not a glance.
The architecture, API surface and type discipline remain excellent (9.9/9.9/9.4) and the underlying adopt() re-provisioning fix addresses a real, live-confirmed wrong-verdict bug, but the PR ships that fix by teaching the grading path to run installers from an untrusted recorded config while leaving the consent gate — and the test that asserts its exemption — untouched, with no rollback on a failed install, no fork guard on a newly-required pull_request workflow, and rationale notes that credit the fix with a Harbor failure it provably cannot reach; the bottom line is that the code change is right in spirit but must not merge until the _gate_scope_for_grade premise, the packager's missing verifier sandbox: block, and the failure-path cleanup are resolved.
Summary
| Axis | Score | 🔴 | 🟠 | 🟡 | 🔵 | Top Issue |
|---|---|---|---|---|---|---|
| 1. Code Quality & Style | 9 / 10 | 0 | 0 | 2 | 0 | assert_scenario_artifacts complexity pushed C(12)→C(17) by inlining a diagnostics block that duplicates three file reads/globs the function already performs ~37 lines later |
| 2. Type Safety | 9.4 / 10 | 0 | 0 | 1 | 1 | New CI-script code becomes a required PR gate while .github/scripts/ sits outside the pyright pass (and the pytest pass) that Makefile:18-21 claims covers it |
| 3. Test Health | 8 / 10 | 0 | 1 | 2 | 0 | adopt()'s new Node re-provisioning arm (sandbox.py:330-331) is untested across the whole suite while it rewrites package.json + a lockfile in the graded tree; the "existing venv is never rebuilt" inverse is also unpinned |
| 4. Security | 7 / 10 | 1 | 0 | 0 | 0 | harbor-e2e.yml gains an unconditional pull_request trigger with no fork carve-out: fork code would run on the shared self-hosted uipath-ubuntu-latest pool with Bedrock secrets, and the required gate can never go green without those secrets |
| 5. Architecture & Design | 9.9 / 10 | 0 | 0 | 0 | 1 | The Python and Node re-provisioning branches drift in shape (a 115-char triple-and line re-checking conditions the callee enforces) and "node_modules" is a bare literal beside VENV_DIRNAME |
| 6. Error Handling & Resilience | 7.5 / 10 | 0 | 2 | 1 | 0 | adopt() creates .venv/node_modules in a caller-owned workspace with no cleanup on a failed install, so a half-provisioned tree latches and the retry silently takes the discover branch |
| 7. API Surface & Maintainability | 9.9 / 10 | 0 | 0 | 0 | 1 | FAILURE_ARTIFACTS_DIR is coupled to the workflow's upload path by an unenforced string literal that fails silently |
| 8. Evaluation Harness Quality | 5.4 / 10 | 1 | 1 | 1 | 1 | adopt() now runs uv pip install / npm install from the untrusted recorded config on the in-place evaluate <run_dir> path, which _gate_scope_for_grade exempts from the --allow-recorded-commands disclosure (and its include_setup_phase = not grade_in_place premise that "adopt runs no installer" is now false) |
Overall Score: 8.3 / 10 · Weakest Axis: Evaluation Harness Quality at 5.4 / 10
Totals: 🔴 2 · 🟠 4 · 🟡 7 · 🔵 4 across 8 axes.
Blockers
- [Axis 3] adopt()'s new Node re-provisioning arm (sandbox.py:330-331) is untested across the whole suite while it rewrites package.json + a lockfile in the graded tree; the "existing venv is never rebuilt" inverse is also unpinned (
src/coder_eval/sandbox.py:331) — The PR adds one new conditional to the publicSandbox.adopt()(sandbox.py:322-331) and one new test. Two arms ship untested:
(a) Node arm — tool-confirmed dead in test: src/coder_eval/sandbox.py:330-331
if self.config.node and self.config.node.env_packages and not (self.sandbox_dir / "node_modules").is_dir():
self._install_node_packages()The routed coverage run lists 331 verbatim in sandbox.py's missing lines (tmp/code-review-260917-1149/automated/pytest.txt:238: ... 257-265, 331, 483, ...), i.e. line 330's condition is evaluated but never taken. It is structurally unreachable from this test file: the only workspace factory, tests/test_sandbox_adopt.py:21-29, always creates (ws / "node_modules" / "pkg").mkdir(parents=True), so not (... / "node_modules").is_dir() is False for every existing adopt test. The callee body is uncovered too (same coverage line lists 843-869, which is _install_node_packages's body starting at sandbox.py:841), so nothing in the suite exercises adopt → node install end to end.
(b) Inverse of the new guard not asserted (Technique 5): .claude/notes/isolation.md states "A venv that already exists (the ordinary preserve_to / non-captured path) is still only discovered, never rebuilt." No test pins that. test_adopt_discovers_an_existing_venv_without_creating_one (tests/test_sandbox_adopt.py:75-82) plants .venv/bin but declares python={"env_packages": []}, so the interesting combination — an existing .venv plus non-empty env_packages — never runs; a future edit turning elif self.config.python.env_packages: (line 326) into an unconditional re-provision would rebuild a graded workspace's venv with no failing test.
Fix (cheap, hermetic, matches the file's siblings): add two mock-based tests to tests/test_sandbox_adopt.py — one with a workspace that has no node_modules and node={"env_packages": ["left-pad"]} asserting _install_node_packages was called (and a second with node_modules present asserting it was NOT), and one with .venv/bin planted plus python={"env_packages": ["requests"]} asserting _setup_virtualenv/_install_packages were NOT called. patch.object(sandbox, "_install_node_packages") is already the established idiom here — see tests/test_node_env_config.py:65 and :124.
2. [Axis 4] harbor-e2e.yml gains an unconditional pull_request trigger with no fork carve-out: fork code would run on the shared self-hosted uipath-ubuntu-latest pool with Bedrock secrets, and the required gate can never go green without those secrets (.github/workflows/harbor-e2e.yml:16) — UiPath/coder_eval is PUBLIC (gh repo view --json visibility → "PUBLIC"), so anyone can open a fork PR. The diff adds, at harbor-e2e.yml:16-17:
pull_request:
branches: [main]while line 39 is an unguarded runs-on: uipath-ubuntu-latest. For a pull_request event GitHub runs the workflow definition and scripts from the PR head, so a fork PR's own .github/workflows/harbor-e2e.yml, .github/scripts/harbor_e2e.py, uv.lock, docker/Dockerfile (make docker-image, line 87) and templates/byod_smoke_test/ (docker build, line 90) all execute on the shared org pool — with Docker daemon access and, on a non-ephemeral runner, persistence into the next push:/schedule: run, which DOES carry AWS_BEARER_TOKEN_BEDROCK (lines 32-34).
This is not a theoretical bar: the repo already states and enforces the rule next door. .github/workflows/pr-checks.yml:40-43:
# Fork PRs go to stock GitHub-hosted runners: this job runs the PR's own
# `uv.lock` build hooks and test files, and the repo is public, so untrusted code
# must not land on the shared pool image. Everything else uses the pool.
runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'ubuntu-latest' || 'uipath-ubuntu-latest' }}harbor-e2e.yml now takes the same untrusted input with none of that protection, and its steps are strictly more privileged (two docker builds vs. pytest).
Fix, in order of preference:
- Gate the job on same-repo PRs only —
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository— since the job cannot pass on a fork anyway (fork PRs receive no secrets, soAWS_BEARER_TOKEN_BEDROCKis empty and the real model call fails). A required check that is guaranteed to fail for every external contributor is its own problem; skipping is the honest outcome. - Or copy pr-checks.yml:43's ternary verbatim so fork PRs land on
ubuntu-latest. - Independently: move
AWS_BEARER_TOKEN_BEDROCK/AWS_REGION/BEDROCK_MODELoff the workflow-levelenv:(lines 26-34) and onto only theRun Harbor E2E scenariosstep (line 92). Today they are in the environment ofnpm install -g @anthropic-ai/claude-code(line 57),pip install uv(line 72),uv sync(line 81) and bothdocker builds — every unpinned third-party install step. That pre-dates this PR, but the new trigger multiplies how often it runs and who can influence what runs there. CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H - [Axis 6] adopt() creates .venv/node_modules in a caller-owned workspace with no cleanup on a failed install, so a half-provisioned tree latches and the retry silently takes the discover branch (
src/coder_eval/sandbox.py:326) —adopt()now writes provisioning state into a directory it does not own, with no cleanup on failure and no staleness check on reuse:
elif self.config.python.env_packages:
self._setup_virtualenv() # line 327 -- creates <workspace>/.venv
self._install_packages() # line 328 -- may raise RuntimeError
if self.config.node and self.config.node.env_packages and not (self.sandbox_dir / "node_modules").is_dir():
self._install_node_packages() # line 331Two concrete failures:
-
Partial state latches.
_setup_virtualenv(line 795:self.venv_dir = self.sandbox_dir / VENV_DIRNAME) creates.venvfirst;_install_packagesthen raisesRuntimeError(f"Failed to install packages: {e.stderr}")(line 839) on a network/bad-package failure. Nothing removes the half-built.venv:_cleanup_on_exitis False by contract (line 307) and--resumedeliberately does not clear a to-grade row's artifacts (run_command.py: "to_grade is deliberately NOT cleared: its artifacts are what is being graded"). On the operator's retry, line 324if candidate.is_dir(): self.venv_dir = candidatetakes the discover branch — grading proceeds against a venv with none ofenv_packagesinstalled, which is precisely the silent-wrong-verdict this PR exists to fix, now permanent instead of transient. Same fornode_modulesafter a partialnpm install. -
Stale reuse across grades. No failure is even needed: grade #1 plants
.venvwithenv_packages: [requests]; the task YAML/variant later declares[requests, pyyaml]; grade #2 hits line 324 and discovers the old venv, so the second verdict is computed against the first grade's package set. Before this PR the grading path never wrote a.venv, so this stale-artifact class could not arise from coder_eval's own writes. (Cross-axis: this is the axis-8 "stale artifacts surviving a re-run task" blocker shape.)
Note the parallel path already gets this right: setup()'s identical _setup_virtualenv()/_install_packages() pair sits inside try: ... except Exception: at sandbox.py:395-405, which tears the tree down and re-raises.
Fix: wrap the new block so a failed provision leaves nothing behind — try: self._setup_virtualenv(); self._install_packages() except Exception: shutil.rmtree(self.venv_dir, ignore_errors=True); self.venv_dir = None; raise (and the equivalent for node_modules) — and, for the stale case, either record the resolved env_packages alongside the venv and rebuild on mismatch, or provision into a grader-owned directory outside the adopted workspace. Also update the now-false docstring at lines 268 and 275-276 ("materializing nothing into it", "it writes no FILES"), and add an error-path test next to test_adopt_reprovisions_env_packages_missing_from_a_captured_workspace (tests/test_sandbox_adopt.py:91) asserting no .venv survives a failed install.
4. [Axis 6] adopt()'s new re-provisioning can raise subprocess.SubprocessError (TimeoutExpired, and CalledProcessError from the stdlib-venv fallback), which neither grading caller catches — one row's failure aborts the whole --resume batch (src/coder_eval/sandbox.py:328) — The new self._install_packages() (line 328), self._setup_virtualenv() (line 327) and self._install_node_packages() (line 331) run network subprocesses on the GRADING path for the first time. Their own handlers only wrap CalledProcessError:
sandbox.py:837-839:subprocess.run(cmd, ..., timeout=300, env=env)insideexcept subprocess.CalledProcessError as e: raise RuntimeError(...)— a hung index leaves the 300s timeout to raisesubprocess.TimeoutExpired, which is not wrapped.sandbox.py:801-805:uv venvwithtimeout=60underexcept (subprocess.CalledProcessError, FileNotFoundError)— same gap; the fallbackvenv.create(..., with_pip=True)(line 811) raises a baresubprocess.CalledProcessErrorfrom ensurepip.sandbox.py:855-866:_install_node_packagesrepeats the shape withtimeout=300.
subprocess.TimeoutExpired.__mro__ is (TimeoutExpired, SubprocessError, Exception, ...) — verified locally: it is neither OSError nor RuntimeError nor the builtin TimeoutError. So it slips past both callers of adopt():
cli/run_command.py:817:except (RegradeError, OSError, RuntimeError, ValueError) as e:— the handler whose own comment (line 793) says "outside it, one bad task.json aborts the whole resume before run_batch, so none of theto_runtasks execute either". A 300s-timeout install on ONE row now aborts the entire resume, including every task still queued to execute.cli/evaluate_command.py:490-491:result = asyncio.run(_setup_and_run())/except RegradeError as e:— here even the ordinary wrappedRuntimeError("Failed to install packages: ...")escapes, so any install failure prints a raw traceback instead of the✗ <message>rendering that handler exists for ("unwrapped, these operator-facing messages arrived as the tail of a stack trace").
Fix: wrap the new block in adopt() so every provisioning failure leaves as one declared class — catch subprocess.SubprocessError (or Exception) around lines 327-331 and re-raise as RuntimeError(f"Could not re-provision env_packages for {self.sandbox_dir}: ...") — and add RuntimeError (or RegradeError) coverage to evaluate_command.py:491 so a grade-time provisioning failure degrades to a named operator error rather than a traceback. Fixing the wrapping inside _install_packages/_install_node_packages/_setup_virtualenv themselves (except (subprocess.CalledProcessError, subprocess.TimeoutExpired)) fixes both call sites at once.
5. [Axis 8] adopt() now runs uv pip install / npm install from the untrusted recorded config on the in-place evaluate <run_dir> path, which _gate_scope_for_grade exempts from the --allow-recorded-commands disclosure (and its include_setup_phase = not grade_in_place premise that "adopt runs no installer" is now false) (src/coder_eval/sandbox.py:326) — The in-place grading gate was built on the premise that adopt installs nothing, and this PR falsifies it without touching the gate. src/coder_eval/orchestration/regrade.py:132-134 states the premise verbatim: "include_setup_phase is not grade_in_place. pre_run and the sandbox's own provisioning exist only on the --copy path; adopt runs no installer and the orchestrator skips pre_run, so in place they are not a capability the run dir has." embedded_commands puts exactly these commands behind that flag (regrade.py:232-238: if include_setup_phase: ... commands.append(f"uv pip install {' '.join(sandbox.python.env_packages)}")). And tests/test_detached_grading_boundaries.py:804-811 now LOCKS THE HOLE IN: """adopt() installs nothing, so in place these are not capabilities the run dir has...""" followed by task = self._task_with(python=PythonEnvConfig(env_packages=["attacker-pkg"])) / check_embedded_commands(task, tmp_path, allow_recorded_commands=False, include_setup_phase=False) — asserting NO refusal. Full reachable chain, no operator opt-in anywhere: coder-eval evaluate <shared_run_dir> -> _resolve_run_dir_or_work_dir (cli/evaluate_command.py:162) -> task_from_prior(..., allow_recorded_commands=False, grade_in_place=True) -> _gate_scope_for_grade returns include_setup_phase=False -> gate passes -> regrade_in_place -> grading_sandbox_config returns the recorded sandbox block verbatim (regrade.py:603 return task.sandbox.model_copy(deep=True)) -> sandbox.adopt(workspace) (regrade.py:990) -> the new elif self.config.python.env_packages: self._setup_virtualenv(); self._install_packages() -> _install_packages builds cmd = ["uv", "pip", "install", *self.config.python.env_packages] (sandbox.py:827) with entries the attacker chose in task.json's task_config.resolved. An attacker who shares a run directory omits .venv from it (they control its contents, and capture_to strips .venv anyway) and gets arbitrary code execution on the grader's host at install time (PEP 517 build hooks, npm postinstall), or simply injects --index-url/a URL/-e git+... since the list is splatted into argv unquoted. Fix: either exclude env_packages re-provisioning when the task came from an untrusted recorded config (thread the trust decision into Sandbox/grading_sandbox_config), or flip _gate_scope_for_grade to include_setup_phase=True unconditionally and update regrade.py:132-134 and test_the_in_place_path_is_exempt to match. Do not ship the code change while that test still asserts the exemption.
6. [Axis 8] The note credits the adopt() re-provisioning fix with a Harbor E2E failure it cannot reach: the exported tests/task.yaml carries no sandbox: block, so the verifier grades with env_packages == [] (.claude/notes/isolation.md:862) — The note claims (line 862-865): "Confirmed live: a Harbor E2E scenario's run_command criterion failed No module named pytest against a workspace whose agent phase had run pytest successfully moments earlier." That scenario is template_sources (the only fixture with env_packages: [pytest] plus command: python -m pytest tests/), and the fix cannot reach it. _write_verifier_task_yaml (src/coder_eval/harbor/packager.py:342-361) builds payload from exactly task_id, description, agent, initial_prompt, success_criteria (+ optional reference, run_limits, checker_context) — there is no "sandbox" key — so the verifier's /tests/task.yaml gets the default SandboxConfig, i.e. python.env_packages == []. I verified this by running coder-eval export tests/harbor_e2e/fixtures/template_sources.yaml: the emitted tests/task.yaml has no sandbox: key, while environment/task.yaml (agent phase) does carry python: env_packages: [pytest]. test.sh grades with that file (coder-eval evaluate /tests/task.yaml /logs/agent --in-place, packager.py:79), and an explicit task file wins over the recorded config (evaluate_command.py:154-156), so self.config.python.env_packages is empty at sandbox.py:326 and the new elif is dead on this path. The only in-tree coverage of the agent/verifier asymmetry is tests/test_harbor_packager.py:583 test_agent_phase_sandbox_preserves_python_and_limits — the agent phase only. Either carry sandbox.python/sandbox.node into _write_verifier_task_yaml (and add the packager test), or correct the note: as written it tells the next reader this class of failure is handled when the CI scenario that produced it is not.
Non-blocking, but please consider before merge
- [Axis 1]
assert_scenario_artifactscomplexity pushed C(12)→C(17) by inlining a diagnostics block that duplicates three file reads/globs the function already performs ~37 lines later (.github/scripts/harbor_e2e.py:124) — Verified withuv run radon cc -s:F 124:0 assert_scenario_artifacts - C (17)at the PR head vsF 118:0 assert_scenario_artifacts - C (12)onmain— the PR added 5 to the single most complex function in the file, and it is now the same function that both asserts and gathers diagnostics. The added block duplicates work the function already did below it, three times over:verifier_task_json = trial_dir / "verifier" / "task.json"appears at line 134 and again identically at line 174;verifier_result = json.loads(verifier_task_json.read_text(encoding="utf-8"))appears at line 137 and again at line 182 (the same file parsed twice per scenario); andagent_task_jsons = sorted((trial_dir / "agent").glob("**/task.json"))at line 150 duplicatesagent_task_jsons = list((trial_dir / "agent").glob("**/task.json"))at line 171, differing only insortedvslist. The block at 130-141 also runs unconditionally on every green scenario to build a string used only inside theif reward.get("reward") != 1.0:branch at 143 — and its justifying comment ("Read the per-criterion breakdown BEFORE the reward gate below, so a failing reward's own root cause ... is always in the failure message") is not a reason: the failure branch can read it itself. Fix: hoistverifier_task_json/verifier_result/agent_task_jsonsto single reads at the top of the function, and extract the two diagnostic formatters into module-level helpers (e.g._criteria_detail(verifier_result)and_agent_recorded_commands(agent_task_json)) called only from the failure branch — that removes the duplication and drops the function back toward its pre-PR grade. While there, replace the sentinel unionagent_commands: list[dict[str, object]] | str = "no agent/task.json found"(line 151) with a plain list plus a separate absence note, so the variable has one type. - [Axis 1] .claude/notes/isolation.md:867 justifies adopt()'s env_packages gate with an "asking for packages earns a venv" rule that
setup()does not actually have (.claude/notes/isolation.md:867) — Lines 866-868 read:gated on \env_packages`, not bare `config.python`, so the common empty-`env_packages` default still adopts as a pure no-op (matching `setup`'s own "asking for packages is what earns a venv" rule).setup()has no such rule. src/coder_eval/sandbox.py:377-383 isif self.config.python:→self._setup_virtualenv()unconditionally, and only thenif self.config.python.env_packages: self._install_packages()— i.e. bareconfig.pythonDOES earn a venv insetup; only the *install* is gated onenv_packages. The note therefore claims parity where the PR actually introduces a divergence: forpython: {env_packages: []}with a captured workspace,setupyields a venv (and a populatedvenv_dir/VIRTUAL_ENVon the criterion PATH) whileadoptnow yields none. CLAUDE.md makes.claude/notes/the thing a maintainer must read before changing this code, so a false claim here is the worst place for one. Fix: state the divergence instead of denying it — e.g. "gated onenv_packages, unlikesetup, which creates a venv for bareconfig.python; the empty-env_packagesadopt path deliberately stays a no-op because there is nothing to install and an empty venv would only shadow the interpreter" — and note the consequentVIRTUAL_ENV` asymmetry so the next reader does not rediscover it as a bug. - [Axis 2] New CI-script code becomes a required PR gate while
.github/scripts/sits outside the pyright pass (and the pytest pass) thatMakefile:18-21claims covers it (.github/scripts/harbor_e2e.py:n/a) — This PR does two things at once:.github/workflows/harbor-e2e.ymladdspull_request:\n branches: [main](making this script block every PR, per its own new comment "Required on every PR"), and it adds +61 lines toharbor_e2e.py— the most type-loose code in the file (three untyped.get()walks overAnyfromjson.loads, one hand-writtenlist[dict[str, object]] | strannotation, one-> Path | None).
No type checker covers that file. pyproject.toml [tool.pyright] declares include = ["src/coder_eval"], and Makefile:71 (inside verify:) runs a bare uv run pyright, so .github/scripts/ is never analyzed. Makefile:18-22 reads the other way and is what a contributor will believe:
# `.github/scripts/` is in scope on purpose: release tooling that lives in a real
# module (rather than inline in a workflow `run:` heredoc) is exactly what ruff,
# pyright and pytest can see — leaving it unlinted would forfeit the reason it was
# extracted.
LINT_PATHS := src/ tests/ .github/scripts/
LINT_PATHS feeds only ruff format (Makefile:25/69) and ruff check (Makefile:28/70). The typecheck: target (Makefile:45-55) never references it.
The fix is free today: uv run pyright --outputjson .github/scripts/ returns {'filesAnalyzed': 2, 'errorCount': 0, 'warningCount': 0} — both harbor_e2e.py and release_notes.py already pass. Add ".github/scripts" to [tool.pyright] include (or give the directory its own derived config the way typecheck: already does for the CE036 engine via .pyright-tests.json, which exists precisely because pyright's exclude beats a CLI file arg). If that is deliberately not wanted, correct the Makefile comment so it stops claiming pyright coverage the gate does not have.
Severity calibration: the Axis-2 Low anchor is "missing types on private helpers in cold paths"; this is filed one step up because as of this PR the file is no longer a cold path — it gates merges — and the repo's own documentation asserts a static gate that does not exist. No live defect exists today (the file type-checks clean), which is why it is not higher.
4. [Axis 3] The Harbor E2E fixtures are never parsed offline, yet this PR makes the live Docker+model run a required PR gate (tests/harbor_e2e/fixtures/trajectory_criteria.yaml:40) — This PR edits the fixture's criterion (tests/harbor_e2e/fixtures/trajectory_criteria.yaml:40):
command_pattern: "touch\\s+\\S*done\\.txt"and .github/workflows/harbor-e2e.yml adds pull_request: branches: [main], so a malformed fixture now blocks every PR. Nothing under tests/ validates these fixtures offline: grep -rn "harbor_e2e" tests/ matches only the fixture files themselves (no test module references them), and every YAML-validating test globs tasks/ exclusively — tests/test_yaml_migration.py:25 ((ROOT / "tasks").rglob("*.yaml")), tests/test_tags.py:137, tests/test_custom_lint.py:2960 and :4642. A bad key, a dropped required field, or an uncompilable regex therefore surfaces only after a full Docker-build + real-model CI round trip; note command_executed swallows a bad regex into a 0.0 score rather than raising (src/coder_eval/criteria/command_executed.py:270-276, details=f"Invalid command_pattern regex: {e}"), so the CI symptom is an opaque reward != 1.0, not a parse error.
Add a ~10-line offline test (e.g. tests/test_harbor_e2e_fixtures.py) parametrized over tests/harbor_e2e/fixtures/*.yaml that does TaskDefinition.model_validate(yaml.safe_load(...)) and re.compile()s every command_pattern/exclude_pattern. I ran exactly that against the PR HEAD and all four fixtures validate today (docker_baseline.yaml OK ['file_exists','file_contains','run_command'], trajectory_criteria.yaml OK ['file_exists','command_executed'], …), so the test lands green and is pure guard.
Separately, while editing: \S*done\.txt also matches touch notdone.txt / touch xdone.txt. touch\s+(\S*/)?done\.txt keeps the absolute-path tolerance the comment argues for (touch /work/done.txt) without loosening the filename itself.
5. [Axis 3] harbor_e2e.py's new failure-only agent-command dump does an unguarded json.loads (line 153) that can replace the reward/criteria root-cause message it exists to produce, and none of the ~61 new lines has any test (.github/scripts/harbor_e2e.py:153) — The PR adds ~60 lines that run only when a scenario has already failed, with no test anywhere (grep -rn "harbor_e2e" tests/ matches no test module) and no coverage instrumentation (the file is outside --cov=coder_eval; it does not appear in tmp/code-review-260917-1149/automated/pytest.txt at all). That is the class of code that breaks precisely when it is needed. One concrete defect is visible on a read:
agent_task_jsons = sorted((trial_dir / "agent").glob("**/task.json"))
agent_commands: list[dict[str, object]] | str = "no agent/task.json found"
if agent_task_jsons:
agent_result = json.loads(agent_task_jsons[0].read_text(encoding="utf-8")) # line 153Failure scenario: reward is 0.0 and the agent phase died mid-write leaving a truncated agent/**/task.json (exactly the crash shape this script exists to diagnose). json.loads raises JSONDecodeError, which nothing here catches; main()'s except Exception at line 231 then prints [name] FAILED: Expecting value: line 1 column 1 (char 0) and the reward/criteria root cause the new code was added to surface is lost. The same applies to the pre-gate read at line 137 (json.loads(verifier_task_json.read_text(...))), which now runs on the success path too and can turn a passing-but-odd verifier record into a decode error. The sibling helper already states the right rule and honours it — _zip_scenario_dir (lines 202-216): "Best-effort: a zip failure must never mask the real scenario failure it was trying to preserve evidence for", with except OSError.
Fix: wrap each diagnostic read in try/except Exception and fall back to a string (f"unreadable: {exc}"), so the diagnostic can never outrank the failure it describes. Then extract the two summarizers into pure helpers (_criteria_detail(path) / _agent_commands(path)) taking a Path and returning list | str, and unit-test them under tests/ against a truncated file, a missing file, and a well-formed one — they are plain JSON reshaping with no Docker or Harbor dependency, so they are testable offline even though the driver as a whole is not.
6. [Axis 6] _zip_scenario_dir can raise past its own try, killing main()'s scenario loop from inside the except handler (.github/scripts/harbor_e2e.py:211) — The docstring at lines 205-206 promises "Best-effort: a zip failure must never mask the real scenario failure it was trying to preserve evidence for", but two escape routes break it:
211: FAILURE_ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True) # OUTSIDE the try
212: try:
213: archive = shutil.make_archive(str(FAILURE_ARTIFACTS_DIR / scenario.name), "zip", root_dir=str(scenario_dir))
214: except OSError as exc:- The
mkdiris outside thetry(low likelihood —WORK_DIR.mkdirat line 221 already provedtmp/writable — but it is on the wrong side of the guard for free). except OSErroris too narrow forshutil.make_archive. Verified locally against this machine's CPython: a single file in the tree with an mtime before 1980 makes it raiseValueError('ZIP does not support timestamps before 1980')— MRO(ValueError, Exception, ...), not anOSError. The tree being zipped isjobs/plus container-capturedartifacts/workspaces, where epoch-0 mtimes from image layers are entirely ordinary.UnicodeEncodeErroron an undecodable filename from the same trees escapes identically.
Because the call site is inside the handler — except Exception as exc: at line 231, archive = _zip_scenario_dir(scenario) at line 234 — an escape there propagates out of the for scenario in SCENARIOS loop: the remaining scenarios never run and the === Summary === block (lines 238-240) never prints. On a check that is now required on every PR, a first-scenario failure would hide the state of the other four for a full ~20-minute CI round trip. (The original error itself survives, because line 232 prints it before the zip is attempted.)
Fix: move the mkdir inside the try and widen to except Exception as exc: — a genuinely best-effort evidence path is the one place a blanket catch is correct, and the docstring already states that intent. This file is outside --cov=coder_eval and has no unit test, so a small tests/ case (or a strict_timestamps=False zipfile walk) is the only thing that would catch a regression here.
7. [Axis 8] adopt()'s docstring still promises "materializing nothing" / "writes no FILES" (sandbox.py:268, 276-278) while the function now provisions .venv/ and node_modules/; regrade.py and .claude/notes/isolation.md repeat the stale guarantee (src/coder_eval/sandbox.py:268) — Line 268 still reads """Use ``workspace`` **as** the sandbox, materializing nothing into it. and line 276-278 still reads "Materializing nothing" means it writes no FILES; it does still chmod ``+x`` over the task's declared mock-PATH directories. After this PR adopt writes a whole .venv/ (and node_modules/ + package.json/lockfile) into the graded tree. CLAUDE.md is explicit that "A docstring states the contract", and the in-body comment at lines 310-314 was updated while the docstring above it was not. This is not cosmetic: the directory adopt now writes into is the caller's — cleanup() never deletes it (_cleanup_on_exit stays False, line 307), PreservationMode.NONE is passed precisely because "the workspace belongs to the run being graded; never move or delete it" (regrade.py:987-988), and on the container route it is a read-WRITE bind mount of the original run's output (argv += ["-v", f"{self.grade_workspace.resolve()}:{CONTAINER_GRADE_WORKSPACE}"], docker_runner.py:1318) whose own docstring says "the latter is adopted and never written over" (regrade.py:797). So a grade now permanently mutates the artifact directory of the run it is grading. Update the docstring to state exactly which files adopt may create and under what condition, and fix regrade.py:797.
Nits
Omitted here for size — 4 🔵 findings are in the full report (tmp/code-review-260917-1149/00-summary.md).
What's Missing
Parallel paths:
- 🔴 The repo's fork-PR runner convention was applied in nine other places but not here:
.github/workflows/pr-checks.ymlcarries thehead.repo.full_name != github.repositoryternary at lines 43/202/294 and the skip-if:at 347/469/677/849/927/1009, andclaude-pr-review.yml:36-39guards the same way — after this PRharbor-e2e.ymlis the onlypull_request-triggered workflow with neither, while running twodocker builds and the PR's own.github/scripts/harbor_e2e.pyon the shareduipath-ubuntu-latestpool. (trigger: .github/workflows/harbor-e2e.yml) (restates: Axis 4: harbor-e2e.yml gains an unconditional pull_request trigger with no fork carve-out) - 🟠
src/coder_eval/harbor/packager.py:338-364(_write_verifier_task_yaml) is the parallel path the fix needs and was not touched — the verifier's/tests/task.yamlcarries nosandbox:key, soenv_packagesis[]at grading time and the newelifis dead on exactly thetemplate_sourcesscenario.claude/notes/isolation.md:862cites as the live proof. (trigger: src/coder_eval/sandbox.py) (restates: Axis 8: The note credits the adopt() re-provisioning fix with a Harbor E2E failure it cannot reach) - 🟡 Only the in-body comment at
sandbox.py:310-314was updated; the three other statements of the old contract were not — the docstring atsandbox.py:268and276-278,orchestration/regrade.py:799("the latter is adopted and never written over"), and.claude/notes/isolation.md:276("adopt()guarantees it materializes nothing into the workspace"), the last of which is in an in-scope file the PR edited further down. (trigger: src/coder_eval/sandbox.py) (restates: Axis 8: adopt()'s docstring still promises "materializing nothing" / "writes no FILES") - 🟡
setup()(sandbox.py:378-383) was left as-is, so the two provisioning paths now diverge — bareconfig.pythonearns a venv insetupbut not inadopt— and nothing reconciles the resultingVIRTUAL_ENV/ PATH asymmetry (_command_env,sandbox.py:1103-1105) for apython: {env_packages: []}task graded from a captured workspace. (trigger: .claude/notes/isolation.md) (restates: Axis 1: isolation.md:867 justifies adopt()'s env_packages gate with an "asking for packages earns a venv" rule that setup() does not actually have) - 🟡 User-facing docs were not updated for a behaviour change operators will feel:
docs/USER_GUIDE.md:266-275still describes in-place grading purely as "grade where the files are" with "an adopted directory is never moved or deleted", and says nothing about a grade now runninguv pip install/npm install— i.e. needing network, taking install-time, and being able to fail the grade outright. (trigger: src/coder_eval/sandbox.py)
Tests:
- 🟠 The one new test does not actually pin the new behaviour:
_setup_virtualenvbuilds with--system-site-packages(sandbox.py:803/811) andrequestsis importable in the project env (verified: 2.33.0), sopython -c "import requests"succeeds even if_install_packages()were deleted fromadopt— the test survives the mutation it exists to catch. Assert against a package absent from the host env, or assert the venv's ownsite-packagesgained it. (trigger: tests/test_sandbox_adopt.py) - 🟠 The node half of the new rule (
sandbox.py:330-331) ships with no test at all — the full 6433-item run lists line 331 and_install_node_packages's body (843-869) as never executed, and_workspace()(tests/test_sandbox_adopt.py:21-29) always createsnode_modules/, so no adopt test can ever take the branch. (trigger: src/coder_eval/sandbox.py) (restates: Axis 3: adopt()'s new Node re-provisioning arm (sandbox.py:330-331) is untested across the whole suite) - 🟡 No hermeticity marker was added for the suite's new network dependency:
test_adopt_reprovisions_env_packages_missing_from_a_captured_workspacedoes a realuv venv+ PyPIuv pip install requestsand is unmarked, somake test(-m "not live and not lint") now fails offline — every otherenv_packagestest in the repo patches the installer instead (tests/test_node_env_config.py:53/66/79/126). (trigger: tests/test_sandbox_adopt.py) - 🟡 The no-op half of the new gate is unasserted: nothing exercises
python: {env_packages: []}against a workspace with no.venv, so the note's central claim ("the common empty-env_packagesdefault still adopts as a pure no-op") has no test —test_adopt_writes_nothing_into_the_workspace(tests/test_sandbox_adopt.py:56) uses the default config and passes trivially. (trigger: tests/test_sandbox_adopt.py) - 🟡 No error-path test accompanies the new writes: nothing asserts what the graded tree looks like after
_install_packagesraisesRuntimeError(sandbox.py:839) — the case where a half-built.venvlatches and the next adopt silently takes the discover branch. (trigger: src/coder_eval/sandbox.py) (restates: Axis 6: adopt() creates .venv/node_modules in a caller-owned workspace with no cleanup on a failed install) - 🟡 The fixture edit lands with no offline guard: nothing under
tests/validatestests/harbor_e2e/fixtures/*.yaml(every YAML-parsing test globstasks/only), andcommand_executeddegrades a bad regex toscore=0.0(criteria/command_executed.py:269-279), so a malformed fixture now surfaces as an opaquereward != 1.0after a ~20-minute build on a required check. (trigger: tests/harbor_e2e/fixtures/trajectory_criteria.yaml) (restates: Axis 3: The Harbor E2E fixtures are never parsed offline, yet this PR makes the live Docker+model run a required PR gate) - 🟡 None of the +60 new lines has a test or any coverage instrumentation (the file is outside
--cov=coder_evaland absent from the coverage report), although the sibling.github/scripts/release_notes.pyalready hastests/test_release_notes.pyimporting it by path — the precedent for unit-testing this directory exists and was not followed. (trigger: .github/scripts/harbor_e2e.py) (restates: Axis 3: harbor_e2e.py's new failure-only agent-command dump does an unguarded json.loads that can replace the root-cause message it exists to produce)
Downstream consumers:
- 🔴 Every consumer of the invariant this PR falsified was left asserting it:
orchestration/regrade.py:133-136still documents "adoptruns no installer" as the reason_gate_scope_for_gradereturnsinclude_setup_phase=False,regrade.py:232-238still hidesuv pip install {env_packages}behind that flag, andtests/test_detached_grading_boundaries.py:804-811now actively locks the exemption in withenv_packages=["attacker-pkg"]. (trigger: src/coder_eval/sandbox.py) (restates: Axis 8: adopt() now runs uv pip install / npm install from the untrusted recorded config on the in-place evaluate <run_dir> path) - 🟡 Repeat in-place grading of the same run directory is no longer idempotent and nothing accounts for it: the first
evaluate <run_dir>writes.venv(andnode_modules+package.json/lockfile) intorun_dir/artifacts/<task_id>/, so the second grade of the same directory takes the discover branch instead of the install branch, and any preserved run dir that is archived, copied or shipped downstream now carries a venv it never used to. (trigger: src/coder_eval/sandbox.py)
Display & mapping dicts:
- 🔵 The new
criteria_detailprojection (harbor_e2e.py:138-141) covers onlycriterion_type/score/details, while a serialized criterion result also carrieserror,evaluation_statusandresult_kind— so a criterion that raised or came backNOT_GRADEDprintsdetails: None, which is precisely the case the dump was added to disambiguate. (trigger: .github/scripts/harbor_e2e.py)
Daily/nightly:
- 🟠 The PR promotes a live Docker + real-Bedrock workflow (5 scenarios, two image builds,
timeout-minutes: 20) to a per-PR required gate on the shared self-hosted pool without stating the blast radius: pool contention and Bedrock spend per PR are unquantified, and the nightly's own stated purpose — catching drift in Harbor's upstream releases — now means an upstream break red-lights every open PR, with no documented bypass. (trigger: .github/workflows/harbor-e2e.yml) - 🟡 The adopt change lands on the production grading path (
cli/evaluate_command.py:462,orchestration/regrade.py:990, and the in-container adopt atcli/run_task_internal_command.py:276) yet the PR states nothing about the nightly /coder-eval-uipathconsumer: nightly in-place grading now performs network installs mid-grade, adding latency and a failure class that did not previously exist on that side of the run. (trigger: src/coder_eval/sandbox.py) (restates: Axis 6: adopt()'s new re-provisioning can raise subprocess.SubprocessError, which neither grading caller catches — one row's failure aborts the whole --resume batch)
Harness & Lint Improvements
Condensed for GitHub's comment-size limit — full rationale, CE ids, file targets and wiring in 08-.../00-summary.md.
Static checks (lint / type):
- Widen the CE AST runner's scan set to
.github/scripts/ - Add
".github/scripts"to[tool.pyright] include(pyproject.toml:329), and addCE067 — toolchain-scope parity forLINT_PATHS` - Turn on
C901for.github/scripts/via a directory-scoped.github/scripts/ruff.toml CE068— fork-untrusted-execution guard onpull_requestworkflowsCE069— an artifact upload may not fail silentlyCE070— no installer is reachable fromSandbox.adopt- Give the task-YAML lint parametrizations one SSOT and extend it past
tasks/. - Add
actionsto the CodeQL matrix
Harness improvements:
- Make
adopt()'s write contract executable. - Add a per-diff coverage floor to
make verify - Bring
.github/scripts/under pytest and coverage. - Add an agent↔verifier parity test to the Harbor packager.
- Make the
setupvsadoptvenv-policy difference a named test pair, and have.claude/notes/isolation.mdcite the test names instead of paraphrasing the rule - Audit required checks against fork reachability.
Top 5 Priority Actions
- Close the untrusted-installer hole this PR opens:
src/coder_eval/sandbox.py:326-331now runsuv pip install/npm installfrom the attacker-controlled recorded config on theevaluate <run_dir>in-place path, which_gate_scope_for_gradestill exempts on the now-false premise atsrc/coder_eval/orchestration/regrade.py:133-136("adoptruns no installer") — either thread the trust decision intograding_sandbox_configor setinclude_setup_phase=Trueunconditionally, and do not ship whiletests/test_detached_grading_boundaries.py:804-811still asserts the exemption for anenv_packages=["attacker-pkg"]payload. - Make the fix reach the failure it claims, or stop claiming it:
_write_verifier_task_yaml(src/coder_eval/harbor/packager.py:338-364) emits nosandbox:key, so the Harbor verifier gradestemplate_sourceswithenv_packages == []and the new branch is dead on exactly the CI path cited at.claude/notes/isolation.md:862— carrysandbox.python/sandbox.nodeinto the verifier task.yaml with a packager test, or correct the note. - Stop a failed provision from latching a silently-wrong verdict: wrap
src/coder_eval/sandbox.py:326-331so a raising_install_packages/_install_node_packagesremoves the.venvit just created and re-raises (today the half-built venv survives in the caller-owned workspace and the retry takes thecandidate.is_dir()discover branch at line 324, grading against a venv with none ofenv_packages), and widen the helpers' ownexcept subprocess.CalledProcessErroratsandbox.py:805/839/866tosubprocess.SubprocessErrorso aTimeoutExpired/ensurepip failure no longer escapescli/run_command.py:817and aborts an entire--resumebatch. - Add the fork carve-out before the new
pull_requesttrigger at.github/workflows/harbor-e2e.yml:16-17lands on the public repo: the job is unguardedruns-on: uipath-ubuntu-latest(line 39) with workflow-level Bedrock secrets (lines 26-34) and twodocker builds, while the same-repo guard already appears nine times inpr-checks.yml/claude-pr-review.yml— and since fork PRs get no secrets, skipping is also the only way this newly-required check is not permanently red for external contributors. - Fix the contract-and-coverage debt around the change: correct the stale
adopt()docstring (src/coder_eval/sandbox.py:268, 276-278"materializing nothing"/"writes no FILES"),regrade.py:799and the false "setup's own rule" claim at.claude/notes/isolation.md:867and:276; add the missing tests for the node arm atsandbox.py:330-331(dead across the full 6433-item suite) and for existing-venv-plus-non-empty-env_packages; and harden the new CI diagnostics with a guarded read at.github/scripts/harbor_e2e.py:153,except Exceptionat:214, plus a ~10-line offlineTaskDefinition.model_validatetest overtests/harbor_e2e/fixtures/*.yaml.
Stats: 2 🔴 · 4 🟠 · 7 🟡 · 4 🔵 across 8 axes reviewed.
Verification: 42 findings proposed, 31 verified, 3 refuted as false-positive, 24 corrected in place, 11 low-severity passed through unverified. Full report: tmp/code-review-260917-1149/.
uipreliga
left a comment
There was a problem hiding this comment.
Fix what you agree with and 🚢
…review's other findings - Disclose env_packages installs unconditionally in the untrusted-recorded- config gate, since Sandbox.adopt() can now run them in place too (was exempted on the now-false premise that adopt runs no installer). - Carry sandbox.python/node's env_packages into the Harbor verifier's tests/task.yaml, so adopt()'s re-provisioning fix actually reaches the template_sources scenario it was credited with fixing. - adopt() now removes a half-built .venv/node_modules on a failed install instead of latching a silently-wrong verdict; widened the install helpers' exception handling so a hung network install can't escape --resume/evaluate uncaught. - Added a same-repo fork-PR guard to harbor-e2e.yml's new pull_request trigger, scoped Bedrock secrets to the one step that needs them. - Added missing test coverage (node re-provisioning arm, existing-venv + env_packages inverse, hermetic mock replacement for the live network test), an offline fixture-validation test, and fixed a regex over-match in trajectory_criteria.yaml. - Hardened harbor_e2e.py's diagnostics (removed duplicate reads, guarded against a truncated/missing file masking the real failure, widened _zip_scenario_dir's exception handling) and added .github/scripts to pyright's include set. - Corrected stale "materializes nothing" docstrings/notes now that adopt() can write files. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Summary
push/scheduletomain, so thetrajectory_criteriascenario added in feat(harbor): unblock trajectory-dependent criteria, always allow credentials at export #186 got no PR signal before merging — it failed on the very first post-merge run (https://github.com/UiPath/coder_eval/actions/runs/35242305732). Addpull_requestto its triggers and add the job as a required status check onmain.reward=0.5,command_executednot matched): the fixture asked the agent to runtouch done.txta second time purely "to confirm it exists," which a capable agent can reasonably skip since it's redundant. Reworded the prompt so runningtouchis the only way the file gets created, removing the ambiguity.harbor_e2e.pyonly printed each criterion's own score/details when the aggregate reward was already 1.0 (i.e. only for a "passed by luck" case) — reordered so any reward mismatch also surfaces the per-criterion breakdown, since that's exactly what was missing from the original failure logs.Test plan
make format/make check/make testall passProtect mainruleset (Harbor E2E job now required)pull_request