Skip to content

feat(cli): add gym sandbox debug - #2403

Draft
terrykong wants to merge 4 commits into
terryk/sandbox-network-policyfrom
terryk/gym-sandbox-debug
Draft

feat(cli): add gym sandbox debug#2403
terrykong wants to merge 4 commits into
terryk/sandbox-network-policyfrom
terryk/gym-sandbox-debug

Conversation

@terrykong

@terrykong terrykong commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Top of a 5-PR stack (#2400#2401#2402#2407#2403). Review the three below first; this one is the consumer that makes them worth having.

Why

Poking at a sandbox-backed task currently means standing up the whole stack — model server, Ray, agent, rollout — and hoping the failure reproduces. There is no way to ask "what is actually inside that container?" without writing a harness.

What

gym sandbox debug boots the sandbox a server would create for a task — same provider, same image, same SandboxSpec a rollout uses — runs a command or uploaded script inside it, and writes a re-runnable trace. Plus gym sandbox exec / rm for reattaching to a kept sandbox.

Two things need no cluster and no credentials: --list-tasks reads only the dataset, and --dry-run resolves the image and command wrapping offline in milliseconds.

Worked example: a mini-swe-agent-2 container running pip install torch

Point at a server (nothing lands in a config file):

export OPENSANDBOX_DOMAIN=<your-opensandbox-host>:<port>
export OPENSANDBOX_API_KEY=<your-api-key>

If the server runs with auth disabled (api_key = "" plus OPENSANDBOX_INSECURE_SERVER=YES) there is no key to obtain — but the variable must still be set to something, because the config interpolates it and Hydra fails on an unset reference. For a cluster-internal server, port-forward and point OPENSANDBOX_DOMAIN at the local end.

First, what would this task even get? No cluster needed:

AGENT=responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_2.yaml
PROVIDER=nemo_gym/sandbox/providers/opensandbox/configs/opensandbox.yaml

gym sandbox debug --config $AGENT --config $PROVIDER --task django__django-10973 --dry-run
image     docker.io/swebench/sweb.eval.x86_64.django_1776_django-10973:latest
          via spec_resolver (responses_api_agents.mini_swe_agent_2.sandbox_hooks:spec_for_row)
spec      ttl_s=900.0  ready_timeout_s=1200  cpu=2.0  memory_mib=8192  disk_gib=30  workdir=/testbed
exec      activate_conda=True conda_env=testbed cwd=/testbed user=root
wrapper   responses_api_agents.mini_swe_agent_2.sandbox_hooks:conda_activate_wrap

The image was derived from instance_id + subset — no row field carried it. That answer comes from #2402's hooks; before them it was unreachable without importing ray.

Now actually run the install and time it:

gym sandbox debug --config $AGENT --config $PROVIDER \
    --task django__django-10973 --command "pip install torch" --timeout-total 900
✓ django__django-10973  reason=pass exit=0  67.1s (command 21.0s + overhead 1.3s, boot 44.8s)
gym sandbox debug ... --command "pip install torch" --json | jq '.timing'
{"boot": 44.812, "setup": 0.0, "exec": 22.303, "command": 21.024, "overhead": 1.279, "total": 67.115}

command is execd's measurement from inside the sandbox (#2400); overhead is what the round trip added. Only the first is a property of the task, so it is the number to compare across images, package indexes, or clusters — and here it is a third of the total, with boot dominating because SWE-bench images are large.

Note which torch: this image's testbed env is Python 3.6, so pip resolves to torch 1.10.2 — the last release with cp36 wheels — at about 880 MB, not a multi-GB modern build. Measured against a warm in-cluster package cache the same install runs 21s cold / 13s warm, which is what makes it a useful probe for whether a cache is actually being used.

If the sandbox's egress sidecar is intercepting TLS

A conda-based image will not trust the proxy's CA and pip fails with CERTIFICATE_VERIFY_FAILED. The CA is installed into the system trust store, but conda ships and consults its own at /opt/miniconda3/envs/<env>/ssl/cert.pem — both hold 151 CAs, only the system one has the proxy's. Every SWE-bench image is conda-based, so this is the common case:

--env SSL_CERT_FILE=/opt/opensandbox/mitmproxy-ca-cert.pem \
--env REQUESTS_CA_BUNDLE=/opt/opensandbox/mitmproxy-ca-cert.pem

Debian-style images (python:3.12-slim) read the system store and need none of this. Documented in debugging.mdx.

Not leaking sandboxes

The failure mode of getting this wrong is a leaked, paid-for sandbox, so it is handled deliberately rather than incidentally:

  • sandboxes get a short lifetime that renewal (feat(sandbox): let providers renew a sandbox's lifetime #2401) extends while the command runs, instead of a long one nobody remembers to clean up;
  • the first SIGINT logs and re-raises so every finally unwinds, and later signals are swallowed — a second Ctrl-C must not orphan what the first was cleaning up;
  • an atexit backstop over a WeakSet catches the case where the loop is already gone.

Known limitations

No live streaming. SandboxProvider.exec returns a completed result, so incremental output needs new provider surface. execd does stream over SSE, so this is a follow-up rather than a dead end.

Requesting an egress sidecar. --metadata and --provider-option (added here) are the top layer of the resolution chain, so a spec_resolver's spec can now be extended from the command line — previously only --env could, which left networkPolicy and the istio label unreachable. The provider side is #2407, directly below this PR, so the whole thing works as one command:

gym sandbox debug --config $AGENT --config $PROVIDER --task django__django-10973 \
    --command "pip install --no-input tabulate" \
    --env OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT=true \
    --env SSL_CERT_FILE=/opt/opensandbox/mitmproxy-ca-cert.pem \
    --metadata istio.io/dataplane-mode=none \
    --provider-option 'network_policy={"defaultAction":"allow","egress":[]}'

Verified against a live cell: the wheel was served by the in-cluster cache (cache=MISS on the first run, HIT on the second) with nothing in the container configured to use it.

Tests

260 tests across test_cli_sandbox.py, test_cli_main.py and test_sandbox_hooks.py — resolution at each layer, the precedence chain, --bare, --dry-run, trace shape, classification, cleanup on failure and interrupt, and a resources-server case alongside an agent case so generality is enforced rather than asserted.

@copy-pr-bot

copy-pr-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Poking at a sandbox-backed task currently means standing up the whole stack --
model server, Ray, agent, rollout -- and hoping the failure reproduces. This
boots the sandbox a server would create for a task, using the same provider,
image and SandboxSpec a rollout would, runs a command or uploaded script in it,
and writes a re-runnable trace.

`--dry-run` answers "what image would this row get, and how would the command be
wrapped" offline, in milliseconds, with no cluster and no credentials.
`--list-tasks` reads only the dataset.

Sandboxes get a short lifetime that renewal extends while the command runs,
rather than a long one nobody remembers to clean up. Interrupt handling and an
atexit backstop exist because the failure mode of getting that wrong is a
leaked, paid-for sandbox: the first SIGINT unwinds each `finally`, and later
ones are swallowed so a second Ctrl-C cannot orphan what the first was cleaning.

Timing separates the command's own runtime from the round trip, so a slow
command and a slow link stop looking alike.

Signed-off-by: Terry Kong <terryk@nvidia.com>
Three figures in the example were illustrative and one was simply wrong. Replaced
with what the commands actually print, measured on a
sweb.eval.x86_64.django_1776_django-10973 sandbox:

  * ttl_s is 900, not 18000 -- the dry-run reads it from the resolver's spec.
  * The timing block now shows a real run. Boot dominates for a SWE-bench image
    (44.8s) rather than the 7s a small image takes, which is worth seeing.
  * `pip install torch` does NOT pull 2.7 GB here. The testbed env is Python 3.6,
    so pip resolves to torch 1.10.2 -- the last release with cp36 wheels -- at
    about 880 MB.

Also documents a trap that costs an hour if you meet it cold: when the egress
sidecar intercepts TLS, conda images fail with CERTIFICATE_VERIFY_FAILED. The CA
is installed into the system trust store, but conda consults its own at
/opt/miniconda3/envs/<env>/ssl/cert.pem. Both stores hold 151 CAs; only the
system one has the proxy's. Every SWE-bench image is conda-based, so this is the
common case, and SSL_CERT_FILE/REQUESTS_CA_BUNDLE fix it.

Signed-off-by: Terry Kong <terryk@nvidia.com>
CLI flags are meant to be the top layer of the resolution chain, above both the
declarative sandbox_spec and a spec_resolver's output. Only --env was, so a
server that computes its spec in Python could not be handed anything else from
the command line: the resolver's spec wins outright over config, and there was no
flag to override it.

That made a whole class of sandbox unreachable from the debugger. Transparent
egress interception, for one, needs a networkPolicy (a provider option) and an
istio label (metadata) alongside its env var -- so debugging it meant abandoning
the CLI and POSTing to the REST API by hand, which is exactly what this command
exists to avoid.

--provider-option decodes its value as JSON when it decodes, because provider
options are not all strings: a network policy is a mapping and skip_health_check
is a bool. Anything that is not valid JSON stays a string, so ids and names need
no quoting ceremony.

Signed-off-by: Terry Kong <terryk@nvidia.com>
These are the top layer of the resolution chain, which is only interesting for a
server that computes its spec in Python: the resolver's spec wins outright over
config, so without them there is no way to add anything to it from the command
line. The worked example is the case that motivated them -- asking for a
transparently-intercepting egress sidecar needs an env var, a metadata label and
a provider option together.

Signed-off-by: Terry Kong <terryk@nvidia.com>
@terrykong
terrykong force-pushed the terryk/gym-sandbox-debug branch from 60946b7 to 5908cd2 Compare August 7, 2026 07:06
@terrykong
terrykong changed the base branch from terryk/sandbox-task-hooks to terryk/sandbox-network-policy August 7, 2026 07:06
Comment thread nemo_gym/cli/sandbox.py
(output_dir / "config.yaml").write_text(_redacted_config_yaml(global_config_dict))
traces_path = output_dir / "traces.jsonl"
traces_path.write_text("")
rich.print(f"[dim]results: {output_dir}/[/dim]")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is as_json is only handled for list/dry-run, so gym sandbox debug --json still reaches this rich.print, so the documented --json | jq '.timing' path won’t produce parseable JSON?

Comment thread nemo_gym/cli/sandbox.py

stage, budget = "exec", config.timeout_total
exec_started = time.monotonic()
result = await sandbox.exec(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If run_plan() never calls _renew() before sandbox.exec, can a long --timeout-total outlive the default 900s TTL - should we renew here like gym sandbox exec ?

eg
if you run gym sandbox debug --command "long thing" --timeout-total 1800 the sandbox may expire after default 900s.

Answering "what is actually inside this task's container?" normally means starting the whole
stack — model server, Ray, agent, rollout — and hoping the failure reproduces. `gym sandbox
debug` skips all of that: it boots the sandbox a server would create for a task, using the
same provider, image, and `SandboxSpec` a rollout gets, and runs whatever you ask inside it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This sounds like gym sandbox debug can reproduce any sandbox-backed rollout, but envs computed sandbox logic need to opt into sandbox_task - should we either update all the sandbox envs to work, like AnySWE/AnyTerminal, or document this as an opt-in debug contract?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants