Skip to content

fix(agent): build Hermes and Stirrup runtimes on the host - #5152

Merged
yaoyu-33 merged 52 commits into
mainfrom
fix/host-built-hermes-stirrup-runtime
Nov 4, 2026
Merged

yaoyu-33 merged 52 commits into
mainfrom
fix/host-built-hermes-stirrup-runtime

Conversation

@yaoyu-33

@yaoyu-33 yaoyu-33 commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Stack: #5151 (merged as da26cd2da) → this PR → #5153. Replaces #5137, reopened from a branch in this repo so the three PRs can form a GitHub Stack. This PR now targets main; merge it before #5153.

The merge of main here (d4c74ed3a) had one conflict, in Hermes's install. It carries #4567's mcp>=1.28.1,<2 requirement and streamablehttp_client import check into the new runtime.

What and why

ananthsub pointed out on #5094 (Hermes, Stirrup, docs) that Hermes and Stirrup still upload uv into the task sandbox and build a virtual environment there from the network. Hermes ran it with the task workdir as cwd, without --no-config, UV_CACHE_DIR or UV_PYTHON_INSTALL_DIR. That meant three problems:

  • a task's uv.toml or [tool.uv] could change the install;
  • uv's cache and managed Python landed in the task's HOME, where the agent and verifier see them;
  • --python 3.13 could bind the image's own python3.13.

Stirrup had the same problem but set the two env vars; it still lacked --no-config. #5151 lists both as known exceptions. This PR removes them.

nemo_gym/agents/utils/sandbox_python.py (new): SandboxPythonRuntime. On the Agent Server host it:

  • probes the sandbox's architecture, C library and glibc version (probe_sandbox_platform()), preferring glibc reported by getconf or ldd over an optionally installed musl loader;
  • downloads a pinned python-build-standalone CPython 3.13.14 (20260805, install_only_stripped) for that platform and checks its published SHA-256 on every use. The digests come from the release's SHA256SUMS, whose install_only gnu entries match NOOA's existing pins. musl and aarch64 are included;
  • installs the harness's requirements into a separate site-packages archive with uv pip install --no-config --target … --python-platform <arch>-manylinux_2_<N>|<arch>-unknown-linux-musl --python-version 3.13.14 --only-binary :all:.
    • The manylinux level never exceeds the sandbox's glibc.
    • Only requirements given as direct URLs are built from source (--no-binary hermes-agent for Hermes pinned to a commit). Every other package must publish a wheel for the sandbox's platform.
    • Versions are constraints taken from the agent environment's index-installed packages, so the sandbox runs what the adapter is tested with, as the harness guide's installation contract asks. I first used overrides; the Stirrup smoke caught that an override replaces lxml[html_clean] with plain lxml and drops lxml_html_clean. Adapter-specific overrides are an explicit field: Stirrup passes its overrides.txt (Pillow).
  • caches both archives under sandbox_python in Gym's cache_dir. Concurrent cold seeds in a process share one build and its outcome, a seed that is cancelled leaves the build running for the others, writes are atomic across processes, and a failed build is retried by the next seed.
  • downloads the Python build through the environment's HTTPS_PROXY, from UV_PYTHON_INSTALL_MIRROR when set, with at most 3 attempts.

nemo_gym/agents/utils/install_python_runtime.sh (new, runs in the sandbox with bash, like the supervisor and the harness runner, plus tar/gzip and rm):

  • unpacks both archives in a private .setup-<uuid> directory under the runtime directory;
  • checks the harness import there with python -I, cwd=/, and HOME inside the runtime directory (importing Hermes writes ~/.hermes);
  • removes the private setup directory after a local installer exits, including timeout or cancellation, and after any provider returns a failed command with no provider error (including missing bash, before the shell sets its trap). Remote timeout and provider-error results retain the installer's ownership because exec may still be running;
  • publishes with an atomic rename(2). Checking the runtime in place, setting it aside and publishing run under an exclusive lock on the runtime directory, held by the fresh runtime's own Python, so concurrent seeds share whichever runtime is published first. A runtime that fails its check (for example one from another platform) is set aside and replaced, and a runtime another seed accepted is never moved.

The runtime lives at /tmp/nemo-gym-<harness>-runtime-<key>/python. The key hashes the Python pin, the requirements, the overrides and the agent environment's versions, so a runtime left in the sandbox is reused only when it was built from the same inputs. Seed-time execs run from /, and nothing is written into the task's workdir or HOME.

Adapters.

  • Hermes and Stirrup call RUNTIME.is_installed() / RUNTIME.install() at seed. Activation uses the absolute runtime interpreter with -E -s and an absolute harness-owned script path. This ignores task PYTHON* settings and user site-packages while retaining trusted sibling imports. No venv is activated, and the task's Python environment remains available to tool subprocesses.
  • Hermes writes its session config and sets HERMES_HOME and Gym model routing before the first framework import. This keeps import-time state paths and .env loading out of the task's home and routes import-time MCP sampling through Gym. Seed import checks also override inherited HERMES_HOME under the runtime's private HOME.
  • uv_bootstrap.py and its tests are removed; nothing else used it.
  • Hermes's uv_cache_dir option is removed.

