diff --git a/CHANGELOG.md b/CHANGELOG.md
index f5ecb4f..4211dcb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,62 @@
## Unreleased
+- Added three cloud variants: `e2b`, `coreweave` and `cloudflare`.
+
+ `e2b` runs in a Firecracker microVM through E2B's code interpreter SDK, so it
+ holds a Jupyter kernel per context — `x = 1` in one call is still there in
+ the next — and answers with rich display data: a figure comes back as an
+ image, an HTML repr as HTML. It needs `E2B_API_KEY` and
+ `pip install code-sandboxes[e2b]`. `set_timeout()` extends the life of a
+ running sandbox and `get_host(port)` gives the public host of a port inside.
+
+ `coreweave` runs a container on CoreWeave's GPU cloud. What the SDK offers is
+ `exec` — a process at a time — so a namespace is held here instead: one
+ `python -u -c` session is started with the sandbox and fed JSON lines on
+ stdin, the same arrangement the `modal` variant uses, and snippets share a
+ namespace as they do everywhere else. A session that cannot start, or that
+ goes away, drops back to a process per snippet rather than failing. It needs
+ `CWSANDBOX_API_KEY` and `pip install code-sandboxes[coreweave]`.
+
+ `cloudflare` runs a container on Cloudflare's edge. Cloudflare's own SDK is a
+ Workers binding written in TypeScript, which a Python process cannot hold, so
+ this variant drives the SANDBOX BRIDGE — the Worker Cloudflare publishes to
+ expose the SDK over HTTP. Deploy it once with
+ `npm create cloudflare -- sandbox-bridge --template=cloudflare/sandbox-sdk/bridge/worker`,
+ then set `CLOUDFLARE_SANDBOX_API_URL` and `CLOUDFLARE_SANDBOX_API_KEY`. The
+ bridge gives a started process nothing to write to, so each snippet runs in
+ one of its own and state does not carry between calls — put what shares state
+ in one snippet, or keep it in a file, which does persist. Its manager creates,
+ gets and deletes; it cannot list, because the bridge has no endpoint that
+ enumerates sandboxes, and says so rather than answering with an empty list.
+
+- Hardened the three new variants against silently doing something other than
+ what was asked. `cloudflare` now carries `SandboxConfig.env_vars` into every
+ snippet — the bridge takes no environment when it creates a sandbox, so they
+ had been accepted and dropped — refuses a `network_policy` it cannot apply
+ rather than leaving a sandbox believed to be cut off connected, refuses
+ `get_variable` with the reason instead of answering the misleading "no such
+ variable", and serves `files.read`/`files.write` through the bridge's own
+ file endpoints so they need no session at all. `coreweave` refuses the
+ variable APIs when there is no session process — under `stateful=False`, or
+ after one was lost — rather than reporting a successful set that vanishes
+ with the process, and a snippet that runs past its timeout now has its
+ session STOPPED rather than left running and changing the namespace behind a
+ call that already returned.
+
+- A GPU asked of a variant that has none is now REFUSED rather than dropped.
+ `--gpu` reaches `coreweave`, `datalayer`, `daytona`, `kaggle` and `modal`,
+ and `code-sandboxes exec -v e2b --gpu H100` says which variants can give one
+ instead of running on a CPU as though nothing had been asked — a sandbox that
+ looks as though it asked for an H100 and did not is one whose timings mean
+ nothing. `--gpu` was previously accepted and silently ignored for every other
+ variant.
+
+- Corrected the module docstring of the `modal` variant, which still described
+ the process-per-snippet behaviour that the session process replaced: `modal`
+ keeps a namespace between snippets, and falls back to a process per snippet
+ only when the session cannot be held.
+
- Added GPU support to the `daytona` variant. `gpu=` takes Daytona's own
flavors, `gpu_count=` how many, and several names comma-separated are an
ordered list of preferences Daytona falls back along — `gpu="H100,H200"`
diff --git a/README.md b/README.md
index 9708380..e142d48 100644
--- a/README.md
+++ b/README.md
@@ -15,9 +15,12 @@ Code Sandboxes (`code_sandboxes`) is a Python package for running code in isolat
Canonical variant names:
+- `cloudflare`
+- `coreweave`
- `datalayer`
- `daytona`
- `docker`
+- `e2b`
- `eval`
- `google-colab`
- `jupyter-server`
diff --git a/code_sandboxes/__init__.py b/code_sandboxes/__init__.py
index 5635223..775923d 100644
--- a/code_sandboxes/__init__.py
+++ b/code_sandboxes/__init__.py
@@ -21,6 +21,9 @@
Cloud container sandboxes:
- ModalSandbox: Modal cloud containers, per-snippet process execution
- DaytonaSandbox: Daytona cloud sandboxes, stateful Python interpreter
+ - E2BSandbox: E2B microVMs, stateful Python kernel and rich outputs
+ - CoreWeaveSandbox: CoreWeave containers, stateful Python session
+ - CloudflareSandbox: Cloudflare containers, through a sandbox bridge Worker
Features:
- Code execution with streaming support
@@ -60,6 +63,7 @@
from .base import Sandbox
from .client import CodeExecutionOutcome, CodeSandboxClient, execution_result_to_reply
+from .cloudflare_sandbox import CloudflareSandbox
from .commands import CommandResult, ProcessHandle, SandboxCommands
from .console import (
EXIT_COMMANDS,
@@ -69,9 +73,11 @@
show_code,
show_result,
)
+from .coreweave_sandbox import CoreWeaveSandbox
from .datalayer_sandbox import DatalayerSandbox
from .daytona_sandbox import DaytonaSandbox
from .docker_sandbox import DockerSandbox
+from .e2b_sandbox import E2BSandbox
from .eval_sandbox import EvalSandbox
from .exceptions import (
ContextNotFoundError,
@@ -147,15 +153,18 @@
"EXIT_COMMANDS",
"KAGGLE_API_TOKEN_ENV",
"PROVIDERS",
+ "CloudflareSandbox",
"CodeError",
"CodeExecutionOutcome",
"CodeSandboxClient",
"CommandResult",
"Context",
"ContextNotFoundError",
+ "CoreWeaveSandbox",
"DatalayerSandbox",
"DaytonaSandbox",
"DockerSandbox",
+ "E2BSandbox",
"EvalSandbox",
"ExecutionResult",
"FileInfo",
diff --git a/code_sandboxes/__version__.py b/code_sandboxes/__version__.py
index 1eead6c..f30f69b 100644
--- a/code_sandboxes/__version__.py
+++ b/code_sandboxes/__version__.py
@@ -3,4 +3,4 @@
"""Code Sandboxes."""
-__version__ = "1.1.1"
+__version__ = "1.1.2"
diff --git a/code_sandboxes/base.py b/code_sandboxes/base.py
index b8ab02a..db0a876 100644
--- a/code_sandboxes/base.py
+++ b/code_sandboxes/base.py
@@ -324,6 +324,18 @@ def create( # noqa: C901
from .daytona_sandbox import DaytonaSandbox
sandbox = DaytonaSandbox(config=config, **kwargs)
+ elif variant_value == "e2b":
+ from .e2b_sandbox import E2BSandbox
+
+ sandbox = E2BSandbox(config=config, **kwargs)
+ elif variant_value == "coreweave":
+ from .coreweave_sandbox import CoreWeaveSandbox
+
+ sandbox = CoreWeaveSandbox(config=config, **kwargs)
+ elif variant_value == "cloudflare":
+ from .cloudflare_sandbox import CloudflareSandbox
+
+ sandbox = CloudflareSandbox(config=config, **kwargs)
else:
raise ValueError(
f"Unknown sandbox variant: {variant}. "
@@ -358,7 +370,7 @@ def from_id(cls, sandbox_id: str, **kwargs) -> Sandbox:
return DatalayerSandbox.from_id(sandbox_id, **kwargs)
@classmethod
- def list_environments(
+ def list_environments( # noqa: C901
cls,
variant: SandboxVariant | str = SandboxVariant.DATALAYER,
**kwargs,
@@ -398,6 +410,18 @@ def list_environments(
from .daytona_sandbox import DaytonaSandbox
return DaytonaSandbox.list_environments()
+ if variant_value == "e2b":
+ from .e2b_sandbox import E2BSandbox
+
+ return E2BSandbox.list_environments()
+ if variant_value == "coreweave":
+ from .coreweave_sandbox import CoreWeaveSandbox
+
+ return CoreWeaveSandbox.list_environments()
+ if variant_value == "cloudflare":
+ from .cloudflare_sandbox import CloudflareSandbox
+
+ return CloudflareSandbox.list_environments()
if variant_value == "kaggle":
from .kaggle_sandbox import KaggleSandbox
diff --git a/code_sandboxes/cli.py b/code_sandboxes/cli.py
index 2651716..7d92434 100644
--- a/code_sandboxes/cli.py
+++ b/code_sandboxes/cli.py
@@ -31,9 +31,12 @@
#: brings it back to one of these.
_SUPPORTED_RUN_VARIANTS = frozenset(
{
+ "cloudflare",
+ "coreweave",
"datalayer",
"daytona",
"docker",
+ "e2b",
"eval",
"google-colab",
"jupyter-server",
@@ -44,6 +47,11 @@
)
+#: The variants a GPU can be asked of. The others have none at all, and say so
+#: rather than running on a CPU as though nothing had been asked.
+_GPU_VARIANTS = frozenset({"coreweave", "datalayer", "daytona", "kaggle", "modal"})
+
+
@app.callback(invoke_without_command=True)
def _root(ctx: typer.Context) -> None:
"""Code sandboxes CLI."""
@@ -103,7 +111,7 @@ def _kaggle_kwargs(
return kwargs
-def _resolve_variant_kwargs(
+def _resolve_variant_kwargs( # noqa: C901
variant: str,
server_url: str | None,
kernel_id: str | None,
@@ -131,7 +139,15 @@ def _resolve_variant_kwargs(
if run_url:
kwargs["run_url"] = run_url
- if variant in {"modal", "daytona", "datalayer", "kaggle"} and gpu:
+ if gpu:
+ # A GPU reaches the variants that can give one, and is REFUSED by the
+ # rest rather than dropped: a sandbox that looks as though it asked
+ # for an H100 and did not is one whose timings mean nothing.
+ if variant not in _GPU_VARIANTS:
+ raise typer.BadParameter(
+ f"--gpu is not something {variant} can give. The variants with "
+ "a GPU are: " + ", ".join(sorted(_GPU_VARIANTS)) + "."
+ )
kwargs["gpu"] = gpu
if spot:
@@ -149,10 +165,10 @@ def _resolve_variant_kwargs(
None,
"--variant",
"-v",
- help=(
- "Sandbox variant (datalayer, daytona, docker, eval, "
- "google-colab, jupyter-server, kaggle, modal, monty)."
- ),
+ # Built from the set itself, the way the management commands build theirs:
+ # a hand-written list is one more place to forget a variant, and it had
+ # already fallen behind twice.
+ help="Sandbox variant (" + ", ".join(sorted(_SUPPORTED_RUN_VARIANTS)) + ").",
)
_RUN_TIMEOUT_OPTION = typer.Option(60.0, help="Code execution timeout (seconds).")
_RUN_ENVIRONMENT_OPTION = typer.Option(
diff --git a/code_sandboxes/cloudflare_sandbox.py b/code_sandboxes/cloudflare_sandbox.py
new file mode 100644
index 0000000..239469c
--- /dev/null
+++ b/code_sandboxes/cloudflare_sandbox.py
@@ -0,0 +1,652 @@
+# Copyright (c) 2025-2026 Datalayer, Inc.
+#
+# BSD 3-Clause License
+
+"""Cloudflare sandbox implementation.
+
+`Cloudflare Sandboxes `_ run
+untrusted code in containers on Cloudflare's edge. The SDK for them is a
+Workers binding, written in TypeScript, and a Python process cannot hold one:
+``getSandbox(env.Sandbox, id)`` only means something inside a Worker.
+
+What a Python process CAN talk to is the SANDBOX BRIDGE — a small Worker
+Cloudflare publishes as a reference implementation, which exposes the SDK as
+an HTTP API — so that is what this variant drives. It is deployed once per
+account with
+
+.. code-block:: bash
+
+ npm create cloudflare -- sandbox-bridge \\
+ --template=cloudflare/sandbox-sdk/bridge/worker
+
+which returns a URL and generates a key. Both are what this variant needs:
+``CLOUDFLARE_SANDBOX_API_URL`` and ``CLOUDFLARE_SANDBOX_API_KEY``.
+
+The bridge offers a container and ``exec`` — one process per call, with its
+output streamed back as server-sent events — and no way to feed a process
+stdin after it has started. A namespace therefore CANNOT be held between
+calls the way the CoreWeave and Modal variants hold one: each snippet runs in
+a process of its own, and ``x = 1`` in one call is gone by the next. Combine
+statements into a single snippet when they need to share state, or keep the
+state in a file — the sandbox's filesystem does persist. Rich display data
+has no channel either. The value of a trailing expression is reported.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+import logging
+import os
+import time
+from collections.abc import Iterator
+from typing import Any
+from urllib.parse import quote
+
+from .base import Sandbox
+from .exceptions import (
+ SandboxConfigurationError,
+ SandboxConnectionError,
+ SandboxExecutionError,
+ SandboxNotStartedError,
+)
+from .filesystem import SandboxFilesystem
+from .models import (
+ CodeError,
+ Context,
+ ExecutionResult,
+ Logs,
+ OutputHandler,
+ OutputMessage,
+ ResourceConfig,
+ Result,
+ SandboxConfig,
+ SandboxEnvironment,
+ SandboxInfo,
+ SandboxStatus,
+)
+
+logger = logging.getLogger(__name__)
+
+#: Where the bridge Worker answers, and the key it was deployed with.
+API_URL_ENV_VAR = "CLOUDFLARE_SANDBOX_API_URL"
+API_KEY_ENV_VAR = "CLOUDFLARE_SANDBOX_API_KEY"
+
+#: The Python inside the container the bridge starts.
+DEFAULT_PYTHON = "python3"
+
+#: The program each snippet is run by. It takes its request as its one
+#: argument rather than on stdin — the bridge starts a process and streams its
+#: output, and gives nothing to write to — and answers with one JSON line, so
+#: that what the code itself printed stays separable from the reply.
+_RUNNER_SOURCE = """
+import ast, contextlib, io, json, sys, traceback
+
+request = json.loads(sys.argv[1])
+out, err = io.StringIO(), io.StringIO()
+reply = {"status": "ok"}
+try:
+ tree = ast.parse(request.get("code", ""), mode="exec")
+ trailing = None
+ if tree.body and isinstance(tree.body[-1], ast.Expr):
+ trailing = ast.Expression(tree.body.pop(-1).value)
+ namespace = {"__name__": "__main__"}
+ namespace.update(request.get("globals") or {})
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
+ if tree.body:
+ exec(compile(tree, "", "exec"), namespace)
+ if trailing is not None:
+ value = eval(compile(trailing, "", "eval"), namespace)
+ if value is not None:
+ reply["result"] = repr(value)
+except BaseException as error:
+ reply["status"] = "error"
+ reply["error"] = {
+ "name": type(error).__name__,
+ "value": str(error),
+ "traceback": traceback.format_exc(),
+ }
+reply["stdout"] = out.getvalue()
+reply["stderr"] = err.getvalue()
+sys.stdout.write("\\n" + json.dumps(reply))
+"""
+
+
+def _import_httpx() -> Any:
+ try:
+ import httpx
+ except ImportError as exc:
+ raise SandboxConfigurationError(
+ "httpx is required for CloudflareSandbox. Install it with: "
+ "pip install code-sandboxes[cloudflare]"
+ ) from exc
+ return httpx
+
+
+def _sse_events(lines: Iterator[str]) -> Iterator[tuple[str, str]]:
+ """The (event, data) pairs of a server-sent-event stream.
+
+ Written here rather than taken from a library because the bridge's stream
+ is the whole of its execution protocol and this package should not grow a
+ dependency for twenty lines. A record is a run of ``field: value`` lines
+ ended by a blank one; ``data`` may appear more than once in a record, and
+ the pieces are joined with newlines, as the specification says.
+ """
+ event = "message"
+ data: list[str] = []
+ for raw in lines:
+ line = raw.rstrip("\r")
+ if not line:
+ if data:
+ yield event, "\n".join(data)
+ event, data = "message", []
+ continue
+ if line.startswith(":"):
+ # A comment, which the bridge sends as a keep-alive.
+ continue
+ field, _, value = line.partition(":")
+ value = value[1:] if value.startswith(" ") else value
+ if field == "event":
+ event = value
+ elif field == "data":
+ data.append(value)
+ if data:
+ yield event, "\n".join(data)
+
+
+class _CloudflareFilesystem(SandboxFilesystem):
+ """The filesystem of a Cloudflare sandbox, read through the bridge.
+
+ The base class reads a text file by running a snippet that binds the
+ contents to a name and then reading that name back in a SECOND execution.
+ That works wherever a namespace outlives a snippet, and here nothing does
+ — so text reads and writes are served by the bridge's own file endpoints
+ instead, which is one round trip rather than two and needs no session at
+ all. The binary forms already went this way: they call `_read_file` and
+ `_write_file`, which this variant overrides.
+ """
+
+ def read(self, path: str) -> str:
+ return self._sandbox._read_file(path).decode("utf-8")
+
+ def write(self, path: str, content: str, make_dirs: bool = True) -> None:
+ # `make_dirs` is not honoured by the bridge's PUT, which creates the
+ # parents it needs; the argument is kept for the shared interface.
+ self._sandbox._write_file(path, content.encode("utf-8"))
+
+
+class CloudflareSandbox(Sandbox):
+ """Sandbox backed by a Cloudflare container, through the sandbox bridge.
+
+ Args:
+ config: Optional sandbox configuration.
+ api_url: Where the bridge Worker answers, e.g.
+ ``https://cloudflare-sandbox-bridge.example.workers.dev``. Read
+ from ``CLOUDFLARE_SANDBOX_API_URL`` when omitted.
+ api_key: The key the bridge was deployed with, sent as a bearer token.
+ Read from ``CLOUDFLARE_SANDBOX_API_KEY`` when omitted. A bridge
+ running locally for development may have none.
+ python_executable: The Python inside the container.
+ working_dir: Where snippets run. The container's ``/workspace`` unless
+ the configuration names another.
+ """
+
+ def __init__(
+ self,
+ config: SandboxConfig | None = None,
+ api_url: str | None = None,
+ api_key: str | None = None,
+ python_executable: str = DEFAULT_PYTHON,
+ working_dir: str | None = None,
+ **kwargs,
+ ):
+ super().__init__(config)
+ self._api_url = (api_url or os.environ.get(API_URL_ENV_VAR) or "").rstrip("/")
+ self._api_key = api_key or os.environ.get(API_KEY_ENV_VAR) or ""
+ self._python_executable = python_executable
+ self._working_dir = working_dir or self.config.working_dir or "/workspace"
+ self._client: Any | None = None
+ self._sandbox_id: str | None = None
+ self._execution_count = 0
+ self._extra_kwargs = kwargs
+
+ @classmethod
+ def list_environments(cls) -> list[SandboxEnvironment]:
+ """The environments this provider ships.
+
+ A Cloudflare sandbox is one shape — the container the bridge Worker
+ was deployed with — so there is one environment, and it is named after
+ the bridge rather than after a machine.
+ """
+ return [
+ SandboxEnvironment(
+ name="cloudflare-default",
+ title="Cloudflare",
+ language="python",
+ owner="cloudflare",
+ visibility="cloud",
+ burning_rate=0.0,
+ metadata={"variant": "cloudflare"},
+ ),
+ ]
+
+ def start(self) -> None:
+ if self._started:
+ return
+ if not self._api_url:
+ raise SandboxConfigurationError(
+ "CloudflareSandbox needs the URL of a deployed sandbox bridge: "
+ f"set {API_URL_ENV_VAR}, or pass api_url=. Deploy one with "
+ "`npm create cloudflare -- sandbox-bridge "
+ "--template=cloudflare/sandbox-sdk/bridge/worker`."
+ )
+
+ if self.config.gpu:
+ raise SandboxConfigurationError(
+ "Cloudflare sandboxes have no GPU, so gpu=" + repr(self.config.gpu) + " "
+ "cannot be honoured. Use the daytona, coreweave or modal "
+ "variant for a GPU."
+ )
+
+ # The bridge exposes no networking controls at all — no egress rules,
+ # no allowlist, no switch — so a policy asked for here could only be
+ # accepted and then not applied. A sandbox believed to be cut off from
+ # the network while it is not is the failure that matters.
+ if self.config.network_policy in ("none", "allowlist") or self.config.allowed_hosts:
+ raise SandboxConfigurationError(
+ f"Cloudflare sandboxes cannot restrict the network, so "
+ f"network_policy={self.config.network_policy!r} cannot be "
+ "honoured. Use the e2b variant to cut a sandbox off, or the "
+ "daytona or coreweave variant for an allowlist."
+ )
+
+ self._client = self.build_client()
+ response = self._client.post("/v1/sandbox")
+ self._raise_for_status(response, "create a sandbox")
+ self._sandbox_id = str(response.json()["id"])
+
+ self._default_context = self.create_context("default")
+ self._info = SandboxInfo(
+ id=self._sandbox_id,
+ variant="cloudflare",
+ status=SandboxStatus.RUNNING,
+ created_at=time.time(),
+ name=self.config.name,
+ metadata={
+ "cloudflare_sandbox_id": self._sandbox_id,
+ "api_url": self._api_url,
+ "working_dir": self._working_dir,
+ },
+ resources=ResourceConfig(),
+ config=self.config,
+ )
+ self._started = True
+
+ def build_client(self) -> Any:
+ """An HTTP client for the bridge, with no sandbox behind it yet.
+
+ Separate from :meth:`start` because talking to the bridge and HAVING a
+ sandbox are different things: the manager deletes a sandbox by id and
+ asks after one by id, and creating a throwaway container merely to get
+ a client to do it with would leave a container running and billed.
+ """
+ if not self._api_url:
+ raise SandboxConfigurationError(
+ "CloudflareSandbox needs the URL of a deployed sandbox bridge: "
+ f"set {API_URL_ENV_VAR}, or pass api_url=. Deploy one with "
+ "`npm create cloudflare -- sandbox-bridge "
+ "--template=cloudflare/sandbox-sdk/bridge/worker`."
+ )
+ httpx = _import_httpx()
+ headers = {"Authorization": f"Bearer {self._api_key}"} if self._api_key else {}
+ return httpx.Client(
+ base_url=self._api_url,
+ headers=headers,
+ # Creating a container is slower than the default five seconds,
+ # and an execution runs for as long as the caller asked it to.
+ timeout=httpx.Timeout(30.0, read=None),
+ )
+
+ def _raise_for_status(self, response: Any, what: str) -> None:
+ """Say which call failed, and what the bridge said about it.
+
+ ``raise_for_status`` names the URL and the code and nothing else; a
+ 401 from the bridge means the key is wrong, and a caller should be
+ able to read that from the message rather than infer it.
+ """
+ if response.status_code < 400:
+ return
+ detail = (response.text or "").strip()
+ if response.status_code in (401, 403):
+ detail = (
+ f"{detail} — the bridge refused the key. Check "
+ f"{API_KEY_ENV_VAR} against the secret it was deployed with."
+ ).strip(" —")
+ raise SandboxConnectionError(
+ self._api_url,
+ f"could not {what}: HTTP {response.status_code}. {detail}".strip(),
+ )
+
+ def stop(self) -> None:
+ if not self._started:
+ return
+ if self._client is not None and self._sandbox_id:
+ try:
+ self._client.delete(f"/v1/sandbox/{quote(self._sandbox_id)}")
+ except Exception:
+ logger.debug(
+ "Ignoring error while destroying the Cloudflare sandbox", exc_info=True
+ )
+ if self._client is not None:
+ with_close = getattr(self._client, "close", None)
+ if with_close is not None:
+ with_close()
+ self._client = None
+ self._sandbox_id = None
+ self._started = False
+ if self._info:
+ self._info.status = SandboxStatus.STOPPED
+
+ def is_running(self) -> bool:
+ """Whether the container is still up.
+
+ Cloudflare puts a sandbox to sleep on its own schedule, so a sandbox
+ this process created may be gone without this process having asked for
+ it. The bridge answers the question directly.
+ """
+ if not self._started or self._client is None or not self._sandbox_id:
+ return False
+ try:
+ response = self._client.get(f"/v1/sandbox/{quote(self._sandbox_id)}/running")
+ return bool(response.json().get("running"))
+ except Exception:
+ return False
+
+ def run_code(
+ self,
+ code: str,
+ language: str = "python",
+ context: Context | None = None,
+ on_stdout: OutputHandler[OutputMessage] | None = None,
+ on_stderr: OutputHandler[OutputMessage] | None = None,
+ on_result: OutputHandler[Result] | None = None,
+ on_error: OutputHandler[CodeError] | None = None,
+ envs: dict[str, str] | None = None,
+ timeout: float | None = None,
+ ) -> ExecutionResult:
+ if not self._started or self._client is None or not self._sandbox_id:
+ raise SandboxNotStartedError()
+ if language != "python":
+ raise ValueError(f"CloudflareSandbox only supports Python, got: {language}")
+
+ started_at = time.time()
+ self._execution_count += 1
+ seconds = timeout if timeout is not None else self.config.timeout
+ # The environment of the CONFIGURATION as well as the one asked for
+ # here. The bridge takes no environment when it creates a sandbox — it
+ # has nowhere to put one — so `env_vars` would otherwise be accepted
+ # and then silently dropped from every snippet. Per-call values win.
+ environment = {**(self.config.env_vars or {}), **(envs or {})}
+ request = json.dumps({"code": _with_envs(code, environment)})
+
+ try:
+ stdout, stderr, exit_code = self._exec(
+ [self._python_executable, "-u", "-c", _RUNNER_SOURCE, request],
+ seconds,
+ )
+ except Exception as error:
+ # The bridge or the container, not the code.
+ return ExecutionResult(
+ execution_ok=False,
+ execution_error=f"Failed to execute code on Cloudflare: {error}",
+ started_at=started_at,
+ completed_at=time.time(),
+ context_id=context.id if context else "default",
+ )
+
+ reply = _reply_of(stdout)
+ if reply is None:
+ # The runner never got to answer: the process was killed, or the
+ # image has no Python where it was looked for. What the container
+ # said is the only evidence, so it is what gets reported.
+ return ExecutionResult(
+ execution_ok=False,
+ execution_error=(
+ f"The Cloudflare sandbox answered with nothing this package "
+ f"could read (exit code {exit_code}). {stderr or stdout}".strip()
+ ),
+ started_at=started_at,
+ completed_at=time.time(),
+ context_id=context.id if context else "default",
+ )
+
+ return self._execution_result(
+ reply, context, started_at, on_stdout, on_stderr, on_result, on_error
+ )
+
+ def _exec(self, argv: list[str], timeout: float) -> tuple[str, str, int | None]:
+ """One process in the container, and what it wrote.
+
+ The bridge answers with a server-sent-event stream: the output as it
+ is written, base64-encoded, and one terminal event — the exit code, or
+ an error that stopped it from running at all.
+ """
+ stdout: list[bytes] = []
+ stderr: list[bytes] = []
+ exit_code: int | None = None
+ failure: str | None = None
+
+ with self._client.stream(
+ "POST",
+ f"/v1/sandbox/{quote(self._sandbox_id or '')}/exec",
+ json={
+ "argv": argv,
+ "cwd": self._working_dir,
+ "timeout_ms": max(1, round(timeout * 1000)),
+ },
+ timeout=timeout + 30.0,
+ ) as response:
+ if response.status_code >= 400:
+ response.read()
+ self._raise_for_status(response, "run a command")
+ for event, data in _sse_events(response.iter_lines()):
+ if event == "stdout":
+ stdout.append(_decode(data))
+ elif event == "stderr":
+ stderr.append(_decode(data))
+ elif event == "exit":
+ with_code = _json_or_none(data) or {}
+ exit_code = with_code.get("exit_code")
+ elif event == "error":
+ with_error = _json_or_none(data) or {}
+ failure = str(with_error.get("error") or data)
+
+ if failure is not None:
+ raise SandboxExecutionError("SandboxError", failure)
+ return (
+ b"".join(stdout).decode("utf-8", errors="replace"),
+ b"".join(stderr).decode("utf-8", errors="replace"),
+ exit_code,
+ )
+
+ def _execution_result(
+ self,
+ reply: dict,
+ context: Context | None,
+ started_at: float,
+ on_stdout: OutputHandler[OutputMessage] | None,
+ on_stderr: OutputHandler[OutputMessage] | None,
+ on_result: OutputHandler[Result] | None,
+ on_error: OutputHandler[CodeError] | None,
+ ) -> ExecutionResult:
+ """One reply of the runner, as an `ExecutionResult`.
+
+ The runner collects the output and answers once, so the callbacks are
+ called here, in order, on the lines it carried. A caller that streams
+ sees the lines it would have seen, later.
+ """
+ now = time.time()
+ stdout_messages: list[OutputMessage] = []
+ for line in _lines(reply.get("stdout")):
+ message = OutputMessage(line=line, timestamp=now, error=False)
+ stdout_messages.append(message)
+ if on_stdout:
+ on_stdout(message)
+
+ stderr_messages: list[OutputMessage] = []
+ for line in _lines(reply.get("stderr")):
+ message = OutputMessage(line=line, timestamp=now, error=True)
+ stderr_messages.append(message)
+ if on_stderr:
+ on_stderr(message)
+
+ results: list[Result] = []
+ if reply.get("result") is not None:
+ value = Result(data={"text/plain": reply["result"]}, is_main_result=True)
+ results.append(value)
+ if on_result:
+ on_result(value)
+
+ code_error: CodeError | None = None
+ error = reply.get("error")
+ if reply.get("status") == "error" and isinstance(error, dict):
+ code_error = CodeError(
+ name=error.get("name") or "Error",
+ value=error.get("value") or "",
+ traceback=error.get("traceback") or "",
+ )
+ if on_error:
+ on_error(code_error)
+
+ return ExecutionResult(
+ results=results,
+ logs=Logs(stdout=stdout_messages, stderr=stderr_messages),
+ execution_ok=True,
+ code_error=code_error,
+ execution_count=self._execution_count,
+ context_id=context.id if context else "default",
+ started_at=started_at,
+ completed_at=now,
+ )
+
+ def _do_interrupt(self) -> bool:
+ """The bridge takes no interrupt; a timeout is the only stop."""
+ return False
+
+ @property
+ def files(self) -> SandboxFilesystem:
+ """The bridge-backed filesystem, rather than the base's code-driven one."""
+ if self._files is None:
+ self._files = _CloudflareFilesystem(self)
+ return self._files
+
+ def get_variable(self, name: str, context: Context | None = None) -> Any:
+ """Refused: reading a variable takes a session, and there is none.
+
+ The base class reads a variable in TWO executions — it binds the value
+ to a name of its own, then reads that name back — which every other
+ variant serves because its namespace outlives a snippet. Here the
+ first execution's process is gone before the second starts, so the
+ read would fail as "no such variable": true of the name, and entirely
+ misleading about the reason.
+ """
+ raise SandboxConfigurationError(
+ f"A Cloudflare sandbox runs each snippet in a process of its own, "
+ f"so there is no session to read {name!r} from. Have the snippet "
+ "print what you need and read it from the execution, or keep it "
+ "in a file — the filesystem of the sandbox does persist."
+ )
+
+ def _get_internal_variable(self, name: str, context: Context | None = None) -> Any:
+ """The base class reaches this only through `get_variable`, which is
+ refused above; it is implemented so the abstraction stays satisfied."""
+ return self.get_variable(name, context)
+
+ def _set_internal_variable(self, name: str, value: Any, context: Context | None = None) -> None:
+ if not self._started:
+ raise SandboxNotStartedError()
+ try:
+ json.dumps(value)
+ except TypeError as error:
+ raise SandboxConfigurationError(
+ f"A Cloudflare sandbox runs elsewhere, so {name!r} has to cross "
+ "as JSON and this value cannot be encoded. Build it inside the "
+ "sandbox with run_code instead."
+ ) from error
+ raise SandboxConfigurationError(
+ "A Cloudflare sandbox runs each snippet in a process of its own, "
+ f"so {name!r} would be gone before the next one reads it. Set it "
+ "inside the snippet that uses it, or keep it in a file — the "
+ "filesystem of the sandbox does persist."
+ )
+
+ def _write_file(self, path: str, content: bytes) -> None:
+ """Straight to the filesystem of the container, not through the code."""
+ if not self._started or self._client is None:
+ raise SandboxNotStartedError()
+ response = self._client.put(self._file_url(path), content=content)
+ self._raise_for_status(response, f"write {path}")
+
+ def _read_file(self, path: str) -> bytes:
+ if not self._started or self._client is None:
+ raise SandboxNotStartedError()
+ response = self._client.get(self._file_url(path))
+ if response.status_code == 404:
+ raise FileNotFoundError(f"Could not read file: {path}")
+ self._raise_for_status(response, f"read {path}")
+ return bytes(response.content)
+
+ def _file_url(self, path: str) -> str:
+ """The bridge's URL for one file of this sandbox.
+
+ The path travels as the tail of the URL, so its separators must stay
+ separators while everything else about it is escaped.
+ """
+ return f"/v1/sandbox/{quote(self._sandbox_id or '')}/file/{quote(path.lstrip('/'))}"
+
+
+def _decode(data: str) -> bytes:
+ """One base64 payload of the stream, or nothing when it is not one."""
+ try:
+ return base64.b64decode(data)
+ except Exception:
+ return data.encode()
+
+
+def _json_or_none(data: str) -> dict | None:
+ try:
+ parsed = json.loads(data)
+ except ValueError:
+ return None
+ return parsed if isinstance(parsed, dict) else None
+
+
+def _reply_of(stdout: str) -> dict | None:
+ """The runner's JSON reply, out of everything the process wrote.
+
+ It is the LAST line: the runner writes it after a newline of its own, so
+ whatever the container printed on its own account — a warning from the
+ interpreter, a message from an entrypoint — comes before it.
+ """
+ for line in reversed(stdout.splitlines()):
+ reply = _json_or_none(line)
+ if reply is not None:
+ return reply
+ return None
+
+
+def _lines(raw: Any) -> list[str]:
+ """The lines of one stream, without the empty one a trailing newline makes."""
+ if not raw:
+ return []
+ return str(raw).splitlines()
+
+
+def _with_envs(code: str, envs: dict[str, str] | None) -> str:
+ """The snippet, with the environment it asked for set first."""
+ if not envs:
+ return code
+ assignments = "".join(
+ f"_code_sandboxes_os.environ[{key!r}] = {value!r}\n" for key, value in envs.items()
+ )
+ return f"import os as _code_sandboxes_os\n{assignments}del _code_sandboxes_os\n{code}"
diff --git a/code_sandboxes/coreweave_sandbox.py b/code_sandboxes/coreweave_sandbox.py
new file mode 100644
index 0000000..5f95c18
--- /dev/null
+++ b/code_sandboxes/coreweave_sandbox.py
@@ -0,0 +1,754 @@
+# Copyright (c) 2025-2026 Datalayer, Inc.
+#
+# BSD 3-Clause License
+
+"""CoreWeave sandbox implementation.
+
+`CoreWeave Sandboxes `_ run a
+container on CoreWeave's own GPU cloud, started against a managed runner and
+addressed through the ``cwsandbox`` SDK. What it offers is a container and
+``exec`` — a process at a time, with its streams — and nothing that holds a
+Python namespace between calls.
+
+So one is held here. A single ``python -u -c`` process is started with the
+sandbox and fed JSON lines on stdin, one request and one reply each, which is
+the same arrangement the Modal variant uses and for the same reason: ``x = 1``
+in one call and ``print(x)`` in the next behave the way they do in every other
+variant of this package. A driver that cannot be started, or that goes away
+mid-session, drops the sandbox back to a process per snippet — working, merely
+stateless — rather than failing.
+
+Rich display data — a figure, an HTML repr — has no channel in this
+arrangement, and is not reported. The value of a trailing expression is.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import json
+import logging
+import math
+import time
+from typing import Any
+
+from .base import Sandbox
+from .exceptions import (
+ SandboxConfigurationError,
+ SandboxExecutionError,
+ SandboxNotStartedError,
+ VariableNotFoundError,
+)
+from .models import (
+ CodeError,
+ Context,
+ ExecutionResult,
+ Logs,
+ OutputHandler,
+ OutputMessage,
+ ResourceConfig,
+ Result,
+ SandboxConfig,
+ SandboxEnvironment,
+ SandboxInfo,
+ SandboxStatus,
+)
+
+logger = logging.getLogger(__name__)
+
+#: What every sandbox this package creates is tagged with, so the ones it made
+#: can be told from the rest of an organization's.
+CREATED_BY_LABEL = "code-sandboxes"
+
+#: The container a sandbox runs when the caller names no image. It is the
+#: SDK's own default, and carries nothing but Python.
+DEFAULT_CONTAINER_IMAGE = "python:3.11"
+
+#: The session process. CoreWeave's `exec` is one process per call — whatever
+#: a snippet defined is gone when its process exits, so a namespace has to be
+#: kept by something that outlives them. This driver is started once and fed
+#: JSON lines on stdin — one request, one reply — executing everything in a
+#: single namespace, and answering with what the code printed, what its
+#: trailing expression evaluated to, and the error it raised.
+_DRIVER_SOURCE = """
+import ast, contextlib, io, json, sys, traceback
+
+namespace = {"__name__": "__main__"}
+for line in sys.stdin:
+ line = line.strip()
+ if not line:
+ continue
+ request = json.loads(line)
+ out, err = io.StringIO(), io.StringIO()
+ reply = {"seq": request.get("seq"), "status": "ok"}
+ try:
+ tree = ast.parse(request.get("code", ""), mode="exec")
+ trailing = None
+ if tree.body and isinstance(tree.body[-1], ast.Expr):
+ trailing = ast.Expression(tree.body.pop(-1).value)
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
+ if tree.body:
+ exec(compile(tree, "", "exec"), namespace)
+ if trailing is not None:
+ value = eval(compile(trailing, "", "eval"), namespace)
+ if value is not None:
+ reply["result"] = repr(value)
+ except BaseException as error:
+ reply["status"] = "error"
+ reply["error"] = {
+ "name": type(error).__name__,
+ "value": str(error),
+ "traceback": traceback.format_exc(),
+ }
+ reply["stdout"] = out.getvalue()
+ reply["stderr"] = err.getvalue()
+ print(json.dumps(reply), flush=True)
+"""
+
+#: The program a snippet is run by when there is no driver — the fallback, and
+#: what `commands.run` uses.
+_STATELESS_SOURCE = """
+import ast, contextlib, io, json, sys, traceback
+
+request = json.loads(sys.stdin.read() or "{}")
+out, err = io.StringIO(), io.StringIO()
+reply = {"status": "ok"}
+try:
+ tree = ast.parse(request.get("code", ""), mode="exec")
+ trailing = None
+ if tree.body and isinstance(tree.body[-1], ast.Expr):
+ trailing = ast.Expression(tree.body.pop(-1).value)
+ namespace = {"__name__": "__main__"}
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
+ if tree.body:
+ exec(compile(tree, "", "exec"), namespace)
+ if trailing is not None:
+ value = eval(compile(trailing, "", "eval"), namespace)
+ if value is not None:
+ reply["result"] = repr(value)
+except BaseException as error:
+ reply["status"] = "error"
+ reply["error"] = {
+ "name": type(error).__name__,
+ "value": str(error),
+ "traceback": traceback.format_exc(),
+ }
+reply["stdout"] = out.getvalue()
+reply["stderr"] = err.getvalue()
+print(json.dumps(reply), flush=True)
+"""
+
+
+def _import_cwsandbox() -> Any:
+ try:
+ import cwsandbox
+ except ImportError as exc:
+ raise SandboxConfigurationError(
+ "cwsandbox is required for CoreWeaveSandbox. Install it with: "
+ "pip install code-sandboxes[coreweave]"
+ ) from exc
+ return cwsandbox
+
+
+class CoreWeaveSandbox(Sandbox):
+ """Sandbox backed by a CoreWeave sandbox.
+
+ Args:
+ config: Optional sandbox configuration.
+ api_key: CoreWeave API token. The SDK reads ``CWSANDBOX_API_KEY`` from
+ the environment when this is omitted; passing it here sets that
+ variable for the process, since the SDK offers no other way in.
+ base_url: The control plane to talk to. ``CWSANDBOX_BASE_URL`` when
+ omitted, which itself defaults to ``https://api.cwsandbox.com``.
+ container_image: The image the sandbox runs.
+ :data:`DEFAULT_CONTAINER_IMAGE` when omitted.
+ profile_names: The sandbox profiles to run under — CoreWeave's own
+ policy objects, which decide what a sandbox may do.
+ runner_ids: Particular managed runners to place the sandbox on. Left
+ to CoreWeave when omitted, which is the usual case.
+ python_executable: The Python inside the container. ``python3`` unless
+ the image keeps it elsewhere.
+ stateful: Whether to keep one process for the session, so snippets
+ share a namespace. On by default; turning it off runs each snippet
+ in a process of its own.
+ """
+
+ def __init__(
+ self,
+ config: SandboxConfig | None = None,
+ api_key: str | None = None,
+ base_url: str | None = None,
+ container_image: str | None = None,
+ profile_names: list[str] | None = None,
+ runner_ids: list[str] | None = None,
+ python_executable: str = "python3",
+ stateful: bool = True,
+ **kwargs,
+ ):
+ super().__init__(config)
+ self._api_key = api_key
+ self._base_url = base_url
+ self._container_image = container_image
+ self._profile_names = profile_names
+ self._runner_ids = runner_ids
+ self._python_executable = python_executable
+ self._stateful = stateful
+ self._sandbox: Any | None = None
+ self._driver: Any | None = None
+ self._driver_replies: Any | None = None
+ self._driver_seq = 0
+ self._execution_count = 0
+ self._extra_kwargs = kwargs
+
+ @classmethod
+ def list_environments(cls) -> list[SandboxEnvironment]:
+ """The environments this provider ships.
+
+ CoreWeave takes an image and a machine specification per sandbox
+ rather than a catalogue of named ones, so what is offered here are the
+ shapes worth naming — a plain container and one with a GPU, which is
+ what CoreWeave is for. The shape is asked for by argument: `gpu=`,
+ `container_image=`.
+ """
+ return [
+ SandboxEnvironment(
+ name="coreweave-default",
+ title="CoreWeave",
+ language="python",
+ owner="coreweave",
+ visibility="cloud",
+ burning_rate=0.0,
+ metadata={
+ "variant": "coreweave",
+ "container_image": DEFAULT_CONTAINER_IMAGE,
+ "gpu": None,
+ },
+ ),
+ SandboxEnvironment(
+ name="coreweave-gpu",
+ title="CoreWeave GPU",
+ language="python",
+ owner="coreweave",
+ visibility="cloud",
+ burning_rate=0.0,
+ metadata={
+ "variant": "coreweave",
+ "container_image": DEFAULT_CONTAINER_IMAGE,
+ "gpu": "H100",
+ },
+ ),
+ ]
+
+ def start(self) -> None:
+ if self._started:
+ return
+
+ cwsandbox = _import_cwsandbox()
+ self._apply_credentials()
+ self._sandbox = cwsandbox.Sandbox.run(**self._run_params(cwsandbox))
+ # `run` sends the request and answers at once; the container is not
+ # there to `exec` in until it is running. Waited for WITHOUT a deadline
+ # of ours: `config.timeout` bounds how long a snippet may run, and
+ # spending it on a cold start — pulling an image, finding a GPU
+ # runner — would fail a sandbox that was merely slow to arrive. The
+ # SDK's own request timeout is what bounds this.
+ self._sandbox.wait()
+
+ if self._stateful:
+ self._start_driver()
+
+ self._default_context = self.create_context("default")
+ self._info = SandboxInfo(
+ id=self._sandbox.sandbox_id,
+ variant="coreweave",
+ status=SandboxStatus.RUNNING,
+ created_at=time.time(),
+ name=self.config.name,
+ metadata={
+ "coreweave_sandbox_id": self._sandbox.sandbox_id,
+ "container_image": self._container_image or DEFAULT_CONTAINER_IMAGE,
+ "runner_id": getattr(self._sandbox, "runner_id", None),
+ "stateful": self._driver is not None,
+ },
+ resources=ResourceConfig(
+ cpu=self.config.cpu_limit,
+ memory=self.config.memory_limit,
+ gpu=self.config.gpu,
+ ),
+ config=self.config,
+ )
+ self._started = True
+
+ def _apply_credentials(self) -> None:
+ """Put an explicitly given token where the SDK looks for one.
+
+ ``cwsandbox`` authenticates from ``CWSANDBOX_API_KEY`` — there is no
+ argument for a token on `Sandbox.run` — so a caller who passes one
+ here has it set for this process. A caller who passes none changes
+ nothing, and the environment answers as it did.
+ """
+ import os
+
+ if self._api_key:
+ os.environ["CWSANDBOX_API_KEY"] = self._api_key
+ if self._base_url:
+ os.environ["CWSANDBOX_BASE_URL"] = self._base_url
+
+ def _run_params(self, cwsandbox: Any) -> dict[str, Any]:
+ """What to ask CoreWeave for, from the configuration of this sandbox."""
+ params: dict[str, Any] = {
+ "container_image": self._container_image or DEFAULT_CONTAINER_IMAGE,
+ "tags": self._tag_list(),
+ }
+ if self.config.env_vars:
+ params["environment_variables"] = dict(self.config.env_vars)
+ if self.config.max_lifetime:
+ params["max_lifetime_seconds"] = float(self.config.max_lifetime)
+ if self._profile_names:
+ params["profile_names"] = list(self._profile_names)
+ if self._runner_ids:
+ params["runner_ids"] = list(self._runner_ids)
+ resources = self._resources(cwsandbox)
+ if resources is not None:
+ params["resources"] = resources
+ network = self._network_params(cwsandbox)
+ if network is not None:
+ params["network"] = network
+ return params
+
+ def _tag_list(self) -> list[str]:
+ """The metadata the sandbox carries in CoreWeave.
+
+ CoreWeave keeps tags as a flat list of strings rather than as a map,
+ so a pair is written ``key=value`` — which is how the name of the
+ sandbox and the tags of the configuration survive the crossing and
+ can be read back by `list`.
+ """
+ tags = [f"created-by={CREATED_BY_LABEL}"]
+ if self.config.name:
+ tags.append(f"name={self.config.name}")
+ tags.extend(f"{key}={value}" for key, value in self._tags.items())
+ return tags
+
+ def _resources(self, cwsandbox: Any) -> Any | None:
+ """The machine asked for, or nothing when the defaults will do.
+
+ CoreWeave takes Kubernetes quantities — ``"2"`` cores, ``"4Gi"`` of
+ memory — as requests and limits, and a GPU as a count and a kind.
+ """
+ requests: dict[str, str] = {}
+ if self.config.cpu_limit:
+ requests["cpu"] = str(max(1, math.ceil(self.config.cpu_limit)))
+ if self.config.memory_limit:
+ requests["memory"] = f"{max(1, math.ceil(self.config.memory_limit / 1024**3))}Gi"
+ gpu: dict[str, Any] | None = None
+ if self.config.gpu:
+ # One name, or the first of several: CoreWeave places a sandbox on
+ # a runner of one kind, and has no fallback list of its own.
+ kind = self.config.gpu.split(",")[0].strip()
+ if kind:
+ gpu = {"count": 1, "type": kind}
+ if not requests and gpu is None:
+ return None
+ return cwsandbox.ResourceOptions(
+ requests=requests or None,
+ limits=requests or None,
+ gpu=gpu,
+ )
+
+ def _network_params(self, cwsandbox: Any) -> Any | None:
+ """What the network policy of the configuration means to CoreWeave."""
+ policy = self.config.network_policy
+ if policy == "none":
+ return cwsandbox.NetworkOptions(deny_egress=True, deny_ingress=True)
+ if policy == "allowlist":
+ if not self.config.allowed_hosts:
+ raise SandboxConfigurationError(
+ "network_policy='allowlist' needs allowed_hosts: a sandbox "
+ "allowed nothing is a sandbox with no network at all, which "
+ "is network_policy='none'."
+ )
+ # An egress list IS the allowlist: naming what may be reached
+ # denies everything else.
+ return cwsandbox.NetworkOptions(
+ egress=[cwsandbox.EgressRule(dns_name=host) for host in self.config.allowed_hosts]
+ )
+ return None
+
+ def _start_driver(self) -> None:
+ """Start the session process, and fall back to nothing on failure.
+
+ A driver that cannot come up leaves `self._driver` unset, and
+ `run_code` then executes each snippet in its own process — working,
+ merely stateless.
+ """
+ import queue
+ import threading
+
+ try:
+ driver = self._sandbox.exec(
+ [self._python_executable, "-u", "-c", _DRIVER_SOURCE],
+ stdin=True,
+ )
+ except Exception:
+ logger.warning(
+ "The CoreWeave session driver could not be started; snippets will not share state.",
+ exc_info=True,
+ )
+ return
+ replies: queue.Queue = queue.Queue()
+
+ def pump() -> None:
+ # The reader dies with the driver, and says so with the sentinel
+ # below rather than with an exception nobody is there to catch.
+ with contextlib.suppress(Exception):
+ for line in driver.stdout:
+ replies.put(line)
+ replies.put(None)
+
+ # A thread reads the replies: the stream blocks, and a request that
+ # never gets its answer must time out rather than hang run_code.
+ thread = threading.Thread(target=pump, name="coreweave-driver-stdout", daemon=True)
+ thread.start()
+ self._driver = driver
+ self._driver_replies = replies
+ self._driver_seq = 0
+
+ def _driver_request(self, code: str, timeout: float) -> dict | None:
+ """One request to the session process, or None when it cannot serve."""
+ import queue
+
+ if self._driver is None:
+ return None
+ self._driver_seq += 1
+ try:
+ self._driver.stdin.writeline(json.dumps({"seq": self._driver_seq, "code": code}))
+ except Exception:
+ logger.warning("The CoreWeave session driver went away; restarting stateless.")
+ self._discard_driver()
+ return None
+ deadline = time.monotonic() + timeout
+ while True:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ # Giving up on the ANSWER is not giving up on the work: the
+ # session process is still running that snippet, and would go
+ # on mutating the namespace long after this call reported a
+ # timeout. So the process is cancelled and dropped. The next
+ # call starts a fresh session, which costs the state — the
+ # honest price of a snippet that would not stop.
+ self._discard_driver()
+ raise TimeoutError(
+ f"No reply from the CoreWeave session within {timeout:.0f}s. "
+ "The session was stopped, so the next execution starts a "
+ "new one and nothing defined before it is still there."
+ )
+ try:
+ line = self._driver_replies.get(timeout=remaining)
+ except queue.Empty:
+ continue
+ if line is None:
+ # The reader reached EOF: the driver is gone.
+ self._driver = None
+ return None
+ try:
+ reply = json.loads(line)
+ except ValueError:
+ continue
+ if reply.get("seq") == self._driver_seq:
+ return reply
+
+ def _discard_driver(self) -> None:
+ """Stop the session process and forget it.
+
+ Called when it cannot be trusted any more — it never answered, or it
+ went away. `cancel` is what CoreWeave offers to end a running process;
+ closing stdin alone would leave a snippet that ignores EOF running.
+ """
+ driver, self._driver, self._driver_replies = self._driver, None, None
+ if driver is None:
+ return
+ for stop in (lambda: driver.cancel(), lambda: driver.stdin.close()):
+ try:
+ stop()
+ except Exception:
+ logger.debug("Ignoring error while stopping the session", exc_info=True)
+
+ def _stateless_request(self, code: str, timeout: float) -> dict:
+ """One snippet in a process of its own, for when there is no driver."""
+ process = self._sandbox.exec(
+ [self._python_executable, "-u", "-c", _STATELESS_SOURCE],
+ timeout_seconds=timeout,
+ stdin=True,
+ )
+ process.stdin.writeline(json.dumps({"code": code}))
+ process.stdin.close()
+ result = process.result(timeout=timeout)
+ printed = _text(getattr(result, "stdout_bytes", b""))
+ for line in reversed(printed.splitlines()):
+ # The reply is the LAST line: anything the program wrote outside
+ # the redirect — a warning from the interpreter itself — comes
+ # before it.
+ with contextlib.suppress(ValueError):
+ reply = json.loads(line)
+ if isinstance(reply, dict):
+ return reply
+ return {
+ "status": "error",
+ "stdout": printed,
+ "stderr": _text(getattr(result, "stderr_bytes", b"")),
+ "error": {
+ "name": "SandboxError",
+ "value": "The sandbox answered with nothing this package could read.",
+ "traceback": "",
+ },
+ }
+
+ def stop(self) -> None:
+ if not self._started:
+ return
+ if self._driver is not None:
+ with contextlib.suppress(Exception):
+ self._driver.stdin.close()
+ self._driver = None
+ self._driver_replies = None
+ if self._sandbox is not None:
+ try:
+ self._sandbox.stop().result()
+ except Exception:
+ logger.debug("Ignoring error while stopping the CoreWeave sandbox", exc_info=True)
+ self._sandbox = None
+ self._started = False
+ if self._info:
+ self._info.status = SandboxStatus.STOPPED
+
+ def run_code(
+ self,
+ code: str,
+ language: str = "python",
+ context: Context | None = None,
+ on_stdout: OutputHandler[OutputMessage] | None = None,
+ on_stderr: OutputHandler[OutputMessage] | None = None,
+ on_result: OutputHandler[Result] | None = None,
+ on_error: OutputHandler[CodeError] | None = None,
+ envs: dict[str, str] | None = None,
+ timeout: float | None = None,
+ ) -> ExecutionResult:
+ if not self._started or self._sandbox is None:
+ raise SandboxNotStartedError()
+ if language != "python":
+ raise ValueError(f"CoreWeaveSandbox only supports Python, got: {language}")
+
+ started_at = time.time()
+ self._execution_count += 1
+ seconds = timeout if timeout is not None else self.config.timeout
+ prepared = _with_envs(code, envs)
+
+ try:
+ reply = self._driver_request(prepared, seconds)
+ if reply is None:
+ reply = self._stateless_request(prepared, seconds)
+ except Exception as error:
+ # The container, not the code: a sandbox CoreWeave has taken down,
+ # a runner that went away, a session that never answered.
+ return ExecutionResult(
+ execution_ok=False,
+ execution_error=f"Failed to execute code on CoreWeave: {error}",
+ started_at=started_at,
+ completed_at=time.time(),
+ context_id=context.id if context else "default",
+ )
+
+ return self._execution_result(
+ reply, context, started_at, on_stdout, on_stderr, on_result, on_error
+ )
+
+ def _execution_result(
+ self,
+ reply: dict,
+ context: Context | None,
+ started_at: float,
+ on_stdout: OutputHandler[OutputMessage] | None,
+ on_stderr: OutputHandler[OutputMessage] | None,
+ on_result: OutputHandler[Result] | None,
+ on_error: OutputHandler[CodeError] | None,
+ ) -> ExecutionResult:
+ """One reply of the driver, as an `ExecutionResult`.
+
+ The output arrives whole rather than as it was written — the driver
+ collects it and answers once — so the callbacks are called here, in
+ order, on the lines it carried. A caller that streams sees the lines
+ it would have seen, later.
+ """
+ now = time.time()
+ stdout_messages: list[OutputMessage] = []
+ for line in _lines(reply.get("stdout")):
+ message = OutputMessage(line=line, timestamp=now, error=False)
+ stdout_messages.append(message)
+ if on_stdout:
+ on_stdout(message)
+
+ stderr_messages: list[OutputMessage] = []
+ for line in _lines(reply.get("stderr")):
+ message = OutputMessage(line=line, timestamp=now, error=True)
+ stderr_messages.append(message)
+ if on_stderr:
+ on_stderr(message)
+
+ results: list[Result] = []
+ if reply.get("result") is not None:
+ value = Result(data={"text/plain": reply["result"]}, is_main_result=True)
+ results.append(value)
+ if on_result:
+ on_result(value)
+
+ code_error: CodeError | None = None
+ error = reply.get("error")
+ if reply.get("status") == "error" and isinstance(error, dict):
+ code_error = CodeError(
+ name=error.get("name") or "Error",
+ value=error.get("value") or "",
+ traceback=error.get("traceback") or "",
+ )
+ if on_error:
+ on_error(code_error)
+
+ return ExecutionResult(
+ results=results,
+ logs=Logs(stdout=stdout_messages, stderr=stderr_messages),
+ execution_ok=True,
+ code_error=code_error,
+ execution_count=self._execution_count,
+ context_id=context.id if context else "default",
+ started_at=started_at,
+ completed_at=now,
+ )
+
+ def _do_interrupt(self) -> bool:
+ """The session process takes no interrupt; a timeout is the only stop."""
+ return False
+
+ def _needs_a_session(self, what: str) -> None:
+ """Refuse a read or a write that only a session process can serve.
+
+ Without one, every snippet runs in a process of its own: an assignment
+ made by one is gone before the next starts. Reporting success for a
+ variable that will not be there is worse than refusing it, so the two
+ variable APIs ask this first. `stateful=False` chooses this, and a
+ session that died falls into it.
+ """
+ if self._driver is None:
+ raise SandboxConfigurationError(
+ f"This CoreWeave sandbox has no session process, so {what} "
+ "cannot be kept: each snippet runs in a process of its own. "
+ "Start the sandbox with stateful=True (the default), or keep "
+ "the value in a file — the filesystem does persist."
+ )
+
+ def get_variable(self, name: str, context: Context | None = None) -> Any:
+ """The value of a variable — which takes a session to be worth asking.
+
+ Guarded HERE rather than in `_get_internal_variable`: the base class
+ binds the name to one of its own in a first execution and reads it
+ back in a second, so without a session the first snippet's process is
+ gone and the read fails as "no such variable" — true of the name, and
+ entirely misleading about the reason.
+ """
+ self._needs_a_session(f"{name!r}")
+ return super().get_variable(name, context)
+
+ def _get_internal_variable(self, name: str, context: Context | None = None) -> Any:
+ """The value of a variable, carried back as JSON."""
+ if not self._started or self._sandbox is None:
+ raise SandboxNotStartedError()
+ self._needs_a_session(f"{name!r}")
+ execution = self.run_code(
+ "import json as _code_sandboxes_json\n"
+ f"print(_code_sandboxes_json.dumps({name}, default=repr))\n"
+ "del _code_sandboxes_json\n",
+ context=context,
+ )
+ if not execution.execution_ok:
+ raise SandboxExecutionError(
+ "SandboxError", execution.execution_error or "Sandbox execution failed"
+ )
+ if execution.code_error is not None:
+ raise VariableNotFoundError(name)
+ printed = "\n".join(message.line for message in execution.logs.stdout).strip()
+ if not printed:
+ raise VariableNotFoundError(name)
+ return json.loads(printed)
+
+ def _set_internal_variable(self, name: str, value: Any, context: Context | None = None) -> None:
+ if not self._started or self._sandbox is None:
+ raise SandboxNotStartedError()
+ self._needs_a_session(f"{name!r}")
+ try:
+ payload = json.dumps(value)
+ except TypeError as error:
+ raise SandboxConfigurationError(
+ f"A CoreWeave sandbox runs elsewhere, so {name!r} has to cross "
+ "as JSON and this value cannot be encoded. Build it inside the "
+ "sandbox with run_code instead."
+ ) from error
+ execution = self.run_code(
+ "import json as _code_sandboxes_json\n"
+ f"{name} = _code_sandboxes_json.loads({payload!r})\n"
+ "del _code_sandboxes_json\n",
+ context=context,
+ )
+ if not execution.execution_ok:
+ raise SandboxExecutionError(
+ "SandboxError", execution.execution_error or "Sandbox execution failed"
+ )
+ if execution.code_error is not None:
+ raise SandboxExecutionError(
+ execution.code_error.name,
+ execution.code_error.value,
+ execution.code_error.traceback,
+ )
+
+ def _write_file(self, path: str, content: bytes) -> None:
+ """Straight to the filesystem of the container, not through the code."""
+ if not self._started or self._sandbox is None:
+ raise SandboxNotStartedError()
+ self._sandbox.write_file(path, content).result()
+
+ def _read_file(self, path: str) -> bytes:
+ if not self._started or self._sandbox is None:
+ raise SandboxNotStartedError()
+ content = self._sandbox.read_file(path).result()
+ if content is None:
+ raise FileNotFoundError(f"Could not read file: {path}")
+ return bytes(content)
+
+
+def _text(raw: Any) -> str:
+ """Whatever the SDK handed back, as text."""
+ if raw is None:
+ return ""
+ if isinstance(raw, bytes):
+ return raw.decode("utf-8", errors="replace")
+ return str(raw)
+
+
+def _lines(raw: Any) -> list[str]:
+ """The lines of one stream, without the empty one a trailing newline makes."""
+ text = _text(raw)
+ if not text:
+ return []
+ return text.splitlines()
+
+
+def _with_envs(code: str, envs: dict[str, str] | None) -> str:
+ """The snippet, with the environment it asked for set first.
+
+ The session process is started once, so variables meant for one execution
+ cannot be passed to it the way they would be to a fresh process. They are
+ set inside the namespace instead, which is where the code reads them from.
+ """
+ if not envs:
+ return code
+ assignments = "".join(
+ f"_code_sandboxes_os.environ[{key!r}] = {value!r}\n" for key, value in envs.items()
+ )
+ return f"import os as _code_sandboxes_os\n{assignments}del _code_sandboxes_os\n{code}"
diff --git a/code_sandboxes/e2b_sandbox.py b/code_sandboxes/e2b_sandbox.py
new file mode 100644
index 0000000..0bfcc53
--- /dev/null
+++ b/code_sandboxes/e2b_sandbox.py
@@ -0,0 +1,535 @@
+# Copyright (c) 2025-2026 Datalayer, Inc.
+#
+# BSD 3-Clause License
+
+"""E2B sandbox implementation.
+
+`E2B `_ runs code in Firecracker microVMs that start in about
+150 ms. This variant drives one through the CODE INTERPRETER SDK —
+``e2b-code-interpreter`` — rather than through the plain ``e2b`` SDK: the
+interpreter keeps a Jupyter kernel per context, so ``x = 1`` in one call and
+``print(x)`` in the next behave the way they do in every other variant of this
+package, and rich display data — a figure, an HTML repr — arrives as results
+rather than being lost.
+
+The mapping is unusually direct. E2B's ``run_code`` takes the same arguments
+as :meth:`Sandbox.run_code` down to the names of the callbacks, and answers
+with an execution carrying logs, results and an error. What is done here is
+therefore mostly translation: E2B names each rich format with an attribute
+(``png``, ``html``, …) where this package keys them by MIME type, and E2B
+stamps its output messages with a millisecond integer where this package
+counts in seconds.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import time
+from typing import Any
+
+from .base import Sandbox
+from .exceptions import (
+ SandboxConfigurationError,
+ SandboxExecutionError,
+ SandboxNotStartedError,
+ VariableNotFoundError,
+)
+from .models import (
+ CodeError,
+ Context,
+ ExecutionResult,
+ Logs,
+ MIMEType,
+ OutputHandler,
+ OutputMessage,
+ ResourceConfig,
+ Result,
+ SandboxConfig,
+ SandboxEnvironment,
+ SandboxInfo,
+ SandboxStatus,
+)
+
+logger = logging.getLogger(__name__)
+
+#: What every sandbox this package creates is tagged with, so the ones it made
+#: can be told from the rest of an account's.
+CREATED_BY_LABEL = "code-sandboxes"
+
+#: The E2B template a sandbox is created from when the caller names none.
+#:
+#: The code interpreter's own, NOT E2B's ``base``: the interpreter talks to a
+#: Jupyter kernel inside the sandbox, and only a template carrying one can
+#: answer it. ``base`` is the default of the plain ``e2b`` SDK, where there is
+#: no kernel to talk to, and a sandbox created from it here would start and
+#: then fail every execution.
+DEFAULT_TEMPLATE = "code-interpreter-v1"
+
+#: What E2B calls each rich format, and the MIME type this package keys it by.
+#: E2B answers with named attributes rather than a MIME dictionary, so the
+#: translation has to be written down somewhere; here, once.
+_FORMAT_MIME_TYPES: dict[str, str] = {
+ "text": MIMEType.TEXT_PLAIN.value,
+ "html": MIMEType.TEXT_HTML.value,
+ "markdown": MIMEType.TEXT_MARKDOWN.value,
+ "svg": MIMEType.IMAGE_SVG.value,
+ "png": MIMEType.IMAGE_PNG.value,
+ "jpeg": MIMEType.IMAGE_JPEG.value,
+ "gif": MIMEType.IMAGE_GIF.value,
+ "pdf": MIMEType.APPLICATION_PDF.value,
+ "json": MIMEType.APPLICATION_JSON.value,
+ "latex": "text/latex",
+ "javascript": "application/javascript",
+}
+
+
+def _import_e2b() -> Any:
+ try:
+ import e2b_code_interpreter
+ except ImportError as exc:
+ raise SandboxConfigurationError(
+ "e2b-code-interpreter is required for E2BSandbox. Install it with: "
+ "pip install code-sandboxes[e2b]"
+ ) from exc
+ return e2b_code_interpreter
+
+
+def _timestamp(value: Any) -> float:
+ """One of E2B's timestamps, in the seconds this package counts in.
+
+ E2B stamps an output message with an integer of MILLISECONDS since the
+ epoch. Passing it through unchanged would put every line of output fifty
+ thousand years in the future, which is what a reader comparing it against
+ `started_at` would see.
+ """
+ try:
+ number = float(value)
+ except (TypeError, ValueError):
+ return time.time()
+ if number <= 0:
+ return time.time()
+ # Anything this large is not a count of seconds: 1e11 seconds is the year
+ # 5138, while 1e11 milliseconds is 1973.
+ return number / 1000.0 if number > 1e11 else number
+
+
+def _result_data(result: Any) -> dict[str, Any]:
+ """The formats one E2B result carries, keyed by MIME type.
+
+ ``formats()`` answers with the names of the formats that are actually
+ present, including the ones E2B does not have an attribute for — a custom
+ MIME type a library declared — which arrive in ``extra``. Both are taken,
+ so a result this package hands on is as complete as the one it was given.
+ """
+ data: dict[str, Any] = {}
+ for name in result.formats():
+ value = getattr(result, name, None)
+ if value is None:
+ extra = getattr(result, "extra", None) or {}
+ value = extra.get(name)
+ if value is None:
+ continue
+ data[_FORMAT_MIME_TYPES.get(name, name)] = value
+ return data
+
+
+class E2BSandbox(Sandbox):
+ """Sandbox backed by an E2B microVM.
+
+ Args:
+ config: Optional sandbox configuration.
+ api_key: E2B API key. Read from ``E2B_API_KEY`` when omitted.
+ domain: E2B domain to talk to, for a self-hosted cluster. Read from
+ ``E2B_DOMAIN`` when omitted, which itself defaults to ``e2b.dev``.
+ template: The E2B template to create from — a base image with its
+ packages already installed. :data:`DEFAULT_TEMPLATE` when omitted.
+ allow_internet_access: Whether the sandbox may reach the network at
+ all. ``network_policy='none'`` of the configuration says the same
+ thing and wins when it is set.
+ secure: E2B's own hardening of the sandbox's control plane. On by
+ default, as it is in the SDK.
+ """
+
+ def __init__(
+ self,
+ config: SandboxConfig | None = None,
+ api_key: str | None = None,
+ domain: str | None = None,
+ template: str | None = None,
+ allow_internet_access: bool = True,
+ secure: bool = True,
+ **kwargs,
+ ):
+ super().__init__(config)
+ self._api_key = api_key
+ self._domain = domain
+ self._template = template
+ self._allow_internet_access = allow_internet_access
+ self._secure = secure
+ self._sandbox: Any | None = None
+ #: The E2B context standing for each of ours, made on first use —
+ #: creating one starts a kernel, and most callers use the default
+ #: namespace and never need a second.
+ self._contexts: dict[str, Any] = {}
+ self._execution_count = 0
+ self._extra_kwargs = kwargs
+
+ @classmethod
+ def list_environments(cls) -> list[SandboxEnvironment]:
+ """The environments this provider ships.
+
+ E2B takes a TEMPLATE per sandbox rather than a catalogue of machines,
+ and only a template carrying a Jupyter kernel can serve the code
+ interpreter this variant drives — so there is ONE environment here,
+ the interpreter's own template, rather than a menu including images
+ that would start and then fail every execution. A template built on
+ top of it is asked for by argument — `template=` — the way an image is
+ everywhere else in this package.
+ """
+ return [
+ SandboxEnvironment(
+ name="e2b-code-interpreter",
+ title="E2B",
+ language="python",
+ owner="e2b",
+ visibility="cloud",
+ burning_rate=0.0,
+ metadata={"variant": "e2b", "template": DEFAULT_TEMPLATE},
+ ),
+ ]
+
+ def start(self) -> None:
+ if self._started:
+ return
+
+ e2b = _import_e2b()
+ self._sandbox = e2b.Sandbox.create(**self._create_params())
+
+ self._default_context = self.create_context("default")
+ self._info = SandboxInfo(
+ id=self._sandbox.sandbox_id,
+ variant="e2b",
+ status=SandboxStatus.RUNNING,
+ created_at=time.time(),
+ name=self.config.name,
+ metadata={
+ "e2b_sandbox_id": self._sandbox.sandbox_id,
+ "template": self._template or DEFAULT_TEMPLATE,
+ "domain": self._domain,
+ },
+ resources=ResourceConfig(
+ cpu=self.config.cpu_limit,
+ memory=self.config.memory_limit,
+ ),
+ config=self.config,
+ )
+ self._started = True
+
+ def _create_params(self) -> dict[str, Any]:
+ """What to ask E2B for, from the configuration of this sandbox.
+
+ A setting that was not given is left out entirely rather than passed
+ as ``None``: the SDK reads ``E2B_API_KEY`` and ``E2B_DOMAIN`` from the
+ environment for exactly the arguments that are absent, and handing it
+ an explicit ``None`` is not the same as handing it nothing.
+ """
+ if self.config.gpu:
+ # Silently running on a CPU is the worse failure: a sandbox that
+ # looks as though it asked for an H100 and did not is one whose
+ # timings mean nothing.
+ raise SandboxConfigurationError(
+ "E2B sandboxes have no GPU, so gpu=" + repr(self.config.gpu) + " "
+ "cannot be honoured. Use the daytona, coreweave or modal "
+ "variant for a GPU."
+ )
+ params: dict[str, Any] = {
+ "template": self._template or DEFAULT_TEMPLATE,
+ "metadata": self._metadata(),
+ "secure": self._secure,
+ "allow_internet_access": self._network_allowed(),
+ }
+ if self._api_key:
+ params["api_key"] = self._api_key
+ if self._domain:
+ params["domain"] = self._domain
+ if self.config.env_vars:
+ params["envs"] = dict(self.config.env_vars)
+ if self.config.max_lifetime:
+ # E2B counts the life of a sandbox in whole seconds, and takes it
+ # down when they are up.
+ params["timeout"] = max(1, round(self.config.max_lifetime))
+ return params
+
+ def _metadata(self) -> dict[str, str]:
+ """The metadata the sandbox carries in E2B.
+
+ E2B keeps this as a flat map of strings and lets a sandbox be looked
+ up by it, so the name and the tags of the configuration go here — it
+ is what makes `list` able to say which sandboxes this package made.
+ """
+ metadata = {"created-by": CREATED_BY_LABEL}
+ if self.config.name:
+ metadata["name"] = self.config.name
+ metadata.update({key: str(value) for key, value in self._tags.items()})
+ return metadata
+
+ def _network_allowed(self) -> bool:
+ """Whether this sandbox may reach the network.
+
+ E2B offers one switch rather than an allowlist, so a policy naming
+ hosts cannot be honoured and is refused instead of being silently
+ widened to the whole internet — which is the failure that matters.
+ """
+ policy = self.config.network_policy
+ if policy == "none":
+ return False
+ if policy == "allowlist":
+ raise SandboxConfigurationError(
+ "E2B has no host allowlist: a sandbox either reaches the "
+ "network or it does not. Use network_policy='none' to cut it "
+ "off, or 'all' to allow it."
+ )
+ return self._allow_internet_access
+
+ def stop(self) -> None:
+ if not self._started:
+ return
+ if self._sandbox is not None:
+ try:
+ self._sandbox.kill()
+ except Exception:
+ logger.debug("Ignoring error while killing the E2B sandbox", exc_info=True)
+ self._sandbox = None
+ self._contexts.clear()
+ self._started = False
+ if self._info:
+ self._info.status = SandboxStatus.STOPPED
+
+ def set_timeout(self, seconds: float) -> None:
+ """Give the sandbox longer to live, counted from now.
+
+ E2B takes a sandbox down when its timeout runs out, whatever it is
+ doing. A long-running job therefore has to say so before it starts,
+ and this is how — the count restarts at the moment of the call.
+ """
+ if not self._started or self._sandbox is None:
+ raise SandboxNotStartedError()
+ self._sandbox.set_timeout(max(1, round(seconds)))
+
+ def get_host(self, port: int) -> str:
+ """The host a service listening on `port` inside is reachable at.
+
+ E2B gives every sandbox a public hostname per port, which is what
+ makes a server started inside — a dashboard, an API under test —
+ reachable from outside without a tunnel of one's own.
+ """
+ if not self._started or self._sandbox is None:
+ raise SandboxNotStartedError()
+ return self._sandbox.get_host(port)
+
+ def _e2b_context(self, context: Context | None) -> Any | None:
+ """The E2B context one of ours stands for, made on first use.
+
+ ``None`` — and the default context, which is the same namespace — is
+ the sandbox's own kernel. Anything else is a context E2B keeps apart,
+ so :meth:`create_context` really does isolate.
+ """
+ if context is None or context.id == "default":
+ return None
+ existing = self._contexts.get(context.id)
+ if existing is None:
+ existing = self._sandbox.create_code_context(cwd=context.cwd, language="python")
+ self._contexts[context.id] = existing
+ return existing
+
+ def run_code(
+ self,
+ code: str,
+ language: str = "python",
+ context: Context | None = None,
+ on_stdout: OutputHandler[OutputMessage] | None = None,
+ on_stderr: OutputHandler[OutputMessage] | None = None,
+ on_result: OutputHandler[Result] | None = None,
+ on_error: OutputHandler[CodeError] | None = None,
+ envs: dict[str, str] | None = None,
+ timeout: float | None = None,
+ ) -> ExecutionResult:
+ if not self._started or self._sandbox is None:
+ raise SandboxNotStartedError()
+ if language != "python":
+ raise ValueError(f"E2BSandbox only supports Python, got: {language}")
+
+ started_at = time.time()
+ self._execution_count += 1
+
+ stdout_messages: list[OutputMessage] = []
+ stderr_messages: list[OutputMessage] = []
+ results: list[Result] = []
+
+ def take_stdout(message: Any) -> None:
+ translated = OutputMessage(
+ line=message.line,
+ timestamp=_timestamp(getattr(message, "timestamp", None)),
+ error=False,
+ )
+ stdout_messages.append(translated)
+ if on_stdout:
+ on_stdout(translated)
+
+ def take_stderr(message: Any) -> None:
+ translated = OutputMessage(
+ line=message.line,
+ timestamp=_timestamp(getattr(message, "timestamp", None)),
+ error=True,
+ )
+ stderr_messages.append(translated)
+ if on_stderr:
+ on_stderr(translated)
+
+ def take_result(result: Any) -> None:
+ translated = Result(
+ data=_result_data(result),
+ is_main_result=bool(getattr(result, "is_main_result", False)),
+ extra=dict(getattr(result, "extra", None) or {}),
+ )
+ results.append(translated)
+ if on_result:
+ on_result(translated)
+
+ seconds = timeout if timeout is not None else self.config.timeout
+ try:
+ execution = self._sandbox.run_code(
+ code,
+ language="python",
+ context=self._e2b_context(context),
+ on_stdout=take_stdout,
+ on_stderr=take_stderr,
+ on_result=take_result,
+ envs=envs,
+ timeout=seconds,
+ )
+ except Exception as error:
+ # The microVM, not the code: a dropped connection, a sandbox that
+ # E2B has already taken down. Reported rather than raised, so a
+ # caller reads it the same way as every other infrastructure
+ # failure in this package.
+ return ExecutionResult(
+ execution_ok=False,
+ execution_error=f"Failed to execute code on E2B: {error}",
+ started_at=started_at,
+ completed_at=time.time(),
+ context_id=context.id if context else "default",
+ )
+
+ # The callbacks above collect everything as it streams, but E2B also
+ # answers with the whole execution — and does NOT call `on_result` for
+ # a sandbox that replayed a cached execution. Anything that only
+ # appears in the answer is taken from there, without duplicating what
+ # already arrived.
+ for result in execution.results[len(results) :]:
+ take_result(result)
+
+ code_error: CodeError | None = None
+ if execution.error is not None:
+ code_error = CodeError(
+ name=execution.error.name or "Error",
+ value=execution.error.value or "",
+ traceback=execution.error.traceback or "",
+ )
+ if on_error:
+ on_error(code_error)
+
+ return ExecutionResult(
+ results=results,
+ logs=Logs(stdout=stdout_messages, stderr=stderr_messages),
+ execution_ok=True,
+ code_error=code_error,
+ execution_count=self._execution_count,
+ context_id=context.id if context else "default",
+ started_at=started_at,
+ completed_at=time.time(),
+ )
+
+ def _do_interrupt(self) -> bool:
+ """E2B's interpreter takes no interrupt; a timeout is the only stop."""
+ return False
+
+ def _get_internal_variable(self, name: str, context: Context | None = None) -> Any:
+ """The value of a variable, carried back as JSON.
+
+ An E2B sandbox is a machine of its own: what comes back is what can be
+ encoded, and anything that cannot arrives as its repr rather than
+ raising — a partial answer being more use than none for the reading
+ this serves, which is `commands.run` and the filesystem.
+ """
+ if not self._started or self._sandbox is None:
+ raise SandboxNotStartedError()
+ execution = self.run_code(
+ "import json as _code_sandboxes_json\n"
+ f"print(_code_sandboxes_json.dumps({name}, default=repr))\n"
+ "del _code_sandboxes_json\n",
+ context=context,
+ )
+ if not execution.execution_ok:
+ raise SandboxExecutionError(
+ "SandboxError", execution.execution_error or "Sandbox execution failed"
+ )
+ if execution.code_error is not None:
+ raise VariableNotFoundError(name)
+ printed = "\n".join(message.line for message in execution.logs.stdout).strip()
+ if not printed:
+ raise VariableNotFoundError(name)
+ return json.loads(printed)
+
+ def _set_internal_variable(self, name: str, value: Any, context: Context | None = None) -> None:
+ if not self._started or self._sandbox is None:
+ raise SandboxNotStartedError()
+ try:
+ payload = json.dumps(value)
+ except TypeError as error:
+ raise SandboxConfigurationError(
+ f"An E2B sandbox runs elsewhere, so {name!r} has to cross as "
+ "JSON and this value cannot be encoded. Build it inside the "
+ "sandbox with run_code instead."
+ ) from error
+ execution = self.run_code(
+ "import json as _code_sandboxes_json\n"
+ f"{name} = _code_sandboxes_json.loads({payload!r})\n"
+ "del _code_sandboxes_json\n",
+ context=context,
+ )
+ if not execution.execution_ok:
+ raise SandboxExecutionError(
+ "SandboxError", execution.execution_error or "Sandbox execution failed"
+ )
+ if execution.code_error is not None:
+ raise SandboxExecutionError(
+ execution.code_error.name,
+ execution.code_error.value,
+ execution.code_error.traceback,
+ )
+
+ def _write_file(self, path: str, content: bytes) -> None:
+ """Straight to the filesystem of the sandbox, not through the code.
+
+ The base class writes a file by running a snippet that base64-decodes
+ it, which is the only way when all a variant has is an interpreter.
+ E2B has a filesystem API, so a large file does not have to become a
+ large program.
+ """
+ if not self._started or self._sandbox is None:
+ raise SandboxNotStartedError()
+ self._sandbox.files.write(path, content)
+
+ def _read_file(self, path: str) -> bytes:
+ if not self._started or self._sandbox is None:
+ raise SandboxNotStartedError()
+ content = self._sandbox.files.read(path, format="bytes")
+ if content is None:
+ # A file that is not there raises out of the SDK, so this is the
+ # other case: an answer carrying neither content nor error.
+ # Reading it as an empty file would make a failed read look like a
+ # successful one.
+ raise FileNotFoundError(f"Could not read file: {path}")
+ return bytes(content)
diff --git a/code_sandboxes/manage.py b/code_sandboxes/manage.py
index e6795f1..a487433 100644
--- a/code_sandboxes/manage.py
+++ b/code_sandboxes/manage.py
@@ -827,6 +827,276 @@ def create(self, **kwargs: Any) -> SandboxInfo:
return info
+#: What E2B calls the life of a sandbox, in the words used here.
+_E2B_STATES = {
+ "running": SandboxStatus.RUNNING,
+ "paused": SandboxStatus.STOPPED,
+ "pausing": SandboxStatus.STOPPING,
+ "resuming": SandboxStatus.STARTING,
+ "killed": SandboxStatus.TERMINATED,
+ "error": SandboxStatus.ERROR,
+}
+
+
+class E2BSandboxManager(SandboxManager):
+ """Sandboxes of an E2B account."""
+
+ variant = "e2b"
+ capabilities = frozenset({"create", "list", "get", "delete"})
+
+ def __init__(self, api_key: str | None = None, domain: str | None = None, **_: Any) -> None:
+ self._settings = {"api_key": api_key, "domain": domain}
+
+ def _sandbox_class(self) -> Any:
+ try:
+ from e2b_code_interpreter import Sandbox
+ except ImportError as exc:
+ raise SandboxManagementError(
+ "e2b-code-interpreter package is required: pip install code-sandboxes[e2b]"
+ ) from exc
+ return Sandbox
+
+ def _opts(self) -> dict[str, Any]:
+ """Only the settings that were given: the SDK reads the rest from the
+ environment, and an explicit ``None`` is not the same as nothing."""
+ return {key: value for key, value in self._settings.items() if value}
+
+ def _info(self, sandbox: Any) -> SandboxInfo:
+ metadata = dict(getattr(sandbox, "metadata", None) or {})
+ state = getattr(sandbox, "state", None)
+ state_value = getattr(state, "value", state)
+ return SandboxInfo(
+ id=getattr(sandbox, "sandbox_id", None) or str(sandbox),
+ variant=self.variant,
+ status=_E2B_STATES.get(str(state_value), SandboxStatus.PENDING),
+ # E2B has a name of its own, and this package puts the name it was
+ # given in the metadata; the sandbox's own wins when it has one.
+ name=getattr(sandbox, "name", None) or metadata.get("name"),
+ metadata={
+ "state": state_value,
+ "template": getattr(sandbox, "template_id", None),
+ "metadata": metadata,
+ "started_at": str(getattr(sandbox, "started_at", "") or "") or None,
+ "cpu_count": getattr(sandbox, "cpu_count", None),
+ "memory_mb": getattr(sandbox, "memory_mb", None),
+ },
+ )
+
+ def list(self) -> list[SandboxInfo]:
+ # `list` answers with a paginator rather than a list: E2B pages, and
+ # an account with more sandboxes than one page holds would otherwise
+ # be reported as having only the first of them.
+ paginator = self._sandbox_class().list(**self._opts())
+ sandboxes: list[Any] = []
+ if hasattr(paginator, "next_items"):
+ while paginator.has_next:
+ sandboxes.extend(paginator.next_items())
+ else: # pragma: no cover - an older SDK answering with a plain list
+ sandboxes.extend(paginator)
+ return [self._info(sandbox) for sandbox in sandboxes]
+
+ def get(self, sandbox_id: str) -> SandboxInfo | None:
+ for info in self.list():
+ if info.id == sandbox_id:
+ return info
+ return None
+
+ def delete(self, sandbox_id: str) -> bool:
+ try:
+ return bool(self._sandbox_class().kill(sandbox_id, **self._opts()))
+ except Exception:
+ return False
+
+ def create(self, **kwargs: Any) -> SandboxInfo:
+ from .e2b_sandbox import E2BSandbox
+
+ sandbox = E2BSandbox(**self._opts(), **kwargs)
+ sandbox.start()
+ info = sandbox.info
+ if info is None:
+ raise SandboxManagementError("The sandbox started without an identity.")
+ # Detached, so it outlives this call: stopping it is `delete`.
+ sandbox._sandbox = None
+ sandbox._started = False
+ return info
+
+
+#: What CoreWeave calls the life of a sandbox, in the words used here.
+_COREWEAVE_STATES = {
+ "running": SandboxStatus.RUNNING,
+ "creating": SandboxStatus.STARTING,
+ "pending": SandboxStatus.PENDING,
+ "paused": SandboxStatus.STOPPED,
+ "terminating": SandboxStatus.STOPPING,
+ "completed": SandboxStatus.STOPPED,
+ "terminated": SandboxStatus.TERMINATED,
+ "failed": SandboxStatus.ERROR,
+ "unspecified": SandboxStatus.PENDING,
+}
+
+
+class CoreWeaveSandboxManager(SandboxManager):
+ """Sandboxes of a CoreWeave organization."""
+
+ variant = "coreweave"
+ capabilities = frozenset({"create", "list", "get", "delete"})
+
+ def __init__(self, api_key: str | None = None, base_url: str | None = None, **_: Any) -> None:
+ self._settings = {"api_key": api_key, "base_url": base_url}
+
+ def _sandbox_class(self) -> Any:
+ try:
+ from cwsandbox import Sandbox
+ except ImportError as exc:
+ raise SandboxManagementError(
+ "cwsandbox package is required: pip install code-sandboxes[coreweave]"
+ ) from exc
+ import os
+
+ # The SDK authenticates from the environment and takes no token
+ # argument, so a token given here is put where it looks for one.
+ if self._settings["api_key"]:
+ os.environ["CWSANDBOX_API_KEY"] = str(self._settings["api_key"])
+ if self._settings["base_url"]:
+ os.environ["CWSANDBOX_BASE_URL"] = str(self._settings["base_url"])
+ return Sandbox
+
+ def _info(self, sandbox: Any) -> SandboxInfo:
+ tags = list(getattr(sandbox, "tags", None) or [])
+ # CoreWeave keeps tags as a flat list of strings, so a pair travels as
+ # `key=value` and is read back the same way.
+ pairs = dict(tag.split("=", 1) for tag in tags if "=" in tag)
+ status = getattr(sandbox, "status", None)
+ status_value = getattr(status, "value", status)
+ return SandboxInfo(
+ id=sandbox.sandbox_id,
+ variant=self.variant,
+ status=_COREWEAVE_STATES.get(str(status_value), SandboxStatus.PENDING),
+ name=pairs.get("name"),
+ metadata={
+ "status": status_value,
+ "tags": tags,
+ "runner_id": getattr(sandbox, "runner_id", None),
+ },
+ )
+
+ def list(self) -> list[SandboxInfo]:
+ sandboxes = self._sandbox_class().list().result()
+ return [self._info(sandbox) for sandbox in sandboxes]
+
+ def get(self, sandbox_id: str) -> SandboxInfo | None:
+ try:
+ return self._info(self._sandbox_class().from_id(sandbox_id).result())
+ except Exception:
+ return None
+
+ def delete(self, sandbox_id: str) -> bool:
+ try:
+ sandbox = self._sandbox_class().from_id(sandbox_id).result()
+ except Exception:
+ return False
+ try:
+ sandbox.stop(missing_ok=True).result()
+ except Exception:
+ return False
+ return True
+
+ def create(self, **kwargs: Any) -> SandboxInfo:
+ from .coreweave_sandbox import CoreWeaveSandbox
+
+ given = {key: value for key, value in self._settings.items() if value}
+ # No session process: a sandbox nothing is holding open should not be
+ # paying for a driver waiting on a stdin that will never be written.
+ sandbox = CoreWeaveSandbox(stateful=False, **given, **kwargs)
+ sandbox.start()
+ info = sandbox.info
+ if info is None:
+ raise SandboxManagementError("The sandbox started without an identity.")
+ # Detached, so it outlives this call: stopping it is `delete`.
+ sandbox._sandbox = None
+ sandbox._started = False
+ return info
+
+
+class CloudflareSandboxManager(SandboxManager):
+ """Sandboxes of a deployed Cloudflare sandbox bridge.
+
+ The bridge exposes one sandbox at a time by its id and has no endpoint
+ that enumerates them, so `list` cannot be answered — which is said plainly
+ rather than answered with an empty list, since "none" and "cannot know"
+ are different facts.
+ """
+
+ variant = "cloudflare"
+ capabilities = frozenset({"create", "get", "delete"})
+
+ def __init__(self, api_url: str | None = None, api_key: str | None = None, **_: Any) -> None:
+ self._settings = {"api_url": api_url, "api_key": api_key}
+
+ def _client(self) -> Any:
+ """An HTTP client for the bridge, with no sandbox behind it.
+
+ Deleting a sandbox by id and asking after one by id are both plain
+ calls to the bridge; creating a container merely to have something to
+ make the call with would leave that container running and billed.
+ """
+ from .cloudflare_sandbox import CloudflareSandbox
+
+ given = {key: value for key, value in self._settings.items() if value}
+ try:
+ return CloudflareSandbox(**given).build_client()
+ except Exception as exc:
+ raise SandboxManagementError(str(exc)) from exc
+
+ def list(self) -> list[SandboxInfo]:
+ raise self._unsupported(
+ "list", "the sandbox bridge has no endpoint that enumerates sandboxes"
+ )
+
+ def get(self, sandbox_id: str) -> SandboxInfo | None:
+ from urllib.parse import quote
+
+ try:
+ with self._client() as client:
+ response = client.get(f"/v1/sandbox/{quote(sandbox_id)}/running")
+ if response.status_code >= 400:
+ return None
+ running = bool(response.json().get("running"))
+ except Exception:
+ return None
+ return SandboxInfo(
+ id=sandbox_id,
+ variant=self.variant,
+ # The bridge knows one thing about a sandbox — whether its
+ # container is up — so that is all this claims to know.
+ status=SandboxStatus.RUNNING if running else SandboxStatus.STOPPED,
+ )
+
+ def delete(self, sandbox_id: str) -> bool:
+ from urllib.parse import quote
+
+ try:
+ with self._client() as client:
+ response = client.delete(f"/v1/sandbox/{quote(sandbox_id)}")
+ except Exception:
+ return False
+ return response.status_code < 400
+
+ def create(self, **kwargs: Any) -> SandboxInfo:
+ from .cloudflare_sandbox import CloudflareSandbox
+
+ given = {key: value for key, value in self._settings.items() if value}
+ sandbox = CloudflareSandbox(**given, **kwargs)
+ sandbox.start()
+ info = sandbox.info
+ if info is None:
+ raise SandboxManagementError("The sandbox started without an identity.")
+ # Detached, so it outlives this call: stopping it is `delete`.
+ sandbox._client = None
+ sandbox._started = False
+ return info
+
+
_MANAGERS: dict[str, type[SandboxManager]] = {
"eval": EvalSandboxManager,
"monty": MontySandboxManager,
@@ -837,6 +1107,9 @@ def create(self, **kwargs: Any) -> SandboxInfo:
"modal": ModalSandboxManager,
"daytona": DaytonaSandboxManager,
"datalayer": DatalayerSandboxManager,
+ "e2b": E2BSandboxManager,
+ "coreweave": CoreWeaveSandboxManager,
+ "cloudflare": CloudflareSandboxManager,
}
diff --git a/code_sandboxes/modal_sandbox.py b/code_sandboxes/modal_sandbox.py
index b1751d6..d87899d 100644
--- a/code_sandboxes/modal_sandbox.py
+++ b/code_sandboxes/modal_sandbox.py
@@ -8,10 +8,12 @@
containers that can run arbitrary code. This sandbox uses ``modal.Sandbox`` to
provision a container and executes Python snippets inside it via ``sandbox.exec``.
-Each ``run_code`` call runs the snippet as a fresh ``python -c`` process, so
-Python variables do **not** persist across calls (use the filesystem or a single
-snippet for stateful workflows). Rich display outputs (images, HTML) are not
-captured; only stdout/stderr text and the process exit code are returned.
+One ``python -u -c`` process is started with the sandbox and fed JSON lines on
+stdin — one request, one reply — so snippets share a namespace: ``x = 1`` in one
+call is still there in the next. A session that cannot be started, or that goes
+away mid-run, drops back to a fresh ``python -c`` process per snippet, which
+works and merely forgets. Rich display outputs (images, HTML) are not captured;
+stdout, stderr and the value of a trailing expression are.
"""
from __future__ import annotations
diff --git a/code_sandboxes/models.py b/code_sandboxes/models.py
index 72abe96..fd2324c 100644
--- a/code_sandboxes/models.py
+++ b/code_sandboxes/models.py
@@ -65,9 +65,12 @@ class SandboxStatus(str, Enum):
class SandboxVariant(str, Enum):
"""Supported sandbox variants."""
+ CLOUDFLARE = "cloudflare"
+ COREWEAVE = "coreweave"
DATALAYER = "datalayer"
DAYTONA = "daytona"
DOCKER = "docker"
+ E2B = "e2b"
EVAL = "eval"
GOOGLE_COLAB = "google-colab"
JUPYTER = "jupyter-server"
diff --git a/code_sandboxes/providers.py b/code_sandboxes/providers.py
index 1244669..1e59283 100644
--- a/code_sandboxes/providers.py
+++ b/code_sandboxes/providers.py
@@ -255,6 +255,57 @@ def read(**kwargs) -> list[SandboxEnvironment]:
),
list_environments=_environments_of(SandboxVariant.DAYTONA),
),
+ SandboxProvider(
+ variant=SandboxVariant.E2B,
+ title="E2B",
+ description=(
+ "Sandboxes on E2B, in Firecracker microVMs that start in about 150 ms, "
+ "with a stateful Python kernel and rich outputs."
+ ),
+ extra="e2b",
+ requirements=(
+ ProviderRequirement(
+ env_vars=("E2B_API_KEY",),
+ hint="Create an API key at e2b.dev and set E2B_API_KEY.",
+ ),
+ ),
+ list_environments=_environments_of(SandboxVariant.E2B),
+ ),
+ SandboxProvider(
+ variant=SandboxVariant.COREWEAVE,
+ title="CoreWeave",
+ description=(
+ "Containers on CoreWeave, with a stateful Python session and an optional GPU."
+ ),
+ extra="coreweave",
+ requirements=(
+ ProviderRequirement(
+ env_vars=("CWSANDBOX_API_KEY",),
+ hint=("Create an access token in the CoreWeave console and set CWSANDBOX_API_KEY."),
+ ),
+ ),
+ list_environments=_environments_of(SandboxVariant.COREWEAVE),
+ ),
+ SandboxProvider(
+ variant=SandboxVariant.CLOUDFLARE,
+ title="Cloudflare",
+ description=(
+ "Containers on Cloudflare's edge, reached through a deployed sandbox "
+ "bridge Worker. Each snippet runs in a process of its own."
+ ),
+ extra="cloudflare",
+ requirements=(
+ ProviderRequirement(
+ env_vars=("CLOUDFLARE_SANDBOX_API_URL", "CLOUDFLARE_SANDBOX_API_KEY"),
+ hint=(
+ "Deploy the sandbox bridge Worker, then set "
+ "CLOUDFLARE_SANDBOX_API_URL to where it answers and "
+ "CLOUDFLARE_SANDBOX_API_KEY to the secret it generated."
+ ),
+ ),
+ ),
+ list_environments=_environments_of(SandboxVariant.CLOUDFLARE),
+ ),
SandboxProvider(
variant=SandboxVariant.DOCKER,
title="Docker",
diff --git a/docs/docs/api-reference/index.mdx b/docs/docs/api-reference/index.mdx
index 049d3e6..6a93d1d 100644
--- a/docs/docs/api-reference/index.mdx
+++ b/docs/docs/api-reference/index.mdx
@@ -28,7 +28,10 @@ def create(
tags: dict[str, str] | None = None,
name: str | None = None,
snapshot_name: str | None = None,
+ network_policy: str | None = None,
+ allowed_hosts: list[str] | None = None,
config: SandboxConfig | None = None,
+ **kwargs,
) -> Sandbox
```
@@ -36,7 +39,7 @@ def create(
| Parameter | Type | Description |
|-----------|------|-------------|
-| `variant` | `str` | Sandbox type: `"eval"`, `"docker"`, `"jupyter-server"`, `"monty"`, `"kaggle"`, `"google-colab"`, `"modal"`, or `"datalayer"`. Defaults to `"datalayer"`. |
+| `variant` | `str` | Sandbox type: `"cloudflare"`, `"coreweave"`, `"datalayer"`, `"daytona"`, `"docker"`, `"e2b"`, `"eval"`, `"google-colab"`, `"jupyter-server"`, `"kaggle"`, `"modal"`, or `"monty"`. Defaults to `"datalayer"`. |
| `timeout` | `float` | Execution timeout in seconds |
| `environment` | `str` | Runtime environment name |
| `gpu` | `str` | GPU type (e.g., `"T4"`, `"A100"`, `"H100"`) |
@@ -46,7 +49,10 @@ def create(
| `tags` | `dict` | Metadata tags |
| `name` | `str` | Sandbox name |
| `snapshot_name` | `str` | Snapshot to restore from |
+| `network_policy` | `str` | Network access policy: `"inherit"`, `"none"`, `"allowlist"`, `"all"` |
+| `allowed_hosts` | `list[str]` | Hosts reachable when the policy is `"allowlist"` |
| `config` | `SandboxConfig` | Full configuration object |
+| `**kwargs` | `dict` | Variant-specific arguments handed to the sandbox constructor — `template=` (e2b), `container_image=` (coreweave), `api_url=` (cloudflare), and the rest documented on each [variant's page](/sandboxes) |
#### `Sandbox.from_id()`
@@ -82,7 +88,7 @@ def list_environments(
| Parameter | Type | Description |
|-----------|------|-------------|
-| `variant` | `str` | Sandbox type: `"eval"`, `"docker"`, `"jupyter-server"`, `"monty"`, `"kaggle"`, `"google-colab"`, `"modal"`, or `"datalayer"` |
+| `variant` | `str` | Sandbox type: `"cloudflare"`, `"coreweave"`, `"datalayer"`, `"daytona"`, `"docker"`, `"e2b"`, `"eval"`, `"google-colab"`, `"jupyter-server"`, `"kaggle"`, `"modal"`, or `"monty"` |
| `**kwargs` | `dict` | Variant-specific arguments (e.g., credentials, run URL) |
Legacy `local-eval`, `local-docker`, and `local-jupyter` variant names are not supported.
diff --git a/docs/docs/cli/index.mdx b/docs/docs/cli/index.mdx
index dc65dd3..e091ba3 100644
--- a/docs/docs/cli/index.mdx
+++ b/docs/docs/cli/index.mdx
@@ -75,9 +75,12 @@ for a reminder. On exit, the sandbox is terminated.
Supported variants:
+- `cloudflare`
+- `coreweave`
- `datalayer`
- `daytona`
- `docker`
+- `e2b`
- `eval`
- `google-colab`
- `jupyter-server`
@@ -91,9 +94,20 @@ needs.
## Variant-specific Behavior
+- `cloudflare`: talks to a deployed sandbox bridge Worker, named by
+ `CLOUDFLARE_SANDBOX_API_URL` with the `CLOUDFLARE_SANDBOX_API_KEY` it was
+ deployed with. Each snippet — and each line of the REPL — runs in a process
+ of its own, so a definition on one line is gone by the next.
+- `coreweave`: starts a CoreWeave sandbox and holds a session process for it,
+ so lines share a namespace. `--gpu` is not passed on for this variant; a GPU
+ is asked for from Python, `Sandbox.create(variant="coreweave", gpu="H100")`.
- `daytona`: starts a Daytona sandbox; `--gpu` takes Daytona's own flavors
(`H100`, `H200`, `RTX-4090`, ...), several of them comma-separated to fall
back along, and `--spot` runs on preemptible capacity.
+- `e2b`: starts an E2B microVM from the interpreter's own template,
+ `code-interpreter-v1`. Another is named from Python with `template=`, and has
+ to be built on top of it — only a template carrying a Jupyter kernel can
+ serve this variant. State persists between lines.
- `google-colab`: prompts for runtime URL, kernel ID, and proxy token.
- `jupyter-server`: starts a managed local Jupyter server on a random port.
- `kaggle`: supports either interactive runtime settings or credential-based batch execution.
diff --git a/docs/docs/cli/management.mdx b/docs/docs/cli/management.mdx
index 42bf7e0..353da8a 100644
--- a/docs/docs/cli/management.mdx
+++ b/docs/docs/cli/management.mdx
@@ -59,7 +59,7 @@ below the table instead of hiding the ones that answered.
from code_sandboxes import get_manager, manageable_variants
print(manageable_variants())
-# ['datalayer', 'daytona', 'docker', 'eval', 'google-colab', 'jupyter-server', 'kaggle', 'modal', 'monty']
+# ['cloudflare', 'coreweave', 'datalayer', 'daytona', 'docker', 'e2b', 'eval', 'google-colab', 'jupyter-server', 'kaggle', 'modal', 'monty']
manager = get_manager("modal")
for info in manager.list():
@@ -76,6 +76,9 @@ get_manager("jupyter-server", server_url="http://localhost:8888", token="...")
get_manager("google-colab", server_url="https://...", proxy_token="...")
get_manager("modal", app_name="code-sandboxes")
get_manager("daytona", api_key="dtn_...", target="eu")
+get_manager("e2b", api_key="e2b_...", domain="e2b.example.com")
+get_manager("coreweave", api_key="...", base_url="https://api.cwsandbox.com")
+get_manager("cloudflare", api_url="https://...workers.dev", api_key="...")
get_manager("kaggle", username="...")
get_manager("datalayer", token="...", run_url="https://...")
```
@@ -84,9 +87,12 @@ get_manager("datalayer", token="...", run_url="https://...")
| Variant | A sandbox is | Update changes | Delete removes |
| --- | --- | --- | --- |
+| `cloudflare` | a container of a deployed sandbox bridge | — nothing in place | the Cloudflare sandbox |
+| `coreweave` | a sandbox of the CoreWeave organization | — nothing in place | the CoreWeave sandbox |
| `datalayer` | a runtime of the Datalayer platform | the capabilities | the runtime |
| `daytona` | a sandbox of the Daytona organization | the labels | the Daytona sandbox |
| `docker` | a container labelled `code-sandboxes` | the name | the container (forced) |
+| `e2b` | a sandbox of the E2B account | — nothing in place | the E2B sandbox |
| `eval`, `monty` | an object inside the creating process | — not supported | — not supported |
| `google-colab` | a kernel of the Colab runtime | — nothing in place | the kernel |
| `jupyter-server` | a kernel of the Jupyter Server | — nothing in place | the kernel |
@@ -99,6 +105,12 @@ reason for the rest — an `eval` sandbox lives and dies inside the process that
made it, so `list` truthfully answers `[]` and `delete` explains itself rather
than silently doing nothing.
+Cloudflare notes: `create`, `get` and `delete` are answered; `list` is **not**.
+The sandbox bridge exposes a sandbox by its id and has no endpoint that
+enumerates them, so `code-sandboxes list -v cloudflare` raises with that
+reason instead of answering with an empty list — "none" and "cannot know" are
+different facts, and a management tool that confuses them loses sandboxes.
+
Kaggle notes: `list` enumerates your kernels (`mine=True`), `get` adds the
live run status, and `create` pushes a batch kernel with the given `--code` —
creation on Kaggle *is* a code push. Both `user/slug` and a bare `slug` of
diff --git a/docs/docs/comparison/index.mdx b/docs/docs/comparison/index.mdx
index 30b3e45..870018c 100644
--- a/docs/docs/comparison/index.mdx
+++ b/docs/docs/comparison/index.mdx
@@ -5,165 +5,172 @@ sidebar_position: 3
# Comparison with Other Solutions
-This page compares Code Sandboxes with other popular code execution platforms: E2B and Modal.
-
-## Overview
-
-| Feature | Code Sandboxes | E2B | Modal |
-|---------|---------------|-----|-------|
-| **Open Source** | ✅ Yes (BSD-3) | ✅ Yes (Apache-2) | ❌ No |
-| **Self-hostable** | ✅ Yes | ✅ Yes | ❌ No |
-| **Cloud Offering** | ✅ Datalayer | ✅ E2B Cloud | ✅ Modal Cloud |
-| **Execution** | ✅ Yes | ❌ No | ❌ No |
-| **GPU Support** | ✅ Yes | ❌ No | ✅ Yes |
-| **Snapshots** | ✅ Yes | ✅ Yes | ✅ Yes |
-| **Jupyter Kernel** | ✅ Native | ✅ Yes | ❌ No |
+This page used to weigh Code Sandboxes against E2B and Modal as alternatives.
+They are not alternatives any more: both are **backends this package drives**,
+alongside [Daytona](/sandboxes/daytona), [CoreWeave](/sandboxes/coreweave) and
+[Cloudflare](/sandboxes/cloudflare). `variant="e2b"` and `variant="modal"` run
+code on E2B and on Modal, through their own SDKs, with their own accounts and
+their own bills.
+
+So the question this page answers is not which of them to use instead. It is
+what the package adds over calling one of them directly, and what each of them
+still decides for you once you are on it.
+
+## What Code Sandboxes Adds
+
+- **One result, whichever backend answered.** `run_code` comes back as an
+ `ExecutionResult` everywhere, with the failure split three ways: the sandbox
+ (`execution_ok`), the process (`exit_code`), and the code itself
+ (`code_error`). A caller that handles those handles every backend.
+- **One vocabulary for the rest.** `sandbox.files`, `sandbox.commands`,
+ `create_context()`, `set_timeout()`, names and tags mean the same thing on
+ each backend and are mapped onto whatever it actually offers — a filesystem
+ API where there is one, a snippet that decodes base64 where there is not.
+- **CRUD in one place.** `code-sandboxes list`, `get`, `create` and `delete`
+ work across the backends that can answer them; see the
+ [management guide](/cli/management).
+- **A refusal instead of a surprise.** Where a backend cannot do what was
+ asked, the call says so rather than quietly doing something else:
+ `network_policy="allowlist"` on E2B is refused because E2B has no host
+ allowlist, `list` on Cloudflare raises because the bridge cannot enumerate
+ sandboxes, and a value that cannot cross as JSON is refused rather than
+ mangled.
+- **Nothing is required to be a cloud.** `eval`, `monty`, `docker` and
+ `jupyter-server` run on the machine in front of you, through the same
+ interface as the cloud backends — which is what makes a test suite that
+ exercises the interface cheap to run.
+
+The package is BSD-3 licensed and no backend is privileged in it. Moving
+between them is a change of one string.
+
+## The Same Program, On Any Backend
-## Feature Comparison
-
-### Sandbox Creation
-
-| Operation | Code Sandboxes | E2B | Modal |
-|-----------|---------------|-----|-------|
-| Create sandbox | `Sandbox.create()` | `Sandbox()` | `Sandbox.create()` |
-| With timeout | `Sandbox.create(timeout=60)` | `Sandbox(timeout=60000)` | N/A |
-| With GPU | `Sandbox.create(gpu="T4")` | N/A | `Sandbox.create(gpu="T4")` |
-| From snapshot | `Sandbox.create(snapshot_name="...")` | `Sandbox(template="...")` | `Sandbox.from_id("...")` |
-| Reconnect | `Sandbox.from_id(id)` | `Sandbox.reconnect(id)` | `Sandbox.from_id(id)` |
-| List sandboxes | `Sandbox.list()` | N/A | `Sandbox.list()` |
-
-### Code Execution
-
-| Operation | Code Sandboxes | E2B | Modal |
-|-----------|---------------|-----|-------|
-| Run code | `sandbox.run_code(code)` | `sandbox.runCode(code)` | `sandbox.exec("python", "-c", code)` |
-| Streaming | `on_stdout=callback` | `onStdout=callback` | `for line in proc.stdout` |
-| Variables | `sandbox.get_variable(name)` | `sandbox.getVar(name)` | N/A |
-| Install packages | `sandbox.install_packages([...])` | `sandbox.installPackages([...])` | `sandbox.exec("pip", "install", ...)` |
-
-### Filesystem Operations
-
-| Operation | Code Sandboxes | E2B | Modal |
-|-----------|---------------|-----|-------|
-| Read file | `sandbox.files.read(path)` | `sandbox.files.read(path)` | `sandbox.read_file(path)` |
-| Write file | `sandbox.files.write(path, content)` | `sandbox.files.write(path, content)` | `sandbox.open(path, "w")` |
-| List directory | `sandbox.files.list(path)` | `sandbox.files.list(path)` | `sandbox.list_files(path)` |
-| Make directory | `sandbox.files.mkdir(path)` | `sandbox.files.makeDir(path)` | N/A |
-| Upload | `sandbox.files.upload(local, remote)` | `sandbox.files.upload(local, remote)` | N/A |
-| Download | `sandbox.files.download(remote, local)` | `sandbox.files.download(remote, local)` | N/A |
-
-### Command Execution
-
-| Operation | Code Sandboxes | E2B | Modal |
-|-----------|---------------|-----|-------|
-| Run command | `sandbox.commands.run(cmd)` | `sandbox.commands.run(cmd)` | `sandbox.exec(cmd)` |
-| Streaming exec | `sandbox.commands.exec(*args)` | N/A | `sandbox.exec(*args)` |
-| Background process | `sandbox.commands.spawn(cmd)` | `sandbox.process.start(cmd)` | N/A |
-| System packages | `sandbox.commands.install_system_packages([...])` | N/A | N/A |
-
-### Lifecycle Management
-
-| Operation | Code Sandboxes | E2B | Modal |
-|-----------|---------------|-----|-------|
-| Set timeout | `sandbox.set_timeout(seconds)` | `sandbox.setTimeout(ms)` | N/A |
-| Terminate | `sandbox.terminate()` | `sandbox.close()` | `sandbox.terminate()` |
-| Force kill | `sandbox.kill()` | `sandbox.kill()` | N/A |
-| Get info | `sandbox.get_info()` | N/A | N/A |
-
-### Snapshots
-
-| Operation | Code Sandboxes | E2B | Modal |
-|-----------|---------------|-----|-------|
-| Create snapshot | `sandbox.create_snapshot(name)` | `sandbox.pause()` | `sandbox.snapshot_filesystem()` |
-| List snapshots | `sandbox.list_snapshots()` | N/A | N/A |
-| Restore | `Sandbox.create(snapshot_name=...)` | `Sandbox(template=...)` | `Sandbox.from_id(...)` |
-
-## Architecture Comparison
-
-### E2B
-
-E2B focuses on cloud-based code interpretation, primarily designed for AI assistants. It uses microVMs for isolation and provides a JavaScript/TypeScript SDK.
-
-**Pros:**
-- Simple API
-- Good for AI chatbots
-- Fast cold starts
-
-**Cons:**
-- Cloud-only
-- No GPU support
-- JavaScript-first (Python SDK is secondary)
-
-### Modal
-
-Modal is a serverless platform for running Python code in the cloud. It's designed for ML workloads with strong GPU support.
-
-**Pros:**
-- Excellent GPU support
-- Powerful for ML workloads
-- Great parallelization
-
-**Cons:**
-- Cloud-only (no self-hosting)
-- Closed source
-- More complex API
-
-### Code Sandboxes
-
-Code Sandboxes provides a unified API across all supported variants (`eval`, `monty`, `docker`, `jupyter-server`, `kaggle`, `google-colab`, `modal`, `datalayer`), with native Jupyter kernel support.
-
-**Pros:**
-- Open source and self-hostable
-- Multiple execution variants through one API
-- Native Jupyter integration
-- GPU support via Datalayer runtime
-- Simple, consistent API
-
-**Cons:**
-- Newer project
-- Smaller community
+```python
+from code_sandboxes import Sandbox
-## Migration Guides
+for variant in ("eval", "e2b", "modal", "daytona", "coreweave"):
+ with Sandbox.create(variant=variant) as sandbox:
+ print(variant, sandbox.run_code("40 + 2").text) # "42" on each
+```
-### From E2B
+What the interface cannot do is give a backend a capability it does not have.
+That is the other half of the comparison, and it is the table below.
+
+## What Each Backend Brings
+
+| Backend | Runs code in | State between calls | Rich display data | GPU | Management verbs |
+| --- | --- | --- | --- | --- | --- |
+| [`cloudflare`](/sandboxes/cloudflare) | a container on the edge, through a bridge Worker | none — a process per snippet | no | not exposed here | create, get, delete |
+| [`coreweave`](/sandboxes/coreweave) | a container on CoreWeave | a session process, falling back to none | no | `gpu=` | create, list, get, delete |
+| [`datalayer`](/sandboxes/datalayer) | a Datalayer runtime | a kernel | yes | `gpu=` | create, list, get, update, delete |
+| [`daytona`](/sandboxes/daytona) | a Daytona sandbox | the code interpreter, one namespace per context | no | `gpu=`, spot capacity | create, list, get, update, delete |
+| [`e2b`](/sandboxes/e2b) | a Firecracker microVM | a Jupyter kernel per context | yes | not exposed here | create, list, get, delete |
+| [`modal`](/sandboxes/modal) | a Modal container | a session process, falling back to none | no | `gpu=` | create, list, get, update, delete |
+
+"Not exposed here" is about this package, not about the vendor: it means the
+variant has no `gpu=` to pass on, not that the platform has no hardware.
+
+The local backends answer the same interface with none of the accounts:
+[`docker`](/sandboxes/docker) runs a container on this machine,
+[`jupyter-server`](/sandboxes/jupyter-server) a kernel of a Jupyter Server,
+[`monty`](/sandboxes/monty) a restricted Python subset in this process, and
+[`eval`](/sandboxes/eval) plain `exec()` — which isolates nothing and is for
+development and tests.
+
+## What The Package Calls On Your Behalf
+
+The SDKs underneath are still there, and this is roughly what a variant does
+with them. The point of the table is not that one column is better: it is that
+the left column is the same sentence in every row, and the others are not.
+
+| Operation | Code Sandboxes | E2B SDK | Modal SDK |
+| --- | --- | --- | --- |
+| Create a sandbox | `Sandbox.create(variant=...)` | `Sandbox.create()` | `modal.Sandbox.create(app=app, image=image)` |
+| Run a snippet | `sandbox.run_code(code)` | `sandbox.run_code(code)` | `sandbox.exec("python", "-c", code)` |
+| Stream output | `on_stdout=callback` | `on_stdout=callback` | `for line in process.stdout` |
+| A value back from the code | `result.text`, `result.results` | `execution.results` | — read it off stdout yourself |
+| Shell command | `sandbox.commands.run(cmd)` | `sandbox.commands.run(cmd)` | `sandbox.exec(*args)` |
+| Write a file | `sandbox.files.write(path, content)` | `sandbox.files.write(path, content)` | `sandbox.open(path, "w")` |
+| Read a file | `sandbox.files.read(path)` | `sandbox.files.read(path)` | `sandbox.open(path)` |
+| Extend the life | `sandbox.set_timeout(seconds)` | `sandbox.set_timeout(seconds)` | — |
+| Terminate | `sandbox.terminate()` | `sandbox.kill()` | `sandbox.terminate()` |
+| List what is running | `get_manager(variant).list()` | `Sandbox.list()` | `modal.Sandbox.list()` |
+| A GPU | `Sandbox.create(variant="modal", gpu="T4")` | — | `modal.Sandbox.create(gpu="T4")` |
+
+Where a backend has nothing for a row, the variant either builds it —
+Cloudflare and CoreWeave run each snippet through a small program that reports
+the trailing expression and the traceback, because `exec` on its own reports
+neither — or refuses it, and its page says which.
+
+## Coming From The E2B SDK
+
+The sandbox is the same sandbox; what changes is the interface in front of it,
+and that a second backend costs one string.
```python
-# E2B
+# E2B, directly
from e2b_code_interpreter import Sandbox
-sandbox = Sandbox(timeout=60000)
-result = sandbox.runCode("print('hello')")
-sandbox.close()
-# Code Sandboxes
+sandbox = Sandbox.create()
+execution = sandbox.run_code("print('hello')")
+sandbox.kill()
+
+# Through Code Sandboxes
from code_sandboxes import Sandbox
-with Sandbox.create(timeout=60) as sandbox:
+
+with Sandbox.create(variant="e2b") as sandbox:
result = sandbox.run_code("print('hello')")
+ print(result.stdout)
```
-### From Modal
+`E2B_API_KEY` is read the same way in both, and `E2B_DOMAIN` still points at a
+self-hosted cluster. What the variant adds is the shared result model, the
+management verbs, and the refusal where E2B's own model has no answer — see
+[E2B](/sandboxes/e2b).
+
+## Coming From The Modal SDK
```python
-# Modal
+# Modal, directly
import modal
-sb = modal.Sandbox.create(gpu="T4")
-process = sb.exec("python", "-c", "print('hello')")
+
+app = modal.App.lookup("my-app", create_if_missing=True)
+sandbox = modal.Sandbox.create(app=app, gpu="T4")
+process = sandbox.exec("python", "-c", "print('hello')")
print(process.stdout.read())
-sb.terminate()
+sandbox.terminate()
-# Code Sandboxes
+# Through Code Sandboxes
from code_sandboxes import Sandbox
-with Sandbox.create(variant="datalayer", gpu="T4") as sandbox:
+
+with Sandbox.create(variant="modal", gpu="T4") as sandbox:
result = sandbox.run_code("print('hello')")
print(result.stdout)
```
-## Choosing the Right Solution
+Credentials are Modal's own — `modal token new`, or `MODAL_TOKEN_ID` with
+`MODAL_TOKEN_SECRET`. See [Modal](/sandboxes/modal).
+
+## Choosing A Backend
+
+| Use case | Backend |
+|----------|---------|
+| Development and tests, no account, no isolation | `eval` |
+| LLM-generated snippets that must not touch the host | `monty` |
+| Notebook-shaped work with a kernel, locally | `jupyter-server`, `docker` |
+| An agent session that keeps its variables, in a cloud | `e2b`, `daytona`, `datalayer` |
+| Figures and HTML back from the code | `e2b`, `datalayer`, `jupyter-server` |
+| ML training on a GPU | `coreweave`, `modal`, `daytona`, `datalayer` |
+| The cheapest GPU capacity, preemption accepted | `daytona` with `spot=True` |
+| Short snippets close to the user, no state to keep | `cloudflare` |
+| Production runtimes with snapshots and quotas | `datalayer` |
+
+## What This Package Is Not
+
+It is not a compute platform. Capacity, regions, quotas, cold starts and the
+bill belong to the backend you chose, and no interface in front of them changes
+any of it. It adds no isolation either: an `eval` sandbox is `exec()` in this
+process however carefully the result is reported, and a sandbox is only as
+contained as the backend running it.
-| Use Case | Recommended |
-|----------|-------------|
-| AI chatbot code execution | Code Sandboxes or E2B |
-| ML training with GPUs | Code Sandboxes or Modal |
-| development/testing | Code Sandboxes |
-| Self-hosted infrastructure | Code Sandboxes or E2B |
-| Jupyter notebook workflows | Code Sandboxes |
-| Serverless Python functions | Modal |
+What it does is make the choice reversible.
diff --git a/docs/docs/examples/index.mdx b/docs/docs/examples/index.mdx
index 7b4a9fd..2b12712 100644
--- a/docs/docs/examples/index.mdx
+++ b/docs/docs/examples/index.mdx
@@ -11,9 +11,30 @@ For canonical setup details per variant (requirements, credentials, and
parameters), see [Sandboxes](/sandboxes). For installation extras, see
[Installation](/installation).
+## Cloudflare
+
+- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/cloudflare_sandbox_example.py
+
+Needs a deployed sandbox bridge Worker, named by `CLOUDFLARE_SANDBOX_API_URL`
+with the `CLOUDFLARE_SANDBOX_API_KEY` it generated. The example shows what a
+stateless runner means and the two ways round it — one snippet, or a file.
+
+```bash
+make cloudflare
+```
+
+## CoreWeave
+
+- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/coreweave_sandbox_example.py
+
+```bash
+make coreweave
+make coreweave-gpu # COREWEAVE_GPU, default H100
+```
+
## Datalayer
-- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/datalayer_sandbox_example.py
+- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/datalayer_sandbox_example.py
```bash
make datalayer
@@ -29,15 +50,23 @@ make daytona
## Docker
-- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/docker_sandbox_example.py
+- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/docker_sandbox_example.py
```bash
make docker
```
+## E2B
+
+- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/e2b_sandbox_example.py
+
+```bash
+make e2b
+```
+
## Eval
-- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/eval_sandbox_example.py
+- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/eval_sandbox_example.py
```bash
make eval
@@ -51,12 +80,12 @@ make eval
make google-colab
```
-## Jupyter
+## Jupyter Server
-- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/jupyter_server_sandbox_example.py
+- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/jupyter_server_sandbox_example.py
```bash
-make jupyter
+make jupyter-server
```
## Kaggle
@@ -69,7 +98,7 @@ make kaggle
## Modal
-- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/modal_sandbox_example.py
+- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/modal_sandbox_example.py
```bash
make modal
@@ -77,13 +106,15 @@ make modal
## Monty
-- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/monty_sandbox_example.py
+- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/monty_sandbox_example.py
```bash
make monty
```
-See the Makefile for all targets: https://github.com/datalayer/code-sandboxes/blob/main/examples/Makefile
+See the Makefiles for all targets — one per set:
+https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/Makefile
+and https://github.com/datalayer/code-sandboxes/blob/main/examples/repl/Makefile
Every example runs on the machinery the package exports — `show_and_run`
for the exec examples, `run_repl` for the REPL ones — which is the same code
diff --git a/docs/docs/index.mdx b/docs/docs/index.mdx
index 7b17593..75942c8 100644
--- a/docs/docs/index.mdx
+++ b/docs/docs/index.mdx
@@ -51,7 +51,7 @@ This section clarifies what the package owns versus what is delegated to adjacen
- **🧭 Unified Client API**: Use `CodeSandboxClient` for variant-agnostic execution and streaming
- **📊 Detailed Status Reporting**: Distinguish between infrastructure and code-level failures
- **🎯 Pydantic Models**: Type-safe models with automatic validation and JSON serialization
-- **⚡ Multiple Backends**: eval, Docker, Jupyter, Monty, Kaggle, Colab, Modal, and Datalayer
+- **⚡ Multiple Backends**: eval, Docker, Jupyter, Monty, Kaggle, Colab, Modal, Daytona, E2B, CoreWeave, Cloudflare, and Datalayer
- **🔄 State Persistence**: Maintain variables and context between executions
- **📊 Rich Output**: Support for text, HTML, images, and structured data
- **📸 Snapshots**: Save and restore sandbox state
@@ -63,9 +63,12 @@ Code Sandboxes supports these execution variants:
| Variant | Isolation Level | Best For |
|---------|-----------------|----------|
+| `cloudflare` | Managed container on the edge | Short snippets, each on its own |
+| `coreweave` | Managed container | GPU work with a stateful session |
| `datalayer` | Managed VM/runtime | Production and GPU workloads |
| `daytona` | Managed cloud sandbox | Stateful agent sessions |
| `docker` | Container | Isolated execution |
+| `e2b` | Firecracker microVM | Fast starts, stateful kernel, rich outputs |
| `eval` | None (Python exec) | Development, testing |
| `google-colab` | Managed notebook runtime | Interactive Colab-connected runs |
| `jupyter-server` | Process (Jupyter kernel) | Persistent notebook-style state |
diff --git a/docs/docs/installation/index.mdx b/docs/docs/installation/index.mdx
index b387e2d..2e8afde 100644
--- a/docs/docs/installation/index.mdx
+++ b/docs/docs/installation/index.mdx
@@ -18,6 +18,12 @@ pip install code-sandboxes
Code Sandboxes supports different execution backends via extras:
```bash
+# With Cloudflare variant support (an HTTP client for the sandbox bridge)
+pip install code-sandboxes[cloudflare]
+
+# With CoreWeave variant support
+pip install code-sandboxes[coreweave]
+
# With Datalayer variant support
pip install code-sandboxes[datalayer]
@@ -27,6 +33,9 @@ pip install code-sandboxes[daytona]
# With Docker variant support
pip install code-sandboxes[docker]
+# With E2B variant support
+pip install code-sandboxes[e2b]
+
# With Kaggle support
pip install code-sandboxes[kaggle]
@@ -43,10 +52,20 @@ pip install code-sandboxes[all]
## Requirements
- Python 3.10 or higher
+- For Cloudflare variant: `code-sandboxes[cloudflare]`, a deployed **sandbox
+ bridge** Worker (`npm create cloudflare -- sandbox-bridge
+ --template=cloudflare/sandbox-sdk/bridge/worker`), and both
+ `CLOUDFLARE_SANDBOX_API_URL` and `CLOUDFLARE_SANDBOX_API_KEY`. Cloudflare's
+ own SDK is a Workers binding, which a Python process cannot hold — the
+ bridge is what it talks to instead
+- For CoreWeave variant: `code-sandboxes[coreweave]` and `CWSANDBOX_API_KEY`,
+ an access token from the CoreWeave console
- For Datalayer variant: valid `DATALAYER_API_KEY`
- For Daytona variant: `code-sandboxes[daytona]` and a Daytona API key
(`DAYTONA_API_KEY`, from app.daytona.io)
- For Docker variant: Docker installed and running
+- For E2B variant: `code-sandboxes[e2b]` and an E2B API key (`E2B_API_KEY`,
+ from e2b.dev)
- For Google Colab variant: a Colab runtime assignment
(server URL, kernel id, proxy token)
- For Kaggle variant: `code-sandboxes[kaggle]` and Kaggle credentials (for batch mode) or runtime connection values (for interactive mode)
@@ -67,6 +86,12 @@ pip install code-sandboxes[all]
| `DAYTONA_API_KEY` | Daytona API key (Daytona variant) |
| `DAYTONA_API_URL` | Daytona API URL (optional, defaults to app.daytona.io) |
| `DAYTONA_TARGET` | Daytona region (optional) |
+| `E2B_API_KEY` | E2B API key (E2B variant) |
+| `E2B_DOMAIN` | E2B domain (optional, for a self-hosted cluster; defaults to e2b.dev) |
+| `CWSANDBOX_API_KEY` | CoreWeave access token (CoreWeave variant) |
+| `CWSANDBOX_BASE_URL` | CoreWeave control plane (optional, defaults to api.cwsandbox.com) |
+| `CLOUDFLARE_SANDBOX_API_URL` | Where the deployed sandbox bridge Worker answers (Cloudflare variant) |
+| `CLOUDFLARE_SANDBOX_API_KEY` | The key that bridge was deployed with (Cloudflare variant) |
### Programmatic Configuration
diff --git a/docs/docs/sandboxes/cloudflare.mdx b/docs/docs/sandboxes/cloudflare.mdx
new file mode 100644
index 0000000..4c01e47
--- /dev/null
+++ b/docs/docs/sandboxes/cloudflare.mdx
@@ -0,0 +1,153 @@
+---
+sidebar_position: 2
+title: Cloudflare
+---
+
+# Cloudflare
+
+Runs code in a [Cloudflare Sandbox](https://developers.cloudflare.com/sandbox/)
+— a container on Cloudflare's edge, started next to the Worker that owns it.
+
+Cloudflare's own SDK is a **Workers binding** written in TypeScript, and a
+Python process cannot hold one: `getSandbox(env.Sandbox, id)` only means
+something inside a Worker. What a Python process can talk to is the **sandbox
+bridge**, a small reference-implementation Worker Cloudflare publishes that
+exposes the SDK over HTTP, and that is what this variant drives. It is deployed
+once per account; see [below](#deploying-the-bridge).
+
+State does **not** persist between calls. The bridge's exec starts a process
+and streams its output back, and gives nothing to write to its stdin, so a
+namespace cannot be held open between snippets the way the
+[CoreWeave](./coreweave) and [Modal](./modal) variants hold one — each snippet
+runs in a process of its own:
+
+```python
+sandbox.run_code("x = 40")
+sandbox.run_code("x + 2").code_error.name # "NameError"
+```
+
+- **Requirements:** `code-sandboxes[cloudflare]` (installs `httpx`), and a
+ deployed sandbox bridge Worker.
+- **Parameters:** `api_url`, `api_key`, `python_executable`, `working_dir`.
+
+## Deploying The Bridge
+
+1. Create the Worker from Cloudflare's template, which deploys it and generates
+ the key it will accept:
+ ```bash
+ npm create cloudflare -- sandbox-bridge \
+ --template=cloudflare/sandbox-sdk/bridge/worker
+ ```
+ It answers with a URL of the shape
+ `https://cloudflare-sandbox-bridge..workers.dev` and a
+ `SANDBOX_API_KEY` secret.
+2. Export both — this variant needs **both**, since a bridge on the public
+ internet with no key is a sandbox anybody may run code in:
+ ```bash
+ export CLOUDFLARE_SANDBOX_API_URL="https://cloudflare-sandbox-bridge.example.workers.dev"
+ export CLOUDFLARE_SANDBOX_API_KEY="..."
+ ```
+
+Either can be passed to the sandbox instead of exported:
+
+```python
+Sandbox.create(variant="cloudflare", api_url="https://...workers.dev", api_key="...")
+```
+
+The key travels as a bearer token. A bridge that refuses it says so with the
+reason rather than with a bare HTTP code — a 401 means the key is wrong, and
+the message names the variable to check.
+
+The bridge documentation is at
+[developers.cloudflare.com/sandbox/bridge](https://developers.cloudflare.com/sandbox/bridge/).
+
+## Usage
+
+```python
+from code_sandboxes import Sandbox
+
+with Sandbox.create(variant="cloudflare") as sandbox:
+ result = sandbox.run_code("import sys; sys.version_info[:2]")
+ print(result.text) # "(3, 11)"
+```
+
+Snippets run in `/workspace` unless `working_dir=` says otherwise.
+
+### Carrying State Between Calls
+
+Since each snippet is its own process, anything that has to survive a call has
+to be written down somewhere both processes can see. There are two ways, and
+neither is a workaround for a bug — it is what a stateless runner means.
+
+Put the statements that share state in **one snippet**:
+
+```python
+sandbox.run_code("""
+x = 40
+x + 2
+""").text # "42"
+```
+
+Or keep the state in a **file**. The filesystem of the sandbox does persist
+between calls:
+
+```python
+sandbox.files.write("/workspace/state.json", '{"x": 40}')
+sandbox.run_code(
+ "import json; json.load(open('/workspace/state.json'))['x'] + 2"
+).text # "42"
+```
+
+The same fact is what `set_variable` refuses to pretend about: a variable set
+from outside would be gone before the next snippet could read it, so the call
+explains that instead of quietly doing nothing.
+
+### Files
+
+Files go straight to the bridge's filesystem endpoints rather than through a
+program that decodes them, so a large file does not have to become a large
+snippet:
+
+```python
+sandbox.files.write_bytes("/workspace/data.parquet", payload)
+sandbox.files.read_bytes("/workspace/data.parquet")
+```
+
+### Management
+
+`create`, `get` and `delete` are answered; `list` is **not**. The bridge
+exposes a sandbox by its id and has no endpoint that enumerates them, so
+`code-sandboxes list -v cloudflare` raises with that reason rather than
+answering with an empty list — "none" and "cannot know" are different facts,
+and a management tool that confuses them loses sandboxes. See the
+[management guide](/cli/management).
+
+## What Is Not Reported
+
+Each snippet is run by a small program that captures the streams and answers
+with one JSON line, so:
+
+- The value of a trailing expression **is** reported — it is evaluated and its
+ `repr` carried back on that line, which is what makes `result.text` work
+ above.
+- Rich display data — a figure, an HTML repr, a PNG — has no channel at all and
+ is **not** returned. `result.results` only ever holds the text value.
+- There is no interrupt: `sandbox.interrupt()` answers `False`, and a runaway
+ execution is stopped by its timeout.
+- Reading a variable is **refused**, with the reason: `get_variable` binds the
+ value in one execution and reads it back in a second, and this backend's
+ first process is gone before the second starts. `sandbox.commands.run`, which
+ is built on it, is refused the same way. Have the snippet print what you
+ need, or keep it in a file.
+- `sandbox.files.read` and `.write` **do** work: they go through the bridge's
+ own file endpoints rather than through a variable, which is one round trip
+ and needs no session. So do `upload_file` and `download_file`.
+- The network cannot be restricted. The bridge exposes no egress rules, no
+ allowlist and no switch, so `network_policy="none"` or `"allowlist"` is
+ refused at `start()` rather than accepted and left unapplied — believing a
+ sandbox is cut off while it is not is the failure that matters. Use the
+ `e2b` variant to cut one off, or `daytona` / `coreweave` for an allowlist.
+- There is no GPU, and `gpu=` is refused for the same reason.
+- `SandboxConfig.env_vars` is honoured: the bridge takes no environment when it
+ creates a sandbox, so the configured variables are set at the top of every
+ snippet instead. Anything passed to `run_code(envs=...)` wins over them.
diff --git a/docs/docs/sandboxes/coreweave.mdx b/docs/docs/sandboxes/coreweave.mdx
new file mode 100644
index 0000000..9aae8ce
--- /dev/null
+++ b/docs/docs/sandboxes/coreweave.mdx
@@ -0,0 +1,189 @@
+---
+sidebar_position: 3
+title: CoreWeave
+---
+
+# CoreWeave
+
+Runs code in a
+[CoreWeave Sandbox](https://www.coreweave.com/products/coreweave-sandboxes) — a
+container on CoreWeave's own GPU cloud, placed on a managed runner and
+addressed through the `cwsandbox` SDK.
+
+State **persists** between calls, though CoreWeave itself offers nothing that
+holds a Python namespace: what it offers is a container and `exec`, a process
+at a time. So the variant holds one itself. A single `python -u -c` process is
+started with the sandbox and fed JSON lines on stdin, one request and one reply
+each — the same arrangement the [Modal](./modal) variant uses, and for the same
+reason:
+
+```python
+sandbox.run_code("x = 40")
+sandbox.run_code("x + 2").text # "42"
+```
+
+A session process that cannot be started, or that goes away mid-session, drops
+the sandbox back to a process per snippet — working, merely stateless — rather
+than failing the run. Which of the two you got is recorded on the sandbox, so a
+caller who depends on state can ask rather than assume:
+
+```python
+sandbox.info.metadata["stateful"] # True when the session process is there
+```
+
+Pass `stateful=False` to turn the session process off and run every snippet on
+its own.
+
+- **Requirements:** `code-sandboxes[coreweave]` (installs `cwsandbox`).
+- **Parameters:** `api_key`, `base_url`, `container_image`, `profile_names`,
+ `runner_ids`, `python_executable`, `stateful`.
+
+## How To Obtain CoreWeave Credentials
+
+1. Create an access token in the CoreWeave console and export it:
+ ```bash
+ export CWSANDBOX_API_KEY="..."
+ ```
+ It travels as a bearer token.
+2. Optionally point at another control plane:
+ ```bash
+ export CWSANDBOX_BASE_URL="https://api.cwsandbox.com" # the default
+ ```
+
+Both can be passed to the sandbox instead of exported. The SDK authenticates
+from the environment and takes no token argument, so passing one here sets the
+variable for this process:
+
+```python
+Sandbox.create(variant="coreweave", api_key="...", base_url="https://api.cwsandbox.com")
+```
+
+The product documentation is at
+[docs.coreweave.com/products/sandboxes](https://docs.coreweave.com/products/sandboxes).
+
+## Usage
+
+```python
+from code_sandboxes import Sandbox
+
+with Sandbox.create(variant="coreweave") as sandbox:
+ result = sandbox.run_code("import sys; sys.version_info[:2]")
+ print(result.text) # "(3, 11)"
+```
+
+The container is `python:3.11` unless `container_image=` names another. An
+image that carries what the code needs saves installing it on every run:
+
+```python
+Sandbox.create(variant="coreweave", container_image="my-registry/analysis:2026-08")
+```
+
+### GPUs
+
+CoreWeave is a GPU cloud, and a GPU is asked for the way it is everywhere else
+in this package:
+
+```python
+Sandbox.create(variant="coreweave", gpu="H100")
+```
+
+CoreWeave places a sandbox on a runner of one kind and has no fallback list of
+its own, so a comma-separated list is read as the first name in it rather than
+as preferences to fall back along. The image has to carry what the code needs
+to use the card — `python:3.11` carries nothing of CUDA.
+
+### CPU and memory
+
+`cpu` and `memory` become Kubernetes quantities, sent as both the requests and
+the limits of the container — cores as a whole number, memory rounded up to
+whole gibibytes:
+
+```python
+Sandbox.create(variant="coreweave", cpu=2.0, memory=4096)
+```
+
+Left out, CoreWeave's own defaults for the runner apply.
+
+### Network policy
+
+The policy of the configuration becomes CoreWeave's own network options:
+
+```python
+# No egress and no ingress at all.
+Sandbox.create(variant="coreweave", network_policy="none")
+
+# Only these hosts, as egress rules — naming what may be reached denies the rest.
+Sandbox.create(
+ variant="coreweave",
+ network_policy="allowlist",
+ allowed_hosts=["pypi.org", "files.pythonhosted.org"],
+)
+```
+
+`network_policy="allowlist"` with no `allowed_hosts` is refused with the
+reason: a sandbox allowed nothing is a sandbox with no network at all, which is
+`network_policy="none"` and should say so.
+
+### Sandbox profiles and runners
+
+`profile_names` names CoreWeave's own policy objects — what a sandbox is
+allowed to do — and `runner_ids` pins it to particular managed runners. Both
+are left to CoreWeave when omitted, which is the usual case:
+
+```python
+Sandbox.create(variant="coreweave", profile_names=["restricted"])
+```
+
+### Names and tags
+
+CoreWeave keeps tags as a flat list of strings rather than as a map, so a pair
+is written `key=value`. That is how the name and the tags of the configuration
+survive the crossing and can be read back by `list`, next to
+`created-by=code-sandboxes`, which is what tells the sandboxes this package
+made from the rest of the organization's:
+
+```python
+Sandbox.create(variant="coreweave", name="nightly-report", tags={"team": "ai"})
+```
+
+`code-sandboxes list -v coreweave` shows what is there; see the
+[management guide](/cli/management).
+
+## What Is Not Reported
+
+The session process answers with what the code printed, the value of its
+trailing expression, and the error it raised. There is no channel for anything
+else, so:
+
+- The value of a trailing expression **is** reported — it is evaluated and its
+ `repr` carried back in the reply, which is what makes `result.text` work
+ above.
+- Rich display data — a figure, an HTML repr, a PNG — has no channel at all and
+ is **not** returned. `result.results` only ever holds the text value.
+- There is no interrupt: `sandbox.interrupt()` answers `False`. A snippet that
+ runs past its timeout has the SESSION stopped — not just the wait for its
+ answer — because a snippet nobody is waiting for would otherwise go on
+ changing the namespace. The next execution starts a new session, so nothing
+ defined before the timeout is still there; the timeout message says so.
+- Contexts do not isolate: there is one session process and therefore one
+ namespace. `create_context()` gives a context back, but two of them see each
+ other's variables.
+
+Variables cross as JSON (`get_variable`, `set_variable`, and everything built
+on them such as `sandbox.commands.run`), so a value that cannot be encoded
+comes back as its `repr` rather than as the object itself, and one that cannot
+be *sent* is refused with a reason.
+
+They need the session process, and say so when there is none — under
+`stateful=False`, or after a session was stopped. Without it each snippet runs
+in a process of its own, so a variable set by one would be gone before the next
+could read it; refusing is the honest answer, and reporting a successful set
+would not be.
+
+Binary files go straight to CoreWeave's filesystem API rather than through a
+program that decodes them:
+
+```python
+sandbox.files.write_bytes("/tmp/data.parquet", payload)
+sandbox.files.read_bytes("/tmp/data.parquet")
+```
diff --git a/docs/docs/sandboxes/datalayer.mdx b/docs/docs/sandboxes/datalayer.mdx
index c11478a..6f8e3f8 100644
--- a/docs/docs/sandboxes/datalayer.mdx
+++ b/docs/docs/sandboxes/datalayer.mdx
@@ -1,5 +1,5 @@
---
-sidebar_position: 2
+sidebar_position: 4
title: Datalayer
---
diff --git a/docs/docs/sandboxes/daytona.mdx b/docs/docs/sandboxes/daytona.mdx
index 7c91723..1daafc4 100644
--- a/docs/docs/sandboxes/daytona.mdx
+++ b/docs/docs/sandboxes/daytona.mdx
@@ -1,5 +1,5 @@
---
-sidebar_position: 3
+sidebar_position: 5
title: Daytona
---
diff --git a/docs/docs/sandboxes/docker.mdx b/docs/docs/sandboxes/docker.mdx
index e99e037..07a6929 100644
--- a/docs/docs/sandboxes/docker.mdx
+++ b/docs/docs/sandboxes/docker.mdx
@@ -1,5 +1,5 @@
---
-sidebar_position: 4
+sidebar_position: 6
title: Docker
---
diff --git a/docs/docs/sandboxes/e2b.mdx b/docs/docs/sandboxes/e2b.mdx
new file mode 100644
index 0000000..eeb2ad2
--- /dev/null
+++ b/docs/docs/sandboxes/e2b.mdx
@@ -0,0 +1,188 @@
+---
+sidebar_position: 7
+title: E2B
+---
+
+# E2B
+
+Runs code in an [E2B](https://e2b.dev) sandbox — a Firecracker microVM that
+starts in about 150 ms.
+
+The variant drives E2B through its **code interpreter** SDK
+(`e2b-code-interpreter`) rather than through the plain `e2b` SDK, which is what
+makes it behave like the rest of this package: the interpreter keeps a Jupyter
+kernel per context, so state **persists** between calls, and rich display data
+arrives as results rather than being lost.
+
+```python
+sandbox.run_code("x = 40")
+sandbox.run_code("x + 2").text # "42"
+```
+
+- **Requirements:** `code-sandboxes[e2b]` (installs `e2b-code-interpreter`).
+- **Parameters:** `api_key`, `domain`, `template`, `allow_internet_access`,
+ `secure`.
+
+## How To Obtain E2B Credentials
+
+1. Sign in at [e2b.dev](https://e2b.dev).
+2. Create an API key and export it:
+ ```bash
+ export E2B_API_KEY="e2b_..."
+ ```
+3. Optionally point at a self-hosted cluster rather than at e2b.dev:
+ ```bash
+ export E2B_DOMAIN="e2b.example.com"
+ ```
+
+Either can be passed to the sandbox instead of exported. What is left out is
+read from the environment by the E2B SDK, so passing nothing is not the same as
+passing `None`:
+
+```python
+Sandbox.create(variant="e2b", api_key="e2b_...", domain="e2b.example.com")
+```
+
+The SDK documentation is at [docs.e2b.dev](https://docs.e2b.dev).
+
+## Usage
+
+```python
+from code_sandboxes import Sandbox
+
+with Sandbox.create(variant="e2b") as sandbox:
+ result = sandbox.run_code("import numpy as np; np.arange(5).sum()")
+ print(result.text) # "10"
+```
+
+### Templates
+
+E2B takes a **template** per sandbox — an image with its packages already
+installed — rather than a catalogue of machines. Here it is always
+`code-interpreter-v1`, E2B's own interpreter template, unless `template=` names
+one of your own:
+
+```python
+Sandbox.create(variant="e2b", template="my-team-template")
+```
+
+A template named here has to be built **on top of** the interpreter's. This
+variant drives the code interpreter SDK, which talks to a Jupyter kernel inside
+the sandbox, and only a template carrying such a kernel can answer it. `base` —
+the default of the plain `e2b` SDK, where there is no kernel — would start a
+sandbox that then failed every execution, which is why it is not what you get
+here.
+
+For the same reason `Sandbox.list_environments(variant="e2b")` ships **one**
+environment, `e2b-code-interpreter`, rather than a menu including images that
+cannot serve the interpreter. It is what `code-sandboxes environments` shows.
+
+### Contexts
+
+The default namespace is shared by every call. `create_context()` asks E2B for
+a kernel it keeps apart, so two pieces of work can run in the same sandbox
+without seeing each other's variables:
+
+```python
+with Sandbox.create(variant="e2b") as sandbox:
+ other = sandbox.create_context()
+
+ sandbox.run_code("secret = 1")
+ sandbox.run_code("secret", context=other).code_error.name # "NameError"
+```
+
+A context is created on first use, since creating one starts a kernel and most
+callers never need a second.
+
+### Rich Outputs
+
+A kernel has a channel for display data that a process writing to stdout has
+not, so what the code displays comes back as a result keyed by MIME type — a
+figure as `image/png`, an HTML repr as `text/html`:
+
+```python
+result = sandbox.run_code("""
+import matplotlib.pyplot as plt
+plt.plot([1, 2, 3])
+plt.show()
+""")
+figure = next(item for item in result.results if item.png)
+figure.png # base64 PNG
+```
+
+E2B names each format with an attribute of its own (`png`, `html`, …) where
+this package keys them by MIME type; the translation happens here, including
+for a custom MIME type a library declares.
+
+### Timeouts
+
+E2B takes a sandbox down when its timeout runs out, whatever it is doing. A
+long-running job therefore has to say so before it starts, and `set_timeout`
+is how — the count restarts at the moment of the call:
+
+```python
+sandbox.set_timeout(600) # ten more minutes, from now
+```
+
+`max_lifetime` on the configuration says the same thing at creation, in whole
+seconds.
+
+### Reaching A Service Inside
+
+Every port inside has a public hostname of its own, which is what makes a
+server started in the sandbox — a dashboard, an API under test — reachable
+from outside without a tunnel of your own:
+
+```python
+sandbox.run_code("import subprocess; subprocess.Popen(['python', '-m', 'http.server', '8000'])")
+print(f"https://{sandbox.get_host(8000)}")
+```
+
+### Network policy
+
+E2B offers one switch rather than a list of hosts:
+
+```python
+# No outbound network at all.
+Sandbox.create(variant="e2b", network_policy="none")
+```
+
+`network_policy="allowlist"` is **refused** with the reason: E2B has no host
+allowlist, and silently widening a policy that names hosts to the whole
+internet is the failure that matters. Use `"none"` to cut the network off, or
+`"all"` to allow it. `allow_internet_access=False` says the same thing as
+`"none"` for a caller who would rather pass it as an argument.
+
+`secure=True` — the default here as in the SDK — keeps E2B's own hardening of
+the sandbox's control plane.
+
+### Names and metadata
+
+E2B keeps a flat map of strings per sandbox and lets one be looked up by it, so
+the name and the tags of the configuration travel there, next to
+`created-by=code-sandboxes` — which is what tells the sandboxes this package
+made from the rest of the account's:
+
+```python
+Sandbox.create(variant="e2b", name="nightly-report", tags={"team": "ai"})
+```
+
+`code-sandboxes list -v e2b` shows what is there; see the
+[management guide](/cli/management).
+
+## What Is Not Reported
+
+- There is no interrupt: `sandbox.interrupt()` answers `False`, and a runaway
+ execution is stopped by its timeout.
+- Variables cross as JSON (`get_variable`, `set_variable`, and everything built
+ on them such as `sandbox.commands.run`), so a value that cannot be encoded
+ comes back as its `repr` rather than as the object itself, and one that
+ cannot be *sent* is refused with a reason.
+
+Binary files go straight to E2B's filesystem API rather than through a program
+that decodes them, so a large file does not have to become a large program:
+
+```python
+sandbox.files.write_bytes("/tmp/data.parquet", payload)
+sandbox.files.read_bytes("/tmp/data.parquet")
+```
diff --git a/docs/docs/sandboxes/eval.mdx b/docs/docs/sandboxes/eval.mdx
index eb12ac7..e02b2d7 100644
--- a/docs/docs/sandboxes/eval.mdx
+++ b/docs/docs/sandboxes/eval.mdx
@@ -1,5 +1,5 @@
---
-sidebar_position: 5
+sidebar_position: 8
title: Eval
---
diff --git a/docs/docs/sandboxes/google-colab.mdx b/docs/docs/sandboxes/google-colab.mdx
index c81be3c..547a5ad 100644
--- a/docs/docs/sandboxes/google-colab.mdx
+++ b/docs/docs/sandboxes/google-colab.mdx
@@ -1,5 +1,5 @@
---
-sidebar_position: 6
+sidebar_position: 9
title: Google Colab
---
diff --git a/docs/docs/sandboxes/index.mdx b/docs/docs/sandboxes/index.mdx
index 24e39bf..ea052a5 100644
--- a/docs/docs/sandboxes/index.mdx
+++ b/docs/docs/sandboxes/index.mdx
@@ -11,9 +11,9 @@ A sandbox is an isolated environment where code can be executed safely. Code San
Use `Sandbox.create()` to create a new sandbox:
-Canonical variant names are `datalayer`, `daytona`, `docker`, `eval`,
-`google-colab`, `jupyter-server`, `kaggle`, `modal`, and `monty`. Older `local-*`
-names are no longer supported.
+Canonical variant names are `cloudflare`, `coreweave`, `datalayer`,
+`daytona`, `docker`, `e2b`, `eval`, `google-colab`, `jupyter-server`, `kaggle`,
+`modal`, and `monty`. Older `local-*` names are no longer supported.
A name is read in whatever spelling it arrives in: `google-colab`,
`google_colab` and `Google Colab` all name the same variant.
@@ -64,9 +64,12 @@ Each page below explains how to configure each variant.
| Variant | Summary |
|---------|---------|
+| [`cloudflare`](./cloudflare) | Container on Cloudflare's edge, through a deployed sandbox bridge Worker |
+| [`coreweave`](./coreweave) | Container on CoreWeave with a session process and an optional GPU |
| [`datalayer`](./datalayer) | Datalayer managed runtime with optional GPU |
| [`daytona`](./daytona) | Daytona cloud sandbox with a stateful interpreter |
| [`docker`](./docker) | Jupyter execution in a Docker container |
+| [`e2b`](./e2b) | E2B Firecracker microVM with a Jupyter kernel and rich outputs |
| [`eval`](./eval) | In-process `exec()` for fast development-only runs |
| [`google-colab`](./google-colab) | Google Colab runtime via runtime proxy |
| [`jupyter-server`](./jupyter-server) | Jupyter kernel-backed execution with persistent state |
@@ -150,7 +153,7 @@ value * 2
## State Persistence
-All sandbox variants keep state (variables, imports, and definitions)
+Most sandbox variants keep state (variables, imports, and definitions)
within the same sandbox instance. For example:
```python
@@ -161,6 +164,12 @@ with Sandbox.create() as sandbox:
print(result.text) # 2
```
+What a variant cannot do is hold a namespace open when its backend gives it
+nothing to hold one with. [`cloudflare`](./cloudflare) runs each snippet in a
+process of its own for that reason, and its page says what to do instead;
+[`coreweave`](./coreweave) keeps a session process and falls back to the same
+arrangement when that process cannot be started.
+
### Streaming Output
```python
diff --git a/docs/docs/sandboxes/jupyter-server.mdx b/docs/docs/sandboxes/jupyter-server.mdx
index ee9d948..c25902a 100644
--- a/docs/docs/sandboxes/jupyter-server.mdx
+++ b/docs/docs/sandboxes/jupyter-server.mdx
@@ -1,5 +1,5 @@
---
-sidebar_position: 7
+sidebar_position: 10
title: Jupyter
---
diff --git a/docs/docs/sandboxes/kaggle.mdx b/docs/docs/sandboxes/kaggle.mdx
index 360f3b8..40ce35e 100644
--- a/docs/docs/sandboxes/kaggle.mdx
+++ b/docs/docs/sandboxes/kaggle.mdx
@@ -1,5 +1,5 @@
---
-sidebar_position: 8
+sidebar_position: 11
title: Kaggle
---
diff --git a/docs/docs/sandboxes/modal.mdx b/docs/docs/sandboxes/modal.mdx
index 4133ab4..96d7b98 100644
--- a/docs/docs/sandboxes/modal.mdx
+++ b/docs/docs/sandboxes/modal.mdx
@@ -1,5 +1,5 @@
---
-sidebar_position: 9
+sidebar_position: 12
title: Modal
---
@@ -8,9 +8,13 @@ title: Modal
Runs code in a [Modal](https://modal.com/docs/guide) cloud sandbox, providing
fully isolated, on-demand containers with configurable images and secrets.
-Each `run_code` call executes in a fresh `python -c` process, so state does **not**
-persist across calls (use a single multi-statement snippet if you need shared
-state). Configure the image with additional pip packages as needed.
+State **persists** between calls, though Modal itself offers nothing that holds
+a Python namespace: what it offers is a container and `exec`, a process at a
+time. So the variant holds one itself — a single `python -u -c` session process
+is started with the sandbox and fed one snippet at a time. A session process
+that cannot be started, or that goes away mid-session, drops the sandbox back
+to a process per snippet — working, merely stateless — rather than failing the
+run. Configure the image with additional pip packages as needed.
- **Requirements:** `code-sandboxes[modal]` (installs `modal`).
- **Parameters:** `app_name`, `image` (a prebuilt `modal.Image`), `pip_packages`,
diff --git a/docs/docs/sandboxes/monty.mdx b/docs/docs/sandboxes/monty.mdx
index 64b7549..b13f75d 100644
--- a/docs/docs/sandboxes/monty.mdx
+++ b/docs/docs/sandboxes/monty.mdx
@@ -1,5 +1,5 @@
---
-sidebar_position: 10
+sidebar_position: 13
title: Monty
---
diff --git a/examples/README.md b/examples/README.md
index 91d5397..0af36f7 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -23,9 +23,12 @@ example here: it shows how to use a sandbox, not how to print things.
Supported sandbox variants:
+- `cloudflare`
+- `coreweave`
- `datalayer`
- `daytona`
- `docker`
+- `e2b`
- `eval`
- `google-colab`
- `jupyter-server`
@@ -37,9 +40,12 @@ Run one-shot examples from `examples/exec/`:
```bash
cd exec
+python cloudflare_sandbox_example.py
+python coreweave_sandbox_example.py
python datalayer_sandbox_example.py
python daytona_sandbox_example.py
python docker_sandbox_example.py
+python e2b_sandbox_example.py
python eval_sandbox_example.py
python google_colab_sandbox_example.py
python jupyter_server_sandbox_example.py
@@ -52,9 +58,12 @@ Or run one-shot examples via Make targets:
```bash
cd exec
+make cloudflare
+make coreweave
make datalayer
make daytona
make docker
+make e2b
make eval
make google-colab
make jupyter-server
@@ -67,9 +76,12 @@ Run REPL examples from `examples/repl/`:
```bash
cd repl
+make cloudflare
+make coreweave
make datalayer
make daytona
make docker
+make e2b
make eval
make google-colab
make jupyter-server
@@ -80,10 +92,19 @@ make monty
Notes by variant:
+- `cloudflare`: requires `code-sandboxes[cloudflare]` and a deployed sandbox
+ bridge Worker — `npm create cloudflare -- sandbox-bridge --template=cloudflare/sandbox-sdk/bridge/worker` — named by
+ `CLOUDFLARE_SANDBOX_API_URL` with the `CLOUDFLARE_SANDBOX_API_KEY` it
+ generated. Each snippet runs in a process of its own, so nothing crosses
+ between them.
+- `coreweave`: requires `code-sandboxes[coreweave]` and `CWSANDBOX_API_KEY`
+ (`CWSANDBOX_BASE_URL` for another control plane).
- `datalayer`: requires Datalayer runtime credentials/config.
- `daytona`: requires `code-sandboxes[daytona]` and `DAYTONA_API_KEY` (or
`DAYTONA_JWT_TOKEN` with `DAYTONA_ORGANIZATION_ID`).
- `docker`: requires Docker support and a Docker image (for example `code-sandboxes-jupyter:latest`).
+- `e2b`: requires `code-sandboxes[e2b]` and `E2B_API_KEY` (`E2B_DOMAIN` for a
+ self-hosted cluster).
- `google-colab`: requires `RUNTIME_URL`, `RUNTIME_ID`, and `RUNTIME_PROXY_TOKEN`.
- `kaggle`: requires `RUNTIME_CHANNELS_URL`, or `RUNTIME_URL` and `RUNTIME_ID`.
- `modal`: requires `MODAL_TOKEN_ID`/`MODAL_TOKEN_SECRET` or `~/.modal.toml`.
diff --git a/examples/exec/Makefile b/examples/exec/Makefile
index c8f56a3..cd3f01a 100644
--- a/examples/exec/Makefile
+++ b/examples/exec/Makefile
@@ -2,9 +2,9 @@
PYTHON ?= python
-.PHONY: all eval docker jupyter-server monty google-colab kaggle kaggle-gpu modal modal-gpu daytona daytona-gpu daytona-gpu-spot datalayer
+.PHONY: all eval docker jupyter-server monty google-colab kaggle kaggle-gpu modal modal-gpu daytona daytona-gpu daytona-gpu-spot datalayer e2b coreweave coreweave-gpu cloudflare
-all: eval docker jupyter-server monty google-colab modal daytona datalayer
+all: eval docker jupyter-server monty google-colab modal daytona datalayer e2b coreweave cloudflare
eval:
$(PYTHON) eval_sandbox_example.py
@@ -64,3 +64,28 @@ daytona-gpu-spot: ## Daytona example on a SPOT GPU (DAYTONA_GPU flavor, default
datalayer:
$(PYTHON) datalayer_sandbox_example.py
+
+e2b: ## E2B example (Firecracker microVM with a Jupyter kernel)
+ @echo "==> E2B example"
+ @echo " auth: E2B_API_KEY, and E2B_DOMAIN for a self-hosted cluster"
+ @echo " The kernel holds one namespace, so snippets share their state."
+ $(PYTHON) e2b_sandbox_example.py
+
+coreweave: ## CoreWeave example (CPU)
+ @echo "==> CoreWeave example (CPU)"
+ @echo " auth: CWSANDBOX_API_KEY, and CWSANDBOX_BASE_URL for another control plane"
+ $(PYTHON) coreweave_sandbox_example.py
+
+coreweave-gpu: ## CoreWeave example on a GPU (COREWEAVE_GPU flavor, default H100)
+ @echo "==> CoreWeave example (GPU: $${COREWEAVE_GPU:-H100})"
+ @echo " The run fails unless nvidia-smi lists a device in the sandbox."
+ @echo " COREWEAVE_IMAGE names the image, which has to carry what the code needs."
+ COREWEAVE_GPU=$${COREWEAVE_GPU:-H100} $(PYTHON) coreweave_sandbox_example.py --gpu "$${COREWEAVE_GPU:-H100}"
+
+cloudflare: ## Cloudflare example (through a deployed sandbox bridge Worker)
+ @echo "==> Cloudflare example"
+ @echo " auth: CLOUDFLARE_SANDBOX_API_URL and CLOUDFLARE_SANDBOX_API_KEY"
+ @echo " Deploy the bridge once: npm create cloudflare -- sandbox-bridge \\"
+ @echo " --template=cloudflare/sandbox-sdk/bridge/worker"
+ @echo " Each snippet runs in a process of its own: nothing crosses between them."
+ $(PYTHON) cloudflare_sandbox_example.py
diff --git a/examples/exec/cloudflare_sandbox_example.py b/examples/exec/cloudflare_sandbox_example.py
new file mode 100644
index 0000000..3a8d929
--- /dev/null
+++ b/examples/exec/cloudflare_sandbox_example.py
@@ -0,0 +1,100 @@
+# Copyright (c) 2025-2026 Datalayer, Inc.
+# BSD 3-Clause License
+
+"""Example: cloudflare sandbox (container on Cloudflare, through the bridge).
+
+Run with:
+ python examples/exec/cloudflare_sandbox_example.py
+
+Auth:
+- Cloudflare's own SDK is a Workers binding written in TypeScript, and a Python
+ process cannot hold one. What a Python process can talk to is the SANDBOX
+ BRIDGE — a reference-implementation Worker Cloudflare publishes, which
+ exposes the SDK over HTTP. Deploy it once:
+
+ npm create cloudflare -- sandbox-bridge \\
+ --template=cloudflare/sandbox-sdk/bridge/worker
+
+- export CLOUDFLARE_SANDBOX_API_URL (where the Worker answers, e.g.
+ https://cloudflare-sandbox-bridge..workers.dev) and
+ CLOUDFLARE_SANDBOX_API_KEY (the secret it generated).
+
+The bridge's exec gives no stdin, so each snippet runs in a process of its own
+and `x = 1` is gone by the next call. That is not worked around here, it is
+SHOWN — along with the two ways round it: put the statements that share state
+in one snippet, or keep the state in a file, since the filesystem persists.
+"""
+
+import os
+
+from code_sandboxes import Sandbox, show_and_run
+
+BRIDGE_DEPLOY_COMMAND = (
+ "npm create cloudflare -- sandbox-bridge --template=cloudflare/sandbox-sdk/bridge/worker"
+)
+
+
+def main() -> None:
+ if not os.environ.get("CLOUDFLARE_SANDBOX_API_URL") or not os.environ.get(
+ "CLOUDFLARE_SANDBOX_API_KEY"
+ ):
+ print("Cloudflare sandbox bridge settings not found.")
+ print("Set CLOUDFLARE_SANDBOX_API_URL and CLOUDFLARE_SANDBOX_API_KEY.")
+ print("Deploy a bridge Worker with:")
+ print(f" {BRIDGE_DEPLOY_COMMAND}")
+ print("See https://developers.cloudflare.com/sandbox/bridge/.")
+ raise SystemExit(1)
+
+ print("Launching cloudflare sandbox through the bridge at")
+ print(f" {os.environ['CLOUDFLARE_SANDBOX_API_URL']}")
+
+ try:
+ with Sandbox.create(variant="cloudflare", timeout=60) as sandbox:
+ print(f"Sandbox: {sandbox.sandbox_id}")
+
+ show_and_run(sandbox, "import sys; print(sys.version.split()[0])")
+
+ # STATELESS, and demonstrated rather than described: the second
+ # snippet is a new process, and does not know what the first one
+ # defined.
+ show_and_run(sandbox, "x = 40")
+ gone = show_and_run(sandbox, "x + 2")
+ if gone.code_error is None or gone.code_error.name != "NameError":
+ raise RuntimeError(
+ "A snippet saw the namespace of the one before it, which "
+ "the bridge does not allow — this example is out of date."
+ )
+ print("as expected: nothing crossed between snippets.")
+
+ # The first way round it: whatever shares state shares a snippet.
+ together = show_and_run(sandbox, "x = 40\nx + 2")
+ if together.text != "42":
+ raise RuntimeError(f"One snippet did not answer with its value: {together.text!r}.")
+ print("one snippet: the statements that share state ran together.")
+
+ # The second: the filesystem of the sandbox does persist, so state
+ # that has to outlive a call goes there rather than in a variable.
+ sandbox.files.write("/workspace/state.json", '{"x": 40}')
+ kept = show_and_run(
+ sandbox,
+ "import json; json.load(open('/workspace/state.json'))['x'] + 2",
+ )
+ if kept.text != "42":
+ raise RuntimeError(f"The state file did not survive: {kept.text!r}.")
+ print("a file: the state crossed the call on the filesystem.")
+
+ # The error path, demonstrated ON PURPOSE: the run must not die,
+ # the failure must come back as a `code_error` on the result. Said
+ # before it happens, or the example's last lines read as a crash.
+ print("-- error handling: the next snippet raises deliberately --")
+ error_result = show_and_run(sandbox, "raise RuntimeError('cloudflare failure example')")
+ if error_result.code_error is None:
+ raise RuntimeError("The deliberate failure did not surface as a code_error.")
+ print("error captured as expected — cloudflare example completed.")
+ except Exception as exc:
+ print("cloudflare example failed:", exc)
+ raise SystemExit(1) from exc
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/exec/coreweave_sandbox_example.py b/examples/exec/coreweave_sandbox_example.py
new file mode 100644
index 0000000..cbfdeee
--- /dev/null
+++ b/examples/exec/coreweave_sandbox_example.py
@@ -0,0 +1,140 @@
+# Copyright (c) 2025-2026 Datalayer, Inc.
+# BSD 3-Clause License
+
+"""Example: coreweave sandbox (container on CoreWeave, optionally on a GPU).
+
+Run with:
+ python examples/exec/coreweave_sandbox_example.py
+
+Auth:
+- create an access token in the CoreWeave console and export CWSANDBOX_API_KEY.
+- export CWSANDBOX_BASE_URL to talk to another control plane than
+ https://api.cwsandbox.com.
+
+GPU options:
+- pass `--gpu H100`, or set COREWEAVE_GPU in the environment.
+- a GPU is a machine specification, and the image has to carry what the code
+ needs — `--image` says which one to run.
+
+CoreWeave offers a container and `exec`, a process at a time, and nothing that
+holds a Python namespace between calls. So the variant holds one itself: a
+single session process is started with the sandbox and fed one snippet at a
+time. A driver that cannot start, or that goes away mid-session, drops the
+sandbox back to a process per snippet — working, merely stateless — which is
+why the state check below asks the sandbox which of the two it got.
+"""
+
+import argparse
+import os
+
+from code_sandboxes import Sandbox, show_and_run
+
+
+def _parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Run the coreweave sandbox example.")
+ parser.add_argument(
+ "--gpu",
+ default=os.environ.get("COREWEAVE_GPU"),
+ help="Optional GPU (for example: H100, H200, A100).",
+ )
+ parser.add_argument(
+ "--image",
+ default=os.environ.get("COREWEAVE_IMAGE"),
+ help="The container image the sandbox runs. `python:3.11` unless given.",
+ )
+ return parser.parse_args()
+
+
+def _gpu_probe_code() -> str:
+ """Code that PROVES a GPU, not merely looks for one.
+
+ Prints one `GPU-PROBE:` line per fact so the caller can assert on them:
+ the driver must be there, and must list at least one device.
+ """
+ return """
+import shutil
+import subprocess
+
+smi = shutil.which("nvidia-smi")
+print("GPU-PROBE: nvidia-smi", "present" if smi else "MISSING")
+if smi:
+ listing = subprocess.run(
+ ["nvidia-smi", "-L"], check=False, capture_output=True, text=True
+ ).stdout.strip()
+ print("GPU-PROBE: devices", listing or "NONE")
+"""
+
+
+def _verify_gpu(sandbox, gpu: str) -> None:
+ """Prove the GPU is there rather than take the request for an answer."""
+ probe = show_and_run(sandbox, _gpu_probe_code()).stdout.strip()
+ if "nvidia-smi present" not in probe:
+ raise RuntimeError("GPU requested but nvidia-smi is missing in the sandbox.")
+ if "devices NONE" in probe or "GPU-PROBE: devices" not in probe:
+ raise RuntimeError("GPU requested but no device is listed by nvidia-smi.")
+ print(f"GPU verified: {gpu} is present.")
+
+
+def main() -> None:
+ args = _parse_args()
+ if not os.environ.get("CWSANDBOX_API_KEY"):
+ print("CoreWeave auth not found.")
+ print("Set CWSANDBOX_API_KEY to an access token from the CoreWeave console.")
+ print("See https://docs.coreweave.com/products/sandboxes.")
+ raise SystemExit(1)
+
+ if args.gpu:
+ print(f"Launching coreweave sandbox with GPU: {args.gpu}")
+ else:
+ print("Launching coreweave sandbox without GPU.")
+
+ try:
+ with Sandbox.create(
+ variant="coreweave",
+ timeout=60,
+ gpu=args.gpu,
+ container_image=args.image,
+ ) as sandbox:
+ print(f"Sandbox: {sandbox.sandbox_id}")
+
+ show_and_run(sandbox, "x = 40")
+ show_and_run(sandbox, "import sys; print(sys.version.split()[0])")
+ state = show_and_run(sandbox, "x + 2")
+
+ # The sandbox says which arrangement it ended up with, and only a
+ # session process promises the namespace crosses. Asserting on
+ # state that was never promised would fail an honest fallback.
+ info = sandbox.info
+ stateful = bool(info.metadata.get("stateful")) if info else False
+ if stateful:
+ if state.text != "42":
+ raise RuntimeError(
+ f"State did not survive between snippets: x + 2 gave {state.text!r}."
+ )
+ print("state verified: the session process shares one namespace.")
+ else:
+ print("no session process: each snippet ran on its own, so x did not cross.")
+
+ # Bytes take the filesystem of the container, not a program that
+ # decodes them.
+ sandbox.files.write_bytes("/tmp/hello.bin", b"from coreweave")
+ print("file round trip:", sandbox.files.read_bytes("/tmp/hello.bin"))
+
+ if args.gpu:
+ _verify_gpu(sandbox, args.gpu)
+
+ # The error path, demonstrated ON PURPOSE: the run must not die,
+ # the failure must come back as a `code_error` on the result. Said
+ # before it happens, or the example's last lines read as a crash.
+ print("-- error handling: the next snippet raises deliberately --")
+ error_result = show_and_run(sandbox, "raise RuntimeError('coreweave failure example')")
+ if error_result.code_error is None:
+ raise RuntimeError("The deliberate failure did not surface as a code_error.")
+ print("error captured as expected — coreweave example completed.")
+ except Exception as exc:
+ print("coreweave example failed:", exc)
+ raise SystemExit(1) from exc
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/exec/e2b_sandbox_example.py b/examples/exec/e2b_sandbox_example.py
new file mode 100644
index 0000000..22bd510
--- /dev/null
+++ b/examples/exec/e2b_sandbox_example.py
@@ -0,0 +1,107 @@
+# Copyright (c) 2025-2026 Datalayer, Inc.
+# BSD 3-Clause License
+
+"""Example: e2b sandbox (Firecracker microVM with a Jupyter kernel).
+
+Run with:
+ python examples/exec/e2b_sandbox_example.py
+
+Auth:
+- create an API key at https://e2b.dev and export E2B_API_KEY.
+- export E2B_DOMAIN as well to talk to a self-hosted cluster rather than to
+ e2b.dev.
+
+The variant drives E2B through its code interpreter SDK, which keeps a Jupyter
+kernel per context. So two things hold here that do not hold in a variant
+running a process per snippet: definitions persist between snippets, and rich
+display data — an HTML repr, a figure — comes back as a result rather than
+being lost.
+"""
+
+import argparse
+import os
+
+from code_sandboxes import Sandbox, show_and_run
+
+
+def _parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Run the e2b sandbox example.")
+ parser.add_argument(
+ "--template",
+ default=os.environ.get("E2B_TEMPLATE"),
+ help=(
+ "The E2B template to create from. `code-interpreter-v1` unless "
+ "told otherwise — and anything named here has to be built on top "
+ "of it, since only a template carrying a Jupyter kernel can serve "
+ "the interpreter this variant drives."
+ ),
+ )
+ return parser.parse_args()
+
+
+def main() -> None:
+ args = _parse_args()
+ if not os.environ.get("E2B_API_KEY"):
+ print("E2B auth not found.")
+ print("Set E2B_API_KEY. Create a key at https://e2b.dev.")
+ raise SystemExit(1)
+
+ print(f"Launching e2b sandbox from template: {args.template or 'code-interpreter-v1'}")
+
+ try:
+ with Sandbox.create(variant="e2b", timeout=60, template=args.template) as sandbox:
+ print(f"Sandbox: {sandbox.sandbox_id}")
+
+ # What tells this variant apart from a per-snippet runner: the
+ # kernel holds one namespace, so the second snippet sees what the
+ # first defined.
+ show_and_run(sandbox, "x = 40")
+ show_and_run(sandbox, "import sys; print(sys.version.split()[0])")
+ state = show_and_run(sandbox, "x + 2")
+ if state.text != "42":
+ raise RuntimeError(
+ f"State did not survive between snippets: x + 2 gave {state.text!r}."
+ )
+ print("state verified: the namespace is shared between snippets.")
+
+ # A kernel has a channel for rich display data, which a process
+ # writing to stdout has not: what the code displays arrives as a
+ # result keyed by its MIME type.
+ rich = show_and_run(
+ sandbox,
+ "from IPython.display import HTML\nHTML('from e2b')",
+ )
+ if not any(result.html for result in rich.results):
+ raise RuntimeError("The HTML repr did not come back as a result.")
+ print("rich output verified: text/html arrived as a result.")
+
+ # E2B takes a sandbox down when its timeout runs out, whatever it
+ # is doing — so a long job says so before it starts, and the count
+ # restarts at the call.
+ sandbox.set_timeout(300)
+ print("timeout extended: the sandbox has five minutes from now.")
+
+ # Every port inside has a public host of its own, which is what
+ # makes a server started in the sandbox reachable without a tunnel.
+ print("host for port 8000:", sandbox.get_host(8000))
+
+ # Bytes take the filesystem of the sandbox, not a program that
+ # decodes them.
+ sandbox.files.write_bytes("/tmp/hello.bin", b"from e2b")
+ print("file round trip:", sandbox.files.read_bytes("/tmp/hello.bin"))
+
+ # The error path, demonstrated ON PURPOSE: the run must not die,
+ # the failure must come back as a `code_error` on the result. Said
+ # before it happens, or the example's last lines read as a crash.
+ print("-- error handling: the next snippet raises deliberately --")
+ error_result = show_and_run(sandbox, "raise RuntimeError('e2b failure example')")
+ if error_result.code_error is None:
+ raise RuntimeError("The deliberate failure did not surface as a code_error.")
+ print("error captured as expected — e2b example completed.")
+ except Exception as exc:
+ print("e2b example failed:", exc)
+ raise SystemExit(1) from exc
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/repl/Makefile b/examples/repl/Makefile
index 84b86b9..c3943f5 100644
--- a/examples/repl/Makefile
+++ b/examples/repl/Makefile
@@ -2,9 +2,9 @@
PYTHON ?= python
-.PHONY: all eval docker jupyter-server monty google-colab kaggle kaggle-gpu kaggle-live kaggle-gpu-live modal modal-gpu daytona daytona-gpu daytona-gpu-spot datalayer
+.PHONY: all eval docker jupyter-server monty google-colab kaggle kaggle-gpu kaggle-live kaggle-gpu-live modal modal-gpu daytona daytona-gpu daytona-gpu-spot datalayer e2b coreweave coreweave-gpu cloudflare
-all: eval docker jupyter-server monty google-colab modal daytona datalayer
+all: eval docker jupyter-server monty google-colab modal daytona datalayer e2b coreweave cloudflare
eval:
$(PYTHON) eval_sandbox_example.py
@@ -68,3 +68,29 @@ daytona-gpu-spot: ## Daytona REPL on a SPOT GPU (DAYTONA_GPU flavor, default H10
datalayer:
$(PYTHON) datalayer_sandbox_example.py
+
+e2b: ## E2B REPL (Firecracker microVM with a Jupyter kernel)
+ @echo "==> E2B REPL"
+ @echo " auth: E2B_API_KEY, and E2B_DOMAIN for a self-hosted cluster"
+ @echo " Definitions persist between lines: the kernel holds one namespace."
+ @echo " The sandbox is given five minutes to live; --minutes says otherwise."
+ $(PYTHON) e2b_sandbox_example.py
+
+coreweave: ## CoreWeave REPL (CPU)
+ @echo "==> CoreWeave REPL (CPU)"
+ @echo " auth: CWSANDBOX_API_KEY, and CWSANDBOX_BASE_URL for another control plane"
+ @echo " Definitions persist between lines: one session process holds them."
+ $(PYTHON) coreweave_sandbox_example.py
+
+coreweave-gpu: ## CoreWeave REPL on a GPU (COREWEAVE_GPU flavor, default H100)
+ @echo "==> CoreWeave REPL (GPU: $${COREWEAVE_GPU:-H100})"
+ @echo " COREWEAVE_IMAGE names the image, which has to carry what the code needs."
+ COREWEAVE_GPU=$${COREWEAVE_GPU:-H100} $(PYTHON) coreweave_sandbox_example.py --gpu "$${COREWEAVE_GPU:-H100}"
+
+cloudflare: ## Cloudflare REPL (through a deployed sandbox bridge Worker)
+ @echo "==> Cloudflare REPL"
+ @echo " auth: CLOUDFLARE_SANDBOX_API_URL and CLOUDFLARE_SANDBOX_API_KEY"
+ @echo " Deploy the bridge once: npm create cloudflare -- sandbox-bridge \\"
+ @echo " --template=cloudflare/sandbox-sdk/bridge/worker"
+ @echo " Each line runs in its own process: definitions do NOT persist."
+ $(PYTHON) cloudflare_sandbox_example.py
diff --git a/examples/repl/cloudflare_sandbox_example.py b/examples/repl/cloudflare_sandbox_example.py
new file mode 100644
index 0000000..8910427
--- /dev/null
+++ b/examples/repl/cloudflare_sandbox_example.py
@@ -0,0 +1,62 @@
+# Copyright (c) 2025-2026 Datalayer, Inc.
+# BSD 3-Clause License
+
+"""REPL example: cloudflare sandbox (container on Cloudflare, through the bridge).
+
+Run with:
+ python examples/repl/cloudflare_sandbox_example.py
+
+Auth:
+- deploy the sandbox bridge Worker once, since Cloudflare's own SDK is a
+ Workers binding a Python process cannot hold:
+
+ npm create cloudflare -- sandbox-bridge \\
+ --template=cloudflare/sandbox-sdk/bridge/worker
+
+- export CLOUDFLARE_SANDBOX_API_URL (where the Worker answers) and
+ CLOUDFLARE_SANDBOX_API_KEY (the secret it generated).
+
+This prompt is NOT a REPL in the usual sense, and says so before it opens:
+each line runs in a process of its own, so `x = 40` on one line leaves nothing
+behind for `x + 2` on the next. Write what shares state on a single line, or
+keep it in a file — the filesystem of the sandbox does persist.
+"""
+
+import os
+
+from code_sandboxes import Sandbox, run_repl
+
+BRIDGE_DEPLOY_COMMAND = (
+ "npm create cloudflare -- sandbox-bridge --template=cloudflare/sandbox-sdk/bridge/worker"
+)
+
+
+def main() -> None:
+ if not os.environ.get("CLOUDFLARE_SANDBOX_API_URL") or not os.environ.get(
+ "CLOUDFLARE_SANDBOX_API_KEY"
+ ):
+ print("Cloudflare sandbox bridge settings not found.")
+ print("Set CLOUDFLARE_SANDBOX_API_URL and CLOUDFLARE_SANDBOX_API_KEY.")
+ print("Deploy a bridge Worker with:")
+ print(f" {BRIDGE_DEPLOY_COMMAND}")
+ print("See https://developers.cloudflare.com/sandbox/bridge/.")
+ raise SystemExit(1)
+
+ print("Launching cloudflare sandbox REPL through the bridge at")
+ print(f" {os.environ['CLOUDFLARE_SANDBOX_API_URL']}")
+
+ try:
+ with Sandbox.create(variant="cloudflare", timeout=60) as sandbox:
+ print(f"Sandbox: {sandbox.sandbox_id}")
+ # Said before the prompt opens rather than discovered at the first
+ # NameError: nothing defined on one line reaches the next.
+ print("Each line runs in its own process — definitions do NOT persist.")
+ print("Use one line for what shares state, or a file: `open('/workspace/x', 'w')`.")
+ run_repl(sandbox)
+ except Exception as exc:
+ print("cloudflare REPL failed:", exc)
+ raise SystemExit(1) from exc
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/repl/coreweave_sandbox_example.py b/examples/repl/coreweave_sandbox_example.py
new file mode 100644
index 0000000..b3a7550
--- /dev/null
+++ b/examples/repl/coreweave_sandbox_example.py
@@ -0,0 +1,74 @@
+# Copyright (c) 2025-2026 Datalayer, Inc.
+# BSD 3-Clause License
+
+"""REPL example: coreweave sandbox (container on CoreWeave, optionally on a GPU).
+
+Run with:
+ python examples/repl/coreweave_sandbox_example.py
+
+Auth:
+- create an access token in the CoreWeave console and export CWSANDBOX_API_KEY.
+- export CWSANDBOX_BASE_URL to talk to another control plane than
+ https://api.cwsandbox.com.
+
+Definitions persist between lines because the variant keeps one session process
+for the sandbox and feeds it a line at a time. When that process cannot be
+started, the sandbox falls back to a process per line — still working, no
+longer stateful — and the prompt says so before it opens.
+"""
+
+import argparse
+import os
+
+from code_sandboxes import Sandbox, run_repl
+
+
+def _parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Run the coreweave sandbox REPL example.")
+ parser.add_argument(
+ "--gpu",
+ default=os.environ.get("COREWEAVE_GPU"),
+ help="Optional GPU (for example: H100, H200, A100).",
+ )
+ parser.add_argument(
+ "--image",
+ default=os.environ.get("COREWEAVE_IMAGE"),
+ help="The container image the sandbox runs. `python:3.11` unless given.",
+ )
+ return parser.parse_args()
+
+
+def main() -> None:
+ args = _parse_args()
+ if not os.environ.get("CWSANDBOX_API_KEY"):
+ print("CoreWeave auth not found.")
+ print("Set CWSANDBOX_API_KEY to an access token from the CoreWeave console.")
+ print("See https://docs.coreweave.com/products/sandboxes.")
+ raise SystemExit(1)
+
+ if args.gpu:
+ print(f"Launching coreweave sandbox REPL with GPU: {args.gpu}")
+ else:
+ print("Launching coreweave sandbox REPL without GPU.")
+
+ try:
+ with Sandbox.create(
+ variant="coreweave",
+ timeout=60,
+ gpu=args.gpu,
+ container_image=args.image,
+ ) as sandbox:
+ print(f"Sandbox: {sandbox.sandbox_id}")
+ info = sandbox.info
+ if info and info.metadata.get("stateful"):
+ print("Definitions persist between lines: one session process holds them.")
+ else:
+ print("No session process: each line runs on its own, and nothing crosses.")
+ run_repl(sandbox)
+ except Exception as exc:
+ print("coreweave REPL failed:", exc)
+ raise SystemExit(1) from exc
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/repl/e2b_sandbox_example.py b/examples/repl/e2b_sandbox_example.py
new file mode 100644
index 0000000..94e02cd
--- /dev/null
+++ b/examples/repl/e2b_sandbox_example.py
@@ -0,0 +1,72 @@
+# Copyright (c) 2025-2026 Datalayer, Inc.
+# BSD 3-Clause License
+
+"""REPL example: e2b sandbox (Firecracker microVM with a Jupyter kernel).
+
+Run with:
+ python examples/repl/e2b_sandbox_example.py
+
+Auth:
+- create an API key at https://e2b.dev and export E2B_API_KEY.
+- export E2B_DOMAIN as well to talk to a self-hosted cluster rather than to
+ e2b.dev.
+
+The prompt behaves as a REPL should: the kernel holds one namespace, so
+definitions persist between lines, and a line that is an expression answers
+with its value.
+"""
+
+import argparse
+import os
+
+from code_sandboxes import Sandbox, run_repl
+
+
+def _parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Run the e2b sandbox REPL example.")
+ parser.add_argument(
+ "--template",
+ default=os.environ.get("E2B_TEMPLATE"),
+ help=(
+ "The E2B template to create from. `code-interpreter-v1` unless "
+ "told otherwise — and anything named here has to be built on top "
+ "of it, since only a template carrying a Jupyter kernel can serve "
+ "the interpreter this variant drives."
+ ),
+ )
+ parser.add_argument(
+ "--minutes",
+ type=float,
+ default=5.0,
+ help=(
+ "How long the sandbox may live. E2B takes one down when its "
+ "timeout runs out, whatever it is doing — including a prompt "
+ "somebody is still typing at."
+ ),
+ )
+ return parser.parse_args()
+
+
+def main() -> None:
+ args = _parse_args()
+ if not os.environ.get("E2B_API_KEY"):
+ print("E2B auth not found.")
+ print("Set E2B_API_KEY. Create a key at https://e2b.dev.")
+ raise SystemExit(1)
+
+ print(f"Launching e2b sandbox REPL from template: {args.template or 'code-interpreter-v1'}")
+
+ try:
+ with Sandbox.create(variant="e2b", timeout=60, template=args.template) as sandbox:
+ print(f"Sandbox: {sandbox.sandbox_id}")
+ # A REPL is read at human speed, and the default life of a sandbox
+ # is shorter than a session usually is.
+ sandbox.set_timeout(args.minutes * 60)
+ run_repl(sandbox)
+ except Exception as exc:
+ print("e2b REPL failed:", exc)
+ raise SystemExit(1) from exc
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pyproject.toml b/pyproject.toml
index 1cdd7fd..8ea13af 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -37,17 +37,23 @@ code-sandbox = "code_sandboxes.cli:main"
code-sandboxes = "code_sandboxes.cli:main"
[project.optional-dependencies]
+cloudflare = ["httpx>=0.27"]
+coreweave = ["cwsandbox>=1.6"]
datalayer = ["agent_runtimes>=1.0.16"]
daytona = ["daytona>=0.100"]
docker = ["docker>=6.0"]
+e2b = ["e2b-code-interpreter>=2.0"]
google-colab = []
kaggle = ["kaggle>=1.6"]
monty = ["pydantic-monty"]
modal = ["modal>=0.64"]
all = [
"agent_runtimes",
+ "cwsandbox>=1.6",
"daytona>=0.100",
"docker>=6.0",
+ "e2b-code-interpreter>=2.0",
+ "httpx>=0.27",
"kaggle>=1.6",
"pydantic-monty",
"modal>=0.64",
@@ -148,3 +154,4 @@ ignore = [
"code_sandboxes/docker_sandbox.py" = ["C901", "S110", "UP007"]
"code_sandboxes/eval_sandbox.py" = ["C901", "S102", "S307"]
"code_sandboxes/jupyter_sandbox.py" = ["C901", "S110", "S603", "UP007"]
+"code_sandboxes/e2b_sandbox.py" = ["C901"]
diff --git a/tests/test_cloudflare.py b/tests/test_cloudflare.py
new file mode 100644
index 0000000..9c6d3e7
--- /dev/null
+++ b/tests/test_cloudflare.py
@@ -0,0 +1,471 @@
+# Copyright (c) 2025-2026 Datalayer, Inc.
+#
+# BSD 3-Clause License
+
+"""The Cloudflare sandbox, against a bridge that really answers.
+
+The fake below is an HTTP transport, not a mock of the variant's methods: it
+speaks the bridge's protocol — a server-sent-event stream of base64 chunks and
+a terminal event — and runs the program the variant asks it to run. That is
+what is worth testing here, because the protocol is where this variant lives:
+everything else is the same `ExecutionResult` as every other variant's.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+import subprocess
+import sys
+
+import pytest
+
+from code_sandboxes.base import Sandbox
+from code_sandboxes.cloudflare_sandbox import (
+ _RUNNER_SOURCE,
+ API_KEY_ENV_VAR,
+ API_URL_ENV_VAR,
+ CloudflareSandbox,
+ _reply_of,
+ _sse_events,
+)
+from code_sandboxes.exceptions import (
+ SandboxConfigurationError,
+ SandboxConnectionError,
+)
+from code_sandboxes.manage import get_manager, manageable_variants
+from code_sandboxes.models import SandboxConfig, SandboxVariant
+from code_sandboxes.providers import get_provider
+
+BRIDGE_URL = "https://bridge.example.workers.dev"
+
+
+def _sse(*events: tuple[str, object]) -> bytes:
+ """Those events as the bridge would write them on the wire."""
+ records = []
+ for name, payload in events:
+ if name in ("stdout", "stderr"):
+ data = base64.b64encode(str(payload).encode()).decode()
+ else:
+ data = json.dumps(payload)
+ records.append(f"event: {name}\ndata: {data}\n\n")
+ # A keep-alive comment, which the reader must skip rather than choke on.
+ return (":\n\n" + "".join(records)).encode()
+
+
+class _FakeBridge:
+ """The sandbox bridge Worker, answering over httpx's transport hook."""
+
+ def __init__(self) -> None:
+ self.sandboxes: list[str] = []
+ self.deleted: list[str] = []
+ self.files: dict[str, bytes] = {}
+ self.requests: list[tuple[str, str]] = []
+ self.authorization: str | None = None
+ self.refuse = False
+
+ def handle(self, request):
+ import httpx
+
+ self.requests.append((request.method, request.url.path))
+ self.authorization = request.headers.get("authorization")
+ if self.refuse:
+ return httpx.Response(401, text="invalid key")
+
+ path = request.url.path
+ if request.method == "POST" and path == "/v1/sandbox":
+ sandbox_id = f"cf-sbx-{len(self.sandboxes) + 1}"
+ self.sandboxes.append(sandbox_id)
+ return httpx.Response(200, json={"id": sandbox_id})
+ if request.method == "DELETE" and path.count("/") == 3:
+ self.deleted.append(path.rsplit("/", 1)[-1])
+ return httpx.Response(204)
+ if path.endswith("/running"):
+ sandbox_id = path.split("/")[3]
+ running = sandbox_id in self.sandboxes and sandbox_id not in self.deleted
+ return httpx.Response(200, json={"running": running})
+ if path.endswith("/exec"):
+ return self._exec(httpx, json.loads(request.content))
+ if "/file/" in path:
+ name = path.split("/file/", 1)[1]
+ if request.method == "PUT":
+ self.files[name] = request.content
+ return httpx.Response(200, json={"ok": True})
+ if name not in self.files:
+ return httpx.Response(404, text="no such file")
+ return httpx.Response(200, content=self.files[name])
+ return httpx.Response(404, text=f"no route for {path}")
+
+ def _exec(self, httpx, body):
+ """Really run the argv, and stream what it wrote."""
+ argv = list(body["argv"])
+ # The variant asks for `python3`; this machine's Python is the one
+ # that is actually here.
+ argv[0] = sys.executable
+ finished = subprocess.run( # noqa: S603
+ argv, capture_output=True, text=True, timeout=60, check=False
+ )
+ return httpx.Response(
+ 200,
+ headers={"content-type": "text/event-stream"},
+ content=_sse(
+ ("stdout", finished.stdout),
+ ("stderr", finished.stderr),
+ ("exit", {"exit_code": finished.returncode}),
+ ),
+ )
+
+
+def _started(bridge: _FakeBridge | None = None, config: SandboxConfig | None = None, **kwargs):
+ """A sandbox talking to the fake bridge over a real httpx client."""
+ import httpx
+
+ bridge = bridge or _FakeBridge()
+ sandbox = CloudflareSandbox(config=config, api_url=BRIDGE_URL, api_key="cf_key", **kwargs)
+ # The client is built in `start`; here it is built with the transport that
+ # answers, so the protocol is exercised end to end.
+ sandbox._client = httpx.Client(
+ base_url=BRIDGE_URL,
+ headers={"Authorization": "Bearer cf_key"},
+ transport=httpx.MockTransport(bridge.handle),
+ )
+ response = sandbox._client.post("/v1/sandbox")
+ sandbox._sandbox_id = response.json()["id"]
+ sandbox._started = True
+ sandbox._default_context = sandbox.create_context("default")
+ sandbox.bridge = bridge # for the assertions
+ return sandbox
+
+
+pytest.importorskip("httpx")
+
+
+# --- The stream ------------------------------------------------------------
+
+
+def test_the_events_of_a_stream_are_read_the_way_the_specification_says():
+ stream = (
+ ":keep-alive\n"
+ "event: stdout\n"
+ "data: aGVsbG8=\n"
+ "\n"
+ "event: message\n"
+ "data: one\n"
+ "data: two\n"
+ "\n"
+ "event: exit\n"
+ 'data: {"exit_code": 0}\n'
+ "\n"
+ )
+
+ events = list(_sse_events(iter(stream.split("\n"))))
+
+ assert events[0] == ("stdout", "aGVsbG8=")
+ # Two data lines in one record are joined with a newline, not dropped.
+ assert events[1] == ("message", "one\ntwo")
+ assert events[2] == ("exit", '{"exit_code": 0}')
+
+
+def test_the_reply_is_taken_from_the_last_line_the_process_wrote():
+ """Anything the container printed on its own account comes before it."""
+ stdout = "warning: something\n" + json.dumps({"status": "ok", "result": "42"})
+
+ assert _reply_of(stdout) == {"status": "ok", "result": "42"}
+ assert _reply_of("nothing json here") is None
+
+
+def test_the_runner_source_is_a_program_that_answers_what_it_promises():
+ request = json.dumps({"code": "print('hi'); 40 + 2"})
+ finished = subprocess.run( # noqa: S603
+ [sys.executable, "-u", "-c", _RUNNER_SOURCE, request],
+ capture_output=True,
+ text=True,
+ timeout=60,
+ check=False,
+ )
+
+ reply = _reply_of(finished.stdout)
+ assert reply["status"] == "ok"
+ assert reply["stdout"] == "hi\n"
+ assert reply["result"] == "42"
+
+
+# --- Executing -------------------------------------------------------------
+
+
+def test_a_snippet_runs_and_its_output_comes_back():
+ sandbox = _started()
+ streamed: list[str] = []
+
+ execution = sandbox.run_code(
+ "import sys\nprint('one')\nprint('bad', file=sys.stderr)",
+ on_stdout=lambda message: streamed.append(message.line),
+ )
+
+ assert execution.execution_ok
+ assert streamed == ["one"]
+ assert [message.line for message in execution.logs.stderr] == ["bad"]
+
+
+def test_the_value_of_a_trailing_expression_is_answered_with():
+ execution = _started().run_code("1 + 1")
+
+ assert execution.text == "2"
+
+
+def test_a_raising_snippet_is_reported_as_the_codes_error_not_the_sandboxs():
+ execution = _started().run_code("1 / 0")
+
+ assert execution.execution_ok
+ assert execution.code_error is not None
+ assert execution.code_error.name == "ZeroDivisionError"
+
+
+def test_each_snippet_runs_in_a_process_of_its_own():
+ """This variant cannot hold a namespace; the docs say so, and so does this."""
+ sandbox = _started()
+
+ sandbox.run_code("x = 41")
+ execution = sandbox.run_code("print(x)")
+
+ assert execution.code_error is not None
+ assert execution.code_error.name == "NameError"
+
+
+def test_an_environment_asked_for_reaches_the_snippet():
+ execution = _started().run_code("import os\nprint(os.environ['TOKEN'])", envs={"TOKEN": "shhh"})
+
+ assert [message.line for message in execution.logs.stdout] == ["shhh"]
+
+
+def test_a_bridge_that_refuses_the_key_says_which_variable_to_check():
+ sandbox = _started()
+ sandbox.bridge.refuse = True
+
+ execution = sandbox.run_code("1 + 1")
+
+ assert not execution.execution_ok
+ assert API_KEY_ENV_VAR in (execution.execution_error or "")
+
+
+def test_only_python_is_offered():
+ with pytest.raises(ValueError, match="only supports Python"):
+ _started().run_code("console.log(1)", language="javascript")
+
+
+# --- Files and lifetime ----------------------------------------------------
+
+
+def test_files_go_through_the_bridge_not_through_the_code():
+ sandbox = _started()
+
+ sandbox._write_file("/workspace/notes.txt", b"hello")
+
+ assert sandbox._read_file("/workspace/notes.txt") == b"hello"
+ assert ("PUT", f"/v1/sandbox/{sandbox._sandbox_id}/file/workspace/notes.txt") in (
+ sandbox.bridge.requests
+ )
+
+
+def test_a_file_that_is_not_there_is_not_read_as_an_empty_one():
+ with pytest.raises(FileNotFoundError):
+ _started()._read_file("/workspace/missing.txt")
+
+
+def test_a_sandbox_says_whether_it_is_still_up():
+ sandbox = _started()
+
+ assert sandbox.is_running()
+
+ sandbox.stop()
+
+ assert not sandbox.is_running()
+
+
+def test_stopping_destroys_the_container():
+ sandbox = _started()
+ bridge, sandbox_id = sandbox.bridge, sandbox._sandbox_id
+
+ sandbox.stop()
+
+ assert bridge.deleted == [sandbox_id]
+ assert not sandbox.is_started
+
+
+def test_the_configured_environment_reaches_every_snippet():
+ """`env_vars` has nowhere to go at creation, so it rides with each call."""
+ sandbox = _started(config=SandboxConfig(env_vars={"TOKEN": "from-config"}))
+
+ execution = sandbox.run_code("import os\nprint(os.environ['TOKEN'])")
+
+ assert [message.line for message in execution.logs.stdout] == ["from-config"]
+
+
+def test_a_per_call_environment_wins_over_the_configured_one():
+ sandbox = _started(config=SandboxConfig(env_vars={"TOKEN": "from-config", "KEEP": "yes"}))
+
+ execution = sandbox.run_code(
+ "import os\nprint(os.environ['TOKEN'], os.environ['KEEP'])",
+ envs={"TOKEN": "from-call"},
+ )
+
+ assert [message.line for message in execution.logs.stdout] == ["from-call yes"]
+
+
+def test_a_network_policy_that_cannot_be_honoured_is_refused_not_ignored():
+ """Believing a sandbox is cut off while it is not is the failure here."""
+ for policy in ("none", "allowlist"):
+ sandbox = CloudflareSandbox(
+ SandboxConfig(network_policy=policy, allowed_hosts=["pypi.org"]),
+ api_url=BRIDGE_URL,
+ api_key="cf_key",
+ )
+ with pytest.raises(SandboxConfigurationError, match="cannot restrict the network"):
+ sandbox.start()
+
+
+def test_reading_a_variable_says_there_is_no_session_rather_than_no_variable():
+ """The base class reads in two executions; the first process is gone."""
+ sandbox = _started()
+
+ with pytest.raises(SandboxConfigurationError, match="no session to read"):
+ sandbox.get_variable("x")
+
+
+def test_text_files_go_through_the_bridge_so_they_need_no_session():
+ sandbox = _started()
+
+ sandbox.files.write("/workspace/notes.txt", "hello")
+
+ assert sandbox.files.read("/workspace/notes.txt") == "hello"
+ # One round trip each, straight at the file endpoints.
+ assert ("PUT", f"/v1/sandbox/{sandbox._sandbox_id}/file/workspace/notes.txt") in (
+ sandbox.bridge.requests
+ )
+
+
+def test_a_variable_cannot_be_set_for_a_later_snippet_and_says_why():
+ """Refused rather than silently lost between two processes."""
+ sandbox = _started()
+
+ with pytest.raises(SandboxConfigurationError, match="process of its own"):
+ sandbox.set_variable("payload", {"a": 1})
+
+
+# --- Configuring -----------------------------------------------------------
+
+
+def test_starting_without_a_bridge_says_how_to_get_one(monkeypatch):
+ monkeypatch.delenv(API_URL_ENV_VAR, raising=False)
+ monkeypatch.delenv(API_KEY_ENV_VAR, raising=False)
+
+ with pytest.raises(SandboxConfigurationError, match="sandbox bridge"):
+ CloudflareSandbox().start()
+
+
+def test_a_gpu_that_cannot_be_given_is_refused_rather_than_ignored():
+ """Running on a CPU while looking as though it asked for a GPU is worse."""
+ sandbox = CloudflareSandbox(SandboxConfig(gpu="H100"), api_url=BRIDGE_URL, api_key="k")
+
+ with pytest.raises(SandboxConfigurationError, match="no GPU"):
+ sandbox.start()
+
+
+def test_the_bridge_is_read_from_the_environment_when_it_is_not_given(monkeypatch):
+ monkeypatch.setenv(API_URL_ENV_VAR, f"{BRIDGE_URL}/")
+ monkeypatch.setenv(API_KEY_ENV_VAR, "from-env")
+
+ sandbox = CloudflareSandbox()
+
+ # The trailing slash is dropped: the paths this variant builds start with one.
+ assert sandbox._api_url == BRIDGE_URL
+ assert sandbox._api_key == "from-env"
+
+
+def test_the_client_sends_the_key_it_was_given_as_a_bearer_token():
+ """Every authenticated call rides on this header; a placeholder here would
+ make the bridge refuse all of them."""
+ client = CloudflareSandbox(api_url=BRIDGE_URL, api_key="cf_secret_value").build_client()
+
+ try:
+ assert client.headers["authorization"] == "Bearer cf_secret_value"
+ finally:
+ client.close()
+
+ # And no header at all when there is no key: a bridge run locally for
+ # development has none, and `Bearer ` alone would be a wrong answer.
+ anonymous = CloudflareSandbox(api_url=BRIDGE_URL, api_key="").build_client()
+ try:
+ assert "authorization" not in anonymous.headers
+ finally:
+ anonymous.close()
+
+
+def test_a_refused_call_names_the_bridge_and_the_key():
+ sandbox = _started()
+ sandbox.bridge.refuse = True
+
+ with pytest.raises(SandboxConnectionError, match=API_KEY_ENV_VAR):
+ sandbox._write_file("/workspace/x", b"1")
+
+
+# --- Registration ----------------------------------------------------------
+
+
+def test_the_variant_is_registered_everywhere_a_variant_is_named():
+ assert SandboxVariant.CLOUDFLARE.value == "cloudflare"
+ assert isinstance(Sandbox.create(variant="cloudflare"), CloudflareSandbox)
+ assert [env.name for env in Sandbox.list_environments(variant="cloudflare")] == [
+ "cloudflare-default",
+ ]
+ assert get_provider("cloudflare") is not None
+ assert "cloudflare" in manageable_variants()
+ assert get_manager("cloudflare").variant == "cloudflare"
+
+
+def test_the_provider_says_what_it_needs():
+ provider = get_provider("cloudflare")
+
+ assert provider.extra == "cloudflare"
+ assert not provider.is_available({})
+ # Both halves: a URL without a key is a bridge that will refuse it.
+ assert not provider.is_available({API_URL_ENV_VAR: BRIDGE_URL})
+ assert provider.is_available({API_URL_ENV_VAR: BRIDGE_URL, API_KEY_ENV_VAR: "k"})
+
+
+def test_the_manager_deletes_by_id_without_creating_a_container_to_do_it():
+ """A container made merely to hold a client is a container left billed."""
+ import httpx
+
+ from code_sandboxes import cloudflare_sandbox
+
+ bridge = _FakeBridge()
+ bridge.sandboxes.append("cf-sbx-existing")
+ manager = get_manager("cloudflare", api_url=BRIDGE_URL, api_key="cf_key")
+ # Every client the manager builds answers from the fake bridge.
+ original = cloudflare_sandbox.CloudflareSandbox.build_client
+ cloudflare_sandbox.CloudflareSandbox.build_client = lambda self: httpx.Client(
+ base_url=BRIDGE_URL,
+ headers={"Authorization": "Bearer cf_key"},
+ transport=httpx.MockTransport(bridge.handle),
+ )
+ try:
+ assert manager.get("cf-sbx-existing").status.value == "running"
+ assert manager.delete("cf-sbx-existing") is True
+ assert manager.get("cf-sbx-existing").status.value == "stopped"
+ finally:
+ cloudflare_sandbox.CloudflareSandbox.build_client = original
+
+ # Nothing was created along the way: only the one that was already there.
+ assert bridge.sandboxes == ["cf-sbx-existing"]
+ assert ("POST", "/v1/sandbox") not in bridge.requests
+ assert bridge.deleted == ["cf-sbx-existing"]
+
+
+def test_the_manager_says_it_cannot_list_rather_than_answering_none():
+ """ "None" and "cannot know" are different facts, and must read differently."""
+ manager = get_manager("cloudflare")
+
+ assert "list" not in manager.capabilities
+ with pytest.raises(Exception, match="list"):
+ manager.list()
diff --git a/tests/test_coreweave.py b/tests/test_coreweave.py
new file mode 100644
index 0000000..0859927
--- /dev/null
+++ b/tests/test_coreweave.py
@@ -0,0 +1,467 @@
+# Copyright (c) 2025-2026 Datalayer, Inc.
+#
+# BSD 3-Clause License
+
+"""The CoreWeave sandbox, against a container that really runs the driver.
+
+The fake below does not answer with canned replies: it runs the driver source
+the variant sends — as a real subprocess, in
+:func:`test_the_driver_source_is_a_program_that_answers_what_it_promises`, and
+in-process everywhere else — because the driver IS the protocol here. A change
+to it that broke the framing would otherwise pass every test in this file.
+"""
+
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+from types import SimpleNamespace
+
+import pytest
+
+from code_sandboxes.base import Sandbox
+from code_sandboxes.coreweave_sandbox import (
+ _DRIVER_SOURCE,
+ DEFAULT_CONTAINER_IMAGE,
+ CoreWeaveSandbox,
+ _with_envs,
+)
+from code_sandboxes.exceptions import SandboxConfigurationError
+from code_sandboxes.manage import get_manager, manageable_variants
+from code_sandboxes.models import SandboxConfig, SandboxVariant
+from code_sandboxes.providers import get_provider
+
+
+class _FakeStdin:
+ def __init__(self, on_line, on_close=None) -> None:
+ self._on_line = on_line
+ self._on_close = on_close
+ self.closed = False
+
+ def writeline(self, text: str):
+ self._on_line(text)
+ return SimpleNamespace(result=lambda timeout=None: None)
+
+ def close(self):
+ self.closed = True
+ if self._on_close is not None:
+ self._on_close()
+ return SimpleNamespace(result=lambda timeout=None: None)
+
+
+def _serve(code: str, namespace: dict, seq=None) -> dict:
+ """Run one request the way the driver does, and answer as it answers."""
+ import ast
+ import contextlib
+ import io
+ import traceback
+
+ out, err = io.StringIO(), io.StringIO()
+ reply: dict = {"status": "ok"}
+ if seq is not None:
+ reply["seq"] = seq
+ try:
+ tree = ast.parse(code, mode="exec")
+ trailing = None
+ if tree.body and isinstance(tree.body[-1], ast.Expr):
+ trailing = ast.Expression(tree.body.pop(-1).value)
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
+ if tree.body:
+ exec(compile(tree, "", "exec"), namespace) # noqa: S102
+ if trailing is not None:
+ value = eval(compile(trailing, "", "eval"), namespace) # noqa: S307
+ if value is not None:
+ reply["result"] = repr(value)
+ except BaseException as error: # the point of the fake: run it for real
+ reply["status"] = "error"
+ reply["error"] = {
+ "name": type(error).__name__,
+ "value": str(error),
+ "traceback": traceback.format_exc(),
+ }
+ reply["stdout"] = out.getvalue()
+ reply["stderr"] = err.getvalue()
+ return reply
+
+
+class _FakeDriver:
+ """The session process: one namespace, one reply per request.
+
+ Its stdout BLOCKS the way a real stream does — the variant reads it from a
+ thread, and a stream that ended as soon as it was empty would look like a
+ driver that had died before the first request was even written.
+ """
+
+ def __init__(self) -> None:
+ import queue
+
+ self.namespace: dict = {"__name__": "__main__"}
+ self.requests: list[dict] = []
+ self._replies: queue.Queue = queue.Queue()
+ self.stdin = _FakeStdin(self.serve, on_close=lambda: self._replies.put(None))
+ self.stdout = self._lines()
+
+ def _lines(self):
+ while True:
+ line = self._replies.get()
+ if line is None:
+ return
+ yield line
+
+ def serve(self, line: str) -> None:
+ request = json.loads(line)
+ self.requests.append(request)
+ reply = _serve(request.get("code", ""), self.namespace, seq=request.get("seq"))
+ self._replies.put(json.dumps(reply))
+
+
+class _FakeProcess:
+ """One process of its own: the stateless fallback, and what it answers."""
+
+ def __init__(self) -> None:
+ self._reply: dict | None = None
+ self.stdin = _FakeStdin(self._run)
+
+ def _run(self, line: str) -> None:
+ request = json.loads(line)
+ self._reply = _serve(request.get("code", ""), {"__name__": "__main__"})
+
+ def result(self, timeout=None):
+ printed = json.dumps(self._reply or {}) + "\n"
+ return SimpleNamespace(stdout_bytes=printed.encode(), stderr_bytes=b"")
+
+
+class _FakeCoreWeaveSandbox:
+ def __init__(self, **params) -> None:
+ self.params = params
+ self.sandbox_id = "cw-sbx-1"
+ self.runner_id = "runner-1"
+ self.files: dict[str, bytes] = {}
+ self.execs: list[list[str]] = []
+ self.driver: _FakeDriver | None = None
+ self.stopped = False
+ self.waited = False
+
+ def wait(self, timeout=None):
+ self.waited = True
+ return self
+
+ def exec(self, command, *, cwd=None, check=False, timeout_seconds=None, stdin=False):
+ self.execs.append(list(command))
+ # Which program was started decides what it is: the session driver, or
+ # one process for one snippet.
+ if _DRIVER_SOURCE in command:
+ self.driver = _FakeDriver()
+ return self.driver
+ return _FakeProcess()
+
+ def stop(self, **_):
+ self.stopped = True
+ return SimpleNamespace(result=lambda timeout=None: None)
+
+ def write_file(self, filepath, contents, **_):
+ self.files[filepath] = contents
+ return SimpleNamespace(result=lambda timeout=None: None)
+
+ def read_file(self, filepath, **_):
+ content = self.files.get(filepath)
+ return SimpleNamespace(result=lambda timeout=None: content)
+
+
+def _started(config: SandboxConfig | None = None, *, stateful: bool = True, **kwargs):
+ """A sandbox that believes it started, holding the fake above."""
+ sandbox = CoreWeaveSandbox(config=config, stateful=stateful, **kwargs)
+ sandbox._sandbox = _FakeCoreWeaveSandbox()
+ sandbox._started = True
+ if stateful:
+ sandbox._start_driver()
+ sandbox._default_context = sandbox.create_context("default")
+ return sandbox
+
+
+# --- The driver, as a real program -----------------------------------------
+
+
+def test_the_driver_source_is_a_program_that_answers_what_it_promises():
+ """Run it for real: the driver is the protocol, and must hold its shape."""
+ process = subprocess.run( # noqa: S603
+ [sys.executable, "-u", "-c", _DRIVER_SOURCE],
+ input='{"seq": 1, "code": "x = 21"}\n{"seq": 2, "code": "print(x); x * 2"}\n',
+ capture_output=True,
+ text=True,
+ timeout=60,
+ check=False,
+ )
+
+ replies = [json.loads(line) for line in process.stdout.splitlines() if line.strip()]
+ assert [reply["seq"] for reply in replies] == [1, 2]
+ # The namespace is shared, the trailing expression is reported, and what
+ # was printed stays separate from it.
+ assert replies[1]["stdout"] == "21\n"
+ assert replies[1]["result"] == "42"
+ assert replies[1]["status"] == "ok"
+
+
+def test_the_driver_reports_a_raising_snippet_without_dying():
+ process = subprocess.run( # noqa: S603
+ [sys.executable, "-u", "-c", _DRIVER_SOURCE],
+ input='{"seq": 1, "code": "1 / 0"}\n{"seq": 2, "code": "40 + 2"}\n',
+ capture_output=True,
+ text=True,
+ timeout=60,
+ check=False,
+ )
+
+ replies = [json.loads(line) for line in process.stdout.splitlines() if line.strip()]
+ assert replies[0]["status"] == "error"
+ assert replies[0]["error"]["name"] == "ZeroDivisionError"
+ # Still serving afterwards: one bad snippet does not end the session.
+ assert replies[1]["result"] == "42"
+
+
+# --- Executing --------------------------------------------------------------
+
+
+def test_a_snippet_keeps_what_the_one_before_it_defined():
+ sandbox = _started()
+
+ sandbox.run_code("x = 41")
+ execution = sandbox.run_code("print(x + 1)")
+
+ assert [message.line for message in execution.logs.stdout] == ["42"]
+ assert execution.execution_ok
+ assert execution.code_error is None
+
+
+def test_the_value_of_a_trailing_expression_is_answered_with():
+ sandbox = _started()
+
+ execution = sandbox.run_code("1 + 1")
+
+ assert [result.data["text/plain"] for result in execution.results] == ["2"]
+ assert execution.text == "2"
+
+
+def test_output_reaches_a_caller_that_streams():
+ sandbox = _started()
+ streamed: list[str] = []
+ errors: list[str] = []
+
+ sandbox.run_code(
+ "import sys\nprint('one')\nprint('bad', file=sys.stderr)",
+ on_stdout=lambda message: streamed.append(message.line),
+ on_stderr=lambda message: errors.append(message.line),
+ )
+
+ assert streamed == ["one"]
+ assert errors == ["bad"]
+
+
+def test_a_raising_snippet_is_reported_as_the_codes_error_not_the_sandboxs():
+ sandbox = _started()
+
+ execution = sandbox.run_code("1 / 0")
+
+ assert execution.execution_ok
+ assert execution.code_error is not None
+ assert execution.code_error.name == "ZeroDivisionError"
+
+
+def test_a_session_that_never_answers_times_out_rather_than_hanging():
+ sandbox = _started()
+ # A driver that takes the request and says nothing.
+ sandbox._driver.stdin.writeline = lambda text: None
+
+ execution = sandbox.run_code("1 + 1", timeout=0.05)
+
+ assert not execution.execution_ok
+ assert "within" in (execution.execution_error or "")
+
+
+def test_a_session_that_went_away_falls_back_to_a_process_per_snippet():
+ """Working, merely stateless: the sandbox must not stop being usable."""
+ sandbox = _started()
+
+ def gone(text):
+ raise BrokenPipeError("the driver is gone")
+
+ sandbox._driver.stdin.writeline = gone
+ execution = sandbox.run_code("print('still here')")
+
+ assert sandbox._driver is None
+ assert execution.execution_ok
+ assert [message.line for message in execution.logs.stdout] == ["still here"]
+
+
+def test_without_a_driver_each_snippet_runs_in_its_own_process():
+ sandbox = _started(stateful=False)
+
+ assert sandbox._driver is None
+ execution = sandbox.run_code("21 * 2")
+
+ assert execution.text == "42"
+
+
+def test_only_python_is_offered():
+ with pytest.raises(ValueError, match="only supports Python"):
+ _started().run_code("console.log(1)", language="javascript")
+
+
+def test_an_environment_asked_for_reaches_the_snippet():
+ """The session process is started once, so envs are set in the namespace."""
+ sandbox = _started()
+
+ execution = sandbox.run_code("import os\nprint(os.environ['TOKEN'])", envs={"TOKEN": "shhh"})
+
+ assert [message.line for message in execution.logs.stdout] == ["shhh"]
+ assert "TOKEN" in _with_envs("pass", {"TOKEN": "shhh"})
+
+
+# --- Variables and files ----------------------------------------------------
+
+
+def test_a_variable_crosses_as_json_in_both_directions():
+ sandbox = _started()
+
+ sandbox.set_variable("payload", {"a": [1, 2]})
+
+ assert sandbox.get_variable("payload") == {"a": [1, 2]}
+
+
+def test_a_value_that_cannot_be_encoded_is_refused_where_it_is_set():
+ with pytest.raises(SandboxConfigurationError, match="cannot be encoded"):
+ _started().set_variable("payload", object())
+
+
+def test_without_a_session_a_variable_is_refused_rather_than_lost():
+ """A set that reports success and then vanishes is the worse answer."""
+ sandbox = _started(stateful=False)
+
+ with pytest.raises(SandboxConfigurationError, match="no session process"):
+ sandbox.set_variable("payload", {"a": 1})
+ with pytest.raises(SandboxConfigurationError, match="no session process"):
+ sandbox.get_variable("payload")
+
+
+def test_a_timed_out_snippet_has_its_session_stopped():
+ """Giving up on the answer is not giving up on the work: it must be cut."""
+ sandbox = _started()
+ driver = sandbox._driver
+ cancelled: list[bool] = []
+ driver.cancel = lambda: cancelled.append(True)
+ # A session that takes the request and never answers.
+ driver.stdin.writeline = lambda text: None
+
+ execution = sandbox.run_code("1 + 1", timeout=0.05)
+
+ assert not execution.execution_ok
+ assert "stopped" in (execution.execution_error or "")
+ assert cancelled == [True]
+ # Dropped, so the next execution starts a session of its own.
+ assert sandbox._driver is None
+
+
+def test_files_go_through_the_filesystem_api_not_through_the_code():
+ sandbox = _started()
+
+ sandbox._write_file("/workspace/notes.txt", b"hello")
+
+ assert sandbox._sandbox.files["/workspace/notes.txt"] == b"hello"
+ assert sandbox._read_file("/workspace/notes.txt") == b"hello"
+
+
+def test_a_file_that_is_not_there_is_not_read_as_an_empty_one():
+ with pytest.raises(FileNotFoundError):
+ _started()._read_file("/workspace/missing.txt")
+
+
+# --- Creating ---------------------------------------------------------------
+
+
+def test_the_configuration_becomes_what_coreweave_is_asked_for():
+ cwsandbox = pytest.importorskip("cwsandbox")
+ sandbox = CoreWeaveSandbox(
+ SandboxConfig(
+ name="mine",
+ max_lifetime=600.0,
+ env_vars={"TOKEN": "t"},
+ cpu_limit=2.0,
+ memory_limit=4 * 1024**3,
+ gpu="H100",
+ )
+ )
+
+ params = sandbox._run_params(cwsandbox)
+
+ assert params["container_image"] == DEFAULT_CONTAINER_IMAGE
+ assert params["max_lifetime_seconds"] == 600.0
+ assert params["environment_variables"] == {"TOKEN": "t"}
+ assert "name=mine" in params["tags"]
+ assert "created-by=code-sandboxes" in params["tags"]
+ assert params["resources"].requests == {"cpu": "2", "memory": "4Gi"}
+ assert params["resources"].gpu == {"count": 1, "type": "H100"}
+
+
+def test_a_sandbox_cut_off_from_the_network_says_so():
+ cwsandbox = pytest.importorskip("cwsandbox")
+
+ options = CoreWeaveSandbox(SandboxConfig(network_policy="none"))._network_params(cwsandbox)
+
+ assert options.deny_egress is True
+
+
+def test_an_allowlist_of_nothing_is_refused_rather_than_silently_meaning_none():
+ cwsandbox = pytest.importorskip("cwsandbox")
+ sandbox = CoreWeaveSandbox(SandboxConfig(network_policy="allowlist"))
+
+ with pytest.raises(SandboxConfigurationError, match="needs allowed_hosts"):
+ sandbox._network_params(cwsandbox)
+
+
+def test_the_defaults_ask_for_no_particular_machine():
+ cwsandbox = pytest.importorskip("cwsandbox")
+
+ assert CoreWeaveSandbox()._resources(cwsandbox) is None
+
+
+def test_stopping_stops_the_sandbox_and_closes_the_session():
+ sandbox = _started()
+ fake = sandbox._sandbox
+ driver = sandbox._driver
+
+ sandbox.stop()
+
+ assert driver.stdin.closed
+ assert fake.stopped
+ assert not sandbox.is_started
+
+
+# --- Registration -----------------------------------------------------------
+
+
+def test_the_variant_is_registered_everywhere_a_variant_is_named():
+ assert SandboxVariant.COREWEAVE.value == "coreweave"
+ assert isinstance(Sandbox.create(variant="coreweave"), CoreWeaveSandbox)
+ assert [env.name for env in Sandbox.list_environments(variant="coreweave")] == [
+ "coreweave-default",
+ "coreweave-gpu",
+ ]
+ assert get_provider("coreweave") is not None
+ assert "coreweave" in manageable_variants()
+ assert get_manager("coreweave").variant == "coreweave"
+
+
+def test_the_provider_says_what_it_needs():
+ provider = get_provider("coreweave")
+
+ assert provider.extra == "coreweave"
+ assert not provider.is_available({})
+ assert provider.is_available({"CWSANDBOX_API_KEY": "cw_key"})
+
+
+def test_the_sdk_is_only_needed_when_the_sandbox_starts():
+ sandbox = Sandbox.create(variant="coreweave")
+
+ assert isinstance(sandbox, CoreWeaveSandbox)
+ assert not sandbox.is_started
diff --git a/tests/test_e2b.py b/tests/test_e2b.py
new file mode 100644
index 0000000..7b388a9
--- /dev/null
+++ b/tests/test_e2b.py
@@ -0,0 +1,395 @@
+# Copyright (c) 2025-2026 Datalayer, Inc.
+#
+# BSD 3-Clause License
+
+"""The E2B sandbox, against an interpreter that really runs the code.
+
+The fake below is not a mock answering with canned strings: it executes what
+the variant sends it and answers in E2B's own shapes. What is worth testing
+here is the translation — E2B names each rich format with an attribute where
+this package keys them by MIME type, and stamps its output in milliseconds
+where this package counts in seconds — so the fake has to speak E2B, and the
+assertions read this package's models.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import io
+import traceback
+from types import SimpleNamespace
+
+import pytest
+
+from code_sandboxes.base import Sandbox
+from code_sandboxes.e2b_sandbox import E2BSandbox, _result_data, _timestamp
+from code_sandboxes.exceptions import SandboxConfigurationError
+from code_sandboxes.manage import get_manager, manageable_variants
+from code_sandboxes.models import SandboxConfig, SandboxVariant
+from code_sandboxes.providers import get_provider
+
+
+class _FakeResult:
+ """One of E2B's results: named formats, and `formats()` over them."""
+
+ def __init__(self, **data) -> None:
+ self.is_main_result = bool(data.pop("is_main_result", False))
+ self.extra = data.pop("extra", {}) or {}
+ for name, value in data.items():
+ setattr(self, name, value)
+ self._names = list(data) + list(self.extra)
+
+ def formats(self):
+ return list(self._names)
+
+
+class _FakeFiles:
+ def __init__(self) -> None:
+ self.written: dict[str, bytes] = {}
+
+ def write(self, path, data, **_):
+ self.written[path] = data if isinstance(data, bytes) else str(data).encode()
+
+ def read(self, path, format="text", **_): # noqa: A002 - E2B names it so
+ if path not in self.written:
+ raise FileNotFoundError(path)
+ content = self.written[path]
+ return content if format == "bytes" else content.decode()
+
+
+class _FakeE2BSandbox:
+ """E2B's code interpreter, executed here instead of over there."""
+
+ def __init__(self, **params) -> None:
+ self.params = params
+ self.sandbox_id = "e2b-sbx-1"
+ self.files = _FakeFiles()
+ self.namespaces: dict[str | None, dict] = {None: {"__name__": "__main__"}}
+ self.contexts: list[SimpleNamespace] = []
+ self.calls: list[dict] = []
+ self.killed = False
+ self.timeouts: list[int] = []
+
+ def create_code_context(self, cwd=None, language=None, **_):
+ context = SimpleNamespace(
+ context_id=f"ctx-{len(self.contexts)}", cwd=cwd, language=language
+ )
+ self.contexts.append(context)
+ self.namespaces[context.context_id] = {"__name__": "__main__"}
+ return context
+
+ def run_code(
+ self,
+ code,
+ language=None,
+ context=None,
+ on_stdout=None,
+ on_stderr=None,
+ on_result=None,
+ envs=None,
+ timeout=None,
+ **_,
+ ):
+ self.calls.append({"code": code, "context": context, "envs": envs, "timeout": timeout})
+ key = context.context_id if context is not None else None
+ namespace = self.namespaces.setdefault(key, {"__name__": "__main__"})
+ out, err = io.StringIO(), io.StringIO()
+ error = None
+ try:
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
+ exec(compile(code, "", "exec"), namespace) # noqa: S102
+ except BaseException as raised: # the point of the fake: run it for real
+ error = SimpleNamespace(
+ name=type(raised).__name__,
+ value=str(raised),
+ traceback=traceback.format_exc(),
+ )
+
+ stdout_lines = out.getvalue().splitlines()
+ stderr_lines = err.getvalue().splitlines()
+ for line in stdout_lines:
+ if on_stdout:
+ # E2B stamps in MILLISECONDS since the epoch.
+ on_stdout(SimpleNamespace(line=line, timestamp=1_700_000_000_000, error=False))
+ for line in stderr_lines:
+ if on_stderr:
+ on_stderr(SimpleNamespace(line=line, timestamp=1_700_000_000_000, error=True))
+
+ results = list(namespace.pop("_test_results", []))
+ for result in results:
+ if on_result:
+ on_result(result)
+ return SimpleNamespace(
+ results=results,
+ logs=SimpleNamespace(stdout=stdout_lines, stderr=stderr_lines),
+ error=error,
+ execution_count=len(self.calls),
+ )
+
+ def kill(self, **_):
+ self.killed = True
+ return True
+
+ def set_timeout(self, timeout, **_):
+ self.timeouts.append(timeout)
+
+ def get_host(self, port):
+ return f"{port}-{self.sandbox_id}.e2b.app"
+
+
+def _started(config: SandboxConfig | None = None, **kwargs) -> E2BSandbox:
+ """A sandbox that believes it started, holding the fake above."""
+ sandbox = E2BSandbox(config=config, **kwargs)
+ sandbox._sandbox = _FakeE2BSandbox()
+ sandbox._started = True
+ sandbox._default_context = sandbox.create_context("default")
+ return sandbox
+
+
+# --- Executing ------------------------------------------------------------
+
+
+def test_a_snippet_keeps_what_the_one_before_it_defined():
+ sandbox = _started()
+
+ sandbox.run_code("x = 41")
+ execution = sandbox.run_code("print(x + 1)")
+
+ assert [message.line for message in execution.logs.stdout] == ["42"]
+ assert execution.code_error is None
+ assert execution.execution_ok
+
+
+def test_output_arrives_as_it_is_written_and_is_kept():
+ sandbox = _started()
+ streamed: list[str] = []
+
+ execution = sandbox.run_code(
+ "import sys\nprint('one')\nprint('two')\nprint('bad', file=sys.stderr)",
+ on_stdout=lambda message: streamed.append(message.line),
+ )
+
+ assert streamed == ["one", "two"]
+ assert [message.line for message in execution.logs.stdout] == ["one", "two"]
+ assert [message.line for message in execution.logs.stderr] == ["bad"]
+
+
+def test_output_is_stamped_in_the_seconds_this_package_counts_in():
+ """E2B stamps in milliseconds; a line an epoch in the future is the bug."""
+ sandbox = _started()
+
+ execution = sandbox.run_code("print('now')")
+
+ assert execution.logs.stdout[0].timestamp == pytest.approx(1_700_000_000.0)
+
+
+def test_a_raising_snippet_is_reported_as_the_codes_error_not_the_sandboxs():
+ sandbox = _started()
+ errors: list = []
+
+ execution = sandbox.run_code("1 / 0", on_error=errors.append)
+
+ # The sandbox did its job; the code is what failed.
+ assert execution.execution_ok
+ assert execution.code_error is not None
+ assert execution.code_error.name == "ZeroDivisionError"
+ assert errors and errors[0].name == "ZeroDivisionError"
+
+
+def test_a_sandbox_that_went_away_is_reported_rather_than_raised():
+ sandbox = _started()
+
+ def gone(*_, **__):
+ raise ConnectionError("sandbox not found")
+
+ sandbox._sandbox.run_code = gone
+ execution = sandbox.run_code("1 + 1")
+
+ assert not execution.execution_ok
+ assert "sandbox not found" in (execution.execution_error or "")
+
+
+def test_rich_formats_are_keyed_by_mime_type():
+ result = _FakeResult(
+ text="",
+ png="aGk=",
+ html="hi",
+ extra={"application/vnd.plotly.v1+json": {"data": []}},
+ is_main_result=True,
+ )
+
+ data = _result_data(result)
+
+ assert data["text/plain"] == ""
+ assert data["image/png"] == "aGk="
+ assert data["text/html"] == "hi"
+ # A format E2B has no attribute for keeps the name it arrived under.
+ assert data["application/vnd.plotly.v1+json"] == {"data": []}
+
+
+def test_a_result_the_sandbox_only_reported_at_the_end_is_not_lost():
+ """E2B skips `on_result` for a replayed execution; the answer still has it."""
+ sandbox = _started()
+ sandbox._sandbox.run_code = lambda *args, **kwargs: SimpleNamespace(
+ results=[_FakeResult(text="42", is_main_result=True)],
+ logs=SimpleNamespace(stdout=[], stderr=[]),
+ error=None,
+ execution_count=1,
+ )
+
+ execution = sandbox.run_code("40 + 2")
+
+ assert [result.data["text/plain"] for result in execution.results] == ["42"]
+
+
+def test_a_second_context_is_a_namespace_of_its_own():
+ sandbox = _started()
+ other = sandbox.create_context("other")
+
+ sandbox.run_code("x = 'default'")
+ execution = sandbox.run_code("print('x' in dir())", context=other)
+
+ assert [message.line for message in execution.logs.stdout] == ["False"]
+
+
+def test_only_python_is_offered():
+ with pytest.raises(ValueError, match="only supports Python"):
+ _started().run_code("console.log(1)", language="javascript")
+
+
+# --- Variables and files ---------------------------------------------------
+
+
+def test_a_variable_crosses_as_json_in_both_directions():
+ sandbox = _started()
+
+ sandbox.set_variable("payload", {"a": [1, 2]})
+
+ assert sandbox.get_variable("payload") == {"a": [1, 2]}
+
+
+def test_a_value_that_cannot_be_encoded_is_refused_where_it_is_set():
+ sandbox = _started()
+
+ with pytest.raises(SandboxConfigurationError, match="cannot be encoded"):
+ sandbox.set_variable("payload", object())
+
+
+def test_files_go_through_the_filesystem_api_not_through_the_code():
+ sandbox = _started()
+
+ sandbox._write_file("/home/user/notes.txt", b"hello")
+
+ assert sandbox._sandbox.files.written["/home/user/notes.txt"] == b"hello"
+ assert sandbox._read_file("/home/user/notes.txt") == b"hello"
+
+
+# --- Creating --------------------------------------------------------------
+
+
+def test_the_configuration_becomes_what_e2b_is_asked_for():
+ sandbox = E2BSandbox(
+ SandboxConfig(name="mine", max_lifetime=600.0, env_vars={"TOKEN": "t"}),
+ api_key="e2b_key",
+ )
+
+ params = sandbox._create_params()
+
+ # The interpreter's template, not E2B's `base`: `base` carries no kernel
+ # for the interpreter to talk to.
+ assert params["template"] == "code-interpreter-v1"
+ assert params["timeout"] == 600
+ assert params["envs"] == {"TOKEN": "t"}
+ assert params["api_key"] == "e2b_key"
+ assert params["metadata"]["name"] == "mine"
+ assert params["metadata"]["created-by"] == "code-sandboxes"
+
+
+def test_a_setting_that_was_not_given_is_left_for_the_environment():
+ """Passing an explicit None is not the same as passing nothing."""
+ params = E2BSandbox()._create_params()
+
+ assert "api_key" not in params
+ assert "domain" not in params
+
+
+def test_a_gpu_that_cannot_be_given_is_refused_rather_than_ignored():
+ """Running on a CPU while looking as though it asked for a GPU is worse."""
+ sandbox = E2BSandbox(SandboxConfig(gpu="H100"))
+
+ with pytest.raises(SandboxConfigurationError, match="no GPU"):
+ sandbox._create_params()
+
+
+def test_a_sandbox_cut_off_from_the_network_says_so():
+ params = E2BSandbox(SandboxConfig(network_policy="none"))._create_params()
+
+ assert params["allow_internet_access"] is False
+
+
+def test_an_allowlist_is_refused_rather_than_widened_to_the_whole_internet():
+ sandbox = E2BSandbox(SandboxConfig(network_policy="allowlist", allowed_hosts=["pypi.org"]))
+
+ with pytest.raises(SandboxConfigurationError, match="no host allowlist"):
+ sandbox._create_params()
+
+
+def test_stopping_kills_the_sandbox_and_says_it_stopped():
+ sandbox = _started()
+ fake = sandbox._sandbox
+ sandbox._info = None
+
+ sandbox.stop()
+
+ assert fake.killed
+ assert not sandbox.is_started
+
+
+def test_the_life_of_a_sandbox_can_be_extended_while_it_runs():
+ sandbox = _started()
+
+ sandbox.set_timeout(120.4)
+
+ assert sandbox._sandbox.timeouts == [120]
+
+
+def test_a_port_inside_has_a_host_outside():
+ assert _started().get_host(8888).startswith("8888-")
+
+
+def test_a_timestamp_that_is_not_one_falls_back_to_now():
+ assert _timestamp(None) > 0
+ assert _timestamp("nonsense") > 0
+ # Seconds are left alone; milliseconds are divided.
+ assert _timestamp(1_700_000_000) == pytest.approx(1_700_000_000.0)
+ assert _timestamp(1_700_000_000_000) == pytest.approx(1_700_000_000.0)
+
+
+# --- Registration ----------------------------------------------------------
+
+
+def test_the_variant_is_registered_everywhere_a_variant_is_named():
+ assert SandboxVariant.E2B.value == "e2b"
+ assert isinstance(Sandbox.create(variant="e2b"), E2BSandbox)
+ assert [env.name for env in Sandbox.list_environments(variant="e2b")] == [
+ "e2b-code-interpreter",
+ ]
+ assert get_provider("e2b") is not None
+ assert "e2b" in manageable_variants()
+ assert get_manager("e2b").variant == "e2b"
+
+
+def test_the_provider_says_what_it_needs():
+ provider = get_provider("e2b")
+
+ assert provider.extra == "e2b"
+ assert not provider.is_available({})
+ assert provider.is_available({"E2B_API_KEY": "e2b_key"})
+
+
+def test_the_sdk_is_only_needed_when_the_sandbox_starts():
+ """Creating one must not import e2b: a listing names every variant."""
+ sandbox = Sandbox.create(variant="e2b")
+
+ assert isinstance(sandbox, E2BSandbox)
+ assert not sandbox.is_started
diff --git a/tests/test_manage.py b/tests/test_manage.py
index 61b8bd5..8febcfe 100644
--- a/tests/test_manage.py
+++ b/tests/test_manage.py
@@ -32,9 +32,12 @@ def test_every_variant_of_the_enum_has_a_manager_in_any_spelling():
def test_every_variant_has_a_manager():
assert manageable_variants() == [
+ "cloudflare",
+ "coreweave",
"datalayer",
"daytona",
"docker",
+ "e2b",
"eval",
"google-colab",
"jupyter-server",