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 = []

Reply via email to