Docs. The harness guide no longer lists Hermes and Stirrup as exceptions; Codex stays. Its shared-module table and installation-contract example now describe sandbox_python.py, and the paragraph that recommended NOOA now points at SandboxPythonRuntime (NOOA's runtime still installs packages from the network in the sandbox). The Hermes and Stirrup READMEs describe the new requirements. The guide explicitly marks hermes_sandboxed_agent as being deprecated, removes its read-only mounted runtime and supervisor migration examples, and directs new Hermes development to hermes_agent and SandboxPythonRuntime.

No issue was filed; this is the follow-up named in #5151's "Known exceptions".

Compatibility

  • Sandbox requirements drop: no network, uv or Python needed for seeding; bash and tar with gzip are. glibc ≥ 2.17 or musl; x86_64 or aarch64. Stirrup needs glibc ≥ 2.28 and does not support musl: imageio-ffmpeg publishes no musllinux wheel, and the Pillow version in its overrides.txt publishes only manylinux_2_28 wheels. The supervisor already needed bash, so an image without it now fails at seed (bash: not found, exit 127) instead of at the first activation. Hermes still needs the Model Server's address, as before.
  • Agent Server requirements rise: it needs uv (it already needed it for the old path) and access to GitHub releases and the package index. HTTPS_PROXY applies to both. UV_PYTHON_INSTALL_MIRROR replaces the GitHub releases base for HTTP(S) mirrors (file:// is not supported), and uv's index settings (UV_* env vars, which --no-config still honors) select an index mirror. A host without access can be given the Python archive in the cache directory.
  • Dependency versions change: the sandbox's versions now follow the agent venv (constraints) instead of a fresh resolve at seed time.
  • Hermes config: uv_cache_dir is removed. BaseServerConfig ignores unknown keys, so configs that set it still load.
  • Runtime paths change: /tmp/nemo-gym-hermes-runtime-<commit>/venv → /tmp/nemo-gym-hermes-runtime-<key>/python. An image that baked the old venv path no longer short-circuits the install.
  • Cold-seed cost: about 35 MB Python plus 20 MB (Hermes) or 77 MB (Stirrup) of site-packages are uploaded per new sandbox. The host build happens once per host and platform.

Validation

Import-conflict reconciliation — fb963551f

  • Merged main at 972def79f after the package refactors. Kept SandboxPythonRuntime alongside main's new agent exports, and imported raises_agent_failure through its new public facade in the Stirrup tests. The runtime implementation, installer, and their regression tests are unchanged from the prior validated revision.
  • Linux/Python 3.13.14/OpenAI 2.44.0, exact commit archive verified after transfer, source binding checked: 1,137 passed, 2 opt-in skips, 24 subtests passed for pytest tests/unit_tests/test_sandbox_python.py tests/unit_tests/test_sandbox.py tests/unit_tests/test_local_provider.py tests/unit_tests/test_public_api.py tests/unit_tests/test_public_api_facades.py tests/unit_tests/test_moved_paths.py tests/unit_tests/test_import_linter_coverage.py tests/unit_tests/test_fern_docs_links.py; 352 passed for pytest responses_api_agents/hermes_agent/tests; 172 passed for pytest responses_api_agents/stirrup_agent/tests. Each invocation used -q -o addopts= -o asyncio_mode=auto -o log_cli=false -p pytest_asyncio.plugin -p no:cacheprovider with plugin autoload disabled. 1,661 tests passed overall.
  • pre-commit run --all-files and git diff --check: pass. cd fern && npm run check -- --warnings: 0 errors, with the authenticated missing-redirect check skipped.
  • Resolved the eight remaining inline review threads after verifying their fixes and recorded regression evidence. No new model rollout for these import-only conflict resolutions; the prior real Docker runtime smoke remains recorded below. Full CI and reviewer reapproval remain required.

Final main reconciliation and Linux validation — ff4bf1b58

  • Merged main at bedd1aaea after feat(eval): report causal failures across service boundaries #5072 landed. Preserved both AsyncSandbox.provider_name and the new lifecycle observation properties. Updated the Stirrup runtime-failure assertion to use the new agent-route failure wrapper while retaining the actual missing-moviepy cause.
  • Validated an exact git archive of this commit on Linux/Python 3.13.14/OpenAI 2.44.0, checked the archive SHA-256 after transfer, and verified that loaded Gym and adapter modules came from that snapshot.
  • Core selection: 1,023 passed, 2 opt-in skips, 24 subtests passed across test_sandbox_python.py, test_sandbox.py, test_local_provider.py, test_public_api.py, test_public_api_facades.py, test_moved_paths.py, test_import_linter_coverage.py, test_fern_docs_links.py, test_sandbox_lifecycle_observation.py, test_agent_seed_failure_boundary.py, test_agent_route_failure_boundary.py, test_harness_termination_failure.py, and test_failure_assertions.py under tests/unit_tests/. Initially 1,019 passed and four failed because the export lacked a Git index and the runner imported Gym before pytest installed its import hook. Supplied the tracked-file index, corrected runner import timing, and reran those four checks: 4 passed, with repository code unchanged.
  • pytest responses_api_agents/hermes_agent/tests: 352 passed.
  • pytest responses_api_agents/stirrup_agent/tests: 172 passed. Its cached environment initially had aiohttp 3.14.1, below the repository minimum; installed the lockfile's aiohttp 3.14.4 into an isolated test dependency directory and reran successfully.
  • All Linux pytest commands used -q -o addopts= -o asyncio_mode=auto -o log_cli=false -p pytest_asyncio.plugin -p no:cacheprovider, with plugin autoload disabled and imports bound to the snapshot. 1,547 tests passed overall. Disabling the cache plugin produces the benign unknown-cache_dir warning.
  • Real Docker smoke on the final commit: debian:bookworm-slim, network disabled, real pinned CPython plus cached Hermes package archives, through AsyncSandbox and DockerProvider. Installed and accepted the runtime, temporarily hid the container's bash, and attempted installation again. Restoring only the previous cleanup logic reproduced exit 127 with retained .setup-*; the fixed logic returned the same error with no retained setup files on two attempts. The accepted runtime and task files remained intact. Restoring bash allowed a successful retry, and the temporary container was removed. This tests real installation/cleanup, not a model rollout.
  • pre-commit run --all-files, git diff --check: pass on the final tree. cd fern && npm run check -- --warnings: 0 errors; the authenticated missing-redirect check was skipped.
  • No fresh real-model rollout: the configured NIM_API_KEY remains unavailable. Earlier model evidence below retains its original revision limits. New-head CI and reviewer reapproval remain required.

Review follow-up — 207e13068

  • Merged main at 3628b3493; resolved the sole conflict in nemo_gym/agents/__init__.py, retaining check_agent_seed_rejected and SandboxPythonRuntime.
  • Addressed the four latest inline comments: clean up remote command failures before the installer trap starts; keep SandboxPlatform internal and make SandboxPythonRuntime keyword-only; consistently document python -E -s; document HTTP(S)-only Python mirrors and offline cache prepopulation.
  • Also repaired redirects from both removed uv_bootstrap paths, documented sandbox_python/ in the configuration reference, and added the suggested setup-mkdir and NO_PROXY regression tests.
  • macOS/Python 3.13.14/OpenAI 2.44.0: ran pytest tests/unit_tests/test_sandbox_python.py tests/unit_tests/test_sandbox.py tests/unit_tests/test_local_provider.py tests/unit_tests/test_public_api.py tests/unit_tests/test_public_api_facades.py tests/unit_tests/test_moved_paths.py tests/unit_tests/test_import_linter_coverage.py tests/unit_tests/test_fern_docs_links.py -q -o addopts= -o log_cli=false -p pytest_asyncio.plugin, with PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 and PYTHONPATH bound to the final tree: 979 passed, 6 skipped, 24 subtests passed, plus two failures caused by the absent optional OpenSandbox SDK. Installed that SDK into a temporary directory and reran exactly those two failing tests: 2 passed (981 passed overall).
  • Negative controls: restoring the old local-only cleanup makes both new remote-failure cases fail; removing proxy bypass makes the NO_PROXY test fail; ignoring failed setup mkdir makes its regression fail. The expanded remote-installer test passes for caller timeout, returned timeout, and returned provider error, retaining setup until the detached installer completes.
  • pre-commit run --all-files and git diff --check: pass. cd fern && npm run check -- --warnings: 0 errors; authenticated missing-redirect checking was skipped.
  • Subsequent Linux validation of this revision passed: core sandbox suites 147 passed, 2 skipped, Hermes 352 passed, and Stirrup 172 passed, with source binding verified. The same real Docker cleanup smoke described above also passed on this revision. No fresh real-model rollout: the configured NIM_API_KEY is unavailable. Earlier Linux/model evidence below retains its revision limits. Full-scope CI and reviewer reapproval remain pending.

Conflict and review fixes — 1c9f83f05

  • Merged main at e12458ff0, moved the runtime files into nemo_gym/agents/utils/, and reconciled adapter imports, public exports, and guide links. The final tree also merges cleanly with the later main tip 101a44204.
  • Fixed archive ownership: both extractions use --no-same-owner. Two Linux tests run the actual installer under unshare -Ur, with an unmapped owner in each archive separately. Removing only those flags makes both tests fail with Cannot change ownership.
  • Addressed the non-blocking timeout-test comment: setup retains a 60-second bound; the short caller timeout starts only after the import-check marker appears. Local cleanup, accepted-runtime preservation, retry, and remote-installer ownership assertions remain.
  • Linux/Python 3.13.14, OpenAI 2.44.0: pytest tests/unit_tests/test_sandbox_python.py tests/unit_tests/test_sandbox.py tests/unit_tests/test_local_provider.py -q -o addopts= -o asyncio_mode=auto -o log_cli=false -p pytest_asyncio.plugin -p no:cacheprovider: 141 passed, 2 skipped (opt-in provider tests). The same flags with responses_api_agents/hermes_agent/tests: 352 passed; with responses_api_agents/stirrup_agent/tests: 172 passed, including the wall-time/partial-output case. A source-binding runner verified that all Gym and adapter imports came from the final tree.
  • Real pinned CPython and cached Hermes archives under remapped root: previous installer exits 2 without publishing; fixed installer exits 0, imports Hermes and its MCP client, publishes Python, and removes setup files. The CPython archive matches the production SHA-256. This is installer evidence, not a complete Docker/Enroot provider rollout.
  • macOS/Python 3.13.14: runtime/public API/import-layer/docs-link selection 168 passed, 2 Linux-only skips, 24 subtests passed; public-facade/moved-path suites 638 passed.
  • pre-commit run --all-files and git diff --check: pass. cd fern && npm run check: 0 errors; authenticated redirect checking was skipped. Independent final-tree review found no remaining issue in this fix scope.
  • No fresh real-model rollout: the configured NIM_API_KEY was unavailable. Earlier rollout evidence below retains its original revision limits. Full repository CI still needs to qualify the new head.

glibc detection with an optional musl loader — e8efb762e

  • Fixes the mixed-libc image detection reported in review: positive glibc evidence from getconf or ldd takes precedence over the presence of a musl loader. The ldd fallback also covers slim images without getconf; musl version output is retained even when ldd exits nonzero.
  • pytest tests/unit_tests/test_sandbox_python.py -q -o addopts= -o log_cli=false: 45 passed on Linux/Python 3.13.14. The same source-bound suite passed 45 tests on macOS, with -o asyncio_mode=auto -p no:cacheprovider (three plugin/config warnings).
  • Eight new cases execute the actual POSIX-shell probe with controlled utilities. Negative control replacing only the probe with the previous revision: 3 failed, 5 passed; all three failures are glibc images containing a musl loader.
  • Real Docker checks through AsyncSandbox and probe_sandbox_platform(): clean debian:bookworm-slim remains glibc 2.36; Debian with its official musl package changes from the old incorrect musl result to glibc 2.36, including after removing getconf from PATH. alpine:3.20 and bash:5.2 still report musl.
  • Real Stirrup RUNTIME.install() and is_installed() pass on Debian + musl without getconf, using the pinned CPython and real package archives (31.93 s); the task workdir is unchanged. All smoke containers were removed. This validates runtime seeding; no new model rollout was run for this probe-only change.
  • pre-commit run --all-files and git diff --check: pass.

Python and Hermes startup isolation — 87ff060b3

  • Five behavioral regression cases pass: both adapters' actual staged argv starts despite invalid task PYTHONHOME and a task PYTHONPATH containing shadow modules; trusted sibling imports work, and tool subprocesses still select the task's Python and inherit its original environment. Hermes seed and fresh worker processes use private state, ignore the task's Hermes .env, and establish Gym model routing before the first import.
  • Negative control: those same five cases fail with the three startup modules restored from bae2b42df (4 Hermes + 1 Stirrup failures).
  • On Linux/Python 3.13.14, pytest responses_api_agents/hermes_agent/tests -q -o addopts= -o log_cli=false: 352 passed. After the final model-routing reorder, pytest responses_api_agents/hermes_agent/tests/test_sandbox_runner.py responses_api_agents/hermes_agent/tests/test_model_kwargs.py responses_api_agents/hermes_agent/tests/test_summary.py -q -o addopts= -o log_cli=false: 47 passed.
  • pytest responses_api_agents/stirrup_agent/tests -q -o addopts= -o log_cli=false: 172 passed on Linux with Gym's required openai==2.44.0. The sandbox test double now exposes the new provider_name contract.
  • pre-commit run --all-files and git diff --check: pass. cd fern && npm run check: 0 errors, the same unauthenticated redirect-check warning.
  • Tests include real pinned Hermes imports and deterministic model-server fixtures; they are not fresh real-model rollout evidence. The available NIM config still requires an unset NIM_API_KEY, so the earlier real-model rollouts below were not rerun for this revision.

Timeout cleanup review fixes — bae2b42df

  • pytest tests/unit_tests/test_sandbox_python.py on macOS: 37 passed. The new cases exercise timeout and explicit cancellation after extraction, repeated interruptions, preservation of the accepted runtime and task directories, successful retry, and a remote installer that continues after its caller times out.
  • Negative control: replacing only SandboxPythonRuntime.install with the previous revision makes both new local interruption cases fail on retained .setup-* directories.
  • pytest tests/unit_tests/test_sandbox.py tests/unit_tests/test_local_provider.py tests/unit_tests/test_fern_docs_links.py on macOS: 121 passed, 4 skipped, 24 subtests passed. Skips require live OpenSandbox or Linux /proc.
  • pytest tests/unit_tests/test_sandbox_python.py tests/unit_tests/test_sandbox.py tests/unit_tests/test_local_provider.py on Linux: 131 passed, 2 skipped (live OpenSandbox opt-in).
  • Tests used Python 3.13.14 with imports bound to the modified source snapshot and -o addopts= -o asyncio_mode=auto -o log_cli=false -p pytest_asyncio.plugin -p no:cacheprovider; the cache plugin exclusion causes one benign cache_dir config warning.
  • pre-commit run --all-files and git diff --check: pass. cd fern && npm run check: 0 errors, one warning that the authenticated missing-redirect check was skipped.
  • Fresh Linux smoke with the real pinned CPython and Hermes archives: cold seed 18.37 s, task workdir/HOME unchanged at seed, an installation timeout leaves no setup directory, the accepted runtime remains importable, and retry succeeds. A new real-model rollout could not complete because the available NIM configuration references an unset NIM_API_KEY; no successful model call occurred. Earlier model rollout evidence below has not been rerun on this revision.

Earlier implementation validation

  • pre-commit run --files <changed files>: all hooks pass, no changes.

  • tests/unit_tests/test_sandbox_python.py (new, 26 tests), passing on macOS and Linux:

    • platform probe and manylinux selection;
    • environment pins;
    • directory keying;
    • host uv argv (--no-config, platform, --only-binary, --no-binary only for URL requirements, constraints, adapter overrides, scratch cwd);
    • shared and retried builds;
    • Python build checksum (download and every use);
    • sandbox-side install on the local provider, all with a hostile task (below): a cold install, 4 concurrent installs, a broken runtime replaced while 2 installs race, and an import failure that publishes nothing.
  • Regression test test_seeding_ignores_the_task_uv_configuration_and_leaves_the_task_untouched. The task's workdir has a uv.toml and a pyproject.toml [tool.uv] pointing at a dead index, and its PATH has a uv that records calls and exits 2. Seeding succeeds, the fake uv is never called, and the workdir and HOME are byte-for-byte unchanged while the harness writes under the runtime's own HOME. I checked that the test fails when the HOME redirect in the installer is removed.

  • responses_api_agents/stirrup_agent/tests on Linux: 171 passed.

  • responses_api_agents/hermes_agent/tests on Linux: 346 passed, 2 failed. The 2 failures (test_close_preserves_interrupted_observations_before_release[False|True], "launch outcome is unknown; termination deadline exceeded") also fail on refactor(agent_utils): run sandboxed harnesses with only bash in the task image #5151's head with the base Hermes files on the same host, regardless of TMPDIR. I checked at 7908559f8, and they still fail with fb642f9d2 on this branch. They exercise only the supervisor's close path, which this PR doesn't touch. refactor(agent_utils): run sandboxed harnesses with only bash in the task image #5151 traced them to slow forced stops on that busy host (about 1,100 processes) and fixed them in 05bac0530, which this branch includes; CI's server suite runs the Hermes tests green on this branch.

  • On macOS, the Hermes suite passes (342 passed, 6 Linux-only skipped). The Stirrup end-to-end tests that need /proc for the bash supervisor fail there; that is unrelated and they pass on Linux.

  • install_python_runtime.sh by hand with --network none and the real Hermes archives, run with bash as install() runs it (a11310a77), three rounds each:

    • debian:bookworm-slim (no Python), python:3.13-slim (image has Python 3.13.17) and bash:5.2 (Alpine: musl and BusyBox utilities, plus bash);
    • each round: two concurrent installs over a broken runtime, plus a failing import check.
    • Both installs succeeded every time. The runtime ran its own 3.13.14, the task's uv.toml and HOME were untouched, the setup directory was removed, and a failing check published nothing. On Alpine, pydantic_core and yaml import from musllinux wheels.
    • On alpine:3.20, which has no bash, the install fails with sh: bash: not found (exit 127). The earlier sh version of the installer passed the same rounds there, but that image cannot run the bash supervisor anyway.
  • a11310a77 (installer runs with bash): tests/unit_tests/test_sandbox_python.py 26 passed on macOS (bash 3.2); pre-commit passes. Hermes on macOS: 341 passed, 6 skipped, and test_close_retires_the_launch_path_before_removing_its_fence failed once while other suites ran in parallel, then passed alone. Stirrup on macOS: the same /proc failures as before. I did not rerun the Linux suites or the model rollouts below for this commit; the by-hand runs above cover the changed installer with the real archives.

  • e6ea97a99 (review fixes; merged with main in 3344e770f, whose tree is main plus exactly this PR's diff): tests/unit_tests/test_sandbox_python.py 34 passed on macOS. Among the new tests, test_a_runtime_another_seed_accepted_is_never_set_aside fails 3/3 against the previous installer. An installer stress run (3 seeds over a broken runtime, 40-module import check) went from 9/30 failed seeds to 0/90. Hermes on macOS: 343 passed, 6 skipped. Stirrup on macOS: 152 passed, with the same 19 /proc/uptime failures as before. pre-commit passes. I did not rerun the Linux suites, the Docker installer rounds or the model rollouts for this commit.

Rollout evidence

Real-model smoke rollouts with nvidia/nemotron-3-ultra-550b-a55b (NIM) on an x86_64 Linux workstation, using the Docker sandbox provider:

  • Each sandbox was on a docker network create --internal network: it reached only the Gym Model Server proxy on the gateway, with no route to the internet by IP or by name.
  • The agent owned the sandbox, with workdir: /workspace.
  • Task: "compute 37 * 43 and write only the number to answer.txt in the current working directory", checked by reading /workspace/answer.txt in the sandbox.
  • The host's root disk was full, so each sandbox's /tmp was a per-run bind mount from /dev/shm. docker cp does not write into tmpfs mounts.
Harness Image Seed (host cache) Image python3 / uv / internet After seed: /workspace, ~ Tools /workspace/answer.txt Close
Stirrup debian:bookworm-slim 32.0 s (cold build) none / none / unreachable empty, .bashrc .profile code_exec, code_exec, finish 1591 200, container removed
Hermes debian:bookworm-slim 35.0 s (cold build) none / none / unreachable empty, .bashrc .profile terminal, terminal 1591 200, container removed
Hermes python:3.13-slim 18.1 s (warm) image has /usr/local/bin/python3; runtime still reports 3.13.14 under /tmp/nemo-gym-hermes-runtime-…/python empty, unchanged terminal, terminal 1591 200, container removed

After merging main (Hermes venv now has mcp 1.30.0 and the runtime adds mcp>=1.28.1,<2), I reran the same checks on debian:bookworm-slim. Both passed, with the same sandbox findings and /workspace/answer.txt = 1591, and their containers were removed. Hermes seeded in 17.4 s with a fresh host build (terminal ×2); Stirrup seeded in 32.0 s (code_exec ×2, finish). On Linux after the merge: Stirrup 171 passed; test_sandbox_python.py 26 passed; Hermes 346 passed plus the same 2 pre-existing failures.

An earlier Hermes run (with the overrides version) seeded in 18.5 s from a cold host cache with a 15 MB site-packages archive. The constraints version is 20 MB because it keeps the extras the overrides dropped.

Not in this PR (follow-ups)

  • Codex's task-image Python stays an exception; where its 429 proxy lives is the maintainers' call.

🤖 Generated with Claude Code

hemildesai and others added 30 commits October 26, 2026 15:04
The supervisor uploaded into task sandboxes now needs only bash, /proc, sleep and mv, so
sandboxed harnesses no longer require Python 3.8+ in the task image. It keeps the CLI and
receipt contract: run the harness in its own process group, stop it at the deadline or
when the stop file appears (TERM, then KILL the group), and confirm no group member runs.

Stops now reach the supervisor through stop.request instead of SIGTERM, so the launch
shell no longer ignores TERM and the harness keeps the default disposition. Shared
receipt types and timeouts move to supervisor_client; SandboxCommand drops its python
field. shell_assignments() renders runner inputs as bash source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
harness_runner.sh replaces per-harness Python runners: it sources the PARAMS written by
shell_assignments(), records runtime.json, applies the harness environment and runs the
CLI with file-backed stdio. With events set, it stamps each JSON stdout line with the time
it was seen, reading a file so a detached tool holding stdout cannot stall it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
…rmes tests

Point the harness guide, package docs and SandboxSession docstring at process_supervisor.sh
and harness_runner.sh, drop the Python-in-sandbox requirement and the subreaper drain the
bash supervisor no longer performs. Hermes tests use the bash supervisor. The runner stamps
a final unterminated event line with the same fallback time as the others on bash < 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
Kilo sessions now run through the shared bash harness runner with PARAMS
written by shell_assignments(); sandbox_runner.py is gone. A short setup
string removes a stale plugin receipt and records the task's HOME/XDG for
the task-environment plugin, unset_env drops inherited KILO_*/OPENCODE_*/
OpenAI variables, and the exit code comes from the cleanup receipt.
Path checks are POSIX shell, the bootstrap installs only bash, and the
installer drops its Python checks. The SQLite snapshot moves host-side:
kilo.db and its WAL files are downloaded and backed up locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
…ndbox

Claude sessions now launch through the shared bash harness_runner.sh with params.sh instead of
sandbox_runner.py and input.json. The offline runtime installer is a bash script with the same
SHA-256, version and lock semantics, so task images need bash and coreutils but no Python.

Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
Launch OpenClaw through the shared bash harness_runner.sh with PARAMS from shell_assignments()
and a staged prompt file, replacing sandbox_runner.py. The workdir overlap check is POSIX sh,
session setup installs only a missing bash, and the runtime installer drops its Python checks
(apk now reports whether musl images have libstdc++; the prepare step already guards the
runtime path against the task workdir).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
Task images now need only bash. The shared harness_runner.sh replaces
sandbox_runner.py: PARAMS carry the CLI argv, environment and stdio files, and a
short setup records the task HOME/XDG directories for the shell.env hook, removes
a stale initialization receipt and creates the private CLI directories.

The SQLite snapshot moves to the agent server: after confirmed cleanup the adapter
downloads opencode.db (and its -wal/-shm files when present) and runs the same
export there, producing the same export.json and observations.db. The bootstrap
no longer installs python3 and checks workdir overlap with POSIX shell, and the
runtime installer serializes with flock, or a reclaimable lock directory without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
Pi now runs under the shared bash harness runner: the adapter stages params.sh
and prompt.txt, and the runner records [seen_at, event] lines. Session setup no
longer installs python3, the runtime installer checks for the musl C++ library
by path instead of with ctypes, and the host parser skips recorded lines that
are not [number, object] records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
…available

A missing or undownloadable session database again leaves no export, so
collection reports "OpenCode sandbox runner returned no valid result" with the
output log, as when export.json was missing before. The export no longer depends
on copying the observation store: a failed copy, like the failed observations.db
download before, only adds observation gaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
The bash supervisor no longer takes an interpreter, so NOOA drops the removed
SandboxCommand.python field (its own pinned Python still runs its worker), and
the Codex worker keeps its Python runner under the bash supervisor: its
sandbox-local 429 retry proxy still needs Python. OpenCode drops
sandbox_python_executable; sandbox_path_prefix now reaches the CLI through the
runner setup. Tests and READMEs name process_supervisor.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
… runner names

On bash < 4.2 printf has no strftime, so every event was stamped with an empty
time and the host parsers dropped them all; fall back to date. Strip a trailing
CR before recording a JSON line. Inherited variables named like runner PARAMS
(setup, events, stdout, ...) no longer configure the runner or leak into the CLI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
…he bash supervisor

A stopped CLI's tools that run in their own session (OpenCode and Codex start every
shell tool that way) kept writing to the task after a confirmed receipt. Before
stopping the harness, the supervisor now records its descendants outside its
process group from /proc and kills them, and the groups they lead, with the
harness; confirmation waits for them too. Services orphaned before the stop are
still left for the sandbox owner.

The supervisor also:
- reads each /proc/PID/stat whole and never evaluates its fields as arithmetic, so
  a process name holding a newline cannot run code or hide a group member;
- refuses to launch without a readable /proc, and counts unreadable process
  state as running;
- bounds the wait for a KILLed harness and writes a negative receipt instead;
- exits on a missing option value or separator instead of looping or running part
  of the command, and ignores an inherited errexit.

Launches render fixed-point seconds, and a deadline shorter than the supervisor's
centisecond resolution expires before launch instead of leaving no receipt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
…runner descriptors

Without job control bash starts an async command with INT and QUIT ignored, and
the CLI's tools inherited that. The CLI also inherited the runner's read and
events descriptors, so a tool could write raw lines into events.jsonl. Reset the
two signals (bash 5 honours the reset) and close both descriptors for the CLI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
…e to older bash

Without flock, the Claude installer never reclaimed the lock of a killed
installer, and OpenCode's reclaim could delete a lock another installer had just
taken (or skip an owner killed before it wrote its PID). Both now take a symlink
that names the owner by PID and start time, and remove a dead owner's lock only
under a second lock after re-reading it.

The Claude installer lowercases the trusted digest without bash 4 expansions.
The Pi installer no longer fails under set -u on bash < 4.4 when it needs no
packages, and checks every default musl library directory for libstdc++.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
sqlite3's context manager commits but does not close, so a failed export kept the
copied databases open while the snapshot directory was removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
…wn sessions

The receipt docstring, the harness guide and the Codex, Pi, OpenCode, OpenClaw,
Hermes and Terminal-Bench READMEs now describe what the bash supervisor does:
it kills the harness group and, after a forced stop, the descendants that ran in
other process groups when the stop began; processes that left the harness
earlier stay with the sandbox owner. The receipt documents shell exit statuses.
The Kilo and local-provider tests cover a tool in its own session again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
… stop test

shell_assignments() now raises for NUL and non-string words, which bash would
silently drop or which raised an opaque TypeError. The Hermes close test stops
the supervisor through the stop file alone, as production does, and the
sandboxed Hermes README states that the supervisor needs the task image's bash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
…completed tools

The bash port's tests asserted that a tool still running in its own session
survives a close, and modelled a completed tool's service as a direct child of
the CLI. With the supervisor's stop-time cleanup, an in-flight detached tool is
stopped again, as before the port; a service left by a tool that already exited
still survives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
The harness guide said Codex starts its CLI through harness_runner.sh. On main
Codex keeps its Python sandbox_runner.py for its sandbox-local retry proxy, so it
still needs the task image's Python; Hermes, Stirrup and NOOA run their Python
shims on interpreters they install privately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
…sandbox-runtime

# Conflicts:
#	responses_api_agents/hermes_agent/tests/test_sandbox.py
Resolve the conflicts with #4805, which hid the Python supervisor and opted-in
Python runners from `pkill python` by running them through interpreter links.
The supervisor here is bash, so its link is dropped. Its runner link survives as
SandboxCommand.stdlib_runner on the bash launch: Codex, the one harness that
keeps a standard-library Python runner, runs it as bin/gym-runner from
`sandbox_runner`. OpenCode, OpenClaw, Pi and Kilo keep the bash runner, whose
name never mentions python. The pkill and delayed-launch tests follow the bash
supervisor; the READMEs and the harness guide describe the result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
…afe task dirs

Address review findings on the bash runtime:
- Without flock, the OpenCode and Claude installers now queue on numbered
  owner links that are never removed or reused, and skip a number whose
  owner is done or gone. There is no second reclaim lock left to strand, and
  an installer killed at any point cannot block or release another.
- A preinstalled OpenCode binary must exit successfully from --version, not
  only print the version, before it replaces the cached runtime.
- OpenCode's and Kilo's task-env.json writers escape every JSON control
  character, so task HOME/XDG paths with tabs or newlines stay valid JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
…l-name tests

The pkill tests merged from main called payload() with a timeout, which only
the Python runner's input carried; the bash runner takes its timeout from
execute().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
The pkill test from main assumed the supervisor's only child is the runner. The
bash supervisor also forks its polling sleeps, so take the runner as the parent
of the tool it started and check that it is a supervisor child.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
Review found that one descendant snapshot before TERM let a confirmed receipt
leave processes running: a TERM handler that runs `setsid ... &` and exits, or
a tool that keeps forking new sessions during the grace period.

- The supervisor gives the harness a NEMO_GYM_SUPERVISOR environment marker
  unique to this run. On a forced stop it scans /proc when the stop begins, on
  every grace and drain round, and before the final KILL. It reaches the
  harness group, descendants of the harness, of its group and of reached
  processes, and processes carrying the marker. Marked processes that had
  already left the harness when the stop began are preserved as services, as
  the subreaper on main did. The Codex runner passes the marker through its
  env allowlist.
- A recorded group is killed or awaited only while its ID is free or still
  held by the recorded leader (same start time), so a reused group ID is not
  hit.
- Without a process group of its own, the supervisor restarts itself as a job
  in a new group, so a provider's group KILL cannot end it before the receipt.
  The shell left behind passes on TERM and the exit status.
- Docstrings and READMEs state what a forced stop reaches and what it does not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
…statuses

- emit() strips a trailing CR without `${1%$'\r'}`, which tried the suffix at
  every position and took seconds per MB, long enough for Pi's agent_end event
  to outlast the deadline.
- The runner resets errexit, nounset, pipefail and IFS, so a BASH_ENV with
  `set -e` no longer stops it at the first non-JSON line.
- A failed exec redirection no longer falls through into events mode or
  launches the CLI. The runner exits 125 for its own failures (bad cwd, an
  exit in setup) and 126 or 127 when the CLI could not be started.
- Drop `trap - INT QUIT`: it did not change what the CLI inherits, and its test
  passed without it.
- Kilo tells runner failures apart from Kilo's own exit status, reports no exit
  code for a Kilo the runner never started, and none for a cancelled run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
…e Code

- The OpenCode installer removes a partly staged binary from its single EXIT
  trap, however it exits, and a cached binary must exit successfully from
  --version to be reused.
- Claude Code setup installs a missing bash on root images with apk or
  apt-get, as the other bash-runner adapters do, and otherwise fails with a
  message that names bash instead of a bare 127.
- Reword "native" in the Claude installer docs, docstring and a test name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

Copy link
Copy Markdown
Contributor

I'll watch this ready-to-merge PR and report substantive progress. Running on nim-gpt-6-1-sol.

Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

Copy link
Copy Markdown
Contributor

💬 I couldn't inspect the failed checks or their logs: GitHub reads failed with HTTP 503 because the platform's Git authority was unavailable. I haven't diagnosed the CI failure or requested a rerun. I'll keep the PR watch armed.

Copy link
Copy Markdown
Contributor

💬 GitHub access recovered. The PR is still open, but GitHub now reports merge conflicts with main. I’m checking the current CI and review evidence before preparing a resolution.

Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
@yaoyu-33

yaoyu-33 commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test fb96355

Copy link
Copy Markdown
Contributor

💬 The run failed: {"container_build":{"correction":"","duties_complete":false,"equivalent_attempt":0,"justification_evidence":"","preflight_complete":false,"preflight_evidence":"","reason":"not_requested","requested":false,"reuse_checked":false,"reuse_evidence":""},"do":[],"say":"","state":"working","wait":[{"item":"watch","on":"retry_turn","retry_kind":"platform"},{"item":"watch","on":"ci_watch_pr","pr":5152,"repo":"NVIDIA-NeMo/Gym"}]}.

1 similar comment

Copy link
Copy Markdown
Contributor

💬 The run failed: {"container_build":{"correction":"","duties_complete":false,"equivalent_attempt":0,"justification_evidence":"","preflight_complete":false,"preflight_evidence":"","reason":"not_requested","requested":false,"reuse_checked":false,"reuse_evidence":""},"do":[],"say":"","state":"working","wait":[{"item":"watch","on":"retry_turn","retry_kind":"platform"},{"item":"watch","on":"ci_watch_pr","pr":5152,"repo":"NVIDIA-NeMo/Gym"}]}.

@yaoyu-33
yaoyu-33 enabled stacked auto-merge November 4, 2026 03:02

@yuhezhang-ai yuhezhang-ai 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.

Thanks for the fixes. Both of my earlier findings are addressed at fb96355: archive extraction no longer restores unmapped host ownership, and the interruption tests wait for installer readiness before starting the short deadline. I also checked the remote-failure cleanup and import reconciliation and found no further blocking issues. The focused regressions and required CI checks pass on this head.

@yaoyu-33
yaoyu-33 added this pull request to the merge queue Nov 4, 2026
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

🔄 Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/41767955867

Copy link
Copy Markdown
Contributor

PR NVIDIA-NeMo/Gym#5152 left the merge queue after validation failed.

Merged via the queue into main with commit a6cd35c Nov 4, 2026
63 of 66 checks passed
@yaoyu-33
yaoyu-33 deleted the fix/host-built-hermes-stirrup-runtime branch November 4, 2026 03:52

Copy link
Copy Markdown
Contributor

The PR is no longer an open ready-to-merge PR targeting main.

yaoyu-33 added a commit that referenced this pull request Nov 4, 2026
#5152 landed on main as a6cd35c with commits this branch had not seen.
Five conflicted files belong to #5152 (nemo_gym/agents/__init__.py,
sandbox_python.py, nemo_gym/sandbox/api.py, Stirrup's session tests and
test_sandbox_python.py); this branch does not change them, so main's
version stands. The harness guide is main's text with this branch's four
Codex edits: Codex starts through the runner and Node launcher, no
adapter depends on the task image's Python, stdlib_runner remains only
for existing shims, and the Codex launcher keeps the supervisor marker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
yaoyu-33 added a commit to yaoyu-33/Gym that referenced this pull request Nov 4, 2026
Resolve the harness-guide conflicts with NVIDIA-NeMo#5152 by taking its host-built
runtime text and dropping its notes that hermes_sandboxed_agent is being
deprecated, since this change removes the adapter. Update the Hermes
migration notes to match NVIDIA-NeMo#5152: the Agent Server builds and uploads the
runtime to /tmp/nemo-gym-hermes-runtime-<key>/python, so network-restricted
task images no longer need a preinstalled runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
gwarmstrong added a commit to gwarmstrong/Gym that referenced this pull request Nov 4, 2026
…-in-venv-v2

Hermes and Stirrup now start a host-built standalone runtime with -E -s (NVIDIA-NeMo#5152),
so their runners take the session's bin/gym-runner link rather than a venv link.
Both keep their suffix-less runners. Stirrup builds the runner path from
_PACKAGE_DIR when it is used, so tests that move the package move the runner too.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
dhruvnathawani pushed a commit to salma2vec/Gym that referenced this pull request Nov 6, 2026
…VIDIA-NeMo#5153)

* refactor(agent_utils): run the harness process supervisor in bash

The supervisor uploaded into task sandboxes now needs only bash, /proc, sleep and mv, so
sandboxed harnesses no longer require Python 3.8+ in the task image. It keeps the CLI and
receipt contract: run the harness in its own process group, stop it at the deadline or
when the stop file appears (TERM, then KILL the group), and confirm no group member runs.

Stops now reach the supervisor through stop.request instead of SIGTERM, so the launch
shell no longer ignores TERM and the harness keeps the default disposition. Shared
receipt types and timeouts move to supervisor_client; SandboxCommand drops its python
field. shell_assignments() renders runner inputs as bash source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* feat(agent_utils): add a shared bash harness runner

harness_runner.sh replaces per-harness Python runners: it sources the PARAMS written by
shell_assignments(), records runtime.json, applies the harness environment and runs the
CLI with file-backed stdio. With events set, it stamps each JSON stdout line with the time
it was seen, reading a file so a detached tool holding stdout cannot stall it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* docs(agent_utils): describe the bash supervisor and runner; update hermes tests

Point the harness guide, package docs and SandboxSession docstring at process_supervisor.sh
and harness_runner.sh, drop the Python-in-sandbox requirement and the subreaper drain the
bash supervisor no longer performs. Hermes tests use the bash supervisor. The runner stamps
a final unterminated event line with the same fallback time as the others on bash < 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* test(hermes_agent): expect stop requests as the stop file, not a signal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* refactor(kilocode): run the harness without Python in the sandbox

Kilo sessions now run through the shared bash harness runner with PARAMS
written by shell_assignments(); sandbox_runner.py is gone. A short setup
string removes a stale plugin receipt and records the task's HOME/XDG for
the task-environment plugin, unset_env drops inherited KILO_*/OPENCODE_*/
OpenAI variables, and the exit code comes from the cleanup receipt.
Path checks are POSIX shell, the bootstrap installs only bash, and the
installer drops its Python checks. The SQLite snapshot moves host-side:
kilo.db and its WAL files are downloaded and backed up locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* refactor(claude_code_agent): run the harness without Python in the sandbox

Claude sessions now launch through the shared bash harness_runner.sh with params.sh instead of
sandbox_runner.py and input.json. The offline runtime installer is a bash script with the same
SHA-256, version and lock semantics, so task images need bash and coreutils but no Python.

Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* refactor(openclaw_agent): run the harness without Python in the sandbox

Launch OpenClaw through the shared bash harness_runner.sh with PARAMS from shell_assignments()
and a staged prompt file, replacing sandbox_runner.py. The workdir overlap check is POSIX sh,
session setup installs only a missing bash, and the runtime installer drops its Python checks
(apk now reports whether musl images have libstdc++; the prepare step already guards the
runtime path against the task workdir).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* refactor(opencode): run the harness without Python in the sandbox

Task images now need only bash. The shared harness_runner.sh replaces
sandbox_runner.py: PARAMS carry the CLI argv, environment and stdio files, and a
short setup records the task HOME/XDG directories for the shell.env hook, removes
a stale initialization receipt and creates the private CLI directories.

The SQLite snapshot moves to the agent server: after confirmed cleanup the adapter
downloads opencode.db (and its -wal/-shm files when present) and runs the same
export there, producing the same export.json and observations.db. The bootstrap
no longer installs python3 and checks workdir overlap with POSIX shell, and the
runtime installer serializes with flock, or a reclaimable lock directory without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* refactor(pi_agent): run the harness without Python in the sandbox

Pi now runs under the shared bash harness runner: the adapter stages params.sh
and prompt.txt, and the runner records [seen_at, event] lines. Session setup no
longer installs python3, the runtime installer checks for the musl C++ library
by path instead of with ctypes, and the host parser skips recorded lines that
are not [number, object] records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(opencode): keep pre-port outcomes when the session database is unavailable

A missing or undownloadable session database again leaves no export, so
collection reports "OpenCode sandbox runner returned no valid result" with the
output log, as when export.json was missing before. The export no longer depends
on copying the observation store: a failed copy, like the failed observations.db
download before, only adds observation gaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* style: format bash supervisor tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* refactor(agent): adapt GitHub-only harness paths to the bash supervisor

The bash supervisor no longer takes an interpreter, so NOOA drops the removed
SandboxCommand.python field (its own pinned Python still runs its worker), and
the Codex worker keeps its Python runner under the bash supervisor: its
sandbox-local 429 retry proxy still needs Python. OpenCode drops
sandbox_python_executable; sandbox_path_prefix now reaches the CLI through the
runner setup. Tests and READMEs name process_supervisor.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent_utils): keep runner events on old bash and ignore inherited runner names

On bash < 4.2 printf has no strftime, so every event was stamped with an empty
time and the host parsers dropped them all; fall back to date. Strip a trailing
CR before recording a JSON line. Inherited variables named like runner PARAMS
(setup, events, stdout, ...) no longer configure the runner or leak into the CLI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent_utils): stop tools that left the harness group and harden the bash supervisor

