Skip to content

refactor(agent_utils): run sandboxed harnesses with only bash in the task image - #5151

Merged
yaoyu-33 merged 35 commits into
mainfrom
refactor/bash-sandbox-runtime
Oct 28, 2026
Merged

yaoyu-33 merged 35 commits into
mainfrom
refactor/bash-sandbox-runtime

Conversation

@yaoyu-33

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

Copy link
Copy Markdown
Contributor

Stack: this PR → #5152 (Hermes and Stirrup runtimes built on the agent server) → #5153 (Codex without the task image's Python). Replaces #5094, reopened from a branch in this repo so the three PRs can form a GitHub Stack, which does not accept pull requests from forks. The earlier review, including its inline threads, is on #5094.

What

Sandboxed harnesses no longer need Python in the task image. The shared process supervisor (nemo_gym/agent_utils/process_supervisor.sh) and a shared harness runner (harness_runner.sh) are bash scripts, and the OpenCode/Kilo SQLite snapshot moves to the agent server. Claude Code, OpenCode, Kilo, OpenClaw and Pi run their CLIs through the bash runner; Hermes, NOOA and Stirrup keep their own Python workers under the bash supervisor.

This ports Hemil Desai's bash sandbox runtime change (his commits are kept with authorship) onto main, adapts it to code that only exists here, and adds fixes found in review.

Before / after

Colour key: amber = Python inside the task sandbox, green = moved from Python to bash, blue = moved out of the sandbox to the agent server, grey = Python worker kept as is.

Before: every sandboxed harness needs Python ≥ 3.8 in the task image.

flowchart LR
  subgraph host["Agent server"]
    S["SandboxSession<br/>supervisor_client"]
  end
  subgraph box["Task sandbox (task image)"]
    B["Bootstrap<br/>apt/apk python3 if missing<br/>workdir check with python3 -c"]:::py
    I["Runtime installers<br/>install_claude_runtime.py<br/>Python ≥ 3.8 assert, fcntl lock,<br/>hashlib digest, ctypes libstdc++"]:::py
    SUP["process_supervisor.py<br/>subreaper drain"]:::py
    R["sandbox_runner.py<br/>one per harness"]:::py
    CLI["Harness CLI<br/>Claude Code · OpenCode · Kilo · OpenClaw · Pi"]
    W["Codex worker + 429 proxy<br/>Hermes · NOOA · Stirrup workers"]:::keep
    DB[("opencode.db / kilo.db")]
    X["SQLite export<br/>sandbox_runner.py --snapshot"]:::py
  end
  S -->|"seed"| B --> I
  S -->|"launch: python3 -I"| SUP --> R --> CLI
  SUP --> W
  CLI --> DB --> X -->|"export.json<br/>observations.db"| S
  classDef py fill:#fde68a,stroke:#b45309,color:#111
  classDef keep fill:#e5e7eb,stroke:#6b7280,color:#111
Loading

After: the shared pieces need only bash; Codex, Hermes, NOOA and Stirrup still bring or use their own Python worker.

flowchart LR
  subgraph host["Agent server"]
    S["SandboxSession<br/>supervisor_client<br/>shell_assignments() → params.sh"]
    X["SQLite export<br/>snapshot.py"]:::host
  end
  subgraph box["Task sandbox (task image needs bash)"]
    B["Bootstrap<br/>apt/apk bash only if missing<br/>POSIX sh workdir check"]:::sh
    I["Runtime installers<br/>install_claude_runtime.sh<br/>flock or owner-symlink lock,<br/>sha256sum, path / apk checks"]:::sh
    SUP["process_supervisor.sh<br/>/proc rescans + env marker"]:::sh
    R["harness_runner.sh<br/>shared by 5 harnesses"]:::sh
    CLI["Harness CLI<br/>Claude Code · OpenCode · Kilo · OpenClaw · Pi"]
    W["Codex worker + 429 proxy<br/>Hermes · NOOA · Stirrup workers"]:::keep
    DB[("opencode.db / kilo.db<br/>+ WAL files")]
  end
  S -->|"seed"| B --> I
  S -->|"launch: bash"| SUP --> R --> CLI
  SUP --> W
  CLI --> DB -->|"download raw files"| X
  classDef sh fill:#bbf7d0,stroke:#15803d,color:#111
  classDef host fill:#bfdbfe,stroke:#1d4ed8,color:#111
  classDef keep fill:#e5e7eb,stroke:#6b7280,color:#111
Loading
Piece Before After Change
Process supervisor process_supervisor.py process_supervisor.sh Python → shell
Runner for Claude Code, OpenCode, Kilo, OpenClaw, Pi one sandbox_runner.py per harness shared harness_runner.sh Python → shell
Claude Code installer install_claude_runtime.py install_claude_runtime.sh Python → shell
OpenCode installer: lock and digest check inline Python (fcntl, hashlib) flock or owner symlink, sha256sum Python → shell
Kilo, OpenClaw, Pi installers: version and libstdc++ checks Python assert, ctypes probe path and apk info checks Python → shell
Bootstrap workdir overlap check (OpenCode, Kilo, OpenClaw, Pi) python3 -c POSIX sh Python → shell
Bootstrap packages python3 and bash if missing bash if missing; Codex still python3 and bash python3 no longer installed, except by Codex
OpenCode/Kilo SQLite export in the sandbox (--snapshot) on the agent server (snapshot.py) sandbox → host
Codex worker sandbox_runner.py + sandbox_transport.py unchanged, now under the bash supervisor stays Python
Hermes, NOOA, Stirrup workers own Python worker unchanged, now under the bash supervisor stays Python

