Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-cubesandbox for
openSUSE:Factory checked in at 2026-10-15 12:25:34
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-cubesandbox (Old)
and /work/SRC/openSUSE:Factory/.python-cubesandbox.new.3698736 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-cubesandbox"
Thu Oct 15 12:25:34 2026 rev:5 rq:1384881 version:0.7.3
Changes:
--------
--- /work/SRC/openSUSE:Factory/python-cubesandbox/python-cubesandbox.changes
2026-08-29 17:44:27.057478552 +0200
+++
/work/SRC/openSUSE:Factory/.python-cubesandbox.new.3698736/python-cubesandbox.changes
2026-10-15 12:25:37.850853352 +0200
@@ -1,0 +2,11 @@
+Thu Oct 15 04:48:02 UTC 2026 - Martin Pluskal <[email protected]>
+
+- Update to version 0.7.3:
+ * Add E2B-compatible MCP gateway support: sandboxes can now be
+ created with an mcp parameter returning an MCP URL and token
+ * Stability and bug fixes: intermittent sandbox network failures,
+ paused sandboxes losing metadata, clone and resource-reclaim
+ races, and template list hangs on large clusters
+- No new runtime dependencies; requires-python stays >= 3.9
+
+-------------------------------------------------------------------
Old:
----
cubesandbox-0.7.0.tar.gz
New:
----
cubesandbox-0.7.3.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-cubesandbox.spec ++++++
--- /var/tmp/diff_new_pack.nvCmBL/_old 2026-10-15 12:25:38.621885616 +0200
+++ /var/tmp/diff_new_pack.nvCmBL/_new 2026-10-15 12:25:38.622885657 +0200
@@ -17,7 +17,7 @@
Name: python-cubesandbox
-Version: 0.7.0
+Version: 0.7.3
Release: 0
Summary: Python SDK for CubeSandbox
License: Apache-2.0
++++++ cubesandbox-0.7.0.tar.gz -> cubesandbox-0.7.3.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cubesandbox-0.7.0/PKG-INFO
new/cubesandbox-0.7.3/PKG-INFO
--- old/cubesandbox-0.7.0/PKG-INFO 2026-08-28 09:58:41.174158800 +0200
+++ new/cubesandbox-0.7.3/PKG-INFO 2026-10-12 10:40:38.878262000 +0200
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: cubesandbox
-Version: 0.7.0
+Version: 0.7.3
Summary: Python SDK for CubeSandbox
License: Apache-2.0
Project-URL: Homepage, https://github.com/TencentCloud/CubeSandbox
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cubesandbox-0.7.0/README.md
new/cubesandbox-0.7.3/README.md
--- old/cubesandbox-0.7.0/README.md 2026-08-28 09:58:28.025799800 +0200
+++ new/cubesandbox-0.7.3/README.md 2026-10-12 10:40:25.193279300 +0200
@@ -6,7 +6,7 @@
<a href="https://github.com/TencentCloud/CubeSandbox"><img
src="https://img.shields.io/badge/CubeSandbox-GitHub-blue" alt="CubeSandbox"
/></a>
<a href="../../LICENSE"><img
src="https://img.shields.io/badge/License-Apache_2.0-green" alt="Apache 2.0"
/></a>
<img src="https://img.shields.io/badge/Python-3.9%2B-blue" alt="Python 3.9+"
/>
- <img src="https://img.shields.io/badge/version-0.7.0-orange" alt="v0.7.0" />
+ <img src="https://img.shields.io/badge/version-0.7.3-orange" alt="v0.7.3" />
</p>
---
@@ -106,6 +106,16 @@
sb2 = Sandbox.connect(sb.sandbox_id)
```
+### MCP gateway (preview)
+
+Pass the E2B-compatible `mcp` option to start an MCP gateway inside the
sandbox. It needs a template that ships `mcp-gateway`; when `template` is
omitted, the alias `mcp-gateway` is tried first (override with
`CUBE_MCP_TEMPLATE_ID`), then `CUBE_TEMPLATE_ID` if that template does not
exist. `TemplateNotFoundError` is raised when neither exists. See [MCP
Gateway](../../docs/guide/mcp-gateway.md).
+
+```python
+sb = Sandbox.create(mcp={"duckduckgo": {}, "arxiv": {"storagePath":
"/tmp/papers"}})
+url = sb.get_mcp_url() # streamable HTTP endpoint
+token = sb.get_mcp_token() # send as "Authorization: Bearer <token>"
+```
+
### Compute node placement
Restrict scheduling to one or more compute node IDs or host IPs. A single
@@ -310,6 +320,7 @@
| `CUBE_API_KEY` | | — | API key for auth-enabled deployments |
| `CUBE_PROXY_PORT_HTTP` | | `80` | CubeProxy HTTP port |
| `CUBE_SANDBOX_DOMAIN` | | `cube.app` | Sandbox domain suffix |
+| `CUBE_MCP_TEMPLATE_ID` | | `mcp-gateway` | Template tried first when `mcp`
is set and no template is given; falls back to `CUBE_TEMPLATE_ID` if it does
not exist |
You can also pass a `Config` object directly:
@@ -333,7 +344,7 @@
| Method | Description |
|---|---|
| `Sandbox.create(template, *, timeout, env_vars, envs, metadata,
distribution_scope, volume_mounts, config)` | `POST /sandboxes` — create a new
sandbox (optionally restricted to specified compute nodes or mounting volumes);
`envs` is the E2B-compatible alias for `env_vars` |
-| `Sandbox.connect(sandbox_id, *, config)` | `POST /sandboxes/:id/connect` —
connect (auto-resumes if paused) |
+| `Sandbox.connect(sandbox_id, timeout=None, *, config)` | `POST
/sandboxes/:id/connect` — connect (auto-resumes if paused) and optionally reset
the idle timeout |
| `Sandbox.list(config)` | `GET /sandboxes` — list running sandboxes (v1) |
| `Sandbox.list_v2(config)` | `GET /v2/sandboxes` — list sandboxes (v2) |
| `Sandbox.health(config)` | `GET /health` — service health check |
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cubesandbox-0.7.0/cubesandbox/__init__.py
new/cubesandbox-0.7.3/cubesandbox/__init__.py
--- old/cubesandbox-0.7.0/cubesandbox/__init__.py 2026-08-28
09:58:28.025799800 +0200
+++ new/cubesandbox-0.7.3/cubesandbox/__init__.py 2026-10-12
10:40:25.193279300 +0200
@@ -47,4 +47,4 @@
"Inject",
]
-__version__ = "0.7.0"
+__version__ = "0.7.3"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cubesandbox-0.7.0/cubesandbox/_commands.py
new/cubesandbox-0.7.3/cubesandbox/_commands.py
--- old/cubesandbox-0.7.0/cubesandbox/_commands.py 2026-08-28
09:58:28.026039600 +0200
+++ new/cubesandbox-0.7.3/cubesandbox/_commands.py 2026-10-12
10:40:25.193764400 +0200
@@ -10,8 +10,6 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING
-from ._config import _auth_headers
-
if TYPE_CHECKING:
from .sandbox import Sandbox
@@ -49,9 +47,8 @@
) -> CommandResult:
"""Run a shell command inside the sandbox through envd's process API.
- This mirrors E2B's SDK path by using the generated envd ProcessClient
- when the E2B protocol package is available. The hand-written Connect
- fallback is kept for source-tree usage without the optional dependency.
+ The SDK always uses envd's Connect RPC endpoint directly so it does
+ not rely on optional external protobuf/runtime packages.
Args:
env: Alias for envs, matching the E2B SDK command API.
@@ -61,64 +58,15 @@
"""
process_envs = envs if envs is not None else (env or {})
effective_user = user or DEFAULT_ENVD_USER
- try:
- return self._run_with_e2b_connect(
- cmd,
- timeout=timeout,
- cwd=cwd,
- envs=process_envs,
- user=effective_user,
- )
- except ImportError:
- return self._run_with_connect_fallback(
- cmd,
- timeout=timeout,
- cwd=cwd,
- envs=process_envs,
- user=effective_user,
- )
-
- def _run_with_e2b_connect(
- self,
- cmd: str,
- *,
- timeout: float | None,
- cwd: str | None,
- envs: dict[str, str],
- user: str | None,
- ) -> CommandResult:
- from httpcore import ConnectionPool
- from e2b.envd.process import process_connect, process_pb2
-
- base_url, headers = _envd_rpc_base_url_and_headers(self._sandbox)
- pool = ConnectionPool()
- try:
- rpc = process_connect.ProcessClient(
- base_url,
- pool=pool,
- json=True,
- headers=headers,
- )
- request = process_pb2.StartRequest(
- process=process_pb2.ProcessConfig(
- cmd="/bin/bash",
- args=["-l", "-c", cmd],
- envs=envs,
- cwd=cwd or "",
- ),
- stdin=False,
- )
- events = rpc.start(
- request,
- headers=_user_headers(user),
- timeout=timeout,
- request_timeout=self._sandbox._config.request_timeout,
- )
- return _collect_process_events(events)
- finally:
- pool.close()
+ return self._run_with_connect_api(
+ cmd,
+ timeout=timeout,
+ cwd=cwd,
+ envs=process_envs,
+ user=effective_user,
+ )
- def _run_with_connect_fallback(
+ def _run_with_connect_api(
self,
cmd: str,
*,
@@ -135,19 +83,37 @@
"cmd": "/bin/bash",
"args": ["-l", "-c", cmd],
"envs": envs,
+ "cwd": cwd or "",
},
"stdin": False,
}
- if cwd:
- payload["process"]["cwd"] = cwd
headers = {
"Content-Type": CONNECT_CONTENT_TYPE,
"Connect-Protocol-Version": CONNECT_PROTOCOL_VERSION,
"Connect-Content-Encoding": "identity",
}
- if timeout is not None:
- headers["Connect-Timeout-Ms"] = str(int(timeout * 1000))
+ # The timeout has two readers that spell "no deadline" differently, so
+ # it is normalised once here.
+ #
+ # envd reads Connect-Timeout-Ms as a hard wall-clock deadline, so a
zero
+ # or negative one has already passed and the request never gets an
+ # answer -- it hangs until the HTTP client gives up on headers, and
+ # reports a transport error that says nothing about the cause. The
+ # header is therefore omitted rather than sent as zero.
+ #
+ # httpx reads the same number as a client-side deadline, where only
None
+ # means "no deadline": 0 makes every socket operation time out at once,
+ # and -1 is rejected outright. Forwarding the raw value would trade the
+ # hang for an immediate failure, which is not what either value asks
+ # for.
+ #
+ # Both non-positive values arrive in ordinary use: 0 is how the e2b SDK
+ # spells "no deadline", and NEVER_TIMEOUT is how this one does. _pty.py
+ # and the Go SDK already guard this way.
+ effective_timeout = timeout if timeout is not None and timeout > 0
else None
+ if effective_timeout is not None:
+ headers["Connect-Timeout-Ms"] = str(int(effective_timeout * 1000))
access_token = self._sandbox._data.get("envdAccessToken")
if access_token:
headers["X-Access-Token"] = access_token
@@ -159,7 +125,7 @@
url,
content=_encode_connect_envelope(json.dumps(payload).encode("utf-8")),
headers=headers,
- timeout=timeout,
+ timeout=effective_timeout,
) as resp:
if resp.status_code >= 400:
detail = _http_error_detail(resp)
@@ -168,51 +134,6 @@
return _parse_process_start_stream(resp.iter_raw())
-def _envd_rpc_base_url_and_headers(sandbox: "Sandbox") -> tuple[str, dict[str,
str]]:
- # The e2b-connect path uses its own httpcore pool (not sandbox._client),
- # so it does not inherit the client's default headers. Attach the API key
- # here so CubeAPI's auth middleware accepts the request. No-op when unset.
- headers: dict[str, str] = _auth_headers(sandbox._config)
- access_token = sandbox._data.get("envdAccessToken")
- if access_token:
- headers["X-Access-Token"] = access_token
- traffic_token = sandbox.traffic_access_token
- if traffic_token:
- headers["e2b-traffic-access-token"] = traffic_token
-
- if sandbox._config.proxy_node_ip:
- headers["Host"] = sandbox.get_host(ENVD_PORT)
- return
f"http://{sandbox._config.proxy_node_ip}:{sandbox._config.proxy_port}", headers
-
- return f"http://{sandbox.get_host(ENVD_PORT)}", headers
-
-
-def _collect_process_events(events) -> CommandResult:
- stdout: list[str] = []
- stderr: list[str] = []
- exit_code: int | None = None
-
- for response in events:
- if not response.HasField("event"):
- continue
- event = response.event
- if event.HasField("data"):
- if event.data.stdout:
- stdout.append(event.data.stdout.decode("utf-8", "replace"))
- if event.data.stderr:
- stderr.append(event.data.stderr.decode("utf-8", "replace"))
- if event.HasField("end"):
- exit_code = _exit_code_from_end_event(event.end)
- if exit_code is None:
- if event.end.error:
- raise RuntimeError(f"process failed: {event.end.error}")
- raise RuntimeError("process EndEvent missing exit code")
-
- if exit_code is None:
- raise RuntimeError("process stream ended without EndEvent")
- return CommandResult(stdout="".join(stdout), stderr="".join(stderr),
exit_code=exit_code)
-
-
def _parse_process_start_stream(chunks) -> CommandResult:
stdout: list[str] = []
stderr: list[str] = []
@@ -326,31 +247,6 @@
return None
-def _exit_code_from_end_event(end) -> int | None:
- if _has_proto_field(end, "exit_code"):
- return int(end.exit_code)
-
- parsed = _exit_code_from_status(end.status)
- if parsed is not None:
- return parsed
-
- # Some generated proto3 bindings do not expose scalar field presence.
- # Preserve the legacy non-zero path while still allowing status strings to
- # override an unset default value of 0.
- if getattr(end, "exit_code", 0) != 0:
- return int(end.exit_code)
- if end.exited:
- return 0
- return None
-
-
-def _has_proto_field(message, field_name: str) -> bool:
- try:
- return bool(message.HasField(field_name))
- except (AttributeError, ValueError):
- return False
-
-
def _basic_auth_user(user: str) -> str:
token = base64.b64encode(f"{user}:".encode("utf-8")).decode("utf-8")
return f"Basic {token}"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cubesandbox-0.7.0/cubesandbox/_config.py
new/cubesandbox-0.7.3/cubesandbox/_config.py
--- old/cubesandbox-0.7.0/cubesandbox/_config.py 2026-08-28
09:58:28.026039600 +0200
+++ new/cubesandbox-0.7.3/cubesandbox/_config.py 2026-10-12
10:40:25.193764400 +0200
@@ -23,6 +23,11 @@
template_id: str | None = field(
default_factory=lambda: os.environ.get("CUBE_TEMPLATE_ID")
)
+ #: Template (ID or alias) used when ``Sandbox.create(mcp=...)`` is called
+ #: without an explicit template. Mirrors E2B's ``mcp-gateway`` default.
+ mcp_template_id: str = field(
+ default_factory=lambda: os.environ.get("CUBE_MCP_TEMPLATE_ID",
"mcp-gateway")
+ )
proxy_node_ip: str | None = field(
default_factory=lambda: os.environ.get("CUBE_PROXY_NODE_IP")
)
@@ -69,6 +74,7 @@
return (
f"Config(api_url={self.api_url!r}, api_key={masked!r}, "
f"template_id={self.template_id!r}, "
+ f"mcp_template_id={self.mcp_template_id!r}, "
f"proxy_node_ip={self.proxy_node_ip!r},
proxy_port={self.proxy_port!r}, "
f"sandbox_domain={self.sandbox_domain!r},
timeout={self.timeout!r}, "
f"request_timeout={self.request_timeout!r})"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cubesandbox-0.7.0/cubesandbox/_filesystem.py
new/cubesandbox-0.7.3/cubesandbox/_filesystem.py
--- old/cubesandbox-0.7.0/cubesandbox/_filesystem.py 2026-08-28
09:58:28.026039600 +0200
+++ new/cubesandbox-0.7.3/cubesandbox/_filesystem.py 2026-10-12
10:40:25.193764400 +0200
@@ -10,7 +10,7 @@
logger = logging.getLogger(__name__)
-from ._commands import DEFAULT_ENVD_USER, ENVD_PORT, MAX_CONNECT_ENVELOPE_SIZE
+from ._commands import DEFAULT_ENVD_USER, ENVD_PORT,
MAX_CONNECT_ENVELOPE_SIZE, _user_headers
from ._exceptions import FilesystemNotFoundError, PartialWriteError
if TYPE_CHECKING:
@@ -37,15 +37,24 @@
headers["X-Access-Token"] = access_token
return headers
- def _filesystem_rpc(self, method: str, payload: dict[str, Any]) ->
dict[str, Any]:
+ def _filesystem_rpc(
+ self,
+ method: str,
+ payload: dict[str, Any],
+ *,
+ user: str | None = None,
+ ) -> dict[str, Any]:
self._ensure_client()
headers = self._base_headers()
headers["Content-Type"] = "application/json"
headers["Connect-Protocol-Version"] = CONNECT_PROTOCOL_VERSION
+ req_payload = dict(payload)
+ headers.update(_user_headers(user))
+
resp = self._sandbox._client.post(
f"http://{self._sandbox.get_host(ENVD_PORT)}/filesystem.Filesystem/{method}",
- json=payload,
+ json=req_payload,
headers=headers,
)
if resp.status_code >= 400:
@@ -140,36 +149,36 @@
) from e
return len(files)
- def list(self, path: str) -> list[dict[str, Any]]:
+ def list(self, path: str, *, user: str | None = None) -> list[dict[str,
Any]]:
"""List entries in a directory."""
- result = self._filesystem_rpc("ListDir", {"path": path})
+ result = self._filesystem_rpc("ListDir", {"path": path}, user=user)
return result.get("entries", [])
- def stat(self, path: str) -> dict[str, Any]:
+ def stat(self, path: str, *, user: str | None = None) -> dict[str, Any]:
"""Return metadata for a file or directory."""
- result = self._filesystem_rpc("Stat", {"path": path})
+ result = self._filesystem_rpc("Stat", {"path": path}, user=user)
return result.get("entry", {})
- def exists(self, path: str) -> bool:
+ def exists(self, path: str, *, user: str | None = None) -> bool:
"""Return True if the path exists inside the sandbox."""
try:
- self.stat(path)
+ self.stat(path, user=user)
return True
except FilesystemNotFoundError:
return False
- def remove(self, path: str) -> None:
+ def remove(self, path: str, *, user: str | None = None) -> None:
"""Delete a file or directory inside the sandbox."""
- self._filesystem_rpc("Remove", {"path": path})
+ self._filesystem_rpc("Remove", {"path": path}, user=user)
- def rename(self, old_path: str, new_path: str) -> dict[str, Any]:
+ def rename(self, old_path: str, new_path: str, *, user: str | None = None)
-> dict[str, Any]:
"""Move or rename a file or directory."""
- result = self._filesystem_rpc("Move", {"source": old_path,
"destination": new_path})
+ result = self._filesystem_rpc("Move", {"source": old_path,
"destination": new_path}, user=user)
return result.get("entry", {})
- def make_dir(self, path: str) -> dict[str, Any]:
+ def make_dir(self, path: str, *, user: str | None = None) -> dict[str,
Any]:
"""Create a directory inside the sandbox."""
- result = self._filesystem_rpc("MakeDir", {"path": path})
+ result = self._filesystem_rpc("MakeDir", {"path": path}, user=user)
return result.get("entry", {})
def watch_dir(self, path: str) -> Watcher:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cubesandbox-0.7.0/cubesandbox/_policy.py
new/cubesandbox-0.7.3/cubesandbox/_policy.py
--- old/cubesandbox-0.7.0/cubesandbox/_policy.py 2026-08-28
09:58:28.026039600 +0200
+++ new/cubesandbox-0.7.3/cubesandbox/_policy.py 2026-10-12
10:40:25.193764400 +0200
@@ -25,6 +25,17 @@
AuditLevel = Literal["full", "metadata", "none"]
+# Align with e2b maxNetworkRuleHeaderValueLen. Well under nginx's default
+# large_client_header_buffers (8k), so injected headers fit common proxies.
+SECRET_MAX_BYTES = 2048
+
+
+def _validate_inject_secret(secret: Any, field: str) -> None:
+ if not isinstance(secret, str):
+ return
+ if len(secret.encode("utf-8")) > SECRET_MAX_BYTES:
+ raise ValueError(f"{field} exceeds {SECRET_MAX_BYTES} bytes")
+
def _validate_scheme(value: Any, field: str) -> Optional[str]:
"""Normalize *value* (strip + lowercase) and validate against http/https.
@@ -118,6 +129,9 @@
secret: str
format: Optional[str] = None
+ def __post_init__(self) -> None:
+ _validate_inject_secret(self.secret, "Inject.secret")
+
def render(self) -> str:
"""Render the final injected header value (preview helper)."""
fmt = self.format or "${SECRET}"
@@ -204,7 +218,9 @@
def _normalize_inject_dict(i: Dict[str, Any]) -> Dict[str, Any]:
# No snake_case keys to translate today; pass through verbatim.
- return dict(i)
+ out = dict(i)
+ _validate_inject_secret(out.get("secret"), "inject.secret")
+ return out
def _normalize_action_dict(a: Dict[str, Any]) -> Dict[str, Any]:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cubesandbox-0.7.0/cubesandbox/_pty.py
new/cubesandbox-0.7.3/cubesandbox/_pty.py
--- old/cubesandbox-0.7.0/cubesandbox/_pty.py 2026-08-28 09:58:28.026039600
+0200
+++ new/cubesandbox-0.7.3/cubesandbox/_pty.py 2026-10-12 10:40:25.193764400
+0200
@@ -6,7 +6,7 @@
Mirrors the E2B SDK's ``sandbox.pty`` API but speaks envd's Connect-JSON RPC
directly over httpx — no dependency on the ``e2b`` / ``e2b_connect`` Python
packages. The on-the-wire encoding is the same one used by
-:func:`cubesandbox._commands._run_with_connect_fallback`: each Connect frame
+:func:`cubesandbox._commands._run_with_connect_api`: each Connect frame
is a 5-byte envelope (1 flags byte + big-endian uint32 length) followed by a
JSON body, and protobuf ``bytes`` fields are base64 strings on the JSON side.
"""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cubesandbox-0.7.0/cubesandbox/sandbox.py
new/cubesandbox-0.7.3/cubesandbox/sandbox.py
--- old/cubesandbox-0.7.0/cubesandbox/sandbox.py 2026-08-28
09:58:28.026039600 +0200
+++ new/cubesandbox-0.7.3/cubesandbox/sandbox.py 2026-10-12
10:40:25.194279400 +0200
@@ -3,7 +3,10 @@
from __future__ import annotations
+import json
+import shlex
import threading
+import uuid
from typing import Any, Callable, Dict
import httpx
@@ -26,6 +29,13 @@
JUPYTER_PORT = 49999
+#: Port the in-sandbox ``mcp-gateway`` listens on (E2B-compatible).
+MCP_PORT = 50005
+MCP_TOKEN_PATH = "/etc/mcp-gateway/.token"
+#: How long ``mcp-gateway --config`` may take, matching the E2B SDK's default
+#: command timeout. Pre-install servers in the template to stay within it.
+MCP_STARTUP_TIMEOUT = 60
+
#: Never-timeout sentinel. See docs/guide/lifecycle.md.
NEVER_TIMEOUT = -1
@@ -82,6 +92,12 @@
raise ApiError(msg, code)
+def _is_template_not_found(resp: requests.Response) -> bool:
+ # Older CubeAPI builds relay CubeMaster's 130404 without mapping it to 404.
+ text = resp.text.lower()
+ return (resp.status_code == 404 and "template" in text) or "130404" in text
+
+
_VALID_ON_TIMEOUT = ("kill", "pause")
@@ -127,6 +143,7 @@
self._files = Filesystem(self)
self._pty = Pty(self)
self._clone_cleanup: _CloneCleanup | None = None
+ self._mcp_token: str | None = None
@property
@@ -167,6 +184,41 @@
"""
return f"{port}-{self.sandbox_id}.{self.domain}"
+ def get_mcp_url(self) -> str:
+ """Return the streamable-HTTP URL of the sandbox MCP gateway.
+
+ Send ``Authorization: Bearer <get_mcp_token()>`` with every request.
+ """
+ return f"http://{self.get_host(MCP_PORT)}/mcp"
+
+ def get_mcp_token(self) -> str | None:
+ """Return the MCP gateway bearer token, or ``None`` if MCP is not
enabled.
+
+ The token is cached on the instance that started the gateway; other
+ instances (e.g. from :meth:`connect`) read it from the sandbox.
+ """
+ if self._mcp_token is None and self.files.exists(MCP_TOKEN_PATH,
user="root"):
+ self._mcp_token = self.files.read(MCP_TOKEN_PATH,
user="root").strip()
+ return self._mcp_token
+
+ def _start_mcp_gateway(self, mcp: Dict[str, Any]) -> None:
+ token = str(uuid.uuid4())
+ result = self.commands.run(
+ f"mcp-gateway --config {shlex.quote(json.dumps(mcp))}",
+ user="root",
+ envs={"GATEWAY_ACCESS_TOKEN": token},
+ timeout=MCP_STARTUP_TIMEOUT,
+ )
+ if result.exit_code != 0:
+ detail = (result.stderr or result.stdout).strip()
+ if result.exit_code == 127:
+ detail = (
+ f"template {self.template_id!r} does not provide
mcp-gateway "
+ f"(see docs/guide/mcp-gateway.md): {detail}"
+ )
+ raise CubeSandboxError(f"Failed to start MCP gateway: {detail}")
+ self._mcp_token = token
+
@property
def commands(self) -> "Commands":
return self._commands
@@ -194,13 +246,17 @@
network: Dict[str, Any] | None = None,
lifecycle: Dict[str, Any] | None = None,
volume_mounts: VolumeMountsArg | None = None,
+ mcp: Dict[str, Any] | None = None,
config: Config | None = None,
**kwargs: Any,
) -> "Sandbox":
"""POST /sandboxes - Create a new sandbox.
Args:
- template: Template ID. Falls back to ``CUBE_TEMPLATE_ID`` env var.
+ template: Template ID or alias. Falls back to ``CUBE_TEMPLATE_ID``.
+ When ``mcp`` is set, ``Config.mcp_template_id``
(``mcp-gateway``
+ by default) is tried first and ``CUBE_TEMPLATE_ID`` is used
only
+ if that template does not exist.
timeout: Sandbox idle timeout in seconds (``None`` omits the
field).
See ``docs/guide/lifecycle.md``.
env_vars: Environment variables injected into the sandbox.
@@ -254,6 +310,13 @@
:meth:`cubesandbox.Volume.create`. ``read_only`` applies to
this
sandbox attachment; it does not make the volume an immutable
snapshot.
+ mcp: E2B-compatible MCP servers keyed by server name, e.g.
+ ``{"duckduckgo": {}, "arxiv": {"storagePath": "/"}}`` or
+ ``{"github/<owner>/<repo>": {"runCmd": ..., "installCmd": ...,
+ "envs": {...}}}``. When set, ``mcp-gateway`` is started inside
+ the sandbox after creation; use :meth:`get_mcp_url` and
+ :meth:`get_mcp_token` to connect. The template must ship the
+ ``mcp-gateway`` binary.
config: SDK config. Uses default (env-based) config if omitted.
Returns:
@@ -262,11 +325,24 @@
Raises:
ValueError: If no template ID is provided, or if ``lifecycle``
contains an unsupported ``on_timeout`` value.
- ApiError: On unexpected backend error (HTTP 500).
+ ApiError: On unexpected backend error (HTTP 500), or HTTP 400 for
+ an invalid ``mcp`` shape.
+ TemplateNotFoundError: If ``mcp`` is set without ``template`` and
+ none of the candidate templates exists.
+ CubeSandboxError: If the MCP gateway fails to start. The sandbox
+ is killed before the error is raised.
"""
cfg = config or Config()
- tpl = template or cfg.template_id
- if not tpl:
+ # A templateID in kwargs is an explicit choice: it is sent verbatim,
+ # without the MCP template fallback.
+ fallback = not template and mcp is not None and "templateID" not in
kwargs
+ if template:
+ templates = [template]
+ elif fallback:
+ templates = list(dict.fromkeys(t for t in (cfg.mcp_template_id,
cfg.template_id) if t))
+ else:
+ templates = [cfg.template_id] if cfg.template_id else []
+ if not templates:
raise ValueError("template is required. Set CUBE_TEMPLATE_ID or
pass template=")
if env_vars is not None and envs is not None and env_vars != envs:
@@ -274,7 +350,7 @@
sandbox_env_vars = env_vars if env_vars is not None else envs
# Omitted when None; see docs/guide/lifecycle.md.
- payload: dict = {"templateID": tpl}
+ payload: dict = {"templateID": templates[0]}
if timeout is not None:
payload["timeout"] = timeout
if sandbox_env_vars:
@@ -296,22 +372,53 @@
payload["lifecycle"] = _serialize_lifecycle(lifecycle)
if volume_mounts:
payload["volumeMounts"] = _serialize_volume_mounts(volume_mounts)
- payload.update(kwargs)
-
+ if mcp is not None:
+ payload["mcp"] = mcp
s = requests.Session()
- resp = s.post(f"{cfg.api_url}/sandboxes", json=payload,
- headers={"Content-Type": "application/json",
**_auth_headers(cfg)})
+ for tpl in templates:
+ resp = s.post(f"{cfg.api_url}/sandboxes", json={**payload,
"templateID": tpl, **kwargs},
+ headers={"Content-Type": "application/json",
**_auth_headers(cfg)})
+ if not (fallback and _is_template_not_found(resp)):
+ break
+ else:
+ raise TemplateNotFoundError(
+ f"no template for mcp: tried {', '.join(repr(t) for t in
templates)}. "
+ "Build a template that provides mcp-gateway (see
docs/guide/mcp-gateway.md), "
+ "then pass template= or set CUBE_MCP_TEMPLATE_ID",
+ resp.status_code,
+ )
_check_response(resp)
- return cls(resp.json(), config=cfg)
+ sandbox = cls(resp.json(), config=cfg)
+ if mcp is not None:
+ try:
+ sandbox._start_mcp_gateway(mcp)
+ except BaseException:
+ try:
+ sandbox.kill()
+ except Exception: # noqa: BLE001 — surface the gateway error
+ pass
+ raise
+ return sandbox
@classmethod
- def connect(cls, sandbox_id: str, *, config: Config | None = None) ->
"Sandbox":
+ def connect(
+ cls,
+ sandbox_id: str,
+ timeout: int | None = None,
+ *,
+ config: Config | None = None,
+ ) -> "Sandbox":
"""POST /sandboxes/:sandboxID/connect - Connect to an existing sandbox.
Resumes the sandbox if it is currently paused.
Args:
sandbox_id: Sandbox identifier.
+ timeout: Sandbox idle timeout in seconds after connecting. ``None``
+ keeps the current timeout, ``-1`` disables idle expiry, and a
+ positive value ensures at least that much remaining lifetime
+ (running and paused sandboxes are never shortened). ``0`` and
+ values below ``-1`` are rejected.
config: SDK config. Uses default (env-based) config if omitted.
Returns:
@@ -319,13 +426,17 @@
Raises:
SandboxNotFoundError: If the sandbox does not exist (HTTP 404).
- ApiError: On unexpected backend error (HTTP 500).
+ ApiError: If the timeout is ``0`` or below ``-1`` (HTTP 400), an
+ overlapping lifecycle transition prevents the connection
+ (HTTP 409), or an unexpected backend error occurs.
"""
cfg = config or Config()
s = requests.Session()
- # Connect omits timeout; see docs/guide/lifecycle.md.
+ body: dict[str, int] = {}
+ if timeout is not None:
+ body["timeout"] = timeout
resp = s.post(f"{cfg.api_url}/sandboxes/{sandbox_id}/connect",
- json={},
+ json=body,
headers={"Content-Type": "application/json",
**_auth_headers(cfg)})
_check_response(resp)
return cls(resp.json(), config=cfg)
@@ -891,7 +1002,7 @@
When the sandbox was created with
``network.allow_public_traffic=False``,
CubeProxy rejects unauthenticated traffic with 403. Attaching the token
as a default header on the httpx client covers run_code, the Connect
- fallback path, and filesystem read/write in one place.
+ commands path, and filesystem read/write in one place.
Data-plane requests are also routed through CubeAPI's auth middleware,
which requires ``X-API-Key`` (or ``Authorization: Bearer``) whenever
the
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cubesandbox-0.7.0/cubesandbox.egg-info/PKG-INFO
new/cubesandbox-0.7.3/cubesandbox.egg-info/PKG-INFO
--- old/cubesandbox-0.7.0/cubesandbox.egg-info/PKG-INFO 2026-08-28
09:58:41.161230000 +0200
+++ new/cubesandbox-0.7.3/cubesandbox.egg-info/PKG-INFO 2026-10-12
10:40:38.843980300 +0200
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: cubesandbox
-Version: 0.7.0
+Version: 0.7.3
Summary: Python SDK for CubeSandbox
License: Apache-2.0
Project-URL: Homepage, https://github.com/TencentCloud/CubeSandbox
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cubesandbox-0.7.0/pyproject.toml
new/cubesandbox-0.7.3/pyproject.toml
--- old/cubesandbox-0.7.0/pyproject.toml 2026-08-28 09:58:28.027073100
+0200
+++ new/cubesandbox-0.7.3/pyproject.toml 2026-10-12 10:40:25.195129600
+0200
@@ -4,7 +4,7 @@
[project]
name = "cubesandbox"
-version = "0.7.0"
+version = "0.7.3"
description = "Python SDK for CubeSandbox"
license = { text = "Apache-2.0" }
requires-python = ">=3.9"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cubesandbox-0.7.0/tests/test_policy.py
new/cubesandbox-0.7.3/tests/test_policy.py
--- old/cubesandbox-0.7.0/tests/test_policy.py 2026-08-28 09:58:28.027225000
+0200
+++ new/cubesandbox-0.7.3/tests/test_policy.py 2026-10-12 10:40:25.195279400
+0200
@@ -8,7 +8,9 @@
from cubesandbox import Action, Inject, Match, Rule
from cubesandbox._policy import (
+ SECRET_MAX_BYTES,
_convert_e2b_per_host_rules,
+ _normalize_inject_dict,
_normalize_match_dict,
_serialize_rule,
)
@@ -135,6 +137,28 @@
assert wire["match"]["port"] == 8443
+class TestInjectSecretValidation:
+ def test_secret_at_cap_accepted(self):
+ inj = Inject(header="Authorization", secret="x" * SECRET_MAX_BYTES)
+ assert inj.to_wire()["secret"] == "x" * SECRET_MAX_BYTES
+
+ def test_secret_over_cap_rejected(self):
+ with pytest.raises(ValueError, match="exceeds 2048 bytes"):
+ Inject(header="Authorization", secret="x" * (SECRET_MAX_BYTES + 1))
+
+ def test_dict_secret_over_cap_rejected(self):
+ with pytest.raises(ValueError, match="exceeds 2048 bytes"):
+ _normalize_inject_dict({"header": "Authorization", "secret": "x" *
(SECRET_MAX_BYTES + 1)})
+
+ def test_e2b_header_over_cap_rejected(self):
+ with pytest.raises(ValueError, match="exceeds 2048 bytes"):
+ _convert_e2b_per_host_rules({
+ "api.example.com": [
+ {"transform": {"headers": {"Authorization": "x" *
(SECRET_MAX_BYTES + 1)}}},
+ ],
+ })
+
+
class TestE2BPerHostRulesCompat:
def test_transform_still_works_without_port(self):
# E2B compat layer does not use port; generated rules should not carry
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cubesandbox-0.7.0/tests/test_sandbox.py
new/cubesandbox-0.7.3/tests/test_sandbox.py
--- old/cubesandbox-0.7.0/tests/test_sandbox.py 2026-08-28 09:58:28.027225000
+0200
+++ new/cubesandbox-0.7.3/tests/test_sandbox.py 2026-10-12 10:40:25.195279400
+0200
@@ -20,9 +20,9 @@
import httpx
import pytest
-from cubesandbox import CommandResult, Template
+from cubesandbox import NEVER_TIMEOUT, CommandResult, Template
from cubesandbox._template import TemplateInfo
-from cubesandbox._commands import Commands, _collect_process_events
+from cubesandbox._commands import Commands
from cubesandbox._config import Config
from cubesandbox._exceptions import (
ApiError,
@@ -79,6 +79,23 @@
return Sandbox(d, config=make_config())
+def _recording_client(transport: httpx.MockTransport, seen: dict) ->
httpx.Client:
+ """An httpx client that records the timeout each request was given.
+
+ MockTransport answers synchronously and enforces no timeouts at all, so the
+ value a caller passes is only observable by recording it here.
+ """
+ client = httpx.Client(transport=transport)
+ stream = client.stream
+
+ def recording_stream(*args, **kwargs):
+ seen["timeout"] = kwargs.get("timeout")
+ return stream(*args, **kwargs)
+
+ client.stream = recording_stream
+ return client
+
+
def connect_envelope(flags: int, payload: str) -> bytes:
raw = payload.encode("utf-8")
return bytes([flags]) + struct.pack(">I", len(raw)) + raw
@@ -300,6 +317,158 @@
assert body["lifecycle"] == {"autoResume": True}
+# ── MCP gateway (E2B-compatible ``mcp`` option)
───────────────────────────────
+
+class TestMcp:
+ MCP = {"duckduckgo": {}, "github/acme/tools": {"runCmd": "echo 'hi'",
"envs": {"K": "v"}}}
+
+ def _create(self, run_result=None, **kwargs):
+ run_result = run_result or CommandResult(stdout="ready", stderr="",
exit_code=0)
+ with patch("requests.Session.post",
return_value=mock_response(SANDBOX_DATA, status=201)) as post, \
+ patch.object(Commands, "run", return_value=run_result) as run,
\
+ patch.object(Sandbox, "kill") as kill:
+ sb = Sandbox.create(config=make_config(), **kwargs)
+ return sb, post.call_args.kwargs["json"], run, kill
+
+ def test_defaults_to_mcp_gateway_template(self):
+ _, body, _, _ = self._create(mcp=self.MCP)
+ assert body["templateID"] == "mcp-gateway"
+ assert body["mcp"] == self.MCP
+
+ def test_mcp_template_can_be_configured(self):
+ with patch("requests.Session.post",
return_value=mock_response(SANDBOX_DATA, status=201)) as post, \
+ patch.object(Commands, "run", return_value=CommandResult("",
"", 0)):
+ Sandbox.create(mcp={},
config=make_config(mcp_template_id="tpl-mcp"))
+ assert post.call_args.kwargs["json"]["templateID"] == "tpl-mcp"
+
+ def test_explicit_template_wins(self):
+ _, body, _, _ = self._create(template="tpl-custom", mcp=self.MCP)
+ assert body["templateID"] == "tpl-custom"
+
+ def _create_with_responses(self, responses, **kwargs):
+ sent = []
+
+ def post(*_args, **kw):
+ sent.append(kw["json"]["templateID"])
+ return responses[len(sent) - 1]
+
+ with patch("requests.Session.post", side_effect=post), \
+ patch.object(Commands, "run", return_value=CommandResult("",
"", 0)) as run, \
+ patch.object(Sandbox, "kill"):
+ try:
+ Sandbox.create(config=make_config(), **kwargs)
+ finally:
+ self.sent, self.run = sent, run
+
+ MISSING = {"code": 404, "message": "template mcp-gateway not found:
template not found"}
+ LEGACY_MISSING = {"code": 500, "message": 'CubeMaster returned error code
130404: failed to resolve template identifier "mcp-gateway": template not
found'}
+
+ @pytest.mark.parametrize("missing", [
+ mock_response(MISSING, status=404),
+ mock_response(LEGACY_MISSING, status=500),
+ ])
+ def test_falls_back_to_default_template_when_mcp_template_missing(self,
missing):
+ self._create_with_responses([missing, mock_response(SANDBOX_DATA,
status=201)], mcp=self.MCP)
+ assert self.sent == ["mcp-gateway", "tpl-test"]
+ self.run.assert_called_once()
+
+ def test_errors_when_no_candidate_template_exists(self):
+ missing = mock_response(self.MISSING, status=404)
+ with pytest.raises(TemplateNotFoundError, match="'mcp-gateway',
'tpl-test'.*mcp-gateway.md"):
+ self._create_with_responses([missing, missing], mcp=self.MCP)
+ assert self.sent == ["mcp-gateway", "tpl-test"]
+ self.run.assert_not_called()
+
+ def test_missing_explicit_template_does_not_fall_back(self):
+ with pytest.raises(TemplateNotFoundError):
+ self._create_with_responses([mock_response(self.MISSING,
status=404)], template="tpl-x", mcp=self.MCP)
+ assert self.sent == ["tpl-x"]
+
+ def test_kwargs_template_id_is_sent_verbatim(self):
+ self._create_with_responses([mock_response(SANDBOX_DATA, status=201)],
templateID="tpl-x")
+ assert self.sent == ["tpl-x"]
+ with pytest.raises(TemplateNotFoundError):
+ self._create_with_responses([mock_response(self.MISSING,
status=404)], templateID="tpl-x", mcp=self.MCP)
+ assert self.sent == ["tpl-x"]
+ self.run.assert_not_called()
+
+ def test_other_errors_do_not_fall_back(self):
+ with pytest.raises(ApiError):
+ self._create_with_responses([mock_response({"message": "boom"},
status=500)], mcp=self.MCP)
+ assert self.sent == ["mcp-gateway"]
+
+ def test_missing_gateway_binary_names_template(self):
+ missing = CommandResult(stdout="", stderr="/bin/bash: mcp-gateway:
command not found", exit_code=127)
+ with pytest.raises(CubeSandboxError, match="template 'tpl-test' does
not provide mcp-gateway"):
+ self._create(run_result=missing, mcp=self.MCP)
+
+ def test_without_mcp_does_not_start_gateway(self):
+ _, body, run, _ = self._create()
+ assert body["templateID"] == "tpl-test"
+ assert "mcp" not in body
+ run.assert_not_called()
+
+ def test_starts_gateway_with_token(self):
+ sb, _, run, kill = self._create(mcp=self.MCP)
+ cmd = run.call_args.args[0]
+ kwargs = run.call_args.kwargs
+ assert cmd.startswith("mcp-gateway --config ")
+ import shlex
+ assert shlex.split(cmd)[:2] == ["mcp-gateway", "--config"]
+ assert len(shlex.split(cmd)) == 3
+ assert json.loads(shlex.split(cmd)[2]) == self.MCP
+ assert kwargs["user"] == "root"
+ token = kwargs["envs"]["GATEWAY_ACCESS_TOKEN"]
+ assert token and sb.get_mcp_token() == token
+ assert kwargs["timeout"] == 60
+ kill.assert_not_called()
+
+ def test_empty_mcp_still_starts_gateway(self):
+ _, body, run, _ = self._create(mcp={})
+ assert body["mcp"] == {}
+ run.assert_called_once()
+
+ def test_gateway_failure_kills_sandbox(self):
+ failed = CommandResult(stdout="", stderr='mcp server "duckduckgo":
boom', exit_code=1)
+ with patch("requests.Session.post",
return_value=mock_response(SANDBOX_DATA, status=201)), \
+ patch.object(Commands, "run", return_value=failed), \
+ patch.object(Sandbox, "kill") as kill:
+ with pytest.raises(CubeSandboxError, match="Failed to start MCP
gateway: .*boom"):
+ Sandbox.create(mcp=self.MCP, config=make_config())
+ kill.assert_called_once()
+
+ def test_gateway_exception_kills_sandbox(self):
+ with patch("requests.Session.post",
return_value=mock_response(SANDBOX_DATA, status=201)), \
+ patch.object(Commands, "run",
side_effect=TimeoutError("slow")), \
+ patch.object(Sandbox, "kill", side_effect=ApiError("gone",
500)) as kill:
+ with pytest.raises(TimeoutError):
+ Sandbox.create(mcp=self.MCP, config=make_config())
+ kill.assert_called_once()
+
+ def test_invalid_mcp_rejected_by_api(self):
+ err = mock_response({"code": 400, "message": "mcp must be an object
keyed by MCP server name"}, status=400)
+ with patch("requests.Session.post", return_value=err),
patch.object(Commands, "run") as run:
+ with pytest.raises(ApiError, match="keyed by MCP server name"):
+ Sandbox.create(mcp=["duckduckgo"], config=make_config())
+ run.assert_not_called()
+
+ def test_get_mcp_url(self):
+ assert make_sandbox().get_mcp_url() ==
f"http://50005-{SANDBOX_ID}.{DOMAIN}/mcp"
+
+ def test_get_mcp_token_reads_token_file(self):
+ sb = make_sandbox()
+ with patch.object(Filesystem, "exists", return_value=True) as exists, \
+ patch.object(Filesystem, "read", return_value="tok\n") as read:
+ assert sb.get_mcp_token() == "tok"
+ assert sb.get_mcp_token() == "tok"
+ exists.assert_called_once_with("/etc/mcp-gateway/.token", user="root")
+ read.assert_called_once_with("/etc/mcp-gateway/.token", user="root")
+
+ def test_get_mcp_token_none_without_gateway(self):
+ with patch.object(Filesystem, "exists", return_value=False):
+ assert make_sandbox().get_mcp_token() is None
+
+
# ── domain filtering (DNS allow-list) ────────────────────────────────────────
class TestDomainFiltering:
@@ -704,6 +873,12 @@
body = m.call_args.kwargs["json"]
assert "timeout" not in body
+ @pytest.mark.parametrize("timeout", [-1, 120])
+ def test_connect_sends_explicit_timeout(self, timeout):
+ with patch("requests.Session.post",
return_value=mock_response(SANDBOX_DATA)) as m:
+ Sandbox.connect(SANDBOX_ID, timeout, config=make_config())
+ assert m.call_args.kwargs["json"]["timeout"] == timeout
+
# ── GET /sandboxes
────────────────────────────────────────────────────────────
@@ -1380,10 +1555,7 @@
return httpx.Response(200, stream=httpx.ByteStream(body))
client = httpx.Client(transport=httpx.MockTransport(handler))
- with (
- patch.object(Commands, "_run_with_e2b_connect",
side_effect=ImportError),
- patch.object(sb, "_build_data_client", return_value=client),
- ):
+ with patch.object(sb, "_build_data_client", return_value=client):
result = sb.commands.run("echo hello", cwd="/work", env={"A": "B"})
assert result.stdout == "hello\nworld\n"
@@ -1400,6 +1572,83 @@
assert seen["payload"]["process"]["envs"] == {"A": "B"}
assert seen["payload"]["process"]["args"] == ["-l", "-c", "echo hello"]
+ def test_run_defaults_cwd_to_empty(self):
+ sb = make_sandbox()
+ seen = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["payload"] = decode_connect_payload(request.content)
+ body = b"".join(
+ [
+ connect_envelope(0,
'{"event":{"end":{"exitCode":0,"exited":true}}}'),
+ connect_envelope(0x02, "{}"),
+ ]
+ )
+ return httpx.Response(200, stream=httpx.ByteStream(body))
+
+ client = httpx.Client(transport=httpx.MockTransport(handler))
+ with patch.object(sb, "_build_data_client", return_value=client):
+ result = sb.commands.run("pwd", user="nobody")
+
+ assert result.exit_code == 0
+ assert seen["payload"]["process"]["cwd"] == ""
+
+ # envd reads Connect-Timeout-Ms as a hard wall-clock deadline, so a
+ # non-positive one has already passed and the request is never answered.
+ # Both values below arrive in ordinary use: 0 is how the e2b SDK spells
+ # "no deadline", and NEVER_TIMEOUT is how this one does.
+ #
+ # The client-side deadline is asserted as well as the header. httpx reads
+ # the same number, and only None disables it there -- 0 times out every
+ # socket operation at once and -1 is rejected -- so forwarding the raw
+ # value would trade the hang for an immediate failure. MockTransport
+ # enforces no timeouts at all, which is why what was passed is inspected
+ # rather than its effect.
+ @pytest.mark.parametrize("timeout", [0, NEVER_TIMEOUT])
+ def test_run_omits_non_positive_timeout_header(self, timeout):
+ sb = make_sandbox()
+ seen = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["headers"] = request.headers
+ body = b"".join(
+ [
+ connect_envelope(0,
'{"event":{"end":{"exitCode":0,"exited":true}}}'),
+ connect_envelope(0x02, "{}"),
+ ]
+ )
+ return httpx.Response(200, stream=httpx.ByteStream(body))
+
+ client = _recording_client(httpx.MockTransport(handler), seen)
+ with patch.object(sb, "_build_data_client", return_value=client):
+ result = sb.commands.run("echo hi", timeout=timeout)
+
+ assert result.exit_code == 0
+ assert "connect-timeout-ms" not in seen["headers"]
+ assert seen["timeout"] is None
+
+ def test_run_sends_positive_timeout_header(self):
+ sb = make_sandbox()
+ seen = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["headers"] = request.headers
+ body = b"".join(
+ [
+ connect_envelope(0,
'{"event":{"end":{"exitCode":0,"exited":true}}}'),
+ connect_envelope(0x02, "{}"),
+ ]
+ )
+ return httpx.Response(200, stream=httpx.ByteStream(body))
+
+ client = _recording_client(httpx.MockTransport(handler), seen)
+ with patch.object(sb, "_build_data_client", return_value=client):
+ result = sb.commands.run("echo hi", timeout=30)
+
+ assert result.exit_code == 0
+ assert seen["headers"]["connect-timeout-ms"] == "30000"
+ assert seen["timeout"] == 30
+
def test_run_stderr_event(self):
sb = make_sandbox()
@@ -1416,10 +1665,7 @@
return httpx.Response(200, stream=httpx.ByteStream(body))
client = httpx.Client(transport=httpx.MockTransport(handler))
- with (
- patch.object(Commands, "_run_with_e2b_connect",
side_effect=ImportError),
- patch.object(sb, "_build_data_client", return_value=client),
- ):
+ with patch.object(sb, "_build_data_client", return_value=client):
result = sb.commands.run("echo warn >&2")
assert result.stdout == ""
@@ -1440,10 +1686,7 @@
return httpx.Response(200, stream=httpx.ByteStream(body))
client = httpx.Client(transport=httpx.MockTransport(handler))
- with (
- patch.object(Commands, "_run_with_e2b_connect",
side_effect=ImportError),
- patch.object(sb, "_build_data_client", return_value=client),
- ):
+ with patch.object(sb, "_build_data_client", return_value=client):
result = sb.commands.run("false")
assert result.exit_code == 1
@@ -1461,10 +1704,7 @@
return httpx.Response(200, stream=httpx.ByteStream(body))
client = httpx.Client(transport=httpx.MockTransport(handler))
- with (
- patch.object(Commands, "_run_with_e2b_connect",
side_effect=ImportError),
- patch.object(sb, "_build_data_client", return_value=client),
- ):
+ with patch.object(sb, "_build_data_client", return_value=client):
result = sb.commands.run("false")
assert result.exit_code == 7
@@ -1485,38 +1725,10 @@
return httpx.Response(200, stream=httpx.ByteStream(body))
client = httpx.Client(transport=httpx.MockTransport(handler))
- with (
- patch.object(Commands, "_run_with_e2b_connect",
side_effect=ImportError),
- patch.object(sb, "_build_data_client", return_value=client),
- ):
+ with patch.object(sb, "_build_data_client", return_value=client):
result = sb.commands.run("kill")
assert result.exit_code == 137
- def test_collect_process_events_prefers_status_when_exit_code_unset(self):
- class End:
- exit_code = 0
- status = "exit status 7"
- exited = True
- error = ""
-
- def HasField(self, name):
- return False
-
- class Event:
- end = End()
-
- def HasField(self, name):
- return name == "end"
-
- class Response:
- event = Event()
-
- def HasField(self, name):
- return name == "event"
-
- result = _collect_process_events([Response()])
- assert result.exit_code == 7
-
def test_run_timeout_forwarded(self):
sb = make_sandbox()
seen = {}
@@ -1533,10 +1745,7 @@
return httpx.Response(200, stream=httpx.ByteStream(body))
client = httpx.Client(transport=httpx.MockTransport(handler))
- with (
- patch.object(Commands, "_run_with_e2b_connect",
side_effect=ImportError),
- patch.object(sb, "_build_data_client", return_value=client),
- ):
+ with patch.object(sb, "_build_data_client", return_value=client):
sb.commands.run("sleep 1", timeout=5.0)
assert seen["headers"]["connect-timeout-ms"] == "5000"
@@ -1547,10 +1756,7 @@
return httpx.Response(400, json={"message": "sandbox is not
ready"})
client = httpx.Client(transport=httpx.MockTransport(handler))
- with (
- patch.object(Commands, "_run_with_e2b_connect",
side_effect=ImportError),
- patch.object(sb, "_build_data_client", return_value=client),
- ):
+ with patch.object(sb, "_build_data_client", return_value=client):
with pytest.raises(RuntimeError, match="HTTP 400: sandbox is not
ready"):
sb.commands.run("echo hello")
@@ -1702,6 +1908,22 @@
entries = sb.files.list("/empty")
assert entries == []
+ def test_list_includes_user_when_provided(self):
+ sb = make_sandbox()
+ seen = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["body"] = json.loads(request.content)
+ seen["authorization"] = request.headers.get("authorization")
+ return httpx.Response(200, json={"entries": []})
+
+ client = httpx.Client(transport=httpx.MockTransport(handler))
+ with patch.object(sb, "_build_data_client", return_value=client):
+ sb.files.list("/tmp", user="nobody")
+
+ assert seen["body"] == {"path": "/tmp"}
+ assert seen["authorization"] == "Basic bm9ib2R5Og=="
+
def test_stat_success(self):
sb = make_sandbox()
seen = {}
@@ -1720,6 +1942,25 @@
assert entry["name"] == "hello.txt"
assert entry["size"] == "30"
+ def test_stat_includes_user_when_provided(self):
+ sb = make_sandbox()
+ seen = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["body"] = json.loads(request.content)
+ seen["authorization"] = request.headers.get("authorization")
+ return httpx.Response(200, json={
+ "entry": {"name": "hello.txt", "type": "FILE_TYPE_FILE",
"path": "/tmp/hello.txt", "size": "30"}
+ })
+
+ client = httpx.Client(transport=httpx.MockTransport(handler))
+ with patch.object(sb, "_build_data_client", return_value=client):
+ entry = sb.files.stat("/tmp/hello.txt", user="nobody")
+
+ assert seen["body"] == {"path": "/tmp/hello.txt"}
+ assert seen["authorization"] == "Basic bm9ib2R5Og=="
+ assert entry["name"] == "hello.txt"
+
def test_exists_returns_true(self):
sb = make_sandbox()
@@ -1732,6 +1973,24 @@
with patch.object(sb, "_build_data_client", return_value=client):
assert sb.files.exists("/tmp/f.txt") is True
+ def test_exists_includes_user_when_provided(self):
+ sb = make_sandbox()
+ seen = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["body"] = json.loads(request.content)
+ seen["authorization"] = request.headers.get("authorization")
+ return httpx.Response(200, json={
+ "entry": {"name": "f.txt", "type": "FILE_TYPE_FILE", "path":
"/tmp/f.txt"}
+ })
+
+ client = httpx.Client(transport=httpx.MockTransport(handler))
+ with patch.object(sb, "_build_data_client", return_value=client):
+ assert sb.files.exists("/tmp/f.txt", user="nobody") is True
+
+ assert seen["body"] == {"path": "/tmp/f.txt"}
+ assert seen["authorization"] == "Basic bm9ib2R5Og=="
+
def test_exists_returns_false_on_404(self):
sb = make_sandbox()
@@ -1759,6 +2018,22 @@
assert seen["path"] == "/filesystem.Filesystem/Remove"
assert seen["body"] == {"path": "/tmp/old.txt"}
+ def test_remove_includes_user_when_provided(self):
+ sb = make_sandbox()
+ seen = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["body"] = json.loads(request.content)
+ seen["authorization"] = request.headers.get("authorization")
+ return httpx.Response(200, json={})
+
+ client = httpx.Client(transport=httpx.MockTransport(handler))
+ with patch.object(sb, "_build_data_client", return_value=client):
+ sb.files.remove("/tmp/old.txt", user="nobody")
+
+ assert seen["body"] == {"path": "/tmp/old.txt"}
+ assert seen["authorization"] == "Basic bm9ib2R5Og=="
+
def test_rename_success(self):
sb = make_sandbox()
seen = {}
@@ -1776,6 +2051,25 @@
assert seen["body"] == {"source": "/tmp/old.txt", "destination":
"/tmp/new.txt"}
assert entry["name"] == "new.txt"
+ def test_rename_includes_user_when_provided(self):
+ sb = make_sandbox()
+ seen = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["body"] = json.loads(request.content)
+ seen["authorization"] = request.headers.get("authorization")
+ return httpx.Response(200, json={
+ "entry": {"name": "new.txt", "type": "FILE_TYPE_FILE", "path":
"/tmp/new.txt"}
+ })
+
+ client = httpx.Client(transport=httpx.MockTransport(handler))
+ with patch.object(sb, "_build_data_client", return_value=client):
+ entry = sb.files.rename("/tmp/old.txt", "/tmp/new.txt",
user="nobody")
+
+ assert seen["body"] == {"source": "/tmp/old.txt", "destination":
"/tmp/new.txt"}
+ assert seen["authorization"] == "Basic bm9ib2R5Og=="
+ assert entry["name"] == "new.txt"
+
def test_make_dir_success(self):
sb = make_sandbox()
seen = {}
@@ -1793,6 +2087,25 @@
assert seen["body"] == {"path": "/tmp/newdir"}
assert entry["type"] == "FILE_TYPE_DIRECTORY"
+ def test_make_dir_includes_user_when_provided(self):
+ sb = make_sandbox()
+ seen = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["body"] = json.loads(request.content)
+ seen["authorization"] = request.headers.get("authorization")
+ return httpx.Response(200, json={
+ "entry": {"name": "newdir", "type": "FILE_TYPE_DIRECTORY",
"path": "/tmp/newdir"}
+ })
+
+ client = httpx.Client(transport=httpx.MockTransport(handler))
+ with patch.object(sb, "_build_data_client", return_value=client):
+ entry = sb.files.make_dir("/tmp/newdir", user="nobody")
+
+ assert seen["body"] == {"path": "/tmp/newdir"}
+ assert seen["authorization"] == "Basic bm9ib2R5Og=="
+ assert entry["type"] == "FILE_TYPE_DIRECTORY"
+
def test_write_files_success(self):
sb = make_sandbox()
paths = []