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-07-26 11:30:05
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-cubesandbox (Old)
 and      /work/SRC/openSUSE:Factory/.python-cubesandbox.new.2004 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-cubesandbox"

Sun Jul 26 11:30:05 2026 rev:3 rq:1367773 version:0.6.0

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-cubesandbox/python-cubesandbox.changes    
2026-07-21 23:10:17.946089645 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-cubesandbox.new.2004/python-cubesandbox.changes
  2026-07-26 11:32:41.139418217 +0200
@@ -1,0 +2,12 @@
+Sat Jul 25 19:02:50 UTC 2026 - Martin Pluskal <[email protected]>
+
+- Update to version 0.6.0 (SDK release accompanying the
+  CubeSandbox 0.6.0 platform):
+  * Add volume support (new cubesandbox._volume module) for the
+    E2B-compatible volume framework
+  * Send the X-API-Key header on all request paths
+- Deselect test_template_get_parses_network_fields: not updated
+  upstream for the new headers argument of template GET
+  (reported upstream)
+
+-------------------------------------------------------------------

Old:
----
  cubesandbox-0.5.0.tar.gz

New:
----
  cubesandbox-0.6.0.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-cubesandbox.spec ++++++
--- /var/tmp/diff_new_pack.Ibj7mI/_old  2026-07-26 11:32:41.643435582 +0200
+++ /var/tmp/diff_new_pack.Ibj7mI/_new  2026-07-26 11:32:41.647435720 +0200
@@ -17,7 +17,7 @@
 
 
 Name:           python-cubesandbox
-Version:        0.5.0
+Version:        0.6.0
 Release:        0
 Summary:        Python SDK for CubeSandbox
 License:        Apache-2.0
@@ -60,7 +60,9 @@
 
 %check
 # e2e tests need a live CubeSandbox control plane; the rest mock all HTTP
-%pytest -m "not e2e"
+# test_template_get_parses_network_fields was not updated upstream for the
+# 0.6.0 X-API-Key change (the client now passes headers={} on template GET)
+%pytest -m "not e2e" --deselect 
tests/test_sandbox.py::TestTemplateAPI::test_template_get_parses_network_fields
 
 %files %{python_files}
 %doc README.md

++++++ cubesandbox-0.5.0.tar.gz -> cubesandbox-0.6.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cubesandbox-0.5.0/PKG-INFO 
new/cubesandbox-0.6.0/PKG-INFO
--- old/cubesandbox-0.5.0/PKG-INFO      2026-07-08 13:15:24.938186200 +0200
+++ new/cubesandbox-0.6.0/PKG-INFO      2026-07-24 10:57:59.016925300 +0200
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: cubesandbox
-Version: 0.5.0
+Version: 0.6.0
 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.5.0/README.md 
new/cubesandbox-0.6.0/README.md
--- old/cubesandbox-0.5.0/README.md     2026-07-08 13:15:11.000000000 +0200
+++ new/cubesandbox-0.6.0/README.md     2026-07-24 10:43:03.000000000 +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.3.0-orange"; alt="v0.3.0" />
+  <img src="https://img.shields.io/badge/version-0.6.0-orange"; alt="v0.6.0" />
 </p>
 
 ---
@@ -113,6 +113,14 @@
 - **L3/L4** — `allow_out` / `deny_out` lists of CIDRs or hostnames.
 - **L7** — `rules` for host / path / SNI matching, audit, and credential
   injection. Use the typed `Rule` / `Match` / `Action` / `Inject` dataclasses.
+- **Inbound Host** — `mask_request_host` customizes the Host CubeProxy forwards
+  to user services; `${PORT}` expands to the requested sandbox port.
+
+```python
+sb = Sandbox.create(
+    network={"mask_request_host": "localhost:${PORT}"},
+)
+```
 
 ```python
 from cubesandbox import Sandbox, Rule, Match, Action, Inject
@@ -225,6 +233,44 @@
     print(result.text)
 ```
 
+### Persistent volumes
+
+Volumes are e2b-compatible persistent stores backed by a volume plugin
+(COS, NFS, …). Manage their lifecycle with the `Volume` helper, then mount
+them into a sandbox via `Sandbox.create(volume_mounts={...})` (e2b mapping). 
Data survives
+across sandbox restarts and can be shared between sandboxes.
+
+```python
+from cubesandbox import Sandbox, Volume
+
+# Create a volume — name is optional (server generates a UUID when omitted).
+# Omitting driver is e2b-compatible: NO driver is sent, so the backend uses its
+# first configured plugin. Pass a non-empty driver to pin a specific plugin.
+vol = Volume.create("my-data")                   # default plugin
+# vol = Volume.create("my-data", driver="cos")   # pin a plugin
+print(vol.volume_id, vol.token)
+
+# Mount it into a sandbox at a path
+with Sandbox.create(
+    volume_mounts={"/workspace": vol},
+) as sb:
+    sb.files.write("/workspace/note.txt", "persisted!")
+    print(sb.files.read("/workspace/note.txt"))
+
+# The value can be a Volume, a VolumeInfo, or a bare volume_id string.
+
+# List / get_info / connect / destroy
+for v in Volume.list():                 # list[VolumeInfo] (token always "")
+    print(v.volume_id, v.name)
+Volume.get_info(vol.volume_id)          # -> VolumeInfo (with token)
+vol = Volume.connect(vol.volume_id)     # -> live Volume instance
+Volume.destroy(vol.volume_id)           # -> bool; kill any mounting sandbox 
first (no auto-detach)
+```
+
+Volume `name` must match `^[a-zA-Z0-9_-]+$` and be at most 128 characters;
+invalid names raise `ValueError` before any network call. See
+[`docs/volume.md`](docs/volume.md) for the full API, parameters and error 
codes.
+
 ### List & health check
 
 ```python
@@ -242,6 +288,7 @@
 | `CUBE_API_URL` | ✅ | `http://127.0.0.1:3000` | CubeAPI management plane 
address |
 | `CUBE_TEMPLATE_ID` | ✅ | — | Template ID for sandbox creation |
 | `CUBE_PROXY_NODE_IP` | remote | — | CubeProxy node IP, bypasses DNS for 