Known exceptions (follow-ups, not in this PR)

These still put Python or the network into the task sandbox. No tracking issues are filed yet.

  • Codex still needs python3 ≥ 3.8 in the task image and apt/apk-installs it when missing. Python runs its workdir overlap check, the installer's version and libstdc++ probes, its worker, the 429 retry proxy (sandbox_transport.py) and the --snapshot export. Only the proxy has no bash equivalent; where it should live (for example the agent server's rate_limit_proxy) is still to be decided. Once Codex moves, stdlib_runner, RUNNER_INTERPRETER and bin/gym-runner can go.
  • Hermes uploads uv and runs uv venv --python 3.13 and uv pip install inside the sandbox, from the network, with cwd set to the task workdir, without --no-config, and without UV_CACHE_DIR or UV_PYTHON_INSTALL_DIR: a task's uv.toml or [tool.uv] can change the install, uv's cache and managed Python land in the task's home, and --python 3.13 can bind the image's own python3.13.
  • Stirrup does the same with uv pip install stirrup==...; it sets UV_CACHE_DIR and UV_PYTHON_INSTALL_DIR but not --no-config.
  • The fix for Hermes and Stirrup is a relocatable runtime prepared on the host, as hermes_sandboxed_agent/prepare_runtime.sh and NOOA do. The harness guide now presents these three paths as exceptions, not options.

Why

Before this change the supervisor and per-harness runners ran on whatever python3 the task image had:

  • python3 older than 3.8 failed the task (seen on SWE images with old Python);
  • a missing python3 was apt/apk-installed into the task image, changing what both the agent and the verifier see (for example Go/Rust/JS images in multilingual SWE sets).

No public issue; this mirrors internal work on the same change.

Port notes (differences from the original change)

  • Codex keeps its Python worker under the bash supervisor. On main the Codex worker runs a sandbox-local 429 retry proxy (sandbox_transport.py) that has no bash equivalent, so Codex still needs Python ≥ 3.8 in the task image. Where that proxy should live is an open question for a follow-up.
  • Cline's sandboxed mode and troxy are not on main; those parts are left out.
  • OpenCode drops sandbox_python_executable; sandbox_path_prefix now reaches the CLI through the runner setup. benchmarks/swe_together/opencode.yaml is updated.
  • NOOA drops the removed SandboxCommand.python field; its pinned Python still runs its worker.

Fixes on top of the port

  • Tools in their own session are stopped again. OpenCode/Kilo start shell tools detached, so after a timeout or close the group KILL missed them and they could write during verification after a confirmed receipt. On a forced stop the supervisor now scans /proc when the stop begins and on every polling round. It kills the harness group plus everything it reaches: descendants of the harness, of its group and of reached processes, and processes that inherited the harness's NEMO_GYM_SUPERVISOR environment marker (for example a tool that daemonizes, or a TERM handler that runs setsid ... & and exits). Marked processes that had already left the harness when the stop began are preserved as services, as the subreaper did on main. What is not reached is documented: a process that leaves between two scans after dropping the marker. The Codex runner passes the marker through its env allowlist.
  • Supervisor: a group is killed or awaited only while its ID still belongs to the recorded leader (same start time) or is free, so a reused group ID is not hit. Without a group of its own, the supervisor restarts itself as a job in a new process group, so a provider's group KILL cannot end it before the receipt; the remaining shell passes on TERM and the exit status.
  • Supervisor hardening: reads /proc/PID/stat whole and never evaluates its fields as arithmetic (a crafted process name could run code and hide a member); refuses to launch without a readable /proc; bounds the wait for a KILLed harness; rejects malformed arguments instead of looping; ignores an inherited errexit. Launches render fixed-point seconds, and a deadline under one centisecond expires before launch instead of leaving no receipt.
  • Supervisor stop cost: a forced stop parses every /proc/PID/stat several times, and on a busy host this made stops slow (1.65 s with about 1,100 processes, so Hermes's close test, which allows 2 s, failed there while CI passed). Parsing no longer uses bash's quadratic longest-prefix match or a regex per field, the drain takes liveness from its scan instead of a second pass, and no extra scan runs once the harness has exited. The same stop now takes about 0.55 s.
  • Runner: event timestamps on bash < 4.2; inherited variables named like runner PARAMS no longer configure the runner; the events-mode CLI gets no runner descriptors; CR stripping is linear in the line length (a quadratic ${1%$'\r'} took seconds per MB); an inherited set -e no longer stops the runner at a non-JSON line; a failed exec redirection no longer falls through into events mode. The runner exits 125 for its own failures (bad cwd, an exit in setup) and 126/127 when the CLI could not be started, and Kilo reports these apart from Kilo's own exit status. A cancelled Kilo run reports no exit code, as before.
  • Installers: the flock-less lock fallback is crash-safe (a ticket queue of owner symlinks with PID + start time); Claude installer works on bash < 4; Pi installer survives set -u on bash < 4.4 and checks all musl library directories; the OpenCode installer removes a partly staged binary on any failure and no longer reuses a cached binary whose --version fails.
  • Claude Code installs a missing bash during setup, like the other bash-runner adapters, and otherwise fails with a message that names bash.
  • Host-side SQLite connections are closed; shell_assignments() rejects NUL and non-string words.

Compatibility

  • nemo_gym.agent_utils.process_supervisor (Python) is removed; CleanupReceipt, exec_timeout and the constants live in nemo_gym.agent_utils.supervisor_client. SandboxCommand(python=...) and supervised_launch_command(python=...) are removed. Out-of-tree adapters need the migration in fern/versions/latest/pages/agent-server/add-or-update-a-harness.mdx.
  • Receipt return_code is now the shell status (128+N for a signal death) instead of −N; a missing CLI gives a confirmed receipt with 126/127, and a runner failure before the CLI starts gives 125. The harness guide's migration note lists these together with the removed python= parameters and the bash requirement.
  • Task images now need bash (adapters that installed python3 now install only a missing bash; Codex still installs python3).
  • Harness processes see a new NEMO_GYM_SUPERVISOR environment variable.
  • No benchmark-result impact expected; verification is unchanged.

Validation

Local (macOS, bash 3.2 and 5.2; Linux-only /proc tests are skipped here and run in CI):

  • pytest tests/unit_tests responses_api_agents/{opencode,kilocode,codex,pi,openclaw,claude_code,hermes_sandboxed}_agent/tests: 13766 passed, 480 skipped, 50 failed, 1 collection error. All 50 failures also fail on unmodified main in the same local venv (missing opensandbox/mlflow, macOS-specific telemetry/CI checks; one flaky Harbor test); the collection error is test_exporters.py needing mlflow.
  • Runner tests on both bash 3.2 and bash 5.2; the new regression tests fail on the unfixed scripts and pass after.
  • Manual run of the Claude installer's flock-less lock (stale and live owner) on bash 3.2 and 5.2.
  • pre-commit run --files <changed files>: passed.
  • Not run locally: Hermes/NOOA/Stirrup suites (packages not in the local venv), Linux-only supervisor tests.

Review round 2 (local, macOS):

  • Affected suites (tests/unit_tests/test_{harness_runner,process_supervisor,sandbox_supervisor_client,sandbox_supervisor_bootstrap,sandbox_session}.py, responses_api_agents/{kilocode,claude_code,opencode,codex,pi,openclaw}_agent/tests): 1910 passed, 228 skipped, 3 failed. The 3 failures are test_opensandbox_sdk_is_available_in_agent_environment, which needs opensandbox, missing from the local venv.
  • New runner tests on bash 3.2 and 5.2; the long-line and inherited-errexit tests fail on the previous runner.
  • The supervisor's scan, kill and group-identity logic, checked on bash 3.2 and 5.2 against a fake /proc: harness, tools, orphaned group member and daemons reached; earlier service and its children preserved; reused group not killed.
  • OpenCode installer staging failure and unhealthy cache, run by hand with a stub uname.
  • The new Linux supervisor tests (daemonized during the stop, reused IDs, provider group KILL) run only in CI.

Linux host (bash 5.0, about 1,100 processes), at 05bac05:

  • responses_api_agents/hermes_agent/tests: 349 passed. test_close_preserves_interrupted_observations_before_release failed in both cases at fb642f9 and passed 5 of 5 runs with the fix.
  • tests/unit_tests/test_{process_supervisor,sandbox_supervisor_client,harness_runner}.py, including the Linux-only supervisor tests: all passed. The new name-parsing test fails on a parser that splits at the first ") ".

Rollout evidence: smoke rollouts with a real model at 656e1739e, with Pi (events mode), OpenCode and Kilo (host-side database capture) on debian:bookworm-slim, a SWE-bench image with Python 3.6 first on PATH, and alpine:3.20. 8 of 9 pass. OpenCode on Alpine fails because its binary needs libstdc++ and libgcc, which main doesn't install either.

🤖 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>
…kdir check

Review on #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>
@yaoyu-33

Copy link
Copy Markdown
Contributor Author

/ok to test 656e173

@yaoyu-33

Copy link
Copy Markdown
Contributor Author

Smoke rollouts with a real model at 656e1739e

This answers the rollout RISK in the review above. 8 of 9 cases pass. The one failure, OpenCode on Alpine, is an existing gap that main has too (details below).

Setup

  • Code: this PR's head, 656e1739e.
  • Model: nvidia/nemotron-3.5-lightning-30b-a3b at integrate.api.nvidia.com, served through Gym's vllm_model server (temperature 0, reasoning parser on). Every model request returned 200.
  • Harnesses, at their pinned versions:
    • Pi 0.80.2, through the runner's events mode;
    • OpenCode 1.17.11 and Kilo 7.4.15, in direct mode. Their SQLite databases are copied to the agent server and parsed there.
  • Sandboxes: Gym's Docker provider on a Linux workstation, with host networking. Each case borrows a container that the smoke script owns, the way a Resources server does.
Image Image ID OS python3 bash
debian:bookworm-slim sha256:8da84df25d28… Debian 12 none present
SWE-bench swebench/sweb.eval.x86_64.django_1776_django-11099, plus ENV PATH=/opt/miniconda3/envs/testbed/bin:$PATH, as a harness that activates the testbed sees it sha256:8e44fd757da3… Ubuntu 22.04 3.6.13 present
alpine:3.20 sha256:bf8527eb54c3… Alpine 3.20.10 none none

Each case

  1. Setup: the owner starts the container and creates /task. It puts a marker file in each of the task's $XDG_CONFIG_HOME, $XDG_DATA_HOME, $XDG_CACHE_HOME and $XDG_STATE_HOME (defaults under $HOME), and records python3 and bash.
  2. Seed and activation: the agent seeds a session on the borrowed container, which includes bootstrap and runtime install. It then runs one activation: "create /task/deliverable.txt containing <random token> with your shell or file tool, then reply done".
  3. Close: the agent closes the session, and the case records the supervisor's cleanup receipt.
  4. Verification, only after close: the owner checks that
    • the deliverable equals the token;
    • all four markers are kept;
    • the adapter's session directory is gone;
    • no harness process (node, opencode, kilo, the runner or the supervisor) is left;
    • python3 is unchanged.
  5. Teardown: the owner stops the container and confirms docker inspect no longer finds it.

Results

Image Harness Result python3 before → after Model calls Time
debian:bookworm-slim pi pass none → none 2 51 s
debian:bookworm-slim opencode pass none → none 2 185 s
debian:bookworm-slim kilo pass none → none 2 264 s
SWE-bench django-11099 pi pass 3.6.13 → 3.6.13 2 42 s
SWE-bench django-11099 opencode pass 3.6.13 → 3.6.13 2 54 s
SWE-bench django-11099 kilo pass 3.6.13 → 3.6.13 2 77 s
alpine:3.20 pi pass none → none 2 47 s
alpine:3.20 opencode fail (see below) none → n/a 0 14 s
alpine:3.20 kilo pass none → none 2 51 s

Every passing case shows

  • A real deliverable: the output has a function_call and its function_call_output, and the file is verified after close.
  • Confirmed cleanup: the activation and close receipts both read cleanup_confirmed: true, return_code: 0, timed_out: false.
  • Task state kept, adapter state gone: all four task HOME/XDG markers survive, the adapter's session directory is removed, and no harness process is left. Owner teardown then removes the container.
  • No Python installed: Debian and Alpine have no python3 before or after, and the SWE image keeps its Python 3.6.13. On Alpine, the POSIX sh bootstrap installed the missing bash.
  • Capture:
    • Pi: 28 event lines stamped by the runner's events mode.
    • OpenCode and Kilo: 3 observation records each, parsed on the agent server from the copied database.

OpenCode on Alpine: the install completes, but OpenCode's musl binary can't start: Error relocating …/opencode: … symbol not found, because it needs libstdc++ and libgcc.

  • Not a regression: neither main's installer nor this PR's installs those packages. In a clean alpine:3.20, the same 1.17.11 binary fails without them and prints its version with them, so OpenCode has never run on a bare Alpine image.
  • Coverage: Kilo covers host-side database capture on Alpine.
  • Fix: installing libstdc++ and libgcc on musl, as the Pi, Kilo and Codex installers already do for libstdc++, is a small follow-up.

Limits

  • Verifier: the owner's check of the deliverable after close, not a benchmark Resources server.
  • Scope: one short task per case and one model.
  • Old-Python case: it puts the image's own Python 3.6 testbed first on PATH instead of using an image where it's already the default.
  • Not included: Claude Code and OpenClaw were not part of this run.

The runs used a standalone script, smoke_rollouts.py, invoked as python smoke_rollouts.py <evidence.json> <image>. It reads the API key from a file and redacts it from all output. It isn't in the repo; I can add it if it would help.

Per-case evidence (one JSON object per case)
{"harness": "pi", "image": "debian:bookworm-slim", "image_id": "sha256:8da84df25d28176be84164040cd45e128c6b4c6a80b7d7cbe17fe2b9f198efc2", "passed": true, "seed_seconds": 22.4, "seconds": 50.6, "model_calls": 2, "response_status": "completed", "output_item_types": {"reasoning": 2, "function_call": 1, "function_call_output": 1, "message": 1}, "activation_receipt": {"cleanup_confirmed": true, "return_code": 0, "error": null, "timed_out": false}, "close_receipt": {"cleanup_confirmed": true, "return_code": 0, "error": null, "timed_out": false}, "session_closed": true, "close_observation_records": 2, "close_observation_gaps": ["cached_token_usage_unavailable", "reasoning_token_usage_unavailable", "subagent_hierarchy_unavailable"], "recorded_event_lines": 28, "adapter_session_dir_removed": true, "verifier_passed": true, "owner_teardown_container_removed": true, "error_type": null, "before": {"python3": "none", "bash": "/usr/bin/bash", "os": "debian-12", "markers": ["/root/.config/task-owned/marker", "/root/.local/share/task-owned/marker", "/root/.cache/task-owned/marker", "/root/.local/state/task-owned/marker"]}, "after": {"deliverable": "deliverable-04f7d7446df6729f", "markers_kept": ["/root/.config/task-owned/marker", "/root/.local/share/task-owned/marker", "/root/.cache/task-owned/marker", "/root/.local/state/task-owned/marker"], "markers_lost": [], "python3": "none", "harness_processes": []}}
{"harness": "opencode", "image": "debian:bookworm-slim", "image_id": "sha256:8da84df25d28176be84164040cd45e128c6b4c6a80b7d7cbe17fe2b9f198efc2", "passed": true, "seed_seconds": 35.6, "seconds": 185.4, "model_calls": 2, "response_status": "completed", "output_item_types": {"message": 2, "function_call": 1, "function_call_output": 1}, "activation_receipt": {"cleanup_confirmed": true, "return_code": 0, "error": null, "timed_out": false}, "close_receipt": {"cleanup_confirmed": true, "return_code": 0, "error": null, "timed_out": false}, "session_closed": true, "close_observation_records": 3, "close_observation_gaps": ["model_usage_reconciliation_unavailable"], "recorded_event_lines": 1, "adapter_session_dir_removed": true, "verifier_passed": true, "owner_teardown_container_removed": true, "error_type": null, "before": {"python3": "none", "bash": "/usr/bin/bash", "os": "debian-12", "markers": ["/root/.config/task-owned/marker", "/root/.local/share/task-owned/marker", "/root/.cache/task-owned/marker", "/root/.local/state/task-owned/marker"]}, "after": {"deliverable": "deliverable-10a7a43cd7a09dd6", "markers_kept": ["/root/.config/task-owned/marker", "/root/.local/share/task-owned/marker", "/root/.cache/task-owned/marker", "/root/.local/state/task-owned/marker"], "markers_lost": [], "python3": "none", "harness_processes": []}}
{"harness": "kilo", "image": "debian:bookworm-slim", "image_id": "sha256:8da84df25d28176be84164040cd45e128c6b4c6a80b7d7cbe17fe2b9f198efc2", "passed": true, "seed_seconds": 19.4, "seconds": 264.0, "model_calls": 2, "response_status": "completed", "output_item_types": {"message": 2, "function_call": 1, "function_call_output": 1}, "activation_receipt": {"cleanup_confirmed": true, "return_code": 0, "error": null, "timed_out": false}, "close_receipt": {"cleanup_confirmed": true, "return_code": 0, "error": null, "timed_out": false}, "session_closed": true, "close_observation_records": 3, "close_observation_gaps": ["usage_details_unavailable"], "recorded_event_lines": null, "adapter_session_dir_removed": true, "verifier_passed": true, "owner_teardown_container_removed": true, "error_type": null, "before": {"python3": "none", "bash": "/usr/bin/bash", "os": "debian-12", "markers": ["/root/.config/task-owned/marker", "/root/.local/share/task-owned/marker", "/root/.cache/task-owned/marker", "/root/.local/state/task-owned/marker"]}, "after": {"deliverable": "deliverable-40ad775d414b366d", "markers_kept": ["/root/.config/task-owned/marker", "/root/.local/share/task-owned/marker", "/root/.cache/task-owned/marker", "/root/.local/state/task-owned/marker"], "markers_lost": [], "python3": "none", "harness_processes": []}}
{"harness": "pi", "image": "gym-smoke/sweb-django-11099-py36:20261027", "image_id": "sha256:8e44fd757da3659ec422133fbe4276e3608aa1adb89858f2b4cc8c2f6fe35774", "passed": true, "seed_seconds": 7.5, "seconds": 42.5, "model_calls": 2, "response_status": "completed", "output_item_types": {"reasoning": 2, "function_call": 1, "function_call_output": 1, "message": 1}, "activation_receipt": {"cleanup_confirmed": true, "return_code": 0, "error": null, "timed_out": false}, "close_receipt": {"cleanup_confirmed": true, "return_code": 0, "error": null, "timed_out": false}, "session_closed": true, "close_observation_records": 2, "close_observation_gaps": ["cached_token_usage_unavailable", "reasoning_token_usage_unavailable", "subagent_hierarchy_unavailable"], "recorded_event_lines": 28, "adapter_session_dir_removed": true, "verifier_passed": true, "owner_teardown_container_removed": true, "error_type": null, "before": {"python3": "/opt/miniconda3/envs/testbed/bin/python3 3.6.13", "bash": "/usr/bin/bash", "os": "ubuntu-22.04", "markers": ["/root/.config/task-owned/marker", "/root/.local/share/task-owned/marker", "/root/.cache/task-owned/marker", "/root/.local/state/task-owned/marker"]}, "after": {"deliverable": "deliverable-b01ae03447a97a2d", "markers_kept": ["/root/.config/task-owned/marker", "/root/.local/share/task-owned/marker", "/root/.cache/task-owned/marker", "/root/.local/state/task-owned/marker"], "markers_lost": [], "python3": "/opt/miniconda3/envs/testbed/bin/python3 3.6.13", "harness_processes": []}}
{"harness": "opencode", "image": "gym-smoke/sweb-django-11099-py36:20261027", "image_id": "sha256:8e44fd757da3659ec422133fbe4276e3608aa1adb89858f2b4cc8c2f6fe35774", "passed": true, "seed_seconds": 12.0, "seconds": 54.0, "model_calls": 2, "response_status": "completed", "output_item_types": {"message": 2, "function_call": 1, "function_call_output": 1}, "activation_receipt": {"cleanup_confirmed": true, "return_code": 0, "error": null, "timed_out": false}, "close_receipt": {"cleanup_confirmed": true, "return_code": 0, "error": null, "timed_out": false}, "session_closed": true, "close_observation_records": 3, "close_observation_gaps": ["model_usage_reconciliation_unavailable", "token_usage_detail_unavailable"], "recorded_event_lines": 1, "adapter_session_dir_removed": true, "verifier_passed": true, "owner_teardown_container_removed": true, "error_type": null, "before": {"python3": "/opt/miniconda3/envs/testbed/bin/python3 3.6.13", "bash": "/usr/bin/bash", "os": "ubuntu-22.04", "markers": ["/root/.config/task-owned/marker", "/root/.local/share/task-owned/marker", "/root/.cache/task-owned/marker", "/root/.local/state/task-owned/marker"]}, "after": {"deliverable": "deliverable-d7f255e90e0b9bd5", "markers_kept": ["/root/.config/task-owned/marker", "/root/.local/share/task-owned/marker", "/root/.cache/task-owned/marker", "/root/.local/state/task-owned/marker"], "markers_lost": [], "python3": "/opt/miniconda3/envs/testbed/bin/python3 3.6.13", "harness_processes": []}}
{"harness": "kilo", "image": "gym-smoke/sweb-django-11099-py36:20261027", "image_id": "sha256:8e44fd757da3659ec422133fbe4276e3608aa1adb89858f2b4cc8c2f6fe35774", "passed": true, "seed_seconds": 9.9, "seconds": 76.9, "model_calls": 2, "response_status": "completed", "output_item_types": {"message": 2, "function_call": 1, "function_call_output": 1}, "activation_receipt": {"cleanup_confirmed": true, "return_code": 0, "error": null, "timed_out": false}, "close_receipt": {"cleanup_confirmed": true, "return_code": 0, "error": null, "timed_out": false}, "session_closed": true, "close_observation_records": 3, "close_observation_gaps": ["usage_details_unavailable"], "recorded_event_lines": null, "adapter_session_dir_removed": true, "verifier_passed": true, "owner_teardown_container_removed": true, "error_type": null, "before": {"python3": "/opt/miniconda3/envs/testbed/bin/python3 3.6.13", "bash": "/usr/bin/bash", "os": "ubuntu-22.04", "markers": ["/root/.config/task-owned/marker", "/root/.local/share/task-owned/marker", "/root/.cache/task-owned/marker", "/root/.local/state/task-owned/marker"]}, "after": {"deliverable": "deliverable-f4a8430b7699a437", "markers_kept": ["/root/.config/task-owned/marker", "/root/.local/share/task-owned/marker", "/root/.cache/task-owned/marker", "/root/.local/state/task-owned/marker"], "markers_lost": [], "python3": "/opt/miniconda3/envs/testbed/bin/python3 3.6.13", "harness_processes": []}}
{"harness": "pi", "image": "alpine:3.20", "image_id": "sha256:bf8527eb54c3680e728d5b4b383a8ba730d72dae7236fbc8dff97ed6b224a731", "passed": true, "seed_seconds": 27.8, "seconds": 47.4, "model_calls": 2, "response_status": "completed", "output_item_types": {"reasoning": 2, "function_call": 1, "function_call_output": 1, "message": 1}, "activation_receipt": {"cleanup_confirmed": true, "return_code": 0, "error": null, "timed_out": false}, "close_receipt": {"cleanup_confirmed": true, "return_code": 0, "error": null, "timed_out": false}, "session_closed": true, "close_observation_records": 2, "close_observation_gaps": ["cached_token_usage_unavailable", "reasoning_token_usage_unavailable", "subagent_hierarchy_unavailable"], "recorded_event_lines": 28, "adapter_session_dir_removed": true, "verifier_passed": true, "owner_teardown_container_removed": true, "error_type": null, "before": {"python3": "none", "bash": "none", "os": "alpine-3.20.10", "markers": ["/root/.config/task-owned/marker", "/root/.local/share/task-owned/marker", "/root/.cache/task-owned/marker", "/root/.local/state/task-owned/marker"]}, "after": {"deliverable": "deliverable-e306c86a7beba666", "markers_kept": ["/root/.config/task-owned/marker", "/root/.local/share/task-owned/marker", "/root/.cache/task-owned/marker", "/root/.local/state/task-owned/marker"], "markers_lost": [], "python3": "none", "harness_processes": []}}
{"harness": "opencode", "image": "alpine:3.20", "image_id": "sha256:bf8527eb54c3680e728d5b4b383a8ba730d72dae7236fbc8dff97ed6b224a731", "passed": false, "seed_seconds": null, "seconds": 14.0, "model_calls": 0, "response_status": null, "output_item_types": null, "activation_receipt": null, "close_receipt": null, "session_closed": null, "close_observation_records": null, "close_observation_gaps": null, "recorded_event_lines": null, "adapter_session_dir_removed": null, "verifier_passed": null, "owner_teardown_container_removed": true, "error_type": "RuntimeError", "before": {"python3": "none", "bash": "none", "os": "alpine-3.20.10", "markers": ["/root/.config/task-owned/marker", "/root/.local/share/task-owned/marker", "/root/.cache/task-owned/marker", "/root/.local/state/task-owned/marker"]}, "error_first_lines": ["pencode-runtime-1.17.11/opencode: _ZNKSt8__detail20_Prime_rehash_policy14_M_need_rehashEmmm: symbol not found", "Error relocating /tmp/nemo-gym-opencode-runtime-1.17.11/opencode: _ZSt13set_terminatePFvvE: symbol not found", "Error relocating /tmp/nemo-gym-opencode-runtime-1.17.11/opencode: _ZSt21__glibcxx_assert_failPKciS0_S0_: symbol not found"]}
{"harness": "kilo", "image": "alpine:3.20", "image_id": "sha256:bf8527eb54c3680e728d5b4b383a8ba730d72dae7236fbc8dff97ed6b224a731", "passed": true, "seed_seconds": 21.5, "seconds": 51.0, "model_calls": 2, "response_status": "completed", "output_item_types": {"message": 2, "function_call": 1, "function_call_output": 1}, "activation_receipt": {"cleanup_confirmed": true, "return_code": 0, "error": null, "timed_out": false}, "close_receipt": {"cleanup_confirmed": true, "return_code": 0, "error": null, "timed_out": false}, "session_closed": true, "close_observation_records": 3, "close_observation_gaps": ["usage_details_unavailable"], "recorded_event_lines": null, "adapter_session_dir_removed": true, "verifier_passed": true, "owner_teardown_container_removed": true, "error_type": null, "before": {"python3": "none", "bash": "none", "os": "alpine-3.20.10", "markers": ["/root/.config/task-owned/marker", "/root/.local/share/task-owned/marker", "/root/.cache/task-owned/marker", "/root/.local/state/task-owned/marker"]}, "after": {"deliverable": "deliverable-2adebc9f36532759", "markers_kept": ["/root/.config/task-owned/marker", "/root/.local/share/task-owned/marker", "/root/.cache/task-owned/marker", "/root/.local/state/task-owned/marker"], "markers_lost": [], "python3": "none", "harness_processes": []}}

🤖 Generated with Claude Code

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

Copy link
Copy Markdown
Contributor

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

Merged via the queue into main with commit da26cd2 Oct 28, 2026
67 of 71 checks passed
@yaoyu-33
yaoyu-33 deleted the refactor/bash-sandbox-runtime branch October 28, 2026 02:11
yaoyu-33 added a commit to yaoyu-33/Gym that referenced this pull request Oct 28, 2026
Resolves modify/delete conflicts in `responses_api_agents/hermes_sandboxed_agent/` (README.md,
app.py, tests/test_app.py) by keeping this branch's deletion. NVIDIA-NeMo#5151 adapted that adapter to the
bash process supervisor and updated `hermes_agent/sandbox.py` itself, so nothing needs to carry
over.

Resolves two conflicts in `add-or-update-a-harness.mdx` by taking NVIDIA-NeMo#5151's rewritten text and
dropping only the references to the removed adapter. NVIDIA-NeMo#5151 also added two new references to it:
one names it as an example of a runtime prepared outside the task container and mounted
read-only, and one links to its `prepare_runtime.sh`. Keep that guidance and drop both, since
the link would 404 once the adapter is gone. NOOA stays as the worked example.

Main also tightened the runtime check in `hermes_agent/sandbox.py` from `import run_agent, mcp`
to importing `run_agent` and `mcp.client.streamable_http.streamablehttp_client`. Update the
migration notes to state the check offline deployments must now satisfy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
yaoyu-33 added a commit that referenced this pull request Oct 28, 2026
Resolve the conflicts with the bash-only harness runner (#5151):
- Stage Kilo through harness_runner.sh, still writing input.json with the
  required MCP servers and preflight flag that the task-environment plugin reads.
- Drop sandbox_runner.py; the activation setup now removes the stale
  required-MCP receipt alongside the task-environment receipt.
- Take the preflight's exit status from the cleanup receipt, as activations do,
  and fall back to the supervisor log when Kilo's stderr is missing.
- Run the pinned-CLI preflight test and the fake-sandbox flow tests through the
  bash runner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
yaoyu-33 added a commit that referenced this pull request Oct 28, 2026
…isor

#5151 replaced SandboxCommand's python field with stdlib_runner and moved
the process supervisor to bash. Declare Pool's standard-library runner with
stdlib_runner=True and upload it as RUNNER_FILE, like Codex, so task-side
pkill patterns for python or .py cannot match it.

The bash supervisor reports a SIGKILLed harness as 137 rather than -9, and
Harbor's materialize_task now reads task_data, so build the Harbor test row
from a real task folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
athitten added a commit that referenced this pull request Oct 29, 2026
Drop the Pi sandbox_runner.py drain fix and its test: #5151 replaced that
runner with harness_runner.sh, which reads Pi's stdout from a file to its
end after Pi exits.

Signed-off-by: Abhishree <athittenaman@nvidia.com>
copy-pr-bot Bot pushed a commit that referenced this pull request Oct 29, 2026
Main's #5151 runs sandboxed harnesses with only bash in the task image and deletes
OpenCode's Python sandbox runner, which this branch had extended for owner exports, pinned files and
session import. Those now need no Python in the sandbox either: the sandbox runs POSIX sh and
coreutils, and the agent server reads OpenCode's session store and checks archives itself, as main's
host-side snapshot does.

- Session export: the stored session tree comes from the store that the capture copies out. Each
  session's `opencode export` is its own command, bounded by the collection deadline. It writes into
  the session directory and reaches `export_dir` only once it is a JSON object.
- Pinned ripgrep and plugin dependencies: the sandbox checks a private copy's SHA-256 with sha256sum
  and runs `--version`. The agent server checks an archive's members in a download of the verified
  copy before the sandbox unpacks it with tar.
- Session import: `opencode import` runs from the task workdir without the run's configuration. The
  agent server then reads the imported session and its messages from a copy of the store.
- The plugin-file and export_dir checks are POSIX sh.
- Main's runner `cd`s into the task workdir, so every run already gets PWD set to it. The opt-in PWD
  setting is gone; import and export set PWD themselves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Katherine Huang <katherineh@nvidia.com>
patdevinwilson pushed a commit to patdevinwilson/nemoGym that referenced this pull request Nov 2, 2026
* feat(pool): run Pool in task sandboxes

Signed-off-by: Marat Kitov <mkitov@nvidia.com>

* fix(pool): preserve failures and drain sessions on shutdown

Signed-off-by: Marat Kitov <mkitov@nvidia.com>

* fix(pool): retain failure artifacts and clarify runner metadata

Signed-off-by: Marat Kitov <mkitov@nvidia.com>

* fix(pool): harden session lifecycle and timeout outcomes

Signed-off-by: Marat Kitov <mkitov@nvidia.com>

* fix(pool): require EULA opt-in and verify pinned release archives

Signed-off-by: Marat Kitov <mkitov@nvidia.com>

* fix(pool): allowlist pinned release checksums for secret scanning

detect-secrets flags the four pinned Pool v1.0.16 archive SHA-256 values
as high-entropy strings. They are public release checksums, so mark them
as false positives inline.

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

* test(terminus-2): clear PROMPT_COMMAND before the interrupt-exit scenario (NVIDIA-NeMo#4966)

On Ubuntu 26.04, systemd's /etc/profile.d/80-systemd-osc-context.sh appends a
hook to PROMPT_COMMAND in every login bash. With PROMPT_COMMAND set, an
interactive bash returns to the prompt after Ctrl-C instead of exiting under
`set -e`, so the interrupt case of
test_exited_shell_recovery_discards_pending_input_and_reports_lost_state never
sees a dead pane. GitHub's ubuntu-latest runners moved to 26.04, so the test
now fails on main and on every PR.

Clear PROMPT_COMMAND in the command the test sends, so the shell exits as the
scenario requires regardless of the host's login profile.

Generated with [Devin](https://devin.ai)

Signed-off-by: Marc Romeyn <mromeijn@nvidia.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
(cherry picked from commit c5c4782)
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

* test(pool): drain proxy fixture request bodies

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

* fix(pool): launch the runner as a stdlib runner under the bash supervisor

NVIDIA-NeMo#5151 replaced SandboxCommand's python field with stdlib_runner and moved
the process supervisor to bash. Declare Pool's standard-library runner with
stdlib_runner=True and upload it as RUNNER_FILE, like Codex, so task-side
pkill patterns for python or .py cannot match it.

The bash supervisor reports a SIGKILLed harness as 137 rather than -9, and
Harbor's materialize_task now reads task_data, so build the Harbor test row
from a real task folder.

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

---------

Signed-off-by: Marat Kitov <mkitov@nvidia.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
Signed-off-by: Marc Romeyn <mromeijn@nvidia.com>
Co-authored-by: yaoyu-33 <yaoyu.094@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Marc Romeyn <marcromeyn@gmail.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
yaoyu-33 added a commit to yaoyu-33/Gym that referenced this pull request Nov 4, 2026
…o#5152)

* 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>

* 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(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(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>

---------

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>
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 — 656e1739 Deployed Oct 28, 2026 by copy-pr-bot[bot] via release / finalize / notify #7716
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 feature New capabilities, enhancements, or enablement work needs-review PR is ready for code review and waiting on a reviewer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants