From 784692de354a26fa3ca0bc18b759bb30c0d2ba10 Mon Sep 17 00:00:00 2001 From: JunYeopLee Date: Tue, 24 Mar 2026 10:40:18 +0000 Subject: [PATCH 01/22] feat: add E2B cloud sandbox environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `E2BEnvironment`, a new environment backend that runs commands inside [E2B](https://e2b.dev) cloud sandboxes. Unlike the Docker and Modal backends, it requires no local Docker daemon — the sandbox runs entirely in the cloud. Key design decisions: - **Automatic template management**: The first time a Docker image is used, `E2BTemplateManager` converts it into a persistent E2B template via `Template.build()`. Subsequent runs reuse the cached template, so the build cost is paid only once per unique image. - **Deterministic template naming**: `_image_to_template_name()` produces a stable, collision-resistant name (sha256 8-char suffix) that stays within E2B's 63-character, alphanumeric-plus-hyphen limit. - **Thread-safe build timeout**: Template builds run in a `ThreadPoolExecutor` (not `signal.alarm`) so that the timeout works correctly when called from worker threads (e.g., parallel SWE-bench runs). - **SWE-bench integration**: `get_sb_environment()` in `swebench.py` now injects the instance image for `e2b` the same way it does for `docker` and `swerex_modal`. Changes: - `src/minisweagent/environments/extra/e2b.py` — new environment class - `src/minisweagent/environments/__init__.py` — register `"e2b"` key - `src/minisweagent/run/benchmarks/swebench.py` — inject image for e2b - `pyproject.toml` — add `e2b` optional dependency (`e2b>=1.0.0`) - `tests/environments/extra/test_e2b.py` — 18 unit tests (all passing) - `docs/` — update environments reference and README --- README.md | 2 +- docs/advanced/environments.md | 2 + docs/reference/environments/e2b.md | 78 +++++++ pyproject.toml | 4 + src/minisweagent/environments/__init__.py | 1 + src/minisweagent/environments/extra/e2b.py | 243 ++++++++++++++++++++ src/minisweagent/run/benchmarks/swebench.py | 2 +- tests/environments/extra/test_e2b.py | 224 ++++++++++++++++++ 8 files changed, 554 insertions(+), 2 deletions(-) create mode 100644 docs/reference/environments/e2b.md create mode 100644 src/minisweagent/environments/extra/e2b.py create mode 100644 tests/environments/extra/test_e2b.py diff --git a/README.md b/README.md index 0b9804402..96156a6e3 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ We now ask: **What if our agent was 100x simpler, and still worked nearly as wel - **Minimal**: Just some 100 lines of python for the [agent class](https://github.com/SWE-agent/mini-swe-agent/blob/main/src/minisweagent/agents/default.py) (and a bit more for the [environment](https://github.com/SWE-agent/mini-swe-agent/blob/main/src/minisweagent/environments/local.py), [model](https://github.com/SWE-agent/mini-swe-agent/blob/main/src/minisweagent/models/litellm_model.py), and [run script](https://github.com/SWE-agent/mini-swe-agent/blob/main/src/minisweagent/run/hello_world.py)) — no fancy dependencies! - **Performant:** Scores >74% on the [SWE-bench verified benchmark](https://www.swebench.com/); starts much faster than Claude Code -- **Deployable:** Supports **local environments**, **docker/podman**, **singularity/apptainer**, **bublewrap**, **contree**, and more +- **Deployable:** Supports **local environments**, **docker/podman**, **singularity/apptainer**, **bubblewrap**, **contree**, **[E2B](https://e2b.dev)** (no local Docker required), and more - **Compatible:** Supports all models via **litellm**, **openrouter**, **portkey**, and more. Support for `/completion` and `/response` endpoints, interleaved thinking etc. - Built by the Princeton & Stanford team behind [SWE-bench](https://swebench.com), [SWE-agent](https://swe-agent.com), and more - **Tested:** [![Codecov](https://img.shields.io/codecov/c/github/swe-agent/mini-swe-agent?style=flat-square)](https://codecov.io/gh/SWE-agent/mini-swe-agent) diff --git a/docs/advanced/environments.md b/docs/advanced/environments.md index 7d672755d..3e30d3959 100644 --- a/docs/advanced/environments.md +++ b/docs/advanced/environments.md @@ -30,3 +30,5 @@ On top, there are a few more specialized environment classes that you can use: * **`contree`** ([`ContreeEnvironment`](../reference/environments/contree.md)) - Uses [ConTree](https://contree.dev/) for safe code execution sandboxing. Platform that built for agents and supports Git-like execution. +* **`e2b`** ([`E2BEnvironment`](../reference/environments/e2b.md)) - [E2B](https://e2b.dev) cloud sandbox execution. Converts Docker images into persistent E2B templates so **no local Docker daemon is required**. Suitable for large-scale, fully-remote SWE-bench evaluations. + diff --git a/docs/reference/environments/e2b.md b/docs/reference/environments/e2b.md new file mode 100644 index 000000000..25085afd1 --- /dev/null +++ b/docs/reference/environments/e2b.md @@ -0,0 +1,78 @@ +# E2B + +!!! note "E2B Environment class" + + - [Read on GitHub](https://github.com/swe-agent/mini-swe-agent/blob/main/src/minisweagent/environments/extra/e2b.py) + - Requires an [E2B](https://e2b.dev) account and API key + + ??? note "Full source code" + + ```python + --8<-- "src/minisweagent/environments/extra/e2b.py" + ``` + +::: minisweagent.environments.extra.e2b + +This environment executes commands in [E2B](https://e2b.dev) cloud sandboxes. +E2B converts Docker images into persistent sandbox templates, so **no local Docker daemon is required** — everything runs in the cloud. + +This makes it well-suited for: + +- Large-scale, fully-remote SWE-bench evaluations +- Environments where Docker is unavailable (CI, serverless) +- Parallel agent runs without managing local container infrastructure + +## How it works + +The first time a Docker image is used, `E2BEnvironment` builds a persistent E2B template from that image (via `Template.build`). Subsequent runs reuse the cached template, so the build cost is paid only once per unique image. + +## Setup + +1. Install the E2B extra: + ```bash + pip install "mini-swe-agent[e2b]" + ``` + +2. Set your E2B API key: + ```bash + export E2B_API_KEY="your-e2b-api-key" + ``` + +## Usage + +Evaluate on SWE-bench using E2B as the sandbox backend: +```bash +mini-extra swebench \ + --subset verified \ + --split test \ + --workers 50 \ + --environment-class e2b +``` + +Or specify it in your YAML config: +```yaml +environment: + environment_class: e2b + sandbox_timeout: 3600 # seconds the sandbox stays alive + cpu_count: 2 + memory_mb: 2048 +``` + +## Configuration reference + +| Field | Default | Description | +|-------|---------|-------------| +| `image` | *(required)* | Docker Hub image to use as the sandbox base | +| `cwd` | `/` | Default working directory for commands | +| `timeout` | `30` | Per-command timeout in seconds | +| `env` | `{}` | Environment variables set in every command | +| `sandbox_timeout` | `3600` | How long the sandbox stays alive (seconds) | +| `cpu_count` | `2` | vCPUs allocated to the sandbox | +| `memory_mb` | `2048` | Memory allocated to the sandbox (MiB) | +| `build_timeout` | `1800` | Max seconds to wait for a template build | +| `skip_cache` | `False` | Force-rebuild the template even if it exists | +| `api_key` | `None` | E2B API key (falls back to `E2B_API_KEY` env var) | +| `registry_username` | `None` | Username for private Docker registry auth | +| `registry_password` | `None` | Password for private Docker registry auth | + +{% include-markdown "../../_footer.md" %} diff --git a/pyproject.toml b/pyproject.toml index 808e9e10b..fdfe58008 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,10 @@ contree = [ "contree-sdk>=0.2.0", ] +e2b = [ + "e2b>=1.0.0", +] + [project.urls] Documentation = "https://mini-swe-agent.com/latest/" Repository = "https://github.com/SWE-agent/mini-swe-agent" diff --git a/src/minisweagent/environments/__init__.py b/src/minisweagent/environments/__init__.py index 08ae2c65c..8104b6fd7 100644 --- a/src/minisweagent/environments/__init__.py +++ b/src/minisweagent/environments/__init__.py @@ -13,6 +13,7 @@ "swerex_modal": "minisweagent.environments.extra.swerex_modal.SwerexModalEnvironment", "bubblewrap": "minisweagent.environments.extra.bubblewrap.BubblewrapEnvironment", "contree": "minisweagent.environments.extra.contree.ContreeEnvironment", + "e2b": "minisweagent.environments.extra.e2b.E2BEnvironment", } diff --git a/src/minisweagent/environments/extra/e2b.py b/src/minisweagent/environments/extra/e2b.py new file mode 100644 index 000000000..0f0fe197e --- /dev/null +++ b/src/minisweagent/environments/extra/e2b.py @@ -0,0 +1,243 @@ +"""E2B cloud sandbox environment implementation.""" + +from __future__ import annotations + +import concurrent.futures +import hashlib +import logging +import re +from typing import Any + +from pydantic import BaseModel, Field + + +class E2BEnvironmentConfig(BaseModel): + image: str + """Docker Hub image name to use as the E2B template base. + Example: ``'swebench/sweb.eval.x86_64.django__django-11099:latest'`` + """ + cwd: str = "/" + """Working directory in which to execute commands.""" + timeout: int = 30 + """Timeout for executing commands in the sandbox.""" + env: dict[str, str] = Field(default_factory=dict) + """Environment variables to set when executing commands.""" + sandbox_timeout: int = 3600 + """How long (in seconds) the sandbox is allowed to stay alive.""" + + # Template build options (passed to Template.build()) + cpu_count: int = 2 + """Number of vCPUs allocated to the sandbox.""" + memory_mb: int = 2048 + """Memory allocated to the sandbox in MiB. Default is higher than E2B's 1024 MiB default + to accommodate larger SWE-bench images.""" + skip_cache: bool = False + """If True, force-rebuild the template even if it already exists.""" + tags: list[str] = Field(default_factory=list) + """Optional tags to attach to the template.""" + build_timeout: int = 1800 + """Timeout for template builds in seconds (default 30 min to handle large images).""" + + # E2B authentication (can also be set via E2B_API_KEY / E2B_ACCESS_TOKEN env vars) + api_key: str | None = None + """E2B API key. Falls back to the E2B_API_KEY environment variable.""" + access_token: str | None = None + """E2B access token. Falls back to the E2B_ACCESS_TOKEN environment variable.""" + + # Private registry credentials (passed to Template().from_image()) + registry_username: str | None = None + """Username for authenticating against a private Docker registry.""" + registry_password: str | None = None + """Password for authenticating against a private Docker registry.""" + + +class E2BTemplateManager: + """Converts Docker images to E2B templates and manages their lifecycle. + + Can be used independently of :class:`E2BEnvironment` for pre-building + templates in batch scripts. + """ + + def __init__(self, config: E2BEnvironmentConfig) -> None: + self.config = config + self.logger = logging.getLogger("minisweagent.environment.e2b") + + @staticmethod + def _image_to_template_name(docker_image: str) -> str: + """Deterministically map a Docker image name to a valid E2B template name. + + A sha256 8-character suffix is appended to avoid collisions between + images that produce the same sanitized prefix. The result is at most + 63 characters and contains only lower-case alphanumerics and hyphens. + + Example:: + + 'swebench/sweb.eval.x86_64.django__django-11099:latest' + → 'swebench-sweb-eval-x86-64-django--django-11099-l-a1b2c3d4' + """ + hash_suffix = hashlib.sha256(docker_image.encode()).hexdigest()[:8] + name = re.sub(r"[^a-zA-Z0-9-]", "-", docker_image) + name = re.sub(r"-{3,}", "--", name) + name = name.lower() + # Reserve 9 characters for "-" + 8-char hash suffix → prefix max 54 chars + prefix = name[:54].strip("-") + if not prefix: + return hash_suffix + return f"{prefix}-{hash_suffix}" + + def get_or_build(self, docker_image: str) -> str: + """Return the E2B template name for *docker_image*, building it if needed.""" + from e2b import Template + + template_name = self._image_to_template_name(docker_image) + if not Template.exists(template_name, api_key=self.config.api_key): + self.logger.info( + "E2B template %s not found. Starting build (up to %d seconds)...", + template_name, + self.config.build_timeout, + ) + self._build_template(docker_image, template_name) + self.logger.info("E2B template %s built successfully.", template_name) + else: + self.logger.debug("E2B template %s already exists.", template_name) + return template_name + + def _build_template(self, docker_image: str, template_name: str) -> None: + """Build an E2B template from *docker_image*. + + Uses :class:`concurrent.futures.ThreadPoolExecutor` for timeout + enforcement because ``signal.alarm`` only works on the main thread + and this method may be called from worker threads. + """ + from e2b import Template + + template = Template().from_image( + docker_image, + username=self.config.registry_username, + password=self.config.registry_password, + ) + + def _do_build() -> None: + Template.build( + template, + template_name, + cpu_count=self.config.cpu_count, + memory_mb=self.config.memory_mb, + skip_cache=self.config.skip_cache, + tags=self.config.tags or None, + api_key=self.config.api_key, + access_token=self.config.access_token, + ) + + executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) + future = executor.submit(_do_build) + try: + future.result(timeout=self.config.build_timeout) + except concurrent.futures.TimeoutError as e: + executor.shutdown(wait=False, cancel_futures=True) + msg = f"E2B template build timed out after {self.config.build_timeout}s: {template_name}" + raise TimeoutError(msg) from e + except Exception: + executor.shutdown(wait=False, cancel_futures=True) + raise + else: + executor.shutdown(wait=True) + + +class E2BEnvironment: + """Executes bash commands inside an E2B cloud sandbox. + + `E2B `_ provides isolated cloud sandboxes that can run + arbitrary Docker images without requiring a local Docker daemon. This + makes it suitable for large-scale, fully-remote SWE-bench evaluations. + + The first time a Docker image is used it is converted into a persistent + E2B template; subsequent runs reuse the cached template. + + See :class:`E2BEnvironmentConfig` for keyword arguments. + """ + + def __init__(self, **kwargs: Any) -> None: + from e2b import Sandbox + + self.logger = logging.getLogger("minisweagent.environment.e2b") + self.config = E2BEnvironmentConfig(**kwargs) + manager = E2BTemplateManager(self.config) + template_name = manager.get_or_build(self.config.image) + self.logger.info("Creating E2B sandbox (template: %s)...", template_name) + self.sandbox = Sandbox.create( + template=template_name, + timeout=self.config.sandbox_timeout, + api_key=self.config.api_key, + access_token=self.config.access_token, + ) + self.logger.info("E2B sandbox ready (id: %s)", self.sandbox.sandbox_id) + + def execute(self, action: dict, cwd: str = "", *, timeout: int | None = None) -> dict[str, Any]: + """Execute a command in the sandbox and return the output.""" + command = action.get("command", "") if isinstance(action, dict) else action + try: + result = self.sandbox.commands.run( + command, + cwd=cwd or self.config.cwd, + timeout=timeout or self.config.timeout, + envs=self.config.env or None, + ) + output: dict[str, Any] = { + "output": result.stdout + result.stderr, + "returncode": result.exit_code, + "exception_info": "", + } + except Exception as e: + output = { + "output": "", + "returncode": -1, + "exception_info": f"An error occurred while executing the command: {e}", + "extra": {"exception_type": type(e).__name__, "exception": str(e)}, + } + self._check_finished(output) + return output + + def _check_finished(self, output: dict) -> None: + """Raise :class:`~minisweagent.exceptions.Submitted` when the task-submission marker is detected.""" + from minisweagent.exceptions import Submitted + + lines = output.get("output", "").lstrip().splitlines(keepends=True) + if lines and lines[0].strip() == "COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT" and output["returncode"] == 0: + submission = "".join(lines[1:]) + raise Submitted( + { + "role": "exit", + "content": submission, + "extra": {"exit_status": "Submitted", "submission": submission}, + } + ) + + def get_template_vars(self, **kwargs: Any) -> dict[str, Any]: + from minisweagent.utils.serialize import recursive_merge + + return recursive_merge(self.config.model_dump(), kwargs) + + def serialize(self) -> dict: + return { + "info": { + "config": { + "environment": self.config.model_dump( + mode="json", + exclude={"api_key", "access_token", "registry_password"}, + ), + "environment_type": f"{self.__class__.__module__}.{self.__class__.__name__}", + } + } + } + + def stop(self) -> None: + sandbox = getattr(self, "sandbox", None) + if sandbox is not None: + try: + sandbox.kill() + except Exception: + pass + + def __del__(self) -> None: + self.stop() diff --git a/src/minisweagent/run/benchmarks/swebench.py b/src/minisweagent/run/benchmarks/swebench.py index a708eee51..6ce30cc10 100644 --- a/src/minisweagent/run/benchmarks/swebench.py +++ b/src/minisweagent/run/benchmarks/swebench.py @@ -94,7 +94,7 @@ def get_sb_environment(config: dict, instance: dict) -> Environment: env_config = config.setdefault("environment", {}) env_config["environment_class"] = env_config.get("environment_class", "docker") image_name = get_swebench_docker_image_name(instance) - if env_config["environment_class"] in ["docker", "swerex_modal"]: + if env_config["environment_class"] in ["docker", "swerex_modal", "e2b"]: env_config["image"] = image_name elif env_config["environment_class"] in ["singularity", "contree"]: env_config["image"] = "docker://" + image_name diff --git a/tests/environments/extra/test_e2b.py b/tests/environments/extra/test_e2b.py new file mode 100644 index 000000000..0f466a4ae --- /dev/null +++ b/tests/environments/extra/test_e2b.py @@ -0,0 +1,224 @@ +"""Tests for the E2B cloud sandbox environment.""" + +import sys +from types import ModuleType +from unittest.mock import MagicMock, patch + +import pytest + +from minisweagent.environments.extra.e2b import ( + E2BEnvironment, + E2BEnvironmentConfig, + E2BTemplateManager, +) +from minisweagent.exceptions import Submitted + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_mock_e2b() -> ModuleType: + """Return a minimal mock of the `e2b` module.""" + mock_e2b = MagicMock() + mock_e2b.Template = MagicMock() + mock_e2b.Sandbox = MagicMock() + return mock_e2b + + +def _make_env(**kwargs) -> E2BEnvironment: + """Create an E2BEnvironment without touching real E2B infrastructure.""" + with patch.object(E2BEnvironment, "__init__", lambda self, **kw: None): + env = E2BEnvironment() + env.config = E2BEnvironmentConfig(image="swebench/test-image:latest", **kwargs) + env.sandbox = MagicMock() + env.logger = MagicMock() + return env + + +# --------------------------------------------------------------------------- +# E2BEnvironmentConfig +# --------------------------------------------------------------------------- + + +class TestE2BEnvironmentConfig: + def test_defaults(self): + cfg = E2BEnvironmentConfig(image="python:3.11") + assert cfg.cwd == "/" + assert cfg.timeout == 30 + assert cfg.sandbox_timeout == 3600 + assert cfg.cpu_count == 2 + assert cfg.memory_mb == 2048 + assert cfg.skip_cache is False + assert cfg.tags == [] + assert cfg.build_timeout == 1800 + assert cfg.api_key is None + assert cfg.access_token is None + assert cfg.registry_username is None + assert cfg.registry_password is None + + def test_custom_values(self): + cfg = E2BEnvironmentConfig(image="my-image:tag", sandbox_timeout=7200, cpu_count=4) + assert cfg.sandbox_timeout == 7200 + assert cfg.cpu_count == 4 + + +# --------------------------------------------------------------------------- +# E2BTemplateManager._image_to_template_name +# --------------------------------------------------------------------------- + + +class TestImageToTemplateName: + def test_basic_sanitization(self): + name = E2BTemplateManager._image_to_template_name("python:3.11") + assert re.match(r"^[a-z0-9-]+$", name), f"Invalid chars in: {name}" + + def test_length_limit(self): + long_image = "a" * 100 + ":latest" + name = E2BTemplateManager._image_to_template_name(long_image) + assert len(name) <= 63 + + def test_deterministic(self): + image = "swebench/sweb.eval.x86_64.django__django-11099:latest" + assert E2BTemplateManager._image_to_template_name(image) == E2BTemplateManager._image_to_template_name(image) + + def test_different_images_different_names(self): + a = E2BTemplateManager._image_to_template_name("image-a:latest") + b = E2BTemplateManager._image_to_template_name("image-b:latest") + assert a != b + + def test_no_triple_hyphens(self): + # Dots and slashes become hyphens; consecutive runs are collapsed to "--" + name = E2BTemplateManager._image_to_template_name("a/b/c.d.e:latest") + assert "---" not in name + + def test_empty_prefix_falls_back_to_hash(self): + # An image that sanitizes to only hyphens should return just the hash + name = E2BTemplateManager._image_to_template_name("---") + assert len(name) == 8 # just the 8-char sha256 prefix + + +import re # noqa: E402 (needed after class definitions above for clarity) + + +# --------------------------------------------------------------------------- +# E2BEnvironment.execute +# --------------------------------------------------------------------------- + + +class TestE2BEnvironmentExecute: + def test_execute_dict_action(self): + env = _make_env() + mock_result = MagicMock() + mock_result.stdout = "hello\n" + mock_result.stderr = "" + mock_result.exit_code = 0 + env.sandbox.commands.run.return_value = mock_result + + output = env.execute({"command": "echo hello"}) + + assert output["output"] == "hello\n" + assert output["returncode"] == 0 + assert output["exception_info"] == "" + + def test_execute_string_action(self): + env = _make_env() + mock_result = MagicMock() + mock_result.stdout = "ok\n" + mock_result.stderr = "" + mock_result.exit_code = 0 + env.sandbox.commands.run.return_value = mock_result + + output = env.execute("echo ok") + + assert output["output"] == "ok\n" + + def test_execute_nonzero_exit(self): + env = _make_env() + mock_result = MagicMock() + mock_result.stdout = "" + mock_result.stderr = "error\n" + mock_result.exit_code = 1 + env.sandbox.commands.run.return_value = mock_result + + output = env.execute({"command": "false"}) + + assert output["returncode"] == 1 + + def test_execute_exception(self): + env = _make_env() + env.sandbox.commands.run.side_effect = RuntimeError("connection lost") + + output = env.execute({"command": "ls"}) + + assert output["returncode"] == -1 + assert "connection lost" in output["exception_info"] + + def test_execute_raises_submitted(self): + env = _make_env() + mock_result = MagicMock() + mock_result.stdout = "COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT\ndiff --git a/f.py b/f.py\n" + mock_result.stderr = "" + mock_result.exit_code = 0 + env.sandbox.commands.run.return_value = mock_result + + with pytest.raises(Submitted) as exc_info: + env.execute({"command": "submit"}) + + msg = exc_info.value.messages[0] + assert msg["extra"]["exit_status"] == "Submitted" + assert "diff --git" in msg["extra"]["submission"] + + +# --------------------------------------------------------------------------- +# E2BEnvironment.serialize +# --------------------------------------------------------------------------- + + +class TestE2BEnvironmentSerialize: + def test_serialize_structure(self): + env = _make_env() + result = env.serialize() + + assert "info" in result + assert "config" in result["info"] + assert "environment" in result["info"]["config"] + assert "environment_type" in result["info"]["config"] + assert "E2BEnvironment" in result["info"]["config"]["environment_type"] + + def test_serialize_excludes_credentials(self): + env = _make_env() + env.config.api_key = "secret-key" + env.config.access_token = "secret-token" + env.config.registry_password = "secret-pass" + + result = env.serialize() + env_cfg = result["info"]["config"]["environment"] + + assert "api_key" not in env_cfg + assert "access_token" not in env_cfg + assert "registry_password" not in env_cfg + + +# --------------------------------------------------------------------------- +# E2BEnvironment.stop / __del__ +# --------------------------------------------------------------------------- + + +class TestE2BEnvironmentStop: + def test_stop_kills_sandbox(self): + env = _make_env() + env.stop() + env.sandbox.kill.assert_called_once() + + def test_stop_tolerates_missing_sandbox(self): + with patch.object(E2BEnvironment, "__init__", lambda self, **kw: None): + env = E2BEnvironment() + # sandbox was never set + env.stop() # should not raise + + def test_stop_tolerates_kill_exception(self): + env = _make_env() + env.sandbox.kill.side_effect = RuntimeError("already dead") + env.stop() # should not raise From db34ac786edbee1c69bd86fc69d77a5997492ce8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:41:05 +0000 Subject: [PATCH 02/22] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/environments/extra/test_e2b.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/environments/extra/test_e2b.py b/tests/environments/extra/test_e2b.py index 0f466a4ae..481f0f25c 100644 --- a/tests/environments/extra/test_e2b.py +++ b/tests/environments/extra/test_e2b.py @@ -1,6 +1,5 @@ """Tests for the E2B cloud sandbox environment.""" -import sys from types import ModuleType from unittest.mock import MagicMock, patch @@ -13,7 +12,6 @@ ) from minisweagent.exceptions import Submitted - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -101,7 +99,6 @@ def test_empty_prefix_falls_back_to_hash(self): import re # noqa: E402 (needed after class definitions above for clarity) - # --------------------------------------------------------------------------- # E2BEnvironment.execute # --------------------------------------------------------------------------- From f94ad5297f0ed406867636bb2e68de5e7f025cb4 Mon Sep 17 00:00:00 2001 From: JunYeopLee Date: Tue, 24 Mar 2026 10:49:55 +0000 Subject: [PATCH 03/22] feat: register active sandboxes for atexit cleanup Add a module-level _active_sandboxes set and an atexit handler (_cleanup_all_sandboxes) that kills all live sandboxes when the interpreter exits. This ensures sandboxes are cleaned up on Ctrl+C or unhandled exceptions where __del__ may not be reliably called. - __init__ adds self to _active_sandboxes after sandbox creation - stop() removes self from _active_sandboxes before calling sandbox.kill() - atexit handler iterates over a snapshot of the set to avoid mutation issues Two additional tests cover the registry and cleanup behaviour. --- src/minisweagent/environments/extra/e2b.py | 16 +++++++++++ tests/environments/extra/test_e2b.py | 31 ++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/minisweagent/environments/extra/e2b.py b/src/minisweagent/environments/extra/e2b.py index 0f0fe197e..c36684501 100644 --- a/src/minisweagent/environments/extra/e2b.py +++ b/src/minisweagent/environments/extra/e2b.py @@ -2,12 +2,26 @@ from __future__ import annotations +import atexit import concurrent.futures import hashlib import logging import re from typing import Any +# Module-level registry of live sandboxes for best-effort cleanup on exit +# (covers Ctrl+C and unhandled exceptions where __del__ may not be called). +_active_sandboxes: set[E2BEnvironment] = set() + + +def _cleanup_all_sandboxes() -> None: + """Kill all sandboxes that are still alive at interpreter shutdown.""" + for env in list(_active_sandboxes): + env.stop() + + +atexit.register(_cleanup_all_sandboxes) + from pydantic import BaseModel, Field @@ -172,6 +186,7 @@ def __init__(self, **kwargs: Any) -> None: access_token=self.config.access_token, ) self.logger.info("E2B sandbox ready (id: %s)", self.sandbox.sandbox_id) + _active_sandboxes.add(self) def execute(self, action: dict, cwd: str = "", *, timeout: int | None = None) -> dict[str, Any]: """Execute a command in the sandbox and return the output.""" @@ -232,6 +247,7 @@ def serialize(self) -> dict: } def stop(self) -> None: + _active_sandboxes.discard(self) sandbox = getattr(self, "sandbox", None) if sandbox is not None: try: diff --git a/tests/environments/extra/test_e2b.py b/tests/environments/extra/test_e2b.py index 481f0f25c..c37281844 100644 --- a/tests/environments/extra/test_e2b.py +++ b/tests/environments/extra/test_e2b.py @@ -219,3 +219,34 @@ def test_stop_tolerates_kill_exception(self): env = _make_env() env.sandbox.kill.side_effect = RuntimeError("already dead") env.stop() # should not raise + + +# --------------------------------------------------------------------------- +# atexit cleanup registry +# --------------------------------------------------------------------------- + + +class TestAtexitCleanup: + def test_stop_removes_from_active_sandboxes(self): + from minisweagent.environments.extra import e2b as e2b_mod + + env = _make_env() + e2b_mod._active_sandboxes.add(env) + assert env in e2b_mod._active_sandboxes + + env.stop() + assert env not in e2b_mod._active_sandboxes + + def test_cleanup_all_sandboxes_kills_all(self): + from minisweagent.environments.extra import e2b as e2b_mod + + env1 = _make_env() + env2 = _make_env() + e2b_mod._active_sandboxes.update([env1, env2]) + + e2b_mod._cleanup_all_sandboxes() + + env1.sandbox.kill.assert_called_once() + env2.sandbox.kill.assert_called_once() + assert env1 not in e2b_mod._active_sandboxes + assert env2 not in e2b_mod._active_sandboxes From 0c7f36287e7ac65b16caef676f90d0b6fc562f36 Mon Sep 17 00:00:00 2001 From: JunYeopLee Date: Tue, 24 Mar 2026 11:06:46 +0000 Subject: [PATCH 04/22] fix: remove unsupported access_token option from E2B environment E2B_ACCESS_TOKEN / access_token is not recognised by the E2B SDK. Remove the config field, all call-site usages (Template.build, Sandbox.create), the serialize exclusion, and the corresponding tests. Co-Authored-By: Claude Sonnet 4.6 --- src/minisweagent/environments/extra/e2b.py | 8 ++------ tests/environments/extra/test_e2b.py | 3 --- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/minisweagent/environments/extra/e2b.py b/src/minisweagent/environments/extra/e2b.py index c36684501..dbb0a0406 100644 --- a/src/minisweagent/environments/extra/e2b.py +++ b/src/minisweagent/environments/extra/e2b.py @@ -52,11 +52,9 @@ class E2BEnvironmentConfig(BaseModel): build_timeout: int = 1800 """Timeout for template builds in seconds (default 30 min to handle large images).""" - # E2B authentication (can also be set via E2B_API_KEY / E2B_ACCESS_TOKEN env vars) + # E2B authentication (can also be set via the E2B_API_KEY env var) api_key: str | None = None """E2B API key. Falls back to the E2B_API_KEY environment variable.""" - access_token: str | None = None - """E2B access token. Falls back to the E2B_ACCESS_TOKEN environment variable.""" # Private registry credentials (passed to Template().from_image()) registry_username: str | None = None @@ -140,7 +138,6 @@ def _do_build() -> None: skip_cache=self.config.skip_cache, tags=self.config.tags or None, api_key=self.config.api_key, - access_token=self.config.access_token, ) executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) @@ -183,7 +180,6 @@ def __init__(self, **kwargs: Any) -> None: template=template_name, timeout=self.config.sandbox_timeout, api_key=self.config.api_key, - access_token=self.config.access_token, ) self.logger.info("E2B sandbox ready (id: %s)", self.sandbox.sandbox_id) _active_sandboxes.add(self) @@ -239,7 +235,7 @@ def serialize(self) -> dict: "config": { "environment": self.config.model_dump( mode="json", - exclude={"api_key", "access_token", "registry_password"}, + exclude={"api_key", "registry_password"}, ), "environment_type": f"{self.__class__.__module__}.{self.__class__.__name__}", } diff --git a/tests/environments/extra/test_e2b.py b/tests/environments/extra/test_e2b.py index c37281844..0da83d6e6 100644 --- a/tests/environments/extra/test_e2b.py +++ b/tests/environments/extra/test_e2b.py @@ -52,7 +52,6 @@ def test_defaults(self): assert cfg.tags == [] assert cfg.build_timeout == 1800 assert cfg.api_key is None - assert cfg.access_token is None assert cfg.registry_username is None assert cfg.registry_password is None @@ -187,14 +186,12 @@ def test_serialize_structure(self): def test_serialize_excludes_credentials(self): env = _make_env() env.config.api_key = "secret-key" - env.config.access_token = "secret-token" env.config.registry_password = "secret-pass" result = env.serialize() env_cfg = result["info"]["config"]["environment"] assert "api_key" not in env_cfg - assert "access_token" not in env_cfg assert "registry_password" not in env_cfg From c4f8222866857380a44c187db6939cd0ab0481fd Mon Sep 17 00:00:00 2001 From: JunYeopLee Date: Tue, 24 Mar 2026 11:21:49 +0000 Subject: [PATCH 05/22] fix: respect skip_cache when E2B template already exists get_or_build() was short-circuiting to the else branch without consulting skip_cache, making force-rebuild impossible despite the field documenting "force-rebuild even if it already exists". Co-Authored-By: Claude Sonnet 4.6 --- src/minisweagent/environments/extra/e2b.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/minisweagent/environments/extra/e2b.py b/src/minisweagent/environments/extra/e2b.py index dbb0a0406..fd0b9b94d 100644 --- a/src/minisweagent/environments/extra/e2b.py +++ b/src/minisweagent/environments/extra/e2b.py @@ -102,7 +102,7 @@ def get_or_build(self, docker_image: str) -> str: from e2b import Template template_name = self._image_to_template_name(docker_image) - if not Template.exists(template_name, api_key=self.config.api_key): + if not Template.exists(template_name, api_key=self.config.api_key) or self.config.skip_cache: self.logger.info( "E2B template %s not found. Starting build (up to %d seconds)...", template_name, From 32d1f918cb40803112a75ae66766ae6b21a7b6d3 Mon Sep 17 00:00:00 2001 From: JunYeopLee Date: Tue, 24 Mar 2026 19:55:11 +0000 Subject: [PATCH 06/22] fix: run E2B sandbox commands as root user SWE-bench Docker images have /testbed owned by root, but E2B sandboxes run commands as user (UID 1000) by default, causing permission denied. Add user="root" to commands.run() to match Docker behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/minisweagent/environments/extra/e2b.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/minisweagent/environments/extra/e2b.py b/src/minisweagent/environments/extra/e2b.py index fd0b9b94d..31ac42ba0 100644 --- a/src/minisweagent/environments/extra/e2b.py +++ b/src/minisweagent/environments/extra/e2b.py @@ -190,6 +190,7 @@ def execute(self, action: dict, cwd: str = "", *, timeout: int | None = None) -> try: result = self.sandbox.commands.run( command, + user="root", cwd=cwd or self.config.cwd, timeout=timeout or self.config.timeout, envs=self.config.env or None, From 8bd3fee1ba6992e70e821bed5ba11345f1e70518 Mon Sep 17 00:00:00 2001 From: JunYeopLee Date: Tue, 7 Apr 2026 12:29:48 +0100 Subject: [PATCH 07/22] fix: auto-rebuild E2B template on 404 stale cache error When a cached template has been deleted on E2B's servers, Template.exists() still returns True but Sandbox.create() fails with a 404. This catches the error and triggers a rebuild automatically instead of requiring manual skip_cache=True. --- src/minisweagent/environments/extra/e2b.py | 32 ++++++++++++++++++---- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/minisweagent/environments/extra/e2b.py b/src/minisweagent/environments/extra/e2b.py index 31ac42ba0..519a23c7c 100644 --- a/src/minisweagent/environments/extra/e2b.py +++ b/src/minisweagent/environments/extra/e2b.py @@ -114,6 +114,14 @@ def get_or_build(self, docker_image: str) -> str: self.logger.debug("E2B template %s already exists.", template_name) return template_name + def rebuild(self, docker_image: str) -> str: + """Force-rebuild the E2B template for *docker_image*.""" + template_name = self._image_to_template_name(docker_image) + self.logger.info("Rebuilding E2B template %s...", template_name) + self._build_template(docker_image, template_name) + self.logger.info("E2B template %s rebuilt successfully.", template_name) + return template_name + def _build_template(self, docker_image: str, template_name: str) -> None: """Build an E2B template from *docker_image*. @@ -170,17 +178,31 @@ class E2BEnvironment: def __init__(self, **kwargs: Any) -> None: from e2b import Sandbox + from e2b.exceptions import SandboxException self.logger = logging.getLogger("minisweagent.environment.e2b") self.config = E2BEnvironmentConfig(**kwargs) manager = E2BTemplateManager(self.config) template_name = manager.get_or_build(self.config.image) self.logger.info("Creating E2B sandbox (template: %s)...", template_name) - self.sandbox = Sandbox.create( - template=template_name, - timeout=self.config.sandbox_timeout, - api_key=self.config.api_key, - ) + try: + self.sandbox = Sandbox.create( + template=template_name, + timeout=self.config.sandbox_timeout, + api_key=self.config.api_key, + metadata={"user": "junyeoplee2"}, # TEMP. DO NOT MERGE + ) + except SandboxException as e: + if "404" not in str(e): + raise + self.logger.warning("Template %s not found (stale cache). Rebuilding...", template_name) + manager.rebuild(self.config.image) + self.sandbox = Sandbox.create( + template=template_name, + timeout=self.config.sandbox_timeout, + api_key=self.config.api_key, + metadata={"user": "junyeoplee2"}, # TEMP. DO NOT MERGE + ) self.logger.info("E2B sandbox ready (id: %s)", self.sandbox.sandbox_id) _active_sandboxes.add(self) From f8b9a0511afaad89070eb52b5c8d12a08712c3fb Mon Sep 17 00:00:00 2001 From: JunYeopLee Date: Tue, 7 Apr 2026 12:31:08 +0100 Subject: [PATCH 08/22] fix: ensure sandbox cleanup in process_instance finally block Previously, sandbox resources were only cleaned up via atexit handler, which would not run if the process was forcefully terminated (e.g. double Ctrl+C). Now env.stop() is called in the finally block. --- src/minisweagent/run/benchmarks/swebench.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/minisweagent/run/benchmarks/swebench.py b/src/minisweagent/run/benchmarks/swebench.py index 6ce30cc10..a72668007 100644 --- a/src/minisweagent/run/benchmarks/swebench.py +++ b/src/minisweagent/run/benchmarks/swebench.py @@ -156,6 +156,7 @@ def process_instance( result = None extra_info = {} + env = None try: env = get_sb_environment(config, instance) agent = ProgressTrackingAgent( @@ -189,6 +190,8 @@ def process_instance( logger.info(f"Saved trajectory to '{traj_path}'") update_preds_file(output_dir / "preds.json", instance_id, model.config.model_name, result) progress_manager.on_instance_end(instance_id, exit_status) + if env is not None and hasattr(env, "stop"): + env.stop() def filter_instances( From bec508e97a2fc22768dd4f6425af9bb0e618b39f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:17:34 +0000 Subject: [PATCH 09/22] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/minisweagent/environments/extra/e2b.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/minisweagent/environments/extra/e2b.py b/src/minisweagent/environments/extra/e2b.py index 519a23c7c..0091e8be0 100644 --- a/src/minisweagent/environments/extra/e2b.py +++ b/src/minisweagent/environments/extra/e2b.py @@ -190,7 +190,7 @@ def __init__(self, **kwargs: Any) -> None: template=template_name, timeout=self.config.sandbox_timeout, api_key=self.config.api_key, - metadata={"user": "junyeoplee2"}, # TEMP. DO NOT MERGE + metadata={"user": "junyeoplee2"}, # TEMP. DO NOT MERGE ) except SandboxException as e: if "404" not in str(e): @@ -201,7 +201,7 @@ def __init__(self, **kwargs: Any) -> None: template=template_name, timeout=self.config.sandbox_timeout, api_key=self.config.api_key, - metadata={"user": "junyeoplee2"}, # TEMP. DO NOT MERGE + metadata={"user": "junyeoplee2"}, # TEMP. DO NOT MERGE ) self.logger.info("E2B sandbox ready (id: %s)", self.sandbox.sandbox_id) _active_sandboxes.add(self) From a07b79e12174989d14d9cb445e236fb1e1998fc0 Mon Sep 17 00:00:00 2001 From: Kilian Lieret Date: Wed, 10 Jun 2026 15:00:41 -0700 Subject: [PATCH 10/22] Move imports top Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/minisweagent/environments/extra/e2b.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/minisweagent/environments/extra/e2b.py b/src/minisweagent/environments/extra/e2b.py index 0091e8be0..3b8c85f37 100644 --- a/src/minisweagent/environments/extra/e2b.py +++ b/src/minisweagent/environments/extra/e2b.py @@ -9,6 +9,8 @@ import re from typing import Any +from pydantic import BaseModel, Field + # Module-level registry of live sandboxes for best-effort cleanup on exit # (covers Ctrl+C and unhandled exceptions where __del__ may not be called). _active_sandboxes: set[E2BEnvironment] = set() @@ -22,9 +24,6 @@ def _cleanup_all_sandboxes() -> None: atexit.register(_cleanup_all_sandboxes) -from pydantic import BaseModel, Field - - class E2BEnvironmentConfig(BaseModel): image: str """Docker Hub image name to use as the E2B template base. From 6718c1ca9b045f0328596c0fcc866fbed7a64795 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:00:50 +0000 Subject: [PATCH 11/22] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/minisweagent/environments/extra/e2b.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/minisweagent/environments/extra/e2b.py b/src/minisweagent/environments/extra/e2b.py index 3b8c85f37..2ab97d44f 100644 --- a/src/minisweagent/environments/extra/e2b.py +++ b/src/minisweagent/environments/extra/e2b.py @@ -24,6 +24,7 @@ def _cleanup_all_sandboxes() -> None: atexit.register(_cleanup_all_sandboxes) + class E2BEnvironmentConfig(BaseModel): image: str """Docker Hub image name to use as the E2B template base. From efc393549771cf39dea1c2675235ad685c36927f Mon Sep 17 00:00:00 2001 From: JunYeopLee Date: Tue, 16 Jun 2026 13:09:29 +0100 Subject: [PATCH 12/22] fix: remove temporary E2B sandbox metadata override Co-Authored-By: Claude Opus 4.8 (1M context) --- src/minisweagent/environments/extra/e2b.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/minisweagent/environments/extra/e2b.py b/src/minisweagent/environments/extra/e2b.py index 2ab97d44f..4e38cc778 100644 --- a/src/minisweagent/environments/extra/e2b.py +++ b/src/minisweagent/environments/extra/e2b.py @@ -190,7 +190,6 @@ def __init__(self, **kwargs: Any) -> None: template=template_name, timeout=self.config.sandbox_timeout, api_key=self.config.api_key, - metadata={"user": "junyeoplee2"}, # TEMP. DO NOT MERGE ) except SandboxException as e: if "404" not in str(e): @@ -201,7 +200,6 @@ def __init__(self, **kwargs: Any) -> None: template=template_name, timeout=self.config.sandbox_timeout, api_key=self.config.api_key, - metadata={"user": "junyeoplee2"}, # TEMP. DO NOT MERGE ) self.logger.info("E2B sandbox ready (id: %s)", self.sandbox.sandbox_id) _active_sandboxes.add(self) From 81684001801e45c589fab639c7fb4db521b73392 Mon Sep 17 00:00:00 2001 From: JunYeopLee Date: Tue, 16 Jun 2026 14:34:02 +0100 Subject: [PATCH 13/22] fix: preserve output and exit code for failing E2B commands e2b's commands.run() raises CommandExitException (carrying stdout/stderr/ exit_code) on any non-zero exit. The generic except branch masked every failing command as an infrastructure error with empty output and returncode -1, hiding real command output from the agent. Detect the exit_code-carrying exception and surface the real result instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/minisweagent/environments/extra/e2b.py | 23 ++++++++++++++++------ tests/environments/extra/test_e2b.py | 16 ++++++++++----- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/minisweagent/environments/extra/e2b.py b/src/minisweagent/environments/extra/e2b.py index 4e38cc778..7c73d7c37 100644 --- a/src/minisweagent/environments/extra/e2b.py +++ b/src/minisweagent/environments/extra/e2b.py @@ -221,12 +221,23 @@ def execute(self, action: dict, cwd: str = "", *, timeout: int | None = None) -> "exception_info": "", } except Exception as e: - output = { - "output": "", - "returncode": -1, - "exception_info": f"An error occurred while executing the command: {e}", - "extra": {"exception_type": type(e).__name__, "exception": str(e)}, - } + # e2b raises ``CommandExitException`` (carrying stdout/stderr/exit_code) + # for any non-zero exit. That is a normal command result, not an + # infrastructure error, so surface the real output and exit code + # instead of masking it as a generic failure. + if (exit_code := getattr(e, "exit_code", None)) is not None: + output = { + "output": getattr(e, "stdout", "") + getattr(e, "stderr", ""), + "returncode": exit_code, + "exception_info": "", + } + else: + output = { + "output": "", + "returncode": -1, + "exception_info": f"An error occurred while executing the command: {e}", + "extra": {"exception_type": type(e).__name__, "exception": str(e)}, + } self._check_finished(output) return output diff --git a/tests/environments/extra/test_e2b.py b/tests/environments/extra/test_e2b.py index 0da83d6e6..13d6eaefa 100644 --- a/tests/environments/extra/test_e2b.py +++ b/tests/environments/extra/test_e2b.py @@ -131,16 +131,22 @@ def test_execute_string_action(self): assert output["output"] == "ok\n" def test_execute_nonzero_exit(self): + # e2b's commands.run() RAISES CommandExitException (carrying stdout/stderr/ + # exit_code) on any non-zero exit. A failing command is a normal result, + # not an infrastructure error: its real output and exit code must survive. env = _make_env() - mock_result = MagicMock() - mock_result.stdout = "" - mock_result.stderr = "error\n" - mock_result.exit_code = 1 - env.sandbox.commands.run.return_value = mock_result + exc = Exception("Command exited with code 1") + exc.stdout = "partial stdout\n" + exc.stderr = "boom\n" + exc.exit_code = 1 + env.sandbox.commands.run.side_effect = exc output = env.execute({"command": "false"}) assert output["returncode"] == 1 + assert "boom" in output["output"] + assert "partial stdout" in output["output"] + assert output["exception_info"] == "" def test_execute_exception(self): env = _make_env() From c785bb2e60edbd0dbe7468171c42ba8444297c2e Mon Sep 17 00:00:00 2001 From: JunYeopLee Date: Tue, 16 Jun 2026 14:34:42 +0100 Subject: [PATCH 14/22] fix: inject platform.uname() into E2B template vars Default/mini configs render {{system}}/{{machine}}/... under Jinja StrictUndefined. E2BEnvironment.get_template_vars omitted these keys (unlike docker/local), crashing those configs at agent startup. Merge platform.uname() like the other environments. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/minisweagent/environments/extra/e2b.py | 4 +++- tests/environments/extra/test_e2b.py | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/minisweagent/environments/extra/e2b.py b/src/minisweagent/environments/extra/e2b.py index 7c73d7c37..51291673f 100644 --- a/src/minisweagent/environments/extra/e2b.py +++ b/src/minisweagent/environments/extra/e2b.py @@ -257,9 +257,11 @@ def _check_finished(self, output: dict) -> None: ) def get_template_vars(self, **kwargs: Any) -> dict[str, Any]: + import platform + from minisweagent.utils.serialize import recursive_merge - return recursive_merge(self.config.model_dump(), kwargs) + return recursive_merge(self.config.model_dump(), platform.uname()._asdict(), kwargs) def serialize(self) -> dict: return { diff --git a/tests/environments/extra/test_e2b.py b/tests/environments/extra/test_e2b.py index 13d6eaefa..65f040df3 100644 --- a/tests/environments/extra/test_e2b.py +++ b/tests/environments/extra/test_e2b.py @@ -173,6 +173,26 @@ def test_execute_raises_submitted(self): assert "diff --git" in msg["extra"]["submission"] +# --------------------------------------------------------------------------- +# E2BEnvironment.get_template_vars +# --------------------------------------------------------------------------- + + +class TestE2BEnvironmentTemplateVars: + def test_includes_platform_uname(self): + # Default configs (mini/default) render {{system}}/{{machine}}/... under + # Jinja StrictUndefined, so these keys must be present like docker/local. + env = _make_env() + result = env.get_template_vars() + for key in ("system", "release", "version", "machine", "node", "processor"): + assert key in result + + def test_kwargs_override(self): + env = _make_env() + result = env.get_template_vars(extra="value") + assert result["extra"] == "value" + + # --------------------------------------------------------------------------- # E2BEnvironment.serialize # --------------------------------------------------------------------------- From f5bc01501d95b3cea5c09176595089bdb3066a83 Mon Sep 17 00:00:00 2001 From: JunYeopLee Date: Tue, 16 Jun 2026 14:35:27 +0100 Subject: [PATCH 15/22] fix: keep E2B credentials out of template vars and serialization get_template_vars dumped the full config (api_key, registry credentials) into the Jinja prompt context. Exclude secrets there, and centralize the secret-field set so serialize() also drops registry_username (previously only api_key and registry_password were excluded). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/minisweagent/environments/extra/e2b.py | 8 ++++++-- tests/environments/extra/test_e2b.py | 8 ++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/minisweagent/environments/extra/e2b.py b/src/minisweagent/environments/extra/e2b.py index 51291673f..b5d5fa95b 100644 --- a/src/minisweagent/environments/extra/e2b.py +++ b/src/minisweagent/environments/extra/e2b.py @@ -176,6 +176,9 @@ class E2BEnvironment: See :class:`E2BEnvironmentConfig` for keyword arguments. """ + #: Config fields that must never leak into prompts or saved trajectories. + _SECRET_FIELDS = {"api_key", "registry_password", "registry_username"} + def __init__(self, **kwargs: Any) -> None: from e2b import Sandbox from e2b.exceptions import SandboxException @@ -261,7 +264,8 @@ def get_template_vars(self, **kwargs: Any) -> dict[str, Any]: from minisweagent.utils.serialize import recursive_merge - return recursive_merge(self.config.model_dump(), platform.uname()._asdict(), kwargs) + config = self.config.model_dump(exclude=self._SECRET_FIELDS) + return recursive_merge(config, platform.uname()._asdict(), kwargs) def serialize(self) -> dict: return { @@ -269,7 +273,7 @@ def serialize(self) -> dict: "config": { "environment": self.config.model_dump( mode="json", - exclude={"api_key", "registry_password"}, + exclude=self._SECRET_FIELDS, ), "environment_type": f"{self.__class__.__module__}.{self.__class__.__name__}", } diff --git a/tests/environments/extra/test_e2b.py b/tests/environments/extra/test_e2b.py index 65f040df3..1f2653e0d 100644 --- a/tests/environments/extra/test_e2b.py +++ b/tests/environments/extra/test_e2b.py @@ -187,6 +187,14 @@ def test_includes_platform_uname(self): for key in ("system", "release", "version", "machine", "node", "processor"): assert key in result + def test_excludes_credentials(self): + # Template vars feed the Jinja prompt context; secrets must not leak there. + env = _make_env(api_key="secret-key", registry_password="secret-pass", registry_username="user") + result = env.get_template_vars() + assert "api_key" not in result + assert "registry_password" not in result + assert "registry_username" not in result + def test_kwargs_override(self): env = _make_env() result = env.get_template_vars(extra="value") From fcff143ab96c84c2a0eda9f6122c72269fbc6e21 Mon Sep 17 00:00:00 2001 From: JunYeopLee Date: Tue, 16 Jun 2026 14:37:02 +0100 Subject: [PATCH 16/22] fix: unify environment teardown in swebench finally block Rename E2BEnvironment.stop() to cleanup() to match the docker/singularity/ bubblewrap convention. The swebench finally block previously called env.stop() guarded by hasattr, which was a silent no-op for the default docker backend (it exposes cleanup()). Call whichever teardown method exists (cleanup or stop) so every backend's per-instance resource is released. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/minisweagent/environments/extra/e2b.py | 6 +++--- src/minisweagent/run/benchmarks/swebench.py | 10 ++++++++-- tests/environments/extra/test_e2b.py | 20 ++++++++++---------- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/minisweagent/environments/extra/e2b.py b/src/minisweagent/environments/extra/e2b.py index b5d5fa95b..fce5842a5 100644 --- a/src/minisweagent/environments/extra/e2b.py +++ b/src/minisweagent/environments/extra/e2b.py @@ -19,7 +19,7 @@ def _cleanup_all_sandboxes() -> None: """Kill all sandboxes that are still alive at interpreter shutdown.""" for env in list(_active_sandboxes): - env.stop() + env.cleanup() atexit.register(_cleanup_all_sandboxes) @@ -280,7 +280,7 @@ def serialize(self) -> dict: } } - def stop(self) -> None: + def cleanup(self) -> None: _active_sandboxes.discard(self) sandbox = getattr(self, "sandbox", None) if sandbox is not None: @@ -290,4 +290,4 @@ def stop(self) -> None: pass def __del__(self) -> None: - self.stop() + self.cleanup() diff --git a/src/minisweagent/run/benchmarks/swebench.py b/src/minisweagent/run/benchmarks/swebench.py index a72668007..4f956738c 100644 --- a/src/minisweagent/run/benchmarks/swebench.py +++ b/src/minisweagent/run/benchmarks/swebench.py @@ -190,8 +190,14 @@ def process_instance( logger.info(f"Saved trajectory to '{traj_path}'") update_preds_file(output_dir / "preds.json", instance_id, model.config.model_name, result) progress_manager.on_instance_end(instance_id, exit_status) - if env is not None and hasattr(env, "stop"): - env.stop() + if env is not None: + # Environments expose teardown as either cleanup() (docker, singularity, + # bubblewrap) or stop() (swerex_modal). Call whichever exists so the + # per-instance resource (container / cloud sandbox) is released. + for teardown_name in ("cleanup", "stop"): + if callable(teardown := getattr(env, teardown_name, None)): + teardown() + break def filter_instances( diff --git a/tests/environments/extra/test_e2b.py b/tests/environments/extra/test_e2b.py index 1f2653e0d..9f76d211b 100644 --- a/tests/environments/extra/test_e2b.py +++ b/tests/environments/extra/test_e2b.py @@ -230,26 +230,26 @@ def test_serialize_excludes_credentials(self): # --------------------------------------------------------------------------- -# E2BEnvironment.stop / __del__ +# E2BEnvironment.cleanup / __del__ # --------------------------------------------------------------------------- -class TestE2BEnvironmentStop: - def test_stop_kills_sandbox(self): +class TestE2BEnvironmentCleanup: + def test_cleanup_kills_sandbox(self): env = _make_env() - env.stop() + env.cleanup() env.sandbox.kill.assert_called_once() - def test_stop_tolerates_missing_sandbox(self): + def test_cleanup_tolerates_missing_sandbox(self): with patch.object(E2BEnvironment, "__init__", lambda self, **kw: None): env = E2BEnvironment() # sandbox was never set - env.stop() # should not raise + env.cleanup() # should not raise - def test_stop_tolerates_kill_exception(self): + def test_cleanup_tolerates_kill_exception(self): env = _make_env() env.sandbox.kill.side_effect = RuntimeError("already dead") - env.stop() # should not raise + env.cleanup() # should not raise # --------------------------------------------------------------------------- @@ -258,14 +258,14 @@ def test_stop_tolerates_kill_exception(self): class TestAtexitCleanup: - def test_stop_removes_from_active_sandboxes(self): + def test_cleanup_removes_from_active_sandboxes(self): from minisweagent.environments.extra import e2b as e2b_mod env = _make_env() e2b_mod._active_sandboxes.add(env) assert env in e2b_mod._active_sandboxes - env.stop() + env.cleanup() assert env not in e2b_mod._active_sandboxes def test_cleanup_all_sandboxes_kills_all(self): From 7f040d1c6ee84f00fe07666c77723fd1961bacb6 Mon Sep 17 00:00:00 2001 From: JunYeopLee Date: Tue, 16 Jun 2026 14:38:28 +0100 Subject: [PATCH 17/22] fix: match leading 404 status for E2B stale-template recovery The stale-cache rebuild path tested 'if "404" not in str(e)', which could match an incidental '404' inside a sandbox id or path (triggering an expensive needless rebuild) or miss differently-worded errors. e2b formats API errors as '{status_code}: {message}', so match the leading 404 status code via a small testable helper instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/minisweagent/environments/extra/e2b.py | 15 ++++++++++++++- tests/environments/extra/test_e2b.py | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/minisweagent/environments/extra/e2b.py b/src/minisweagent/environments/extra/e2b.py index fce5842a5..144380b0c 100644 --- a/src/minisweagent/environments/extra/e2b.py +++ b/src/minisweagent/environments/extra/e2b.py @@ -179,6 +179,19 @@ class E2BEnvironment: #: Config fields that must never leak into prompts or saved trajectories. _SECRET_FIELDS = {"api_key", "registry_password", "registry_username"} + @staticmethod + def _is_stale_template_error(e: Exception) -> bool: + """Return True if *e* is a 'template not found' (HTTP 404) error. + + e2b surfaces a missing template as a ``SandboxException`` whose message is + formatted as ``"{status_code}: {message}"`` (see ``e2b.api.handle_api_exception``). + Match the leading 404 status code rather than a bare ``"404"`` substring, + which could appear inside a sandbox id or path and trigger a costly, + unnecessary template rebuild. + """ + match = re.match(r"\s*(\d{3})\b", str(e)) + return match is not None and match.group(1) == "404" + def __init__(self, **kwargs: Any) -> None: from e2b import Sandbox from e2b.exceptions import SandboxException @@ -195,7 +208,7 @@ def __init__(self, **kwargs: Any) -> None: api_key=self.config.api_key, ) except SandboxException as e: - if "404" not in str(e): + if not self._is_stale_template_error(e): raise self.logger.warning("Template %s not found (stale cache). Rebuilding...", template_name) manager.rebuild(self.config.image) diff --git a/tests/environments/extra/test_e2b.py b/tests/environments/extra/test_e2b.py index 9f76d211b..ea7ab48e1 100644 --- a/tests/environments/extra/test_e2b.py +++ b/tests/environments/extra/test_e2b.py @@ -98,6 +98,27 @@ def test_empty_prefix_falls_back_to_hash(self): import re # noqa: E402 (needed after class definitions above for clarity) +# --------------------------------------------------------------------------- +# E2BEnvironment._is_stale_template_error +# --------------------------------------------------------------------------- + + +class TestIsStaleTemplateError: + def test_404_status_prefix_matches(self): + # e2b formats API errors as "{status_code}: {message}". + exc = Exception("404: template foo not found") + assert E2BEnvironment._is_stale_template_error(exc) is True + + def test_other_status_does_not_match(self): + assert E2BEnvironment._is_stale_template_error(Exception("500: internal error")) is False + assert E2BEnvironment._is_stale_template_error(Exception("429: rate limited")) is False + + def test_incidental_404_substring_does_not_match(self): + # "404" appearing inside an id/path must not trigger a costly rebuild. + assert E2BEnvironment._is_stale_template_error(Exception("Sandbox abc404def failed")) is False + assert E2BEnvironment._is_stale_template_error(Exception("error in /path/404/x")) is False + + # --------------------------------------------------------------------------- # E2BEnvironment.execute # --------------------------------------------------------------------------- From 96f9aed4d674ab2fc06287ea1d3388c9af03a0d4 Mon Sep 17 00:00:00 2001 From: JunYeopLee Date: Tue, 16 Jun 2026 14:40:11 +0100 Subject: [PATCH 18/22] fix: always release environment in process_instance teardown env teardown was the last statement of the finally block, so an exception in agent.save() or update_preds_file() would skip it and leak the cloud sandbox/container until its timeout. Move teardown into its own nested finally and extract a _teardown_environment helper so cleanup always runs. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/minisweagent/run/benchmarks/swebench.py | 58 +++++++++++++-------- tests/run/test_swebench.py | 22 ++++++++ 2 files changed, 57 insertions(+), 23 deletions(-) diff --git a/src/minisweagent/run/benchmarks/swebench.py b/src/minisweagent/run/benchmarks/swebench.py index 4f956738c..65a284af8 100644 --- a/src/minisweagent/run/benchmarks/swebench.py +++ b/src/minisweagent/run/benchmarks/swebench.py @@ -133,6 +133,20 @@ def remove_from_preds_file(output_path: Path, instance_id: str): output_path.write_text(json.dumps(output_data, indent=2)) +def _teardown_environment(env: Environment | None) -> None: + """Release the per-instance environment resource (container / cloud sandbox). + + Environments expose teardown as either ``cleanup()`` (docker, singularity, + bubblewrap) or ``stop()`` (swerex_modal); call whichever exists. + """ + if env is None: + return + for teardown_name in ("cleanup", "stop"): + if callable(teardown := getattr(env, teardown_name, None)): + teardown() + break + + def process_instance( instance: dict, output_dir: Path, @@ -174,30 +188,28 @@ def process_instance( exit_status, result = type(e).__name__, "" extra_info = {"traceback": traceback.format_exc(), "exception_str": str(e)} finally: - if agent is not None: - traj_path = instance_dir / f"{instance_id}.traj.json" - agent.save( - traj_path, - { - "info": { - "exit_status": exit_status, - "submission": result, - **extra_info, + # Teardown lives in its own finally so the environment (container / cloud + # sandbox) is always released even if saving the trajectory or updating + # the predictions file raises. + try: + if agent is not None: + traj_path = instance_dir / f"{instance_id}.traj.json" + agent.save( + traj_path, + { + "info": { + "exit_status": exit_status, + "submission": result, + **extra_info, + }, + "instance_id": instance_id, }, - "instance_id": instance_id, - }, - ) - logger.info(f"Saved trajectory to '{traj_path}'") - update_preds_file(output_dir / "preds.json", instance_id, model.config.model_name, result) - progress_manager.on_instance_end(instance_id, exit_status) - if env is not None: - # Environments expose teardown as either cleanup() (docker, singularity, - # bubblewrap) or stop() (swerex_modal). Call whichever exists so the - # per-instance resource (container / cloud sandbox) is released. - for teardown_name in ("cleanup", "stop"): - if callable(teardown := getattr(env, teardown_name, None)): - teardown() - break + ) + logger.info(f"Saved trajectory to '{traj_path}'") + update_preds_file(output_dir / "preds.json", instance_id, model.config.model_name, result) + progress_manager.on_instance_end(instance_id, exit_status) + finally: + _teardown_environment(env) def filter_instances( diff --git a/tests/run/test_swebench.py b/tests/run/test_swebench.py index cab09c0ae..1886127f3 100644 --- a/tests/run/test_swebench.py +++ b/tests/run/test_swebench.py @@ -7,7 +7,10 @@ from minisweagent import package_dir from minisweagent.models.test_models import DeterministicModel, make_output +from unittest.mock import MagicMock + from minisweagent.run.benchmarks.swebench import ( + _teardown_environment, filter_instances, get_swebench_docker_image_name, main, @@ -16,6 +19,25 @@ ) +class TestTeardownEnvironment: + def test_prefers_cleanup_over_stop(self): + env = MagicMock(spec=["cleanup", "stop"]) + _teardown_environment(env) + env.cleanup.assert_called_once() + env.stop.assert_not_called() + + def test_falls_back_to_stop(self): + env = MagicMock(spec=["stop"]) + _teardown_environment(env) + env.stop.assert_called_once() + + def test_tolerates_env_without_teardown(self): + _teardown_environment(MagicMock(spec=[])) # should not raise + + def test_tolerates_none(self): + _teardown_environment(None) # should not raise + + def _make_model_from_fixture(text_outputs: list[str], cost_per_call: float = 1.0, **kwargs) -> DeterministicModel: """Create a DeterministicModel from trajectory fixture data (raw text outputs).""" From 88385c8478bc8a52d0939a959763b70693e46311 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:44:41 +0000 Subject: [PATCH 19/22] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/run/test_swebench.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/run/test_swebench.py b/tests/run/test_swebench.py index 1886127f3..244a326db 100644 --- a/tests/run/test_swebench.py +++ b/tests/run/test_swebench.py @@ -1,14 +1,12 @@ import json import re -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from pydantic import BaseModel from minisweagent import package_dir from minisweagent.models.test_models import DeterministicModel, make_output -from unittest.mock import MagicMock - from minisweagent.run.benchmarks.swebench import ( _teardown_environment, filter_instances, From 21d7e63db21477cb48d9cd3a0495398a7c5877c9 Mon Sep 17 00:00:00 2001 From: JunYeopLee Date: Tue, 23 Jun 2026 15:18:36 +0100 Subject: [PATCH 20/22] fix: add e2b to full extra so pylint can import it --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index fdfe58008..cb3b2765a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ full = [ "swe-rex>=1.4.0", "mini-swe-agent[modal]", "mini-swe-agent[contree]", + "mini-swe-agent[e2b]", ] modal = [ From 7befef8239be9e5c78bc346fa7bca7e18aebfd3e Mon Sep 17 00:00:00 2001 From: JunYeopLee Date: Tue, 23 Jun 2026 15:19:08 +0100 Subject: [PATCH 21/22] test: remove unused _make_mock_e2b helper --- tests/environments/extra/test_e2b.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/environments/extra/test_e2b.py b/tests/environments/extra/test_e2b.py index ea7ab48e1..50455ee8a 100644 --- a/tests/environments/extra/test_e2b.py +++ b/tests/environments/extra/test_e2b.py @@ -1,6 +1,5 @@ """Tests for the E2B cloud sandbox environment.""" -from types import ModuleType from unittest.mock import MagicMock, patch import pytest @@ -17,14 +16,6 @@ # --------------------------------------------------------------------------- -def _make_mock_e2b() -> ModuleType: - """Return a minimal mock of the `e2b` module.""" - mock_e2b = MagicMock() - mock_e2b.Template = MagicMock() - mock_e2b.Sandbox = MagicMock() - return mock_e2b - - def _make_env(**kwargs) -> E2BEnvironment: """Create an E2BEnvironment without touching real E2B infrastructure.""" with patch.object(E2BEnvironment, "__init__", lambda self, **kw: None): From 0eb096f38ffe6d6a2fad908d7b095228ced299fc Mon Sep 17 00:00:00 2001 From: JunYeopLee Date: Tue, 23 Jun 2026 15:19:45 +0100 Subject: [PATCH 22/22] fix: tear down environment when startup command fails --- src/minisweagent/run/benchmarks/swebench.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/minisweagent/run/benchmarks/swebench.py b/src/minisweagent/run/benchmarks/swebench.py index 65a284af8..fddc67b0a 100644 --- a/src/minisweagent/run/benchmarks/swebench.py +++ b/src/minisweagent/run/benchmarks/swebench.py @@ -102,9 +102,15 @@ def get_sb_environment(config: dict, instance: dict) -> Environment: env = get_environment(env_config) if startup_command := config.get("run", {}).get("env_startup_command"): startup_command = Template(startup_command, undefined=StrictUndefined).render(**instance) - out = env.execute(startup_command) - if out["returncode"] != 0: - raise RuntimeError(f"Error executing startup command: {out}") + try: + out = env.execute(startup_command) + if out["returncode"] != 0: + raise RuntimeError(f"Error executing startup command: {out}") + except BaseException: + # The caller has no reference to env yet, so release it here to avoid + # leaking the container / cloud sandbox on startup-command failure. + _teardown_environment(env) + raise return env