A stopped CLI's tools that run in their own session (OpenCode and Codex start every
shell tool that way) kept writing to the task after a confirmed receipt. Before
stopping the harness, the supervisor now records its descendants outside its
process group from /proc and kills them, and the groups they lead, with the
harness; confirmation waits for them too. Services orphaned before the stop are
still left for the sandbox owner.

The supervisor also:
- reads each /proc/PID/stat whole and never evaluates its fields as arithmetic, so
  a process name holding a newline cannot run code or hide a group member;
- refuses to launch without a readable /proc, and counts unreadable process
  state as running;
- bounds the wait for a KILLed harness and writes a negative receipt instead;
- exits on a missing option value or separator instead of looping or running part
  of the command, and ignores an inherited errexit.

Launches render fixed-point seconds, and a deadline shorter than the supervisor's
centisecond resolution expires before launch instead of leaving no receipt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent_utils): give the events-mode CLI default interrupts and no runner descriptors

Without job control bash starts an async command with INT and QUIT ignored, and
the CLI's tools inherited that. The CLI also inherited the runner's read and
events descriptors, so a tool could write raw lines into events.jsonl. Reset the
two signals (bash 5 honours the reset) and close both descriptors for the CLI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent): make installer locks without flock crash-safe and portable to older bash

Without flock, the Claude installer never reclaimed the lock of a killed
installer, and OpenCode's reclaim could delete a lock another installer had just
taken (or skip an owner killed before it wrote its PID). Both now take a symlink
that names the owner by PID and start time, and remove a dead owner's lock only
under a second lock after re-reading it.

The Claude installer lowercases the trusted digest without bash 4 expansions.
The Pi installer no longer fails under set -u on bash < 4.4 when it needs no
packages, and checks every default musl library directory for libstdc++.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(opencode,kilocode): close host-side SQLite connections

sqlite3's context manager commits but does not close, so a failed export kept the
copied databases open while the snapshot directory was removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* docs(agent_utils): describe the stop-time cleanup of tools in their own sessions

The receipt docstring, the harness guide and the Codex, Pi, OpenCode, OpenClaw,
Hermes and Terminal-Bench READMEs now describe what the bash supervisor does:
it kills the harness group and, after a forced stop, the descendants that ran in
other process groups when the stop began; processes that left the harness
earlier stay with the sandbox owner. The receipt documents shell exit statuses.
The Kilo and local-provider tests cover a tool in its own session again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent_utils): reject shell words bash cannot hold; tighten Hermes stop test

shell_assignments() now raises for NUL and non-string words, which bash would
silently drop or which raised an opaque TypeError. The Hermes close test stops
the supervisor through the stop file alone, as production does, and the
sandboxed Hermes README states that the supervisor needs the task image's bash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* style: apply ruff formatting to the ported tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* test(pi_agent): stop in-flight detached tools and keep services from completed tools

The bash port's tests asserted that a tool still running in its own session
survives a close, and modelled a completed tool's service as a direct child of
the CLI. With the supervisor's stop-time cleanup, an in-flight detached tool is
stopped again, as before the port; a service left by a tool that already exited
still survives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* docs(agent_utils): say which harnesses keep a Python shim in the sandbox

The harness guide said Codex starts its CLI through harness_runner.sh. On main
Codex keeps its Python sandbox_runner.py for its sandbox-local retry proxy, so it
still needs the task image's Python; Hermes, Stirrup and NOOA run their Python
shims on interpreters they install privately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent): crash-safe installer queue, checked version probe, JSON-safe task dirs

Address review findings on the bash runtime:
- Without flock, the OpenCode and Claude installers now queue on numbered
  owner links that are never removed or reused, and skip a number whose
  owner is done or gone. There is no second reclaim lock left to strand, and
  an installer killed at any point cannot block or release another.
- A preinstalled OpenCode binary must exit successfully from --version, not
  only print the version, before it replaces the cached runtime.
- OpenCode's and Kilo's task-env.json writers escape every JSON control
  character, so task HOME/XDG paths with tabs or newlines stay valid JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* test(opencode,openclaw): pass the bash runner's payload to the neutral-name tests

The pkill tests merged from main called payload() with a timeout, which only
the Python runner's input carried; the bash runner takes its timeout from
execute().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* test(agent_utils): find the stdlib runner as the tool's parent

The pkill test from main assumed the supervisor's only child is the runner. The
bash supervisor also forks its polling sleeps, so take the runner as the parent
of the tool it started and check that it is a supervisor child.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent_utils): reach processes that leave the harness during a stop

Review found that one descendant snapshot before TERM let a confirmed receipt
leave processes running: a TERM handler that runs `setsid ... &` and exits, or
a tool that keeps forking new sessions during the grace period.

- The supervisor gives the harness a NEMO_GYM_SUPERVISOR environment marker
  unique to this run. On a forced stop it scans /proc when the stop begins, on
  every grace and drain round, and before the final KILL. It reaches the
  harness group, descendants of the harness, of its group and of reached
  processes, and processes carrying the marker. Marked processes that had
  already left the harness when the stop began are preserved as services, as
  the subreaper on main did. The Codex runner passes the marker through its
  env allowlist.
- A recorded group is killed or awaited only while its ID is free or still
  held by the recorded leader (same start time), so a reused group ID is not
  hit.
- Without a process group of its own, the supervisor restarts itself as a job
  in a new group, so a provider's group KILL cannot end it before the receipt.
  The shell left behind passes on TERM and the exit status.
- Docstrings and READMEs state what a forced stop reaches and what it does not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent_utils): linear CR strip, errexit guard and distinct runner statuses

- emit() strips a trailing CR without `${1%$'\r'}`, which tried the suffix at
  every position and took seconds per MB, long enough for Pi's agent_end event
  to outlast the deadline.
- The runner resets errexit, nounset, pipefail and IFS, so a BASH_ENV with
  `set -e` no longer stops it at the first non-JSON line.
- A failed exec redirection no longer falls through into events mode or
  launches the CLI. The runner exits 125 for its own failures (bad cwd, an
  exit in setup) and 126 or 127 when the CLI could not be started.
- Drop `trap - INT QUIT`: it did not change what the CLI inherits, and its test
  passed without it.
- Kilo tells runner failures apart from Kilo's own exit status, reports no exit
  code for a Kilo the runner never started, and none for a cancelled run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent): clean up staged OpenCode copies; bootstrap bash for Claude Code

- The OpenCode installer removes a partly staged binary from its single EXIT
  trap, however it exits, and a cached binary must exit successfully from
  --version to be reused.
- Claude Code setup installs a missing bash on root images with apk or
  apt-get, as the other bash-runner adapters do, and otherwise fails with a
  message that names bash instead of a bare 127.
- Reword "native" in the Claude installer docs, docstring and a test name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* docs(agent-server): describe sandbox Python and uv paths as exceptions

- Codex's task-image Python, stdlib_runner, and Hermes and Stirrup building a
  uv environment in the sandbox are exceptions being removed; new Python
  harnesses bring a runtime prepared on the host, as NOOA and
  hermes_sandboxed_agent do.
- The runner row names the utilities it needs and its exit statuses.
- The migration note lists the removed python= parameters, the bash
  requirement, and the receipt and runner status changes.
- Harness termination describes what a forced stop reaches, the marker, and
  supervisor.pid when the supervisor leaves the provider group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* test(agent_utils): find the supervisor through the runner after it leaves the group

The pkill test launches without a new session, so the supervisor now restarts
itself in its own process group and supervisor.pid names the process that
started it. Take the supervisor as the runner's parent, accept it as the
recorded PID or that PID's child, and check that neither bash process matches
the python kills.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent): build Hermes and Stirrup runtimes on the host

Hermes and Stirrup uploaded uv into the task sandbox and built a virtual
environment there from the network, with the task workdir as cwd and without
--no-config. A task's uv.toml or [tool.uv] could change the install, uv's cache
and managed Python landed in the task's HOME, and --python 3.13 could bind the
image's own interpreter.

Add nemo_gym/agent_utils/sandbox_python.py. On the Agent Server it downloads a
pinned python-build-standalone CPython for the sandbox's architecture and C
library, checks its SHA-256, and installs the harness's requirements into a
site-packages archive with uv --no-config --python-platform --only-binary :all:,
constrained to the agent environment's versions. Only direct-URL requirements,
such as Hermes at a commit, are built from source. Both archives are cached on
the host. install_python_runtime.sh unpacks them under /tmp, checks the harness
import with HOME inside the runtime directory, and publishes the runtime with an
atomic rename, so concurrent seeds share one runtime and a runtime that fails
its check is replaced. The sandbox needs sh and tar, but no network, uv or
Python.

Remove uv_bootstrap.py and Hermes's uv_cache_dir option, update the tests, and
drop Hermes and Stirrup from the harness guide's exceptions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* perf(agent_utils): make the supervisor's forced stop cheaper on busy hosts

A forced stop scans every process in /proc several times, and each
/proc/PID/stat parse used a longest-prefix match (quadratic in bash) and
three regex compilations. On a host with about 1,100 processes a stop
took 1.65 s, so Hermes's close test, which allows 2 s and polls for the
receipt once a second, failed there while CI passed.

Parse with a shortest-prefix loop and glob digit checks, let the forced
drain take liveness from its scan instead of a second /proc pass, and
skip the extra scan once the harness has exited (the drain scans next).
A stop on the same host now takes about 0.55 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* refactor(codex): run sandboxed Codex without the task image's Python

Codex was the last CLI harness whose sandbox worker ran on the task
image's python3, only because its HTTP 429 retry proxy was Python.
Run Codex through the shared harness_runner.sh, behind a small Node
launcher (sandbox_launcher.mjs) on the Node that the Codex runtime
already installs. The launcher keeps the proxy's behaviour (two 429
retries honouring Retry-After up to 30 s, attempt records, transport
errors), the environment allowlist and TERM forwarding, and exits with
Codex's status as a shell would. The native-session snapshot moves to
the same launcher.

The bootstrap, workdir overlap check and installer no longer need
python3. With no remaining user, stdlib_runner and the bin/gym-runner
interpreter link are removed, as the harness docs promised.

Codex tests that need Node install it at collection on Linux, through
setup_codex.ensure_node(), so CI keeps running them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent): close gaps in the flock-less installer queue and root-workdir check

Review on NVIDIA-NeMo#5151 found three gaps in the bash ports:
- The Claude installer set its queue ticket before linking it, so an
  installer stopped after losing a number marked the winner's ticket
  done and let the next installer run beside it. It now takes the
  ticket only once its link exists, as OpenCode's installer does.
- Both flock-less queues treated a killed but unreaped owner as live, so
  later installers waited until their timeout. A zombie or dead process
  now has no identity and no longer holds its number.
- OpenClaw's overlap check missed a workdir that resolves to /, such as
  "/.", because "/" plus a separator is "//". Both sides now drop a
  trailing slash first; the error names / when it is the owned path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* refactor(agent_utils): run the sandbox Python installer with bash

The supervisor and the harness runner are bash scripts, but the installer ran
with POSIX sh, so the sandbox's script requirements differed by step. Hermes and
Stirrup need bash for the supervisor anyway, so an image without bash now fails
at seed, with "bash: not found", instead of at the first activation.

The installer sets errexit, nounset and pipefail explicitly, so an inherited
SHELLOPTS cannot change how it fails, and it uses bash tests. It still publishes
with the new runtime's os.rename: mv would move the fresh runtime inside one a
concurrent seed published first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(codex): keep events with Unicode line separators; record broken 429 bodies

Review on NVIDIA-NeMo#5153:
- The bash runner copies Codex's JSON event lines verbatim, while the
  removed Python worker re-serialized them with escaped non-ASCII. Codex
  leaves U+0085, U+2028 and U+2029 unescaped inside strings, so
  splitlines() cut such an event in two and its message was lost while
  the turn still reported completed. Split records on "\n" only and
  skip blank lines.
- A retryable 429 whose body timed out or was cut short destroyed the
  response with no error listener, which could end the launcher and
  Codex with it. The drain now catches the error and records it as the
  attempt's transport_error before retrying.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent_utils): serialize runtime publication and harden the host build

Address the review of NVIDIA-NeMo#5152, including the two NeMo-CI review threads.

Sandbox side:
- install_python_runtime.sh checks, sets aside and publishes the runtime
  under a lock on the runtime directory, held by the fresh runtime's own
  Python. A seed whose check of a missing or broken runtime failed could
  move aside, and its EXIT trap delete, a runtime that another seed had
  just published and returned success on.
- install() removes its .setup-<uuid> directory when creating it or an
  upload fails, times out or is cancelled before the installer, whose EXIT
  trap owns it from then on, starts.
- Hermes and Stirrup start their runner with -s. The runtime is not a
  virtual environment, so the task HOME's user site-packages would
  otherwise come ahead of the pinned packages.

Agent Server side:
- The archives live under sandbox_python in Gym's cache_dir instead of a
  hard-coded ~/.cache path resolved at import.
- The Python build download honors HTTPS_PROXY and
  UV_PYTHON_INSTALL_MIRROR, stops after 3 attempts instead of retrying
  until the seed times out, streams to disk in a thread, and reports HTTP
  errors as RuntimeError.
- Concurrent seeds share one build and its failure; a cancelled seed no
  longer kills the build the others wait on.
- The build tree is removed in a thread, and uv runs with
  --link-mode copy so a host UV_LINK_MODE=symlink cannot cache an archive
  of links into the host's uv cache.
- An install timeout names the harness and the limit.

Docs: Stirrup's runtime needs glibc 2.28 or newer and does not support
musl (imageio-ffmpeg has no musllinux wheel; the Pillow override has only
manylinux_2_28 wheels). The READMEs and harness guide give the cache
location, proxy and mirror settings, and the -s requirement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent): clean up interrupted local runtime installs

Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent): isolate harness Python and Hermes startup state

Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(codex): honor musl library search paths during setup

Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent): prefer glibc when musl is also installed

Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent_utils): follow main's import rules in the sandbox Python runtime

main now checks import layering and moved paths. sandbox_python.py read
CACHE_DIR_KEY_NAME, maybe_get_global_config_dict and request through old
paths, and it and its test imported AsyncSandbox and SandboxSpec through
the nemo_gym.sandbox package. Import them from their current, defining
modules. uv_bootstrap is deleted on purpose, so drop it from the recorded
import surface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(codex): keep sandbox records whole and cancel abandoned model streams

- Host parsing of native session records now splits lines only at \n, \r\n
  and \r, as the launcher's snapshot does. str.splitlines() also split at
  U+0085, U+2028 and U+2029, which Codex leaves unescaped inside JSON
  strings, so a tool output holding one lost its record and left tool
  timing unknown.
- The host now skips an event record nested too deeply to parse. The Python
  worker dropped such lines in the sandbox; the bash runner copies them
  verbatim, so the RecursionError failed the response.
- The forwarder now cancels the upstream request when Codex hung up while it
  waited for the response headers. The close listener was registered only
  after the headers arrived, so the abandoned stream ran to its end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(codex): run the launcher through a symlinked path; snapshot long rollouts

- The launcher compared import.meta.url, which Node resolves to the real
  path, with the path it was started by. Under a symlinked session directory
  (a task image whose /tmp is a symlink) it skipped both Codex and the
  snapshot and exited 0, so every rollout failed without an explanation.
- The snapshot spread each rollout file into Array.push, which overflows the
  call stack on a file of a few hundred thousand lines and loses the
  native transcript.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* test(codex): fail the Linux run when Node cannot be installed

The test conftest only warned when ensure_node() failed, so a failed Node
download in CI would skip the launcher and runner tests and still pass.
Let the error fail the run, as the other servers' install hooks do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

---------

Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
Co-authored-by: Hemil Desai <hemildesai@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
yaoyu-33 added a commit to yaoyu-33/Gym that referenced this pull request Nov 6, 2026
…NeMo#5327)

* refactor(agent_utils): run the harness process supervisor in bash

The supervisor uploaded into task sandboxes now needs only bash, /proc, sleep and mv, so
sandboxed harnesses no longer require Python 3.8+ in the task image. It keeps the CLI and
receipt contract: run the harness in its own process group, stop it at the deadline or
when the stop file appears (TERM, then KILL the group), and confirm no group member runs.

Stops now reach the supervisor through stop.request instead of SIGTERM, so the launch
shell no longer ignores TERM and the harness keeps the default disposition. Shared
receipt types and timeouts move to supervisor_client; SandboxCommand drops its python
field. shell_assignments() renders runner inputs as bash source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* feat(agent_utils): add a shared bash harness runner

harness_runner.sh replaces per-harness Python runners: it sources the PARAMS written by
shell_assignments(), records runtime.json, applies the harness environment and runs the
CLI with file-backed stdio. With events set, it stamps each JSON stdout line with the time
it was seen, reading a file so a detached tool holding stdout cannot stall it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* docs(agent_utils): describe the bash supervisor and runner; update hermes tests

Point the harness guide, package docs and SandboxSession docstring at process_supervisor.sh
and harness_runner.sh, drop the Python-in-sandbox requirement and the subreaper drain the
bash supervisor no longer performs. Hermes tests use the bash supervisor. The runner stamps
a final unterminated event line with the same fallback time as the others on bash < 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* test(hermes_agent): expect stop requests as the stop file, not a signal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* refactor(kilocode): run the harness without Python in the sandbox

Kilo sessions now run through the shared bash harness runner with PARAMS
written by shell_assignments(); sandbox_runner.py is gone. A short setup
string removes a stale plugin receipt and records the task's HOME/XDG for
the task-environment plugin, unset_env drops inherited KILO_*/OPENCODE_*/
OpenAI variables, and the exit code comes from the cleanup receipt.
Path checks are POSIX shell, the bootstrap installs only bash, and the
installer drops its Python checks. The SQLite snapshot moves host-side:
kilo.db and its WAL files are downloaded and backed up locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* refactor(claude_code_agent): run the harness without Python in the sandbox

Claude sessions now launch through the shared bash harness_runner.sh with params.sh instead of
sandbox_runner.py and input.json. The offline runtime installer is a bash script with the same
SHA-256, version and lock semantics, so task images need bash and coreutils but no Python.

Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* refactor(openclaw_agent): run the harness without Python in the sandbox

Launch OpenClaw through the shared bash harness_runner.sh with PARAMS from shell_assignments()
and a staged prompt file, replacing sandbox_runner.py. The workdir overlap check is POSIX sh,
session setup installs only a missing bash, and the runtime installer drops its Python checks
(apk now reports whether musl images have libstdc++; the prepare step already guards the
runtime path against the task workdir).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* refactor(opencode): run the harness without Python in the sandbox

Task images now need only bash. The shared harness_runner.sh replaces
sandbox_runner.py: PARAMS carry the CLI argv, environment and stdio files, and a
short setup records the task HOME/XDG directories for the shell.env hook, removes
a stale initialization receipt and creates the private CLI directories.

The SQLite snapshot moves to the agent server: after confirmed cleanup the adapter
downloads opencode.db (and its -wal/-shm files when present) and runs the same
export there, producing the same export.json and observations.db. The bootstrap
no longer installs python3 and checks workdir overlap with POSIX shell, and the
runtime installer serializes with flock, or a reclaimable lock directory without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* refactor(pi_agent): run the harness without Python in the sandbox

Pi now runs under the shared bash harness runner: the adapter stages params.sh
and prompt.txt, and the runner records [seen_at, event] lines. Session setup no
longer installs python3, the runtime installer checks for the musl C++ library
by path instead of with ctypes, and the host parser skips recorded lines that
are not [number, object] records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(opencode): keep pre-port outcomes when the session database is unavailable

A missing or undownloadable session database again leaves no export, so
collection reports "OpenCode sandbox runner returned no valid result" with the
output log, as when export.json was missing before. The export no longer depends
on copying the observation store: a failed copy, like the failed observations.db
download before, only adds observation gaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* style: format bash supervisor tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* refactor(agent): adapt GitHub-only harness paths to the bash supervisor

The bash supervisor no longer takes an interpreter, so NOOA drops the removed
SandboxCommand.python field (its own pinned Python still runs its worker), and
the Codex worker keeps its Python runner under the bash supervisor: its
sandbox-local 429 retry proxy still needs Python. OpenCode drops
sandbox_python_executable; sandbox_path_prefix now reaches the CLI through the
runner setup. Tests and READMEs name process_supervisor.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent_utils): keep runner events on old bash and ignore inherited runner names

On bash < 4.2 printf has no strftime, so every event was stamped with an empty
time and the host parsers dropped them all; fall back to date. Strip a trailing
CR before recording a JSON line. Inherited variables named like runner PARAMS
(setup, events, stdout, ...) no longer configure the runner or leak into the CLI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent_utils): stop tools that left the harness group and harden the bash supervisor

A stopped CLI's tools that run in their own session (OpenCode and Codex start every
shell tool that way) kept writing to the task after a confirmed receipt. Before
stopping the harness, the supervisor now records its descendants outside its
process group from /proc and kills them, and the groups they lead, with the
harness; confirmation waits for them too. Services orphaned before the stop are
still left for the sandbox owner.

The supervisor also:
- reads each /proc/PID/stat whole and never evaluates its fields as arithmetic, so
  a process name holding a newline cannot run code or hide a group member;
- refuses to launch without a readable /proc, and counts unreadable process
  state as running;
- bounds the wait for a KILLed harness and writes a negative receipt instead;
- exits on a missing option value or separator instead of looping or running part
  of the command, and ignores an inherited errexit.

Launches render fixed-point seconds, and a deadline shorter than the supervisor's
centisecond resolution expires before launch instead of leaving no receipt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent_utils): give the events-mode CLI default interrupts and no runner descriptors

Without job control bash starts an async command with INT and QUIT ignored, and
the CLI's tools inherited that. The CLI also inherited the runner's read and
events descriptors, so a tool could write raw lines into events.jsonl. Reset the
two signals (bash 5 honours the reset) and close both descriptors for the CLI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent): make installer locks without flock crash-safe and portable to older bash

Without flock, the Claude installer never reclaimed the lock of a killed
installer, and OpenCode's reclaim could delete a lock another installer had just
taken (or skip an owner killed before it wrote its PID). Both now take a symlink
that names the owner by PID and start time, and remove a dead owner's lock only
under a second lock after re-reading it.

The Claude installer lowercases the trusted digest without bash 4 expansions.
The Pi installer no longer fails under set -u on bash < 4.4 when it needs no
packages, and checks every default musl library directory for libstdc++.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(opencode,kilocode): close host-side SQLite connections

sqlite3's context manager commits but does not close, so a failed export kept the
copied databases open while the snapshot directory was removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* docs(agent_utils): describe the stop-time cleanup of tools in their own sessions

The receipt docstring, the harness guide and the Codex, Pi, OpenCode, OpenClaw,
Hermes and Terminal-Bench READMEs now describe what the bash supervisor does:
it kills the harness group and, after a forced stop, the descendants that ran in
other process groups when the stop began; processes that left the harness
earlier stay with the sandbox owner. The receipt documents shell exit statuses.
The Kilo and local-provider tests cover a tool in its own session again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent_utils): reject shell words bash cannot hold; tighten Hermes stop test

shell_assignments() now raises for NUL and non-string words, which bash would
silently drop or which raised an opaque TypeError. The Hermes close test stops
the supervisor through the stop file alone, as production does, and the
sandboxed Hermes README states that the supervisor needs the task image's bash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* style: apply ruff formatting to the ported tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* test(pi_agent): stop in-flight detached tools and keep services from completed tools

The bash port's tests asserted that a tool still running in its own session
survives a close, and modelled a completed tool's service as a direct child of
the CLI. With the supervisor's stop-time cleanup, an in-flight detached tool is
stopped again, as before the port; a service left by a tool that already exited
still survives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* docs(agent_utils): say which harnesses keep a Python shim in the sandbox

The harness guide said Codex starts its CLI through harness_runner.sh. On main
Codex keeps its Python sandbox_runner.py for its sandbox-local retry proxy, so it
still needs the task image's Python; Hermes, Stirrup and NOOA run their Python
shims on interpreters they install privately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent): crash-safe installer queue, checked version probe, JSON-safe task dirs

Address review findings on the bash runtime:
- Without flock, the OpenCode and Claude installers now queue on numbered
  owner links that are never removed or reused, and skip a number whose
  owner is done or gone. There is no second reclaim lock left to strand, and
  an installer killed at any point cannot block or release another.
- A preinstalled OpenCode binary must exit successfully from --version, not
  only print the version, before it replaces the cached runtime.
- OpenCode's and Kilo's task-env.json writers escape every JSON control
  character, so task HOME/XDG paths with tabs or newlines stay valid JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* test(opencode,openclaw): pass the bash runner's payload to the neutral-name tests

The pkill tests merged from main called payload() with a timeout, which only
the Python runner's input carried; the bash runner takes its timeout from
execute().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* test(agent_utils): find the stdlib runner as the tool's parent

The pkill test from main assumed the supervisor's only child is the runner. The
bash supervisor also forks its polling sleeps, so take the runner as the parent
of the tool it started and check that it is a supervisor child.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent_utils): reach processes that leave the harness during a stop

Review found that one descendant snapshot before TERM let a confirmed receipt
leave processes running: a TERM handler that runs `setsid ... &` and exits, or
a tool that keeps forking new sessions during the grace period.

- The supervisor gives the harness a NEMO_GYM_SUPERVISOR environment marker
  unique to this run. On a forced stop it scans /proc when the stop begins, on
  every grace and drain round, and before the final KILL. It reaches the
  harness group, descendants of the harness, of its group and of reached
  processes, and processes carrying the marker. Marked processes that had
  already left the harness when the stop began are preserved as services, as
  the subreaper on main did. The Codex runner passes the marker through its
  env allowlist.
- A recorded group is killed or awaited only while its ID is free or still
  held by the recorded leader (same start time), so a reused group ID is not
  hit.
- Without a process group of its own, the supervisor restarts itself as a job
  in a new group, so a provider's group KILL cannot end it before the receipt.
  The shell left behind passes on TERM and the exit status.
- Docstrings and READMEs state what a forced stop reaches and what it does not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent_utils): linear CR strip, errexit guard and distinct runner statuses

- emit() strips a trailing CR without `${1%$'\r'}`, which tried the suffix at
  every position and took seconds per MB, long enough for Pi's agent_end event
  to outlast the deadline.
- The runner resets errexit, nounset, pipefail and IFS, so a BASH_ENV with
  `set -e` no longer stops it at the first non-JSON line.
- A failed exec redirection no longer falls through into events mode or
  launches the CLI. The runner exits 125 for its own failures (bad cwd, an
  exit in setup) and 126 or 127 when the CLI could not be started.
- Drop `trap - INT QUIT`: it did not change what the CLI inherits, and its test
  passed without it.
- Kilo tells runner failures apart from Kilo's own exit status, reports no exit
  code for a Kilo the runner never started, and none for a cancelled run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent): clean up staged OpenCode copies; bootstrap bash for Claude Code

- The OpenCode installer removes a partly staged binary from its single EXIT
  trap, however it exits, and a cached binary must exit successfully from
  --version to be reused.
- Claude Code setup installs a missing bash on root images with apk or
  apt-get, as the other bash-runner adapters do, and otherwise fails with a
  message that names bash instead of a bare 127.
- Reword "native" in the Claude installer docs, docstring and a test name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* docs(agent-server): describe sandbox Python and uv paths as exceptions

- Codex's task-image Python, stdlib_runner, and Hermes and Stirrup building a
  uv environment in the sandbox are exceptions being removed; new Python
  harnesses bring a runtime prepared on the host, as NOOA and
  hermes_sandboxed_agent do.
- The runner row names the utilities it needs and its exit statuses.
- The migration note lists the removed python= parameters, the bash
  requirement, and the receipt and runner status changes.
- Harness termination describes what a forced stop reaches, the marker, and
  supervisor.pid when the supervisor leaves the provider group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* test(agent_utils): find the supervisor through the runner after it leaves the group

The pkill test launches without a new session, so the supervisor now restarts
itself in its own process group and supervisor.pid names the process that
started it. Take the supervisor as the runner's parent, accept it as the
recorded PID or that PID's child, and check that neither bash process matches
the python kills.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent): build Hermes and Stirrup runtimes on the host

Hermes and Stirrup uploaded uv into the task sandbox and built a virtual
environment there from the network, with the task workdir as cwd and without
--no-config. A task's uv.toml or [tool.uv] could change the install, uv's cache
and managed Python landed in the task's HOME, and --python 3.13 could bind the
image's own interpreter.

Add nemo_gym/agent_utils/sandbox_python.py. On the Agent Server it downloads a
pinned python-build-standalone CPython for the sandbox's architecture and C
library, checks its SHA-256, and installs the harness's requirements into a
site-packages archive with uv --no-config --python-platform --only-binary :all:,
constrained to the agent environment's versions. Only direct-URL requirements,
such as Hermes at a commit, are built from source. Both archives are cached on
the host. install_python_runtime.sh unpacks them under /tmp, checks the harness
import with HOME inside the runtime directory, and publishes the runtime with an
atomic rename, so concurrent seeds share one runtime and a runtime that fails
its check is replaced. The sandbox needs sh and tar, but no network, uv or
Python.

Remove uv_bootstrap.py and Hermes's uv_cache_dir option, update the tests, and
drop Hermes and Stirrup from the harness guide's exceptions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* perf(agent_utils): make the supervisor's forced stop cheaper on busy hosts

A forced stop scans every process in /proc several times, and each
/proc/PID/stat parse used a longest-prefix match (quadratic in bash) and
three regex compilations. On a host with about 1,100 processes a stop
took 1.65 s, so Hermes's close test, which allows 2 s and polls for the
receipt once a second, failed there while CI passed.

Parse with a shortest-prefix loop and glob digit checks, let the forced
drain take liveness from its scan instead of a second /proc pass, and
skip the extra scan once the harness has exited (the drain scans next).
A stop on the same host now takes about 0.55 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* refactor(codex): run sandboxed Codex without the task image's Python

Codex was the last CLI harness whose sandbox worker ran on the task
image's python3, only because its HTTP 429 retry proxy was Python.
Run Codex through the shared harness_runner.sh, behind a small Node
launcher (sandbox_launcher.mjs) on the Node that the Codex runtime
already installs. The launcher keeps the proxy's behaviour (two 429
retries honouring Retry-After up to 30 s, attempt records, transport
errors), the environment allowlist and TERM forwarding, and exits with
Codex's status as a shell would. The native-session snapshot moves to
the same launcher.

The bootstrap, workdir overlap check and installer no longer need
python3. With no remaining user, stdlib_runner and the bin/gym-runner
interpreter link are removed, as the harness docs promised.

Codex tests that need Node install it at collection on Linux, through
setup_codex.ensure_node(), so CI keeps running them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent): close gaps in the flock-less installer queue and root-workdir check

Review on NVIDIA-NeMo#5151 found three gaps in the bash ports:
- The Claude installer set its queue ticket before linking it, so an
  installer stopped after losing a number marked the winner's ticket
  done and let the next installer run beside it. It now takes the
  ticket only once its link exists, as OpenCode's installer does.
- Both flock-less queues treated a killed but unreaped owner as live, so
  later installers waited until their timeout. A zombie or dead process
  now has no identity and no longer holds its number.
- OpenClaw's overlap check missed a workdir that resolves to /, such as
  "/.", because "/" plus a separator is "//". Both sides now drop a
  trailing slash first; the error names / when it is the owned path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* refactor(agent_utils): run the sandbox Python installer with bash

The supervisor and the harness runner are bash scripts, but the installer ran
with POSIX sh, so the sandbox's script requirements differed by step. Hermes and
Stirrup need bash for the supervisor anyway, so an image without bash now fails
at seed, with "bash: not found", instead of at the first activation.

The installer sets errexit, nounset and pipefail explicitly, so an inherited
SHELLOPTS cannot change how it fails, and it uses bash tests. It still publishes
with the new runtime's os.rename: mv would move the fresh runtime inside one a
concurrent seed published first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(codex): keep events with Unicode line separators; record broken 429 bodies

Review on NVIDIA-NeMo#5153:
- The bash runner copies Codex's JSON event lines verbatim, while the
  removed Python worker re-serialized them with escaped non-ASCII. Codex
  leaves U+0085, U+2028 and U+2029 unescaped inside strings, so
  splitlines() cut such an event in two and its message was lost while
  the turn still reported completed. Split records on "\n" only and
  skip blank lines.
- A retryable 429 whose body timed out or was cut short destroyed the
  response with no error listener, which could end the launcher and
  Codex with it. The drain now catches the error and records it as the
  attempt's transport_error before retrying.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent_utils): serialize runtime publication and harden the host build

Address the review of NVIDIA-NeMo#5152, including the two NeMo-CI review threads.

Sandbox side:
- install_python_runtime.sh checks, sets aside and publishes the runtime
  under a lock on the runtime directory, held by the fresh runtime's own
  Python. A seed whose check of a missing or broken runtime failed could
  move aside, and its EXIT trap delete, a runtime that another seed had
  just published and returned success on.
- install() removes its .setup-<uuid> directory when creating it or an
  upload fails, times out or is cancelled before the installer, whose EXIT
  trap owns it from then on, starts.
- Hermes and Stirrup start their runner with -s. The runtime is not a
  virtual environment, so the task HOME's user site-packages would
  otherwise come ahead of the pinned packages.

Agent Server side:
- The archives live under sandbox_python in Gym's cache_dir instead of a
  hard-coded ~/.cache path resolved at import.
- The Python build download honors HTTPS_PROXY and
  UV_PYTHON_INSTALL_MIRROR, stops after 3 attempts instead of retrying
  until the seed times out, streams to disk in a thread, and reports HTTP
  errors as RuntimeError.
- Concurrent seeds share one build and its failure; a cancelled seed no
  longer kills the build the others wait on.
- The build tree is removed in a thread, and uv runs with
  --link-mode copy so a host UV_LINK_MODE=symlink cannot cache an archive
  of links into the host's uv cache.
- An install timeout names the harness and the limit.

Docs: Stirrup's runtime needs glibc 2.28 or newer and does not support
musl (imageio-ffmpeg has no musllinux wheel; the Pillow override has only
manylinux_2_28 wheels). The READMEs and harness guide give the cache
location, proxy and mirror settings, and the -s requirement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent): clean up interrupted local runtime installs

Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent): isolate harness Python and Hermes startup state

Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* refactor(sandbox): remove unused stdlib runner compatibility

Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(codex): honor musl library search paths during setup

Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent): prefer glibc when musl is also installed

Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(agent_utils): follow main's import rules in the sandbox Python runtime

main now checks import layering and moved paths. sandbox_python.py read
CACHE_DIR_KEY_NAME, maybe_get_global_config_dict and request through old
paths, and it and its test imported AsyncSandbox and SandboxSpec through
the nemo_gym.sandbox package. Import them from their current, defining
modules. uv_bootstrap is deleted on purpose, so drop it from the recorded
import surface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(codex): keep sandbox records whole and cancel abandoned model streams

- Host parsing of native session records now splits lines only at \n, \r\n
  and \r, as the launcher's snapshot does. str.splitlines() also split at
  U+0085, U+2028 and U+2029, which Codex leaves unescaped inside JSON
  strings, so a tool output holding one lost its record and left tool
  timing unknown.
- The host now skips an event record nested too deeply to parse. The Python
  worker dropped such lines in the sandbox; the bash runner copies them
  verbatim, so the RecursionError failed the response.
- The forwarder now cancels the upstream request when Codex hung up while it
  waited for the response headers. The close listener was registered only
  after the headers arrived, so the abandoned stream ran to its end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* fix(codex): run the launcher through a symlinked path; snapshot long rollouts

- The launcher compared import.meta.url, which Node resolves to the real
  path, with the path it was started by. Under a symlinked session directory
  (a task image whose /tmp is a symlink) it skipped both Codex and the
  snapshot and exited 0, so every rollout failed without an explanation.
- The snapshot spread each rollout file into Array.push, which overflows the
  call stack on a file of a few hundred thousand lines and loses the
  native transcript.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

---------

Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
Co-authored-by: Hemil Desai <hemildesai@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch was previously deployed

1 inactive deployment
public — fb963551 Deployed Nov 4, 2026 by copy-pr-bot[bot] via release / finalize / notify #9823
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:agent Agent harnesses and Responses API agent behavior bug Something isn't working ready-to-merge PR is approved, current, and waiting for final CI or merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants