diff --git a/fern/versions/latest/pages/infrastructure/sandbox/e2b.mdx b/fern/versions/latest/pages/infrastructure/sandbox/e2b.mdx new file mode 100644 index 0000000000..e6ae7f13bb --- /dev/null +++ b/fern/versions/latest/pages/infrastructure/sandbox/e2b.mdx @@ -0,0 +1,184 @@ +--- +title: "E2B Provider" +description: "Configure NeMo Gym sandboxes backed by E2B or an E2B-compatible gateway." +position: 2 +--- + +The `e2b` provider creates isolated cloud sandboxes through the E2B Python SDK. It supports +the hosted E2B service and E2B-compatible gateways with custom API and sandbox URLs. + +## Setup + +Install the sandbox extra in the environment that creates sandboxes: + +```bash +uv sync --extra sandbox +``` + +For a package install, use: + +```bash +pip install "nemo-gym[sandbox]" +``` + +The provider requires `e2b>=2.36.0,<3.0.0`. Set `E2B_API_KEY` for the hosted service. For a +compatible gateway, also set `E2B_API_URL` and `E2B_SANDBOX_URL` to the endpoints supplied by +the gateway operator. + +## Provider Config + +NeMo Gym ships an E2B config at `nemo_gym/sandbox/providers/e2b/configs/e2b.yaml`. It defines +a top-level `sandbox` block that agents reference with `sandbox_provider: sandbox`: + +```yaml +sandbox: + default_metadata: + sandbox-api: e2b + e2b: + connection: + api_key: ${oc.env:E2B_API_KEY,null} + api_url: ${oc.env:E2B_API_URL,null} + sandbox_url: ${oc.env:E2B_SANDBOX_URL,null} + request_timeout_s: 120.0 + create: + template: null + template_map: {} + timeout_s: 3600.0 + secure: true + allow_internet_access: true + strict_resources: false + exec: + default_timeout_s: 180.0 + user: null + request_timeout_s: null + background: true + reconnect_attempts: 2 + operations: + retries: 2 + retry_delay_s: 0.5 + retry_max_delay_s: 8.0 +``` + +Pass the provider config beside the agent and model configs: + +```bash +gym env start \ + --config responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_2.yaml \ + --config nemo_gym/sandbox/providers/e2b/configs/e2b.yaml \ + --config responses_api_models/vllm_model/configs/vllm_model.yaml +``` + +## Prepare Templates + +E2B starts a sandbox from a template name or ID rather than an OCI image reference. Because +tagged template names and OCI references can both contain `:`, the provider resolves a template +conservatively: + +1. `SandboxSpec.provider_options.template` +2. `create.template_map[SandboxSpec.image]` +3. `SandboxSpec.image`, when it is an untagged name matching `[A-Za-z0-9_-]+` +4. The `create.template` fallback, only when `SandboxSpec.image` is omitted + +Use `provider_options.template` for a tagged template name or template ID. A non-empty, +unmapped image raises instead of silently selecting an unrelated fallback template. + +Build templates from OCI images as a separate provisioning step: + +```bash +python -m nemo_gym.sandbox.providers.e2b.build \ + --image ghcr.io/acme/task:1.0 \ + --cpu-count 8 \ + --memory-mb 16384 \ + --output template_map.yaml +``` + +The generated YAML contains an image-to-template mapping ready to place under `create`: + +```yaml +template_map: + "ghcr.io/acme/task:1.0": task-1-0__f00ee9d593d9 +``` + +Template building can create E2B resources and is never performed implicitly by sandbox +creation. Generated names are deterministic for the image, CPU count, and memory size, so a +matching existing template can be reused. The helper's local timeout stops waiting but cannot +cancel a remote build that E2B has already accepted; check E2B before retrying after a timeout. +By default a batch failure prevents queued siblings from starting and waits for already-started +builds to finish; pass `--continue-on-error` to keep building the remaining images. + +## Relevant `SandboxSpec` Fields + +| Field | E2B behavior | +| --- | --- | +| `image` | Untagged names matching `[A-Za-z0-9_-]+` are direct; tagged names, IDs, and OCI references require `provider_options.template` or `create.template_map`. | +| `ttl_s` | Sandbox lifetime; overrides `create.timeout_s` and must be positive. | +| `ready_timeout_s` | Positive per-sandbox override for the E2B create request timeout. Omitted values use the connection timeout. | +| `workdir` | Used by the NeMo Gym facade as the default command working directory. | +| `env` | Passed to E2B when the sandbox is created. | +| `files` | Uploaded after creation and before `start()` returns. | +| `metadata` | Passed to E2B as string metadata. | +| `resources` | Fixed by the template rather than applied per sandbox. Requests warn, or raise when `create.strict_resources` is true. | +| `entrypoint` | Unsupported and rejected before allocation; define it in the E2B template. | +| `provider_options` | Supports only `template`; unknown or invalid values are rejected before allocation. | + +The bundled builder controls template CPU count and memory. Disk and GPU requirements need a +suitable template prepared through E2B tooling. + +## Timeouts and Reconnection + +`SandboxSpec.ttl_s` controls the remote sandbox lifetime. Treat it as a cleanup backstop and +still call `stop()` or use a context manager so the sandbox is killed as soon as work finishes. + +`SandboxSpec.ready_timeout_s` overrides the E2B create request timeout. The SDK retains an +explicit create timeout on the returned sandbox connection; the shipped +`connection.request_timeout_s` is reapplied to later calls, while a programmatic config that +leaves it unset inherits `ready_timeout_s` for that sandbox object. + +With E2B 2.36+, the connection or exec `request_timeout_s` only bounds opening command and +reconnect streams. Command `timeout_s` is NeMo Gym's total wait/stream budget across the initial +connection and any reconnects. The public facades normally supply 180 seconds, so pass a larger +value explicitly for long builds or test suites. With background execution, a timed-out stream +does not guarantee that the remote process was killed; close the sandbox before reuse when a +lingering process would be unsafe. + +Background execution lets the provider reconnect to a running process by PID after an output +stream interruption. Output emitted before reconnection is not replayed. If the process exits +during the gap, its result cannot be recovered. Reconnects consume the original wait budget +rather than starting a new one. + +Create is not automatically retried because an ambiguous failure could allocate two billable +sandboxes. Rate-limit responses are returned without automatic retries so callers can choose +their backoff window. Close retries transient kill failures, treats an expired sandbox as +already closed, and clears the local handle only after confirmed cleanup. Serialized handles +carry the E2B sandbox ID for reconnection through another configured provider instance. E2B's +public connect operation may extend a near-expiry sandbox to its SDK-default connection lifetime +(300 seconds in E2B 2.36). + +## Security and Isolation + +E2B supplies the sandbox isolation boundary; when using a compatible gateway, its operator is +responsible for the deployment's security posture. The shipped config uses `secure: true`, so +the sandbox envd service requires an access token. + +Outbound internet access is enabled by default for workloads that install dependencies. Set +`create.allow_internet_access: false` for offline or untrusted workloads. Values in +`SandboxSpec.env` are injected into the remote sandbox, so pass only secrets required by the +workload and keep E2B credentials in the provider connection config or host environment. + +`mini_swe_agent_2` removes `e2b.connection.api_key`, `headers`, and `api_headers` from generated +per-instance worker YAML. It passes them through the worker environment, then reconstructs the +header mappings only in worker memory. The API key stays out of the serializable provider config +and is read directly from `E2B_API_KEY` by the E2B SDK. + +## Integration Attribution + +NeMo Gym appends `nemo-gym/` to the E2B SDK `User-Agent` for both runtime sandbox +requests and template provisioning. This lets E2B distinguish NeMo Gym traffic without adding +metadata to the sandbox itself. An explicit custom `User-Agent` in connection headers takes +precedence in the E2B SDK and can hide this attribution. + +Sandbox and template operations use the E2B SDK's public high-level APIs; attribution uses +E2B's set-once integration hook. Because E2B 2.x does not expose transport injection on those +APIs, NeMo Gym replaces its module-level HTTP transport factories with an aiohttp adapter backed +by Gym's shared client session. The adapter accepts HTTP and HTTPS proxies; SOCKS proxies are +rejected explicitly. E2B's ConnectRPC command streams keep their SDK-owned pyqwest transport. diff --git a/fern/versions/latest/pages/infrastructure/sandbox/index.mdx b/fern/versions/latest/pages/infrastructure/sandbox/index.mdx index 57beef8a57..9bee186bbf 100644 --- a/fern/versions/latest/pages/infrastructure/sandbox/index.mdx +++ b/fern/versions/latest/pages/infrastructure/sandbox/index.mdx @@ -24,6 +24,12 @@ Run sandboxes through an OpenSandbox server and SDK. provider opensandbox + +Run isolated cloud sandboxes through E2B or an E2B-compatible gateway. + +provider e2b + + Run sandboxes as local Apptainer instances on a host or HPC node. diff --git a/nemo_gym/sandbox/providers/e2b/README.md b/nemo_gym/sandbox/providers/e2b/README.md new file mode 100644 index 0000000000..38af250fd4 --- /dev/null +++ b/nemo_gym/sandbox/providers/e2b/README.md @@ -0,0 +1,156 @@ +# E2B Sandbox Provider + +The `e2b` provider runs NeMo Gym sandboxes through the E2B Python SDK. It works with the +hosted E2B service and with E2B-compatible gateways that expose custom API and sandbox URLs. + +## Setup + +Install NeMo Gym's sandbox dependencies and set an API key: + +```bash +uv sync --extra sandbox +export E2B_API_KEY= +``` + +For a package install, use `pip install "nemo-gym[sandbox]"`. The provider requires +`e2b>=2.36.0,<3.0.0`. + +The shipped config is `nemo_gym/sandbox/providers/e2b/configs/e2b.yaml`. It reads +`E2B_API_KEY`, `E2B_API_URL`, and `E2B_SANDBOX_URL`; leave the URL variables unset for the +hosted service. For an E2B-compatible gateway, set both URLs to the endpoints provided by +the gateway operator. + +Add the provider config beside the agent and model configs: + +```bash +gym env start \ + --config responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_2.yaml \ + --config nemo_gym/sandbox/providers/e2b/configs/e2b.yaml \ + --config responses_api_models/vllm_model/configs/vllm_model.yaml +``` + +## Templates + +E2B creates sandboxes from a template name or ID, not directly from an OCI image reference. +For convenience, the provider treats an untagged `SandboxSpec.image` matching +`[A-Za-z0-9_-]+` as a direct template name. Tagged template names such as `task:v1`, template +IDs, and OCI image references must be explicit through `provider_options.template` or +`create.template_map`, because their syntax is ambiguous in the provider-neutral `image` +field. `create.template` is used only when `SandboxSpec.image` is omitted; a non-empty, +unmapped image fails instead of silently selecting an unrelated fallback. + +The bundled provisioning helper builds E2B templates from OCI images before a run: + +```bash +python -m nemo_gym.sandbox.providers.e2b.build \ + --image ghcr.io/acme/task:1.0 \ + --cpu-count 8 \ + --memory-mb 16384 \ + --output template_map.yaml +``` + +Paste the generated mapping under the provider's `create` block: + +```yaml +sandbox: + e2b: + create: + template_map: + "ghcr.io/acme/task:1.0": task-1-0__f00ee9d593d9 +``` + +Template names generated by the helper are deterministic for the image, CPU count, and +memory size. Template builds are provisioning operations and can create E2B resources; they +are never performed implicitly by `E2BProvider.create()`. The helper's local build timeout +stops waiting but cannot cancel a remote build that E2B has already accepted, so check E2B +before retrying a timed-out build. In a batch, the first failure prevents queued sibling builds +from starting and waits for already-started builds to finish; pass `--continue-on-error` to keep +provisioning the remaining images. + +## Configuration + +| Section | Important fields | +| --- | --- | +| `connection` | SDK credentials, hosted or gateway URLs, headers, proxy, and control-plane `request_timeout_s`. | +| `create` | Default template/mapping, sandbox `timeout_s`, `secure`, `allow_internet_access`, and `strict_resources`. | +| `exec` | Fallback command timeout, default user, stream-open request timeout, background execution, and reconnect attempts. | +| `operations` | Retry count and exponential-backoff delays for safe, retryable operations. | + +`SandboxSpec.ttl_s` overrides `create.timeout_s`. `SandboxSpec.ready_timeout_s` is a positive +per-sandbox override for the E2B create request timeout; when omitted, the connection request +timeout applies. The E2B SDK retains an explicit create request timeout on the returned +sandbox connection. The shipped `connection.request_timeout_s` is reapplied to later provider +calls; if a programmatic config leaves it unset, `ready_timeout_s` also becomes that sandbox +object's default request timeout. + +E2B 2.36+ applies command `request_timeout` only while opening an exec or reconnect stream. +Command `timeout_s` is the provider's total wait/stream budget, including reconnects. With +background execution, a timed-out stream does not guarantee that the remote process was +killed; close the sandbox before reuse when a lingering process would be unsafe. + +The public `AsyncSandbox.exec()` and `Sandbox.exec()` facades normally supply a 180-second +command timeout. `exec.default_timeout_s` is only the fallback when the provider receives +`timeout_s=None`; pass a larger timeout explicitly for long builds or test suites. + +## `SandboxSpec` Mapping + +| Field | E2B behavior | +| --- | --- | +| `image` | Untagged names matching `[A-Za-z0-9_-]+` are direct; tagged names, IDs, and OCI references require `provider_options.template` or `create.template_map`. | +| `ttl_s` | Sandbox lifetime; overrides `create.timeout_s` and must be positive. | +| `ready_timeout_s` | Create-request timeout override; must be positive. | +| `workdir` | Used by the NeMo Gym facade as the default working directory for commands. | +| `env` | Passed to E2B when the sandbox is created. | +| `files` | Uploaded by the NeMo Gym facade after creation and before `start()` returns. | +| `metadata` | Passed to E2B as string metadata. | +| `resources` | Fixed by the E2B template, not applied per sandbox. Requests warn, or raise when `create.strict_resources` is true. | +| `entrypoint` | Unsupported and rejected before creating a billable sandbox; define it in the E2B template. | +| `provider_options` | Supports only `template`; unknown or invalid values are rejected before allocation. | + +The bundled template builder can set CPU count and memory. Disk and GPU requirements need a +suitable E2B template prepared through E2B tooling. + +## Lifecycle and Reliability + +- Create is not automatically retried because an ambiguous transport failure could leave the + first, billable sandbox running while a retry creates another one. +- Rate-limit responses are returned without automatic retries so callers can choose an + appropriate backoff window. +- Close retries transient kill failures and treats an already-expired sandbox as closed. The + local handle is cleared only after E2B confirms the sandbox is killed or already absent. +- The configured TTL is a remote cleanup backstop, not a replacement for `stop()` or a context + manager. +- Background commands can reconnect by process ID after a stream interruption. Output emitted + before reconnection is not replayed, and a process that exits during the gap cannot be + recovered. Reconnects share the original command wait budget rather than restarting it. +- Serialized handles contain the E2B sandbox ID so another process can reconnect through a + separately configured `E2BProvider`. E2B's public connect operation may extend a + near-expiry sandbox to its SDK-default connection lifetime (300 seconds in E2B 2.36). + +## Security and Isolation + +E2B supplies the remote sandbox isolation boundary; a custom gateway's operator controls its +deployment and security posture. The shipped config keeps `secure: true`, which protects the +sandbox envd service with an access token. + +Outbound internet access is enabled by default for compatibility with workloads that install +dependencies. Set `create.allow_internet_access: false` for offline or untrusted workloads. +Values in `SandboxSpec.env` are injected into the remote sandbox, so pass only secrets that the +workload needs. Keep E2B API credentials in the provider connection config or host environment, +not in `SandboxSpec.env`. + +When `mini_swe_agent_2` writes its per-instance worker config, it removes +`e2b.connection.api_key`, `headers`, and `api_headers`. It passes them through the worker +environment, then reconstructs the header mappings only in worker memory. The API key stays out +of the serializable provider config and is read directly from `E2B_API_KEY` by the E2B SDK. + +NeMo Gym identifies provider and template-builder SDK traffic by adding +`nemo-gym/` to the E2B SDK `User-Agent`. An explicit custom `User-Agent` in +`connection.headers` or `connection.api_headers` takes precedence in the E2B SDK and can hide +that attribution. + +Sandbox and template operations use the E2B SDK's public high-level APIs; attribution uses +E2B's set-once integration hook. Because E2B 2.x does not expose transport injection on those +APIs, NeMo Gym replaces its module-level HTTP transport factories with an aiohttp adapter backed +by Gym's shared client session. The adapter accepts HTTP and HTTPS proxies; SOCKS proxies are +rejected explicitly. E2B's ConnectRPC command streams keep their SDK-owned pyqwest transport. diff --git a/nemo_gym/sandbox/providers/e2b/__init__.py b/nemo_gym/sandbox/providers/e2b/__init__.py new file mode 100644 index 0000000000..a97b7ebcd1 --- /dev/null +++ b/nemo_gym/sandbox/providers/e2b/__init__.py @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. + +"""E2B provider package.""" + +from nemo_gym.sandbox.providers.e2b.provider import ( + E2BConnectionConfig, + E2BCreateConfig, + E2BCreateError, + E2BExecConfig, + E2BOperationConfig, + E2BProvider, +) + + +__all__ = [ + "E2BConnectionConfig", + "E2BCreateConfig", + "E2BCreateError", + "E2BExecConfig", + "E2BOperationConfig", + "E2BProvider", +] diff --git a/nemo_gym/sandbox/providers/e2b/_sdk.py b/nemo_gym/sandbox/providers/e2b/_sdk.py new file mode 100644 index 0000000000..4b2dce4692 --- /dev/null +++ b/nemo_gym/sandbox/providers/e2b/_sdk.py @@ -0,0 +1,98 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. + +"""Lazy E2B SDK loading, traffic attribution, and async HTTP adaptation.""" + +import threading +from typing import Any + +from nemo_gym.package_info import __version__ + + +E2B_SDK_CONSTRAINT = "e2b>=2.36.0,<3.0.0" +_INTEGRATION = f"nemo-gym/{__version__}" +_CONFIGURED_SDK_MODULES: dict[int, Any] = {} +_CONFIGURE_LOCK = threading.Lock() + + +def _configure_async_http() -> None: + """Route the E2B SDK's httpx control-plane clients through Gym's aiohttp pool. + + E2B 2.x does not expose transport injection on its high-level async API, + so its two module-level factories are the narrowest available seam. The + ConnectRPC command streams use pyqwest and are unaffected. + """ + from urllib.parse import urlsplit + + import httpx + from e2b.api import client_async + from e2b.sandbox_async import main as sandbox_async + from httpx_aiohttp import AiohttpTransport + + from nemo_gym.server_utils import get_global_aiohttp_client + + class E2BAiohttpTransport(AiohttpTransport): + async def aclose(self) -> None: + # The shared session is owned and closed by server_utils. + return None + + def build_transport( + config: Any, + http2: bool = True, + *, + for_streaming: bool = False, + ) -> E2BAiohttpTransport: + # aiohttp speaks HTTP/1.1; the E2B endpoints support it. Streamed and + # regular requests share Gym's globally configured connection pool. + del http2, for_streaming + proxy = config.proxy + if proxy is not None: + proxy_url = str(proxy.url if isinstance(proxy, httpx.Proxy) else proxy) + if urlsplit(proxy_url).scheme.lower() not in {"http", "https"}: + raise ValueError("The E2B aiohttp integration requires an HTTP or HTTPS proxy URL") + proxy = httpx.Proxy(proxy_url) + return E2BAiohttpTransport( + client=get_global_aiohttp_client, + proxy=proxy, + ) + + client_async.get_transport = build_transport + client_async.get_envd_transport = build_transport + # E2B <2.46 imported this factory by value in AsyncSandbox. Newer versions + # import get_envd_api, whose module global resolves the patched factory. + if hasattr(sandbox_async, "get_transport"): + sandbox_async.get_transport = build_transport + + +def require_e2b_sdk(feature: str) -> Any: + """Import E2B lazily and attribute this process's NeMo Gym SDK traffic.""" + try: + import e2b + except ModuleNotFoundError as exc: # pragma: no cover - exercised via monkeypatch in tests + if exc.name != "e2b": + # Preserve the actual missing transitive module from a broken SDK + # installation instead of incorrectly claiming E2B is absent. + raise + raise ImportError( + f"{feature} requires the 'e2b' package. Install it with `pip install '{E2B_SDK_CONSTRAINT}'`." + ) from exc + + module_id = id(e2b) + with _CONFIGURE_LOCK: + if _CONFIGURED_SDK_MODULES.get(module_id) is not e2b: + e2b.ConnectionConfig.set_integration(_INTEGRATION) + _configure_async_http() + _CONFIGURED_SDK_MODULES[module_id] = e2b + return e2b diff --git a/nemo_gym/sandbox/providers/e2b/build.py b/nemo_gym/sandbox/providers/e2b/build.py new file mode 100644 index 0000000000..0c2531dffe --- /dev/null +++ b/nemo_gym/sandbox/providers/e2b/build.py @@ -0,0 +1,293 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. + +"""Build E2B templates from OCI images. + +E2B cannot start a sandbox from an OCI reference: ``POST /sandboxes`` accepts +only a template name or ID. Building a template from the image is the path from +an OCI reference to a running sandbox. + +This module is deliberately **outside** the sandbox public API and the +:class:`SandboxProvider` protocol. Building a template is a *provisioning* +step -- slow, one-off, and shared across runs -- whereas the provider API is +about starting and driving sandboxes. Keeping them apart means +:meth:`E2BProvider.create` never blocks on an image build, and provisioning can +run ahead of time from CI, a notebook, or the CLI below. + +Typical use: build templates once, then feed the resulting mapping into the +provider's ``create.template_map``. + + python -m nemo_gym.sandbox.providers.e2b.build \\ + --image ghcr.io/acme/task-a:1.0 --image ghcr.io/acme/task-b:1.0 \\ + --cpu-count 8 --memory-mb 16384 --output template_map.yaml +""" + +import argparse +import asyncio +import hashlib +import json +import logging +import math +import re +from collections.abc import Iterable, Sequence +from typing import Any + +from nemo_gym.sandbox.providers.e2b._sdk import require_e2b_sdk + + +LOGGER = logging.getLogger(__name__) + +# E2B template aliases accept ASCII letters, digits, hyphens and underscores. +_ALIAS_SAFE_RE = re.compile(r"[^A-Za-z0-9_-]") + +DEFAULT_CPU_COUNT = 2 +DEFAULT_MEMORY_MB = 1024 +DEFAULT_BUILD_TIMEOUT_S = 3600.0 + + +class E2BTemplateBuildError(RuntimeError): + """Raised when a template cannot be built from an image.""" + + +def _validate_resources(cpu_count: int, memory_mb: int) -> None: + for name, value in (("cpu_count", cpu_count), ("memory_mb", memory_mb)): + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ValueError(f"{name} must be a positive integer") + + +def _validate_image_and_resources(image: str, cpu_count: int, memory_mb: int) -> None: + if not isinstance(image, str) or not image.strip(): + raise ValueError("image must be a non-empty string") + _validate_resources(cpu_count, memory_mb) + + +def _require_e2b_sdk() -> Any: + return require_e2b_sdk("Building E2B templates") + + +def derive_alias(image: str, cpu_count: int = DEFAULT_CPU_COUNT, memory_mb: int = DEFAULT_MEMORY_MB) -> str: + """Return a deterministic, charset-safe alias for an image + build size. + + The digest covers the resources as well as the image because E2B bakes + cpu/memory into the template: two sandboxes wanting different sizes must + not share one, or the second silently inherits the first's sizing. + """ + _validate_image_and_resources(image, cpu_count, memory_mb) + digest = hashlib.sha256(f"{image}|cpu={cpu_count}|mem={memory_mb}".encode()).hexdigest()[:12] + stem = image.rsplit("/", 1)[-1].split("@", 1)[0].replace(":", "-") + stem = _ALIAS_SAFE_RE.sub("-", stem).strip("-") or "image" + return f"{stem[:48]}__{digest}" + + +async def template_exists(alias: str, **api_params: Any) -> bool: + """Whether ``alias`` already exists on the target deployment.""" + e2b = _require_e2b_sdk() + try: + return bool(await e2b.AsyncTemplate.exists(alias, **api_params)) + except Exception as exc: # noqa: BLE001 - SDK transport/auth errors vary by deployment + raise E2BTemplateBuildError(f"Failed to check whether e2b template {alias!r} exists: {exc}") from exc + + +async def build_template( + image: str, + *, + alias: str | None = None, + cpu_count: int = DEFAULT_CPU_COUNT, + memory_mb: int = DEFAULT_MEMORY_MB, + build_timeout_s: float = DEFAULT_BUILD_TIMEOUT_S, + registry_username: str | None = None, + registry_password: str | None = None, + skip_existing: bool = True, + on_build_logs: Any = None, + **api_params: Any, +) -> str: + """Build one template from an OCI image and return its alias.""" + _validate_image_and_resources(image, cpu_count, memory_mb) + if alias is not None and (not isinstance(alias, str) or not alias.strip()): + raise ValueError("alias must be a non-empty string when provided") + if ( + isinstance(build_timeout_s, bool) + or not isinstance(build_timeout_s, (int, float)) + or not math.isfinite(build_timeout_s) + or build_timeout_s <= 0 + ): + raise ValueError("build_timeout_s must be a positive finite number") + if (registry_username is None) != (registry_password is None) or ( + registry_username is not None and (not registry_username or not registry_password) + ): + raise ValueError("registry_username and registry_password must both be non-empty when provided") + + e2b = _require_e2b_sdk() + resolved_alias = alias or derive_alias(image, cpu_count, memory_mb) + + if skip_existing and await template_exists(resolved_alias, **api_params): + LOGGER.info("e2b template %s already exists; skipping build", resolved_alias) + return resolved_alias + + LOGGER.info( + "Building e2b template %s from image %s (cpu_count=%d, memory_mb=%d)", + resolved_alias, + image, + cpu_count, + memory_mb, + ) + builder = e2b.AsyncTemplate().from_image(image, username=registry_username, password=registry_password) + build_kwargs: dict[str, Any] = { + "name": resolved_alias, + "cpu_count": cpu_count, + "memory_mb": memory_mb, + **api_params, + } + if on_build_logs is not None: + build_kwargs["on_build_logs"] = on_build_logs + try: + await asyncio.wait_for(e2b.AsyncTemplate.build(builder, **build_kwargs), timeout=build_timeout_s) + except asyncio.TimeoutError as exc: + raise E2BTemplateBuildError( + f"Timed out after {build_timeout_s}s building e2b template {resolved_alias!r} from image {image!r}. " + "The remote build may still be running; check E2B before retrying." + ) from exc + except Exception as exc: + raise E2BTemplateBuildError( + f"Failed to build e2b template {resolved_alias!r} from image {image!r}: {exc}" + ) from exc + return resolved_alias + + +async def build_templates( + images: Iterable[str], + *, + cpu_count: int = DEFAULT_CPU_COUNT, + memory_mb: int = DEFAULT_MEMORY_MB, + concurrency: int = 4, + continue_on_error: bool = False, + **kwargs: Any, +) -> dict[str, str]: + """Build templates for many images, returning an image -> alias mapping. + + The result is exactly the shape of the provider's ``create.template_map``. + With ``continue_on_error`` the failures are logged and omitted, so one bad + image does not discard a long batch. + """ + if isinstance(concurrency, bool) or not isinstance(concurrency, int) or concurrency <= 0: + raise ValueError("concurrency must be a positive integer") + _validate_resources(cpu_count, memory_mb) + + unique_images = list(dict.fromkeys(images)) + semaphore = asyncio.Semaphore(concurrency) + abort = asyncio.Event() + mapping: dict[str, str] = {} + + async def _one(image: str) -> None: + async with semaphore: + # A failed worker sets this before releasing the semaphore, so + # queued workers cannot begin another remote provisioning call in + # the scheduling gap before gather propagates the exception. + if abort.is_set(): + return + try: + mapping[image] = await build_template(image, cpu_count=cpu_count, memory_mb=memory_mb, **kwargs) + except Exception as exc: # noqa: BLE001 - reported per image below + if not continue_on_error: + abort.set() + raise + LOGGER.error("e2b template build failed for %s: %s", image, exc) + + tasks = [asyncio.create_task(_one(image)) for image in unique_images] + try: + await asyncio.gather(*tasks) + except asyncio.CancelledError: + # Caller cancellation should remain prompt. A remote build already + # accepted by E2B may still continue even after its local task stops. + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + raise + except Exception: + # The failed worker set ``abort`` before releasing the semaphore, so + # queued workers exit without provisioning. Do not cancel workers that + # already reached E2B: an accepted remote build cannot be cancelled and + # would otherwise continue unreported. Drain them before propagating the + # first error. + await asyncio.gather(*tasks, return_exceptions=True) + raise + return {image: mapping[image] for image in unique_images if image in mapping} + + +def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--image", action="append", default=[], help="OCI image to build (repeatable).") + parser.add_argument("--images-file", help="File with one OCI image per line ('#' comments allowed).") + parser.add_argument("--cpu-count", type=int, default=DEFAULT_CPU_COUNT) + parser.add_argument("--memory-mb", type=int, default=DEFAULT_MEMORY_MB) + parser.add_argument("--concurrency", type=int, default=4) + parser.add_argument("--build-timeout-s", type=float, default=DEFAULT_BUILD_TIMEOUT_S) + parser.add_argument("--registry-username", default=None) + parser.add_argument("--registry-password", default=None) + parser.add_argument("--rebuild", action="store_true", help="Rebuild even if the alias already exists.") + parser.add_argument("--continue-on-error", action="store_true", help="Skip failures instead of aborting.") + parser.add_argument( + "--output", + help="Write the image -> alias mapping here (.yaml or .json). Defaults to stdout as JSON.", + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + args = _parse_args(argv) + + images = list(args.image) + if args.images_file: + with open(args.images_file) as handle: + images.extend(line.strip() for line in handle if line.strip() and not line.lstrip().startswith("#")) + if not images: + raise SystemExit("No images given: pass --image and/or --images-file.") + + mapping = asyncio.run( + build_templates( + images, + cpu_count=args.cpu_count, + memory_mb=args.memory_mb, + concurrency=args.concurrency, + build_timeout_s=args.build_timeout_s, + registry_username=args.registry_username, + registry_password=args.registry_password, + skip_existing=not args.rebuild, + continue_on_error=args.continue_on_error, + ) + ) + + if args.output and args.output.endswith((".yaml", ".yml")): + import yaml + + with open(args.output, "w") as handle: + yaml.safe_dump({"template_map": mapping}, handle, sort_keys=True) + elif args.output: + with open(args.output, "w") as handle: + json.dump(mapping, handle, indent=2, sort_keys=True) + else: + print(json.dumps(mapping, indent=2, sort_keys=True)) + + failed = len(dict.fromkeys(images)) - len(mapping) + if failed: + LOGGER.error("%d image(s) failed to build", failed) + return 1 + return 0 + + +if __name__ == "__main__": # pragma: no cover - CLI entry point + raise SystemExit(main()) diff --git a/nemo_gym/sandbox/providers/e2b/configs/e2b.yaml b/nemo_gym/sandbox/providers/e2b/configs/e2b.yaml new file mode 100644 index 0000000000..4c4fefe191 --- /dev/null +++ b/nemo_gym/sandbox/providers/e2b/configs/e2b.yaml @@ -0,0 +1,104 @@ +# E2B sandbox provider config. +# +# `sandbox` is the instance name an agent references via `sandbox_provider: sandbox`; +# the child key `e2b` selects the provider class and its value is passed to the +# provider constructor. +# +# Every shipped provider config binds the same name `sandbox`, so swapping providers is +# swapping this config path in `+config_paths` (no agent edit): +# +# gym env start \ +# --config responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_2.yaml \ +# --config nemo_gym/sandbox/providers/e2b/configs/e2b.yaml \ +# --config responses_api_models/vllm_model/configs/vllm_model.yaml +# +# Requires the SDK: `pip install 'e2b>=2.36.0,<3.0.0'`. +# +# Works against e2b.dev and against any e2b-compatible gateway -- point +# `connection.api_url`/`connection.sandbox_url` at the gateway. +# +# `default_metadata` (optional) is merged into every sandbox's spec metadata +# (SandboxSpec.metadata); an agent's own sandbox_spec.metadata overrides it. +sandbox: + default_metadata: + sandbox-api: e2b + e2b: + connection: + # Leave unset to use the SDK's own env vars: E2B_API_KEY, E2B_API_URL, + # E2B_SANDBOX_URL and E2B_DOMAIN. + api_key: ${oc.env:E2B_API_KEY,null} + # Self-hosted / e2b-compatible gateway. Both are usually the same host. + api_url: ${oc.env:E2B_API_URL,null} + sandbox_url: ${oc.env:E2B_SANDBOX_URL,null} + # Optional control-plane headers, including gateway bearer authentication. + # api_headers: + # Authorization: Bearer + # Control-plane request timeout. With E2B 2.36+, this also bounds opening + # command streams, not the full lifetime of a running command. This value + # is reapplied after a SandboxSpec.ready_timeout_s create override. + request_timeout_s: 120.0 + create: + # E2B starts sandboxes from a pre-built TEMPLATE NAME or ID, not a registry + # reference. The direct SandboxSpec.image shortcut accepts only untagged + # names containing letters, digits, '-' and '_' to avoid confusing tagged + # template names with OCI image references. + # + # Resolution order for each sandbox: + # 1. SandboxSpec.provider_options.template + # 2. this template_map, keyed by SandboxSpec.image + # 3. SandboxSpec.image itself, when it is an unambiguous direct name + # 4. this `template` fallback, only when SandboxSpec.image is omitted + # + # Use provider_options.template for a tagged name or template ID. Map OCI + # image references and other indirect names here, e.g.: + # template_map: + # "ghcr.io/acme/task:1.0": acme-task-1-0 + # + # Building templates from OCI images is provisioning, not part of + # starting a sandbox, so it lives outside this provider in + # nemo_gym/sandbox/providers/e2b/build.py. It emits exactly this mapping: + # python -m nemo_gym.sandbox.providers.e2b.build \ + # --image ghcr.io/acme/task:1.0 --cpu-count 8 --memory-mb 16384 \ + # --output template_map.yaml + template: null + template_map: {} + # Default sandbox lifetime. SandboxSpec.ttl_s overrides this per sandbox. + timeout_s: 3600.0 + # Secure the sandbox envd service with an access token. + secure: true + # Permit outbound internet access. Set false for offline/untrusted workloads. + allow_internet_access: true + # Resource limits are fixed when a template is built. False warns when a + # SandboxSpec requests resources; true rejects the request. + strict_resources: false + + exec: + # Fallback only when timeout_s=None. AsyncSandbox.exec() normally supplies + # its own 180s wait/stream budget; pass an explicit timeout for longer + # commands. A background process can outlive a timed-out output stream. + default_timeout_s: 180.0 + user: null + # Optional override for opening command and reconnect streams. E2B 2.36+ + # does not apply this timeout to the full command lifetime. Leave null to + # use connection.request_timeout_s. + request_timeout_s: null + # Start commands detached and reattach by pid if the output stream drops. + # The command keeps running inside the sandbox when the stream dies, so a + # gateway rollout or proxy restart mid-command no longer fails it. + # + # On by default, matching Harbor's own e2b environment, which dispatches + # every command with background=True. + # + # Reattaching is lossy, so it is a fallback rather than the happy path: + # output received before the disconnect is preserved, but the stream is + # live rather than replayed, so output emitted during the gap is lost. A + # command that finishes while disconnected cannot be recovered at all. + background: true + reconnect_attempts: 2 + operations: + # Retries for transient transport/5xx failures. Deterministic errors and + # rate limits are not retried. Command timeouts are terminal; kill + # timeouts may be retried so cleanup is not abandoned. + retries: 2 + retry_delay_s: 0.5 + retry_max_delay_s: 8.0 diff --git a/nemo_gym/sandbox/providers/e2b/provider.py b/nemo_gym/sandbox/providers/e2b/provider.py new file mode 100644 index 0000000000..5999e80394 --- /dev/null +++ b/nemo_gym/sandbox/providers/e2b/provider.py @@ -0,0 +1,681 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. + +"""Sandbox provider backed by the E2B Python SDK. + +Works against e2b.dev itself and against any e2b-compatible gateway (point +``connection.api_url``/``connection.sandbox_url`` at it). + +Two E2B concepts differ from the provider-neutral :class:`SandboxSpec` and are +handled explicitly rather than silently: + +**Templates, not images.** E2B starts sandboxes from a pre-built *template* +name or ID, not from an arbitrary registry reference. Because tagged template +names and OCI image references can both contain ``:``, the direct +``SandboxSpec.image`` shortcut is deliberately limited to unambiguous names in +``[A-Za-z0-9_-]``. Use ``provider_options.template`` or +``create.template_map`` for tagged names, IDs, and image references. + +**Resources are fixed at template build time.** ``cpu_count``/``memory_mb`` are +arguments to the template *build*, so a per-sandbox +``SandboxSpec.resources`` cannot be honoured at create time. Requests are +reported once per provider instance (or raise, with ``create.strict_resources``) +instead of being dropped quietly. +""" + +import asyncio +import logging +import math +import re +from collections.abc import Mapping +from dataclasses import dataclass, field, fields +from pathlib import Path +from time import monotonic +from typing import Any, Awaitable, Callable, TypeVar + +from nemo_gym.sandbox.providers.base import ( + SandboxCreateError, + SandboxExecResult, + SandboxHandle, + SandboxSpec, + SandboxStatus, +) +from nemo_gym.sandbox.providers.e2b._sdk import require_e2b_sdk + + +LOGGER = logging.getLogger(__name__) + +T = TypeVar("T") + +# Conservative direct-template shortcut. E2B also supports tagged names such +# as ``name:v1`` and template IDs, but those must be explicit because ``:`` and +# other punctuation overlap with OCI image syntax in ``SandboxSpec.image``. +_DIRECT_TEMPLATE_RE = re.compile(r"^[A-Za-z0-9_-]+$") + +# Passed straight through to the SDK (``ApiParams``) on every call. +_API_PARAM_KEYS = ( + "api_key", + "api_url", + "sandbox_url", + "domain", + "debug", + "validate_api_key", + "headers", + "api_headers", + "proxy", +) + + +class E2BCreateError(SandboxCreateError): + """Raised when a sandbox cannot be created.""" + + +def _require_e2b_sdk() -> Any: + """Load the optional SDK through the shared helper. + + Keep this small wrapper local so provider unit tests can replace the SDK + without importing or contacting E2B. + """ + return require_e2b_sdk("The e2b sandbox provider") + + +def _config_from_mapping(cls: type[T], value: Any) -> T: + """Build a config dataclass from a mapping, rejecting unknown keys.""" + if value is None: + return cls() + if isinstance(value, cls): + return value + if not isinstance(value, Mapping): + raise TypeError(f"{cls.__name__} expects a mapping, got {type(value).__name__}") + allowed = {f.name for f in fields(cls)} + unknown = set(value) - allowed + if unknown: + raise ValueError( + f"Unknown {cls.__name__} keys: {', '.join(sorted(unknown))}. Expected: {', '.join(sorted(allowed))}" + ) + return cls(**dict(value)) + + +def _is_finite_number(value: Any) -> bool: + return not isinstance(value, bool) and ( + isinstance(value, int) or (isinstance(value, float) and math.isfinite(value)) + ) + + +def _validate_optional_number(name: str, value: Any, *, positive: bool) -> None: + operator = "> 0" if positive else ">= 0" + if value is not None and (not _is_finite_number(value) or (value <= 0 if positive else value < 0)): + raise ValueError(f"{name} must be {operator}") + + +def _validate_nonnegative_int(name: str, value: Any) -> None: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError(f"{name} must be >= 0") + + +def _validate_nonnegative_number(name: str, value: Any) -> None: + if not _is_finite_number(value) or value < 0: + raise ValueError(f"{name} must be >= 0") + + +@dataclass(frozen=True) +class E2BConnectionConfig: + """Connection settings forwarded to the SDK. + + Any field left ``None`` falls back to the SDK's own environment variables + (``E2B_API_KEY``, ``E2B_API_URL``, ``E2B_SANDBOX_URL``, ``E2B_DOMAIN``, ...). + """ + + api_key: str | None = None + api_url: str | None = None + sandbox_url: str | None = None + domain: str | None = None + debug: bool | None = None + validate_api_key: bool | None = None + headers: dict[str, str] | None = None + api_headers: dict[str, str] | None = None + proxy: str | None = None + request_timeout_s: float | None = None + + def __post_init__(self) -> None: + _validate_optional_number("connection.request_timeout_s", self.request_timeout_s, positive=False) + + +@dataclass(frozen=True) +class E2BCreateConfig: + """Sandbox creation settings.""" + + # Template name or ID used only when SandboxSpec.image is omitted. + template: str | None = None + # Explicit ``SandboxSpec.image`` -> template name or ID mapping. Needed whenever + # image references are not themselves valid aliases (registry refs contain + # '/' and ':', which E2B rejects). + template_map: dict[str, str] = field(default_factory=dict) + # Sandbox lifetime in seconds; E2B kills the sandbox when it elapses. + # ``SandboxSpec.ttl_s`` overrides it per sandbox. + timeout_s: float | None = 3600.0 + allow_internet_access: bool = True + secure: bool = True + # Raise instead of warning when a spec requests resources E2B cannot apply + # per sandbox (they are fixed when the template is built). + strict_resources: bool = False + + def __post_init__(self) -> None: + if self.template is not None and (not isinstance(self.template, str) or not self.template.strip()): + raise ValueError("create.template must be a non-empty string when provided") + if not isinstance(self.template_map, Mapping): + raise TypeError("create.template_map must be a mapping") + for image, template in self.template_map.items(): + if not isinstance(image, str) or not image.strip(): + raise ValueError("create.template_map keys must be non-empty strings") + if not isinstance(template, str) or not template.strip(): + raise ValueError("create.template_map values must be non-empty template strings") + _validate_optional_number("create.timeout_s", self.timeout_s, positive=True) + + +@dataclass(frozen=True) +class E2BExecConfig: + """Command execution settings.""" + + # Applied when the provider receives ``timeout_s=None``. The public NeMo Gym + # sandbox facade passes its own 180-second default unless callers override it. + default_timeout_s: float | None = 180.0 + user: str | None = None + request_timeout_s: float | None = None + # Start commands detached and reattach by pid if the output stream drops. + # The command keeps running inside the sandbox when the stream dies, so its + # exit code survives a control-plane restart that would otherwise fail the + # command outright. + # + # On by default, matching Harbor's own e2b environment, which dispatches + # every command with ``background=True``. Set False to have the SDK block + # on the stream instead; note that reattaching is lossy (see + # :meth:`_run_background`). + background: bool = True + # How many times to reattach before giving up. + reconnect_attempts: int = 2 + + def __post_init__(self) -> None: + _validate_optional_number("exec.default_timeout_s", self.default_timeout_s, positive=False) + _validate_optional_number("exec.request_timeout_s", self.request_timeout_s, positive=False) + _validate_nonnegative_int("exec.reconnect_attempts", self.reconnect_attempts) + + +@dataclass(frozen=True) +class E2BOperationConfig: + """Retry policy for transient SDK/transport failures.""" + + retries: int = 2 + retry_delay_s: float = 0.5 + retry_max_delay_s: float = 8.0 + + def __post_init__(self) -> None: + _validate_nonnegative_int("operations.retries", self.retries) + _validate_nonnegative_number("operations.retry_delay_s", self.retry_delay_s) + _validate_nonnegative_number("operations.retry_max_delay_s", self.retry_max_delay_s) + + +class E2BProvider: + """Provider backed by the E2B Python SDK.""" + + name = "e2b" + + def __init__( + self, + *, + connection: E2BConnectionConfig | Mapping[str, Any] | None = None, + create: E2BCreateConfig | Mapping[str, Any] | None = None, + exec: E2BExecConfig | Mapping[str, Any] | None = None, + operations: E2BOperationConfig | Mapping[str, Any] | None = None, + ) -> None: + self._connection = _config_from_mapping(E2BConnectionConfig, connection) + self._create = _config_from_mapping(E2BCreateConfig, create) + self._exec = _config_from_mapping(E2BExecConfig, exec) + self._operations = _config_from_mapping(E2BOperationConfig, operations) + self._warned_resource_specs: set[str] = set() + + # ---------------------------------------------------------------- helpers + + def _api_params(self) -> dict[str, Any]: + """SDK ``ApiParams`` for connection-scoped calls; omitted keys fall back to env. + + Only ``create``/``connect``/``kill`` open a connection and accept these. + Everything else runs against an already-connected sandbox -- see + :meth:`_request_params`. + """ + params = {key: getattr(self._connection, key) for key in _API_PARAM_KEYS} + params = {key: value for key, value in params.items() if value is not None} + if self._connection.request_timeout_s is not None: + params["request_timeout"] = self._connection.request_timeout_s + return params + + def _request_params(self) -> dict[str, Any]: + """Per-request options for calls on an existing sandbox object. + + ``commands.run``, ``files.*`` and ``is_running`` take ``request_timeout`` + only -- the sandbox already carries the connection config, and handing + them the full ``ApiParams`` raises ``TypeError: unexpected keyword + argument 'api_key'``. + """ + if self._connection.request_timeout_s is None: + return {} + return {"request_timeout": self._connection.request_timeout_s} + + def _exec_request_timeout(self) -> float | None: + """Return the E2B 2.36 stream-open timeout for command requests.""" + if self._exec.request_timeout_s is not None: + return self._exec.request_timeout_s + return self._connection.request_timeout_s + + def _resolve_template(self, spec: SandboxSpec) -> str: + """Map a spec onto an E2B template. + + Precedence: ``provider_options.template`` -> ``create.template_map`` -> + an unambiguous direct ``spec.image``. ``create.template`` is used only + when ``spec.image`` is omitted; an unmapped image must not silently + select an unrelated fallback template. + + Building a template from an image is provisioning, not part of starting + a sandbox, so it lives in :mod:`nemo_gym.sandbox.providers.e2b.build` + and never runs on this path. + """ + options = spec.provider_options or {} + if not isinstance(options, Mapping): + raise TypeError("E2B provider_options must be a mapping") + unknown = set(options) - {"template"} + if unknown: + raise ValueError(f"Unknown E2B provider option(s): {', '.join(sorted(unknown))}. Supported: template") + option = options.get("template") + if option is not None: + if not isinstance(option, str) or not option: + raise ValueError("E2B provider option 'template' must be a non-empty string") + return option + + if spec.image: + mapped = self._create.template_map.get(spec.image) + if mapped: + return str(mapped) + if _DIRECT_TEMPLATE_RE.match(spec.image): + return spec.image + raise E2BCreateError( + f"E2B starts sandboxes from a template name or ID, but SandboxSpec.image={spec.image!r} " + "cannot be treated as an unambiguous direct template name. Map it with create.template_map, " + "set provider_options.template (including for tagged names or template IDs), or build a " + "template first with " + "nemo_gym.sandbox.providers.e2b.build (its output is a ready-made template_map)." + ) + + if self._create.template: + return self._create.template + + raise E2BCreateError( + "No E2B template to start from: set SandboxSpec.image, provider_options.template, or create.template." + ) + + def _check_resources(self, spec: SandboxSpec, template: str) -> None: + """Surface resource requests E2B cannot honour per sandbox.""" + resources = spec.resources + requested = { + name: getattr(resources, name) + for name in ("cpu", "memory_mib", "disk_gib", "gpu", "gpu_type") + if getattr(resources, name, None) is not None + } + if not requested: + return + detail = ", ".join(f"{key}={value}" for key, value in sorted(requested.items())) + message = ( + f"E2B fixes sandbox resources when the template is built, so {detail} requested for template " + f"{template!r} cannot be applied at create time. The bundled builder can set cpu_count and " + "memory_mb; disk_gib, gpu, and gpu_type require a suitable pre-built E2B template." + ) + if self._create.strict_resources: + raise E2BCreateError(message) + if template not in self._warned_resource_specs: + self._warned_resource_specs.add(template) + LOGGER.warning("%s", message) + + async def _with_retries( + self, + factory: Callable[[], Awaitable[T]], + *, + operation: str, + retry_timeouts: bool = False, + ) -> T: + """Retry transient failures with exponential backoff.""" + e2b = _require_e2b_sdk() + # Never retry these: they are deterministic and retrying only adds latency. + non_retryable_candidates = [ + getattr(e2b, "NotFoundException", None), + getattr(e2b, "SandboxNotFoundException", None), + getattr(e2b, "AuthenticationException", None), + getattr(e2b, "InvalidArgumentException", None), + # Let the caller choose a backoff window for 429s instead of + # amplifying a deployment-wide limit with fast local retries. + getattr(e2b, "RateLimitException", None), + ] + if not retry_timeouts: + non_retryable_candidates.append(getattr(e2b, "TimeoutException", None)) + non_retryable = tuple(exc for exc in non_retryable_candidates if isinstance(exc, type)) + attempts = self._operations.retries + 1 + delay = self._operations.retry_delay_s + last_exc: BaseException | None = None + for attempt in range(attempts): + try: + return await factory() + except non_retryable: + raise + except Exception as exc: # noqa: BLE001 - transport/5xx errors are provider-specific + last_exc = exc + if attempt == attempts - 1: + break + LOGGER.debug("e2b %s failed (attempt %d/%d): %s", operation, attempt + 1, attempts, exc) + await asyncio.sleep(min(delay, self._operations.retry_max_delay_s)) + delay *= 2 + assert last_exc is not None + raise last_exc + + @staticmethod + def _sandbox(handle: SandboxHandle) -> Any: + sandbox = handle.raw + if sandbox is None: + raise RuntimeError(f"Sandbox handle {handle.sandbox_id} carries no e2b sandbox object") + return sandbox + + # ------------------------------------------------------------- lifecycle + + async def create(self, spec: SandboxSpec) -> SandboxHandle: + if spec.entrypoint: + raise E2BCreateError( + "SandboxSpec.entrypoint is not supported by the e2b provider; " + "the E2B template defines the sandbox entrypoint" + ) + template = self._resolve_template(spec) + + timeout_s = spec.ttl_s if spec.ttl_s is not None else self._create.timeout_s + if timeout_s is not None and (not _is_finite_number(timeout_s) or timeout_s <= 0): + raise E2BCreateError("E2B sandbox ttl_s must be > 0") + if spec.ready_timeout_s is not None and ( + not _is_finite_number(spec.ready_timeout_s) or spec.ready_timeout_s <= 0 + ): + raise E2BCreateError("E2B sandbox ready_timeout_s must be > 0") + self._check_resources(spec, template) + kwargs: dict[str, Any] = { + "template": template, + "timeout": max(1, math.ceil(timeout_s)) if timeout_s is not None else None, + "allow_internet_access": self._create.allow_internet_access, + "secure": self._create.secure, + **self._api_params(), + } + if spec.ready_timeout_s is not None: + # E2B retains this on the returned sandbox connection. Subsequent + # provider calls explicitly reapply connection.request_timeout_s + # when configured; otherwise the SDK-retained value remains. + kwargs["request_timeout"] = float(spec.ready_timeout_s) + if spec.env: + kwargs["envs"] = {str(k): str(v) for k, v in spec.env.items()} + if spec.metadata: + kwargs["metadata"] = {str(k): str(v) for k, v in spec.metadata.items()} + + e2b = _require_e2b_sdk() + try: + # Creating a sandbox is not idempotent. Retrying an ambiguous + # transport failure can leak the first, billable sandbox. + sandbox = await e2b.AsyncSandbox.create(**kwargs) + except Exception as exc: + raise E2BCreateError(f"Failed to create e2b sandbox from template {template!r}: {exc}") from exc + + return SandboxHandle(sandbox_id=sandbox.sandbox_id, provider_name=self.name, raw=sandbox) + + async def serialize_handle(self, handle: SandboxHandle, *, scope: str | None = None) -> dict[str, Any]: + """Return a descriptor for attaching to this sandbox from another process.""" + return {"sandbox_id": handle.sandbox_id} + + async def connect(self, descriptor: Mapping[str, Any]) -> SandboxHandle: + """Attach to the sandbox described by ``descriptor``. + + E2B's public connect API applies its default sandbox timeout when none + is supplied, so attaching may renew a sandbox that is close to expiry. + """ + e2b = _require_e2b_sdk() + sandbox_id = str(descriptor["sandbox_id"]) + sandbox = await self._with_retries( + lambda: e2b.AsyncSandbox.connect(sandbox_id, **self._api_params()), + operation="connect", + ) + return SandboxHandle(sandbox_id=str(sandbox.sandbox_id), provider_name=self.name, raw=sandbox) + + async def status(self, handle: SandboxHandle) -> SandboxStatus: + e2b = _require_e2b_sdk() + sandbox = self._sandbox(handle) + not_found = tuple( + exc + for exc in (getattr(e2b, "SandboxNotFoundException", None), getattr(e2b, "NotFoundException", None)) + if isinstance(exc, type) + ) + try: + running = await sandbox.is_running(**self._request_params()) + except not_found: + return SandboxStatus.STOPPED + except Exception: # noqa: BLE001 - status must not raise for transient issues + return SandboxStatus.UNKNOWN + return SandboxStatus.RUNNING if running else SandboxStatus.STOPPED + + async def close(self, handle: SandboxHandle) -> None: + e2b = _require_e2b_sdk() + sandbox = handle.raw + if sandbox is None: + return + not_found = tuple( + exc + for exc in (getattr(e2b, "SandboxNotFoundException", None), getattr(e2b, "NotFoundException", None)) + if isinstance(exc, type) + ) + try: + killed = await self._with_retries( + lambda: sandbox.kill(**self._api_params()), + operation="kill", + retry_timeouts=True, + ) + except not_found: + # Already gone (expired TTL or killed elsewhere) - closing is idempotent. + LOGGER.debug("e2b sandbox %s already gone on close", handle.sandbox_id) + else: + if killed is False: + LOGGER.debug("e2b sandbox %s already gone on close", handle.sandbox_id) + handle.raw = None + + async def aclose(self) -> None: + """No provider-scoped client to close; sandboxes own their connections.""" + return None + + # -------------------------------------------------------------- commands + + async def _run_background(self, sandbox: Any, kwargs: dict[str, Any]) -> Any: + """Run a command detached, reattaching by pid if the stream drops. + + ``commands.run(background=True)`` returns as soon as the process has + started, handing back its pid. The command then keeps running inside + the sandbox independently of the stream carrying its output, so losing + that stream -- a gateway rollout, a proxy restart, a network blip -- + no longer destroys the command: reattach with ``commands.connect(pid)`` + and the real exit code still arrives. + + Reattaching has two inherent limits: + + * **Output emitted while disconnected is lost.** Output already + received by the previous handle is retained and combined with the + reattached stream, but the stream is live rather than replayed. + * **The process must still be running.** ``connect`` raises + not-found once it has exited, so a command that finishes during the + gap cannot be recovered. + """ + e2b = _require_e2b_sdk() + command_timeout = kwargs.get("timeout") + deadline = ( + monotonic() + float(command_timeout) + if command_timeout is not None and float(command_timeout) > 0 + else None + ) + handle = await sandbox.commands.run(**kwargs, background=True) + pid = getattr(handle, "pid", None) + stdout = "" + stderr = "" + + # Never swallow these while reattaching: a non-zero exit and a command + # timeout are real outcomes, not transport failures. + exit_exc = getattr(e2b, "CommandExitException", None) + terminal = tuple( + exc + for exc in ( + TimeoutError, + exit_exc, + getattr(e2b, "TimeoutException", None), + ) + if isinstance(exc, type) + ) + + for attempt in range(self._exec.reconnect_attempts + 1): + try: + result = await handle.wait() + result.stdout = stdout + (getattr(result, "stdout", "") or "") + result.stderr = stderr + (getattr(result, "stderr", "") or "") + return result + except terminal as exc: + if isinstance(exit_exc, type) and isinstance(exc, exit_exc): + exc.stdout = stdout + (getattr(exc, "stdout", "") or "") + exc.stderr = stderr + (getattr(exc, "stderr", "") or "") + raise + except Exception as exc: # noqa: BLE001 - transport failure; try to reattach + stdout += getattr(handle, "stdout", "") or "" + stderr += getattr(handle, "stderr", "") or "" + if pid is None or attempt >= self._exec.reconnect_attempts: + raise + LOGGER.warning( + "e2b command stream lost (pid=%s, attempt %d/%d): %s; reattaching", + pid, + attempt + 1, + self._exec.reconnect_attempts, + exc, + ) + reconnect_timeout = command_timeout + reconnect_request_timeout = kwargs.get("request_timeout") + if deadline is not None: + remaining = deadline - monotonic() + if remaining <= 0: + raise TimeoutError(f"e2b command timed out after {command_timeout}s") from exc + reconnect_timeout = remaining + if reconnect_request_timeout in (None, 0): + reconnect_request_timeout = remaining + else: + reconnect_request_timeout = min(float(reconnect_request_timeout), remaining) + try: + handle = await sandbox.commands.connect( + pid, + timeout=reconnect_timeout, + request_timeout=reconnect_request_timeout, + ) + except Exception as reconnect_exc: + # Typically not-found: the command finished while we were + # disconnected, so its result is gone. Report the original + # transport failure, which explains what actually happened. + raise exc from reconnect_exc + raise AssertionError("unreachable") # pragma: no cover + + async def exec( + self, + handle: SandboxHandle, + command: str, + *, + cwd: str | None = None, + env: dict[str, str] | None = None, + timeout_s: int | float | None = None, + user: str | int | None = None, + ) -> SandboxExecResult: + e2b = _require_e2b_sdk() + sandbox = self._sandbox(handle) + + effective_timeout = timeout_s if timeout_s is not None else self._exec.default_timeout_s + if effective_timeout is not None and (not _is_finite_number(effective_timeout) or effective_timeout < 0): + raise ValueError("e2b command timeout_s must be >= 0") + effective_user = user if user is not None else self._exec.user + kwargs: dict[str, Any] = {"cmd": command} + if cwd is not None: + kwargs["cwd"] = cwd + if env: + kwargs["envs"] = {str(k): str(v) for k, v in env.items()} + if effective_user is not None: + kwargs["user"] = str(effective_user) + # E2B treats ``timeout`` as "no timeout" when falsy; keep None explicit. + kwargs["timeout"] = float(effective_timeout) if effective_timeout is not None else None + kwargs["request_timeout"] = self._exec_request_timeout() + + timeout_exc = getattr(e2b, "TimeoutException", None) + exit_exc = getattr(e2b, "CommandExitException", None) + try: + if self._exec.background: + result = await self._run_background(sandbox, kwargs) + else: + result = await sandbox.commands.run(**kwargs) + except Exception as exc: + # A non-zero exit is a normal outcome, not a provider failure. + if isinstance(exit_exc, type) and isinstance(exc, exit_exc): + return SandboxExecResult( + stdout=getattr(exc, "stdout", None), + stderr=getattr(exc, "stderr", None), + return_code=int(getattr(exc, "exit_code", 1) or 1), + ) + # E2B uses one TimeoutException for stream-open timeouts, the + # running stream deadline, sandbox expiry and server cancellation. + # Preserve that SDK detail instead of claiming every case was the + # configured command deadline. + if isinstance(timeout_exc, type) and isinstance(exc, timeout_exc): + raise TimeoutError( + f"e2b command did not complete: {exc} (configured wait/stream budget={effective_timeout}s)" + ) from exc + raise + + return SandboxExecResult( + stdout=getattr(result, "stdout", None), + stderr=getattr(result, "stderr", None), + return_code=int(getattr(result, "exit_code", 0) or 0), + ) + + # ----------------------------------------------------------------- files + + async def write_file(self, handle: SandboxHandle, target_path: str, data: str | bytes) -> None: + sandbox = self._sandbox(handle) + await self._with_retries( + lambda: sandbox.files.write(target_path, data, **self._request_params()), + operation="write_file", + ) + + async def read_file(self, handle: SandboxHandle, source_path: str) -> bytes: + sandbox = self._sandbox(handle) + data = await self._with_retries( + lambda: sandbox.files.read(source_path, format="bytes", **self._request_params()), + operation="read_file", + ) + return data if isinstance(data, bytes) else bytes(data) + + async def upload_file(self, handle: SandboxHandle, source_path: Path, target_path: str) -> None: + source = Path(source_path) + if not source.is_file(): + raise FileNotFoundError(f"Source file not found: {source}") + await self.write_file(handle, target_path, source.read_bytes()) + + async def download_file(self, handle: SandboxHandle, source_path: str, target_path: Path) -> None: + target = Path(target_path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(await self.read_file(handle, source_path)) diff --git a/nemo_gym/sandbox/providers/registry.py b/nemo_gym/sandbox/providers/registry.py index 591b79389d..4271a2f81c 100644 --- a/nemo_gym/sandbox/providers/registry.py +++ b/nemo_gym/sandbox/providers/registry.py @@ -161,6 +161,12 @@ def _load_ecs_fargate_provider() -> ProviderClass: return EcsFargateProvider +def _load_e2b_provider() -> ProviderClass: + from nemo_gym.sandbox.providers.e2b import E2BProvider + + return E2BProvider + + def _load_openshell_provider() -> ProviderClass: from nemo_gym.sandbox.providers.openshell import OpenShellProvider @@ -170,6 +176,7 @@ def _load_openshell_provider() -> ProviderClass: _BUILTIN_PROVIDER_LOADERS["apptainer"] = _load_apptainer_provider _BUILTIN_PROVIDER_LOADERS["daytona"] = _load_daytona_provider _BUILTIN_PROVIDER_LOADERS["docker"] = _load_docker_provider +_BUILTIN_PROVIDER_LOADERS["e2b"] = _load_e2b_provider _BUILTIN_PROVIDER_LOADERS["ecs_fargate"] = _load_ecs_fargate_provider _BUILTIN_PROVIDER_LOADERS["enroot"] = _load_enroot_provider _BUILTIN_PROVIDER_LOADERS["opensandbox"] = _load_opensandbox_provider diff --git a/pyproject.toml b/pyproject.toml index a82420c08e..096922b7b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -274,6 +274,14 @@ sandbox = [ # License: Apache 2.0 https://pypi.org/project/daytona/ "daytona>=0.179.0", + # E2B SDK: used by the E2B sandbox provider for create/exec/kill and file operations. + # Also drives any e2b-compatible gateway via connection.api_url/sandbox_url. + # Lower bound 2.36.0: provider traffic attribution, current template APIs, and stream-open timeout semantics. + # Upper bound <3.0.0: the provider depends on the E2B 2.x SDK contract. + # Updated: Tue Aug 04, 2026 with e2b>=2.36.0,<3.0.0 + # License: MIT https://github.com/e2b-dev/E2B/blob/e2b@2.36.0/packages/python-sdk/LICENSE + "e2b>=2.36.0,<3.0.0", + # boto3: AWS SDK used by the ECS Fargate sandbox provider for ECS/EC2/ECR/ # CodeBuild/S3/SSM/Secrets Manager calls. # Updated: Tue Jun 03, 2026 with boto3>=1.34 diff --git a/responses_api_agents/mini_swe_agent_2/app.py b/responses_api_agents/mini_swe_agent_2/app.py index a8b127f043..f29cece3bc 100644 --- a/responses_api_agents/mini_swe_agent_2/app.py +++ b/responses_api_agents/mini_swe_agent_2/app.py @@ -55,6 +55,19 @@ OPENSANDBOX_PROVIDER_NAME = "opensandbox" OPENSANDBOX_API_KEY_ENV = "OPENSANDBOX_API_KEY" # pragma: allowlist secret +E2B_PROVIDER_NAME = "e2b" +E2B_API_KEY_ENV = "E2B_API_KEY" # pragma: allowlist secret +E2B_HEADERS_ENV = "NEMO_GYM_E2B_HEADERS" +E2B_API_HEADERS_ENV = "NEMO_GYM_E2B_API_HEADERS" + +_SANDBOX_API_KEY_ENV_BY_PROVIDER = { + OPENSANDBOX_PROVIDER_NAME: OPENSANDBOX_API_KEY_ENV, + E2B_PROVIDER_NAME: E2B_API_KEY_ENV, +} +_E2B_HEADER_ENV_BY_CONNECTION_FIELD = { + "headers": E2B_HEADERS_ENV, + "api_headers": E2B_API_HEADERS_ENV, +} class MiniSWEAgentConfig(BaseResponsesAPIAgentConfig): @@ -157,10 +170,13 @@ def _responses_create_params_to_model_kwargs( return model_kwargs -def _opensandbox_connection(provider: dict[str, Any] | None) -> dict[str, Any] | None: +def _sandbox_provider_connection( + provider: dict[str, Any] | None, + provider_name: str, +) -> dict[str, Any] | None: if provider is None: return None - provider_config = provider.get(OPENSANDBOX_PROVIDER_NAME) + provider_config = provider.get(provider_name) if not isinstance(provider_config, dict): return None connection = provider_config.get("connection") @@ -171,31 +187,63 @@ def _opensandbox_connection(provider: dict[str, Any] | None) -> dict[str, Any] | def _sandbox_provider_for_config_dump(provider: dict[str, Any]) -> dict[str, Any]: provider_for_disk = deepcopy(provider) - connection = _opensandbox_connection(provider_for_disk) - if connection is not None: - connection.pop("api_key", None) + for provider_name in _SANDBOX_API_KEY_ENV_BY_PROVIDER: + connection = _sandbox_provider_connection(provider_for_disk, provider_name) + if connection is not None: + connection.pop("api_key", None) + e2b_connection = _sandbox_provider_connection(provider_for_disk, E2B_PROVIDER_NAME) + if e2b_connection is not None: + for field_name in _E2B_HEADER_ENV_BY_CONNECTION_FIELD: + e2b_connection.pop(field_name, None) return provider_for_disk def _sandbox_runtime_env(provider: dict[str, Any] | None) -> dict[str, Any]: runtime_env: dict[str, Any] = {"py_executable": sys.executable} - connection = _opensandbox_connection(provider) - if connection is None: - return runtime_env - api_key = connection.get("api_key") - if api_key: - runtime_env["env_vars"] = {OPENSANDBOX_API_KEY_ENV: str(api_key)} + env_vars: dict[str, str] = {} + for provider_name, env_name in _SANDBOX_API_KEY_ENV_BY_PROVIDER.items(): + connection = _sandbox_provider_connection(provider, provider_name) + api_key = connection.get("api_key") if connection is not None else None + if ( + provider_name == E2B_PROVIDER_NAME + and not api_key + and provider is not None + and isinstance(provider.get(provider_name), dict) + ): + # E2B's public SDK reads E2B_API_KEY directly. Preserve that + # fallback when an inline config omits connection.api_key. + api_key = os.getenv(env_name) + if api_key: + env_vars[env_name] = str(api_key) + e2b_connection = _sandbox_provider_connection(provider, E2B_PROVIDER_NAME) + if e2b_connection is not None: + for field_name, env_name in _E2B_HEADER_ENV_BY_CONNECTION_FIELD.items(): + headers = e2b_connection.get(field_name) + if headers: + env_vars[env_name] = json.dumps(headers) + if env_vars: + runtime_env["env_vars"] = env_vars return runtime_env def _restore_sandbox_provider_secrets(config: dict[str, Any]) -> None: provider = config.get("environment", {}).get("provider") - connection = _opensandbox_connection(provider if isinstance(provider, dict) else None) - if connection is None or connection.get("api_key"): - return - api_key = os.getenv(OPENSANDBOX_API_KEY_ENV) - if api_key: - connection["api_key"] = api_key + provider = provider if isinstance(provider, dict) else None + connection = _sandbox_provider_connection(provider, OPENSANDBOX_PROVIDER_NAME) + if connection is not None and not connection.get("api_key"): + api_key = os.getenv(OPENSANDBOX_API_KEY_ENV) + if api_key: + connection["api_key"] = api_key + + e2b_connection = _sandbox_provider_connection(provider, E2B_PROVIDER_NAME) + if e2b_connection is not None: + for field_name, env_name in _E2B_HEADER_ENV_BY_CONNECTION_FIELD.items(): + value = os.getenv(env_name) + if value and not e2b_connection.get(field_name): + headers = json.loads(value) + if not isinstance(headers, dict): + raise ValueError(f"{env_name} must contain a JSON object") + e2b_connection[field_name] = headers def _bash_tool_choice() -> dict[str, Any]: diff --git a/responses_api_agents/mini_swe_agent_2/tests/test_app.py b/responses_api_agents/mini_swe_agent_2/tests/test_app.py index ece8e9db20..7729d73089 100644 --- a/responses_api_agents/mini_swe_agent_2/tests/test_app.py +++ b/responses_api_agents/mini_swe_agent_2/tests/test_app.py @@ -47,6 +47,7 @@ from responses_api_agents.mini_swe_agent_2 import app as mini_swe_app_module from responses_api_agents.mini_swe_agent_2.app import ( + E2B_API_KEY_ENV, OPENSANDBOX_API_KEY_ENV, MiniSWEAgent, MiniSWEAgentConfig, @@ -56,6 +57,7 @@ _json_dict_from_metadata, _message_content_to_text, _responses_create_params_to_model_kwargs, + _restore_sandbox_provider_secrets, _run_mini_swe_v2, _sandbox_provider_for_config_dump, _sandbox_runtime_env, @@ -372,6 +374,35 @@ def test_sandbox_provider_config_dump_strips_api_key(self) -> None: OPENSANDBOX_API_KEY_ENV: "fixture-value" # pragma: allowlist secret } + def test_e2b_sandbox_provider_secret_is_kept_out_of_config_dump(self, monkeypatch: pytest.MonkeyPatch) -> None: + headers = {"Authorization": "Bearer header-value"} # pragma: allowlist secret + api_headers = {"X-Gateway-Key": "api-header-value"} # pragma: allowlist secret + provider = { + "e2b": { + "connection": { + "api_url": "https://gateway.example", + "api_key": "fixture-value", # pragma: allowlist secret + "headers": headers, + "api_headers": api_headers, + } + } + } + + provider_for_disk = _sandbox_provider_for_config_dump(provider) + connection_for_disk = provider_for_disk["e2b"]["connection"] + assert connection_for_disk == {"api_url": "https://gateway.example"} + assert provider["e2b"]["connection"]["api_key"] == "fixture-value" # pragma: allowlist secret + runtime_env = _sandbox_runtime_env(provider) + assert runtime_env["env_vars"][E2B_API_KEY_ENV] == "fixture-value" # pragma: allowlist secret + + config = {"environment": {"provider": provider_for_disk}} + for name, value in runtime_env["env_vars"].items(): + monkeypatch.setenv(name, value) + _restore_sandbox_provider_secrets(config) + assert "api_key" not in connection_for_disk + assert connection_for_disk["headers"] == headers + assert connection_for_disk["api_headers"] == api_headers + def test_split_trajectory_and_resolution_helpers_cover_edge_cases(self) -> None: input_messages, output_items, raw_responses = _split_trajectory_for_responses( [ @@ -800,6 +831,44 @@ async def test_run_writes_generation_params_to_config( "chat_template_kwargs": {"enable_thinking": True}, } + @patch("responses_api_agents.mini_swe_agent_2.app.get_first_server_config_dict") + @patch("responses_api_agents.mini_swe_agent_2.app.get_config_path") + @patch("responses_api_agents.mini_swe_agent_2.app.runner_ray_remote") + async def test_run_keeps_e2b_api_key_out_of_worker_config( + self, + mock_runner_ray_remote, + mock_get_config_path, + mock_get_first_server_config_dict, + tmp_path, + monkeypatch, + ) -> None: + monkeypatch.chdir(tmp_path) + config = create_test_config() + config.sandbox_provider = { + "e2b": { + "connection": { + "api_url": "https://gateway.example", + "api_key": "fixture-value", # pragma: allowlist secret + }, + "create": {"template": "base"}, + } + } + mock_server_client = MagicMock(spec=ServerClient) + server = MiniSWEAgent(config=config, server_client=mock_server_client) + + setup_server_client_mocks(mock_server_client, mock_get_first_server_config_dict) + setup_config_path_mock(mock_get_config_path) + setup_run_mini_swe_mock(mock_runner_ray_remote) + + await server.run(create_run_request()) + + runtime_env = mock_runner_ray_remote.options.call_args.kwargs["runtime_env"] + assert runtime_env["env_vars"] == {E2B_API_KEY_ENV: "fixture-value"} # pragma: allowlist secret + params = mock_runner_ray_remote.options.return_value.remote.call_args.args[1] + generated_provider = yaml.safe_load(Path(params["config"]).read_text())["environment"]["provider"] + assert generated_provider["e2b"]["connection"] == {"api_url": "https://gateway.example"} + assert generated_provider["e2b"]["create"] == {"template": "base"} + @patch("responses_api_agents.mini_swe_agent_2.app.get_first_server_config_dict") @patch("responses_api_agents.mini_swe_agent_2.app.get_config_path") @patch("responses_api_agents.mini_swe_agent_2.app.runner_ray_remote") diff --git a/tests/unit_tests/test_e2b_build.py b/tests/unit_tests/test_e2b_build.py new file mode 100644 index 0000000000..5f4202f32c --- /dev/null +++ b/tests/unit_tests/test_e2b_build.py @@ -0,0 +1,358 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. + +"""Unit tests for e2b template building (SDK faked; no network).""" + +import asyncio +import json +import re +import sys +import types + +import pytest + +from nemo_gym.package_info import __version__ as nemo_gym_version +from nemo_gym.sandbox.providers.e2b import _sdk as e2b_sdk +from nemo_gym.sandbox.providers.e2b import build as e2b_build +from nemo_gym.sandbox.providers.e2b.build import ( + E2BTemplateBuildError, + build_template, + build_templates, + derive_alias, +) + + +pytestmark = pytest.mark.sandbox + +_REAL_BUILD_REQUIRE_E2B_SDK = e2b_build._require_e2b_sdk + + +class FakeTemplateBuilder: + def __init__(self, image: str, username=None, password=None) -> None: + self.image = image + self.username = username + self.password = password + + +class FakeConnectionConfig: + integrations: list[str] = [] + + @classmethod + def set_integration(cls, integration: str) -> None: + cls.integrations.append(integration) + + +class FakeTemplate: + """Records build calls; ``existing_aliases`` fakes server-side state.""" + + builds: list[dict] = [] + build_attempts: list[str] = [] + existing_aliases: set[str] = set() + build_error: Exception | None = None + build_delay_s: float = 0.0 + fail_images: set[str] = set() + max_concurrent: int = 0 + _in_flight: int = 0 + + def from_image(self, image, username=None, password=None): + return FakeTemplateBuilder(image, username, password) + + @staticmethod + async def exists(name, **kwargs): + return name in FakeTemplate.existing_aliases + + @staticmethod + async def build(builder, *, name=None, cpu_count=None, memory_mb=None, **kwargs): + FakeTemplate.build_attempts.append(builder.image) + FakeTemplate._in_flight += 1 + FakeTemplate.max_concurrent = max(FakeTemplate.max_concurrent, FakeTemplate._in_flight) + try: + if FakeTemplate.build_delay_s: + await asyncio.sleep(FakeTemplate.build_delay_s) + if FakeTemplate.build_error is not None: + raise FakeTemplate.build_error + if builder.image in FakeTemplate.fail_images: + raise RuntimeError(f"boom: {builder.image}") + FakeTemplate.builds.append( + { + "image": builder.image, + "name": name, + "cpu_count": cpu_count, + "memory_mb": memory_mb, + "username": builder.username, + "password": builder.password, + **kwargs, + } + ) + FakeTemplate.existing_aliases.add(name) + return types.SimpleNamespace(name=name) + finally: + FakeTemplate._in_flight -= 1 + + +@pytest.fixture(autouse=True) +def fake_sdk(monkeypatch: pytest.MonkeyPatch) -> None: + FakeTemplate.builds.clear() + FakeTemplate.build_attempts.clear() + FakeTemplate.existing_aliases.clear() + FakeTemplate.build_error = None + FakeTemplate.build_delay_s = 0.0 + FakeTemplate.fail_images = set() + FakeTemplate.max_concurrent = 0 + FakeTemplate._in_flight = 0 + FakeConnectionConfig.integrations.clear() + monkeypatch.setattr( + e2b_build, + "_require_e2b_sdk", + lambda: types.SimpleNamespace(AsyncTemplate=FakeTemplate, ConnectionConfig=FakeConnectionConfig), + ) + + +async def test_build_loader_sets_integration_once_per_sdk_module(monkeypatch: pytest.MonkeyPatch) -> None: + sdk_module = types.ModuleType("e2b") + sdk_module.AsyncTemplate = FakeTemplate + sdk_module.ConnectionConfig = FakeConnectionConfig + configured_transports = [] + monkeypatch.setitem(sys.modules, "e2b", sdk_module) + monkeypatch.setattr(e2b_sdk, "_CONFIGURED_SDK_MODULES", {}) + monkeypatch.setattr(e2b_sdk, "_configure_async_http", lambda: configured_transports.append(True)) + monkeypatch.setattr(e2b_build, "_require_e2b_sdk", _REAL_BUILD_REQUIRE_E2B_SDK) + + await build_template("ghcr.io/acme/task:1.0") + + assert FakeConnectionConfig.integrations == [f"nemo-gym/{nemo_gym_version}"] + assert configured_transports == [True] + + +class TestDeriveAlias: + def test_alias_is_charset_safe_and_traceable(self) -> None: + alias = derive_alias("ghcr.io/acme/my_task:1.0", 8, 16384) + assert re.fullmatch(r"[A-Za-z0-9_-]+", alias), "E2B rejects anything outside [A-Za-z0-9_-]" + assert alias.startswith("my_task-1-0__") + + def test_alias_is_deterministic(self) -> None: + assert derive_alias("img:1", 2, 1024) == derive_alias("img:1", 2, 1024) + + def test_resources_change_the_alias(self) -> None: + # E2B bakes cpu/memory into the template, so differing requests must + # not collide onto one alias. + assert derive_alias("img:1", 2, 1024) != derive_alias("img:1", 8, 1024) + assert derive_alias("img:1", 2, 1024) != derive_alias("img:1", 2, 16384) + + def test_digest_disambiguates_same_stem_from_different_repos(self) -> None: + a = derive_alias("ghcr.io/one/task:1.0", 2, 1024) + b = derive_alias("ghcr.io/two/task:1.0", 2, 1024) + assert a.split("__")[0] == b.split("__")[0] + assert a != b + + def test_handles_digest_references_and_odd_characters(self) -> None: + alias = derive_alias("registry.io/org/img@sha256:abc123", 2, 1024) + assert re.fullmatch(r"[A-Za-z0-9_-]+", alias) + + @pytest.mark.parametrize( + ("image", "cpu_count", "memory_mb", "message"), + [ + ("", 2, 1024, "image must be a non-empty string"), + ("img:1", 0, 1024, "cpu_count must be a positive integer"), + ("img:1", 2, -1, "memory_mb must be a positive integer"), + ], + ) + def test_rejects_invalid_inputs(self, image: str, cpu_count: int, memory_mb: int, message: str) -> None: + with pytest.raises(ValueError, match=message): + derive_alias(image, cpu_count, memory_mb) + + +class TestBuildTemplate: + async def test_builds_and_returns_alias(self) -> None: + alias = await build_template("ghcr.io/acme/task:1.0", cpu_count=8, memory_mb=16384) + assert alias == derive_alias("ghcr.io/acme/task:1.0", 8, 16384) + build = FakeTemplate.builds[0] + assert build["image"] == "ghcr.io/acme/task:1.0" + assert (build["cpu_count"], build["memory_mb"]) == (8, 16384) + + async def test_explicit_alias_is_used(self) -> None: + alias = await build_template("ghcr.io/acme/task:1.0", alias="my-alias") + assert alias == "my-alias" + assert FakeTemplate.builds[0]["name"] == "my-alias" + + async def test_existing_template_is_skipped(self) -> None: + alias = derive_alias("ghcr.io/acme/task:1.0") + FakeTemplate.existing_aliases.add(alias) + assert await build_template("ghcr.io/acme/task:1.0") == alias + assert FakeTemplate.builds == [] + + async def test_rebuild_when_skip_existing_false(self) -> None: + alias = derive_alias("ghcr.io/acme/task:1.0") + FakeTemplate.existing_aliases.add(alias) + await build_template("ghcr.io/acme/task:1.0", skip_existing=False) + assert len(FakeTemplate.builds) == 1 + + async def test_registry_credentials_forwarded(self) -> None: + await build_template("ghcr.io/acme/task:1.0", registry_username="u", registry_password="p") + assert (FakeTemplate.builds[0]["username"], FakeTemplate.builds[0]["password"]) == ("u", "p") + + async def test_api_params_forwarded(self) -> None: + await build_template("ghcr.io/acme/task:1.0", api_key="k", api_url="http://gw:8080") + assert FakeTemplate.builds[0]["api_key"] == "k" + assert FakeTemplate.builds[0]["api_url"] == "http://gw:8080" + + async def test_failure_is_wrapped(self) -> None: + FakeTemplate.build_error = RuntimeError("builder offline") + with pytest.raises(E2BTemplateBuildError, match="builder offline"): + await build_template("ghcr.io/acme/task:1.0") + + async def test_timeout_is_reported(self) -> None: + FakeTemplate.build_delay_s = 0.2 + with pytest.raises(E2BTemplateBuildError, match="Timed out"): + await build_template("ghcr.io/acme/task:1.0", build_timeout_s=0.01) + + async def test_template_exists_failure_aborts_before_build(self, monkeypatch: pytest.MonkeyPatch) -> None: + async def boom(name, **kwargs): + raise RuntimeError("gateway down") + + monkeypatch.setattr(FakeTemplate, "exists", boom) + with pytest.raises(E2BTemplateBuildError, match="gateway down"): + await build_template("ghcr.io/acme/task:1.0") + assert FakeTemplate.builds == [] + + @pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"alias": ""}, "alias must be a non-empty string"), + ({"build_timeout_s": 0}, "build_timeout_s must be a positive finite number"), + ({"build_timeout_s": float("nan")}, "build_timeout_s must be a positive finite number"), + ({"registry_username": "user"}, "registry_username and registry_password must both be non-empty"), + ({"registry_username": "", "registry_password": ""}, "must both be non-empty"), + ], + ) + async def test_invalid_inputs_fail_before_loading_sdk( + self, + monkeypatch: pytest.MonkeyPatch, + kwargs: dict[str, object], + message: str, + ) -> None: + monkeypatch.setattr(e2b_build, "_require_e2b_sdk", lambda: pytest.fail("SDK should not be loaded")) + with pytest.raises(ValueError, match=message): + await build_template("ghcr.io/acme/task:1.0", **kwargs) + assert FakeTemplate.builds == [] + + +class TestBuildTemplates: + async def test_returns_image_to_alias_mapping(self) -> None: + images = ["ghcr.io/acme/a:1", "ghcr.io/acme/b:1"] + mapping = await build_templates(images, cpu_count=4, memory_mb=2048) + assert set(mapping) == set(images) + assert mapping["ghcr.io/acme/a:1"] == derive_alias("ghcr.io/acme/a:1", 4, 2048) + assert len(FakeTemplate.builds) == 2 + + async def test_duplicate_images_are_built_once(self) -> None: + mapping = await build_templates(["img:1", "img:1", "img:1"]) + assert len(mapping) == 1 + assert len(FakeTemplate.builds) == 1 + + async def test_concurrency_is_bounded(self) -> None: + FakeTemplate.build_delay_s = 0.01 + await build_templates([f"img:{i}" for i in range(8)], concurrency=3) + assert FakeTemplate.max_concurrent <= 3 + assert len(FakeTemplate.builds) == 8 + + async def test_failure_aborts_by_default(self) -> None: + FakeTemplate.fail_images = {"img:2"} + with pytest.raises(E2BTemplateBuildError): + await build_templates(["img:1", "img:2"], concurrency=1) + + async def test_failure_stops_queued_sibling_builds(self) -> None: + FakeTemplate.fail_images = {"img:1"} + FakeTemplate.build_delay_s = 0.01 + + with pytest.raises(E2BTemplateBuildError): + await build_templates(["img:1", "img:2", "img:3"], concurrency=1) + + # A library caller's event loop stays alive after the error. Give any + # leaked gather tasks time to run; correctly stopped siblings do not. + await asyncio.sleep(0.03) + assert FakeTemplate.build_attempts == ["img:1"] + assert FakeTemplate.builds == [] + assert FakeTemplate._in_flight == 0 + + async def test_failure_drains_already_started_sibling_builds(self) -> None: + FakeTemplate.fail_images = {"img:1"} + FakeTemplate.build_delay_s = 0.01 + + with pytest.raises(E2BTemplateBuildError): + await build_templates(["img:1", "img:2", "img:3"], concurrency=2) + + assert set(FakeTemplate.build_attempts) == {"img:1", "img:2"} + assert [build["image"] for build in FakeTemplate.builds] == ["img:2"] + assert FakeTemplate._in_flight == 0 + + async def test_continue_on_error_omits_failures(self) -> None: + # One bad image must not discard a long batch. + FakeTemplate.fail_images = {"img:2"} + mapping = await build_templates(["img:1", "img:2", "img:3"], concurrency=1, continue_on_error=True) + assert set(mapping) == {"img:1", "img:3"} + + @pytest.mark.parametrize("concurrency", [0, -1, True]) + async def test_invalid_concurrency_is_rejected(self, concurrency: int) -> None: + with pytest.raises(ValueError, match="concurrency must be a positive integer"): + await build_templates(["img:1"], concurrency=concurrency) + assert FakeTemplate.builds == [] + + +class TestCli: + def test_writes_json_mapping(self, tmp_path) -> None: + out = tmp_path / "map.json" + rc = e2b_build.main(["--image", "ghcr.io/acme/a:1", "--output", str(out)]) + assert rc == 0 + assert json.loads(out.read_text()) == {"ghcr.io/acme/a:1": derive_alias("ghcr.io/acme/a:1")} + + def test_writes_yaml_template_map(self, tmp_path) -> None: + yaml = pytest.importorskip("yaml") + out = tmp_path / "map.yaml" + assert e2b_build.main(["--image", "ghcr.io/acme/a:1", "--output", str(out)]) == 0 + loaded = yaml.safe_load(out.read_text()) + # Shaped so it can be pasted straight under the provider's create block. + assert list(loaded) == ["template_map"] + assert loaded["template_map"] == {"ghcr.io/acme/a:1": derive_alias("ghcr.io/acme/a:1")} + + def test_reads_images_file_ignoring_comments(self, tmp_path) -> None: + images_file = tmp_path / "images.txt" + images_file.write_text("# comment\nghcr.io/acme/a:1\n\nghcr.io/acme/b:1\n") + out = tmp_path / "map.json" + assert e2b_build.main(["--images-file", str(images_file), "--output", str(out)]) == 0 + assert set(json.loads(out.read_text())) == {"ghcr.io/acme/a:1", "ghcr.io/acme/b:1"} + + def test_requires_at_least_one_image(self) -> None: + with pytest.raises(SystemExit): + e2b_build.main([]) + + def test_nonzero_exit_when_a_build_fails(self, tmp_path) -> None: + FakeTemplate.fail_images = {"ghcr.io/acme/b:1"} + out = tmp_path / "map.json" + rc = e2b_build.main( + [ + "--image", + "ghcr.io/acme/a:1", + "--image", + "ghcr.io/acme/b:1", + "--continue-on-error", + "--concurrency", + "1", + "--output", + str(out), + ] + ) + assert rc == 1 + assert set(json.loads(out.read_text())) == {"ghcr.io/acme/a:1"} diff --git a/tests/unit_tests/test_e2b_provider.py b/tests/unit_tests/test_e2b_provider.py new file mode 100644 index 0000000000..8ca34a24a0 --- /dev/null +++ b/tests/unit_tests/test_e2b_provider.py @@ -0,0 +1,1050 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. + +"""Unit tests for the e2b sandbox provider (SDK faked; no network).""" + +import inspect +import re +import sys +import types +from importlib.metadata import version +from pathlib import Path + +import pytest + +from nemo_gym.package_info import __version__ as nemo_gym_version +from nemo_gym.sandbox import AsyncSandbox, ConnectableProvider +from nemo_gym.sandbox.providers import e2b as e2b_pkg +from nemo_gym.sandbox.providers.base import SandboxHandle, SandboxSpec, SandboxStatus +from nemo_gym.sandbox.providers.e2b import _sdk as e2b_sdk +from nemo_gym.sandbox.providers.e2b import provider as e2b_provider +from nemo_gym.sandbox.providers.e2b.provider import _API_PARAM_KEYS, E2BCreateError, E2BProvider +from nemo_gym.sandbox.providers.registry import get_provider_class + + +pytestmark = pytest.mark.sandbox + +_REAL_PROVIDER_REQUIRE_E2B_SDK = e2b_provider._require_e2b_sdk + + +# -------------------------------------------------------------------------- +# Fake e2b SDK +# -------------------------------------------------------------------------- + + +class FakeSandboxNotFound(Exception): + pass + + +class FakeTimeout(Exception): + pass + + +class FakeRateLimit(Exception): + pass + + +class FakeCommandExit(Exception): + def __init__(self, exit_code: int, stdout: str = "", stderr: str = "") -> None: + super().__init__(f"exit {exit_code}") + self.exit_code = exit_code + self.stdout = stdout + self.stderr = stderr + + +class FakeCommandResult: + def __init__(self, stdout: str = "", stderr: str = "", exit_code: int = 0) -> None: + self.stdout = stdout + self.stderr = stderr + self.exit_code = exit_code + + +class FakeConnectionConfig: + integrations: list[str] = [] + + @classmethod + def set_integration(cls, integration: str) -> None: + cls.integrations.append(integration) + + +# The real SDK's sandbox-scoped methods take `request_timeout` only -- they are +# bound to an already-connected sandbox. Mirroring that here (rather than a +# permissive **kwargs) is what catches connection params leaking onto them: +# the SDK raises `TypeError: unexpected keyword argument 'api_key'`. +_SANDBOX_SCOPED_ALLOWED = {"request_timeout"} + + +def _reject_connection_params(method: str, kwargs: dict) -> None: + leaked = sorted((set(kwargs) & set(_API_PARAM_KEYS)) - _SANDBOX_SCOPED_ALLOWED) + if leaked: + raise TypeError(f"{method}() got an unexpected keyword argument {leaked[0]!r}") + + +class FakeCommandHandle: + """Background handle: `wait()` replays a scripted sequence of outcomes. + + Falls back to the sandbox's `exec_behaviour` so a test can script an + outcome once and have it apply in either exec mode. + """ + + def __init__(self, pid: int, outcomes: list, fallback=None) -> None: + self.pid = pid + self._outcomes = outcomes + self._fallback = fallback + self.waits = 0 + self.stdout = "" + self.stderr = "" + + async def wait(self): + self.waits += 1 + if self._outcomes: + outcome = self._outcomes.pop(0) + else: + outcome = self._fallback or FakeCommandResult(stdout="ok") + if isinstance(outcome, Exception): + self.stdout = getattr(outcome, "stdout", "") + self.stderr = getattr(outcome, "stderr", "") + raise outcome + return outcome + + +class FakeCommands: + def __init__(self, sandbox: "FakeSandbox") -> None: + self._sandbox = sandbox + + async def run(self, **kwargs): + _reject_connection_params("Commands.run", kwargs) + self._sandbox.exec_calls.append(kwargs) + behaviour = self._sandbox.exec_behaviour + if kwargs.get("background"): + return FakeCommandHandle(self._sandbox.pid, self._sandbox.wait_outcomes, behaviour) + if isinstance(behaviour, Exception): + raise behaviour + return behaviour or FakeCommandResult(stdout="ok") + + async def connect(self, pid, timeout=None, request_timeout=None): + self._sandbox.connect_calls.append({"pid": pid, "timeout": timeout, "request_timeout": request_timeout}) + if self._sandbox.connect_error is not None: + raise self._sandbox.connect_error + return FakeCommandHandle(pid, self._sandbox.wait_outcomes, self._sandbox.exec_behaviour) + + +class FakeFiles: + def __init__(self, sandbox: "FakeSandbox") -> None: + self._sandbox = sandbox + + async def write(self, path, data, **kwargs): + _reject_connection_params("Filesystem.write", kwargs) + self._sandbox.file_write_calls.append({"path": path, "data": data, **kwargs}) + self._sandbox.files_written[path] = data + return None + + async def read(self, path, **kwargs): + _reject_connection_params("Filesystem.read", kwargs) + if path not in self._sandbox.files_written: + raise FakeSandboxNotFound(path) + data = self._sandbox.files_written[path] + return data if isinstance(data, bytes) else str(data).encode() + + +class FakeSandbox: + instances: list["FakeSandbox"] = [] + + def __init__(self, sandbox_id: str = "sbx-1", **create_kwargs) -> None: + self.sandbox_id = sandbox_id + self.create_kwargs = create_kwargs + self.exec_calls: list[dict] = [] + self.file_write_calls: list[dict] = [] + self.files_written: dict[str, object] = {} + self.killed = False + self.kill_calls: list[dict] = [] + self.kill_outcomes: list[object] = [] + self.running = True + self.exec_behaviour = None + self.pid = 4242 + self.wait_outcomes: list = [] + self.connect_calls: list[dict] = [] + self.connect_error = None + self.commands = FakeCommands(self) + self.files = FakeFiles(self) + FakeSandbox.instances.append(self) + + @classmethod + async def create(cls, **kwargs): + return cls(**kwargs) + + @classmethod + async def connect(cls, sandbox_id, **kwargs): + return cls(sandbox_id=sandbox_id, **kwargs) + + async def is_running(self, **kwargs): + _reject_connection_params("Sandbox.is_running", kwargs) + return self.running + + async def kill(self, **kwargs): + self.kill_calls.append(kwargs) + if self.kill_outcomes: + outcome = self.kill_outcomes.pop(0) + if isinstance(outcome, BaseException): + raise outcome + if outcome is False: + return False + self.killed = True + return True + + +def _fake_sdk() -> types.SimpleNamespace: + return types.SimpleNamespace( + AsyncSandbox=FakeSandbox, + ConnectionConfig=FakeConnectionConfig, + SandboxNotFoundException=FakeSandboxNotFound, + NotFoundException=FakeSandboxNotFound, + TimeoutException=FakeTimeout, + RateLimitException=FakeRateLimit, + CommandExitException=FakeCommandExit, + AuthenticationException=type("FakeAuth", (Exception,), {}), + InvalidArgumentException=type("FakeInvalid", (Exception,), {}), + ) + + +def _fake_sdk_module() -> types.ModuleType: + module = types.ModuleType("e2b") + module.__dict__.update(vars(_fake_sdk())) + return module + + +@pytest.fixture(autouse=True) +def fake_e2b_sdk(monkeypatch: pytest.MonkeyPatch) -> None: + FakeSandbox.instances.clear() + FakeConnectionConfig.integrations.clear() + monkeypatch.setattr(e2b_provider, "_require_e2b_sdk", _fake_sdk) + + +def _spec(**kwargs) -> SandboxSpec: + return SandboxSpec(**kwargs) + + +# -------------------------------------------------------------------------- +# Registration +# -------------------------------------------------------------------------- + + +def test_provider_is_registered_as_builtin() -> None: + assert get_provider_class("e2b") is E2BProvider + assert E2BProvider.name == "e2b" + assert e2b_pkg.E2BProvider is E2BProvider + + +async def test_runtime_loader_sets_integration_once_per_sdk_module(monkeypatch: pytest.MonkeyPatch) -> None: + sdk_module = _fake_sdk_module() + configured_transports = [] + monkeypatch.setitem(sys.modules, "e2b", sdk_module) + monkeypatch.setattr(e2b_sdk, "_CONFIGURED_SDK_MODULES", {}) + monkeypatch.setattr(e2b_sdk, "_configure_async_http", lambda: configured_transports.append(True)) + monkeypatch.setattr(e2b_provider, "_require_e2b_sdk", _REAL_PROVIDER_REQUIRE_E2B_SDK) + + provider = E2BProvider(create={"template": "base"}) + handle = await provider.create(_spec()) + assert await provider.status(handle) is SandboxStatus.RUNNING + + assert FakeConnectionConfig.integrations == [f"nemo-gym/{nemo_gym_version}"] + assert configured_transports == [True] + + +async def test_runtime_loader_routes_e2b_httpx_through_global_aiohttp(monkeypatch: pytest.MonkeyPatch) -> None: + import httpx + from httpx_aiohttp import AiohttpTransport + + from nemo_gym.server_utils import get_global_aiohttp_client + + sdk_module = _fake_sdk_module() + sdk_module.__path__ = [] + api_module = types.ModuleType("e2b.api") + api_module.__path__ = [] + client_async_module = types.ModuleType("e2b.api.client_async") + client_async_module.limits = httpx.Limits(max_connections=10) + client_async_module.connection_retries = 2 + client_async_module.get_transport = lambda config, http2=True: object() + client_async_module.get_envd_transport = lambda config, http2=True: object() + sandbox_async_module = types.ModuleType("e2b.sandbox_async") + sandbox_async_module.__path__ = [] + sandbox_main_module = types.ModuleType("e2b.sandbox_async.main") + sandbox_main_module.get_transport = client_async_module.get_envd_transport + + sdk_module.api = api_module + sdk_module.sandbox_async = sandbox_async_module + api_module.client_async = client_async_module + sandbox_async_module.main = sandbox_main_module + for name, module in { + "e2b": sdk_module, + "e2b.api": api_module, + "e2b.api.client_async": client_async_module, + "e2b.sandbox_async": sandbox_async_module, + "e2b.sandbox_async.main": sandbox_main_module, + }.items(): + monkeypatch.setitem(sys.modules, name, module) + monkeypatch.setattr(e2b_sdk, "_CONFIGURED_SDK_MODULES", {}) + + e2b_sdk.require_e2b_sdk("Testing the e2b provider") + config = types.SimpleNamespace(proxy=None) + control_transport = client_async_module.get_transport(config) + envd_transport = sandbox_main_module.get_transport(config) + + assert isinstance(control_transport, AiohttpTransport) + assert isinstance(envd_transport, AiohttpTransport) + assert control_transport.client is get_global_aiohttp_client + assert envd_transport.client is get_global_aiohttp_client + with pytest.raises(ValueError, match="HTTP or HTTPS proxy"): + client_async_module.get_transport(types.SimpleNamespace(proxy="socks5h://proxy.example:1080")) + + +def test_loader_reports_missing_optional_sdk(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem(sys.modules, "e2b", None) + + with pytest.raises(ImportError, match=r"pip install 'e2b>=2\.36\.0,<3\.0\.0'"): + e2b_sdk.require_e2b_sdk("Testing the e2b provider") + + +async def test_real_sdk_user_agent_and_call_shapes() -> None: + e2b = pytest.importorskip("e2b", reason="e2b optional sandbox dependency is not installed") + from e2b.api import client_async + from e2b.sandbox_async import main as sandbox_async + from httpx_aiohttp import AiohttpTransport + + from nemo_gym.server_utils import get_global_aiohttp_client + + installed_match = re.match(r"^(\d+)\.(\d+)", version("e2b")) + assert installed_match is not None + assert (int(installed_match[1]), int(installed_match[2])) >= (2, 36) + assert int(installed_match[1]) < 3 + assert set(_API_PARAM_KEYS) <= set(e2b.ApiParams.__annotations__) + + e2b_sdk.require_e2b_sdk("Testing the e2b provider") + connection = e2b.ConnectionConfig() + products = connection.headers["User-Agent"].split() + assert f"nemo-gym/{nemo_gym_version}" in products + envd_client = sandbox_async.get_envd_api(connection, "https://sandbox.example") + for transport in (client_async.get_transport(connection), envd_client._transport): + assert isinstance(transport, AiohttpTransport) + assert transport.client is get_global_aiohttp_client + await envd_client.aclose() + + inspect.signature(e2b.AsyncSandbox.create).bind( + template="base", + timeout=60, + request_timeout=30, + envs={}, + metadata={}, + secure=True, + allow_internet_access=True, + ) + inspect.signature(e2b.AsyncSandbox.connect).bind("sbx-existing", request_timeout=30) + inspect.signature(e2b.AsyncSandbox.kill).bind( + object(), + **dict.fromkeys(_API_PARAM_KEYS), + request_timeout=30, + ) + inspect.signature(e2b.AsyncSandbox.is_running).bind(object(), request_timeout=30) + inspect.signature(e2b.AsyncTemplate.exists).bind("base") + inspect.signature(e2b.AsyncTemplate.build).bind( + object(), + name="base", + cpu_count=2, + memory_mb=1024, + ) + + +# -------------------------------------------------------------------------- +# Template resolution -- E2B starts from an alias, not a registry reference +# -------------------------------------------------------------------------- + + +class TestTemplateResolution: + async def test_provider_options_template_wins(self) -> None: + provider = E2BProvider(create={"template": "fallback"}) + handle = await provider.create(_spec(image="also-valid", provider_options={"template": "chosen"})) + assert handle.raw.create_kwargs["template"] == "chosen" + + async def test_template_map_translates_registry_reference(self) -> None: + provider = E2BProvider(create={"template_map": {"ghcr.io/acme/task:1.0": "acme-task"}}) + handle = await provider.create(_spec(image="ghcr.io/acme/task:1.0")) + assert handle.raw.create_kwargs["template"] == "acme-task" + + async def test_image_used_directly_when_already_an_alias(self) -> None: + provider = E2BProvider() + handle = await provider.create(_spec(image="build-cython-ext__c9fba49d4bd3")) + assert handle.raw.create_kwargs["template"] == "build-cython-ext__c9fba49d4bd3" + + async def test_falls_back_to_configured_template(self) -> None: + provider = E2BProvider(create={"template": "base-template"}) + handle = await provider.create(_spec()) + assert handle.raw.create_kwargs["template"] == "base-template" + + async def test_registry_reference_without_mapping_does_not_use_fallback(self) -> None: + # Silently starting the wrong template would corrupt a benchmark run, + # so an unmappable image must fail loudly. + provider = E2BProvider(create={"template": "fallback"}) + with pytest.raises(E2BCreateError, match="template_map"): + await provider.create(_spec(image="ghcr.io/acme/task:1.0")) + assert FakeSandbox.instances == [] + + async def test_no_template_at_all_raises(self) -> None: + provider = E2BProvider() + with pytest.raises(E2BCreateError, match="No E2B template"): + await provider.create(_spec()) + + +# -------------------------------------------------------------------------- +# Resources -- fixed at template build time, must not be dropped silently +# -------------------------------------------------------------------------- + + +class TestResourceHandling: + async def test_resource_request_warns_once_per_template(self, caplog) -> None: + provider = E2BProvider(create={"template": "base"}) + with caplog.at_level("WARNING"): + await provider.create( + _spec( + resources={ + "cpu": 8, + "memory_mib": 16384, + "disk_gib": 100, + "gpu": 1, + "gpu_type": "H100", + } + ) + ) + await provider.create(_spec(resources={"cpu": 8})) + warnings = [r for r in caplog.records if "fixes sandbox resources" in r.message] + assert len(warnings) == 1 + for detail in ("cpu=8", "memory_mib=16384", "disk_gib=100", "gpu=1", "gpu_type=H100"): + assert detail in warnings[0].message + + @pytest.mark.parametrize( + "resources", + [ + {"cpu": 1}, + {"memory_mib": 1024}, + {"disk_gib": 10}, + {"gpu": 1}, + {"gpu_type": "H100"}, + ], + ) + async def test_strict_resources_rejects_every_resource(self, resources: dict[str, object]) -> None: + provider = E2BProvider(create={"template": "base", "strict_resources": True}) + with pytest.raises(E2BCreateError, match="fixes sandbox resources"): + await provider.create(_spec(resources=resources)) + assert FakeSandbox.instances == [] + + async def test_no_warning_without_resource_request(self, caplog) -> None: + provider = E2BProvider(create={"template": "base"}) + with caplog.at_level("WARNING"): + await provider.create(_spec()) + assert not [r for r in caplog.records if "fixes sandbox resources" in r.message] + + +# -------------------------------------------------------------------------- +# Connection params are connection-scoped, not per-call +# -------------------------------------------------------------------------- + + +class TestConnectionParamScoping: + """Only create/connect/kill open a connection and accept ``ApiParams``. + + ``commands.run``, ``files.*`` and ``is_running`` run against an + already-connected sandbox and take ``request_timeout`` only. Passing them + the full set raises ``TypeError: Commands.run() got an unexpected keyword + argument 'api_key'`` -- which aborted every trial of a benchmark run at the + first exec, since the sandbox had already been created successfully. + """ + + @staticmethod + def _provider() -> E2BProvider: + return E2BProvider( + connection={"api_key": "k", "api_url": "http://gw:8080", "request_timeout_s": 30.0}, + create={"template": "base"}, + ) + + async def test_exec_passes_only_request_timeout(self) -> None: + provider = self._provider() + handle = await provider.create(_spec()) + await provider.exec(handle, "echo hi") + kwargs = handle.raw.exec_calls[0] + assert not set(kwargs) & set(_API_PARAM_KEYS), "connection params must not reach commands.run" + assert kwargs["request_timeout"] == 30.0 + + async def test_file_and_status_calls_do_not_leak_connection_params(self) -> None: + provider = self._provider() + handle = await provider.create(_spec()) + # Each of these would raise TypeError from the fake (as the real SDK does). + await provider.write_file(handle, "/tmp/f", b"data") + assert await provider.read_file(handle, "/tmp/f") == b"data" + assert await provider.status(handle) is SandboxStatus.RUNNING + + async def test_command_and_request_timeouts_are_independent(self) -> None: + # In E2B 2.36 request_timeout bounds opening the command stream, while + # timeout bounds the running output stream/wait, not the remote process. + provider = E2BProvider( + connection={"api_key": "k", "request_timeout_s": 30.0}, + create={"template": "base"}, + ) + handle = await provider.create(_spec()) + await provider.exec(handle, "make -j8", timeout_s=1800) + kwargs = handle.raw.exec_calls[0] + assert kwargs["timeout"] == 1800.0 + assert kwargs["request_timeout"] == 30.0 + + async def test_exec_request_timeout_override_is_honoured(self) -> None: + provider = E2BProvider( + connection={"request_timeout_s": 30.0}, + create={"template": "base"}, + exec={"request_timeout_s": 900.0}, + ) + handle = await provider.create(_spec()) + await provider.exec(handle, "sleep 1", timeout_s=60) + assert handle.raw.exec_calls[0]["request_timeout"] == 900.0 + + async def test_untimed_command_keeps_the_connection_request_timeout(self) -> None: + provider = E2BProvider( + connection={"request_timeout_s": 30.0}, + create={"template": "base"}, + exec={"default_timeout_s": None}, + ) + handle = await provider.create(_spec()) + await provider.exec(handle, "sleep forever") + kwargs = handle.raw.exec_calls[0] + assert kwargs["timeout"] is None + assert kwargs["request_timeout"] == 30.0 + + async def test_create_still_receives_full_connection_params(self) -> None: + # The narrowing must not strip params from the call that needs them. + provider = self._provider() + handle = await provider.create(_spec()) + assert handle.raw.create_kwargs["api_key"] == "k" + assert handle.raw.create_kwargs["api_url"] == "http://gw:8080" + + +# -------------------------------------------------------------------------- +# Background exec -- survive a lost output stream +# -------------------------------------------------------------------------- + + +class TestBackgroundExec: + """A dropped stream must not destroy a command that is still running. + + `run(background=True)` returns once the process has started, so the command + outlives the stream carrying its output and can be reattached by pid. + """ + + @staticmethod + def _provider(**exec_opts) -> E2BProvider: + opts = {"background": True, **exec_opts} + return E2BProvider(create={"template": "base"}, exec=opts) + + async def test_on_by_default(self) -> None: + # Matches Harbor's own e2b environment, which always dispatches with + # background=True. + provider = E2BProvider(create={"template": "base"}) + handle = await provider.create(_spec()) + await provider.exec(handle, "echo hi") + assert handle.raw.exec_calls[0]["background"] is True + + async def test_can_be_turned_off(self) -> None: + provider = E2BProvider(create={"template": "base"}, exec={"background": False}) + handle = await provider.create(_spec()) + await provider.exec(handle, "echo hi") + assert "background" not in handle.raw.exec_calls[0] + + async def test_background_flag_is_sent(self) -> None: + provider = self._provider() + handle = await provider.create(_spec()) + result = await provider.exec(handle, "echo hi") + assert handle.raw.exec_calls[0]["background"] is True + assert result.return_code == 0 + + async def test_lost_stream_is_reattached_by_pid(self, monkeypatch: pytest.MonkeyPatch) -> None: + monotonic_values = iter([100.0, 105.0]) + monkeypatch.setattr(e2b_provider, "monotonic", monotonic_values.__next__) + provider = self._provider(request_timeout_s=75.0) + handle = await provider.create(_spec()) + handle.raw.wait_outcomes = [ + ConnectionError("peer closed connection without sending complete message body"), + FakeCommandResult(stdout="finished", exit_code=0), + ] + result = await provider.exec(handle, "make -j8", timeout_s=60) + assert handle.raw.connect_calls == [{"pid": 4242, "timeout": 55.0, "request_timeout": 55.0}] + assert result.return_code == 0 + assert result.stdout == "finished" + + async def test_reattach_preserves_output_received_before_disconnect(self) -> None: + provider = self._provider() + handle = await provider.create(_spec()) + stream_error = ConnectionError("stream lost") + stream_error.stdout = "before-out\n" + stream_error.stderr = "before-err\n" + handle.raw.wait_outcomes = [ + stream_error, + FakeCommandResult(stdout="after-out\n", stderr="after-err\n", exit_code=0), + ] + + result = await provider.exec(handle, "make -j8") + + assert result.stdout == "before-out\nafter-out\n" + assert result.stderr == "before-err\nafter-err\n" + + async def test_lost_stream_does_not_reattach_after_deadline( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monotonic_values = iter([100.0, 161.0]) + monkeypatch.setattr(e2b_provider, "monotonic", monotonic_values.__next__) + provider = self._provider() + handle = await provider.create(_spec()) + handle.raw.wait_outcomes = [ConnectionError("stream lost")] + + with pytest.raises(TimeoutError, match="timed out after 60"): + await provider.exec(handle, "make -j8", timeout_s=60) + + assert handle.raw.connect_calls == [] + + async def test_reattach_recovers_the_real_exit_code(self) -> None: + provider = self._provider() + handle = await provider.create(_spec()) + stream_error = ConnectionError("stream lost") + stream_error.stdout = "before-out\n" + stream_error.stderr = "before-err\n" + handle.raw.wait_outcomes = [ + stream_error, + FakeCommandExit(7, stdout="after-out\n", stderr="after-err\n"), + ] + result = await provider.exec(handle, "false") + assert result.return_code == 7 + assert result.stdout == "before-out\nafter-out\n" + assert result.stderr == "before-err\nafter-err\n" + + async def test_reattach_attempts_are_bounded(self) -> None: + provider = self._provider(reconnect_attempts=2) + handle = await provider.create(_spec()) + handle.raw.wait_outcomes = [ConnectionError("a"), ConnectionError("b"), ConnectionError("c")] + with pytest.raises(ConnectionError): + await provider.exec(handle, "sleep 1") + assert len(handle.raw.connect_calls) == 2 + + async def test_non_zero_exit_is_not_treated_as_a_lost_stream(self) -> None: + provider = self._provider() + handle = await provider.create(_spec()) + handle.raw.wait_outcomes = [FakeCommandExit(3, stdout="out", stderr="err")] + result = await provider.exec(handle, "false") + assert result.return_code == 3 + assert handle.raw.connect_calls == [], "a real exit must not trigger a reattach" + + async def test_timeout_is_not_treated_as_a_lost_stream(self) -> None: + provider = self._provider() + handle = await provider.create(_spec()) + handle.raw.wait_outcomes = [FakeTimeout("timed out")] + with pytest.raises(TimeoutError): + await provider.exec(handle, "sleep 999") + assert handle.raw.connect_calls == [] + + async def test_process_gone_reports_the_original_failure(self) -> None: + # connect() raises not-found once the command has exited, so a command + # that finishes during the gap cannot be recovered; the transport + # failure is the useful error to surface. + provider = self._provider() + handle = await provider.create(_spec()) + handle.raw.wait_outcomes = [ConnectionError("stream lost")] + handle.raw.connect_error = FakeSandboxNotFound("process with pid 4242 not found") + with pytest.raises(ConnectionError, match="stream lost"): + await provider.exec(handle, "quick") + + +# -------------------------------------------------------------------------- +# Create / lifecycle +# -------------------------------------------------------------------------- + + +class TestCreateAndLifecycle: + async def test_spec_fields_map_onto_sdk_kwargs(self) -> None: + provider = E2BProvider( + connection={"api_key": "k", "api_url": "http://gw:8080", "request_timeout_s": 30.0}, + create={"template": "base", "allow_internet_access": False}, + ) + handle = await provider.create( + _spec(ttl_s=120, env={"FOO": "bar"}, metadata={"run": "1"}), + ) + kwargs = handle.raw.create_kwargs + assert kwargs["timeout"] == 120 + assert kwargs["envs"] == {"FOO": "bar"} + assert kwargs["metadata"] == {"run": "1"} + assert kwargs["allow_internet_access"] is False + assert kwargs["api_key"] == "k" + assert kwargs["api_url"] == "http://gw:8080" + assert kwargs["request_timeout"] == 30.0 + assert handle.provider_name == "e2b" + assert handle.sandbox_id == "sbx-1" + + async def test_ready_timeout_overrides_connection_request_timeout(self) -> None: + provider = E2BProvider( + connection={"request_timeout_s": 30.0}, + create={"template": "base"}, + ) + handle = await provider.create(_spec(ready_timeout_s=90)) + assert handle.raw.create_kwargs["request_timeout"] == 90.0 + + @pytest.mark.parametrize("ready_timeout_s", [0, -1, float("nan"), float("inf"), True, "60"]) + async def test_invalid_ready_timeout_is_rejected_before_create(self, ready_timeout_s: object) -> None: + provider = E2BProvider(create={"template": "base"}) + with pytest.raises(E2BCreateError, match="ready_timeout_s"): + await provider.create(_spec(ready_timeout_s=ready_timeout_s)) + assert FakeSandbox.instances == [] + + @pytest.mark.parametrize("ttl_s", [0, -1, float("nan"), float("inf"), True, "60"]) + async def test_invalid_ttl_is_rejected_before_create(self, ttl_s: object) -> None: + provider = E2BProvider(create={"template": "base"}) + with pytest.raises(E2BCreateError, match="ttl_s"): + await provider.create(_spec(ttl_s=ttl_s)) + assert FakeSandbox.instances == [] + + async def test_entrypoint_is_rejected_before_create(self) -> None: + provider = E2BProvider(create={"template": "base"}) + with pytest.raises(E2BCreateError, match="entrypoint"): + await provider.create(_spec(entrypoint=["python", "app.py"])) + assert FakeSandbox.instances == [] + + async def test_unknown_provider_options_are_rejected_before_create(self) -> None: + provider = E2BProvider(create={"template": "base"}) + with pytest.raises(ValueError, match="provider option"): + await provider.create(_spec(provider_options={"template": "base", "unknown": True})) + assert FakeSandbox.instances == [] + + async def test_provider_create_leaves_spec_files_to_the_facade(self) -> None: + provider = E2BProvider(create={"template": "base"}) + handle = await provider.create(_spec(files={"/app/seed.txt": "hello"})) + assert handle.raw.file_write_calls == [] + + async def test_async_sandbox_uploads_spec_files_exactly_once(self) -> None: + provider = E2BProvider(create={"template": "base"}) + sandbox = await AsyncSandbox( + provider, + _spec(files={"/app/seed.txt": "hello"}), + ).start() + raw = FakeSandbox.instances[0] + assert raw.file_write_calls == [{"path": "/app/seed.txt", "data": b"hello"}] + assert raw.files_written == {"/app/seed.txt": b"hello"} + await sandbox.stop() + + async def test_async_sandbox_cleans_up_when_spec_file_upload_fails( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + provider = E2BProvider(create={"template": "base"}) + + async def fail_upload(handle, source_path, target_path): + raise RuntimeError("upload failed") + + monkeypatch.setattr(provider, "upload_file", fail_upload) + + with pytest.raises(RuntimeError, match="upload failed"): + await AsyncSandbox( + provider, + _spec(files={"/app/seed.txt": "hello"}), + ).start() + + raw = FakeSandbox.instances[0] + assert raw.killed is True + assert len(raw.kill_calls) == 1 + + async def test_create_failure_is_wrapped(self, monkeypatch: pytest.MonkeyPatch) -> None: + async def boom(**kwargs): + raise RuntimeError("gateway exploded") + + monkeypatch.setattr(FakeSandbox, "create", boom) + provider = E2BProvider(create={"template": "base"}, operations={"retries": 0}) + with pytest.raises(E2BCreateError, match="gateway exploded"): + await provider.create(_spec()) + + async def test_status_and_close(self) -> None: + provider = E2BProvider(create={"template": "base"}) + handle = await provider.create(_spec()) + assert await provider.status(handle) == SandboxStatus.RUNNING + handle.raw.running = False + assert await provider.status(handle) == SandboxStatus.STOPPED + + handle.raw.running = True + sandbox = handle.raw + await provider.close(handle) + assert sandbox.killed is True + assert handle.raw is None + # Closing twice must not raise. + await provider.close(handle) + assert len(sandbox.kill_calls) == 1 + + @pytest.mark.parametrize( + "transient_error", + [ConnectionError("connection reset"), FakeTimeout("request timed out")], + ) + async def test_close_retries_transient_kill_errors(self, transient_error: Exception) -> None: + provider = E2BProvider( + create={"template": "base"}, + operations={"retries": 1, "retry_delay_s": 0}, + ) + handle = await provider.create(_spec()) + sandbox = handle.raw + sandbox.kill_outcomes = [transient_error, True] + + await provider.close(handle) + + assert len(sandbox.kill_calls) == 2 + assert sandbox.killed is True + assert handle.raw is None + + async def test_close_exhausted_failure_preserves_raw_handle(self) -> None: + provider = E2BProvider( + create={"template": "base"}, + operations={"retries": 1, "retry_delay_s": 0}, + ) + handle = await provider.create(_spec()) + sandbox = handle.raw + sandbox.kill_outcomes = [ConnectionError("temporary"), ConnectionError("still unavailable")] + + with pytest.raises(ConnectionError, match="still unavailable"): + await provider.close(handle) + + assert len(sandbox.kill_calls) == 2 + assert handle.raw is sandbox + + async def test_rate_limit_is_not_retried(self) -> None: + provider = E2BProvider( + create={"template": "base"}, + operations={"retries": 5, "retry_delay_s": 0}, + ) + handle = await provider.create(_spec()) + sandbox = handle.raw + sandbox.kill_outcomes = [FakeRateLimit("429 resource exhausted"), True] + + with pytest.raises(FakeRateLimit, match="resource exhausted"): + await provider.close(handle) + + assert len(sandbox.kill_calls) == 1 + assert handle.raw is sandbox + + async def test_close_false_result_clears_handle(self) -> None: + provider = E2BProvider(create={"template": "base"}) + handle = await provider.create(_spec()) + sandbox = handle.raw + sandbox.kill_outcomes = [False] + + await provider.close(handle) + + assert sandbox.killed is False + assert handle.raw is None + + async def test_close_tolerates_already_gone_sandbox(self, monkeypatch: pytest.MonkeyPatch) -> None: + provider = E2BProvider(create={"template": "base"}) + handle = await provider.create(_spec()) + + async def gone(**kwargs): + raise FakeSandboxNotFound("expired") + + monkeypatch.setattr(handle.raw, "kill", gone) + await provider.close(handle) + assert handle.raw is None + + async def test_connectable_provider_serializes_and_connects_mapping(self) -> None: + provider = E2BProvider() + assert isinstance(provider, ConnectableProvider) + descriptor = await provider.serialize_handle( + SandboxHandle(sandbox_id="sbx-existing", provider_name="e2b", raw=object()) + ) + assert descriptor == {"sandbox_id": "sbx-existing"} + + handle = await provider.connect({**descriptor, "workdir": "/repo"}) + assert handle.sandbox_id == "sbx-existing" + assert handle.provider_name == "e2b" + + async def test_async_sandbox_serialize_connect_round_trip_preserves_workdir(self) -> None: + provider = E2BProvider(create={"template": "base"}) + sandbox = await AsyncSandbox(provider, _spec(workdir="/repo")).start() + descriptor = await sandbox.serialize() + assert descriptor == {"sandbox_id": "sbx-1", "workdir": "/repo"} + + connected_provider = E2BProvider() + connected = await AsyncSandbox.connect(descriptor, provider=connected_provider) + await connected.exec("pwd") + assert FakeSandbox.instances[-1].exec_calls[-1]["cwd"] == "/repo" + + async def test_aclose_is_a_noop(self) -> None: + assert await E2BProvider().aclose() is None + + +# -------------------------------------------------------------------------- +# exec +# -------------------------------------------------------------------------- + + +class TestExec: + async def test_exec_maps_arguments_and_result(self) -> None: + provider = E2BProvider(create={"template": "base"}) + handle = await provider.create(_spec()) + handle.raw.exec_behaviour = FakeCommandResult(stdout="out", stderr="err", exit_code=0) + + result = await provider.exec(handle, "echo hi", cwd="/app", env={"A": "1"}, timeout_s=42, user="root") + assert (result.stdout, result.stderr, result.return_code) == ("out", "err", 0) + call = handle.raw.exec_calls[-1] + assert call["cmd"] == "echo hi" + assert call["cwd"] == "/app" + assert call["envs"] == {"A": "1"} + assert call["user"] == "root" + assert call["timeout"] == 42.0 + + async def test_default_exec_timeout_matches_the_public_facade(self) -> None: + provider = E2BProvider(create={"template": "base"}) + sandbox = await AsyncSandbox(provider, _spec()).start() + await sandbox.exec("true") + assert FakeSandbox.instances[0].exec_calls[-1]["timeout"] == 180.0 + + async def test_nonzero_exit_is_a_result_not_an_exception(self) -> None: + provider = E2BProvider(create={"template": "base"}) + handle = await provider.create(_spec()) + handle.raw.exec_behaviour = FakeCommandExit(exit_code=7, stdout="partial", stderr="bad") + + result = await provider.exec(handle, "false") + assert result.return_code == 7 + assert result.stdout == "partial" + assert result.stderr == "bad" + + async def test_timeout_is_raised_as_timeout_error(self) -> None: + provider = E2BProvider(create={"template": "base"}) + handle = await provider.create(_spec()) + handle.raw.exec_behaviour = FakeTimeout("deadline exceeded") + + with pytest.raises(TimeoutError, match=r"deadline exceeded.*wait/stream budget=1s"): + await provider.exec(handle, "sleep 999", timeout_s=1) + + @pytest.mark.parametrize("timeout_s", [-1, float("nan"), float("inf"), True, "1"]) + async def test_invalid_timeout_is_rejected_before_command_start(self, timeout_s: object) -> None: + provider = E2BProvider(create={"template": "base"}) + handle = await provider.create(_spec()) + + with pytest.raises(ValueError, match="timeout_s"): + await provider.exec(handle, "true", timeout_s=timeout_s) + + assert handle.raw.exec_calls == [] + + +# -------------------------------------------------------------------------- +# Files +# -------------------------------------------------------------------------- + + +class TestFiles: + async def test_upload_then_download_round_trip(self, tmp_path: Path) -> None: + provider = E2BProvider(create={"template": "base"}) + handle = await provider.create(_spec()) + + source = tmp_path / "in.bin" + source.write_bytes(b"payload\x00binary") + await provider.upload_file(handle, source, "/remote/in.bin") + + target = tmp_path / "nested" / "out.bin" + await provider.download_file(handle, "/remote/in.bin", target) + assert target.read_bytes() == b"payload\x00binary" + + async def test_upload_missing_file_raises(self, tmp_path: Path) -> None: + provider = E2BProvider(create={"template": "base"}) + handle = await provider.create(_spec()) + with pytest.raises(FileNotFoundError): + await provider.upload_file(handle, tmp_path / "nope.txt", "/remote/nope.txt") + + +# -------------------------------------------------------------------------- +# Retries +# -------------------------------------------------------------------------- + + +class TestRetries: + async def test_transient_create_failure_is_not_retried(self, monkeypatch: pytest.MonkeyPatch) -> None: + calls = {"n": 0} + + async def flaky(cls, **kwargs): + calls["n"] += 1 + raise RuntimeError("502 bad gateway") + + monkeypatch.setattr(FakeSandbox, "create", classmethod(flaky)) + provider = E2BProvider(create={"template": "base"}, operations={"retries": 3, "retry_delay_s": 0}) + with pytest.raises(E2BCreateError, match="502 bad gateway"): + await provider.create(_spec()) + assert calls["n"] == 1 + + async def test_deterministic_errors_are_not_retried(self, monkeypatch: pytest.MonkeyPatch) -> None: + calls = {"n": 0} + + async def not_found(cls, **kwargs): + calls["n"] += 1 + raise FakeSandboxNotFound("template missing") + + monkeypatch.setattr(FakeSandbox, "create", classmethod(not_found)) + provider = E2BProvider(create={"template": "base"}, operations={"retries": 5, "retry_delay_s": 0}) + with pytest.raises(E2BCreateError): + await provider.create(_spec()) + assert calls["n"] == 1 + + +# -------------------------------------------------------------------------- +# Config validation +# -------------------------------------------------------------------------- + + +def test_unknown_config_keys_are_rejected() -> None: + with pytest.raises(ValueError, match="Unknown E2BCreateConfig keys"): + E2BProvider(create={"template": "base", "nope": 1}) + + +@pytest.mark.parametrize( + "create", + [ + {"template": ""}, + {"template_map": {"": "template"}}, + {"template_map": {"image": ""}}, + {"template_map": []}, + ], +) +def test_invalid_template_config_is_rejected(create: dict[str, object]) -> None: + with pytest.raises((TypeError, ValueError), match=r"create\.template"): + E2BProvider(create=create) + + +@pytest.mark.parametrize( + ("section", "config", "message"), + [ + ("connection", {"request_timeout_s": -1}, "connection.request_timeout_s must be >= 0"), + ("connection", {"request_timeout_s": float("nan")}, "connection.request_timeout_s must be >= 0"), + ("create", {"timeout_s": 0}, "create.timeout_s must be > 0"), + ("create", {"timeout_s": float("inf")}, "create.timeout_s must be > 0"), + ("exec", {"default_timeout_s": -1}, "exec.default_timeout_s must be >= 0"), + ("exec", {"request_timeout_s": -1}, "exec.request_timeout_s must be >= 0"), + ("exec", {"reconnect_attempts": -1}, "exec.reconnect_attempts must be >= 0"), + ("exec", {"reconnect_attempts": 1.5}, "exec.reconnect_attempts must be >= 0"), + ("operations", {"retries": -1}, "operations.retries must be >= 0"), + ("operations", {"retries": True}, "operations.retries must be >= 0"), + ("operations", {"retry_delay_s": -1}, "operations.retry_delay_s must be >= 0"), + ("operations", {"retry_delay_s": None}, "operations.retry_delay_s must be >= 0"), + ("operations", {"retry_max_delay_s": -1}, "operations.retry_max_delay_s must be >= 0"), + ("operations", {"retry_max_delay_s": float("nan")}, "operations.retry_max_delay_s must be >= 0"), + ], +) +def test_invalid_config_values_are_rejected(section: str, config: dict[str, object], message: str) -> None: + with pytest.raises(ValueError, match=re.escape(message)): + E2BProvider(**{section: config}) diff --git a/uv.lock b/uv.lock index fd26480e68..2f8bea3fb0 100644 --- a/uv.lock +++ b/uv.lock @@ -370,6 +370,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/8c/5f7e73fd66b28f0705bc55d7060d41ef72328b656b86ca53e75765b3ba2c/botocore-1.43.40-py3-none-any.whl", hash = "sha256:0bc9d352267c9e48415c5d7bb61ff05c3f193eac2fc7e69cfd229a05fbab67d6", size = 15323870, upload-time = "2026-07-03T00:28:12.56Z" }, ] +[[package]] +name = "bracex" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/01/5f394b8bcd6e5b92f73130990960423bbb19711f906bd9fe9ea5557c667c/bracex-3.0.1.tar.gz", hash = "sha256:4e38e32392e4a4780fe15d644bfc7c8514057cfc3861e060b11814ce829c25e4", size = 44019, upload-time = "2026-07-20T13:43:00.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/8f/6f7273a7adb8d73fc8d21ede4376a3e475e52f98435c6007f69100dec8ca/bracex-3.0.1-py3-none-any.whl", hash = "sha256:6523ad83aeb5098a4ee597cff0f964442ff74e460bd3fafaffab6a013ff2288c", size = 11940, upload-time = "2026-07-20T13:42:59.268Z" }, +] + [[package]] name = "cachetools" version = "5.5.2" @@ -590,6 +599,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/63/6edf0415b072fff0bf8b546074dea3f0f9b148e49b601ac98bdc60a76c68/compressed_tensors-0.17.0-py3-none-any.whl", hash = "sha256:4a1b89b508f7efb8ffb4eee8a6e69e0452d9b080cae130146025c64fbe9fa9aa", size = 211714, upload-time = "2026-06-03T16:49:15.672Z" }, ] +[[package]] +name = "connectrpc" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf-py" }, + { name = "pyqwest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/b5/63e14ab9d4d4cc58818562db4120a35fa7454e3035633da41bdc6b712abd/connectrpc-0.11.1.tar.gz", hash = "sha256:18277f7838847b4271ca38d40c7d2387b5a2ea6a29f240689c19e1ec84aaff66", size = 46222, upload-time = "2026-07-15T06:33:31.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/ac/aa647675812cd075f2cb9acb00d62f4f2cbcbc6736049a62342b7371ce5b/connectrpc-0.11.1-py3-none-any.whl", hash = "sha256:8a52e2e92a485fa9681c1101a79a5ebeb31807e3ea3d5aabd41f484a6398bc7b", size = 64991, upload-time = "2026-07-15T06:33:29.396Z" }, +] + [[package]] name = "contourpy" version = "1.3.3" @@ -1130,6 +1152,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, ] +[[package]] +name = "dockerfile-parse" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/df/929ee0b5d2c8bd8d713c45e71b94ab57c7e11e322130724d54f469b2cd48/dockerfile-parse-2.0.1.tar.gz", hash = "sha256:3184ccdc513221983e503ac00e1aa504a2aa8f84e5de673c46b0b6eee99ec7bc", size = 24556, upload-time = "2023-07-18T13:36:07.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/6c/79cd5bc1b880d8c1a9a5550aa8dacd57353fa3bb2457227e1fb47383eb49/dockerfile_parse-2.0.1-py2.py3-none-any.whl", hash = "sha256:bdffd126d2eb26acf1066acb54cb2e336682e1d72b974a40894fac76a4df17f6", size = 14845, upload-time = "2023-07-18T13:36:06.052Z" }, +] + [[package]] name = "docstring-parser" version = "0.18.0" @@ -1139,6 +1170,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] +[[package]] +name = "e2b" +version = "2.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "connectrpc" }, + { name = "dockerfile-parse" }, + { name = "h2" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "protobuf-py" }, + { name = "pyqwest" }, + { name = "python-dateutil" }, + { name = "rich" }, + { name = "typing-extensions" }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/4d/2d6a05bce9a0e48297aa452d62b554e3630bdbcea8ecb12575f3f1cd2aac/e2b-2.46.0.tar.gz", hash = "sha256:7733f2e011038836d1b972ae46837562e3f5938c43f3756d10e349c0f08ce5f9", size = 227610, upload-time = "2026-08-25T11:21:00.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/03/a7f1b9da9a26f94529e52c2361b5244f7b1061d43b55a3089d5b888f5be0/e2b-2.46.0-py3-none-any.whl", hash = "sha256:92611d2f9a0cd97516b16d7ebaf9f633d953549698a4c0ac545cdd719a3878f0", size = 401696, upload-time = "2026-08-25T11:21:01.883Z" }, +] + [[package]] name = "einops" version = "0.8.2" @@ -1568,6 +1622,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" }, +] + [[package]] name = "hf-xet" version = "1.5.2" @@ -1592,6 +1659,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/c6/988383e9dc17294d536fcbcd6fd16eed882e411ad16c954984a53e47b09c/hf_xet-1.5.2-cp38-abi3-win_arm64.whl", hash = "sha256:1da28519496eb7c8094c11e4d25509b4a468457a0302d58136099db2fd9a671d", size = 3816957, upload-time = "2026-07-16T17:29:54.991Z" }, ] +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -1751,6 +1827,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/50/e0edd38dcd63fb26a8547f13d28f7a008bc4a3fd4eb4ff030673f22ad41a/hydra_core-1.3.2-py3-none-any.whl", hash = "sha256:fa0238a9e31df3373b35b0bfb672c34cc92718d21f81311d8996a16de1141d8b", size = 154547, upload-time = "2023-02-23T18:33:40.801Z" }, ] +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "identify" version = "2.6.13" @@ -2601,6 +2686,7 @@ all = [ { name = "coverage" }, { name = "daytona" }, { name = "defusedxml" }, + { name = "e2b" }, { name = "gprof2dot" }, { name = "httpx-aiohttp" }, { name = "mypy" }, @@ -2633,6 +2719,7 @@ openshell = [ sandbox = [ { name = "boto3" }, { name = "daytona" }, + { name = "e2b" }, { name = "httpx-aiohttp" }, { name = "opensandbox" }, { name = "tenacity" }, @@ -2666,6 +2753,7 @@ requires-dist = [ { name = "daytona", marker = "extra == 'sandbox'", specifier = ">=0.179.0" }, { name = "defusedxml", marker = "extra == 'dev'", specifier = ">=0.7.1" }, { name = "devtools" }, + { name = "e2b", marker = "extra == 'sandbox'", specifier = ">=2.36.0,<3.0.0" }, { name = "fastapi" }, { name = "flashinfer-python", marker = "extra == 'vllm'", specifier = "==0.6.13" }, { name = "fonttools", specifier = ">=4.60.2" }, @@ -3921,6 +4009,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] +[[package]] +name = "protobuf-py" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf-py-ext", marker = "(platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/ed/02fd902d9c51b7ff53dfc9a745eb11490722edfd30073af889e171f07b8e/protobuf_py-0.1.1.tar.gz", hash = "sha256:6bd08ac4d8f1661965bbe2685429d79043704cdd1ee720a7a89617331742240b", size = 133525, upload-time = "2026-06-24T19:02:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/00/1b3775aca1c70e3007e06ef5996f6bb9b3a32341eb0cce3ffb6effad8dec/protobuf_py-0.1.1-py3-none-any.whl", hash = "sha256:efc4f50f275ed6dae10a1f30bb81ad1a75368557b3ff22a532b7a472050368f1", size = 181656, upload-time = "2026-06-24T19:01:29.556Z" }, +] + +[[package]] +name = "protobuf-py-ext" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/05/6dc9ccff1e8159eb9a144e6d3c4acfd2211cd4fcd20c34fe9155d17d6a7f/protobuf_py_ext-0.1.1.tar.gz", hash = "sha256:e85bfdfdb3ed50634db8ccc7429dd9286520109489c735463971a418707b4fef", size = 31912, upload-time = "2026-06-24T19:02:16.321Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/70/2b5d62a60a2e0d88ecde1ae98db3132bcd2672fb39c8d581b82b34ac0bd8/protobuf_py_ext-0.1.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:310039e03cb15181781a0b78017419f6d4ee302e988c3c70b87f1facdf05532d", size = 306008, upload-time = "2026-06-24T19:01:31.399Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d6/73c06bb4cac2e04c0adc154965fe8b6520224bd737fd7e73bba405056321/protobuf_py_ext-0.1.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89ec8348d1ba045f79b2fedd14e40cca36f2a41b52f2c4fdf55a60c58add2353", size = 314769, upload-time = "2026-06-24T19:01:32.89Z" }, + { url = "https://files.pythonhosted.org/packages/18/c5/e4e6bc6096b66d1c82639a1b501147f16ee65d32567304b987d002e2c666/protobuf_py_ext-0.1.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:182798e4861aba72d05855bd06febe4926aa7265e6f444a1b8af5252beee4f7e", size = 328122, upload-time = "2026-06-24T19:01:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4b/1d792a40d0f0a0f914f1dfa8bb5e9573ca0ecd5fe5cecf80d77193212abd/protobuf_py_ext-0.1.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9eb25c3a329c0551cc86b209a5e5d8ecb8d834b9924a3aa019377853a703b6d3", size = 492338, upload-time = "2026-06-24T19:01:36.103Z" }, + { url = "https://files.pythonhosted.org/packages/52/51/5ad329223c905e5c530cae38ae24cde3584d8ab7e09457f2a01afce80e60/protobuf_py_ext-0.1.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:aaecbde82bef10c7c40578cbb61b7a19896bf7fa450972050a3bb302acb7d5d6", size = 541578, upload-time = "2026-06-24T19:01:37.481Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c7/01bd8a8bfe2df4b07aafac12ccfb7b7ebe2303f65296a9e02fcafa28ff3e/protobuf_py_ext-0.1.1-cp310-abi3-win_amd64.whl", hash = "sha256:6b0c615c48e95acc53cf33e9310eeaff8b30d2d7555bf93e7bca8fb4f40e9a5c", size = 251319, upload-time = "2026-06-24T19:01:38.946Z" }, + { url = "https://files.pythonhosted.org/packages/24/dc/914065538b5db54b6a920b5af38c1ef142252ea1eea711820435fa259fac/protobuf_py_ext-0.1.1-cp310-abi3-win_arm64.whl", hash = "sha256:72956cd0af5dee24b41c6f5ba5e42622d17e6d555002b5efc1634e27a1446de2", size = 241635, upload-time = "2026-06-24T19:01:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/a4/6c/09841f3dbe7c3d4b3f2779f8f2832ac23fb96ac8ab71674e91af732cf9ff/protobuf_py_ext-0.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ba61d49dace8f874361583030a7c48139b42eb37c9ffbb1e7e8a227a51576f44", size = 305017, upload-time = "2026-06-24T19:01:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/52/43/e450b5e202f6715274acc9acd9f9769908cbdeab861a53c798fcbd467d35/protobuf_py_ext-0.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a9c2f026096ca4c595c89a297067ef371e241d3ff8e1f6d7c779aa8419cdca7", size = 316215, upload-time = "2026-06-24T19:01:51.249Z" }, + { url = "https://files.pythonhosted.org/packages/97/5c/23e79b35f1b5755b9bdc0c0c9b8cd8abababaef1a1639608d8a96bb61a9d/protobuf_py_ext-0.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf78707a040b9294e5e1ec4a1875f0046acfe52e92150cea27de4e9fc9db39bb", size = 326419, upload-time = "2026-06-24T19:01:52.93Z" }, + { url = "https://files.pythonhosted.org/packages/e8/de/5493de0ec12920a3b1dd72608ce0c1e8cdb7e0eb9e87accaecc5216df29e/protobuf_py_ext-0.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae4373845cbb85bdade3ef5368cc2f4b5f80bf173383afc1ab063f95644e5599", size = 493734, upload-time = "2026-06-24T19:01:54.301Z" }, + { url = "https://files.pythonhosted.org/packages/98/89/31da55b414e6332aad11d858e9a86bd36fbb324f52fa3fc6d8f1c57840a9/protobuf_py_ext-0.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2a4cc478eef7a2acc1daaebcde479a3ac2396d47e6bdc7e776ca4c4147ba8b4c", size = 539703, upload-time = "2026-06-24T19:01:55.707Z" }, + { url = "https://files.pythonhosted.org/packages/1c/71/39f231838ef06476d46ac40dd814894277a732a3688c0a0c994850b3b62f/protobuf_py_ext-0.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:359dccdc1c3eafed2a913c570bb082b8df848a1d27d548ce8385f246a7d68be2", size = 302145, upload-time = "2026-06-24T19:01:57.116Z" }, + { url = "https://files.pythonhosted.org/packages/87/f1/01c81ff5f420600366a0e6a613ce2af20834534d2b3d7523f4f3b4ddb54f/protobuf_py_ext-0.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91c38b4a10a306366443273ee03ca554537a0965bf05b7ada8e7dbdee04cc93e", size = 314541, upload-time = "2026-06-24T19:01:58.745Z" }, + { url = "https://files.pythonhosted.org/packages/86/d8/1cbf5a0298ceddc0cdbb28bf1ecaf8bd3cff54ed4ced6a1392d5226172fc/protobuf_py_ext-0.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79eee3bcbb289d6ea114eb8fe3a1469c5b59bf53e207238469f567d9c53ba56f", size = 325092, upload-time = "2026-06-24T19:02:00.382Z" }, + { url = "https://files.pythonhosted.org/packages/af/23/8b1694616044cbfec2d18d4c6fffb03e05089c8bdb5970c6bafd60cc7fd5/protobuf_py_ext-0.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:10992141a8282a71ac3e3530d1a489efb27618d84000b9a47918cf70e5816d9b", size = 491684, upload-time = "2026-06-24T19:02:01.862Z" }, + { url = "https://files.pythonhosted.org/packages/49/d8/99997a7a6cf6989c944d9183c5deb6a045c27460add0316c7b34763891b3/protobuf_py_ext-0.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4411ffe0e06a774b83c5c71c546ce097640a25f596c45f95f53d3e3148e3f22d", size = 538048, upload-time = "2026-06-24T19:02:03.362Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/9e99ecbb68e1d5b46016d821eb1cbba9a91e6b50e71e75faf0c1e6189f0d/protobuf_py_ext-0.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55a17d80ea419501ff221a6627523f38a431bb33e6aa3de81ae3a7f271c49c75", size = 296337, upload-time = "2026-06-24T19:02:04.787Z" }, + { url = "https://files.pythonhosted.org/packages/af/f2/305338a28225fb54b28d6c3f5948109b9088b329a2b6f77ca610c2bdcd34/protobuf_py_ext-0.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbb518b5638403ceaa08ac4fc7dac626f45ed9b856b3517de3906cf3de4d632", size = 308961, upload-time = "2026-06-24T19:02:06.185Z" }, + { url = "https://files.pythonhosted.org/packages/60/f9/416103c93677ff2ea407704ea64fa6de9e700dc030c48360a182d91ce373/protobuf_py_ext-0.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a4adc65ab6a5e4885c67fc808bcacb83d755d05b566d312a0e10c2a873f2ad4", size = 321895, upload-time = "2026-06-24T19:02:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/5a/83/eb3e81bb2f83834b7deff4cee2d56eeb1adcbc847492a56b7f910ab257db/protobuf_py_ext-0.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e97f45c49676efacfb8e95bfcb1a002bc337e618a6780be1e55df2ba5ebd2f1e", size = 486091, upload-time = "2026-06-24T19:02:09.243Z" }, + { url = "https://files.pythonhosted.org/packages/99/96/bd88fca38556b3105e4dd41d6a176e31dcc583fe979e830aeedd2f8c20ee/protobuf_py_ext-0.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:53a6b3590f6aa7f97b8ed60f62fef9b096babfeae82f7283fd3a4c405827d4f8", size = 534582, upload-time = "2026-06-24T19:02:11.227Z" }, +] + [[package]] name = "psutil" version = "7.1.3" @@ -4294,6 +4424,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] +[[package]] +name = "pyqwest" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/ee/0ff9facfa9e7a4f6df2a770d4eaf1ad0f74165da7e8c28e888461f07604c/pyqwest-0.10.0.tar.gz", hash = "sha256:6c1a693be17d57d2c2eca4085e32c2809c53090c16719a907c90ebcf1f40dc01", size = 482248, upload-time = "2026-08-21T06:09:20.656Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ee/b1a28f57c689606cfd065d8a553841150f7daaa91d20e58dcc2c5ea191f8/pyqwest-0.10.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:aa492d5777dd145a60795ed95d9d4707a3cd1091fdcdfc93a82ac7fdc43ebacd", size = 5261059, upload-time = "2026-08-21T06:08:04.999Z" }, + { url = "https://files.pythonhosted.org/packages/dc/13/9c5046cfd6ef705bde0b620ba8a794335bcabc0839342a2a647f2427b27e/pyqwest-0.10.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:59f3f16628e518c674102e7b5fcff2101bba6abb4f6737ec5fade9b9278e6a53", size = 5134207, upload-time = "2026-08-21T06:08:06.955Z" }, + { url = "https://files.pythonhosted.org/packages/93/7d/50021dd88d82d6966ab1c27593ceaee9d1ed62fbe597c40e8dc187cfa5fd/pyqwest-0.10.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6e7db305a8318b1f3218053e87501f8f245ca8bd63e948e0282d04bf0883470", size = 5640730, upload-time = "2026-08-21T06:08:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3f/5bf6c32e9e701837a8c47ce6e3ad38978cfec8eb7bc6596181e5f9e1eaeb/pyqwest-0.10.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5c757cfac5f53c8671dcb4850d5fc4c4339ea3e90636331c9318f8e3ddabc06", size = 5561462, upload-time = "2026-08-21T06:08:10.836Z" }, + { url = "https://files.pythonhosted.org/packages/5f/61/ca9ba5721461b7ce5cfaac373ab3a1723ddcc434af7430f8bd628da6e623/pyqwest-0.10.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:234b3f71e3f314d997c203d8cf829b7117edd041153f9c277d0060ab90134148", size = 5801847, upload-time = "2026-08-21T06:08:12.502Z" }, + { url = "https://files.pythonhosted.org/packages/d2/44/95593919b996a417093f598d887822b9b899e8d025588c9bfaf8c60dd812/pyqwest-0.10.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5637256a0dac0ef57e0eaa02b032014965e4a4c995e1deca1b1b97e6d1765f78", size = 5978692, upload-time = "2026-08-21T06:08:14.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/b2821ce5188457168ebb25d5ff65b1ca1bf27bc6b4a33df4bcc2357e625c/pyqwest-0.10.0-cp310-abi3-win_amd64.whl", hash = "sha256:7ea761937acf3a00d1a7e70e982949d18946e5471d1419266ab3a78bbfa19759", size = 4876627, upload-time = "2026-08-21T06:08:16.084Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3d/8b1092f25159bf61a9470ebd35438c669b91ef553a7ee205bdec8006107b/pyqwest-0.10.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3978e794b9cfd8eaa500fb5d7aee63bc6172c605efa0abc1f62d85485bc049e1", size = 5273599, upload-time = "2026-08-21T06:08:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/75/10/54a9786123942b124c2afb9562b74e158afec7be40ef0caa0d37f615d379/pyqwest-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:715991fd4f04862cd7a9d7452daabcdbd74dff4dff55eb20c22d60382dc2a4ed", size = 5122920, upload-time = "2026-08-21T06:08:33.025Z" }, + { url = "https://files.pythonhosted.org/packages/a2/5b/6a6bd76f91e068b9a619f62aef9fe5ef201f859ddbb6b0a11ad3875ecdda/pyqwest-0.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c04798bed79c1dfa0e5b0e30fb137124311083490d44d6dfbe068d3dd254349e", size = 5639577, upload-time = "2026-08-21T06:08:34.896Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/12277a24a8dd74b0a7f124c624d9ed58eccb41e2087ff1087a14d348c778/pyqwest-0.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35b472877e73dd63fed089c2bc8fa198407f005c8c19e0a93f025ebefde01a81", size = 5565835, upload-time = "2026-08-21T06:08:36.567Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b6/b7da4a3f1fe43600154ae75e91ba7969024d886d60077dd3a1ba8e66d170/pyqwest-0.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:564ec360b7848b35e009038ffbca00466305a9708ab21829477f64aa8cad4c64", size = 5803078, upload-time = "2026-08-21T06:08:38.362Z" }, + { url = "https://files.pythonhosted.org/packages/0c/6f/0c9ba210f49f232289afaa8f06369c5f135ac786e1ca0cb22243b7f1fe2c/pyqwest-0.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b5c80e88a5967c1cadb3237c450f91a84a3683f8838c8dca96f09fee3612e762", size = 5980431, upload-time = "2026-08-21T06:08:39.967Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f5/306eeed41a3cd3100247e6e442f4345277b70f1d24efe1641181b14839cd/pyqwest-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc3d80b402fb59dbe015e25993ac8147456fb231a4c949f92a89f31315ad50f9", size = 4870198, upload-time = "2026-08-21T06:08:41.687Z" }, + { url = "https://files.pythonhosted.org/packages/64/18/0086a408e7cbf39dab18fa5b7e42c969a98382da5a4e6debe40f05acc6a1/pyqwest-0.10.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:23a28beb55fa6d975949bffae4adfb69378f3229bb5cbd71231e95bf66f5b26c", size = 5274542, upload-time = "2026-08-21T06:08:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7b/e1b9aaaf7596e4faaa53cefc2efaca4e3cde721e308e6385e366361cfdcc/pyqwest-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e4415ae40b8eedb1713dab14d7f9fecc3f79d26f3206c561087b88b99d5ce24b", size = 5127922, upload-time = "2026-08-21T06:08:45.116Z" }, + { url = "https://files.pythonhosted.org/packages/75/cd/e6d68bb1de5dd26100fcfc878cbd67c402a928774edf1e8ae304c5a84f5b/pyqwest-0.10.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14b875d2273212d7fa8e4b755d8d736ffd226b1c707a9c0017dfdc8393a96eca", size = 5645856, upload-time = "2026-08-21T06:08:47.055Z" }, + { url = "https://files.pythonhosted.org/packages/f7/48/8c9f9f0467c41f6a563146d57a52f8f6d60c0cb09d0fd3ef88ad5a1c442f/pyqwest-0.10.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5071491e416ea54e3b95bf9ffbed0bd065b093cb96e10a75c3d8f2cbe3c9823", size = 5569831, upload-time = "2026-08-21T06:08:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/16/ac/c85ab70c6c72078d49a82da76b820e46aaf95a3f6fe271dac955ac195d21/pyqwest-0.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b68b5e68d513a4c63a072f8f40e38015160cf90bfbf7e8ef7c3935ca87e9e022", size = 5806735, upload-time = "2026-08-21T06:08:50.652Z" }, + { url = "https://files.pythonhosted.org/packages/66/db/ad7375b22fb2d0807431dcc9bc2aaf840e374c298cd15071024d5f6dd6d1/pyqwest-0.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c48910d27820b9c46fcd001b0fe514a3cf47d4784f59512dcdb8c91c395f82e4", size = 5984786, upload-time = "2026-08-21T06:08:52.377Z" }, + { url = "https://files.pythonhosted.org/packages/17/88/c449a772afe129683fd7acc657cbc7c69bec085dc75b6e8710a50fbb44e7/pyqwest-0.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:d03ba2cd17948b623a6210981d342eb122546d8a8e910ec77511aff4b1acdd00", size = 4872288, upload-time = "2026-08-21T06:08:54.101Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f8/439ffc0ee12cd7d9b57ac07ccea78ad3ba66b0d6817d429dd661d73308c4/pyqwest-0.10.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:07a0eb595f4096232c2d22549b6e4612c1ecada7934e46462c2c37ce14a89cfb", size = 5256823, upload-time = "2026-08-21T06:08:55.681Z" }, + { url = "https://files.pythonhosted.org/packages/df/2b/72ecd27d104d2b4284710194cce796d607674966c3ea66436768e812a66a/pyqwest-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:26401baf7dafc71c8d12d2e8389519d141e6f7c14094d0dd4cf9ec1d3b5555bd", size = 5112624, upload-time = "2026-08-21T06:08:57.366Z" }, + { url = "https://files.pythonhosted.org/packages/a4/24/0fb89c3f7d5a0410fcb7560588b4c741ef24b19195c307d4263f04e75c2b/pyqwest-0.10.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5e3c436e041d8873ce5bb0fdcf9f9e86f5604e8f0ef9e03149efebd8cb474f6", size = 5631844, upload-time = "2026-08-21T06:08:59.165Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4f/921d14754a186f0143ad62b50108dd808328e347e98e9dafab3897eeb405/pyqwest-0.10.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09364115761579eabfc79d1e954cdb3ded508dac1903fac7285d4c6f058c683f", size = 5555869, upload-time = "2026-08-21T06:09:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/b8/70/504780417319a626fe9549a7e6f9020a3d448eddf8a09617238a3426e90c/pyqwest-0.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:559674a98a8b1217e1830ecd41c9905bf2b60983c6b8017063dfac199f00727c", size = 5793738, upload-time = "2026-08-21T06:09:03.324Z" }, + { url = "https://files.pythonhosted.org/packages/45/f7/8d0a5b8a3289f4300dc9005ebde75d316f2371a2671617f942036337731a/pyqwest-0.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f399a696392fff3db3eef0a18ef65b8a3b8396d129193487d966b8eb11006376", size = 5972889, upload-time = "2026-08-21T06:09:05.2Z" }, + { url = "https://files.pythonhosted.org/packages/89/c4/f4c781e475c451cb5f4762a2f814a1750ef375b8db4db09bb2dcea03c4e5/pyqwest-0.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0f9163d6dd991bf1bf27308ba38ba021af660b15fffa47ebca98e41cf6f00309", size = 4858036, upload-time = "2026-08-21T06:09:06.926Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -5713,6 +5882,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, ] +[[package]] +name = "wcmatch" +version = "11.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/43/30e407989e313677dbb9d5f045f966549a7254834571e342eaa4b55cc67b/wcmatch-11.0.1.tar.gz", hash = "sha256:1ea2b4fa678b8ca268253798d5963935df39132d47c3e241c0a0732224005e7d", size = 144662, upload-time = "2026-08-14T15:20:40.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/77/7a02b0f05b3ffcdbef9719ce3ee0b508d6a29b58e95299f1580055671db3/wcmatch-11.0.1-py3-none-any.whl", hash = "sha256:fd149ecddb9f0a88ea780017d6dde17c994e494e7f7303d4e3c9d6251f978f4b", size = 43449, upload-time = "2026-08-14T15:20:39.379Z" }, +] + [[package]] name = "websockets" version = "16.1.1"