Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions nemo_gym/sandbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
"""Public sandbox API for NeMo Gym."""

from nemo_gym.sandbox.api import AsyncSandbox, Sandbox
from nemo_gym.sandbox.config import (
resolve_provider_config,
resolve_provider_default_options,
resolve_provider_metadata,
)
from nemo_gym.sandbox.providers import (
ExecResult,
SandboxCreateError,
Expand Down Expand Up @@ -49,5 +54,8 @@
"get_provider_class",
"list_providers",
"register_provider",
"resolve_provider_config",
"resolve_provider_default_options",
"resolve_provider_metadata",
"rewrite_image",
]
203 changes: 203 additions & 0 deletions nemo_gym/sandbox/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Resolve a sandbox provider reference into a provider config.

An agent selects a sandbox by name (``sandbox_provider: sandbox``). The named
block lives in its own provider config file, so swapping providers is swapping a
``config_paths`` entry, not editing the agent config::

# nemo_gym/sandbox/providers/opensandbox/configs/opensandbox.yaml
sandbox:
opensandbox:
connection: { ... }

# agent config
sandbox_provider: sandbox

An inline single-key mapping (``{provider_name: {...}}``) is also accepted for
keeping everything in one file.

A block may also carry reserved ``default_metadata`` and
``default_provider_options`` keys. Their top-level entries are shallow-merged
into the sandbox's ``SandboxSpec.metadata`` and ``SandboxSpec.provider_options``
respectively (the agent's own ``sandbox_spec`` entries override them), so
provider-owned defaults live with the provider rather than the agent config::

sandbox:
opensandbox: { connection: { ... } }
default_metadata: { sandbox-api: opensandbox-sdk }
default_provider_options: { platform: { os: linux, arch: amd64 } }
"""

from collections.abc import Mapping
from typing import Any


# Reserved keys inside a sandbox block that are not the provider config.
SANDBOX_BLOCK_DEFAULT_METADATA_KEY = "default_metadata"
SANDBOX_BLOCK_DEFAULT_PROVIDER_OPTIONS_KEY = "default_provider_options"
SANDBOX_BLOCK_RESERVED_KEYS = frozenset(
{
SANDBOX_BLOCK_DEFAULT_METADATA_KEY,
SANDBOX_BLOCK_DEFAULT_PROVIDER_OPTIONS_KEY,
}
)


def _to_plain_dict(value: Any) -> Any:
"""Return a plain ``dict`` for mappings, including OmegaConf ``DictConfig``."""
try:
from omegaconf import DictConfig, OmegaConf
except ImportError: # pragma: no cover - omegaconf is a core dependency
DictConfig = () # type: ignore[assignment]
OmegaConf = None # type: ignore[assignment]

if OmegaConf is not None and isinstance(value, DictConfig):
return OmegaConf.to_container(value, resolve=True)
if isinstance(value, Mapping):
return dict(value)
return value


def _provider_keys(block: Mapping[str, Any]) -> list[str]:
"""Return the provider keys in a block (everything but reserved keys)."""
return [key for key in block if key not in SANDBOX_BLOCK_RESERVED_KEYS]


def _candidate_sandbox_names(named_configs: Mapping[str, Any] | None) -> list[str]:
"""List top-level config keys that look like named sandbox provider blocks."""
if not named_configs:
return []
candidates: list[str] = []
for key, value in named_configs.items():
plain = _to_plain_dict(value)
if isinstance(plain, Mapping) and len(_provider_keys(plain)) == 1:
candidates.append(str(key))
return sorted(candidates)


def _resolve_block(
sandbox_provider: str | Mapping[str, Any],
named_configs: Mapping[str, Any] | None,
) -> tuple[dict[str, Any], str]:
"""Resolve a ``sandbox_provider`` reference or inline mapping to a plain block."""
if isinstance(sandbox_provider, str):
name = sandbox_provider
if not name:
raise ValueError("Sandbox provider reference must be a non-empty string")
block = named_configs.get(name) if named_configs is not None else None
if block is None:
available = ", ".join(repr(n) for n in _candidate_sandbox_names(named_configs)) or "(none)"
raise ValueError(
f"Sandbox provider reference {name!r} is not defined in the merged config. "
f"Define a top-level '{name}:' block (e.g. via "
f"nemo_gym/sandbox/providers/<provider>/configs/<provider>.yaml) and include it in "
f"your config_paths. Available sandbox configs: {available}"
)
return _to_plain_dict(block), f"reference {name!r}"
if isinstance(sandbox_provider, Mapping):
return _to_plain_dict(sandbox_provider), "inline sandbox_provider config"
raise TypeError(
"sandbox_provider must be a name reference (str) or a single-key provider mapping, "
f"got {type(sandbox_provider).__name__}"
)


def resolve_provider_config(
sandbox_provider: str | Mapping[str, Any],
named_configs: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Resolve a ``sandbox_provider`` field into a single-key provider config dict.

Args:
sandbox_provider: Either the name of a top-level sandbox config block
(resolved from ``named_configs``) or an inline single-key provider
mapping of the form ``{provider_name: {...}}``.
named_configs: Mapping of top-level config name to config block, typically
the merged global config dict. Required when ``sandbox_provider`` is a
name reference.

Returns:
A plain ``{provider_name: provider_kwargs}`` dict suitable for
:func:`nemo_gym.sandbox.create_provider`. Reserved keys such as
``default_metadata`` and ``default_provider_options`` are excluded; read
them with :func:`resolve_provider_metadata` and
:func:`resolve_provider_default_options`.

Raises:
TypeError: If ``sandbox_provider`` is neither a string nor a mapping.
ValueError: If a named reference cannot be found, or if the block does not
hold exactly one provider key.
"""
block, source = _resolve_block(sandbox_provider, named_configs)
if not isinstance(block, Mapping):
raise ValueError(f"Sandbox provider config from {source} must be a mapping, got: {block!r}")

provider_keys = _provider_keys(block)
if len(provider_keys) != 1:
raise ValueError(
f"Sandbox provider config from {source} must have exactly one provider key "
f"{{provider_name: config}}, got keys: {provider_keys!r}"
)

return {provider_keys[0]: block[provider_keys[0]]}


def resolve_provider_metadata(
sandbox_provider: str | Mapping[str, Any],
named_configs: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Return a sandbox block's ``default_metadata``.

These are provider-contributed defaults to merge into ``SandboxSpec.metadata``.
Returns an empty dict when the block has no ``default_metadata`` key. See
:func:`resolve_provider_config` for argument semantics.
"""
block, source = _resolve_block(sandbox_provider, named_configs)
if not isinstance(block, Mapping):
raise ValueError(f"Sandbox provider config from {source} must be a mapping, got: {block!r}")

metadata = block.get(SANDBOX_BLOCK_DEFAULT_METADATA_KEY) or {}
if not isinstance(metadata, Mapping):
raise ValueError(
f"Sandbox '{SANDBOX_BLOCK_DEFAULT_METADATA_KEY}' from {source} must be a mapping, got: {metadata!r}"
)
return dict(metadata)


def resolve_provider_default_options(
sandbox_provider: str | Mapping[str, Any],
named_configs: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Return a sandbox block's ``default_provider_options``.

These provider-owned defaults are shallow-merged by top-level key into
``SandboxSpec.provider_options``; explicit options from the agent's
``sandbox_spec`` take precedence. Returns an empty dict when the block has no
``default_provider_options`` key. See :func:`resolve_provider_config` for
argument semantics.
"""
block, source = _resolve_block(sandbox_provider, named_configs)
if not isinstance(block, Mapping):
raise ValueError(f"Sandbox provider config from {source} must be a mapping, got: {block!r}")

options = block.get(SANDBOX_BLOCK_DEFAULT_PROVIDER_OPTIONS_KEY)
if options is None:
return {}
if not isinstance(options, Mapping):
raise ValueError(
f"Sandbox '{SANDBOX_BLOCK_DEFAULT_PROVIDER_OPTIONS_KEY}' from {source} must be a mapping, got: {options!r}"
)
return dict(options)
100 changes: 74 additions & 26 deletions nemo_gym/sandbox/providers/apptainer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,30 +72,62 @@ async def run():

## Selecting and configuring the provider

The provider config is a single-key mapping: `{"apptainer": {<kwargs>}}`. The kwargs are
grouped into three optional sections, each of which accepts a plain mapping (e.g. from
Hydra YAML) or the corresponding dataclass:
For Hydra-composed agents, use the bundled named provider config:

```bash
AGENT=responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_2.yaml
MODEL=responses_api_models/vllm_model/configs/vllm_model.yaml
PROVIDER=nemo_gym/sandbox/providers/apptainer/configs/apptainer.yaml
ng_run "+config_paths=[$AGENT,$PROVIDER,$MODEL]"
```

The config binds the conventional name `sandbox`, which the agent selects with
`sandbox_provider: sandbox`. It can therefore replace the OpenSandbox config by changing
only the provider path. `default_metadata` is merged into each `SandboxSpec.metadata`,
with the agent's explicit metadata taking precedence:

```yaml
# Provider config (the value passed as the sandbox provider)
apptainer:
exec:
fakeroot_for_root: true
default_binds: ["/tmp"]
extra_exec_args: ["--writable-tmpfs"]
default_timeout_s: 180
concurrency: 32
create:
mount_point: /sandbox
start_timeout_s: 600
extra_start_args: []
apply_resource_limits: true
probe:
command: printf apptainer-sandbox-ready
expected_stdout: apptainer-sandbox-ready
deadline_s: 120
sandbox:
default_metadata:
sandbox-api: apptainer-cli
apptainer:
exec:
fakeroot_for_root: false # instance start establishes fakeroot below
default_binds: []
extra_exec_args: ["--cleanenv"]
default_timeout_s: 180
concurrency: 32
create:
mount_point: /sandbox
start_timeout_s: 1200
extra_start_args: ["--containall", "--cleanenv", "--fakeroot", "--writable-tmpfs"]
apply_resource_limits: false
probe:
command: printf apptainer-sandbox-ready
expected_stdout: apptainer-sandbox-ready
timeout_s: 30
deadline_s: 120
stable_count: 1
stable_delay_s: 0.0
```

The value under `apptainer` is the provider constructor config. Its three optional
sections accept either a plain mapping (for example, from Hydra YAML) or the corresponding
dataclass. Direct Python callers pass the resolved single-key mapping
`{"apptainer": {<kwargs>}}` to `Sandbox` or `AsyncSandbox`, as shown above.

The bundled config uses `--containall` and `--cleanenv` to suppress Apptainer's standard
host home/tmp binds and ordinary host environment inheritance. It also starts with
fakeroot and an ephemeral writable overlay so Mini SWE commands can run as root and
persistent edits can modify an otherwise read-only SIF image. These flags must be applied
by `instance start`; adding them only to `exec instance://...` cannot change an existing
instance's mount/user namespace. The profile therefore disables CPU/memory cgroup flags,
which Apptainer cannot combine with fakeroot in this provider. A host must support
fakeroot and writable-tmpfs overlays; adjust `create.extra_start_args` for writable
sandbox images or hosts without those features. Apptainer's `sessiondir max size`
controls writable-tmpfs capacity; `SandboxSpec.disk_gib` does not resize it, so raise the
host limit for Mini SWE workloads.

### `create` — `ApptainerCreateConfig`

Settings for starting the instance (`apptainer instance start`).
Expand All @@ -114,9 +146,9 @@ Settings for running commands (`apptainer exec`) and global provider behavior.
| Field | Default | Meaning |
|---|---|---|
| `default_timeout_s` | `180` | Default per-command timeout when the caller doesn't pass one (`None` = no timeout). |
| `fakeroot_for_root` | `true` | When running as root, add `--fakeroot` (map the host user to root inside the container). |
| `fakeroot_for_root` | `true` | When `user` is root, request `--fakeroot` on exec. The instance must also have been started with `--fakeroot`; the bundled config does this. |
| `default_binds` | `[]` | Extra `--bind host:container` mounts added at instance start. |
| `extra_exec_args` | `[]` | Extra raw flags appended to every `apptainer exec` (e.g. `--no-home`, `--writable-tmpfs`, `--contain`). |
| `extra_exec_args` | `[]` | Extra raw flags appended to every `apptainer exec` (e.g. `--cleanenv` or `--no-eval`). Mount/user namespace flags belong in `create.extra_start_args`. |
| `concurrency` | `32` | Upper bound on concurrent `apptainer` subprocesses (shared semaphore). |

### `probe` — `ApptainerProbeConfig`
Expand Down Expand Up @@ -145,9 +177,19 @@ The spec is provider-neutral; the Apptainer provider uses these fields:
| `workdir` | Default working directory for `exec` (applied as `--pwd`). |
| `files` | Seed files written into the sandbox at `start()` (handled by the sandbox API via `upload`). |
| `resources` | Mapped to cgroup flags (see below). |
| `provider_options` | `binds`: a `"src:dst[:opts]"` string or list of them — extra per-sandbox `--bind` mounts added at instance start (on top of the staging mount and `exec.default_binds`). |
| `provider_options` | Validated by `ApptainerProviderOptions`; currently supports only `binds` (see below). |
| `ttl_s` | **Not supported** — ignored with a warning. Tear down via `stop()`/`close()` instead. |

### Per-sandbox provider options — `ApptainerProviderOptions`

`SandboxSpec.provider_options` is parsed into a frozen dataclass before any instance
resources are allocated. The only supported option is `binds`: either one
`"src:dst[:opts]"` string or a list/tuple of strings. These per-sandbox mounts are added
at instance start after the staging mount and `exec.default_binds`.

Unknown keys, a non-mapping `provider_options` value, and non-string bind entries raise a
clear validation error instead of being silently ignored.

## How it works

### Lifecycle: one persistent instance per sandbox
Expand Down Expand Up @@ -256,16 +298,22 @@ failed" rather than "the command exited 125".
per relevant create). Manage lifetime with `stop()` / `close()`.
- **Numeric uids.** The `su`-based user switch expects a *username*; a bare numeric uid
may not resolve. Prefer named users.
- **`--fakeroot` on exec.** Whether `--fakeroot` works on `exec` into an instance that
was started *without* fakeroot varies by Apptainer version and host configuration.
- **Fakeroot is fixed at instance start.** `exec instance://...` cannot retrofit a user
namespace onto a non-fakeroot instance; put `--fakeroot` in
`create.extra_start_args` when commands must run as root.
- **Apptainer launcher variables remain significant.** `APPTAINER_BIND`,
`APPTAINER_BINDPATH`, `APPTAINER_MOUNT`, `APPTAINERENV_*`, and legacy Singularity
aliases can inject mounts or environment values before container-side `--cleanenv`
applies. Sanitize these in the worker/launcher environment when running untrusted code.
- **Resource enforcement.** CPU/memory cgroup flags require cgroups v2 delegation.
Disable them with `create.apply_resource_limits: false`.
- **Runtime-failure detection is heuristic.** It keys off stderr markers, so a user
command whose own output contains `FATAL:` could be misclassified as a sandbox error.

## Development

Source: [`provider.py`](./provider.py). The provider implements the
Source: [`provider.py`](./provider.py); the bundled named config is
[`configs/apptainer.yaml`](./configs/apptainer.yaml). The provider implements the
`SandboxProvider` protocol from [`../base.py`](../base.py) structurally (no subclassing)
and is registered under the name `apptainer` in [`../registry.py`](../registry.py).

Expand Down
Loading
Loading