Skip to content

feat(tbench2_env): per-task sandbox image recipe + Daytona materialization#966

Closed
nblintao wants to merge 3 commits into
huggingface:mainfrom
nblintao:tb2-per-task-sandbox-recipe
Closed

feat(tbench2_env): per-task sandbox image recipe + Daytona materialization#966
nblintao wants to merge 3 commits into
huggingface:mainfrom
nblintao:tb2-per-task-sandbox-recipe

Conversation

@nblintao

@nblintao nblintao commented Jul 14, 2026

Copy link
Copy Markdown

Summary

Terminal-Bench-2 is a per-task-image benchmark: every task pins its official runtime image in task.toml ([environment].docker_image — the exact image the TB2 harness itself runs). This adds tbench2_env/task_snapshots.py: a recipe that builds, per task, the official task image ⊕ this env's server layer (one layer, no docker-in-docker), plus a Daytona materialization that creates one such sandbox per episode. It lets any cloud-sandbox deployment serve TB2 with full per-task environment fidelity, instead of running every task inside one generic server container.

Design notes:

  • Provider-agnostic core: _server_layer_commands() is plain shell — a uv-managed Python venv at /opt/envserver running this checkout's tbench2_env server, and the task directory staged from the tasks checkout's pinned-commit GitHub tarball. The same commands can back a Dockerfile or another provider's build.
  • Daytona materialization: create_task_sandbox() creates declaratively from the Image definition — no named snapshot, so no org snapshot quota; repeat creates hit Daytona's build cache (~1min after the first build). An optional bake CLI pre-registers named snapshots as a warm cache. Sandboxes carry an ownership label (openenv-tbench2-task) so cleanup tooling never touches other workloads in a shared org.
  • Environment fidelity: the official image's apt index is preserved (solutions/agents run bare apt install against it), uv is installed outside PATH (/opt/uv) so no tool the image didn't ship resolves from PATH — this matters in practice: financial-document-processor's official solution uses the image's own uv 0.8.14, and shadowing it with a newer uv broke its dependency resolution.
  • Verifier-asset hygiene (via the stacked fix(tbench2_env): keep verifier assets out of the agent's reach #972): the staged task directory excludes solution/ at build time (tar --exclude anchored to the one task — nothing in this env reads it), and the server command sets TB2_WITHHOLD_TESTS=1 so tests are withheld into server memory at reset() and exist in the agent's filesystem only during the verify window, like the official harness. tests/envs/test_tbench2_task_snapshots.py pins both.
  • Embedded env source (transitional): the build embeds this checkout's tbench2_env source so server fixes ship without a released package; once released upstream, this should switch to installing the published package.

Stacked on #972, which stacks on #965. This branch is based on #972's, so both underlying fix commits appear in this PR until they merge — review this PR's own commit, feat(tbench2_env): per-task sandbox image recipe + Daytona materialization (the last commit; task_snapshots.py plus its test file). Both fixes are functional prerequisites (the embedded server source needs them for per-task containers to score faithfully and hack-resistantly), so they should merge first; this PR then rebases to the feat commit alone. (GitHub can't retarget a fork PR's base onto another fork branch, so the base still reads main.)

Type of Change

  • New feature

Alignment Checklist

  • I have read .claude/docs/PRINCIPLES.md and this PR aligns with our principles (container isolation; one env = one trajectory — one sandbox per episode strengthens both)
  • I have checked .claude/docs/INVARIANTS.md and no invariants are violated (no API changes; the module never imports from server/)
  • I have run bash .claude/hooks/lint.sh — the new files are clean (pre-existing failures in other envs are untouched)

RFC Status

  • Not required (bug fix, docs, minor refactoring)

Env-level tooling with no core-abstraction or API change; happy to write an RFC if maintainers prefer one for the per-task-image pattern (it likely generalizes to other per-task/per-instance-image benchmarks, e.g. SWE-bench-style suites).

Test Plan

Validated end-to-end on Daytona against all 89 official TB2 tasks (with #965 applied), executing each task's official solution under the official oracle convention and scoring with the official tests/test.sh:

  • 89-task golden sweep: 82/89 score 1.0, 0 infra errors across heterogeneous images (compilers, DBs, ML stacks, video, multi-hundred-MB asset tasks). The 7 failures trace to solution drift in terminal-bench-2 itself (per-task evidence available).
  • Verifier-asset hygiene re-verified end-to-end on a per-task Daytona sandbox (fix-git, with fix(tbench2_env): keep verifier assets out of the agent's reach #972's gate enabled by this recipe): no tests/ or solution/ anywhere agent-readable (filesystem-wide find), pre-solution evaluate 0.0 via the canonical harness from the in-memory copy, official oracle solution 1.0.
  • In-repo pytest (tests/envs/test_tbench2_task_snapshots.py): the tar exclusion is anchored to the one task's solution/, and the server command carries the withhold gate.

Note

Medium Risk
Changes reward computation, deletes or withholds verifier files on disk, and introduces cloud image builds; mitigated by locks, cleanup, and broad unit tests, but scoring and RL anti-cheat behavior are security-sensitive.

Overview
Adds task_snapshots.py to build official TB2 task images plus an OpenEnv server layer (uv at /opt/envserver, task tree from a pinned GitHub tarball at /opt/tb2-tasks), with Daytona create_task_sandbox / optional snapshot bake CLI. Sandbox startup sets TB2_WITHHOLD_TESTS=1, CAMEL_RUNTIME=true, and per-task TB2_DEFAULT_TASK_ID.

Local and Docker env behavior is tightened for faithful, hack-resistant scoring: optional TB2_WITHHOLD_TESTS removes tests/ and solution/ from the agent filesystem at reset() (tests cached in server memory), stages a clean copy at /tests only during evaluate, and prefers the task’s tests/test.sh harness with task.toml verifier timeouts. Agents run in the task image WORKDIR (from Dockerfile or override), and shell_exec gets explicit timeouts via TB2_COMMAND_TIMEOUT_S. Docker mode skips copying verifier assets into /task and stages tests only at verify time.

Docs add TB2_WITHHOLD_TESTS; tests cover withholding, symlink hardening, canonical evaluate, Docker staging, and snapshot solution/ exclusion.

Reviewed by Cursor Bugbot for commit eea94f0. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread envs/tbench2_env/task_snapshots.py Outdated
…ask env & workdir

- _evaluate_task runs the task's official tests/test.sh (pinned uvx
  toolchain, /logs/verifier/reward.txt) when the task ships it; the bare
  pytest fallback (custom task dirs without the canonical harness) prefers
  uvx with a pinned pytest so it carries its own toolchain, and runs from
  the same workdir the agent worked in.
- Pass timeouts through to every shell_exec call: camel's shell_exec has
  its own per-call default (20s) and ignores the constructor timeout, so
  agent commands and evaluations were silently truncated at 20s
  (circuit-fibsqrt's verifier declares 3600s and was cut mid-test).
  Agent execs use command_timeout_s (TB2_COMMAND_TIMEOUT_S-overridable);
  evaluate uses the task's own [verifier].timeout_sec budget.
- Shell cwd resolves to the task image's own WORKDIR, parsed from the
  task's environment/Dockerfile (TB2_TASK_WORKDIR overrides): task images
  pin their own WORKDIR (fix-git: /app/personal-site, prove-plus-comm:
  /workspace) and both agents and oracle solutions assume commands run
  there. Falls back to the task source dir for plain local mode.
- Drop install_dependencies=['pytest'] (evaluation carries its own
  pytest via uvx); with CAMEL_RUNTIME=true camel's .initial_env venv
  then stops hijacking PATH away from the task image's native python.
- Serialize canonical evaluation under a lock so concurrent sessions on
  one server cannot clobber the official fixed verifier paths (/tests,
  /logs/verifier/reward.txt).
@nblintao
nblintao force-pushed the tb2-per-task-sandbox-recipe branch 2 times, most recently from eea0d44 to 867e879 Compare July 16, 2026 20:04
Comment thread envs/tbench2_env/server/tbench2_env_environment.py Outdated
Comment thread envs/tbench2_env/server/tbench2_env_environment.py Outdated
@nblintao
nblintao force-pushed the tb2-per-task-sandbox-recipe branch 2 times, most recently from 1096c41 to 75785af Compare July 16, 2026 21:00

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

There are 5 total unresolved issues (including 3 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 75785af. Configure here.

Comment thread envs/tbench2_env/server/tbench2_env_environment.py Outdated
)
self._exec_in_container("rm -rf /task/tests && mkdir -p /task/tests")
self._copy_dir_to_container(tests_src, "/task/tests")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Docker evaluate skips test.sh

Medium Severity

This change adds canonical scoring via tests/test.sh and staged /tests for local mode, but Tbench2DockerEnvironment._evaluate_docker still runs bare python -m pytest under /task/tests, skipping the official harness and uvx-pinned toolchain official TB2 tasks require.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 75785af. Configure here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Out of scope for this PR. Docker mode's bare-pytest scoring is pre-existing behavior; canonical tests/test.sh fidelity for docker mode is a separate track. What #972 does change in docker mode is stage-at-verify (tests excluded from the initial copy, injected only at verify), which is the reward-hacking fix. Canonical scoring there can follow up.

@nblintao
nblintao force-pushed the tb2-per-task-sandbox-recipe branch 3 times, most recently from ac56667 to ecc6211 Compare July 16, 2026 21:46
nblintao and others added 2 commits July 16, 2026 14:57
Both execution modes let the agent read (and tamper with) the assets the
verifier scores against — tests/test_outputs.py spells out the expected
outputs and solution/ is the literal answer. Fine for eval plumbing with a
cooperative agent; for RL training it is a reward-hacking vector. Align
with the official TB2 harness's model instead: the solution never ships,
and tests exist in the agent's filesystem only during the verify window.

- Local mode: with TB2_WITHHOLD_TESTS=1, reset() reads tests/ into server
  memory and deletes it (and solution/ — nothing in this env reads it; it
  exists only for oracle runs) from disk before the agent's first action.
  Gated off by default because in plain local mode TB2_TASKS_DIR may be a
  developer's working checkout; deployments that stage per-task copies
  (e.g. cloud sandboxes) should turn it on. The in-memory copy is
  process-local, so withhold mode assumes one server process owns the task
  filesystem for its lifetime (the per-task-sandbox deployment: one worker,
  one ephemeral sandbox per episode) — documented on the cache.
- Evaluation stages a pristine tests/ copy at /tests for exactly the
  verify window — from server memory when withheld, i.e. a copy the agent
  never had filesystem access to — and removes it again once scoring
  returns, so a later episode / concurrent session sharing the filesystem
  can't read it. Both fixed paths (/tests, /logs/verifier) are recreated
  from scratch first, symlink included: the previous "mkdir -p && cp -a"
  merged into whatever already existed, and shutil.rmtree silently no-ops
  on a symlink, so an agent could pre-plant a /tests/conftest.py (pytest
  auto-loads it) or symlink a fixed path at a dir it controls and survive
  into scoring. _hard_remove unlinks the symlink itself. The in-memory
  tarball is restored with the 3.12 data filter when available, falling
  back cleanly on 3.10/3.11 (the package targets >=3.10). The bare-pytest
  fallback runs against the same staged copy. The canonical harness writes
  its test.sh stdout under /logs/verifier (not a fixed /tmp path) and the
  verify-window cleanup wipes /logs/verifier alongside /tests, so neither
  reward.txt nor the pytest -rA log — which can spell out expected values —
  outlives scoring for a timed-out episode or a later session. The withhold
  cache tars the realpath of tests/ so a symlinked tests/ caches its
  target's files rather than a bare link that would restore empty.
- Docker mode gets true stage-at-verify: the initial /task copy excludes
  tests/ and solution/, and _evaluate_docker streams tests in from the
  host (this server sits outside the container) right before pytest runs,
  then removes them after scoring — in a finally, because docker-mode
  step() reports a scoring error without ending the episode, and the
  agent must not keep its session alongside a staged /task/tests that an
  errored verify left behind.

Residual risk, shared with the official TB2 harness: verification
necessarily runs inside the same container the agent controlled, and
agent-started background processes must be left alive (many tasks are
scored on services the agent set up), so a leftover watcher that copies
the staged tests or rewrites the reward file during the verify window —
or a root agent that tampers with container binaries — could still fake
a pass. Task state (services, git state) is not portable, so verification
cannot move to a container the agent never touched.

tests/envs/test_tbench2_env.py covers the withhold round-trip, staging
hygiene (pre-planted conftest.py and stale reward.txt wiped; symlinked
fixed paths and a symlinked tests/ removed without following into the
target; source checkout untouched without the gate), post-scoring
cleanup, the full evaluate flow from the in-memory copy, and docker
mode's copy exclusions, evaluate-time staging order, and cleanup when
scoring itself raises (mock container — no docker needed). Also verified
end-to-end on a per-task Daytona cloud sandbox (fix-git, gate enabled):
the staged task dir carries no tests/solution, a filesystem-wide find
turns up neither, pre-solution evaluate scores 0.0 via the canonical
test.sh from the in-memory copy, and applying the oracle solution scores
1.0. A static scan of all 89 official tasks' tests/ trees (regular files
and dirs only — no symlinks or special modes) confirms the tar
round-trip is safe suite-wide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ation

TB2 is per-task-image (task.toml [environment].docker_image pins the
official image the TB2 harness itself runs); a cloud sandbox serving
this env must be built per task: official task image + this env's
server layer, one layer, no DinD.

- Provider-agnostic core: _server_layer_commands is plain shell; this
  checkout's env source is embedded into the build so local fixes ship
  without a released package. The task image's apt index is preserved
  (solutions/agents run bare 'apt install' against it).
- Daytona materialization: create_task_sandbox (declarative per-episode
  create, no named-snapshot quota, repeat creates hit the build cache;
  every task dir is staged from the checkout's pinned-SHA GitHub
  tarball — one uniform, payload-free path; sandboxes carry an
  ownership label for safe cleanup) plus an optional named-snapshot
  bake CLI as a warm cache. make_daytona is public API: callers driving
  create_task_sandbox need a client configured the same way this
  module's own CLI is.
- Verifier-asset hygiene: the staged task directory excludes solution/
  at build time (tar --exclude anchored to the one task), and
  SERVER_CMD sets TB2_WITHHOLD_TESTS=1 so the server withholds tests/
  at reset and stages them only at verify (the underlying env fix).
  tests/envs/test_tbench2_task_snapshots.py pins both.

Validated by a full 89-task golden sweep (official solutions + official
test.sh scoring): 0 infra errors; failures traced to upstream solution
drift, not the environment. The verifier-asset hygiene was re-verified
end-to-end on a per-task Daytona sandbox (fix-git): no tests/solution
anywhere agent-readable, pre-solution evaluate 0.0, oracle solution 1.0.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant