Skip to content

Run coding_tools_env verification outside the notebook kernel - #1580

Open
zuraiz-anjum wants to merge 2 commits into
openenv:mainfrom
zuraiz-anjum:fix/coding-tools-verify-outside-kernel
Open

zuraiz-anjum wants to merge 2 commits into
openenv:mainfrom
zuraiz-anjum:fix/coding-tools-verify-outside-kernel

Conversation

@zuraiz-anjum

@zuraiz-anjum zuraiz-anjum commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Ports #1233 to coding_tools_env. Verify commands, the reward file clear and the reward file read went through the code-interpreter kernel (run_shell sends a subprocess.run cell, read_file a Path.read_text cell), so their results depended on kernel state. They now run as their own sandbox processes through E2B's process API, the way jupyter_env does since #1233.

Refs #1232

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • New environment
  • Refactoring

Alignment Checklist

Before submitting, verify:

  • I have read .claude/docs/PRINCIPLES.md and this PR aligns with our principles
  • I have checked .claude/docs/INVARIANTS.md and no invariants are violated
  • I have run /pre-submit-pr (or bash .claude/hooks/lint.sh and tests) and addressed all issues

RFC Status

  • Not required (bug fix, docs, minor refactoring)
  • RFC exists: #___
  • RFC needed (will create before merge)

Details

  • E2BSandbox.run_command is the jupyter_env one with the same rules: it starts the command with background=True as root in /home/user and waits on the handle. A non-zero exit (CommandExitException, matched by its exit_code) is a failed result. A command that cannot be started, or a wait that ends without an exit status, is raised after a best effort kill, so it is never scored.
  • It returns a ToolResult in the shape run_shell uses here (stdout and stderr joined, error="exit_code=N"), so verify_results look the same as before. Its default timeout is 30 s like run_shell; verify commands still pass 120 s.
  • _run_verify_commands uses it for the mkdir, the clear and each verify command, and _read_reward_override reads the file with cat through it instead of read_file.
  • The agent's bash and file tools and the setup commands are unchanged, so nothing the agent sees changes.

Where this differs from jupyter_env:

  • The agent here has no cell tool, so the kernel is not something it writes to directly. Its shell does run as root next to the kernel, though, and verification shouldn't depend on kernel state either way.
  • run_shell here already reported real exit codes, so honest scoring doesn't change.
  • One behaviour change to look at: a verify command that runs past its 120 s timeout used to count as a failed command (the cell raised TimeoutExpired). Now, as in jupyter_env, it is raised: no reward, and the episode doesn't finish. A hung test run is common in coding tasks, so say if you'd rather keep counting it as a failure here.

Same limit as #1233: this is not an isolation boundary. E2B starts commands as bash -l -c, so a bash call that writes an EXIT trap into /root/.bash_profile makes a failing verify command pass. A test pins that, and the README says so. On main the same root shell can already rewrite the tests, a conftest.py or /bin/sh, so it isn't a new class of problem; #1232 tracks the fix.

Test Plan

New tests in tests/envs/test_coding_tools_environment.py drive the real E2BSandbox against a stand-in kernel (cells run with a namespace that persists) and a stand-in process API (each command a fresh bash -l -c with a real exit status), with /home/user and /root mapped to temp dirs:

  • jupyter_env: verification runs in the agent's own kernel, so a rollout can report any reward #1210's cell rebinding subprocess.run reaches the kernel: both exit 1 verify commands still fail and the reward is 0.0. No verify command reaches the kernel.
  • A kernel cell forges Path.read_text to return "1.0"; a verify command that writes 0.8 yields 0.8.
  • A kernel cell calls os.chdir; pwd in verification still reports the notebook dir. Every call goes out in the background, as root, in /home/user, and the verify command keeps its 120 s timeout.
  • run_command unit tests, as in Run jupyter_env verification outside the notebook kernel #1233: non-zero exit, wait failure plus kill (also when the kill returns False or raises), start failure, and e2b.CommandExitException itself.
  • A sandbox that fails during verification gives a tool error, no verify results, no reward and done false.
  • The bash tool writes a poisoned /root/.bash_profile and a failing verify command passes (the limit above).
  • The existing submit test also asserts verify went through run_command, not run_shell; the reward file fake now answers both.

PYTHONPATH=src:envs pytest tests/envs/test_coding_tools_environment.py, with e2b 2.21.0 and e2b-code-interpreter 2.6.2 as locked in envs/coding_tools_env/uv.lock:

  • main with only the new tests: the 11 new or changed cases fail, the other 13 pass.
  • this branch: 25 passed. tests/envs/test_jupyter_environment.py still passes.
  • One more test passes on main too and guards this port: the reward file is parsed from stdout alone, so a login profile that prints to stderr cannot turn a valid override into a fallback to the pass rate.

usort check, ruff format --check and ruff check are clean on the test file, and ruff check on the env files. The two env files were not ruff format clean on main; I left those lines alone, and the findings on them are the same as on main. scripts/sync_env_docs.py --check passes after refreshing the docs stub.

Not run against a live E2B sandbox. With an E2B_API_KEY, this checks the change and the user and directory assumption in one go:

from coding_tools_env.server.coding_tools_env_environment import CodingToolsEnvironment
from openenv.core.env_server.mcp_types import CallToolAction

env = CodingToolsEnvironment()
env.reset(verify=["exit 1", 'test "$(id -u)" -eq 0 && test "$(pwd)" = /home/user'])
# stands in for code that reaches the kernel by another route
env._sandbox._sbx.run_code(
    "import subprocess\nclass F: returncode = 0; stdout = ''; stderr = ''\n"
    "subprocess.run = lambda *a, **k: F()"
)
env.step(CallToolAction(tool_name="submit_solution", arguments={}))
print(env.state.last_reward, [r.ok for r in env.state.verify_results])
# this branch: 0.5 [False, True]    main: 1.0 [True, True]

Out of scope


Note

Medium Risk
Changes reward/verification semantics (timeouts and sandbox failures no longer score as failed tests) and touches training-critical scoring paths, though agent-facing tools are unchanged.

Overview
Moves coding_tools_env verification off the Jupyter code-interpreter kernel so scoring cannot be forged by kernel tampering (e.g. rebinding subprocess.run or Path.read_text, changing os.chdir).

E2BSandbox gains run_command, which starts shell work through E2B’s process API as root in /home/user, handles real exit codes, and raises (with a best-effort kill) when the command never exits—timeouts used to look like a failed verify. _run_verify_commands and reward-file clear/read now use run_command instead of run_shell / read_file; the reward override is parsed from stdout only so login-shell stderr from profiles does not break parsing.

Docs spell out that this decouples verification from kernel state but does not isolate it from the agent (same sandbox, login-shell profiles—tracked in #1232). A large test suite pins kernel-immune scoring, timeout/error behavior, and the remaining profile-poisoning gap.

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

E2BSandbox.run_shell sends a subprocess.run cell to the code-interpreter
kernel, and the verify commands, the reward file clear and the reward
file read all went through that kernel. Their results then depended on
kernel state: a rebound subprocess.run or pathlib.Path.read_text, a
changed working directory or an edited os.environ.

Add E2BSandbox.run_command, ported from jupyter_env (openenv#1233). It runs a
command as its own process through E2B's process API, as root in
/home/user, and reports the real exit status. A non-zero exit becomes a
failed result; a command that cannot be started or a wait that ends
without an exit status is raised after a best effort kill, so it is not
scored. Verification and the reward read now use it. The agent's bash
tool and the setup commands keep using run_shell.

Refs openenv#1232

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

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 1 potential issue.

Fix All in Cursor

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

Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 207c821. Configure here.

run_command joins stdout and stderr into output, so a login profile
that prints to stderr broke a valid reward override and scoring fell
back to the pass rate. jupyter_env parses stdout alone; do the same.
@zuraiz-anjum

Copy link
Copy Markdown
Contributor Author

The test (3.11) failure is test_runtime_collector.py::test_schema_deadline_aborts_trickling_http_server[body] (1.04 s against a 0.15 s deadline), outside coding_tools_env. It passes 3 of 3 locally on this head, and the 3.12 job passed, so it looks like a timing flake on the runner. A rerun of the failed job should clear it.

This branch has not been deployed

No deployments
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