feat(open-swe): add E2B sandbox provider (port of upstream 48217b68, #1489) (#199)
Some checks are pending
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Typecheck (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
CI / Docker build smoke (push) Waiting to run
CI / Triage ledger up to date (push) Waiting to run
CI / ui bun.lock in sync (push) Waiting to run

Additive sandbox provider adapted to this fork's synchronous create_sandbox
factory: agent/integrations/e2b.py registered as a lazy-import entry in
SANDBOX_FACTORIES, so the e2b/langchain-e2b imports only load when
SANDBOX_TYPE=e2b (dark-safe on unset env; unset E2B_API_KEY raises a clean
ValueError only when the provider is selected). Supports reconnect-by-id and
optional E2B_TEMPLATE. Non-langsmith providers skip the GitHub proxy step,
so the GitHub-App token flow is untouched.

Upstream: langchain-ai/open-swe 48217b68 (#1489), re-implemented against the
fork's sync sandbox lifecycle rather than cherry-picked (upstream ships on
the deferred async sandbox.py base).

Docs: provider tables/lists in README, CUSTOMIZATION, INSTALLATION; the
CUSTOMIZATION registration example now shows the lazy-tuple form the code
actually uses. uv.lock refreshed (adds e2b, langchain-e2b, dockerfile-parse).
This commit is contained in:
Adam Moussa 2026-07-16 18:23:42 -04:00 • committed by GitHub
parent 22c517a54f
commit cfb5624663
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 212 additions and 8 deletions

View file

@ -63,6 +63,7 @@ Set the `SANDBOX_TYPE` environment variable to switch providers. Each provider h
| `langsmith` (default) | `agent/integrations/langsmith.py` | `LANGSMITH_API_KEY_PROD`, `SANDBOX_TYPE="langsmith"` |
| `daytona` | `agent/integrations/daytona.py` | `DAYTONA_API_KEY`, `SANDBOX_TYPE="daytona"`, optional `DAYTONA_SANDBOX_SNAPSHOT` |
| `runloop` | `agent/integrations/runloop.py` | `RUNLOOP_API_KEY`, `SANDBOX_TYPE="runloop"` |
| `e2b` | `agent/integrations/e2b.py` | `E2B_API_KEY`, `SANDBOX_TYPE="e2b"`, optional `E2B_TEMPLATE` |
| `modal` | `agent/integrations/modal.py` | Modal credentials, `SANDBOX_TYPE="modal"` |
| `local` | `agent/integrations/local.py` | None (no isolation — development only), `SANDBOX_TYPE="local"` |
@ -86,14 +87,12 @@ def create_my_provider_sandbox(sandbox_id: str | None = None):
...
```
2. **Register it** in `agent/utils/sandbox.py` by importing your factory and adding it to `SANDBOX_FACTORIES`:
2. **Register it** in `agent/utils/sandbox.py` by adding it to `SANDBOX_FACTORIES`:
```python
from agent.integrations.my_provider import create_my_provider_sandbox
SANDBOX_FACTORIES = {
...
"my_provider": create_my_provider_sandbox,
"my_provider": ("agent.integrations.my_provider", "create_my_provider_sandbox"),
}
```

View file

@ -599,7 +599,7 @@ REVIEWER_OUTCOMES_DATASET=""
PUBLIC_REPO_ORG_GATE=""
# === Sandbox (optional) ===
# Provider: langsmith (default), modal, daytona, runloop, or local. See CUSTOMIZATION.md.
# Provider: langsmith (default), modal, daytona, runloop, e2b, or local. See CUSTOMIZATION.md.
SANDBOX_TYPE="langsmith"
DEFAULT_SANDBOX_SNAPSHOT_ID="" # Required when SANDBOX_TYPE=langsmith (see step 4c)
DEFAULT_SANDBOX_SNAPSHOT_FS_CAPACITY_BYTES="" # Root FS size in bytes (default: 32 GiB)

View file

@ -54,7 +54,7 @@ create_deep_agent(
Every task runs in its own **isolated cloud sandbox** — a remote Linux environment with full shell access. The repo is cloned in, the agent gets full permissions, and the blast radius of any mistake is fully contained. No production access, no confirmation prompts.
Open SWE supports multiple sandbox providers out of the box — [Modal](https://modal.com/), [Daytona](https://www.daytona.io/), [Runloop](https://www.runloop.ai/), and [LangSmith](https://smith.langchain.com/) — and you can plug in your own. See the [Customization Guide](CUSTOMIZATION.md#1-sandbox) for details.
Open SWE supports multiple sandbox providers out of the box — [Modal](https://modal.com/), [Daytona](https://www.daytona.io/), [Runloop](https://www.runloop.ai/), [E2B](https://e2b.dev/), and [LangSmith](https://smith.langchain.com/) — and you can plug in your own. See the [Customization Guide](CUSTOMIZATION.md#1-sandbox) for details.
This follows the principle all three companies converge on: **isolate first, then give full permissions inside the boundary.**

46
agent/integrations/e2b.py Normal file
View file

@ -0,0 +1,46 @@
import os
from deepagents.backends.protocol import SandboxBackendProtocol
from e2b import Sandbox
from langchain_e2b import E2BSandbox
DEFAULT_E2B_SANDBOX_TIMEOUT = 60 * 60
E2B_TEMPLATE_ENV = "E2B_TEMPLATE"
def create_e2b_sandbox(sandbox_id: str | None = None) -> SandboxBackendProtocol:
"""Create or reconnect to an E2B sandbox.
Args:
sandbox_id: Optional existing sandbox ID to reconnect to.
If None, creates a new sandbox.
Returns:
E2BSandbox instance implementing SandboxBackendProtocol.
"""
api_key = os.getenv("E2B_API_KEY")
if not api_key:
raise ValueError("E2B_API_KEY environment variable is required")
template = os.getenv(E2B_TEMPLATE_ENV)
if template is not None:
template = template.strip()
if not template:
raise ValueError(f"{E2B_TEMPLATE_ENV} must not be empty")
if sandbox_id:
sandbox = Sandbox.connect(
sandbox_id,
timeout=DEFAULT_E2B_SANDBOX_TIMEOUT,
api_key=api_key,
)
elif template:
sandbox = Sandbox.create(
template=template,
timeout=DEFAULT_E2B_SANDBOX_TIMEOUT,
api_key=api_key,
)
else:
sandbox = Sandbox.create(timeout=DEFAULT_E2B_SANDBOX_TIMEOUT, api_key=api_key)
return E2BSandbox(sandbox=sandbox)

View file

@ -10,6 +10,7 @@ SANDBOX_FACTORIES: dict[str, tuple[str, str]] = {
"daytona": ("agent.integrations.daytona", "create_daytona_sandbox"),
"modal": ("agent.integrations.modal", "create_modal_sandbox"),
"runloop": ("agent.integrations.runloop", "create_runloop_sandbox"),
"e2b": ("agent.integrations.e2b", "create_e2b_sandbox"),
"local": ("agent.integrations.local", "create_local_sandbox"),
}
@ -34,7 +35,7 @@ def create_sandbox(
"""Create or reconnect to a sandbox using the configured provider.
The provider is selected via the SANDBOX_TYPE environment variable.
Supported values: langsmith (default), daytona, modal, runloop, local.
Supported values: langsmith (default), daytona, modal, runloop, e2b, local.
Args:
sandbox_id: Optional existing sandbox ID to reconnect to.

View file

@ -27,6 +27,7 @@ dependencies = [
"langchain-daytona>=0.0.7",
"langchain-modal>=0.0.5",
"langchain-runloop>=0.0.6",
"langchain-e2b==0.0.4",
"exa-py>=2.16.0",
"langchain-google-genai>=4.2.7",
"langchain-mcp-adapters>=0.3.0",

View file

@ -0,0 +1,111 @@
import importlib.util
import sys
import types
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class _FakeSandbox:
create_calls: list[dict[str, object]] = []
connect_calls: list[tuple[str, dict[str, object]]] = []
def __init__(self, sandbox_id: str):
self.sandbox_id = sandbox_id
@classmethod
def create(cls, **kwargs):
cls.create_calls.append(kwargs)
return cls("created-sandbox")
@classmethod
def connect(cls, sandbox_id: str, **kwargs):
cls.connect_calls.append((sandbox_id, kwargs))
return cls(sandbox_id)
class _FakeE2BSandbox:
def __init__(self, *, sandbox):
self.sandbox = sandbox
def _load_e2b_module(monkeypatch):
_FakeSandbox.create_calls = []
_FakeSandbox.connect_calls = []
fake_e2b = types.ModuleType("e2b")
fake_e2b.Sandbox = _FakeSandbox
fake_langchain_e2b = types.ModuleType("langchain_e2b")
fake_langchain_e2b.E2BSandbox = _FakeE2BSandbox
monkeypatch.setitem(sys.modules, "e2b", fake_e2b)
monkeypatch.setitem(sys.modules, "langchain_e2b", fake_langchain_e2b)
module_path = ROOT / "agent" / "integrations" / "e2b.py"
spec = importlib.util.spec_from_file_location("e2b_under_test", module_path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_create_e2b_sandbox_uses_default_timeout(monkeypatch):
monkeypatch.setenv("E2B_API_KEY", "api-key")
monkeypatch.delenv("E2B_TEMPLATE", raising=False)
module = _load_e2b_module(monkeypatch)
backend = module.create_e2b_sandbox()
assert backend.sandbox.sandbox_id == "created-sandbox"
assert _FakeSandbox.create_calls == [{"timeout": 3600, "api_key": "api-key"}]
assert _FakeSandbox.connect_calls == []
def test_create_e2b_sandbox_uses_template(monkeypatch):
monkeypatch.setenv("E2B_API_KEY", "api-key")
monkeypatch.setenv("E2B_TEMPLATE", "open-swe-template")
module = _load_e2b_module(monkeypatch)
module.create_e2b_sandbox()
assert _FakeSandbox.create_calls == [
{"template": "open-swe-template", "timeout": 3600, "api_key": "api-key"}
]
def test_create_e2b_sandbox_reconnects_by_id(monkeypatch):
monkeypatch.setenv("E2B_API_KEY", "api-key")
module = _load_e2b_module(monkeypatch)
backend = module.create_e2b_sandbox("sbx_existing")
assert backend.sandbox.sandbox_id == "sbx_existing"
assert _FakeSandbox.connect_calls == [("sbx_existing", {"timeout": 3600, "api_key": "api-key"})]
assert _FakeSandbox.create_calls == []
def test_e2b_missing_api_key(monkeypatch):
monkeypatch.delenv("E2B_API_KEY", raising=False)
monkeypatch.delenv("E2B_TEMPLATE", raising=False)
module = _load_e2b_module(monkeypatch)
try:
module.create_e2b_sandbox()
except ValueError as exc:
assert "E2B_API_KEY" in str(exc)
else:
raise AssertionError("expected missing E2B_API_KEY to fail")
def test_e2b_rejects_empty_template(monkeypatch):
monkeypatch.setenv("E2B_API_KEY", "api-key")
monkeypatch.setenv("E2B_TEMPLATE", " ")
module = _load_e2b_module(monkeypatch)
try:
module.create_e2b_sandbox()
except ValueError as exc:
assert "E2B_TEMPLATE must not be empty" in str(exc)
else:
raise AssertionError("expected empty E2B_TEMPLATE to fail")

48
uv.lock generated
View file

@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.11"
resolution-markers = [
"python_full_version >= '3.14'",
@ -727,6 +727,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
[[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"
@ -736,6 +745,28 @@ 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.33.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "dockerfile-parse" },
{ name = "h2" },
{ name = "httpcore" },
{ name = "httpx" },
{ name = "packaging" },
{ name = "protobuf" },
{ name = "python-dateutil" },
{ name = "rich" },
{ name = "typing-extensions" },
{ name = "wcmatch" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/76/93a08f6ed33e53c030ed931916c9e69eb81cd3b84c500649c7cf4cefd86d/e2b-2.33.0.tar.gz", hash = "sha256:0a14fa7f99fc09b540db0c191b2ce5b1fd4cc3ed837dbd64385add99305776a3", size = 185400, upload-time = "2026-07-16T09:24:45.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/21/2c7bdcd1c6b188b8ae6ee57e542b1e80436cc6692e955abcf8b9355ea9bf/e2b-2.33.0-py3-none-any.whl", hash = "sha256:c10cb697b713d418f5d70f5c9c707e923630a491bd30009c10c4daeb4e21fe51", size = 337079, upload-time = "2026-07-16T09:24:43.972Z" },
]
[[package]]
name = "exa-py"
version = "2.16.0"
@ -1506,6 +1537,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/84/00/7a203f464e5ce08390129c51b60598a23b4aba5fde4b02ed7521da284368/langchain_daytona-0.0.7-py3-none-any.whl", hash = "sha256:3374c99afc51b290539dcc127f4d4a61deb986e06c2d3a8cdc342eb868246ddb", size = 5456, upload-time = "2026-06-03T20:25:58.859Z" },
]
[[package]]
name = "langchain-e2b"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "deepagents" },
{ name = "e2b" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3c/1a/ab8db1288a70b25bfb2489327fae0d3a82ea07231d446083a2dcb0bfc174/langchain_e2b-0.0.4.tar.gz", hash = "sha256:bf543ad7394b83c3f097c9914adfc59bb3af3af9837dea9ada5064abcf4666d2", size = 207862, upload-time = "2026-06-17T21:25:25.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/6f/4e2263f5372019bd05fc8c7fe50674b041cc6b22df3faf3037897587413f/langchain_e2b-0.0.4-py3-none-any.whl", hash = "sha256:6cf20f155ebf6b1a8f36d69dec4b5925c242dfcf779209c94ccb18fda3296419", size = 7482, upload-time = "2026-06-17T21:25:24.025Z" },
]
[[package]]
name = "langchain-fireworks"
version = "1.4.4"
@ -2192,6 +2236,7 @@ dependencies = [
{ name = "langchain-anthropic" },
{ name = "langchain-aws" },
{ name = "langchain-daytona" },
{ name = "langchain-e2b" },
{ name = "langchain-fireworks" },
{ name = "langchain-google-genai" },
{ name = "langchain-mcp-adapters" },
@ -2227,6 +2272,7 @@ requires-dist = [
{ name = "langchain-anthropic", specifier = ">=1.4.6" },
{ name = "langchain-aws", specifier = ">=1.6.2" },
{ name = "langchain-daytona", specifier = ">=0.0.7" },
{ name = "langchain-e2b", specifier = "==0.0.4" },
{ name = "langchain-fireworks", specifier = ">=1.4.4" },
{ name = "langchain-google-genai", specifier = ">=4.2.7" },
{ name = "langchain-mcp-adapters", specifier = ">=0.3.0" },