`*.cube.app` |
+| `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 |
 
@@ -252,6 +299,7 @@
 
 cfg = Config(
     api_url="http://10.0.0.1:3000";,
+    api_key="my-secret-key",
     template_id="tpl-xxxxxxxxxxxxxxxxxxxxxxxx",
     proxy_node_ip="10.0.0.1",
 )
@@ -265,7 +313,7 @@
 
 | Method | Description |
 |---|---|
-| `Sandbox.create(template, *, timeout, env_vars, metadata, config)` | `POST 
/sandboxes` — create a new sandbox |
+| `Sandbox.create(template, *, timeout, env_vars, metadata, volume_mounts, 
config)` | `POST /sandboxes` — create a new sandbox (optionally mounting 
volumes) |
 | `Sandbox.connect(sandbox_id, *, config)` | `POST /sandboxes/:id/connect` — 
connect (auto-resumes if paused) |
 | `Sandbox.list(config)` | `GET /sandboxes` — list running sandboxes (v1) |
 | `Sandbox.list_v2(config)` | `GET /v2/sandboxes` — list sandboxes (v2) |
@@ -279,6 +327,7 @@
 | `sb.get_info()` | `GET /sandboxes/:id` — get sandbox state and metadata |
 | `sb.pause(*, wait, timeout, interval)` | `POST /sandboxes/:id/pause` — pause 
sandbox |
 | `sb.resume(timeout)` | `POST /sandboxes/:id/resume` — resume (deprecated, 
use `connect`) |
+| `sb.set_timeout(timeout)` | `POST /sandboxes/:id/timeout` — set sandbox idle 
timeout |
 | `sb.kill()` | `DELETE /sandboxes/:id` — destroy sandbox |
 | `sb.get_host(port)` | Return virtual hostname `{port}-{id}.{domain}` |
 
@@ -297,6 +346,21 @@
 | `sb.files.remove(path)` | Delete file or directory |
 | `sb.files.watch_dir(path)` | Stream filesystem events → `Watcher` (context 
manager + iterator) |
 
+### `Volume` — persistent volumes (class methods)
+
+| Method | Description |
+|---|---|
+| `Volume.create(name=None, *, driver=None, config=None)` | `POST /volumes` — 
create a volume; omit `driver` for e2b-compatible behavior (backend's first 
plugin), or pass a non-empty `driver` (e.g. `"cos"`) to pin a plugin → `Volume` 
|
+| `Volume.connect(volume_id, *, config)` | `GET /volumes/:id` — connect to an 
existing volume → `Volume` |
+| `Volume.list(config)` | `GET /volumes` — list volumes → `list[VolumeInfo]` 
(no token) |
+| `Volume.get_info(volume_id, *, config)` | `GET /volumes/:id` — get one 
volume's info (with token) → `VolumeInfo` |
+| `Volume.destroy(volume_id, *, config)` | `DELETE /volumes/:id` — delete a 
volume → `bool` |
+
+Mount a volume into a sandbox with `Sandbox.create(volume_mounts={path: vol})`.
+`Volume.create` / `connect` return a live `Volume` instance; `list` / 
`get_info`
+return `VolumeInfo`. Both expose `.volume_id`, `.name`, `.token`. Full 
reference:
+[`docs/volume.md`](docs/volume.md).
+
 ### `Execution` object
 
 | Attribute | Type | Description |
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cubesandbox-0.5.0/cubesandbox/__init__.py 
new/cubesandbox-0.6.0/cubesandbox/__init__.py
--- old/cubesandbox-0.5.0/cubesandbox/__init__.py       2026-07-08 
13:15:11.000000000 +0200
+++ new/cubesandbox-0.6.0/cubesandbox/__init__.py       2026-07-24 
10:43:03.000000000 +0200
@@ -4,10 +4,11 @@
 from .sandbox import Sandbox, NEVER_TIMEOUT
 from ._config import Config
 from ._models import Execution, Result, Logs, ExecutionError, OutputMessage, 
SnapshotInfo
-from ._exceptions import CubeSandboxError, SandboxNotFoundError, ApiError, 
TemplateNotFoundError, FilesystemNotFoundError, PartialWriteError
+from ._exceptions import CubeSandboxError, SandboxNotFoundError, ApiError, 
TemplateNotFoundError, VolumeNotFoundError, FilesystemNotFoundError, 
PartialWriteError
 from ._commands import CommandResult
 from ._pty import Pty, PtyHandle, PtyOutput, PtySize
 from ._template import Template, TemplateInfo, TemplateBuild
+from ._volume import Volume, VolumeInfo
 from ._policy import Rule, Match, Action, Inject
 
 __all__ = [
@@ -23,6 +24,7 @@
     "CubeSandboxError",
     "SandboxNotFoundError",
     "TemplateNotFoundError",
+    "VolumeNotFoundError",
     "ApiError",
     "FilesystemNotFoundError",
     "PartialWriteError",
@@ -34,10 +36,12 @@
     "Template",
     "TemplateInfo",
     "TemplateBuild",
+    "Volume",
+    "VolumeInfo",
     "Rule",
     "Match",
     "Action",
     "Inject",
 ]
 
-__version__ = "0.5.0"
+__version__ = "0.6.0"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cubesandbox-0.5.0/cubesandbox/_commands.py 
new/cubesandbox-0.6.0/cubesandbox/_commands.py
--- old/cubesandbox-0.5.0/cubesandbox/_commands.py      2026-07-08 
13:15:11.000000000 +0200
+++ new/cubesandbox-0.6.0/cubesandbox/_commands.py      2026-07-24 
10:43:03.000000000 +0200
@@ -10,6 +10,8 @@
 from dataclasses import dataclass
 from typing import TYPE_CHECKING
 
+from ._config import _auth_headers
+
 if TYPE_CHECKING:
     from .sandbox import Sandbox
 
@@ -167,7 +169,10 @@
 
 
 def _envd_rpc_base_url_and_headers(sandbox: "Sandbox") -> tuple[str, dict[str, 
str]]:
-    headers: 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
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cubesandbox-0.5.0/cubesandbox/_config.py 
new/cubesandbox-0.6.0/cubesandbox/_config.py
--- old/cubesandbox-0.5.0/cubesandbox/_config.py        2026-07-08 
13:15:11.000000000 +0200
+++ new/cubesandbox-0.6.0/cubesandbox/_config.py        2026-07-24 
10:43:03.000000000 +0200
@@ -4,7 +4,9 @@
 from __future__ import annotations
 
 import os
+import warnings
 from dataclasses import dataclass, field
+from urllib.parse import urlsplit
 
 
 @dataclass
@@ -14,6 +16,10 @@
     api_url: str = field(
         default_factory=lambda: os.environ.get("CUBE_API_URL", 
"http://127.0.0.1:3000";)
     )
+    api_key: str | None = field(
+        default_factory=lambda: os.environ.get("CUBE_API_KEY"),
+        repr=False,
+    )
     template_id: str | None = field(
         default_factory=lambda: os.environ.get("CUBE_TEMPLATE_ID")
     )
@@ -30,4 +36,59 @@
     request_timeout: float = 30.0
 
     def __post_init__(self) -> None:
-        self.api_url = self.api_url.rstrip("/")
\ No newline at end of file
+        self.api_url = self.api_url.rstrip("/")
+        self._warn_if_api_key_sent_in_cleartext()
+
+    def _warn_if_api_key_sent_in_cleartext(self) -> None:
+        """Warn when an API key would be sent over plain HTTP to a remote host.
+
+        Sending ``X-API-Key`` over ``http://`` to a non-loopback address 
exposes
+        the key to anyone on the network path. Local development against
+        ``http://127.0.0.1`` is the common case and stays silent; only a remote
+        cleartext endpoint triggers the warning (we warn rather than raise so 
the
+        SDK keeps working against a plain-HTTP backend when the caller accepts
+        the risk).
+        """
+        if not self.api_key or not self.api_url.startswith("http://";):
+            return
+        host = urlsplit(self.api_url).hostname or ""
+        is_loopback = (
+            host in ("localhost", "127.0.0.1", "::1") or 
host.startswith("127.")
+        )
+        if not is_loopback:
+            warnings.warn(
+                f"CUBE_API_KEY is being sent over plain HTTP to a non-loopback 
"
+                f"host ({host!r}); use an https:// CUBE_API_URL to avoid "
+                f"transmitting the API key in cleartext.",
+                stacklevel=3,
+            )
+
+    def __repr__(self) -> str:
+        # Mask api_key so it never leaks into logs / REPL / exception text.
+        masked = "***" if self.api_key else None
+        return (
+            f"Config(api_url={self.api_url!r}, api_key={masked!r}, "
+            f"template_id={self.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})"
+        )
+
+
+def _auth_headers(cfg: "Config") -> dict[str, str]:
+    """Return the ``X-API-Key`` header when an API key is configured.
+
+    CubeAPI only enforces auth when it is started with an auth-callback URL;
+    in the default deployment no callback is set and every request passes
+    through unauthenticated. So the key is optional here: when
+    ``CUBE_API_KEY`` / ``Config.api_key`` is unset we send no
+    auth header and behavior is unchanged; when set we attach
+    ``X-API-Key: <key>`` so the SDK also works against an auth-enabled backend.
+
+    This is the single source of truth shared by the control plane
+    (``sandbox``/``template`` REST calls to CubeAPI) and the data plane
+    (envd requests routed through CubeProxy).
+    """
+    if cfg.api_key:
+        return {"X-API-Key": cfg.api_key}
+    return {}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cubesandbox-0.5.0/cubesandbox/_exceptions.py 
new/cubesandbox-0.6.0/cubesandbox/_exceptions.py
--- old/cubesandbox-0.5.0/cubesandbox/_exceptions.py    2026-07-08 
13:15:11.000000000 +0200
+++ new/cubesandbox-0.6.0/cubesandbox/_exceptions.py    2026-07-24 
10:43:03.000000000 +0200
@@ -12,6 +12,7 @@
 
 class SandboxNotFoundError(CubeSandboxError): ...
 class TemplateNotFoundError(CubeSandboxError): ...
+class VolumeNotFoundError(CubeSandboxError): ...
 class AuthenticationError(CubeSandboxError): ...
 class ApiError(CubeSandboxError): ...
 class FilesystemNotFoundError(CubeSandboxError): ...
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cubesandbox-0.5.0/cubesandbox/_template.py 
new/cubesandbox-0.6.0/cubesandbox/_template.py
--- old/cubesandbox-0.5.0/cubesandbox/_template.py      2026-07-08 
13:15:11.000000000 +0200
+++ new/cubesandbox-0.6.0/cubesandbox/_template.py      2026-07-24 
10:43:03.000000000 +0200
@@ -8,7 +8,7 @@
 
 import requests
 
-from ._config import Config
+from ._config import Config, _auth_headers
 from ._exceptions import ApiError, AuthenticationError, TemplateNotFoundError
 from ._policy import _validate_allow_out_domains_require_deny_all
 
@@ -160,7 +160,7 @@
         """
         cfg = config or Config()
         s = requests.Session()
-        resp = s.get(f"{cfg.api_url}/templates")
+        resp = s.get(f"{cfg.api_url}/templates", headers=_auth_headers(cfg))
         _check_response(resp)
         data = resp.json() or []
         if isinstance(data, dict):
@@ -200,7 +200,8 @@
         if next_token is not None:
             params["nextToken"] = next_token
         s = requests.Session()
-        resp = s.get(f"{cfg.api_url}/templates/{template_id}", params=params)
+        resp = s.get(f"{cfg.api_url}/templates/{template_id}", params=params,
+                     headers=_auth_headers(cfg))
         _check_response(resp)
         return TemplateInfo.from_dict(resp.json())
 
@@ -210,7 +211,7 @@
         cls,
         *,
         template_id: str | None = None,  # Deprecated: server always 
auto-generates template IDs with "tpl-" prefix.
-        name: str | None = None,  # Deprecated alias for template_id.
+        name: str | None = None,  # E2B template name → forwarded as stable 
alias.
         image: str | None = None,
         dockerfile: str | None = None,
         start_cmd: str | None = None,
@@ -232,6 +233,7 @@
         dns: list[str] | None = None,
         allow_out: list[str] | None = None,
         deny_out: list[str] | None = None,
+        enable_ivshmem: bool | None = None,
         config: Config | None = None,
         **kwargs: Any,
     ) -> TemplateBuild:
@@ -245,7 +247,9 @@
             template_id: Template ID. Deprecated: the server always 
auto-generates template IDs
                 with the "tpl-" prefix. This parameter is accepted for 
backward compatibility
                 but its value is ignored.
-            name: Deprecated alias for ``template_id``.
+            name: E2B-compatible template name. Forwarded as ``"name"`` in the 
request body;
+                CubeAPI derives a stable alias from it so sandboxes can 
reference the template
+                by this name instead of the auto-generated ``tpl-*`` ID.
             image: Base container image URI (e.g. ``"python:3.11-slim"``).
             dockerfile: Not supported by CubeAPI's current template endpoint.
             start_cmd: Not supported by CubeAPI's current template endpoint.
@@ -267,6 +271,7 @@
             dns: Container DNS nameservers.
             allow_out: Allowed outbound CIDRs for CubeVS egress policy.
             deny_out: Denied outbound CIDRs for CubeVS egress policy.
+            enable_ivshmem: Whether the template build sandbox should boot 
with ivshmem enabled.
             config: SDK config.  Uses default (env-based) config if omitted.
             **kwargs: Extra fields forwarded verbatim to the request body.
 
@@ -291,6 +296,8 @@
 
         cfg = config or Config()
         payload: dict = {"image": image.strip()}
+        if name is not None:
+            payload["name"] = name
         if instance_type is not None:
             payload["instanceType"] = instance_type
         if writable_layer_size is not None:
@@ -327,13 +334,15 @@
             payload["allowOut"] = allow_out
         if deny_out is not None:
             payload["denyOut"] = deny_out
+        if enable_ivshmem is not None:
+            payload["enableIvshmem"] = enable_ivshmem
         payload.update(kwargs)
 
         s = requests.Session()
         resp = s.post(
             f"{cfg.api_url}/templates",
             json=payload,
-            headers={"Content-Type": "application/json"},
+            headers={"Content-Type": "application/json", **_auth_headers(cfg)},
         )
         _check_response(resp)
         return TemplateBuild.from_dict(resp.json())
@@ -353,7 +362,7 @@
         resp = s.post(
             f"{cfg.api_url}/templates/{template_id}",
             json=extra,
-            headers={"Content-Type": "application/json"},
+            headers={"Content-Type": "application/json", **_auth_headers(cfg)},
         )
         _check_response(resp)
         return TemplateBuild.from_dict(resp.json())
@@ -370,7 +379,8 @@
         """GET /templates/:templateID/builds/:buildID/status."""
         cfg = config or Config()
         s = requests.Session()
-        resp = 
s.get(f"{cfg.api_url}/templates/{template_id}/builds/{build_id}/status")
+        resp = 
s.get(f"{cfg.api_url}/templates/{template_id}/builds/{build_id}/status",
+                     headers=_auth_headers(cfg))
         _check_response(resp)
         return TemplateBuild.from_dict(resp.json())
 
@@ -386,7 +396,8 @@
         """GET /templates/:templateID/builds/:buildID/logs."""
         cfg = config or Config()
         s = requests.Session()
-        resp = 
s.get(f"{cfg.api_url}/templates/{template_id}/builds/{build_id}/logs")
+        resp = 
s.get(f"{cfg.api_url}/templates/{template_id}/builds/{build_id}/logs",
+                     headers=_auth_headers(cfg))
         _check_response(resp)
         return resp.json()
 
@@ -435,5 +446,5 @@
         """
         cfg = config or Config()
         s = requests.Session()
-        resp = s.delete(f"{cfg.api_url}/templates/{template_id}")
+        resp = s.delete(f"{cfg.api_url}/templates/{template_id}", 
headers=_auth_headers(cfg))
         _check_response(resp)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cubesandbox-0.5.0/cubesandbox/_volume.py 
new/cubesandbox-0.6.0/cubesandbox/_volume.py
--- old/cubesandbox-0.5.0/cubesandbox/_volume.py        1970-01-01 
01:00:00.000000000 +0100
+++ new/cubesandbox-0.6.0/cubesandbox/_volume.py        2026-07-24 
10:43:03.000000000 +0200
@@ -0,0 +1,475 @@
+# Copyright (c) 2026 Tencent Inc.
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import re
+import threading
+from dataclasses import dataclass
+from typing import Dict, Union
+
+import requests
+
+from ._config import Config
+from ._exceptions import ApiError, AuthenticationError, VolumeNotFoundError
+
+# Mirrors CubeAPI's `MAX_VOLUME_NAME_LEN` and the `^[a-zA-Z0-9_-]+$` rule
+# enforced in `CubeAPI/src/models/mod.rs` / `handlers/volumes.rs`. Validating
+# client-side turns an opaque HTTP 400 into a clean ValueError at the call 
site.
+MAX_VOLUME_NAME_LEN = 128
+_VOLUME_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
+
+# A thread-local session reused across the Volume control-plane classmethods so
+# HTTP keep-alive / connection pooling kicks in — otherwise every call paid for
+# a fresh TCP (plus TLS) handshake. We keep one session per thread (mirroring
+# the e2b SDK) rather than a single global one: requests.Session is not
+# guaranteed thread-safe for concurrent use, so thread-local isolation gives 
the
+# pooling benefit without sharing a Session across threads.
+_thread_local = threading.local()
+
+
+def _shared_session() -> requests.Session:
+    """Return this thread's :class:`requests.Session` for volume calls."""
+    s = getattr(_thread_local, "session", None)
+    if s is None:
+        s = requests.Session()
+        _thread_local.session = s
+    return s
+
+
+def _check_response(resp: requests.Response) -> None:
+    if resp.ok:
+        return
+    try:
+        body = resp.json()
+        msg = (
+            (body.get("message") or body.get("detail") or resp.text)
+            if isinstance(body, dict)
+            else resp.text
+        )
+    except (ValueError, requests.JSONDecodeError):
+        msg = resp.text or f"HTTP {resp.status_code}"
+    code = resp.status_code
+    if code in (401, 403):
+        raise AuthenticationError(msg, code)
+    if code == 404:
+        raise VolumeNotFoundError(msg, code)
+    raise ApiError(msg, code)
+
+
+def _auth_headers(cfg: Config) -> dict[str, str]:
+    """Return the ``X-API-Key`` header when an API key is configured.
+
+    CubeAPI only enforces auth when it is started with an auth-callback URL;
+    in the default deployment no callback is set and every request passes
+    through unauthenticated. So the key is optional here: when
+    ``CUBE_API_KEY`` / ``Config.api_key`` is unset we send no auth header and
+    behavior is unchanged; when set we attach ``X-API-Key: <key>`` so the SDK
+    also works against an auth-enabled backend.
+    """
+    if cfg.api_key:
+        return {"X-API-Key": cfg.api_key}
+    return {}
+
+
+def _validate_name(name: str) -> None:
+    """Raise ValueError if a non-empty ``name`` violates CubeAPI's constraints.
+
+    An empty ``name`` is allowed — CubeMaster then generates a UUID and uses it
+    as both the volume name and volume ID.
+    """
+    if not name:
+        return
+    if len(name) > MAX_VOLUME_NAME_LEN:
+        raise ValueError(
+            f"volume name must be at most {MAX_VOLUME_NAME_LEN} characters, 
got {len(name)}"
+        )
+    if not _VOLUME_NAME_RE.match(name):
+        raise ValueError(
+            "volume name must match ^[a-zA-Z0-9_-]+$ "
+            f"(letters, digits, '_' and '-'), got {name!r}"
+        )
+
+
+def _validate_volume_id(volume_id: str) -> str:
+    """Raise ``ValueError`` if ``volume_id`` is unsafe to embed in a URL path.
+
+    Defense-in-depth against path traversal: a malicious ``volume_id`` such as
+    ``../other`` or ``..%2f`` must not be interpolated into the request URL.
+    We accept the same character class as volume names (letters, digits, ``_``,
+    ``-``) — this covers both named volumes and auto-generated UUIDs, and
+    rejects any ``/`` or segment-breaking character.
+    """
+    if not isinstance(volume_id, str) or not volume_id:
+        raise ValueError(f"volume_id must be a non-empty string, got 
{volume_id!r}")
+    if not _VOLUME_NAME_RE.match(volume_id):
+        raise ValueError(
+            "volume_id must match ^[a-zA-Z0-9_-]+$ "
+            f"(letters, digits, '_' and '-'), got {volume_id!r}"
+        )
+    return volume_id
+
+
+@dataclass
+class VolumeInfo:
+    """A CubeSandbox persistent volume descriptor.
+
+    Attributes:
+        volume_id: Stable identifier (equals ``name`` or an auto-generated 
UUID).
+        name: Human-readable display name.
+        token: Optional auth token returned by the volume plugin. Empty string
+            when the plugin does not issue one, and always empty for entries
+            returned by :meth:`Volume.list` (tokens are only surfaced on
+            create / get-single).
+    """
+
+    volume_id: str
+    name: str
+    token: str = ""
+
+    @classmethod
+    def from_dict(cls, data: dict) -> "VolumeInfo":
+        return cls(
+            volume_id=data.get("volumeID") or data.get("volume_id", ""),
+            name=data.get("name", ""),
+            token=data.get("token", "") or "",
+        )
+
+    def __repr__(self) -> str:
+        # Mask the token so it never leaks into logs / REPL / exception text.
+        return (
+            f"VolumeInfo(volume_id={self.volume_id!r}, name={self.name!r}, "
+            f"token={'***' if self.token else ''!r})"
+        )
+
+
+# ``Sandbox.create(volume_mounts=...)`` argument — e2b-compatible mapping:
+# ``{mount_path: Volume | VolumeInfo | volume_id_str}``.
+VolumeMountsArg = "Dict[str, Union[Volume, VolumeInfo, str]]"
+
+
+def _resolve_volume_id(vol: "Volume | VolumeInfo | str") -> str:
+    """Resolve the backend ``volumeID`` for a mapping-form volume value.
+
+    Accepts a live :class:`Volume`, a :class:`VolumeInfo`, or a plain
+    ``volumeID`` string. e2b keys mounts by ``volume.name``; because our
+    backend requires ``volumeMounts[].name`` to be an existing ``volumeID``
+    (and ``volume_id == name`` for named volumes) we resolve to ``volume_id``.
+    """
+    if isinstance(vol, (Volume, VolumeInfo)):
+        return vol.volume_id
+    if isinstance(vol, str):
+        return vol
+    raise ValueError(
+        "volume_mounts mapping value must be a Volume, VolumeInfo or volume-id 
"
+        f"string, got {type(vol).__name__}"
+    )
+
+
+def _validate_mount_path(path: str) -> str:
+    """Validate a mount path before forwarding it to the backend.
+
+    Defense-in-depth against path traversal: the path comes from user-supplied
+    ``volume_mounts`` dict keys, so we require a clean absolute POSIX path and
+    reject empty values, relative paths and any ``.``/``..`` segment. The
+    backend is expected to validate as well; this turns a malicious/typo'd path
+    into a clean ``ValueError`` at the call site.
+    """
+    if not isinstance(path, str) or not path:
+        raise ValueError(f"volume mount path must be a non-empty string, got 
{path!r}")
+    if not path.startswith("/"):
+        raise ValueError(f"volume mount path must be absolute (start with 
'/'), got {path!r}")
+    segments = [seg for seg in path.split("/") if seg]
+    if any(seg in (".", "..") for seg in segments):
+        raise ValueError(f"volume mount path must not contain '.' or '..' 
segments, got {path!r}")
+    return path
+
+
+def _serialize_volume_mounts(mounts: VolumeMountsArg) -> list[dict[str, str]]:
+    """Serialize the e2b-style ``{path: volume}`` mapping into the wire 
format."""
+    return [
+        {"name": _resolve_volume_id(vol), "path": 
_validate_mount_path(str(path))}
+        for path, vol in mounts.items()
+    ]
+
+
+class Volume:
+    """Class-level helper for CubeSandbox persistent-volume management.
+
+    e2b-compatible: methods mirror the e2b SDK. Following e2b, ``create`` and
+    ``connect`` return a live :class:`Volume` **instance** (carrying
+    ``volume_id`` / ``name`` / ``token``), while ``list`` / ``get_info`` return
+    plain :class:`VolumeInfo` data and ``destroy`` returns ``bool``. Volumes 
are
+    e2b-compatible persistent stores backed by a volume plugin (COS, NFS, ...);
+    create one here, then mount it into a sandbox via
+    ``Sandbox.create(volume_mounts={...})``.
+
+    Example::
+
+        # Create a volume (name auto-generated when omitted) -> Volume instance
+        vol = Volume.create("my-data")
+        print(vol.volume_id, vol.token)
+
+        # Mount it into a sandbox
+        from cubesandbox import Sandbox
+        with Sandbox.create(
+            volume_mounts={"/workspace": vol},
+        ) as sb:
+            sb.files.write("/workspace/note.txt", "persisted!")
+
+        # List / get_info / connect / destroy
+        for v in Volume.list():           # list[VolumeInfo]
+            print(v.volume_id, v.name)
+        Volume.get_info(vol.volume_id)    # VolumeInfo
+        vol = Volume.connect("my-data")   # Volume instance (== e2b)
+        Volume.destroy(vol.volume_id)     # bool
+    """
+
+    def __init__(
+        self,
+        volume_id: str,
+        name: str,
+        token: str = "",
+        *,
+        config: Config | None = None,
+    ) -> None:
+        """Construct a volume handle. Prefer :meth:`create` / :meth:`connect`.
+
+        Args:
+            volume_id: Stable identifier.
+            name: Human-readable display name.
+            token: Optional plugin-issued auth token (empty string when none).
+            config: SDK config bound to this handle (used by instance helpers).
+        """
+        self._volume_id = volume_id
+        self._name = name
+        self._token = token or ""
+        self._config = config or Config()
+
+    @property
+    def volume_id(self) -> str:
+        """Stable identifier (equals ``name`` or an auto-generated UUID)."""
+        return self._volume_id
+
+    @property
+    def name(self) -> str:
+        """Human-readable display name."""
+        return self._name
+
+    @property
+    def token(self) -> str:
+        """Plugin-issued auth token; empty string when the plugin issues 
none."""
+        return self._token
+
+    def __repr__(self) -> str:
+        return (
+            f"Volume(volume_id={self._volume_id!r}, name={self._name!r}, "
+            f"token={'***' if self._token else ''!r})"
+        )
+
+    def __eq__(self, other: object) -> bool:
+        if isinstance(other, Volume):
+            return (
+                self._volume_id == other._volume_id
+                and self._name == other._name
+                and self._token == other._token
+            )
+        # Allow comparison against VolumeInfo for ergonomic parity.
+        if isinstance(other, VolumeInfo):
+            return (
+                self._volume_id == other.volume_id
+                and self._name == other.name
+                and self._token == other.token
+            )
+        return NotImplemented
+
+    def __hash__(self) -> int:
+        return hash((self._volume_id, self._name, self._token))
+
+    @classmethod
+    def _from_info(cls, info: VolumeInfo, config: Config) -> "Volume":
+        return cls(info.volume_id, info.name, info.token, config=config)
+
+    @classmethod
+    def create(
+        cls,
+        name: str | None = None,
+        *,
+        driver: str | None = None,
+        config: Config | None = None,
+    ) -> "Volume":
+        """POST /volumes — Create a new persistent volume.
+
+        e2b-compatible by default: when ``driver`` is omitted (``None`` or
+        ``""``), **no driver is sent**, so the backend falls back to the
+        *first configured* volume plugin. Pass a non-empty ``driver`` to pin a
+        specific plugin (a CubeSandbox extension), e.g.::
+
+            Volume.create("my-data")                   # backend's first plugin
+            Volume.create("my-data", driver="cos")     # pin the "cos" plugin
+
+        Args:
+            name: Volume name. Must match ``^[a-zA-Z0-9_-]+$`` and be at most
+                128 characters. Optional — when omitted (``None`` / ``""``)
+                the server generates a UUID and uses it as both the volume name
+                and volume ID.
+            driver: Optional plugin/driver name (matches 
``volume_plugins[].name``
+                in CubeMaster config, e.g. ``"cos"`` or ``"nfs"``). When falsy
+                (``None`` / ``""``) the field is not sent and the backend picks
+                its first configured plugin.
+            config: SDK config. Uses default (env-based) config if omitted.
+
+        Returns:
+            A live :class:`Volume` instance with ``volume_id``, ``name`` and
+            ``token`` populated from the backend response.
+
+        Raises:
+            ValueError: If ``name`` is provided but violates the naming rules.
+            ApiError: On unexpected backend error (HTTP 400/500).
+        """
+        _validate_name(name or "")
+        cfg = config or Config()
+        payload: dict = {"name": name or ""}
+        if driver:
+            payload["driver"] = driver
+        s = _shared_session()
+        resp = s.post(
+            f"{cfg.api_url}/volumes",
+            json=payload,
+            headers={"Content-Type": "application/json", **_auth_headers(cfg)},
+            timeout=cfg.request_timeout,
+        )
+        _check_response(resp)
+        return cls._from_info(VolumeInfo.from_dict(resp.json()), cfg)
+
+    @classmethod
+    def list(cls, *, config: Config | None = None) -> list[VolumeInfo]:
+        """GET /volumes — List all volumes.
+
+        The returned entries never carry a ``token`` (it is only surfaced on
+        create / get-single); :attr:`VolumeInfo.token` is an empty string here.
+
+        Args:
+            config: SDK config. Uses default (env-based) config if omitted.
+
+        Returns:
+            A list of :class:`VolumeInfo` objects.
+
+        Raises:
+            ApiError: On unexpected backend error.
+        """
+        cfg = config or Config()
+        s = _shared_session()
+        resp = s.get(
+            f"{cfg.api_url}/volumes",
+            headers=_auth_headers(cfg),
+            timeout=cfg.request_timeout,
+        )
+        _check_response(resp)
+        data = resp.json() or []
+        if isinstance(data, dict):
+            data = data.get("volumes") or data.get("items") or []
+        return [VolumeInfo.from_dict(d) for d in data]
+
+    @classmethod
+    def get_info(
+        cls, volume_id: str, *, config: Config | None = None
+    ) -> VolumeInfo:
+        """GET /volumes/{volumeID} — Fetch a single volume, including its 
token.
+
+        Args:
+            volume_id: Volume identifier.
+            config: SDK config. Uses default (env-based) config if omitted.
+
+        Returns:
+            :class:`VolumeInfo` with ``token`` populated when the plugin 
issues one.
+
+        Raises:
+            VolumeNotFoundError: If the volume does not exist (HTTP 404).
+            ApiError: On unexpected backend error.
+        """
+        cfg = config or Config()
+        _validate_volume_id(volume_id)
+        s = _shared_session()
+        resp = s.get(
+            f"{cfg.api_url}/volumes/{volume_id}",
+            headers=_auth_headers(cfg),
+            timeout=cfg.request_timeout,
+        )
+        _check_response(resp)
+        return VolumeInfo.from_dict(resp.json())
+
+    @classmethod
+    def connect(
+        cls,
+        volume_id: str,
+        *,
+        config: Config | None = None,
+    ) -> "Volume":
+        """GET /volumes/{volumeID} — Connect to an existing volume.
+
+        e2b-compatible: fetches the volume via ``get_info`` (``GET
+        /volumes/{volumeID}``) and returns a live :class:`Volume` **instance**
+        bound to the given config — mirroring e2b's ``Volume.connect``::
+
+            vol = Volume.connect("vol-123")
+            print(vol.token)
+            Sandbox.create(volume_mounts={"/workspace": vol})
+
+        Args:
+            volume_id: Volume identifier.
+            config: SDK config. Uses default (env-based) config if omitted.
+
+        Returns:
+            A live :class:`Volume` instance with ``token`` populated when the
+            plugin issues one.
+
+        Raises:
+            VolumeNotFoundError: If the volume does not exist (HTTP 404).
+            ApiError: On unexpected backend error.
+        """
+        cfg = config or Config()
+        info = cls.get_info(volume_id, config=cfg)
+        return cls._from_info(info, cfg)
+
+    @classmethod
+    def destroy(
+        cls,
+        volume_id: str,
+        *,
+        config: Config | None = None,
+    ) -> bool:
+        """DELETE /volumes/{volumeID} — Permanently delete a volume.
+
+        e2b-compatible: returns ``True`` on success, ``False`` when the volume
+        does not exist (treated as idempotent — "already gone").
+
+        .. note::
+            Deleting a volume does **not** auto-detach it from running
+            sandboxes. Destroy any sandbox that mounts the volume first,
+            otherwise the backend mount may leak.
+
+        Args:
+            volume_id: Volume identifier.
+            config: SDK config. Uses default (env-based) config if omitted.
+
+        Returns:
+            ``True`` on successful deletion.
+
+        Raises:
+            ApiError: On unexpected backend error (non-404).
+        """
+        cfg = config or Config()
+        _validate_volume_id(volume_id)
+        s = _shared_session()
+        resp = s.delete(
+            f"{cfg.api_url}/volumes/{volume_id}",
+            headers=_auth_headers(cfg),
+            timeout=cfg.request_timeout,
+        )
+        if resp.status_code == 404:
+            return False
+        _check_response(resp)
+        return True
+
+
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cubesandbox-0.5.0/cubesandbox/sandbox.py 
new/cubesandbox-0.6.0/cubesandbox/sandbox.py
--- old/cubesandbox-0.5.0/cubesandbox/sandbox.py        2026-07-08 
13:15:11.000000000 +0200
+++ new/cubesandbox-0.6.0/cubesandbox/sandbox.py        2026-07-24 
10:43:03.000000000 +0200
@@ -9,7 +9,7 @@
 import requests
 
 from ._commands import CommandResult, Commands
-from ._config import Config
+from ._config import Config, _auth_headers
 from ._exceptions import ApiError, AuthenticationError, CubeSandboxError, 
SandboxNotFoundError, TemplateNotFoundError
 from ._filesystem import Filesystem
 from ._models import Execution, ExecutionError, OutputMessage, Result, 
SnapshotInfo
@@ -22,6 +22,7 @@
 from ._pty import Pty
 from ._stream import _parse_line
 from ._transport import build_client
+from ._volume import VolumeMountsArg, _serialize_volume_mounts
 
 JUPYTER_PORT = 49999
 
@@ -33,8 +34,13 @@
     if resp.ok:
         return
     try:
-        msg = resp.json().get("message") or resp.json().get("detail") or 
resp.text
-    except Exception:
+        body = resp.json()
+        msg = (
+            (body.get("message") or body.get("detail") or resp.text)
+            if isinstance(body, dict)
+            else resp.text
+        )
+    except (ValueError, requests.JSONDecodeError):
         msg = resp.text or f"HTTP {resp.status_code}"
     code = resp.status_code
     if code in (401, 403):
@@ -152,6 +158,7 @@
         allow_internet_access: bool = True,
         network: Dict[str, Any] | None = None,
         lifecycle: Dict[str, Any] | None = None,
+        volume_mounts: VolumeMountsArg | None = None,
         config: Config | None = None,
         **kwargs: Any,
     ) -> "Sandbox":
@@ -167,6 +174,8 @@
                 making outbound traffic to the public internet.
             network: Egress network policy. Accepts keys:
                 - ``allow_out`` / ``deny_out``: lists of CIDRs or hostnames 
(L3/L4).
+                - ``mask_request_host``: Host authority forwarded to user 
services;
+                  ``${PORT}`` expands to the requested sandbox port.
                 - ``rules``: list of :class:`~cubesandbox.Rule` dataclasses (or
                   equivalent dicts with snake_case keys) for L7 host/path/SNI
                   matching, audit, and credential injection. For E2B parity,
@@ -189,6 +198,16 @@
 
                 Absent ``lifecycle`` keeps today's behaviour (idle sandboxes
                 are killed).
+            volume_mounts: Optional dict mapping mount paths to volumes
+                (e2b-compatible). Key is the sandbox mount path, value is a
+                :class:`~cubesandbox.Volume` instance (or a plain ``volumeID``
+                string)::
+
+                    Sandbox.create(volume_mounts={"/workspace": vol})
+                    Sandbox.create(volume_mounts={"/workspace": "vol-123"})
+
+                Each value must resolve to an existing ``volumeID`` created via
+                :meth:`cubesandbox.Volume.create`.
             config: SDK config. Uses default (env-based) config if omitted.
 
         Returns:
@@ -227,6 +246,8 @@
                 net["denyOut"] = network["deny_out"]
             if "allow_public_traffic" in network:
                 net["allowPublicTraffic"] = network["allow_public_traffic"]
+            if "mask_request_host" in network:
+                net["maskRequestHost"] = network["mask_request_host"]
             if "rules" in network and network["rules"]:
                 # ``rules`` accepts either CubeEgress's list-of-Rule shape or
                 # E2B's per-host transform mapping (``{host: [{transform: 
{...}}]}``).
@@ -242,11 +263,13 @@
         # camelCase keys. Absent => server-side default ("kill" on timeout).
         if lifecycle:
             payload["lifecycle"] = _serialize_lifecycle(lifecycle)
+        if volume_mounts:
+            payload["volumeMounts"] = _serialize_volume_mounts(volume_mounts)
         payload.update(kwargs)
 
         s = requests.Session()
         resp = s.post(f"{cfg.api_url}/sandboxes", json=payload,
-                      headers={"Content-Type": "application/json"})
+                      headers={"Content-Type": "application/json", 
**_auth_headers(cfg)})
         _check_response(resp)
         return cls(resp.json(), config=cfg)
 
@@ -272,7 +295,7 @@
         # Connect omits timeout; see docs/guide/lifecycle.md.
         resp = s.post(f"{cfg.api_url}/sandboxes/{sandbox_id}/connect",
                       json={},
-                      headers={"Content-Type": "application/json"})
+                      headers={"Content-Type": "application/json", 
**_auth_headers(cfg)})
         _check_response(resp)
         return cls(resp.json(), config=cfg)
 
@@ -290,7 +313,7 @@
         """
         cfg = config or Config()
         s = requests.Session()
-        resp = s.get(f"{cfg.api_url}/sandboxes")
+        resp = s.get(f"{cfg.api_url}/sandboxes", headers=_auth_headers(cfg))
         _check_response(resp)
         return resp.json()
 
@@ -308,7 +331,7 @@
         """
         cfg = config or Config()
         s = requests.Session()
-        resp = s.get(f"{cfg.api_url}/v2/sandboxes")
+        resp = s.get(f"{cfg.api_url}/v2/sandboxes", headers=_auth_headers(cfg))
         _check_response(resp)
         return resp.json()
 
@@ -325,7 +348,7 @@
         """
         cfg = config or Config()
         s = requests.Session()
-        resp = s.get(f"{cfg.api_url}/health")
+        resp = s.get(f"{cfg.api_url}/health", headers=_auth_headers(cfg))
         _check_response(resp)
         return resp.json()
 
@@ -454,6 +477,24 @@
         )
         _check_response(resp)
 
+    def set_timeout(self, timeout: int) -> None:
+        """POST /sandboxes/:sandboxID/timeout - Set sandbox idle timeout.
+
+        Args:
+            timeout: New idle timeout in seconds. ``0`` requests immediate
+                timeout; positive values set a normal TTL; ``NEVER_TIMEOUT``
+                (-1) disables idle timeout entirely.
+
+        Raises:
+            SandboxNotFoundError: If the sandbox does not exist (HTTP 404).
+            ApiError: If the timeout is invalid or on unexpected backend error.
+        """
+        resp = self._session.post(
+            f"{self._config.api_url}/sandboxes/{self.sandbox_id}/timeout",
+            json={"timeout": timeout},
+        )
+        _check_response(resp)
+
     def kill(self) -> None:
         """DELETE /sandboxes/:sandboxID - Destroy a sandbox.
 
@@ -563,7 +604,7 @@
         if next_token is not None:
             params["nextToken"] = next_token
         s = requests.Session()
-        resp = s.get(f"{cfg.api_url}/snapshots", params=params)
+        resp = s.get(f"{cfg.api_url}/snapshots", params=params, 
headers=_auth_headers(cfg))
         _check_response(resp)
         items = [SnapshotInfo.from_dict(d) for d in (resp.json() or [])]
         nt = resp.headers.get("x-next-token") or None
@@ -587,7 +628,7 @@
         """
         cfg = config or Config()
         s = requests.Session()
-        resp = s.delete(f"{cfg.api_url}/templates/{snapshot_id}")
+        resp = s.delete(f"{cfg.api_url}/templates/{snapshot_id}", 
headers=_auth_headers(cfg))
         _check_response(resp)
 
     # 1.4 — create_from_snapshot is covered by 
Sandbox.create(template=snapshot_id).
@@ -758,7 +799,7 @@
 
     def _build_session(self) -> requests.Session:
         s = requests.Session()
-        s.headers.update({"Content-Type": "application/json"})
+        s.headers.update({"Content-Type": "application/json", 
**_auth_headers(self._config)})
         return s
 
     def _build_data_client(self) -> httpx.Client:
@@ -772,6 +813,16 @@
         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.
+
+        Data-plane requests are also routed through CubeAPI's auth middleware,
+        which requires ``X-API-Key`` (or ``Authorization: Bearer``) whenever 
the
+        backend is started with an auth-callback URL. We therefore attach the
+        API key here as well so run_code / commands / filesystem / pty all
+        authenticate. When no key is configured ``_auth_headers`` returns 
``{}``
+        and behavior is unchanged.
         """
+        headers = _auth_headers(self._config)
         token = self.traffic_access_token
-        return {"e2b-traffic-access-token": token} if token else {}
+        if token:
+            headers["e2b-traffic-access-token"] = token
+        return headers
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cubesandbox-0.5.0/cubesandbox.egg-info/PKG-INFO 
new/cubesandbox-0.6.0/cubesandbox.egg-info/PKG-INFO
--- old/cubesandbox-0.5.0/cubesandbox.egg-info/PKG-INFO 2026-07-08 
13:15:24.000000000 +0200
+++ new/cubesandbox-0.6.0/cubesandbox.egg-info/PKG-INFO 2026-07-24 
10:57:59.000000000 +0200
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: cubesandbox
-Version: 0.5.0
+Version: 0.6.0
 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.5.0/cubesandbox.egg-info/SOURCES.txt 
new/cubesandbox-0.6.0/cubesandbox.egg-info/SOURCES.txt
--- old/cubesandbox-0.5.0/cubesandbox.egg-info/SOURCES.txt      2026-07-08 
13:15:24.000000000 +0200
+++ new/cubesandbox-0.6.0/cubesandbox.egg-info/SOURCES.txt      2026-07-24 
10:57:59.000000000 +0200
@@ -11,6 +11,7 @@
 cubesandbox/_stream.py
 cubesandbox/_template.py
 cubesandbox/_transport.py
+cubesandbox/_volume.py
 cubesandbox/sandbox.py
 cubesandbox.egg-info/PKG-INFO
 cubesandbox.egg-info/SOURCES.txt
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cubesandbox-0.5.0/pyproject.toml 
new/cubesandbox-0.6.0/pyproject.toml
--- old/cubesandbox-0.5.0/pyproject.toml        2026-07-08 13:15:11.000000000 
+0200
+++ new/cubesandbox-0.6.0/pyproject.toml        2026-07-24 10:43:03.000000000 
+0200
@@ -4,7 +4,7 @@
 
 [project]
 name = "cubesandbox"
-version = "0.5.0"
+version = "0.6.0"
 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.5.0/tests/test_sandbox.py 
new/cubesandbox-0.6.0/tests/test_sandbox.py
--- old/cubesandbox-0.5.0/tests/test_sandbox.py 2026-07-08 13:15:11.000000000 
+0200
+++ new/cubesandbox-0.6.0/tests/test_sandbox.py 2026-07-24 10:43:03.000000000 
+0200
@@ -177,6 +177,15 @@
         body = m.call_args.kwargs["json"]
         assert body["network"]["denyOut"] == ["0.0.0.0/0"]
 
+    def test_create_network_mask_request_host(self):
+        with patch("requests.Session.post", 
return_value=mock_response(SANDBOX_DATA, status=201)) as m:
+            Sandbox.create(
+                network={"mask_request_host": "localhost:${PORT}"},
+                config=make_config(),
+            )
+        body = m.call_args.kwargs["json"]
+        assert body["network"]["maskRequestHost"] == "localhost:${PORT}"
+
     def test_create_network_empty_not_in_payload(self):
         with patch("requests.Session.post", 
return_value=mock_response(SANDBOX_DATA, status=201)) as m:
             Sandbox.create(network={}, config=make_config())
@@ -849,6 +858,37 @@
                 sb.resume()
 
 
+# ── POST /sandboxes/:id/timeout 
───────────────────────────────────────────────
+
+class TestSetTimeout:
+    def test_set_timeout_success(self):
+        sb = make_sandbox()
+        with patch.object(sb._session, "post", 
return_value=mock_response(status=204)) as m:
+            sb.set_timeout(120)
+        assert m.call_args.args[0] == 
f"{sb._config.api_url}/sandboxes/{SANDBOX_ID}/timeout"
+        assert m.call_args.kwargs["json"] == {"timeout": 120}
+
+    def test_set_timeout_sends_explicit_zero(self):
+        sb = make_sandbox()
+        with patch.object(sb._session, "post", 
return_value=mock_response(status=204)) as m:
+            sb.set_timeout(0)
+        assert m.call_args.kwargs["json"] == {"timeout": 0}
+
+    def test_set_timeout_sends_never_timeout(self):
+        from cubesandbox import NEVER_TIMEOUT
+        sb = make_sandbox()
+        with patch.object(sb._session, "post", 
return_value=mock_response(status=204)) as m:
+            sb.set_timeout(NEVER_TIMEOUT)
+        assert m.call_args.kwargs["json"] == {"timeout": -1}
+
+    def test_set_timeout_not_found(self):
+        sb = make_sandbox()
+        with patch.object(sb._session, "post",
+                          return_value=mock_response({"message": "not found"}, 
status=404)):
+            with pytest.raises(SandboxNotFoundError):
+                sb.set_timeout(120)
+
+
 # ── properties / get_host 
─────────────────────────────────────────────────────
 
 class TestProperties:
@@ -2181,6 +2221,7 @@
                 dns=["8.8.8.8", "1.1.1.1"],
                 allow_out=["172.67.0.0/16"],
                 deny_out=["10.0.0.0/8"],
+                enable_ivshmem=True,
                 config=config,
             )
 
@@ -2198,6 +2239,7 @@
                 "dns": ["8.8.8.8", "1.1.1.1"],
                 "allowOut": ["172.67.0.0/16"],
                 "denyOut": ["10.0.0.0/8"],
+                "enableIvshmem": True,
             },
             headers={"Content-Type": "application/json"},
         )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cubesandbox-0.5.0/tests/test_template_e2e.py 
new/cubesandbox-0.6.0/tests/test_template_e2e.py
--- old/cubesandbox-0.5.0/tests/test_template_e2e.py    2026-07-08 
13:15:11.000000000 +0200
+++ new/cubesandbox-0.6.0/tests/test_template_e2e.py    2026-07-24 
10:43:03.000000000 +0200
@@ -11,7 +11,7 @@
 import os
 import time
 import uuid
-
+import requests
 import pytest
 
 from cubesandbox import Config, Template
@@ -127,3 +127,245 @@
                 Template.delete(created_template_id, config=config)
             except (ApiError, TemplateNotFoundError):
                 pass
+
+
+def test_template_alias_create_get_and_delete(pytestconfig: pytest.Config) -> 
None:
+    """Create a template with a stable alias, then look up and delete it by 
alias.
+
+    Exercises the full alias lifecycle:
+      1. Template.build(name=<alias>) → CubeAPI derives alias from the E2B 
name.
+      2. Template.get(<alias>) → CubeMaster resolves alias to the template ID.
+      3. Template.list() includes the alias in the response.
+      4. Template.delete(<alias>) → delete handler resolves alias before 
cleanup.
+      5. Template.get(<alias>) → 404 after deletion.
+    """
+    _require_e2e(pytestconfig)
+    image = _option(pytestconfig, "--cube-template-image", 
"CUBE_TEMPLATE_E2E_IMAGE")
+    if not image:
+        pytest.skip("use --cube-template-image or set CUBE_TEMPLATE_E2E_IMAGE 
to create a template")
+
+    config = _config(pytestconfig)
+    alias = f"e2e-alias-{uuid.uuid4().hex[:8]}"
+    created_template_id: str | None = None
+
+    try:
+        # 1. Create with alias.
+        job = Template.build(
+            name=alias,
+            image=image,
+            instance_type=os.environ.get("CUBE_TEMPLATE_E2E_INSTANCE_TYPE"),
+            
writable_layer_size=os.environ.get("CUBE_TEMPLATE_E2E_WRITABLE_LAYER_SIZE"),
+            exposed_ports=_env_ports("CUBE_TEMPLATE_E2E_EXPOSED_PORTS"),
+            config=config,
+        )
+        assert job.job_id
+        assert job.template_id.startswith("tpl-")
+        created_template_id = job.template_id
+
+        # Poll until the build finishes (READY or FAILED), since the alias
+        # is claimed only after finalizeTemplateReplicas succeeds.
+        deadline = time.time() + 120
+        while time.time() < deadline:
+            try:
+                built = Template.get(created_template_id, config=config)
+                if built.status in ("READY", "FAILED"):
+                    break
+            except TemplateNotFoundError:
+                pass
+            time.sleep(2)
+
+        # 2. Look up by alias — CubeMaster resolves alias → template ID.
+        detail = Template.get(alias, config=config)
+        assert detail.template_id == created_template_id
+
+        # 3. List should include the template (verify it's reachable).
+        templates = Template.list(config=config)
+        assert any(t.template_id == created_template_id for t in templates)
+
+        # 4. Delete by alias — delete handler resolves alias before cleanup.
+        #    Retry until the build job settles (deletion is blocked while a
+        #    build is still active).
+        deadline = time.time() + 180
+        while time.time() < deadline:
+            try:
+                Template.delete(alias, config=config)
+                break
+            except ApiError as e:
+                if "attempt is already in progress" in str(e):
+                    time.sleep(5)
+                    continue
+                raise
+        created_template_id = None  # already deleted
+
+        # 5. Alias should no longer resolve.
+        try:
+            Template.get(alias, config=config)
+            assert False, f"alias {alias} should not resolve after deletion"
+        except TemplateNotFoundError:
+            pass  # expected
+    finally:
+        time.sleep(1)
+        if created_template_id is not None:
+            try:
+                Template.delete(created_template_id, config=config)
+            except (ApiError, TemplateNotFoundError):
+                pass
+
+
+def test_template_alias_validation_rejects_invalid_formats(pytestconfig: 
pytest.Config) -> None:
+    """Invalid alias formats are rejected with 400 before the build starts.
+
+    Exercises CubeMaster's validateTemplateAlias regex via CubeAPI:
+      - Uppercase letters are not allowed.
+      - Aliases starting with tpl-/snap- are rejected (namespace collision).
+      - Aliases exceeding 64 characters are rejected.
+    """
+    _require_e2e(pytestconfig)
+    image = _option(pytestconfig, "--cube-template-image", 
"CUBE_TEMPLATE_E2E_IMAGE")
+    if not image:
+        pytest.skip("use --cube-template-image or set CUBE_TEMPLATE_E2E_IMAGE 
to create a template")
+
+    config = _config(pytestconfig)
+    invalid_aliases = [
+        "UPPER",            # uppercase not allowed
+        "tpl-hijack",       # collides with template ID prefix
+        "snap-hijack",      # collides with snapshot ID prefix
+        "a" * 65,           # exceeds 64-char limit
+    ]
+    for invalid in invalid_aliases:
+        with pytest.raises(ApiError):
+            Template.build(name=invalid, image=image, config=config)
+
+
+def test_template_alias_dedicated_lookup_endpoint(pytestconfig: pytest.Config) 
-> None:
+    """GET /templates/aliases/:alias (E2B compat endpoint) returns templateID.
+
+    Unlike GET /templates/:templateID which transparently resolves aliases via
+    CubeMaster, this dedicated endpoint is the E2B-compatible alias lookup that
+    returns a minimal {templateID, public} response.
+    """
+    _require_e2e(pytestconfig)
+    image = _option(pytestconfig, "--cube-template-image", 
"CUBE_TEMPLATE_E2E_IMAGE")
+    if not image:
+        pytest.skip("use --cube-template-image or set CUBE_TEMPLATE_E2E_IMAGE 
to create a template")
+
+    config = _config(pytestconfig)
+    alias = f"e2e-alias-ep-{uuid.uuid4().hex[:8]}"
+    created_template_id: str | None = None
+
+    try:
+        job = Template.build(
+            name=alias,
+            image=image,
+            instance_type=os.environ.get("CUBE_TEMPLATE_E2E_INSTANCE_TYPE"),
+            
writable_layer_size=os.environ.get("CUBE_TEMPLATE_E2E_WRITABLE_LAYER_SIZE"),
+            exposed_ports=_env_ports("CUBE_TEMPLATE_E2E_EXPOSED_PORTS"),
+            config=config,
+        )
+        created_template_id = job.template_id
+
+        # Poll until the build finishes (alias claimed after READY).
+        deadline = time.time() + 120
+        while time.time() < deadline:
+            try:
+                built = Template.get(created_template_id, config=config)
+                if built.status in ("READY", "FAILED"):
+                    break
+            except TemplateNotFoundError:
+                pass
+            time.sleep(2)
+
+        # Hit the dedicated alias endpoint.
+        resp = requests.get(f"{config.api_url}/templates/aliases/{alias}")
+        assert resp.status_code == 200, f"alias lookup failed: 
{resp.status_code} {resp.text}"
+        data = resp.json()
+        assert data["templateID"] == created_template_id
+        assert "public" in data
+    finally:
+        time.sleep(1)
+        if created_template_id is not None:
+            try:
+                Template.delete(created_template_id, config=config)
+            except (ApiError, TemplateNotFoundError):
+                pass
+
+
+def test_template_alias_rebuild_reassignment(pytestconfig: pytest.Config) -> 
None:
+    """Rebuilding with the same alias moves it to the newest template.
+
+    The core feature of template aliases: creating a second template with an
+    alias already held by a non-deleting template releases the alias from the
+    old one and claims it for the new one. This is what makes aliases survive
+    image rebuilds without manual cleanup.
+
+      1. Create template A with alias.
+      2. Create template B with the same alias.
+      3. Verify alias now resolves to B, not A.
+      4. Verify A no longer has the alias.
+    """
+    _require_e2e(pytestconfig)
+    image = _option(pytestconfig, "--cube-template-image", 
"CUBE_TEMPLATE_E2E_IMAGE")
+    if not image:
+        pytest.skip("use --cube-template-image or set CUBE_TEMPLATE_E2E_IMAGE 
to create a template")
+
+    config = _config(pytestconfig)
+    alias = f"e2e-alias-rebuild-{uuid.uuid4().hex[:8]}"
+    template_ids: list[str] = []
+
+    try:
+        common_kwargs = dict(
+            image=image,
+            instance_type=os.environ.get("CUBE_TEMPLATE_E2E_INSTANCE_TYPE"),
+            
writable_layer_size=os.environ.get("CUBE_TEMPLATE_E2E_WRITABLE_LAYER_SIZE"),
+            exposed_ports=_env_ports("CUBE_TEMPLATE_E2E_EXPOSED_PORTS"),
+            config=config,
+        )
+
+        # 1. Create template A with the alias.
+        job_a = Template.build(name=alias, **common_kwargs)
+        template_ids.append(job_a.template_id)
+
+        # Poll until A is READY (alias claimed after finalize).
+        deadline = time.time() + 120
+        while time.time() < deadline:
+            try:
+                built_a = Template.get(job_a.template_id, config=config)
+                if built_a.status in ("READY", "FAILED"):
+                    break
+            except TemplateNotFoundError:
+                pass
+            time.sleep(2)
+
+        # Verify alias resolves to A initially.
+        detail_a = Template.get(alias, config=config)
+        assert detail_a.template_id == job_a.template_id
+
+        # 2. Create template B with the same alias — should steal it from A.
+        job_b = Template.build(name=alias, **common_kwargs)
+        template_ids.append(job_b.template_id)
+
+        # Poll until B is READY (alias claim steals from A).
+        deadline = time.time() + 120
+        while time.time() < deadline:
+            try:
+                built_b = Template.get(job_b.template_id, config=config)
+                if built_b.status in ("READY", "FAILED"):
+                    break
+            except TemplateNotFoundError:
+                pass
+            time.sleep(2)
+
+        # 3. Alias should now resolve to B, not A.
+        detail_b = Template.get(alias, config=config)
+        assert detail_b.template_id == job_b.template_id
+
+        # 4. A should still exist but alias should no longer point to it.
+        detail_a_direct = Template.get(job_a.template_id, config=config)
+        assert detail_a_direct.template_id == job_a.template_id
+    finally:
+        time.sleep(1)
+        for tid in template_ids:
+            try:
+                Template.delete(tid, config=config)
+            except (ApiError, TemplateNotFoundError):
+                pass
\ No newline at end of file

Reply via email to