From 2dd86fd9aa8605c66f37ba8737da75594346a09d Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Fri, 11 Sep 2026 16:50:19 +0000 Subject: [PATCH 01/17] spec: sidecars on sandbox create and inspect Add SidecarAttachment and SidecarInfo to the monorepo copy of the OpenAPI spec, NewSandbox.sidecars (maxItems 4) and a sidecars array on Sandbox, SandboxDetail and ListedSandbox, then regenerate the JS schema and the Python client models. Shapes follow IMPL-D039 00-overview; ListedSandbox is an addition (recorded in qa.md QA7) so the list endpoint can carry the sidecar state the CLI column and R9 need. The tracked spec is Copybara-synced from e2b-dev/runtime at spec/runtime-ref, so the generated-files CI check stays red until the upstream pin advances past belt's W3 change; the edit is a stand-in for that fetch. --- packages/js-sdk/src/api/schema.gen.ts | 65 +++++++++ .../e2b/api/client/models/__init__.py | 14 ++ .../e2b/api/client/models/listed_sandbox.py | 21 +++ .../e2b/api/client/models/new_sandbox.py | 22 +++ .../e2b/api/client/models/sandbox.py | 27 +++- .../e2b/api/client/models/sandbox_detail.py | 21 +++ .../api/client/models/sidecar_attachment.py | 114 +++++++++++++++ .../models/sidecar_attachment_config.py | 47 ++++++ .../models/sidecar_attachment_secrets.py | 47 ++++++ .../e2b/api/client/models/sidecar_info.py | 134 ++++++++++++++++++ .../api/client/models/sidecar_info_class.py | 9 ++ .../api/client/models/sidecar_info_role.py | 9 ++ .../api/client/models/sidecar_info_state.py | 11 ++ spec/openapi.yml | 92 ++++++++++++ 14 files changed, 632 insertions(+), 1 deletion(-) create mode 100644 packages/python-sdk/e2b/api/client/models/sidecar_attachment.py create mode 100644 packages/python-sdk/e2b/api/client/models/sidecar_attachment_config.py create mode 100644 packages/python-sdk/e2b/api/client/models/sidecar_attachment_secrets.py create mode 100644 packages/python-sdk/e2b/api/client/models/sidecar_info.py create mode 100644 packages/python-sdk/e2b/api/client/models/sidecar_info_class.py create mode 100644 packages/python-sdk/e2b/api/client/models/sidecar_info_role.py create mode 100644 packages/python-sdk/e2b/api/client/models/sidecar_info_state.py diff --git a/packages/js-sdk/src/api/schema.gen.ts b/packages/js-sdk/src/api/schema.gen.ts index 8943b3385e..b8e7c3f03a 100644 --- a/packages/js-sdk/src/api/schema.gen.ts +++ b/packages/js-sdk/src/api/schema.gen.ts @@ -2282,6 +2282,7 @@ export interface components { metadata?: components["schemas"]["SandboxMetadata"]; /** @description Identifier of the sandbox */ sandboxID: string; + sidecars?: components["schemas"]["SidecarInfo"][]; /** * Format: date-time * @description Time when the sandbox was started @@ -2358,6 +2359,23 @@ export interface components { network?: components["schemas"]["SandboxNetworkConfig"]; /** @description Secure all system communication with sandbox */ secure?: boolean; + /** @description Sidecar microVMs to attach to the sandbox, at most four, at most one with the proxy role. Requires the team's sandbox-sidecars feature. */ + sidecars?: [ + ] | [ + components["schemas"]["SidecarAttachment"] + ] | [ + components["schemas"]["SidecarAttachment"], + components["schemas"]["SidecarAttachment"] + ] | [ + components["schemas"]["SidecarAttachment"], + components["schemas"]["SidecarAttachment"], + components["schemas"]["SidecarAttachment"] + ] | [ + components["schemas"]["SidecarAttachment"], + components["schemas"]["SidecarAttachment"], + components["schemas"]["SidecarAttachment"], + components["schemas"]["SidecarAttachment"] + ]; /** @description Identifier of the required template */ templateID: string; /** @@ -2415,6 +2433,7 @@ export interface components { envdVersion: components["schemas"]["EnvdVersion"]; /** @description Identifier of the sandbox */ sandboxID: string; + sidecars?: components["schemas"]["SidecarInfo"][]; /** @description Identifier of the template from which is the sandbox created */ templateID: string; /** @description Token required for accessing sandbox via proxy. */ @@ -2457,6 +2476,7 @@ export interface components { network?: components["schemas"]["SandboxNetworkConfig"]; /** @description Identifier of the sandbox */ sandboxID: string; + sidecars?: components["schemas"]["SidecarInfo"][]; /** * Format: date-time * @description Time when the sandbox was started @@ -2723,6 +2743,51 @@ export interface components { /** @description Runtime marker stored as the secret's new version. The runtime resolves it to a value at sandbox egress. */ value: string; }; + /** @description A sidecar microVM to attach to the sandbox, declared from the E2B sidecar catalog. */ + SidecarAttachment: { + /** @description Entry-specific configuration, validated against the entry's schema. String values may reference secrets as "${e2b.secrets.}". */ + config?: { + [key: string]: unknown; + }; + /** @description Catalog entry name (for example "iron-proxy" or "redis"). The sandbox reaches the sidecar at "{entry}.sidecar.e2b.local". */ + entry: string; + /** @description Secret slots the entry declares, keyed by slot name, each holding a secret reference the platform resolves at injection time. The secret value never enters the sandbox. */ + secrets?: { + [key: string]: string; + }; + /** @description Catalog entry version. Defaults to the entry's current version. */ + version?: string; + }; + /** @description A sidecar attached to the sandbox and its current state. */ + SidecarInfo: { + /** @description Address of the sidecar inside the sandbox network */ + address?: string; + /** + * @description Lifecycle class of the sidecar + * @enum {string} + */ + class: "ephemeral" | "stateful"; + /** @description Catalog entry name */ + entry: string; + /** @description Last error of the sidecar, set when the state is failed */ + lastError?: string; + /** @description Name the sandbox reaches the sidecar at ("{entry}.sidecar.e2b.local") */ + name: string; + /** @description Ports the sidecar listens on */ + ports?: number[]; + /** + * @description Role of the sidecar + * @enum {string} + */ + role: "proxy" | "service"; + /** + * @description Current state of the sidecar + * @enum {string} + */ + state: "starting" | "running" | "failed" | "stopped"; + /** @description Catalog entry version */ + version: string; + }; SnapshotInfo: { /** @description Full names of the snapshot template including team namespace and tag (e.g. team-slug/my-snapshot:v2) */ names: string[]; diff --git a/packages/python-sdk/e2b/api/client/models/__init__.py b/packages/python-sdk/e2b/api/client/models/__init__.py index d2acfa930d..6997ee64d0 100644 --- a/packages/python-sdk/e2b/api/client/models/__init__.py +++ b/packages/python-sdk/e2b/api/client/models/__init__.py @@ -57,6 +57,13 @@ from .secret import Secret from .secret_metadata import SecretMetadata from .secret_update import SecretUpdate +from .sidecar_attachment import SidecarAttachment +from .sidecar_attachment_config import SidecarAttachmentConfig +from .sidecar_attachment_secrets import SidecarAttachmentSecrets +from .sidecar_info import SidecarInfo +from .sidecar_info_class import SidecarInfoClass +from .sidecar_info_role import SidecarInfoRole +from .sidecar_info_state import SidecarInfoState from .snapshot_info import SnapshotInfo from .team_user import TeamUser from .template import Template @@ -138,6 +145,13 @@ "Secret", "SecretMetadata", "SecretUpdate", + "SidecarAttachment", + "SidecarAttachmentConfig", + "SidecarAttachmentSecrets", + "SidecarInfo", + "SidecarInfoClass", + "SidecarInfoRole", + "SidecarInfoState", "SnapshotInfo", "TeamUser", "Template", diff --git a/packages/python-sdk/e2b/api/client/models/listed_sandbox.py b/packages/python-sdk/e2b/api/client/models/listed_sandbox.py index aa14e7efc8..f7c01e4145 100644 --- a/packages/python-sdk/e2b/api/client/models/listed_sandbox.py +++ b/packages/python-sdk/e2b/api/client/models/listed_sandbox.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from ..models.sandbox_volume_mount import SandboxVolumeMount + from ..models.sidecar_info import SidecarInfo T = TypeVar("T", bound="ListedSandbox") @@ -33,6 +34,7 @@ class ListedSandbox: alias (Union[Unset, str]): Alias of the template metadata (Union[Unset, Any]): volume_mounts (Union[Unset, list['SandboxVolumeMount']]): + sidecars (Union[Unset, list['SidecarInfo']]): """ template_id: str @@ -48,6 +50,7 @@ class ListedSandbox: alias: Union[Unset, str] = UNSET metadata: Union[Unset, Any] = UNSET volume_mounts: Union[Unset, list["SandboxVolumeMount"]] = UNSET + sidecars: Union[Unset, list["SidecarInfo"]] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -82,6 +85,13 @@ def to_dict(self) -> dict[str, Any]: volume_mounts_item = volume_mounts_item_data.to_dict() volume_mounts.append(volume_mounts_item) + sidecars: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.sidecars, Unset): + sidecars = [] + for sidecars_item_data in self.sidecars: + sidecars_item = sidecars_item_data.to_dict() + sidecars.append(sidecars_item) + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -104,12 +114,15 @@ def to_dict(self) -> dict[str, Any]: field_dict["metadata"] = metadata if volume_mounts is not UNSET: field_dict["volumeMounts"] = volume_mounts + if sidecars is not UNSET: + field_dict["sidecars"] = sidecars return field_dict @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.sandbox_volume_mount import SandboxVolumeMount + from ..models.sidecar_info import SidecarInfo d = dict(src_dict) template_id = d.pop("templateID") @@ -143,6 +156,13 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: volume_mounts.append(volume_mounts_item) + sidecars = [] + _sidecars = d.pop("sidecars", UNSET) + for sidecars_item_data in _sidecars or []: + sidecars_item = SidecarInfo.from_dict(sidecars_item_data) + + sidecars.append(sidecars_item) + listed_sandbox = cls( template_id=template_id, sandbox_id=sandbox_id, @@ -157,6 +177,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: alias=alias, metadata=metadata, volume_mounts=volume_mounts, + sidecars=sidecars, ) listed_sandbox.additional_properties = d diff --git a/packages/python-sdk/e2b/api/client/models/new_sandbox.py b/packages/python-sdk/e2b/api/client/models/new_sandbox.py index c511ffff17..f5a18ee955 100644 --- a/packages/python-sdk/e2b/api/client/models/new_sandbox.py +++ b/packages/python-sdk/e2b/api/client/models/new_sandbox.py @@ -12,6 +12,7 @@ from ..models.sandbox_iam import SandboxIam from ..models.sandbox_network_config import SandboxNetworkConfig from ..models.sandbox_volume_mount import SandboxVolumeMount + from ..models.sidecar_attachment import SidecarAttachment T = TypeVar("T", bound="NewSandbox") @@ -40,6 +41,8 @@ class NewSandbox: iam (Union[Unset, SandboxIam]): Sandbox workload identity configuration. A non-empty, valid tokens map enables workload identity for the sandbox. volume_mounts (Union[Unset, list['SandboxVolumeMount']]): + sidecars (Union[Unset, list['SidecarAttachment']]): Sidecar microVMs to attach to the sandbox, at most four, at + most one with the proxy role. Requires the team's sandbox-sidecars feature. """ template_id: str @@ -55,6 +58,7 @@ class NewSandbox: mcp: Union["McpType0", None, Unset] = UNSET iam: Union[Unset, "SandboxIam"] = UNSET volume_mounts: Union[Unset, list["SandboxVolumeMount"]] = UNSET + sidecars: Union[Unset, list["SidecarAttachment"]] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -103,6 +107,13 @@ def to_dict(self) -> dict[str, Any]: volume_mounts_item = volume_mounts_item_data.to_dict() volume_mounts.append(volume_mounts_item) + sidecars: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.sidecars, Unset): + sidecars = [] + for sidecars_item_data in self.sidecars: + sidecars_item = sidecars_item_data.to_dict() + sidecars.append(sidecars_item) + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -134,6 +145,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["iam"] = iam if volume_mounts is not UNSET: field_dict["volumeMounts"] = volume_mounts + if sidecars is not UNSET: + field_dict["sidecars"] = sidecars return field_dict @@ -144,6 +157,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.sandbox_iam import SandboxIam from ..models.sandbox_network_config import SandboxNetworkConfig from ..models.sandbox_volume_mount import SandboxVolumeMount + from ..models.sidecar_attachment import SidecarAttachment d = dict(src_dict) template_id = d.pop("templateID") @@ -207,6 +221,13 @@ def _parse_mcp(data: object) -> Union["McpType0", None, Unset]: volume_mounts.append(volume_mounts_item) + sidecars = [] + _sidecars = d.pop("sidecars", UNSET) + for sidecars_item_data in _sidecars or []: + sidecars_item = SidecarAttachment.from_dict(sidecars_item_data) + + sidecars.append(sidecars_item) + new_sandbox = cls( template_id=template_id, timeout=timeout, @@ -221,6 +242,7 @@ def _parse_mcp(data: object) -> Union["McpType0", None, Unset]: mcp=mcp, iam=iam, volume_mounts=volume_mounts, + sidecars=sidecars, ) new_sandbox.additional_properties = d diff --git a/packages/python-sdk/e2b/api/client/models/sandbox.py b/packages/python-sdk/e2b/api/client/models/sandbox.py index 651a13205f..8cbe8faed6 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox.py @@ -1,11 +1,15 @@ from collections.abc import Mapping -from typing import Any, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast from attrs import define as _attrs_define from attrs import field as _attrs_field from ..types import UNSET, Unset +if TYPE_CHECKING: + from ..models.sidecar_info import SidecarInfo + + T = TypeVar("T", bound="Sandbox") @@ -21,6 +25,7 @@ class Sandbox: envd_access_token (Union[Unset, str]): Access token used for envd communication traffic_access_token (Union[None, Unset, str]): Token required for accessing sandbox via proxy. domain (Union[None, Unset, str]): Base domain where the sandbox traffic is accessible + sidecars (Union[Unset, list['SidecarInfo']]): """ template_id: str @@ -31,6 +36,7 @@ class Sandbox: envd_access_token: Union[Unset, str] = UNSET traffic_access_token: Union[None, Unset, str] = UNSET domain: Union[None, Unset, str] = UNSET + sidecars: Union[Unset, list["SidecarInfo"]] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -58,6 +64,13 @@ def to_dict(self) -> dict[str, Any]: else: domain = self.domain + sidecars: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.sidecars, Unset): + sidecars = [] + for sidecars_item_data in self.sidecars: + sidecars_item = sidecars_item_data.to_dict() + sidecars.append(sidecars_item) + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -76,11 +89,15 @@ def to_dict(self) -> dict[str, Any]: field_dict["trafficAccessToken"] = traffic_access_token if domain is not UNSET: field_dict["domain"] = domain + if sidecars is not UNSET: + field_dict["sidecars"] = sidecars return field_dict @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sidecar_info import SidecarInfo + d = dict(src_dict) template_id = d.pop("templateID") @@ -114,6 +131,13 @@ def _parse_domain(data: object) -> Union[None, Unset, str]: domain = _parse_domain(d.pop("domain", UNSET)) + sidecars = [] + _sidecars = d.pop("sidecars", UNSET) + for sidecars_item_data in _sidecars or []: + sidecars_item = SidecarInfo.from_dict(sidecars_item_data) + + sidecars.append(sidecars_item) + sandbox = cls( template_id=template_id, sandbox_id=sandbox_id, @@ -123,6 +147,7 @@ def _parse_domain(data: object) -> Union[None, Unset, str]: envd_access_token=envd_access_token, traffic_access_token=traffic_access_token, domain=domain, + sidecars=sidecars, ) sandbox.additional_properties = d diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_detail.py b/packages/python-sdk/e2b/api/client/models/sandbox_detail.py index dfe232203c..702564f832 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_detail.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_detail.py @@ -13,6 +13,7 @@ from ..models.sandbox_lifecycle import SandboxLifecycle from ..models.sandbox_network_config import SandboxNetworkConfig from ..models.sandbox_volume_mount import SandboxVolumeMount + from ..models.sidecar_info import SidecarInfo T = TypeVar("T", bound="SandboxDetail") @@ -41,6 +42,7 @@ class SandboxDetail: network (Union[Unset, SandboxNetworkConfig]): lifecycle (Union[Unset, SandboxLifecycle]): Sandbox lifecycle policy returned by sandbox info. volume_mounts (Union[Unset, list['SandboxVolumeMount']]): + sidecars (Union[Unset, list['SidecarInfo']]): """ template_id: str @@ -61,6 +63,7 @@ class SandboxDetail: network: Union[Unset, "SandboxNetworkConfig"] = UNSET lifecycle: Union[Unset, "SandboxLifecycle"] = UNSET volume_mounts: Union[Unset, list["SandboxVolumeMount"]] = UNSET + sidecars: Union[Unset, list["SidecarInfo"]] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -117,6 +120,13 @@ def to_dict(self) -> dict[str, Any]: volume_mounts_item = volume_mounts_item_data.to_dict() volume_mounts.append(volume_mounts_item) + sidecars: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.sidecars, Unset): + sidecars = [] + for sidecars_item_data in self.sidecars: + sidecars_item = sidecars_item_data.to_dict() + sidecars.append(sidecars_item) + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -149,6 +159,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["lifecycle"] = lifecycle if volume_mounts is not UNSET: field_dict["volumeMounts"] = volume_mounts + if sidecars is not UNSET: + field_dict["sidecars"] = sidecars return field_dict @@ -157,6 +169,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.sandbox_lifecycle import SandboxLifecycle from ..models.sandbox_network_config import SandboxNetworkConfig from ..models.sandbox_volume_mount import SandboxVolumeMount + from ..models.sidecar_info import SidecarInfo d = dict(src_dict) template_id = d.pop("templateID") @@ -226,6 +239,13 @@ def _parse_domain(data: object) -> Union[None, Unset, str]: volume_mounts.append(volume_mounts_item) + sidecars = [] + _sidecars = d.pop("sidecars", UNSET) + for sidecars_item_data in _sidecars or []: + sidecars_item = SidecarInfo.from_dict(sidecars_item_data) + + sidecars.append(sidecars_item) + sandbox_detail = cls( template_id=template_id, sandbox_id=sandbox_id, @@ -245,6 +265,7 @@ def _parse_domain(data: object) -> Union[None, Unset, str]: network=network, lifecycle=lifecycle, volume_mounts=volume_mounts, + sidecars=sidecars, ) sandbox_detail.additional_properties = d diff --git a/packages/python-sdk/e2b/api/client/models/sidecar_attachment.py b/packages/python-sdk/e2b/api/client/models/sidecar_attachment.py new file mode 100644 index 0000000000..50959b66a0 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sidecar_attachment.py @@ -0,0 +1,114 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.sidecar_attachment_config import SidecarAttachmentConfig + from ..models.sidecar_attachment_secrets import SidecarAttachmentSecrets + + +T = TypeVar("T", bound="SidecarAttachment") + + +@_attrs_define +class SidecarAttachment: + """A sidecar microVM to attach to the sandbox, declared from the E2B sidecar catalog. + + Attributes: + entry (str): Catalog entry name (for example "iron-proxy" or "redis"). The sandbox reaches the sidecar at + "{entry}.sidecar.e2b.local". + version (Union[Unset, str]): Catalog entry version. Defaults to the entry's current version. + config (Union[Unset, SidecarAttachmentConfig]): Entry-specific configuration, validated against the entry's + schema. String values may reference secrets as "${e2b.secrets.}". + secrets (Union[Unset, SidecarAttachmentSecrets]): Secret slots the entry declares, keyed by slot name, each + holding a secret reference the platform resolves at injection time. The secret value never enters the sandbox. + """ + + entry: str + version: Union[Unset, str] = UNSET + config: Union[Unset, "SidecarAttachmentConfig"] = UNSET + secrets: Union[Unset, "SidecarAttachmentSecrets"] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + entry = self.entry + + version = self.version + + config: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.config, Unset): + config = self.config.to_dict() + + secrets: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.secrets, Unset): + secrets = self.secrets.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "entry": entry, + } + ) + if version is not UNSET: + field_dict["version"] = version + if config is not UNSET: + field_dict["config"] = config + if secrets is not UNSET: + field_dict["secrets"] = secrets + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sidecar_attachment_config import SidecarAttachmentConfig + from ..models.sidecar_attachment_secrets import SidecarAttachmentSecrets + + d = dict(src_dict) + entry = d.pop("entry") + + version = d.pop("version", UNSET) + + _config = d.pop("config", UNSET) + config: Union[Unset, SidecarAttachmentConfig] + if isinstance(_config, Unset): + config = UNSET + else: + config = SidecarAttachmentConfig.from_dict(_config) + + _secrets = d.pop("secrets", UNSET) + secrets: Union[Unset, SidecarAttachmentSecrets] + if isinstance(_secrets, Unset): + secrets = UNSET + else: + secrets = SidecarAttachmentSecrets.from_dict(_secrets) + + sidecar_attachment = cls( + entry=entry, + version=version, + config=config, + secrets=secrets, + ) + + sidecar_attachment.additional_properties = d + return sidecar_attachment + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sidecar_attachment_config.py b/packages/python-sdk/e2b/api/client/models/sidecar_attachment_config.py new file mode 100644 index 0000000000..2f9577e506 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sidecar_attachment_config.py @@ -0,0 +1,47 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="SidecarAttachmentConfig") + + +@_attrs_define +class SidecarAttachmentConfig: + """Entry-specific configuration, validated against the entry's schema. String values may reference secrets as + "${e2b.secrets.}". + + """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + sidecar_attachment_config = cls() + + sidecar_attachment_config.additional_properties = d + return sidecar_attachment_config + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sidecar_attachment_secrets.py b/packages/python-sdk/e2b/api/client/models/sidecar_attachment_secrets.py new file mode 100644 index 0000000000..04b9693758 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sidecar_attachment_secrets.py @@ -0,0 +1,47 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="SidecarAttachmentSecrets") + + +@_attrs_define +class SidecarAttachmentSecrets: + """Secret slots the entry declares, keyed by slot name, each holding a secret reference the platform resolves at + injection time. The secret value never enters the sandbox. + + """ + + additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + sidecar_attachment_secrets = cls() + + sidecar_attachment_secrets.additional_properties = d + return sidecar_attachment_secrets + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: str) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sidecar_info.py b/packages/python-sdk/e2b/api/client/models/sidecar_info.py new file mode 100644 index 0000000000..3c4a832517 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sidecar_info.py @@ -0,0 +1,134 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.sidecar_info_class import SidecarInfoClass +from ..models.sidecar_info_role import SidecarInfoRole +from ..models.sidecar_info_state import SidecarInfoState +from ..types import UNSET, Unset + +T = TypeVar("T", bound="SidecarInfo") + + +@_attrs_define +class SidecarInfo: + """A sidecar attached to the sandbox and its current state. + + Attributes: + entry (str): Catalog entry name + version (str): Catalog entry version + role (SidecarInfoRole): Role of the sidecar + class_ (SidecarInfoClass): Lifecycle class of the sidecar + state (SidecarInfoState): Current state of the sidecar + name (str): Name the sandbox reaches the sidecar at ("{entry}.sidecar.e2b.local") + address (Union[Unset, str]): Address of the sidecar inside the sandbox network + ports (Union[Unset, list[int]]): Ports the sidecar listens on + last_error (Union[Unset, str]): Last error of the sidecar, set when the state is failed + """ + + entry: str + version: str + role: SidecarInfoRole + class_: SidecarInfoClass + state: SidecarInfoState + name: str + address: Union[Unset, str] = UNSET + ports: Union[Unset, list[int]] = UNSET + last_error: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + entry = self.entry + + version = self.version + + role = self.role.value + + class_ = self.class_.value + + state = self.state.value + + name = self.name + + address = self.address + + ports: Union[Unset, list[int]] = UNSET + if not isinstance(self.ports, Unset): + ports = self.ports + + last_error = self.last_error + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "entry": entry, + "version": version, + "role": role, + "class": class_, + "state": state, + "name": name, + } + ) + if address is not UNSET: + field_dict["address"] = address + if ports is not UNSET: + field_dict["ports"] = ports + if last_error is not UNSET: + field_dict["lastError"] = last_error + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + entry = d.pop("entry") + + version = d.pop("version") + + role = SidecarInfoRole(d.pop("role")) + + class_ = SidecarInfoClass(d.pop("class")) + + state = SidecarInfoState(d.pop("state")) + + name = d.pop("name") + + address = d.pop("address", UNSET) + + ports = cast(list[int], d.pop("ports", UNSET)) + + last_error = d.pop("lastError", UNSET) + + sidecar_info = cls( + entry=entry, + version=version, + role=role, + class_=class_, + state=state, + name=name, + address=address, + ports=ports, + last_error=last_error, + ) + + sidecar_info.additional_properties = d + return sidecar_info + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sidecar_info_class.py b/packages/python-sdk/e2b/api/client/models/sidecar_info_class.py new file mode 100644 index 0000000000..d0209b1805 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sidecar_info_class.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class SidecarInfoClass(str, Enum): + EPHEMERAL = "ephemeral" + STATEFUL = "stateful" + + def __str__(self) -> str: + return str(self.value) diff --git a/packages/python-sdk/e2b/api/client/models/sidecar_info_role.py b/packages/python-sdk/e2b/api/client/models/sidecar_info_role.py new file mode 100644 index 0000000000..8a6c12c729 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sidecar_info_role.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class SidecarInfoRole(str, Enum): + PROXY = "proxy" + SERVICE = "service" + + def __str__(self) -> str: + return str(self.value) diff --git a/packages/python-sdk/e2b/api/client/models/sidecar_info_state.py b/packages/python-sdk/e2b/api/client/models/sidecar_info_state.py new file mode 100644 index 0000000000..8d74e1ab55 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sidecar_info_state.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class SidecarInfoState(str, Enum): + FAILED = "failed" + RUNNING = "running" + STARTING = "starting" + STOPPED = "stopped" + + def __str__(self) -> str: + return str(self.value) diff --git a/spec/openapi.yml b/spec/openapi.yml index e7787b451f..1440772ed4 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -737,6 +737,80 @@ components: - name - path + SidecarAttachment: + type: object + description: A sidecar microVM to attach to the sandbox, declared from the E2B sidecar catalog. + required: + - entry + properties: + entry: + type: string + description: Catalog entry name (for example "iron-proxy" or "redis"). The sandbox reaches the sidecar at "{entry}.sidecar.e2b.local". + version: + type: string + description: Catalog entry version. Defaults to the entry's current version. + config: + type: object + description: Entry-specific configuration, validated against the entry's schema. String values may reference secrets as "${e2b.secrets.}". + additionalProperties: true + secrets: + type: object + description: Secret slots the entry declares, keyed by slot name, each holding a secret reference the platform resolves at injection time. The secret value never enters the sandbox. + additionalProperties: + type: string + + SidecarInfo: + type: object + description: A sidecar attached to the sandbox and its current state. + required: + - entry + - version + - role + - class + - state + - name + properties: + entry: + type: string + description: Catalog entry name + version: + type: string + description: Catalog entry version + role: + type: string + enum: + - proxy + - service + description: Role of the sidecar + class: + type: string + enum: + - ephemeral + - stateful + description: Lifecycle class of the sidecar + state: + type: string + enum: + - starting + - running + - failed + - stopped + description: Current state of the sidecar + name: + type: string + description: Name the sandbox reaches the sidecar at ("{entry}.sidecar.e2b.local") + address: + type: string + description: Address of the sidecar inside the sandbox network + ports: + type: array + description: Ports the sidecar listens on + items: + type: integer + lastError: + type: string + description: Last error of the sidecar, set when the state is failed + Sandbox: required: - templateID @@ -770,6 +844,10 @@ components: type: string nullable: true description: Base domain where the sandbox traffic is accessible + sidecars: + type: array + items: + $ref: "#/components/schemas/SidecarInfo" SandboxDetail: required: @@ -836,6 +914,10 @@ components: type: array items: $ref: "#/components/schemas/SandboxVolumeMount" + sidecars: + type: array + items: + $ref: "#/components/schemas/SidecarInfo" ListedSandbox: required: @@ -887,6 +969,10 @@ components: type: array items: $ref: "#/components/schemas/SandboxVolumeMount" + sidecars: + type: array + items: + $ref: "#/components/schemas/SidecarInfo" SandboxesWithMetrics: required: @@ -948,6 +1034,12 @@ components: type: array items: $ref: "#/components/schemas/SandboxVolumeMount" + sidecars: + type: array + description: Sidecar microVMs to attach to the sandbox, at most four, at most one with the proxy role. Requires the team's sandbox-sidecars feature. + maxItems: 4 + items: + $ref: "#/components/schemas/SidecarAttachment" SandboxIam: type: object From d13ad7c6b0d4ca8d9753a7e64d0963239a315089 Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Fri, 11 Sep 2026 16:56:13 +0000 Subject: [PATCH 02/17] js-sdk: sidecars on sandbox create and info Add the `sidecars` create option (SidecarAttachment: entry, version, config, secrets) serialised into NewSandbox.sidecars from the known keys only, and `sidecars` on SandboxInfo from both inspect and list (SidecarInfo: entry, version, role, class, state, name, address, ports, lastError; empty list when the API sends none). Sidecar rejections carry a SIDECAR_* semantic code in the Error body's error_code: 400s become InvalidArgumentError, SIDECAR_FAILED stays a SandboxError, and both keep the code and the API message (which names the entry) in the message and the HTTP status on statusCode. The same mapping covers SIDECAR_RULE_COLLISION on updateNetwork. The count limit is left to the API (SIDECAR_LIMIT); the generated tuple type from maxItems is cast rather than re-validated client-side. --- packages/js-sdk/src/index.ts | 5 + packages/js-sdk/src/sandbox/sandboxApi.ts | 187 ++++++++++++- .../js-sdk/tests/sandbox/sidecars.test.ts | 249 ++++++++++++++++++ 3 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 packages/js-sdk/tests/sandbox/sidecars.test.ts diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index 1a49508e63..758916ee04 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -85,6 +85,11 @@ export type { SandboxNetworkTransformContext, SandboxNetworkTransformResolver, SandboxNetworkUpdate, + SidecarAttachment, + SidecarInfo, + SidecarRole, + SidecarClass, + SidecarState, SandboxOnTimeout, SandboxLifecycle, SandboxInfoLifecycle, diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 28a2183a0d..15b3f6dc42 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -575,6 +575,99 @@ type SandboxForkResponse = } | Error +/** + * Sidecar microVM to attach to a sandbox at creation, declared from the E2B + * sidecar catalog. + * + * A sidecar runs next to the sandbox inside its private network. The catalog + * carries two roles: a `proxy` sidecar (`'iron-proxy'`) that the sandbox's + * egress is steered through and that swaps a placeholder token for the real + * secret value on the way out, so the secret never enters the sandbox; and a + * `service` sidecar (`'redis'`) the sandbox talks to directly. Code in the + * sandbox reaches a sidecar by the name `{entry}.sidecar.e2b.local`, never by + * IP. + * + * Sidecars are ephemeral: torn down at pause and relaunched at resume with + * freshly injected configuration and secrets. Attaching one requires the + * team's `sandbox-sidecars` feature. + * + * @example + * ```ts + * const sandbox = await Sandbox.create({ + * sidecars: [ + * { entry: 'redis' }, + * { + * entry: 'iron-proxy', + * // Slot names and config keys are defined by the catalog entry. + * secrets: { upstream: '${e2b.secrets.openai-key}' }, + * }, + * ], + * }) + * ``` + */ +export type SidecarAttachment = { + /** Catalog entry name, e.g. `'iron-proxy'` or `'redis'`. */ + entry: string + + /** Catalog entry version. Defaults to the entry's current version. */ + version?: string + + /** + * Entry-specific configuration, validated against the entry's schema by the + * API. String values may reference a secret as `'${e2b.secrets.}'`; + * the platform resolves the reference when it injects the configuration, + * so the value never passes through the SDK. + */ + config?: Record + + /** + * Secret slots the entry declares, keyed by slot name. Each value is a + * secret reference (`'${e2b.secrets.}'`), never the secret itself. + * Every slot the entry declares has to be filled. + */ + secrets?: Record +} + +/** + * Role of a sidecar: `'proxy'` steers the sandbox's egress through it, + * `'service'` is reached by the sandbox directly. + */ +export type SidecarRole = 'proxy' | 'service' + +/** + * Lifecycle class of a sidecar. Only `'ephemeral'` sidecars can be attached in + * this version. + */ +export type SidecarClass = 'ephemeral' | 'stateful' + +/** + * State of a sidecar. `'failed'` is reached after one automatic restart + * attempt; the sandbox itself keeps running. + */ +export type SidecarState = 'starting' | 'running' | 'failed' | 'stopped' + +/** + * A sidecar attached to a sandbox, as returned by the sandbox info and list + * endpoints. + */ +export type SidecarInfo = { + /** Catalog entry name. */ + entry: string + /** Catalog entry version. */ + version: string + role: SidecarRole + class: SidecarClass + state: SidecarState + /** Name the sandbox reaches the sidecar at (`{entry}.sidecar.e2b.local`). */ + name: string + /** Address of the sidecar inside the sandbox network. */ + address?: string + /** Ports the sidecar listens on. */ + ports?: number[] + /** Last error of the sidecar, set when `state` is `'failed'`. */ + lastError?: string +} + /** * Options for creating a new Sandbox. */ @@ -670,6 +763,14 @@ export interface SandboxOpts extends ConnectionOpts { */ volumeMounts?: Record + /** + * Sidecar microVMs to attach to the sandbox — at most four, at most one + * with the proxy role. See {@link SidecarAttachment}. + * + * @default undefined + */ + sidecars?: SidecarAttachment[] + /** * Sandbox URL. Used for local development */ @@ -919,6 +1020,12 @@ export interface SandboxInfo { */ volumeMounts?: Array<{ name: string; path: string }> + /** + * Sidecars attached to the sandbox, empty when there are none. See + * {@link SidecarInfo}. + */ + sidecars?: SidecarInfo[] + /** * Sandbox domain. */ @@ -1149,6 +1256,76 @@ function fromApiEgressProxy( } } +// The spec's maxItems renders as a tuple union; the count is the API's to +// enforce (SIDECAR_LIMIT), so the list is cast rather than re-validated here. +function buildSidecarsBody( + sidecars: SidecarAttachment[] +): NonNullable { + if (!Array.isArray(sidecars)) { + throw new InvalidArgumentError( + `sidecars must be an array of { entry, version?, config?, secrets? } (got ${describeValue(sidecars)}).` + ) + } + + return sidecars.map((sidecar, i) => { + if (!isPlainObject(sidecar) || typeof sidecar.entry !== 'string') { + throw new InvalidArgumentError( + `sidecars[${i}] must be an object with a string 'entry' naming a catalog entry (e.g. 'redis').` + ) + } + + return { + entry: sidecar.entry, + ...(sidecar.version != null ? { version: sidecar.version } : {}), + ...(sidecar.config != null ? { config: sidecar.config } : {}), + ...(sidecar.secrets != null ? { secrets: sidecar.secrets } : {}), + } + }) as NonNullable +} + +function fromApiSidecars( + sidecars: components['schemas']['SidecarInfo'][] | undefined +): SidecarInfo[] { + return (sidecars ?? []).map((sidecar) => ({ + entry: sidecar.entry, + version: sidecar.version, + role: sidecar.role, + class: sidecar.class, + state: sidecar.state, + name: sidecar.name, + ...(sidecar.address !== undefined ? { address: sidecar.address } : {}), + ...(sidecar.ports !== undefined ? { ports: sidecar.ports } : {}), + ...(sidecar.lastError !== undefined + ? { lastError: sidecar.lastError } + : {}), + })) +} + +/** + * Sidecar rejections carry a `SIDECAR_*` semantic code: validation failures + * as 400, a sidecar that did not start as `SIDECAR_FAILED` naming the entry. + * The code stays in the message so callers can tell them apart. + */ +function sidecarApiError(res: { + response: { status: number; statusText: string } + error?: unknown +}): Error | undefined { + const body = isPlainObject(res.error) ? res.error : undefined + const code = body?.error_code + if (typeof code !== 'string' || !code.startsWith('SIDECAR_')) { + return + } + + const status = res.response.status + const message = `${code}: ${body?.message ?? res.response.statusText}` + const err = + status === 400 + ? new InvalidArgumentError(message) + : new SandboxError(message) + err.statusCode = status + return err +} + function buildNetworkBody( network: SandboxNetworkOpts | undefined, iam: components['schemas']['SandboxIam'] | undefined @@ -1344,6 +1521,7 @@ export class SandboxApi extends ClientFactory { : undefined, sandboxDomain: res.data.domain || undefined, volumeMounts: res.data.volumeMounts ?? [], + sidecars: fromApiSidecars(res.data.sidecars), } } @@ -1503,7 +1681,7 @@ export class SandboxApi extends ClientFactory { throw new SandboxNotFoundError(`Sandbox ${sandboxId} not found`) } - const err = handleApiError(res) + const err = sidecarApiError(res) ?? handleApiError(res) if (err) { throw err } @@ -1744,6 +1922,10 @@ export class SandboxApi extends ClientFactory { ) } + if (opts?.sidecars != null) { + body.sidecars = buildSidecarsBody(opts.sidecars) + } + const apiOpts = this.resolveOpts(opts) const config = new ConnectionConfig(apiOpts) const client = new ApiClient(config) @@ -1752,7 +1934,7 @@ export class SandboxApi extends ClientFactory { signal: config.getSignal(apiOpts?.requestTimeoutMs, apiOpts?.signal), }) - const err = handleApiError(res) + const err = sidecarApiError(res) ?? handleApiError(res) if (err) { throw err } @@ -1975,6 +2157,7 @@ export class SandboxPaginator extends Paginator { memoryMB: sandbox.memoryMB, envdVersion: sandbox.envdVersion, volumeMounts: sandbox.volumeMounts ?? [], + sidecars: fromApiSidecars(sandbox.sidecars), }) ) } diff --git a/packages/js-sdk/tests/sandbox/sidecars.test.ts b/packages/js-sdk/tests/sandbox/sidecars.test.ts new file mode 100644 index 0000000000..3a4fdc5308 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/sidecars.test.ts @@ -0,0 +1,249 @@ +import { afterAll, afterEach, beforeAll, expect, test } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' + +import { InvalidArgumentError, Sandbox, SandboxError } from '../../src' +import { TEST_API_KEY, apiUrl } from '../setup' + +const sandboxId = 'test-sandbox-id' + +const redisInfo = { + entry: 'redis', + version: '7.4.1', + role: 'service', + class: 'ephemeral', + state: 'running', + name: 'redis.sidecar.e2b.local', + address: '169.254.0.25', + ports: [6379], +} + +const failedProxyInfo = { + entry: 'iron-proxy', + version: '0.4.1', + role: 'proxy', + class: 'ephemeral', + state: 'failed', + name: 'iron-proxy.sidecar.e2b.local', + lastError: 'readiness probe timed out', +} + +const sandboxDetail = { + sandboxID: sandboxId, + templateID: 'base', + clientID: 'test-client', + envdVersion: '0.2.4', + startedAt: '2026-01-01T00:00:00Z', + endAt: '2026-01-01T01:00:00Z', + state: 'running', + cpuCount: 2, + memoryMB: 512, + diskSizeMB: 1024, +} + +let lastCreateBody: Record | undefined +let createResponse: () => HttpResponse +let infoSidecars: unknown[] | undefined + +const server = setupServer( + http.post(apiUrl('/sandboxes'), async ({ request }) => { + lastCreateBody = (await request.json()) as Record + return createResponse() + }), + http.get(apiUrl(`/sandboxes/${sandboxId}`), () => + HttpResponse.json({ ...sandboxDetail, sidecars: infoSidecars }) + ), + http.get(apiUrl('/v2/sandboxes'), () => + HttpResponse.json([{ ...sandboxDetail, sidecars: infoSidecars }]) + ), + http.put(apiUrl(`/sandboxes/${sandboxId}/network`), () => + HttpResponse.json( + { + code: 400, + error_code: 'SIDECAR_RULE_COLLISION', + message: 'api.openai.com is routed through the iron-proxy sidecar', + }, + { status: 400 } + ) + ) +) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + +afterAll(() => server.close()) + +afterEach(() => { + lastCreateBody = undefined + infoSidecars = undefined + createResponse = () => + HttpResponse.json({ + sandboxID: sandboxId, + templateID: 'base', + envdVersion: '0.2.4', + }) + server.resetHandlers() +}) + +createResponse = () => + HttpResponse.json({ + sandboxID: sandboxId, + templateID: 'base', + envdVersion: '0.2.4', + }) + +test('Sandbox.create sends the sidecars in the request body', async () => { + await Sandbox.create('base', { + apiKey: TEST_API_KEY, + sidecars: [ + { entry: 'redis' }, + { + entry: 'iron-proxy', + version: '0.4.1', + config: { allow: ['api.openai.com'] }, + secrets: { upstream: '${e2b.secrets.openai-key}' }, + }, + ], + }) + + expect(lastCreateBody?.sidecars).toEqual([ + { entry: 'redis' }, + { + entry: 'iron-proxy', + version: '0.4.1', + config: { allow: ['api.openai.com'] }, + secrets: { upstream: '${e2b.secrets.openai-key}' }, + }, + ]) +}) + +test('Sandbox.create omits sidecars when not provided', async () => { + await Sandbox.create('base', { apiKey: TEST_API_KEY }) + + expect(lastCreateBody).toBeDefined() + expect(lastCreateBody).not.toHaveProperty('sidecars') +}) + +test('Sandbox.create strips unknown sidecar properties', async () => { + await Sandbox.create('base', { + apiKey: TEST_API_KEY, + sidecars: [ + // An untyped caller can copy an extra key out of a config file; the + // API rejects unknown properties. + { entry: 'redis', image: 'redis:7' } as any, + ], + }) + + expect(lastCreateBody?.sidecars).toEqual([{ entry: 'redis' }]) +}) + +test('Sandbox.create rejects a sidecar without an entry before any request', async () => { + await expect( + Sandbox.create('base', { + apiKey: TEST_API_KEY, + sidecars: [{ version: '7.4.1' } as any], + }) + ).rejects.toThrowError(InvalidArgumentError) + + await expect( + Sandbox.create('base', { + apiKey: TEST_API_KEY, + sidecars: { entry: 'redis' } as any, + }) + ).rejects.toThrowError(InvalidArgumentError) + + expect(lastCreateBody).toBeUndefined() +}) + +test('Sandbox.getInfo returns the sidecars with their state', async () => { + infoSidecars = [redisInfo, failedProxyInfo] + + const info = await Sandbox.getInfo(sandboxId, { apiKey: TEST_API_KEY }) + + expect(info.sidecars).toEqual([redisInfo, failedProxyInfo]) +}) + +test('Sandbox.getInfo returns an empty sidecar list when the API sends none', async () => { + const info = await Sandbox.getInfo(sandboxId, { apiKey: TEST_API_KEY }) + + expect(info.sidecars).toEqual([]) +}) + +test('Sandbox.list returns the sidecars of each sandbox', async () => { + infoSidecars = [redisInfo] + + const [info] = await Sandbox.list({ apiKey: TEST_API_KEY }).nextItems() + + expect(info.sidecars).toEqual([redisInfo]) +}) + +test('a SIDECAR_* 400 surfaces as InvalidArgumentError with the code preserved', async () => { + createResponse = () => + HttpResponse.json( + { + code: 400, + error_code: 'SIDECAR_UNKNOWN_ENTRY', + message: 'unknown sidecar entry "memcached"', + }, + { status: 400 } + ) + + const err = await Sandbox.create('base', { + apiKey: TEST_API_KEY, + sidecars: [{ entry: 'memcached' }], + }).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(InvalidArgumentError) + expect((err as SandboxError).statusCode).toBe(400) + expect((err as Error).message).toContain('SIDECAR_UNKNOWN_ENTRY') + expect((err as Error).message).toContain('memcached') +}) + +test('SIDECAR_FAILED keeps the entry name and is not an argument error', async () => { + createResponse = () => + HttpResponse.json( + { + code: 500, + error_code: 'SIDECAR_FAILED', + message: 'sidecar "redis" failed to become ready', + }, + { status: 500 } + ) + + const err = await Sandbox.create('base', { + apiKey: TEST_API_KEY, + sidecars: [{ entry: 'redis' }], + }).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(SandboxError) + expect(err).not.toBeInstanceOf(InvalidArgumentError) + expect((err as SandboxError).statusCode).toBe(500) + expect((err as Error).message).toContain('SIDECAR_FAILED') + expect((err as Error).message).toContain('redis') +}) + +test('a 400 without a sidecar code keeps the generic mapping', async () => { + createResponse = () => + HttpResponse.json( + { code: 400, message: 'invalid template' }, + { status: 400 } + ) + + const err = await Sandbox.create('base', { apiKey: TEST_API_KEY }).catch( + (e: unknown) => e + ) + + expect(err).toBeInstanceOf(SandboxError) + expect(err).not.toBeInstanceOf(InvalidArgumentError) + expect((err as Error).message).toBe('400: invalid template') +}) + +test('Sandbox.updateNetwork surfaces SIDECAR_RULE_COLLISION as InvalidArgumentError', async () => { + const err = await Sandbox.updateNetwork( + sandboxId, + { allowOut: ['api.openai.com'] }, + { apiKey: TEST_API_KEY } + ).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(InvalidArgumentError) + expect((err as Error).message).toContain('SIDECAR_RULE_COLLISION') +}) From 54e3442f3b9a9502a0397cca2e335e77a1c6afcf Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Fri, 11 Sep 2026 16:57:28 +0000 Subject: [PATCH 03/17] python-sdk: sidecars on sandbox create and info Add the `sidecars` create option (SidecarAttachment TypedDict: entry, version, config, secrets) on Sandbox.create and AsyncSandbox.create, built into NewSandbox.sidecars from the known keys only, and SandboxInfo.sidecars (SidecarInfo dataclass: entry, version, role, class_, state, name, address, ports, last_error) from both inspect and list, empty when the API sends none. `class_` carries the wire's `class`, the same rename the generated client uses. SIDECAR_* rejections are read off the Error body's error_code: 400s raise InvalidArgumentException, SIDECAR_FAILED stays a SandboxException, both keep the code and the API message (which names the entry) and the HTTP status. The same mapping covers SIDECAR_RULE_COLLISION on update_network; a 404 there still wins. Any other body falls through to the existing handle_api_exception. --- packages/python-sdk/e2b/__init__.py | 11 + .../python-sdk/e2b/sandbox/sandbox_api.py | 204 +++++++++++- packages/python-sdk/e2b/sandbox_async/main.py | 6 + .../e2b/sandbox_async/sandbox_api.py | 10 +- packages/python-sdk/e2b/sandbox_sync/main.py | 6 + .../e2b/sandbox_sync/sandbox_api.py | 10 +- .../tests/shared/sandbox/test_sidecars.py | 304 ++++++++++++++++++ 7 files changed, 546 insertions(+), 5 deletions(-) create mode 100644 packages/python-sdk/tests/shared/sandbox/test_sidecars.py diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index ff010de53d..2201700798 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -98,6 +98,11 @@ SandboxNetworkSelector, SandboxNetworkSelectorContext, SandboxNetworkTransform, + SidecarAttachment, + SidecarClass, + SidecarInfo, + SidecarRole, + SidecarState, SandboxNetworkTransformContext, SandboxNetworkTransformResolver, SandboxNetworkUpdate, @@ -230,6 +235,12 @@ "SandboxNetworkRule", "SandboxNetworkRuleInfo", "SandboxNetworkRules", + # Sidecars + "SidecarAttachment", + "SidecarInfo", + "SidecarRole", + "SidecarClass", + "SidecarState", "SandboxNetworkTransform", "SandboxNetworkTransformContext", "SandboxNetworkTransformResolver", diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 372a1c409f..5ddd6bf69a 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -1,4 +1,5 @@ import inspect +import json from dataclasses import dataclass, field from datetime import datetime from typing import ( @@ -61,9 +62,21 @@ from e2b.api.client.models import ( SandboxNetworkUpdateConfigRules, ) +from e2b.api.client.models import ( + SidecarAttachment as ClientSidecarAttachment, +) +from e2b.api.client.models import ( + SidecarAttachmentConfig as ClientSidecarAttachmentConfig, +) +from e2b.api.client.models import ( + SidecarAttachmentSecrets as ClientSidecarAttachmentSecrets, +) +from e2b.api.client.models import ( + SidecarInfo as ClientSidecarInfo, +) from e2b.api.client.types import UNSET, Unset from e2b.connection_config import ApiParams -from e2b.exceptions import InvalidArgumentException +from e2b.exceptions import InvalidArgumentException, SandboxException from e2b.sandbox.mcp import McpServer as BaseMcpServer from e2b.sandbox.iam import ( IamTokenPlaceholders, @@ -493,6 +506,97 @@ class SandboxNetworkInfo(TypedDict, total=False): https_ports: List[int] +class SidecarAttachment(TypedDict): + """ + Sidecar microVM to attach to a sandbox at creation, declared from the E2B + sidecar catalog. + + A sidecar runs next to the sandbox inside its private network. The catalog + carries two roles: a ``proxy`` sidecar (``"iron-proxy"``) that the + sandbox's egress is steered through and that swaps a placeholder token for + the real secret value on the way out, so the secret never enters the + sandbox; and a ``service`` sidecar (``"redis"``) the sandbox talks to + directly. Code in the sandbox reaches a sidecar by the name + ``{entry}.sidecar.e2b.local``, never by IP. + + Sidecars are ephemeral: torn down at pause and relaunched at resume with + freshly injected configuration and secrets. Attaching one requires the + team's ``sandbox-sidecars`` feature:: + + sandbox = Sandbox.create( + sidecars=[ + {"entry": "redis"}, + { + "entry": "iron-proxy", + # Slot names and config keys are defined by the catalog entry. + "secrets": {"upstream": "${e2b.secrets.openai-key}"}, + }, + ], + ) + """ + + entry: str + """Catalog entry name, e.g. ``"iron-proxy"`` or ``"redis"``.""" + + version: NotRequired[str] + """Catalog entry version. Defaults to the entry's current version.""" + + config: NotRequired[Dict[str, Any]] + """ + Entry-specific configuration, validated against the entry's schema by the + API. String values may reference a secret as ``"${e2b.secrets.}"``; + the platform resolves the reference when it injects the configuration, so + the value never passes through the SDK. + """ + + secrets: NotRequired[Dict[str, str]] + """ + Secret slots the entry declares, keyed by slot name. Each value is a secret + reference (``"${e2b.secrets.}"``), never the secret itself. Every + slot the entry declares has to be filled. + """ + + +SidecarRole = Literal["proxy", "service"] +""" +Role of a sidecar: ``"proxy"`` steers the sandbox's egress through it, +``"service"`` is reached by the sandbox directly. +""" + +SidecarClass = Literal["ephemeral", "stateful"] +"""Lifecycle class of a sidecar. Only ``"ephemeral"`` sidecars can be attached in this version.""" + +SidecarState = Literal["starting", "running", "failed", "stopped"] +""" +State of a sidecar. ``"failed"`` is reached after one automatic restart +attempt; the sandbox itself keeps running. +""" + + +@dataclass +class SidecarInfo: + """A sidecar attached to a sandbox, as returned by sandbox info and list.""" + + entry: str + """Catalog entry name.""" + version: str + """Catalog entry version.""" + role: SidecarRole + """Role of the sidecar.""" + class_: SidecarClass + """Lifecycle class of the sidecar (the wire field ``class``).""" + state: SidecarState + """Current state of the sidecar.""" + name: str + """Name the sandbox reaches the sidecar at (``{entry}.sidecar.e2b.local``).""" + address: Optional[str] = None + """Address of the sidecar inside the sandbox network.""" + ports: List[int] = field(default_factory=list) + """Ports the sidecar listens on.""" + last_error: Optional[str] = None + """Last error of the sidecar, set when ``state`` is ``"failed"``.""" + + class SandboxOnTimeoutPause(TypedDict): """ Object form of `on_timeout` that auto-pauses the sandbox when the timeout is @@ -796,6 +900,101 @@ def build_network_config( return body +def build_sidecars_body( + sidecars: Optional[List[SidecarAttachment]], +) -> Optional[List[ClientSidecarAttachment]]: + """Resolve the ``sidecars`` option into the API client body. + + Rebuilt from the known keys so stray keys in the caller's dicts never + reach the wire. Catalog membership, the count and the proxy limit are the + API's to check; only the shape an untyped caller can get wrong is checked + here, so the error names the option instead of surfacing as a ``KeyError``. + """ + if sidecars is None: + return None + + if isinstance(sidecars, (str, bytes, Mapping)) or not isinstance( + sidecars, Iterable + ): + raise InvalidArgumentException( + "sidecars must be a list of dicts with a string 'entry' " + "(e.g. [{'entry': 'redis'}])." + ) + + body: List[ClientSidecarAttachment] = [] + for i, sidecar in enumerate(sidecars): + if not isinstance(sidecar, Mapping) or not isinstance( + sidecar.get("entry"), str + ): + raise InvalidArgumentException( + f"sidecars[{i}] must be a dict with a string 'entry' naming a " + "catalog entry (e.g. 'redis')." + ) + + attachment = ClientSidecarAttachment(entry=sidecar["entry"]) + if sidecar.get("version") is not None: + attachment.version = sidecar["version"] + if sidecar.get("config") is not None: + config = ClientSidecarAttachmentConfig() + config.additional_properties = dict(sidecar["config"]) + attachment.config = config + if sidecar.get("secrets") is not None: + secrets = ClientSidecarAttachmentSecrets() + secrets.additional_properties = dict(sidecar["secrets"]) + attachment.secrets = secrets + body.append(attachment) + + return body + + +def from_client_sidecars( + sidecars: Union[Unset, List[ClientSidecarInfo]], +) -> List[SidecarInfo]: + if isinstance(sidecars, Unset): + return [] + + return [ + SidecarInfo( + entry=sidecar.entry, + version=sidecar.version, + role=cast(SidecarRole, sidecar.role.value), + class_=cast(SidecarClass, sidecar.class_.value), + state=cast(SidecarState, sidecar.state.value), + name=sidecar.name, + address=sidecar.address if isinstance(sidecar.address, str) else None, + ports=list(sidecar.ports) if not isinstance(sidecar.ports, Unset) else [], + last_error=( + sidecar.last_error if isinstance(sidecar.last_error, str) else None + ), + ) + for sidecar in sidecars + ] + + +def sidecar_api_exception(res: Any) -> Optional[Exception]: + """Map a ``SIDECAR_*`` rejection, or ``None`` for any other response. + + Sidecar validation failures are 400 with a ``SIDECAR_*`` semantic code; a + sidecar that did not start is ``SIDECAR_FAILED`` naming the entry. The code + stays in the message so callers can tell them apart. + """ + try: + body = json.loads(res.content) if res.content else {} + except json.JSONDecodeError: + return None + if not isinstance(body, dict): + return None + + code = body.get("error_code") + if not isinstance(code, str) or not code.startswith("SIDECAR_"): + return None + + message = f"{code}: {body.get('message', res.status_code)}" + if res.status_code == 400: + return InvalidArgumentException(message, status_code=400) + return SandboxException(message, status_code=res.status_code) + + def build_iam_config( iam: Optional[SandboxIamOpts], ) -> Optional[ClientSandboxIam]: @@ -1053,6 +1252,8 @@ class SandboxInfo: """Sandbox lifecycle configuration.""" volume_mounts: List[Dict[str, str]] = field(default_factory=list) """Volume mounts for the sandbox.""" + sidecars: List[SidecarInfo] = field(default_factory=list) + """Sidecars attached to the sandbox, empty when there are none.""" @classmethod def _from_sandbox_data( @@ -1083,6 +1284,7 @@ def _from_sandbox_data( ] if not isinstance(sandbox.volume_mounts, Unset) else [], + sidecars=from_client_sidecars(sandbox.sidecars), allow_internet_access=allow_internet_access, network=network, lifecycle=lifecycle, diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index da6bdc1f3b..424bb7df7a 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -28,6 +28,7 @@ SandboxLifecycle, SandboxMetrics, SandboxNetworkOpts, + SidecarAttachment, SandboxNetworkUpdate, SandboxOnResume, SnapshotInfo, @@ -182,6 +183,7 @@ async def create( iam: Optional[SandboxIamOpts] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[SandboxAsyncVolumeMount] = None, + sidecars: Optional[List[SidecarAttachment]] = None, logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Self: @@ -201,6 +203,7 @@ async def create( :param iam: Sandbox workload identity configuration. A non-empty ``tokens`` map enables workload identity for the sandbox; token definitions can be created with :meth:`Secret.iam_token`. Example: ``{"tokens": {"aws": Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID")}}``. Registered tokens are exposed to ``network.rules`` ``transform`` callables as ``ctx.iam.tokens[name]`` placeholders, which the egress proxy resolves per request :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` or ``"pause"`` (omitted from the request when unset, leaving the API's default, currently ``"kill"``, in effect), or an object ``{"action": "pause"|"kill", "keep_memory": bool}`` where ``keep_memory`` set to ``False`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); an omitted ``keep_memory`` leaves the snapshot kind to the API; ``auto_resume``: leave unset to let the API pick the behavior, set ``False`` to opt out explicitly, or ``True`` (only when ``on_timeout`` action is ``"pause"``). Example: ``{"on_timeout": {"action": "pause", "keep_memory": False}}`` :param volume_mounts: Dictionary mapping mount paths to AsyncVolume instances or volume names + :param sidecars: Sidecar microVMs to attach to the sandbox from the E2B catalog — at most four, at most one with the proxy role. Each is a :class:`SidecarAttachment`: ``{"entry": "redis"}`` for a service sidecar the sandbox reaches at ``redis.sidecar.e2b.local``, or ``{"entry": "iron-proxy", "secrets": {"": "${e2b.secrets.}"}}`` for the proxy sidecar that substitutes the real secret value on egress so it never enters the sandbox. Sidecars are ephemeral (torn down at pause, relaunched at resume) and need the team's ``sandbox-sidecars`` feature :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: A Sandbox instance for the new sandbox @@ -234,6 +237,7 @@ async def create( iam=iam, lifecycle=lifecycle, volume_mounts=transformed_mounts, + sidecars=sidecars, logger=logger, **opts, ) @@ -1157,6 +1161,7 @@ async def _create( iam: Optional[SandboxIamOpts] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[list] = None, + sidecars: Optional[List[SidecarAttachment]] = None, logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Self: @@ -1183,6 +1188,7 @@ async def _create( iam=iam, lifecycle=lifecycle, volume_mounts=volume_mounts, + sidecars=sidecars, logger=logger, **params, ) diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index 8fd0dcb3fc..0ee2aa3e03 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -57,12 +57,15 @@ SandboxNetworkOpts, SandboxNetworkUpdate, SandboxOnResume, + SidecarAttachment, resolve_connect_memory, SandboxQuery, SnapshotInfo, build_iam_config, build_lifecycle_config, build_network_config, + build_sidecars_body, + sidecar_api_exception, ) from e2b.sandbox_async.paginator import AsyncSandboxPaginator @@ -204,7 +207,7 @@ async def _cls_update_network( raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found") if res.status_code >= 300: - raise handle_api_exception(res) + raise sidecar_api_exception(res) or handle_api_exception(res) @classmethod async def _create_sandbox( @@ -220,6 +223,7 @@ async def _create_sandbox( iam: Optional[SandboxIamOpts] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[List[SandboxVolumeMountAPI]] = None, + sidecars: Optional[List[SidecarAttachment]] = None, logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> SandboxCreateResponse: @@ -232,6 +236,7 @@ async def _create_sandbox( # against the workload tokens this request registers. iam_body = build_iam_config(iam) network_body = build_network_config(network, iam_body) + sidecars_body = build_sidecars_body(sidecars) body = NewSandbox( template_id=template, auto_pause=lifecycle_body.auto_pause, @@ -246,6 +251,7 @@ async def _create_sandbox( network=SandboxNetworkConfig(**network_body) if network_body else UNSET, iam=iam_body or UNSET, volume_mounts=volume_mounts if volume_mounts else UNSET, + sidecars=sidecars_body if sidecars_body is not None else UNSET, ) api_client = get_api_client(config) @@ -255,7 +261,7 @@ async def _create_sandbox( ) if res.status_code >= 300: - raise handle_api_exception(res) + raise sidecar_api_exception(res) or handle_api_exception(res) if res.parsed is None: raise Exception("Body of the request is None") diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 3a9e172f34..2099cda95c 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -27,6 +27,7 @@ SandboxLifecycle, SandboxMetrics, SandboxNetworkOpts, + SidecarAttachment, SandboxNetworkUpdate, SandboxOnResume, SnapshotInfo, @@ -178,6 +179,7 @@ def create( iam: Optional[SandboxIamOpts] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[SandboxVolumeMount] = None, + sidecars: Optional[List[SidecarAttachment]] = None, logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Self: @@ -197,6 +199,7 @@ def create( :param iam: Sandbox workload identity configuration. A non-empty ``tokens`` map enables workload identity for the sandbox; token definitions can be created with :meth:`Secret.iam_token`. Example: ``{"tokens": {"aws": Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID")}}``. Registered tokens are exposed to ``network.rules`` ``transform`` callables as ``ctx.iam.tokens[name]`` placeholders, which the egress proxy resolves per request :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` or ``"pause"`` (omitted from the request when unset, leaving the API's default, currently ``"kill"``, in effect), or an object ``{"action": "pause"|"kill", "keep_memory": bool}`` where ``keep_memory`` set to ``False`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); an omitted ``keep_memory`` leaves the snapshot kind to the API; ``auto_resume``: leave unset to let the API pick the behavior, set ``False`` to opt out explicitly, or ``True`` (only when ``on_timeout`` action is ``"pause"``). Example: ``{"on_timeout": {"action": "pause", "keep_memory": False}}`` :param volume_mounts: Dictionary mapping mount paths to Volume instances or volume names + :param sidecars: Sidecar microVMs to attach to the sandbox from the E2B catalog — at most four, at most one with the proxy role. Each is a :class:`SidecarAttachment`: ``{"entry": "redis"}`` for a service sidecar the sandbox reaches at ``redis.sidecar.e2b.local``, or ``{"entry": "iron-proxy", "secrets": {"": "${e2b.secrets.}"}}`` for the proxy sidecar that substitutes the real secret value on egress so it never enters the sandbox. Sidecars are ephemeral (torn down at pause, relaunched at resume) and need the team's ``sandbox-sidecars`` feature :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: A Sandbox instance for the new sandbox @@ -230,6 +233,7 @@ def create( iam=iam, lifecycle=lifecycle, volume_mounts=transformed_mounts, + sidecars=sidecars, logger=logger, **opts, ) @@ -1153,6 +1157,7 @@ def _create( iam: Optional[SandboxIamOpts] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[list] = None, + sidecars: Optional[List[SidecarAttachment]] = None, logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Self: @@ -1179,6 +1184,7 @@ def _create( iam=iam, lifecycle=lifecycle, volume_mounts=volume_mounts, + sidecars=sidecars, logger=logger, **params, ) diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index 61c668ce04..a0ff4d3a21 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -56,12 +56,15 @@ SandboxNetworkOpts, SandboxNetworkUpdate, SandboxOnResume, + SidecarAttachment, resolve_connect_memory, SandboxQuery, SnapshotInfo, build_iam_config, build_lifecycle_config, build_network_config, + build_sidecars_body, + sidecar_api_exception, ) from e2b.sandbox_sync.paginator import SandboxPaginator, get_api_client @@ -203,7 +206,7 @@ def _cls_update_network( raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found") if res.status_code >= 300: - raise handle_api_exception(res) + raise sidecar_api_exception(res) or handle_api_exception(res) @classmethod def _create_sandbox( @@ -219,6 +222,7 @@ def _create_sandbox( iam: Optional[SandboxIamOpts] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[List[SandboxVolumeMountAPI]] = None, + sidecars: Optional[List[SidecarAttachment]] = None, logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> SandboxCreateResponse: @@ -231,6 +235,7 @@ def _create_sandbox( # against the workload tokens this request registers. iam_body = build_iam_config(iam) network_body = build_network_config(network, iam_body) + sidecars_body = build_sidecars_body(sidecars) body = NewSandbox( template_id=template, auto_pause=lifecycle_body.auto_pause, @@ -245,6 +250,7 @@ def _create_sandbox( network=SandboxNetworkConfig(**network_body) if network_body else UNSET, iam=iam_body or UNSET, volume_mounts=volume_mounts if volume_mounts else UNSET, + sidecars=sidecars_body if sidecars_body is not None else UNSET, ) api_client = get_api_client(config) @@ -254,7 +260,7 @@ def _create_sandbox( ) if res.status_code >= 300: - raise handle_api_exception(res) + raise sidecar_api_exception(res) or handle_api_exception(res) if res.parsed is None: raise Exception("Body of the request is None") diff --git a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py new file mode 100644 index 0000000000..798fc7eac1 --- /dev/null +++ b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py @@ -0,0 +1,304 @@ +from types import SimpleNamespace +from typing import Any, Dict, List, cast +from unittest.mock import AsyncMock, Mock + +import pytest + +from e2b import AsyncSandbox, Sandbox, SandboxInfo, SidecarInfo +from e2b.api.client.api.sandboxes import ( + post_sandboxes, + put_sandboxes_sandbox_id_network, +) +from e2b.api.client.models import ListedSandbox, SandboxDetail +from e2b.api.client.models import Sandbox as SandboxModel +from e2b.exceptions import ( + InvalidArgumentException, + SandboxException, + SandboxNotFoundException, +) +from e2b.sandbox.sandbox_api import build_sidecars_body, sidecar_api_exception + +REDIS_INFO: Dict[str, Any] = { + "entry": "redis", + "version": "7.4.1", + "role": "service", + "class": "ephemeral", + "state": "running", + "name": "redis.sidecar.e2b.local", + "address": "169.254.0.25", + "ports": [6379], +} + +FAILED_PROXY_INFO: Dict[str, Any] = { + "entry": "iron-proxy", + "version": "0.4.1", + "role": "proxy", + "class": "ephemeral", + "state": "failed", + "name": "iron-proxy.sidecar.e2b.local", + "lastError": "readiness probe timed out", +} + +SANDBOX_DETAIL: Dict[str, Any] = { + "sandboxID": "sbx-test", + "templateID": "template-id", + "clientID": "client-id", + "envdVersion": "0.2.4", + "startedAt": "2026-01-01T00:00:00Z", + "endAt": "2026-01-01T01:00:00Z", + "state": "running", + "cpuCount": 2, + "memoryMB": 512, + "diskSizeMB": 1024, +} + + +def _response(status_code: int, content: bytes = b"", parsed=None): + return SimpleNamespace( + status_code=status_code, content=content, headers={}, parsed=parsed + ) + + +def _created_sandbox(): + return _response( + 200, + parsed=SandboxModel( + client_id="client-id", + envd_version="0.2.4", + sandbox_id="sbx-test", + template_id="template-id", + ), + ) + + +def _sync_request_body(monkeypatch, api_key: str, **kwargs) -> Dict[str, Any]: + request = Mock(return_value=_created_sandbox()) + monkeypatch.setattr(post_sandboxes, "sync_detailed", request) + + Sandbox.create(api_key=api_key, **kwargs) + + return request.call_args.kwargs["body"].to_dict() + + +async def _async_request_body(monkeypatch, api_key: str, **kwargs) -> Dict[str, Any]: + request = AsyncMock(return_value=_created_sandbox()) + monkeypatch.setattr(post_sandboxes, "asyncio_detailed", request) + + await AsyncSandbox.create(api_key=api_key, **kwargs) + + return request.call_args.kwargs["body"].to_dict() + + +SIDECARS: List[Any] = [ + {"entry": "redis"}, + { + "entry": "iron-proxy", + "version": "0.4.1", + "config": {"allow": ["api.openai.com"]}, + "secrets": {"upstream": "${e2b.secrets.openai-key}"}, + }, +] + +SIDECARS_WIRE = [ + {"entry": "redis"}, + { + "entry": "iron-proxy", + "version": "0.4.1", + "config": {"allow": ["api.openai.com"]}, + "secrets": {"upstream": "${e2b.secrets.openai-key}"}, + }, +] + + +def test_create_sends_the_sidecars(monkeypatch, test_api_key): + body = _sync_request_body(monkeypatch, test_api_key, sidecars=SIDECARS) + + assert body["sidecars"] == SIDECARS_WIRE + + +async def test_async_create_sends_the_sidecars(monkeypatch, test_api_key): + body = await _async_request_body(monkeypatch, test_api_key, sidecars=SIDECARS) + + assert body["sidecars"] == SIDECARS_WIRE + + +def test_create_omits_sidecars_when_not_provided(monkeypatch, test_api_key): + body = _sync_request_body(monkeypatch, test_api_key) + + assert "sidecars" not in body + + +def test_create_strips_unknown_sidecar_keys(): + # An untyped caller can copy an extra key out of a config file; the API + # rejects unknown properties. + body = build_sidecars_body(cast(Any, [{"entry": "redis", "image": "redis:7"}])) + + assert body is not None + assert [s.to_dict() for s in body] == [{"entry": "redis"}] + + +@pytest.mark.parametrize( + "sidecars", + [ + pytest.param([{"version": "7.4.1"}], id="missing-entry"), + pytest.param([{"entry": 6379}], id="non-string-entry"), + pytest.param(["redis"], id="string-item"), + pytest.param({"entry": "redis"}, id="dict-instead-of-list"), + pytest.param("redis", id="string"), + ], +) +def test_create_rejects_a_malformed_sidecar_list(monkeypatch, test_api_key, sidecars): + request = Mock(return_value=_created_sandbox()) + monkeypatch.setattr(post_sandboxes, "sync_detailed", request) + + with pytest.raises(InvalidArgumentException, match="sidecars"): + Sandbox.create(api_key=test_api_key, sidecars=cast(Any, sidecars)) + + request.assert_not_called() + + +def _expected_infos() -> List[SidecarInfo]: + return [ + SidecarInfo( + entry="redis", + version="7.4.1", + role="service", + class_="ephemeral", + state="running", + name="redis.sidecar.e2b.local", + address="169.254.0.25", + ports=[6379], + ), + SidecarInfo( + entry="iron-proxy", + version="0.4.1", + role="proxy", + class_="ephemeral", + state="failed", + name="iron-proxy.sidecar.e2b.local", + last_error="readiness probe timed out", + ), + ] + + +def test_info_returns_the_sidecars_with_their_state(): + detail = SandboxDetail.from_dict( + {**SANDBOX_DETAIL, "sidecars": [REDIS_INFO, FAILED_PROXY_INFO]} + ) + + info = SandboxInfo._from_sandbox_detail(detail) + + assert info.sidecars == _expected_infos() + + +def test_info_returns_an_empty_sidecar_list_when_the_api_sends_none(): + info = SandboxInfo._from_sandbox_detail(SandboxDetail.from_dict(SANDBOX_DETAIL)) + + assert info.sidecars == [] + + +def test_list_returns_the_sidecars_of_each_sandbox(): + listed = ListedSandbox.from_dict({**SANDBOX_DETAIL, "sidecars": [REDIS_INFO]}) + + info = SandboxInfo._from_listed_sandbox(listed) + + assert info.sidecars == _expected_infos()[:1] + + +def test_a_sidecar_400_is_an_argument_error_with_the_code_preserved( + monkeypatch, test_api_key +): + request = Mock( + return_value=_response( + 400, + b'{"code":400,"error_code":"SIDECAR_UNKNOWN_ENTRY",' + b'"message":"unknown sidecar entry \\"memcached\\""}', + ) + ) + monkeypatch.setattr(post_sandboxes, "sync_detailed", request) + + with pytest.raises(InvalidArgumentException) as excinfo: + Sandbox.create(api_key=test_api_key, sidecars=[{"entry": "memcached"}]) + + assert excinfo.value.status_code == 400 + assert "SIDECAR_UNKNOWN_ENTRY" in str(excinfo.value) + assert "memcached" in str(excinfo.value) + + +async def test_async_sidecar_400_is_an_argument_error(monkeypatch, test_api_key): + request = AsyncMock( + return_value=_response( + 400, b'{"code":400,"error_code":"SIDECAR_FLAG_OFF","message":"off"}' + ) + ) + monkeypatch.setattr(post_sandboxes, "asyncio_detailed", request) + + with pytest.raises(InvalidArgumentException, match="SIDECAR_FLAG_OFF"): + await AsyncSandbox.create(api_key=test_api_key, sidecars=[{"entry": "redis"}]) + + +def test_sidecar_failed_keeps_the_entry_name_and_is_not_an_argument_error( + monkeypatch, test_api_key +): + request = Mock( + return_value=_response( + 500, + b'{"code":500,"error_code":"SIDECAR_FAILED",' + b'"message":"sidecar \\"redis\\" failed to become ready"}', + ) + ) + monkeypatch.setattr(post_sandboxes, "sync_detailed", request) + + with pytest.raises(SandboxException) as excinfo: + Sandbox.create(api_key=test_api_key, sidecars=[{"entry": "redis"}]) + + assert not isinstance(excinfo.value, InvalidArgumentException) + assert excinfo.value.status_code == 500 + assert "SIDECAR_FAILED" in str(excinfo.value) + assert "redis" in str(excinfo.value) + + +@pytest.mark.parametrize( + "content", + [ + pytest.param(b'{"code":400,"message":"invalid template"}', id="no-code"), + pytest.param(b'{"error_code":"sandbox_create_failed"}', id="other-code"), + pytest.param(b"not json", id="not-json"), + pytest.param(b"", id="empty"), + pytest.param(b"[1]", id="not-an-object"), + ], +) +def test_other_errors_keep_the_generic_mapping(content): + assert sidecar_api_exception(_response(400, content)) is None + + +def test_update_network_surfaces_a_rule_collision_as_an_argument_error( + monkeypatch, test_api_key +): + request = Mock( + return_value=_response( + 400, + b'{"code":400,"error_code":"SIDECAR_RULE_COLLISION",' + b'"message":"api.openai.com is routed through the iron-proxy sidecar"}', + ) + ) + monkeypatch.setattr(put_sandboxes_sandbox_id_network, "sync_detailed", request) + + with pytest.raises(InvalidArgumentException, match="SIDECAR_RULE_COLLISION"): + Sandbox.update_network( + "sbx-test", {"allow_out": ["api.openai.com"]}, api_key=test_api_key + ) + + +def test_update_network_404_still_wins_over_the_sidecar_mapping( + monkeypatch, test_api_key +): + request = Mock( + return_value=_response( + 404, b'{"code":404,"error_code":"SIDECAR_X","message":"gone"}' + ) + ) + monkeypatch.setattr(put_sandboxes_sandbox_id_network, "sync_detailed", request) + + with pytest.raises(SandboxNotFoundException): + Sandbox.update_network("sbx-test", {}, api_key=test_api_key) From efffbc519a2f46dc4c2e45ffbc0f5b46ae69b263 Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Fri, 11 Sep 2026 17:00:36 +0000 Subject: [PATCH 04/17] cli: show sandbox sidecars `sandbox list` gains a SIDECARS column (entry:state, comma-separated, empty when none) and `sandbox info` prints the sandbox's sidecars as a kubectl-style table (entry, version, role, class, state, name, address, ports), indented under its label and omitted when the list is empty. The JSON output of both carries the field unchanged. formatTable is split out of renderTable so the info view can embed the table lines in its own output; renderTable keeps printing them. --- packages/cli/src/commands/sandbox/info.ts | 29 +++++++- packages/cli/src/commands/sandbox/list.ts | 16 +++- packages/cli/src/utils/table.ts | 32 +++++--- .../cli/tests/commands/sandbox/info.test.ts | 74 +++++++++++++++++++ .../cli/tests/commands/sandbox/list.test.ts | 33 +++++++++ packages/cli/tests/utils/table.test.ts | 23 ++++-- 6 files changed, 185 insertions(+), 22 deletions(-) create mode 100644 packages/cli/tests/commands/sandbox/info.test.ts diff --git a/packages/cli/src/commands/sandbox/info.ts b/packages/cli/src/commands/sandbox/info.ts index c15cb08209..c782ec5d83 100644 --- a/packages/cli/src/commands/sandbox/info.ts +++ b/packages/cli/src/commands/sandbox/info.ts @@ -1,8 +1,9 @@ import * as commander from 'commander' -import { NotFoundError, Sandbox } from 'e2b' +import { NotFoundError, Sandbox, SidecarInfo } from 'e2b' import { ensureAPIKey } from 'src/api' import { asBold } from 'src/utils/format' +import { formatTable } from 'src/utils/table' const fieldLabels: Partial> = { sandboxId: 'Sandbox ID', @@ -17,6 +18,7 @@ const fieldLabels: Partial> = { allowInternetAccess: 'Internet access', lifecycle: 'Lifecycle', network: 'Network', + sidecars: 'Sidecars', sandboxDomain: 'Sandbox domain', metadata: 'Metadata', } @@ -34,6 +36,7 @@ const fieldOrder = [ 'allowInternetAccess', 'lifecycle', 'network', + 'sidecars', 'sandboxDomain', 'metadata', ] @@ -71,7 +74,7 @@ export const infoCommand = new commander.Command('info') } }) -function renderPrettyInfo(info: Record) { +export function renderPrettyInfo(info: Record) { console.log( `\nSandbox info for ${asBold(String(info.sandboxId ?? 'unknown'))}:` ) @@ -87,8 +90,15 @@ function renderPrettyInfo(info: Record) { continue } + if (key === 'sidecars' && Array.isArray(value) && value.length === 0) { + continue + } + const label = fieldLabels[key] ?? key - const formattedValue = formatValue(value) + const formattedValue = + key === 'sidecars' && Array.isArray(value) + ? formatSidecarTable(value).join('\n') + : formatValue(value) if (formattedValue.includes('\n')) { const indentedValue = formattedValue @@ -105,6 +115,19 @@ function renderPrettyInfo(info: Record) { process.stdout.write('\n') } +export function formatSidecarTable(sidecars: SidecarInfo[]): string[] { + return formatTable(sidecars, [ + { header: 'Entry', value: (sidecar) => sidecar.entry }, + { header: 'Version', value: (sidecar) => sidecar.version }, + { header: 'Role', value: (sidecar) => sidecar.role }, + { header: 'Class', value: (sidecar) => sidecar.class }, + { header: 'State', value: (sidecar) => sidecar.state }, + { header: 'Name', value: (sidecar) => sidecar.name }, + { header: 'Address', value: (sidecar) => sidecar.address }, + { header: 'Ports', value: (sidecar) => sidecar.ports?.join(',') }, + ]) +} + function formatValue(value: unknown): string { if (value instanceof Date) { return value.toLocaleString() diff --git a/packages/cli/src/commands/sandbox/list.ts b/packages/cli/src/commands/sandbox/list.ts index e877970d5c..a426e993a0 100644 --- a/packages/cli/src/commands/sandbox/list.ts +++ b/packages/cli/src/commands/sandbox/list.ts @@ -1,5 +1,11 @@ import * as commander from 'commander' -import { components, Sandbox, SandboxInfo, SandboxListOrder } from 'e2b' +import { + components, + Sandbox, + SandboxInfo, + SandboxListOrder, + SidecarInfo, +} from 'e2b' import { ensureAPIKey } from 'src/api' import { renderTable } from 'src/utils/table' @@ -113,9 +119,16 @@ export function buildTableRows( endAt: new Date(sandbox.endAt).toLocaleString(), state: sandbox.state.charAt(0).toUpperCase() + sandbox.state.slice(1), // capitalize metadata: JSON.stringify(sandbox.metadata), + sidecars: formatSidecars(sandbox.sidecars), })) } +export function formatSidecars(sidecars: SidecarInfo[] | undefined) { + return (sidecars ?? []) + .map((sidecar) => `${sidecar.entry}:${sidecar.state}`) + .join(',') +} + function renderSandboxTable( sandboxes: SandboxInfo[], order?: SandboxListOrder @@ -135,6 +148,7 @@ function renderSandboxTable( { header: 'vCPUs', value: (row) => String(row.cpuCount) }, { header: 'RAM MiB', value: (row) => String(row.memoryMB) }, { header: 'Envd version', value: (row) => row.envdVersion }, + { header: 'Sidecars', value: (row) => row.sidecars }, { header: 'Metadata', value: (row) => row.metadata }, ]) } diff --git a/packages/cli/src/utils/table.ts b/packages/cli/src/utils/table.ts index c1cfff847b..d2ac03fb49 100644 --- a/packages/cli/src/utils/table.ts +++ b/packages/cli/src/utils/table.ts @@ -27,6 +27,16 @@ export interface Column { * ``` */ export function renderTable(items: T[], columns: Column[]) { + for (const line of formatTable(items, columns)) { + console.log(line) + } +} + +/** + * Formats `items` as the lines {@link renderTable} prints, for callers that + * embed the table in other output. + */ +export function formatTable(items: T[], columns: Column[]): string[] { const headers = columns.map((column) => column.header.toUpperCase()) const rows = items.map((item) => columns.map((column) => column.value(item) ?? '') @@ -36,16 +46,14 @@ export function renderTable(items: T[], columns: Column[]) { rows.reduce((max, row) => Math.max(max, wcswidth(row[i])), wcswidth(header)) ) - for (const line of [headers, ...rows]) { - console.log( - line - .map((cell, i) => - i === line.length - 1 - ? cell - : cell + ' '.repeat(widths[i] + COLUMN_PADDING - wcswidth(cell)) - ) - .join('') - .trimEnd() - ) - } + return [headers, ...rows].map((line) => + line + .map((cell, i) => + i === line.length - 1 + ? cell + : cell + ' '.repeat(widths[i] + COLUMN_PADDING - wcswidth(cell)) + ) + .join('') + .trimEnd() + ) } diff --git a/packages/cli/tests/commands/sandbox/info.test.ts b/packages/cli/tests/commands/sandbox/info.test.ts new file mode 100644 index 0000000000..998e507596 --- /dev/null +++ b/packages/cli/tests/commands/sandbox/info.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { SidecarInfo } from 'e2b' + +import { + formatSidecarTable, + renderPrettyInfo, +} from '../../../src/commands/sandbox/info' + +const sidecars: SidecarInfo[] = [ + { + entry: 'redis', + version: '7.4.1', + role: 'service', + class: 'ephemeral', + state: 'running', + name: 'redis.sidecar.e2b.local', + address: '169.254.0.25', + ports: [6379], + }, + { + entry: 'iron-proxy', + version: '0.4.1', + role: 'proxy', + class: 'ephemeral', + state: 'failed', + name: 'iron-proxy.sidecar.e2b.local', + lastError: 'readiness probe timed out', + }, +] + +function capture() { + const lines: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => + lines.push(line) + ) + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + return lines +} + +describe('sandbox info sidecars', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + test('formats a table with entry, version, role, class, state, name, address and ports', () => { + expect(formatSidecarTable(sidecars)).toEqual([ + 'ENTRY VERSION ROLE CLASS STATE NAME ADDRESS PORTS', + 'redis 7.4.1 service ephemeral running redis.sidecar.e2b.local 169.254.0.25 6379', + 'iron-proxy 0.4.1 proxy ephemeral failed iron-proxy.sidecar.e2b.local', + ]) + }) + + test('prints the sidecar table indented under its label', () => { + const output = capture() + + renderPrettyInfo({ sandboxId: 'sbx-1', sidecars }) + + // The label and its indented table go out as one multi-line log call. + const lines = output.join('\n').split('\n') + const start = lines.findIndex((line) => line.includes('Sidecars')) + expect(start).toBeGreaterThan(0) + expect(lines[start + 1]).toMatch(/^ ENTRY\s+VERSION/) + expect(lines[start + 2]).toMatch(/^ redis\s+7\.4\.1/) + expect(lines[start + 3]).toMatch(/^ iron-proxy\s+0\.4\.1/) + }) + + test('omits the sidecars field when the sandbox has none', () => { + const lines = capture() + + renderPrettyInfo({ sandboxId: 'sbx-1', sidecars: [] }) + + expect(lines.some((line) => line.includes('Sidecars'))).toBe(false) + }) +}) diff --git a/packages/cli/tests/commands/sandbox/list.test.ts b/packages/cli/tests/commands/sandbox/list.test.ts index bf7c522570..9d12ad02ac 100644 --- a/packages/cli/tests/commands/sandbox/list.test.ts +++ b/packages/cli/tests/commands/sandbox/list.test.ts @@ -3,6 +3,7 @@ import { SandboxInfo } from 'e2b' import { buildTableRows, + formatSidecars, sortSandboxes, } from '../../../src/commands/sandbox/list' @@ -60,6 +61,38 @@ describe('sandbox list table rows', () => { expect(row.metadata).toBe('{}') }) + test('lists sidecars as entry:state pairs, empty when there are none', () => { + const startedAt = new Date('2026-09-01T10:00:00Z') + const [withSidecars, without] = buildTableRows([ + { + ...sandbox('sbx-a', startedAt), + sidecars: [ + { + entry: 'redis', + version: '7.4.1', + role: 'service', + class: 'ephemeral', + state: 'running', + name: 'redis.sidecar.e2b.local', + }, + { + entry: 'iron-proxy', + version: '0.4.1', + role: 'proxy', + class: 'ephemeral', + state: 'failed', + name: 'iron-proxy.sidecar.e2b.local', + }, + ], + }, + sandbox('sbx-b', startedAt), + ]) + + expect(withSidecars.sidecars).toBe('redis:running,iron-proxy:failed') + expect(without.sidecars).toBe('') + expect(formatSidecars(undefined)).toBe('') + }) + test('does not mutate the input array', () => { const september = sandbox('sbx-sep', new Date('2026-09-01T10:00:00Z')) const october = sandbox('sbx-oct', new Date('2026-10-01T09:00:00Z')) diff --git a/packages/cli/tests/utils/table.test.ts b/packages/cli/tests/utils/table.test.ts index df8ef1ab2e..7e14c465ed 100644 --- a/packages/cli/tests/utils/table.test.ts +++ b/packages/cli/tests/utils/table.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { renderTable } from '../../src/utils/table' +import { formatTable, renderTable } from '../../src/utils/table' describe('renderTable', () => { afterEach(() => { @@ -50,6 +50,21 @@ describe('renderTable', () => { expect(lines).toEqual(['A B', 'x']) }) + it('formats the same lines without printing them', () => { + const lines = capture() + + const formatted = formatTable( + [{ id: 'sbx-1', name: 'alpha' }], + [ + { header: 'Sandbox ID', value: (row) => row.id }, + { header: 'Name', value: (row) => row.name }, + ] + ) + + expect(formatted).toEqual(['SANDBOX ID NAME', 'sbx-1 alpha']) + expect(lines).toEqual([]) + }) + it('aligns columns containing wide (CJK) characters by display width', () => { const lines = capture() @@ -64,10 +79,6 @@ describe('renderTable', () => { ] ) - expect(lines).toEqual([ - 'NAME STATE', - '日本語 ok', - 'abcdef ok', - ]) + expect(lines).toEqual(['NAME STATE', '日本語 ok', 'abcdef ok']) }) }) From 4a5a56f9972cd975d6035a322c7e158b245ab7b9 Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Fri, 11 Sep 2026 17:01:28 +0000 Subject: [PATCH 05/17] changeset: sidecars on sandbox create, info and the CLI (minor) --- .changeset/cli-sandbox-sidecars.md | 5 +++++ .changeset/sandbox-sidecars.md | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 .changeset/cli-sandbox-sidecars.md create mode 100644 .changeset/sandbox-sidecars.md diff --git a/.changeset/cli-sandbox-sidecars.md b/.changeset/cli-sandbox-sidecars.md new file mode 100644 index 0000000000..32f7bff495 --- /dev/null +++ b/.changeset/cli-sandbox-sidecars.md @@ -0,0 +1,5 @@ +--- +'@e2b/cli': minor +--- + +`e2b sandbox list` gains a `SIDECARS` column (`entry:state`, comma-separated) and `e2b sandbox info` prints a table of the sandbox's sidecars with their entry, version, role, class, state, name, address and ports. diff --git a/.changeset/sandbox-sidecars.md b/.changeset/sandbox-sidecars.md new file mode 100644 index 0000000000..54be780d44 --- /dev/null +++ b/.changeset/sandbox-sidecars.md @@ -0,0 +1,6 @@ +--- +'e2b': minor +'@e2b/python-sdk': minor +--- + +Add `sidecars` to sandbox creation: companion microVMs from the E2B sidecar catalog that run next to the sandbox inside its private network and are reached by the name `{entry}.sidecar.e2b.local`. Each entry names a catalog item (`iron-proxy`, a proxy that swaps a placeholder for the real secret value on egress so the secret never enters the sandbox; `redis`, a cache), with an optional `version`, entry-specific `config`, and `secrets` slots holding `${e2b.secrets.}` references. Sandbox info and list return the attached sidecars with their role, class, state, name, address and ports. Sidecar rejections surface as `InvalidArgumentError` / `InvalidArgumentException` with the API's `SIDECAR_*` code in the message; a sidecar that fails to start keeps its entry name in the error. Requires the team's `sandbox-sidecars` feature. From 90f9969d3d71e20afd9deb923af0cdf145d12180 Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Fri, 11 Sep 2026 17:14:12 +0000 Subject: [PATCH 06/17] spec, sdks: SidecarInfo.state is an open string Drop the enum on SidecarInfo.state in the spec (known values listed in the description; role and class keep their closed enums) and regenerate both clients, so a state a newer api adds no longer makes the generated Python model raise ValueError and take get_info()/list() down with it. The SDK-level SidecarState unions stay as documentation and admit any string, the SandboxIamTokenType pattern. sidecar_api_exception also swallows a non-UTF-8 error body (ValueError covers JSONDecodeError and UnicodeDecodeError) instead of raising from inside the error path. Review round 1, fix 1 + optional. --- packages/js-sdk/src/api/schema.gen.ts | 7 ++----- packages/js-sdk/src/sandbox/sandboxApi.ts | 6 ++++-- packages/js-sdk/tests/sandbox/sidecars.test.ts | 8 ++++++++ packages/python-sdk/e2b/api/client/models/__init__.py | 2 -- .../python-sdk/e2b/api/client/models/sidecar_info.py | 10 +++++----- .../e2b/api/client/models/sidecar_info_state.py | 11 ----------- packages/python-sdk/e2b/sandbox/sandbox_api.py | 10 ++++++---- .../python-sdk/tests/shared/sandbox/test_sidecars.py | 11 +++++++++++ spec/openapi.yml | 7 +------ 9 files changed, 37 insertions(+), 35 deletions(-) delete mode 100644 packages/python-sdk/e2b/api/client/models/sidecar_info_state.py diff --git a/packages/js-sdk/src/api/schema.gen.ts b/packages/js-sdk/src/api/schema.gen.ts index b8e7c3f03a..56802a783c 100644 --- a/packages/js-sdk/src/api/schema.gen.ts +++ b/packages/js-sdk/src/api/schema.gen.ts @@ -2780,11 +2780,8 @@ export interface components { * @enum {string} */ role: "proxy" | "service"; - /** - * @description Current state of the sidecar - * @enum {string} - */ - state: "starting" | "running" | "failed" | "stopped"; + /** @description Current state of the sidecar. Not a closed set; current values are starting, running, failed and stopped. */ + state: string; /** @description Catalog entry version */ version: string; }; diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 15b3f6dc42..17e98e6bca 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -642,9 +642,11 @@ export type SidecarClass = 'ephemeral' | 'stateful' /** * State of a sidecar. `'failed'` is reached after one automatic restart - * attempt; the sandbox itself keeps running. + * attempt; the sandbox itself keeps running. The set is defined server-side + * and may grow, so any string is allowed. */ -export type SidecarState = 'starting' | 'running' | 'failed' | 'stopped' +export type SidecarState = + 'starting' | 'running' | 'failed' | 'stopped' | (string & {}) /** * A sidecar attached to a sandbox, as returned by the sandbox info and list diff --git a/packages/js-sdk/tests/sandbox/sidecars.test.ts b/packages/js-sdk/tests/sandbox/sidecars.test.ts index 3a4fdc5308..7772ab6e52 100644 --- a/packages/js-sdk/tests/sandbox/sidecars.test.ts +++ b/packages/js-sdk/tests/sandbox/sidecars.test.ts @@ -162,6 +162,14 @@ test('Sandbox.getInfo returns the sidecars with their state', async () => { expect(info.sidecars).toEqual([redisInfo, failedProxyInfo]) }) +test('Sandbox.getInfo passes through a state value the SDK does not know', async () => { + infoSidecars = [{ ...redisInfo, state: 'restarting' }] + + const info = await Sandbox.getInfo(sandboxId, { apiKey: TEST_API_KEY }) + + expect(info.sidecars?.[0].state).toBe('restarting') +}) + test('Sandbox.getInfo returns an empty sidecar list when the API sends none', async () => { const info = await Sandbox.getInfo(sandboxId, { apiKey: TEST_API_KEY }) diff --git a/packages/python-sdk/e2b/api/client/models/__init__.py b/packages/python-sdk/e2b/api/client/models/__init__.py index 6997ee64d0..1646a5aadc 100644 --- a/packages/python-sdk/e2b/api/client/models/__init__.py +++ b/packages/python-sdk/e2b/api/client/models/__init__.py @@ -63,7 +63,6 @@ from .sidecar_info import SidecarInfo from .sidecar_info_class import SidecarInfoClass from .sidecar_info_role import SidecarInfoRole -from .sidecar_info_state import SidecarInfoState from .snapshot_info import SnapshotInfo from .team_user import TeamUser from .template import Template @@ -151,7 +150,6 @@ "SidecarInfo", "SidecarInfoClass", "SidecarInfoRole", - "SidecarInfoState", "SnapshotInfo", "TeamUser", "Template", diff --git a/packages/python-sdk/e2b/api/client/models/sidecar_info.py b/packages/python-sdk/e2b/api/client/models/sidecar_info.py index 3c4a832517..728ca55306 100644 --- a/packages/python-sdk/e2b/api/client/models/sidecar_info.py +++ b/packages/python-sdk/e2b/api/client/models/sidecar_info.py @@ -6,7 +6,6 @@ from ..models.sidecar_info_class import SidecarInfoClass from ..models.sidecar_info_role import SidecarInfoRole -from ..models.sidecar_info_state import SidecarInfoState from ..types import UNSET, Unset T = TypeVar("T", bound="SidecarInfo") @@ -21,7 +20,8 @@ class SidecarInfo: version (str): Catalog entry version role (SidecarInfoRole): Role of the sidecar class_ (SidecarInfoClass): Lifecycle class of the sidecar - state (SidecarInfoState): Current state of the sidecar + state (str): Current state of the sidecar. Not a closed set; current values are starting, running, failed and + stopped. name (str): Name the sandbox reaches the sidecar at ("{entry}.sidecar.e2b.local") address (Union[Unset, str]): Address of the sidecar inside the sandbox network ports (Union[Unset, list[int]]): Ports the sidecar listens on @@ -32,7 +32,7 @@ class SidecarInfo: version: str role: SidecarInfoRole class_: SidecarInfoClass - state: SidecarInfoState + state: str name: str address: Union[Unset, str] = UNSET ports: Union[Unset, list[int]] = UNSET @@ -48,7 +48,7 @@ def to_dict(self) -> dict[str, Any]: class_ = self.class_.value - state = self.state.value + state = self.state name = self.name @@ -92,7 +92,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: class_ = SidecarInfoClass(d.pop("class")) - state = SidecarInfoState(d.pop("state")) + state = d.pop("state") name = d.pop("name") diff --git a/packages/python-sdk/e2b/api/client/models/sidecar_info_state.py b/packages/python-sdk/e2b/api/client/models/sidecar_info_state.py deleted file mode 100644 index 8d74e1ab55..0000000000 --- a/packages/python-sdk/e2b/api/client/models/sidecar_info_state.py +++ /dev/null @@ -1,11 +0,0 @@ -from enum import Enum - - -class SidecarInfoState(str, Enum): - FAILED = "failed" - RUNNING = "running" - STARTING = "starting" - STOPPED = "stopped" - - def __str__(self) -> str: - return str(self.value) diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 5ddd6bf69a..db673d35a7 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -566,10 +566,11 @@ class SidecarAttachment(TypedDict): SidecarClass = Literal["ephemeral", "stateful"] """Lifecycle class of a sidecar. Only ``"ephemeral"`` sidecars can be attached in this version.""" -SidecarState = Literal["starting", "running", "failed", "stopped"] +SidecarState = Union[Literal["starting", "running", "failed", "stopped"], str] """ State of a sidecar. ``"failed"`` is reached after one automatic restart -attempt; the sandbox itself keeps running. +attempt; the sandbox itself keeps running. The set is defined server-side and +may grow, so any string is allowed. """ @@ -959,7 +960,7 @@ def from_client_sidecars( version=sidecar.version, role=cast(SidecarRole, sidecar.role.value), class_=cast(SidecarClass, sidecar.class_.value), - state=cast(SidecarState, sidecar.state.value), + state=sidecar.state, name=sidecar.name, address=sidecar.address if isinstance(sidecar.address, str) else None, ports=list(sidecar.ports) if not isinstance(sidecar.ports, Unset) else [], @@ -980,7 +981,8 @@ def sidecar_api_exception(res: Any) -> Optional[Exception]: """ try: body = json.loads(res.content) if res.content else {} - except json.JSONDecodeError: + except ValueError: + # JSONDecodeError and UnicodeDecodeError, for a non-JSON or non-UTF-8 body return None if not isinstance(body, dict): return None diff --git a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py index 798fc7eac1..e0c7905ca5 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py +++ b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py @@ -191,6 +191,16 @@ def test_info_returns_the_sidecars_with_their_state(): assert info.sidecars == _expected_infos() +def test_info_passes_through_a_state_value_the_sdk_does_not_know(): + detail = SandboxDetail.from_dict( + {**SANDBOX_DETAIL, "sidecars": [{**REDIS_INFO, "state": "restarting"}]} + ) + + info = SandboxInfo._from_sandbox_detail(detail) + + assert info.sidecars[0].state == "restarting" + + def test_info_returns_an_empty_sidecar_list_when_the_api_sends_none(): info = SandboxInfo._from_sandbox_detail(SandboxDetail.from_dict(SANDBOX_DETAIL)) @@ -266,6 +276,7 @@ def test_sidecar_failed_keeps_the_entry_name_and_is_not_an_argument_error( pytest.param(b"not json", id="not-json"), pytest.param(b"", id="empty"), pytest.param(b"[1]", id="not-an-object"), + pytest.param(b"\xff\xfe{", id="not-utf8"), ], ) def test_other_errors_keep_the_generic_mapping(content): diff --git a/spec/openapi.yml b/spec/openapi.yml index 1440772ed4..a42d36ef05 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -790,12 +790,7 @@ components: description: Lifecycle class of the sidecar state: type: string - enum: - - starting - - running - - failed - - stopped - description: Current state of the sidecar + description: Current state of the sidecar. Not a closed set; current values are starting, running, failed and stopped. name: type: string description: Name the sandbox reaches the sidecar at ("{entry}.sidecar.e2b.local") From a3c05a25cb2cc9123dbaa9079c5f59ebf3e3976d Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Fri, 11 Sep 2026 17:14:12 +0000 Subject: [PATCH 07/17] sdks: omit sidecars from the create body when the list is empty An empty list, null/None or an omitted option all leave `sidecars` out of NewSandbox, matching how volumeMounts is sent; shape validation still runs first so a non-list is rejected before the request. Review round 1, fix 2. --- packages/js-sdk/src/sandbox/sandboxApi.ts | 5 ++++- packages/js-sdk/tests/sandbox/sidecars.test.ts | 8 ++++++-- .../e2b/sandbox_async/sandbox_api.py | 2 +- .../python-sdk/e2b/sandbox_sync/sandbox_api.py | 2 +- .../tests/shared/sandbox/test_sidecars.py | 18 ++++++++++++++++-- 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 17e98e6bca..fbcc2e4e4a 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -1925,7 +1925,10 @@ export class SandboxApi extends ClientFactory { } if (opts?.sidecars != null) { - body.sidecars = buildSidecarsBody(opts.sidecars) + const sidecars = buildSidecarsBody(opts.sidecars) + if (sidecars.length) { + body.sidecars = sidecars + } } const apiOpts = this.resolveOpts(opts) diff --git a/packages/js-sdk/tests/sandbox/sidecars.test.ts b/packages/js-sdk/tests/sandbox/sidecars.test.ts index 7772ab6e52..51b98b35cc 100644 --- a/packages/js-sdk/tests/sandbox/sidecars.test.ts +++ b/packages/js-sdk/tests/sandbox/sidecars.test.ts @@ -116,8 +116,12 @@ test('Sandbox.create sends the sidecars in the request body', async () => { ]) }) -test('Sandbox.create omits sidecars when not provided', async () => { - await Sandbox.create('base', { apiKey: TEST_API_KEY }) +test.each([ + ['not provided', {}], + ['an empty list', { sidecars: [] }], + ['null', { sidecars: null as any }], +])('Sandbox.create omits sidecars when %s', async (_, opts) => { + await Sandbox.create('base', { apiKey: TEST_API_KEY, ...opts }) expect(lastCreateBody).toBeDefined() expect(lastCreateBody).not.toHaveProperty('sidecars') diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index 0ee2aa3e03..3473610627 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -251,7 +251,7 @@ async def _create_sandbox( network=SandboxNetworkConfig(**network_body) if network_body else UNSET, iam=iam_body or UNSET, volume_mounts=volume_mounts if volume_mounts else UNSET, - sidecars=sidecars_body if sidecars_body is not None else UNSET, + sidecars=sidecars_body if sidecars_body else UNSET, ) api_client = get_api_client(config) diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index a0ff4d3a21..0ffcb3326e 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -250,7 +250,7 @@ def _create_sandbox( network=SandboxNetworkConfig(**network_body) if network_body else UNSET, iam=iam_body or UNSET, volume_mounts=volume_mounts if volume_mounts else UNSET, - sidecars=sidecars_body if sidecars_body is not None else UNSET, + sidecars=sidecars_body if sidecars_body else UNSET, ) api_client = get_api_client(config) diff --git a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py index e0c7905ca5..06461cba7e 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py +++ b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py @@ -122,8 +122,22 @@ async def test_async_create_sends_the_sidecars(monkeypatch, test_api_key): assert body["sidecars"] == SIDECARS_WIRE -def test_create_omits_sidecars_when_not_provided(monkeypatch, test_api_key): - body = _sync_request_body(monkeypatch, test_api_key) +@pytest.mark.parametrize( + "kwargs", + [ + pytest.param({}, id="not-provided"), + pytest.param({"sidecars": None}, id="none"), + pytest.param({"sidecars": []}, id="empty-list"), + ], +) +def test_create_omits_sidecars_when_there_are_none(monkeypatch, test_api_key, kwargs): + body = _sync_request_body(monkeypatch, test_api_key, **kwargs) + + assert "sidecars" not in body + + +async def test_async_create_omits_an_empty_sidecar_list(monkeypatch, test_api_key): + body = await _async_request_body(monkeypatch, test_api_key, sidecars=[]) assert "sidecars" not in body From d7d9cd2657e41674d85137b81166874d4dfb3934 Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Fri, 11 Sep 2026 17:14:12 +0000 Subject: [PATCH 08/17] cli: LAST ERROR column in the sandbox info sidecar table A failed sidecar shows its reason; values longer than 60 characters are cut to 59 plus an ellipsis. Review round 1, fix 3. --- packages/cli/src/commands/sandbox/info.ts | 13 +++++++++++++ packages/cli/tests/commands/sandbox/info.test.ts | 14 +++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/sandbox/info.ts b/packages/cli/src/commands/sandbox/info.ts index c782ec5d83..c86eb74948 100644 --- a/packages/cli/src/commands/sandbox/info.ts +++ b/packages/cli/src/commands/sandbox/info.ts @@ -125,9 +125,22 @@ export function formatSidecarTable(sidecars: SidecarInfo[]): string[] { { header: 'Name', value: (sidecar) => sidecar.name }, { header: 'Address', value: (sidecar) => sidecar.address }, { header: 'Ports', value: (sidecar) => sidecar.ports?.join(',') }, + { + header: 'Last error', + value: (sidecar) => truncate(sidecar.lastError, LAST_ERROR_WIDTH), + }, ]) } +const LAST_ERROR_WIDTH = 60 + +function truncate(value: string | undefined, width: number) { + if (value === undefined || value.length <= width) { + return value + } + return `${value.slice(0, width - 1)}…` +} + function formatValue(value: unknown): string { if (value instanceof Date) { return value.toLocaleString() diff --git a/packages/cli/tests/commands/sandbox/info.test.ts b/packages/cli/tests/commands/sandbox/info.test.ts index 998e507596..7e6657cac6 100644 --- a/packages/cli/tests/commands/sandbox/info.test.ts +++ b/packages/cli/tests/commands/sandbox/info.test.ts @@ -42,14 +42,22 @@ describe('sandbox info sidecars', () => { vi.restoreAllMocks() }) - test('formats a table with entry, version, role, class, state, name, address and ports', () => { + test('formats a table with entry, version, role, class, state, name, address, ports and last error', () => { expect(formatSidecarTable(sidecars)).toEqual([ - 'ENTRY VERSION ROLE CLASS STATE NAME ADDRESS PORTS', + 'ENTRY VERSION ROLE CLASS STATE NAME ADDRESS PORTS LAST ERROR', 'redis 7.4.1 service ephemeral running redis.sidecar.e2b.local 169.254.0.25 6379', - 'iron-proxy 0.4.1 proxy ephemeral failed iron-proxy.sidecar.e2b.local', + 'iron-proxy 0.4.1 proxy ephemeral failed iron-proxy.sidecar.e2b.local readiness probe timed out', ]) }) + test('truncates a long last error to 60 characters with an ellipsis', () => { + const lastError = 'x'.repeat(70) + const [, row] = formatSidecarTable([{ ...sidecars[1], lastError }]) + + expect(row.endsWith(`${'x'.repeat(59)}…`)).toBe(true) + expect(row).not.toContain('x'.repeat(60)) + }) + test('prints the sidecar table indented under its label', () => { const output = capture() From 4065cbdba2fc34c415bdde4b9e85f5bb426311c8 Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Fri, 11 Sep 2026 17:15:54 +0000 Subject: [PATCH 09/17] sdks: sidecar error codes are lower snake case The api's sidecar codes follow every existing error_code (sidecar_unknown_entry, sidecar_deprecated_entry, sidecar_limit, sidecar_one_proxy, sidecar_config_invalid, sidecar_secret_missing, sidecar_rule_collision, sidecar_flag_off; sidecar_failed on the create failure). Both mappers match the `sidecar_` prefix case-sensitively; an uppercase variant falls through to the generic mapping, and a test in each SDK pins that. Review round 1, architect contract decision. --- .changeset/sandbox-sidecars.md | 2 +- packages/js-sdk/src/sandbox/sandboxApi.ts | 11 +++--- .../js-sdk/tests/sandbox/sidecars.test.ts | 35 ++++++++++++++----- .../python-sdk/e2b/sandbox/sandbox_api.py | 8 ++--- .../tests/shared/sandbox/test_sidecars.py | 19 +++++----- 5 files changed, 47 insertions(+), 28 deletions(-) diff --git a/.changeset/sandbox-sidecars.md b/.changeset/sandbox-sidecars.md index 54be780d44..d368a3e8ae 100644 --- a/.changeset/sandbox-sidecars.md +++ b/.changeset/sandbox-sidecars.md @@ -3,4 +3,4 @@ '@e2b/python-sdk': minor --- -Add `sidecars` to sandbox creation: companion microVMs from the E2B sidecar catalog that run next to the sandbox inside its private network and are reached by the name `{entry}.sidecar.e2b.local`. Each entry names a catalog item (`iron-proxy`, a proxy that swaps a placeholder for the real secret value on egress so the secret never enters the sandbox; `redis`, a cache), with an optional `version`, entry-specific `config`, and `secrets` slots holding `${e2b.secrets.}` references. Sandbox info and list return the attached sidecars with their role, class, state, name, address and ports. Sidecar rejections surface as `InvalidArgumentError` / `InvalidArgumentException` with the API's `SIDECAR_*` code in the message; a sidecar that fails to start keeps its entry name in the error. Requires the team's `sandbox-sidecars` feature. +Add `sidecars` to sandbox creation: companion microVMs from the E2B sidecar catalog that run next to the sandbox inside its private network and are reached by the name `{entry}.sidecar.e2b.local`. Each entry names a catalog item (`iron-proxy`, a proxy that swaps a placeholder for the real secret value on egress so the secret never enters the sandbox; `redis`, a cache), with an optional `version`, entry-specific `config`, and `secrets` slots holding `${e2b.secrets.}` references. Sandbox info and list return the attached sidecars with their role, class, state, name, address and ports. Sidecar rejections surface as `InvalidArgumentError` / `InvalidArgumentException` with the API's `sidecar_*` code in the message; a sidecar that fails to start keeps its entry name in the error. Requires the team's `sandbox-sidecars` feature. diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index fbcc2e4e4a..ce918b34f9 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -1259,7 +1259,7 @@ function fromApiEgressProxy( } // The spec's maxItems renders as a tuple union; the count is the API's to -// enforce (SIDECAR_LIMIT), so the list is cast rather than re-validated here. +// enforce (sidecar_limit), so the list is cast rather than re-validated here. function buildSidecarsBody( sidecars: SidecarAttachment[] ): NonNullable { @@ -1304,9 +1304,10 @@ function fromApiSidecars( } /** - * Sidecar rejections carry a `SIDECAR_*` semantic code: validation failures - * as 400, a sidecar that did not start as `SIDECAR_FAILED` naming the entry. - * The code stays in the message so callers can tell them apart. + * Sidecar rejections carry a lower-snake `sidecar_*` semantic code, like every + * other `error_code`: validation failures as 400, a sidecar that did not start + * as `sidecar_failed` naming the entry. The code stays in the message so + * callers can tell them apart. */ function sidecarApiError(res: { response: { status: number; statusText: string } @@ -1314,7 +1315,7 @@ function sidecarApiError(res: { }): Error | undefined { const body = isPlainObject(res.error) ? res.error : undefined const code = body?.error_code - if (typeof code !== 'string' || !code.startsWith('SIDECAR_')) { + if (typeof code !== 'string' || !code.startsWith('sidecar_')) { return } diff --git a/packages/js-sdk/tests/sandbox/sidecars.test.ts b/packages/js-sdk/tests/sandbox/sidecars.test.ts index 51b98b35cc..a22b428790 100644 --- a/packages/js-sdk/tests/sandbox/sidecars.test.ts +++ b/packages/js-sdk/tests/sandbox/sidecars.test.ts @@ -60,7 +60,7 @@ const server = setupServer( HttpResponse.json( { code: 400, - error_code: 'SIDECAR_RULE_COLLISION', + error_code: 'sidecar_rule_collision', message: 'api.openai.com is routed through the iron-proxy sidecar', }, { status: 400 } @@ -188,12 +188,12 @@ test('Sandbox.list returns the sidecars of each sandbox', async () => { expect(info.sidecars).toEqual([redisInfo]) }) -test('a SIDECAR_* 400 surfaces as InvalidArgumentError with the code preserved', async () => { +test('a sidecar_* 400 surfaces as InvalidArgumentError with the code preserved', async () => { createResponse = () => HttpResponse.json( { code: 400, - error_code: 'SIDECAR_UNKNOWN_ENTRY', + error_code: 'sidecar_unknown_entry', message: 'unknown sidecar entry "memcached"', }, { status: 400 } @@ -206,16 +206,16 @@ test('a SIDECAR_* 400 surfaces as InvalidArgumentError with the code preserved', expect(err).toBeInstanceOf(InvalidArgumentError) expect((err as SandboxError).statusCode).toBe(400) - expect((err as Error).message).toContain('SIDECAR_UNKNOWN_ENTRY') + expect((err as Error).message).toContain('sidecar_unknown_entry') expect((err as Error).message).toContain('memcached') }) -test('SIDECAR_FAILED keeps the entry name and is not an argument error', async () => { +test('sidecar_failed keeps the entry name and is not an argument error', async () => { createResponse = () => HttpResponse.json( { code: 500, - error_code: 'SIDECAR_FAILED', + error_code: 'sidecar_failed', message: 'sidecar "redis" failed to become ready', }, { status: 500 } @@ -229,10 +229,27 @@ test('SIDECAR_FAILED keeps the entry name and is not an argument error', async ( expect(err).toBeInstanceOf(SandboxError) expect(err).not.toBeInstanceOf(InvalidArgumentError) expect((err as SandboxError).statusCode).toBe(500) - expect((err as Error).message).toContain('SIDECAR_FAILED') + expect((err as Error).message).toContain('sidecar_failed') expect((err as Error).message).toContain('redis') }) +test('the sidecar code match is case-sensitive', async () => { + createResponse = () => + HttpResponse.json( + { code: 400, error_code: 'SIDECAR_UNKNOWN_ENTRY', message: 'nope' }, + { status: 400 } + ) + + const err = await Sandbox.create('base', { + apiKey: TEST_API_KEY, + sidecars: [{ entry: 'memcached' }], + }).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(SandboxError) + expect(err).not.toBeInstanceOf(InvalidArgumentError) + expect((err as Error).message).toBe('400: nope') +}) + test('a 400 without a sidecar code keeps the generic mapping', async () => { createResponse = () => HttpResponse.json( @@ -249,7 +266,7 @@ test('a 400 without a sidecar code keeps the generic mapping', async () => { expect((err as Error).message).toBe('400: invalid template') }) -test('Sandbox.updateNetwork surfaces SIDECAR_RULE_COLLISION as InvalidArgumentError', async () => { +test('Sandbox.updateNetwork surfaces sidecar_rule_collision as InvalidArgumentError', async () => { const err = await Sandbox.updateNetwork( sandboxId, { allowOut: ['api.openai.com'] }, @@ -257,5 +274,5 @@ test('Sandbox.updateNetwork surfaces SIDECAR_RULE_COLLISION as InvalidArgumentEr ).catch((e: unknown) => e) expect(err).toBeInstanceOf(InvalidArgumentError) - expect((err as Error).message).toContain('SIDECAR_RULE_COLLISION') + expect((err as Error).message).toContain('sidecar_rule_collision') }) diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index db673d35a7..7e3021d728 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -973,10 +973,10 @@ def from_client_sidecars( def sidecar_api_exception(res: Any) -> Optional[Exception]: - """Map a ``SIDECAR_*`` rejection, or ``None`` for any other response. + """Map a ``sidecar_*`` rejection, or ``None`` for any other response. - Sidecar validation failures are 400 with a ``SIDECAR_*`` semantic code; a - sidecar that did not start is ``SIDECAR_FAILED`` naming the entry. The code + Sidecar validation failures are 400 with a ``sidecar_*`` semantic code; a + sidecar that did not start is ``sidecar_failed`` naming the entry. The code stays in the message so callers can tell them apart. """ try: @@ -988,7 +988,7 @@ def sidecar_api_exception(res: Any) -> Optional[Exception]: return None code = body.get("error_code") - if not isinstance(code, str) or not code.startswith("SIDECAR_"): + if not isinstance(code, str) or not code.startswith("sidecar_"): return None message = f"{code}: {body.get('message', res.status_code)}" diff --git a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py index 06461cba7e..12477a1c50 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py +++ b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py @@ -235,7 +235,7 @@ def test_a_sidecar_400_is_an_argument_error_with_the_code_preserved( request = Mock( return_value=_response( 400, - b'{"code":400,"error_code":"SIDECAR_UNKNOWN_ENTRY",' + b'{"code":400,"error_code":"sidecar_unknown_entry",' b'"message":"unknown sidecar entry \\"memcached\\""}', ) ) @@ -245,19 +245,19 @@ def test_a_sidecar_400_is_an_argument_error_with_the_code_preserved( Sandbox.create(api_key=test_api_key, sidecars=[{"entry": "memcached"}]) assert excinfo.value.status_code == 400 - assert "SIDECAR_UNKNOWN_ENTRY" in str(excinfo.value) + assert "sidecar_unknown_entry" in str(excinfo.value) assert "memcached" in str(excinfo.value) async def test_async_sidecar_400_is_an_argument_error(monkeypatch, test_api_key): request = AsyncMock( return_value=_response( - 400, b'{"code":400,"error_code":"SIDECAR_FLAG_OFF","message":"off"}' + 400, b'{"code":400,"error_code":"sidecar_flag_off","message":"off"}' ) ) monkeypatch.setattr(post_sandboxes, "asyncio_detailed", request) - with pytest.raises(InvalidArgumentException, match="SIDECAR_FLAG_OFF"): + with pytest.raises(InvalidArgumentException, match="sidecar_flag_off"): await AsyncSandbox.create(api_key=test_api_key, sidecars=[{"entry": "redis"}]) @@ -267,7 +267,7 @@ def test_sidecar_failed_keeps_the_entry_name_and_is_not_an_argument_error( request = Mock( return_value=_response( 500, - b'{"code":500,"error_code":"SIDECAR_FAILED",' + b'{"code":500,"error_code":"sidecar_failed",' b'"message":"sidecar \\"redis\\" failed to become ready"}', ) ) @@ -278,7 +278,7 @@ def test_sidecar_failed_keeps_the_entry_name_and_is_not_an_argument_error( assert not isinstance(excinfo.value, InvalidArgumentException) assert excinfo.value.status_code == 500 - assert "SIDECAR_FAILED" in str(excinfo.value) + assert "sidecar_failed" in str(excinfo.value) assert "redis" in str(excinfo.value) @@ -287,6 +287,7 @@ def test_sidecar_failed_keeps_the_entry_name_and_is_not_an_argument_error( [ pytest.param(b'{"code":400,"message":"invalid template"}', id="no-code"), pytest.param(b'{"error_code":"sandbox_create_failed"}', id="other-code"), + pytest.param(b'{"error_code":"SIDECAR_UNKNOWN_ENTRY"}', id="uppercase"), pytest.param(b"not json", id="not-json"), pytest.param(b"", id="empty"), pytest.param(b"[1]", id="not-an-object"), @@ -303,13 +304,13 @@ def test_update_network_surfaces_a_rule_collision_as_an_argument_error( request = Mock( return_value=_response( 400, - b'{"code":400,"error_code":"SIDECAR_RULE_COLLISION",' + b'{"code":400,"error_code":"sidecar_rule_collision",' b'"message":"api.openai.com is routed through the iron-proxy sidecar"}', ) ) monkeypatch.setattr(put_sandboxes_sandbox_id_network, "sync_detailed", request) - with pytest.raises(InvalidArgumentException, match="SIDECAR_RULE_COLLISION"): + with pytest.raises(InvalidArgumentException, match="sidecar_rule_collision"): Sandbox.update_network( "sbx-test", {"allow_out": ["api.openai.com"]}, api_key=test_api_key ) @@ -320,7 +321,7 @@ def test_update_network_404_still_wins_over_the_sidecar_mapping( ): request = Mock( return_value=_response( - 404, b'{"code":404,"error_code":"SIDECAR_X","message":"gone"}' + 404, b'{"code":404,"error_code":"sidecar_x","message":"gone"}' ) ) monkeypatch.setattr(put_sandboxes_sandbox_id_network, "sync_detailed", request) From d387ecfbf4e93dd15db3d6fc7ee4338539261a6f Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Fri, 11 Sep 2026 22:55:15 +0000 Subject: [PATCH 10/17] sdks: list the sidecar 400 codes, including sidecar_egress_conflict The api rejects a proxy-role sidecar attached together with a service sidecar whose egress is `sandbox` with sidecar_egress_conflict. The prefix match already covers it; the mapper docs now list all nine 400 codes and a parametrized test in each SDK pins each one to the argument error. --- packages/js-sdk/src/sandbox/sandboxApi.ts | 9 ++++-- .../js-sdk/tests/sandbox/sidecars.test.ts | 28 +++++++++++++++++++ .../python-sdk/e2b/sandbox/sandbox_api.py | 9 ++++-- .../tests/shared/sandbox/test_sidecars.py | 26 +++++++++++++++++ 4 files changed, 66 insertions(+), 6 deletions(-) diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index ce918b34f9..4aecd85ea8 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -1305,9 +1305,12 @@ function fromApiSidecars( /** * Sidecar rejections carry a lower-snake `sidecar_*` semantic code, like every - * other `error_code`: validation failures as 400, a sidecar that did not start - * as `sidecar_failed` naming the entry. The code stays in the message so - * callers can tell them apart. + * other `error_code`. Validation failures are 400 — `sidecar_unknown_entry`, + * `sidecar_deprecated_entry`, `sidecar_limit`, `sidecar_one_proxy`, + * `sidecar_config_invalid`, `sidecar_secret_missing`, + * `sidecar_rule_collision`, `sidecar_egress_conflict`, `sidecar_flag_off` — + * and a sidecar that did not start is `sidecar_failed` naming the entry. The + * code stays in the message so callers can tell them apart. */ function sidecarApiError(res: { response: { status: number; statusText: string } diff --git a/packages/js-sdk/tests/sandbox/sidecars.test.ts b/packages/js-sdk/tests/sandbox/sidecars.test.ts index a22b428790..7e6435b13e 100644 --- a/packages/js-sdk/tests/sandbox/sidecars.test.ts +++ b/packages/js-sdk/tests/sandbox/sidecars.test.ts @@ -233,6 +233,34 @@ test('sidecar_failed keeps the entry name and is not an argument error', async ( expect((err as Error).message).toContain('redis') }) +test.each([ + 'sidecar_unknown_entry', + 'sidecar_deprecated_entry', + 'sidecar_limit', + 'sidecar_one_proxy', + 'sidecar_config_invalid', + 'sidecar_secret_missing', + 'sidecar_rule_collision', + 'sidecar_egress_conflict', + 'sidecar_flag_off', +])('every 400 sidecar code (%s) is an InvalidArgumentError', async (code) => { + createResponse = () => + HttpResponse.json( + { code: 400, error_code: code, message: 'rejected' }, + { + status: 400, + } + ) + + const err = await Sandbox.create('base', { + apiKey: TEST_API_KEY, + sidecars: [{ entry: 'redis' }], + }).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(InvalidArgumentError) + expect((err as Error).message).toBe(`${code}: rejected`) +}) + test('the sidecar code match is case-sensitive', async () => { createResponse = () => HttpResponse.json( diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 7e3021d728..df7fd5871c 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -975,9 +975,12 @@ def from_client_sidecars( def sidecar_api_exception(res: Any) -> Optional[Exception]: """Map a ``sidecar_*`` rejection, or ``None`` for any other response. - Sidecar validation failures are 400 with a ``sidecar_*`` semantic code; a - sidecar that did not start is ``sidecar_failed`` naming the entry. The code - stays in the message so callers can tell them apart. + Sidecar validation failures are 400 with a ``sidecar_*`` semantic code — + ``sidecar_unknown_entry``, ``sidecar_deprecated_entry``, ``sidecar_limit``, + ``sidecar_one_proxy``, ``sidecar_config_invalid``, ``sidecar_secret_missing``, + ``sidecar_rule_collision``, ``sidecar_egress_conflict``, ``sidecar_flag_off`` — + and a sidecar that did not start is ``sidecar_failed`` naming the entry. The + code stays in the message so callers can tell them apart. """ try: body = json.loads(res.content) if res.content else {} diff --git a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py index 12477a1c50..85641c184e 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py +++ b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py @@ -282,6 +282,32 @@ def test_sidecar_failed_keeps_the_entry_name_and_is_not_an_argument_error( assert "redis" in str(excinfo.value) +@pytest.mark.parametrize( + "code", + [ + "sidecar_unknown_entry", + "sidecar_deprecated_entry", + "sidecar_limit", + "sidecar_one_proxy", + "sidecar_config_invalid", + "sidecar_secret_missing", + "sidecar_rule_collision", + "sidecar_egress_conflict", + "sidecar_flag_off", + ], +) +def test_every_400_sidecar_code_is_an_argument_error(code): + err = sidecar_api_exception( + _response( + 400, f'{{"code":400,"error_code":"{code}","message":"rejected"}}'.encode() + ) + ) + + assert isinstance(err, InvalidArgumentException) + assert err.status_code == 400 + assert str(err) == f"{code}: rejected" + + @pytest.mark.parametrize( "content", [ From 901271b59f66edf0b629ab5b634be6d2abbc3d98 Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Fri, 11 Sep 2026 22:56:17 +0000 Subject: [PATCH 11/17] sdks: document the sqlite and iroh catalog entries The catalog has four entries; the SidecarAttachment docs and the changeset now name sqlite (libsql-server over HTTP on port 8080) and iroh (peer-to-peer tunnel: publish/connect pipes, tickets.json polled until ready, optional node_secret slot) next to iron-proxy and redis. Lifecycle wording is unchanged pending the architect's final text. --- .changeset/sandbox-sidecars.md | 2 +- packages/js-sdk/src/sandbox/sandboxApi.ts | 22 +++++++++++------- .../python-sdk/e2b/sandbox/sandbox_api.py | 23 ++++++++++++------- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/.changeset/sandbox-sidecars.md b/.changeset/sandbox-sidecars.md index d368a3e8ae..ecfa14cdbb 100644 --- a/.changeset/sandbox-sidecars.md +++ b/.changeset/sandbox-sidecars.md @@ -3,4 +3,4 @@ '@e2b/python-sdk': minor --- -Add `sidecars` to sandbox creation: companion microVMs from the E2B sidecar catalog that run next to the sandbox inside its private network and are reached by the name `{entry}.sidecar.e2b.local`. Each entry names a catalog item (`iron-proxy`, a proxy that swaps a placeholder for the real secret value on egress so the secret never enters the sandbox; `redis`, a cache), with an optional `version`, entry-specific `config`, and `secrets` slots holding `${e2b.secrets.}` references. Sandbox info and list return the attached sidecars with their role, class, state, name, address and ports. Sidecar rejections surface as `InvalidArgumentError` / `InvalidArgumentException` with the API's `sidecar_*` code in the message; a sidecar that fails to start keeps its entry name in the error. Requires the team's `sandbox-sidecars` feature. +Add `sidecars` to sandbox creation: companion microVMs from the E2B sidecar catalog that run next to the sandbox inside its private network and are reached by the name `{entry}.sidecar.e2b.local`. Each entry names a catalog item — `iron-proxy`, a proxy that swaps a placeholder for the real secret value on egress so the secret never enters the sandbox; `redis`, a cache; `sqlite`, libsql-server over HTTP at `http://sqlite.sidecar.e2b.local:8080`; `iroh`, a peer-to-peer tunnel with `publish`/`connect` pipes whose tickets are read from `http://iroh.sidecar.e2b.local:8080/tickets.json` once `status` is `ready` — with an optional `version`, entry-specific `config`, and `secrets` slots holding `${e2b.secrets.}` references. Sandbox info and list return the attached sidecars with their role, class, state, name, address and ports. Sidecar rejections surface as `InvalidArgumentError` / `InvalidArgumentException` with the API's `sidecar_*` code in the message; a sidecar that fails to start keeps its entry name in the error. Requires the team's `sandbox-sidecars` feature. diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 4aecd85ea8..8db1b7a319 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -579,13 +579,19 @@ type SandboxForkResponse = * Sidecar microVM to attach to a sandbox at creation, declared from the E2B * sidecar catalog. * - * A sidecar runs next to the sandbox inside its private network. The catalog - * carries two roles: a `proxy` sidecar (`'iron-proxy'`) that the sandbox's - * egress is steered through and that swaps a placeholder token for the real - * secret value on the way out, so the secret never enters the sandbox; and a - * `service` sidecar (`'redis'`) the sandbox talks to directly. Code in the - * sandbox reaches a sidecar by the name `{entry}.sidecar.e2b.local`, never by - * IP. + * A sidecar runs next to the sandbox inside its private network, and code in + * the sandbox reaches it by the name `{entry}.sidecar.e2b.local`, never by IP. + * The catalog has four entries: + * - `'iron-proxy'` (proxy role): the sandbox's egress is steered through it + * and it swaps a placeholder token for the real secret value on the way + * out, so the secret never enters the sandbox. + * - `'redis'` (service role): a cache the sandbox talks to directly. + * - `'sqlite'` (service role): libsql-server over HTTP at + * `http://sqlite.sidecar.e2b.local:8080`. + * - `'iroh'` (service role): a peer-to-peer tunnel configured with `pipes` of + * `publish` / `connect`; tickets are served at + * `http://iroh.sidecar.e2b.local:8080/tickets.json`, to be polled until + * `status == "ready"`. Takes an optional `node_secret` secret slot. * * Sidecars are ephemeral: torn down at pause and relaunched at resume with * freshly injected configuration and secrets. Attaching one requires the @@ -606,7 +612,7 @@ type SandboxForkResponse = * ``` */ export type SidecarAttachment = { - /** Catalog entry name, e.g. `'iron-proxy'` or `'redis'`. */ + /** Catalog entry name: `'iron-proxy'`, `'redis'`, `'sqlite'` or `'iroh'`. */ entry: string /** Catalog entry version. Defaults to the entry's current version. */ diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index df7fd5871c..1b0eeda1c1 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -511,13 +511,20 @@ class SidecarAttachment(TypedDict): Sidecar microVM to attach to a sandbox at creation, declared from the E2B sidecar catalog. - A sidecar runs next to the sandbox inside its private network. The catalog - carries two roles: a ``proxy`` sidecar (``"iron-proxy"``) that the - sandbox's egress is steered through and that swaps a placeholder token for - the real secret value on the way out, so the secret never enters the - sandbox; and a ``service`` sidecar (``"redis"``) the sandbox talks to - directly. Code in the sandbox reaches a sidecar by the name - ``{entry}.sidecar.e2b.local``, never by IP. + A sidecar runs next to the sandbox inside its private network, and code in + the sandbox reaches it by the name ``{entry}.sidecar.e2b.local``, never by + IP. The catalog has four entries: + + - ``"iron-proxy"`` (proxy role): the sandbox's egress is steered through it + and it swaps a placeholder token for the real secret value on the way + out, so the secret never enters the sandbox. + - ``"redis"`` (service role): a cache the sandbox talks to directly. + - ``"sqlite"`` (service role): libsql-server over HTTP at + ``http://sqlite.sidecar.e2b.local:8080``. + - ``"iroh"`` (service role): a peer-to-peer tunnel configured with + ``pipes`` of ``publish`` / ``connect``; tickets are served at + ``http://iroh.sidecar.e2b.local:8080/tickets.json``, to be polled until + ``status == "ready"``. Takes an optional ``node_secret`` secret slot. Sidecars are ephemeral: torn down at pause and relaunched at resume with freshly injected configuration and secrets. Attaching one requires the @@ -536,7 +543,7 @@ class SidecarAttachment(TypedDict): """ entry: str - """Catalog entry name, e.g. ``"iron-proxy"`` or ``"redis"``.""" + """Catalog entry name: ``"iron-proxy"``, ``"redis"``, ``"sqlite"`` or ``"iroh"``.""" version: NotRequired[str] """Catalog entry version. Defaults to the entry's current version.""" From f6f26e02983d0b6d62b62ba7ed2a91299c6d7254 Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Fri, 11 Sep 2026 22:58:15 +0000 Subject: [PATCH 12/17] sdks, cli: sidecars follow the sandbox's lifecycle Operator decision 2026-09-11: the ephemeral class is withdrawn. A sidecar is paused and snapshotted with the sandbox, comes back as it was on resume (data included), is forked with it and terminated with it; a crashed sidecar is restarted once from its clean image and then reported failed while the sandbox keeps running. A forked sandbox's iroh sidecar starts with a fresh peer identity. SidecarAttachment and SidecarClass docs in both SDKs, the create() param docs, and the changeset carry that text; SidecarClass keeps both wire values for stability with every catalog entry reported stateful. Test fixtures report stateful, so the CLI table expectations shift one column. --- .changeset/sandbox-sidecars.md | 2 +- .../cli/tests/commands/sandbox/info.test.ts | 10 +++++----- .../cli/tests/commands/sandbox/list.test.ts | 4 ++-- packages/js-sdk/src/sandbox/sandboxApi.ts | 16 ++++++++++------ packages/js-sdk/tests/sandbox/sidecars.test.ts | 4 ++-- packages/python-sdk/e2b/sandbox/sandbox_api.py | 17 ++++++++++++----- packages/python-sdk/e2b/sandbox_async/main.py | 2 +- packages/python-sdk/e2b/sandbox_sync/main.py | 2 +- .../tests/shared/sandbox/test_sidecars.py | 8 ++++---- 9 files changed, 38 insertions(+), 27 deletions(-) diff --git a/.changeset/sandbox-sidecars.md b/.changeset/sandbox-sidecars.md index ecfa14cdbb..bf50c38d8c 100644 --- a/.changeset/sandbox-sidecars.md +++ b/.changeset/sandbox-sidecars.md @@ -3,4 +3,4 @@ '@e2b/python-sdk': minor --- -Add `sidecars` to sandbox creation: companion microVMs from the E2B sidecar catalog that run next to the sandbox inside its private network and are reached by the name `{entry}.sidecar.e2b.local`. Each entry names a catalog item — `iron-proxy`, a proxy that swaps a placeholder for the real secret value on egress so the secret never enters the sandbox; `redis`, a cache; `sqlite`, libsql-server over HTTP at `http://sqlite.sidecar.e2b.local:8080`; `iroh`, a peer-to-peer tunnel with `publish`/`connect` pipes whose tickets are read from `http://iroh.sidecar.e2b.local:8080/tickets.json` once `status` is `ready` — with an optional `version`, entry-specific `config`, and `secrets` slots holding `${e2b.secrets.}` references. Sandbox info and list return the attached sidecars with their role, class, state, name, address and ports. Sidecar rejections surface as `InvalidArgumentError` / `InvalidArgumentException` with the API's `sidecar_*` code in the message; a sidecar that fails to start keeps its entry name in the error. Requires the team's `sandbox-sidecars` feature. +Add `sidecars` to sandbox creation: companion microVMs from the E2B sidecar catalog that run next to the sandbox inside its private network and are reached by the name `{entry}.sidecar.e2b.local`. Each entry names a catalog item — `iron-proxy`, a proxy that swaps a placeholder for the real secret value on egress so the secret never enters the sandbox; `redis`, a cache; `sqlite`, libsql-server over HTTP at `http://sqlite.sidecar.e2b.local:8080`; `iroh`, a peer-to-peer tunnel with `publish`/`connect` pipes whose tickets are read from `http://iroh.sidecar.e2b.local:8080/tickets.json` once `status` is `ready` — with an optional `version`, entry-specific `config`, and `secrets` slots holding `${e2b.secrets.}` references. A sidecar follows the sandbox's lifecycle: it is paused and snapshotted with the sandbox, comes back exactly as it was on resume (its data included), is forked with it (a forked sandbox's `iroh` sidecar starts with a fresh peer identity), and is terminated with it; a sidecar that crashes is restarted once from its clean image and then reported `failed` while the sandbox keeps running. Sandbox info and list return the attached sidecars with their role, class, state, name, address and ports. Sidecar rejections surface as `InvalidArgumentError` / `InvalidArgumentException` with the API's `sidecar_*` code in the message; a sidecar that fails to start keeps its entry name in the error. Requires the team's `sandbox-sidecars` feature. diff --git a/packages/cli/tests/commands/sandbox/info.test.ts b/packages/cli/tests/commands/sandbox/info.test.ts index 7e6657cac6..0d2aa14bcf 100644 --- a/packages/cli/tests/commands/sandbox/info.test.ts +++ b/packages/cli/tests/commands/sandbox/info.test.ts @@ -11,7 +11,7 @@ const sidecars: SidecarInfo[] = [ entry: 'redis', version: '7.4.1', role: 'service', - class: 'ephemeral', + class: 'stateful', state: 'running', name: 'redis.sidecar.e2b.local', address: '169.254.0.25', @@ -21,7 +21,7 @@ const sidecars: SidecarInfo[] = [ entry: 'iron-proxy', version: '0.4.1', role: 'proxy', - class: 'ephemeral', + class: 'stateful', state: 'failed', name: 'iron-proxy.sidecar.e2b.local', lastError: 'readiness probe timed out', @@ -44,9 +44,9 @@ describe('sandbox info sidecars', () => { test('formats a table with entry, version, role, class, state, name, address, ports and last error', () => { expect(formatSidecarTable(sidecars)).toEqual([ - 'ENTRY VERSION ROLE CLASS STATE NAME ADDRESS PORTS LAST ERROR', - 'redis 7.4.1 service ephemeral running redis.sidecar.e2b.local 169.254.0.25 6379', - 'iron-proxy 0.4.1 proxy ephemeral failed iron-proxy.sidecar.e2b.local readiness probe timed out', + 'ENTRY VERSION ROLE CLASS STATE NAME ADDRESS PORTS LAST ERROR', + 'redis 7.4.1 service stateful running redis.sidecar.e2b.local 169.254.0.25 6379', + 'iron-proxy 0.4.1 proxy stateful failed iron-proxy.sidecar.e2b.local readiness probe timed out', ]) }) diff --git a/packages/cli/tests/commands/sandbox/list.test.ts b/packages/cli/tests/commands/sandbox/list.test.ts index 9d12ad02ac..1a7f18fe11 100644 --- a/packages/cli/tests/commands/sandbox/list.test.ts +++ b/packages/cli/tests/commands/sandbox/list.test.ts @@ -71,7 +71,7 @@ describe('sandbox list table rows', () => { entry: 'redis', version: '7.4.1', role: 'service', - class: 'ephemeral', + class: 'stateful', state: 'running', name: 'redis.sidecar.e2b.local', }, @@ -79,7 +79,7 @@ describe('sandbox list table rows', () => { entry: 'iron-proxy', version: '0.4.1', role: 'proxy', - class: 'ephemeral', + class: 'stateful', state: 'failed', name: 'iron-proxy.sidecar.e2b.local', }, diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 8db1b7a319..01dc0da33c 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -591,11 +591,15 @@ type SandboxForkResponse = * - `'iroh'` (service role): a peer-to-peer tunnel configured with `pipes` of * `publish` / `connect`; tickets are served at * `http://iroh.sidecar.e2b.local:8080/tickets.json`, to be polled until - * `status == "ready"`. Takes an optional `node_secret` secret slot. + * `status == "ready"`. Takes an optional `node_secret` secret slot. A + * forked sandbox's iroh sidecar starts with a fresh peer identity. * - * Sidecars are ephemeral: torn down at pause and relaunched at resume with - * freshly injected configuration and secrets. Attaching one requires the - * team's `sandbox-sidecars` feature. + * A sidecar follows the sandbox's lifecycle: it is paused and snapshotted + * with the sandbox, comes back exactly as it was on resume (its data + * included), is forked with it, and is terminated with it. A sidecar that + * crashes is restarted once from its clean image and then reported + * `'failed'`; the sandbox keeps running. Attaching one requires the team's + * `sandbox-sidecars` feature. * * @example * ```ts @@ -641,8 +645,8 @@ export type SidecarAttachment = { export type SidecarRole = 'proxy' | 'service' /** - * Lifecycle class of a sidecar. Only `'ephemeral'` sidecars can be attached in - * this version. + * Lifecycle class of a sidecar, as reported by the API; every catalog entry + * today is `'stateful'`. Kept for wire stability. */ export type SidecarClass = 'ephemeral' | 'stateful' diff --git a/packages/js-sdk/tests/sandbox/sidecars.test.ts b/packages/js-sdk/tests/sandbox/sidecars.test.ts index 7e6435b13e..be49bfb49e 100644 --- a/packages/js-sdk/tests/sandbox/sidecars.test.ts +++ b/packages/js-sdk/tests/sandbox/sidecars.test.ts @@ -11,7 +11,7 @@ const redisInfo = { entry: 'redis', version: '7.4.1', role: 'service', - class: 'ephemeral', + class: 'stateful', state: 'running', name: 'redis.sidecar.e2b.local', address: '169.254.0.25', @@ -22,7 +22,7 @@ const failedProxyInfo = { entry: 'iron-proxy', version: '0.4.1', role: 'proxy', - class: 'ephemeral', + class: 'stateful', state: 'failed', name: 'iron-proxy.sidecar.e2b.local', lastError: 'readiness probe timed out', diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 1b0eeda1c1..5fa9f73864 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -524,11 +524,15 @@ class SidecarAttachment(TypedDict): - ``"iroh"`` (service role): a peer-to-peer tunnel configured with ``pipes`` of ``publish`` / ``connect``; tickets are served at ``http://iroh.sidecar.e2b.local:8080/tickets.json``, to be polled until - ``status == "ready"``. Takes an optional ``node_secret`` secret slot. + ``status == "ready"``. Takes an optional ``node_secret`` secret slot. A + forked sandbox's iroh sidecar starts with a fresh peer identity. - Sidecars are ephemeral: torn down at pause and relaunched at resume with - freshly injected configuration and secrets. Attaching one requires the - team's ``sandbox-sidecars`` feature:: + A sidecar follows the sandbox's lifecycle: it is paused and snapshotted + with the sandbox, comes back exactly as it was on resume (its data + included), is forked with it, and is terminated with it. A sidecar that + crashes is restarted once from its clean image and then reported + ``"failed"``; the sandbox keeps running. Attaching one requires the team's + ``sandbox-sidecars`` feature:: sandbox = Sandbox.create( sidecars=[ @@ -571,7 +575,10 @@ class SidecarAttachment(TypedDict): """ SidecarClass = Literal["ephemeral", "stateful"] -"""Lifecycle class of a sidecar. Only ``"ephemeral"`` sidecars can be attached in this version.""" +""" +Lifecycle class of a sidecar, as reported by the API; every catalog entry today +is ``"stateful"``. Kept for wire stability. +""" SidecarState = Union[Literal["starting", "running", "failed", "stopped"], str] """ diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 424bb7df7a..65744207cf 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -203,7 +203,7 @@ async def create( :param iam: Sandbox workload identity configuration. A non-empty ``tokens`` map enables workload identity for the sandbox; token definitions can be created with :meth:`Secret.iam_token`. Example: ``{"tokens": {"aws": Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID")}}``. Registered tokens are exposed to ``network.rules`` ``transform`` callables as ``ctx.iam.tokens[name]`` placeholders, which the egress proxy resolves per request :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` or ``"pause"`` (omitted from the request when unset, leaving the API's default, currently ``"kill"``, in effect), or an object ``{"action": "pause"|"kill", "keep_memory": bool}`` where ``keep_memory`` set to ``False`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); an omitted ``keep_memory`` leaves the snapshot kind to the API; ``auto_resume``: leave unset to let the API pick the behavior, set ``False`` to opt out explicitly, or ``True`` (only when ``on_timeout`` action is ``"pause"``). Example: ``{"on_timeout": {"action": "pause", "keep_memory": False}}`` :param volume_mounts: Dictionary mapping mount paths to AsyncVolume instances or volume names - :param sidecars: Sidecar microVMs to attach to the sandbox from the E2B catalog — at most four, at most one with the proxy role. Each is a :class:`SidecarAttachment`: ``{"entry": "redis"}`` for a service sidecar the sandbox reaches at ``redis.sidecar.e2b.local``, or ``{"entry": "iron-proxy", "secrets": {"": "${e2b.secrets.}"}}`` for the proxy sidecar that substitutes the real secret value on egress so it never enters the sandbox. Sidecars are ephemeral (torn down at pause, relaunched at resume) and need the team's ``sandbox-sidecars`` feature + :param sidecars: Sidecar microVMs to attach to the sandbox from the E2B catalog — at most four, at most one with the proxy role. Each is a :class:`SidecarAttachment`: ``{"entry": "redis"}`` for a service sidecar the sandbox reaches at ``redis.sidecar.e2b.local``, or ``{"entry": "iron-proxy", "secrets": {"": "${e2b.secrets.}"}}`` for the proxy sidecar that substitutes the real secret value on egress so it never enters the sandbox. A sidecar follows the sandbox's lifecycle (paused, snapshotted, resumed as it was, forked and terminated with it; a crashed sidecar is restarted once and then reported ``"failed"`` while the sandbox keeps running) and needs the team's ``sandbox-sidecars`` feature :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: A Sandbox instance for the new sandbox diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 2099cda95c..a81ce11a50 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -199,7 +199,7 @@ def create( :param iam: Sandbox workload identity configuration. A non-empty ``tokens`` map enables workload identity for the sandbox; token definitions can be created with :meth:`Secret.iam_token`. Example: ``{"tokens": {"aws": Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID")}}``. Registered tokens are exposed to ``network.rules`` ``transform`` callables as ``ctx.iam.tokens[name]`` placeholders, which the egress proxy resolves per request :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` or ``"pause"`` (omitted from the request when unset, leaving the API's default, currently ``"kill"``, in effect), or an object ``{"action": "pause"|"kill", "keep_memory": bool}`` where ``keep_memory`` set to ``False`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); an omitted ``keep_memory`` leaves the snapshot kind to the API; ``auto_resume``: leave unset to let the API pick the behavior, set ``False`` to opt out explicitly, or ``True`` (only when ``on_timeout`` action is ``"pause"``). Example: ``{"on_timeout": {"action": "pause", "keep_memory": False}}`` :param volume_mounts: Dictionary mapping mount paths to Volume instances or volume names - :param sidecars: Sidecar microVMs to attach to the sandbox from the E2B catalog — at most four, at most one with the proxy role. Each is a :class:`SidecarAttachment`: ``{"entry": "redis"}`` for a service sidecar the sandbox reaches at ``redis.sidecar.e2b.local``, or ``{"entry": "iron-proxy", "secrets": {"": "${e2b.secrets.}"}}`` for the proxy sidecar that substitutes the real secret value on egress so it never enters the sandbox. Sidecars are ephemeral (torn down at pause, relaunched at resume) and need the team's ``sandbox-sidecars`` feature + :param sidecars: Sidecar microVMs to attach to the sandbox from the E2B catalog — at most four, at most one with the proxy role. Each is a :class:`SidecarAttachment`: ``{"entry": "redis"}`` for a service sidecar the sandbox reaches at ``redis.sidecar.e2b.local``, or ``{"entry": "iron-proxy", "secrets": {"": "${e2b.secrets.}"}}`` for the proxy sidecar that substitutes the real secret value on egress so it never enters the sandbox. A sidecar follows the sandbox's lifecycle (paused, snapshotted, resumed as it was, forked and terminated with it; a crashed sidecar is restarted once and then reported ``"failed"`` while the sandbox keeps running) and needs the team's ``sandbox-sidecars`` feature :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: A Sandbox instance for the new sandbox diff --git a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py index 85641c184e..26fa2029ed 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py +++ b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py @@ -22,7 +22,7 @@ "entry": "redis", "version": "7.4.1", "role": "service", - "class": "ephemeral", + "class": "stateful", "state": "running", "name": "redis.sidecar.e2b.local", "address": "169.254.0.25", @@ -33,7 +33,7 @@ "entry": "iron-proxy", "version": "0.4.1", "role": "proxy", - "class": "ephemeral", + "class": "stateful", "state": "failed", "name": "iron-proxy.sidecar.e2b.local", "lastError": "readiness probe timed out", @@ -177,7 +177,7 @@ def _expected_infos() -> List[SidecarInfo]: entry="redis", version="7.4.1", role="service", - class_="ephemeral", + class_="stateful", state="running", name="redis.sidecar.e2b.local", address="169.254.0.25", @@ -187,7 +187,7 @@ def _expected_infos() -> List[SidecarInfo]: entry="iron-proxy", version="0.4.1", role="proxy", - class_="ephemeral", + class_="stateful", state="failed", name="iron-proxy.sidecar.e2b.local", last_error="readiness probe timed out", From 607ace595a53057347fd3502efad046964c49c6c Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Fri, 11 Sep 2026 23:18:28 +0000 Subject: [PATCH 13/17] sdks: sidecar codes on the resume path The connect/resume endpoint can now reject with sidecar_version_unavailable (409: the catalog version the sidecar was snapshotted with has been removed; the sandbox stays paused) and sidecar_snapshot_mismatch (500: a stored sidecar snapshot has no matching declaration). Route the connect response through the sidecar mapper in JS and both Python variants so the code and status are preserved; with no conflict error type in either SDK both stay SandboxError / SandboxException. Docs list them; tests pin both codes and the 404 path. --- packages/js-sdk/src/sandbox/sandboxApi.ts | 8 +++- .../js-sdk/tests/sandbox/sidecars.test.ts | 27 ++++++++++++ .../python-sdk/e2b/sandbox/sandbox_api.py | 8 +++- .../e2b/sandbox_async/sandbox_api.py | 2 +- .../e2b/sandbox_sync/sandbox_api.py | 2 +- .../tests/shared/sandbox/test_sidecars.py | 43 +++++++++++++++++++ 6 files changed, 84 insertions(+), 6 deletions(-) diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 01dc0da33c..c2c29c115f 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -1319,7 +1319,11 @@ function fromApiSidecars( * `sidecar_deprecated_entry`, `sidecar_limit`, `sidecar_one_proxy`, * `sidecar_config_invalid`, `sidecar_secret_missing`, * `sidecar_rule_collision`, `sidecar_egress_conflict`, `sidecar_flag_off` — - * and a sidecar that did not start is `sidecar_failed` naming the entry. The + * and a sidecar that did not start is `sidecar_failed` naming the entry. On + * resume, `sidecar_version_unavailable` (409: the catalog version the sidecar + * was snapshotted with has been removed; the sandbox stays paused) and + * `sidecar_snapshot_mismatch` (500: a stored sidecar snapshot has no matching + * declaration) stay {@link SandboxError}s — the SDK has no conflict type. The * code stays in the message so callers can tell them apart. */ function sidecarApiError(res: { @@ -2082,7 +2086,7 @@ export class SandboxApi extends ClientFactory { throw new SandboxNotFoundError(`Paused sandbox ${sandboxId} not found`) } - const err = handleApiError(res) + const err = sidecarApiError(res) ?? handleApiError(res) if (err) { throw err } diff --git a/packages/js-sdk/tests/sandbox/sidecars.test.ts b/packages/js-sdk/tests/sandbox/sidecars.test.ts index be49bfb49e..c24da192a6 100644 --- a/packages/js-sdk/tests/sandbox/sidecars.test.ts +++ b/packages/js-sdk/tests/sandbox/sidecars.test.ts @@ -43,6 +43,8 @@ const sandboxDetail = { let lastCreateBody: Record | undefined let createResponse: () => HttpResponse +let connectResponse: () => HttpResponse = () => + HttpResponse.json({ sandboxID: sandboxId, envdVersion: '0.2.4' }) let infoSidecars: unknown[] | undefined const server = setupServer( @@ -56,6 +58,7 @@ const server = setupServer( http.get(apiUrl('/v2/sandboxes'), () => HttpResponse.json([{ ...sandboxDetail, sidecars: infoSidecars }]) ), + http.post(apiUrl(`/sandboxes/${sandboxId}/connect`), () => connectResponse()), http.put(apiUrl(`/sandboxes/${sandboxId}/network`), () => HttpResponse.json( { @@ -294,6 +297,30 @@ test('a 400 without a sidecar code keeps the generic mapping', async () => { expect((err as Error).message).toBe('400: invalid template') }) +test.each([ + [ + 'sidecar_version_unavailable', + 409, + 'catalog version redis@7.4.0 is no longer available; sandbox stays paused', + ], + ['sidecar_snapshot_mismatch', 500, 'snapshot for iroh has no declaration'], +])( + 'Sandbox.connect surfaces %s as a SandboxError with the code and status', + async (code, status, message) => { + connectResponse = () => + HttpResponse.json({ code: status, error_code: code, message }, { status }) + + const err = await Sandbox.connect(sandboxId, { + apiKey: TEST_API_KEY, + }).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(SandboxError) + expect(err).not.toBeInstanceOf(InvalidArgumentError) + expect((err as SandboxError).statusCode).toBe(status) + expect((err as Error).message).toBe(`${code}: ${message}`) + } +) + test('Sandbox.updateNetwork surfaces sidecar_rule_collision as InvalidArgumentError', async () => { const err = await Sandbox.updateNetwork( sandboxId, diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 5fa9f73864..df18965bf9 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -993,8 +993,12 @@ def sidecar_api_exception(res: Any) -> Optional[Exception]: ``sidecar_unknown_entry``, ``sidecar_deprecated_entry``, ``sidecar_limit``, ``sidecar_one_proxy``, ``sidecar_config_invalid``, ``sidecar_secret_missing``, ``sidecar_rule_collision``, ``sidecar_egress_conflict``, ``sidecar_flag_off`` — - and a sidecar that did not start is ``sidecar_failed`` naming the entry. The - code stays in the message so callers can tell them apart. + and a sidecar that did not start is ``sidecar_failed`` naming the entry. On + resume, ``sidecar_version_unavailable`` (409: the catalog version the sidecar + was snapshotted with has been removed; the sandbox stays paused) and + ``sidecar_snapshot_mismatch`` (500: a stored sidecar snapshot has no matching + declaration) stay :class:`SandboxException` — the SDK has no conflict type. + The code stays in the message so callers can tell them apart. """ try: body = json.loads(res.content) if res.content else {} diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index 3473610627..cbd63b5d24 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -557,7 +557,7 @@ async def _cls_connect( raise SandboxNotFoundException(f"Paused sandbox {sandbox_id} not found") if res.status_code >= 300: - raise handle_api_exception(res) + raise sidecar_api_exception(res) or handle_api_exception(res) # Check if res.parse is Error if isinstance(res.parsed, Error): diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index 0ffcb3326e..f78173d15e 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -371,7 +371,7 @@ def _cls_connect( raise SandboxNotFoundException(f"Paused sandbox {sandbox_id} not found") if res.status_code >= 300: - raise handle_api_exception(res) + raise sidecar_api_exception(res) or handle_api_exception(res) if isinstance(res.parsed, Error): raise SandboxException(f"{res.parsed.message}: Request failed") diff --git a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py index 26fa2029ed..8316352dcc 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py +++ b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py @@ -7,6 +7,7 @@ from e2b import AsyncSandbox, Sandbox, SandboxInfo, SidecarInfo from e2b.api.client.api.sandboxes import ( post_sandboxes, + post_sandboxes_sandbox_id_connect, put_sandboxes_sandbox_id_network, ) from e2b.api.client.models import ListedSandbox, SandboxDetail @@ -324,6 +325,48 @@ def test_other_errors_keep_the_generic_mapping(content): assert sidecar_api_exception(_response(400, content)) is None +@pytest.mark.parametrize( + "code, status", + [ + ("sidecar_version_unavailable", 409), + ("sidecar_snapshot_mismatch", 500), + ], +) +def test_resume_codes_stay_sandbox_exceptions_with_the_code_and_status(code, status): + err = sidecar_api_exception( + _response( + status, + f'{{"code":{status},"error_code":"{code}","message":"resume"}}'.encode(), + ) + ) + + assert isinstance(err, SandboxException) + assert not isinstance(err, InvalidArgumentException) + assert err.status_code == status + assert str(err) == f"{code}: resume" + + +def test_connect_surfaces_a_sidecar_version_unavailable_conflict( + monkeypatch, test_api_key +): + request = Mock( + return_value=_response( + 409, + b'{"code":409,"error_code":"sidecar_version_unavailable",' + b'"message":"catalog version redis@7.4.0 is no longer available"}', + ) + ) + monkeypatch.setattr(post_sandboxes_sandbox_id_connect, "sync_detailed", request) + + with pytest.raises(SandboxException) as excinfo: + Sandbox.connect("sbx-test", api_key=test_api_key) + + assert not isinstance(excinfo.value, InvalidArgumentException) + assert excinfo.value.status_code == 409 + assert "sidecar_version_unavailable" in str(excinfo.value) + assert "redis@7.4.0" in str(excinfo.value) + + def test_update_network_surfaces_a_rule_collision_as_an_argument_error( monkeypatch, test_api_key ): From 49552d85d24aed4ee1bc6c8039a25d46457f074f Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Sat, 12 Sep 2026 07:06:42 +0000 Subject: [PATCH 14/17] python-sdk: sidecars is keyword-only, after logger sidecars had been inserted before the existing positional logger parameter of Sandbox.create / AsyncSandbox.create (and _create), which shifted positional callers. It now follows logger behind a `*`, so the twelve pre-existing positional parameters bind as before and sidecars can only be passed by keyword; tests cover both bindings in each variant. --- packages/python-sdk/e2b/sandbox_async/main.py | 6 +- packages/python-sdk/e2b/sandbox_sync/main.py | 6 +- .../tests/shared/sandbox/test_sidecars.py | 110 ++++++++++++++++++ 3 files changed, 118 insertions(+), 4 deletions(-) diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 65744207cf..90c6c71946 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -183,8 +183,9 @@ async def create( iam: Optional[SandboxIamOpts] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[SandboxAsyncVolumeMount] = None, - sidecars: Optional[List[SidecarAttachment]] = None, logger: Optional[logging.Logger] = None, + *, + sidecars: Optional[List[SidecarAttachment]] = None, **opts: Unpack[ApiParams], ) -> Self: """ @@ -1161,8 +1162,9 @@ async def _create( iam: Optional[SandboxIamOpts] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[list] = None, - sidecars: Optional[List[SidecarAttachment]] = None, logger: Optional[logging.Logger] = None, + *, + sidecars: Optional[List[SidecarAttachment]] = None, **opts: Unpack[ApiParams], ) -> Self: params = cls._resolve_api_params(**opts) diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index a81ce11a50..080bae5da9 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -179,8 +179,9 @@ def create( iam: Optional[SandboxIamOpts] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[SandboxVolumeMount] = None, - sidecars: Optional[List[SidecarAttachment]] = None, logger: Optional[logging.Logger] = None, + *, + sidecars: Optional[List[SidecarAttachment]] = None, **opts: Unpack[ApiParams], ) -> Self: """ @@ -1157,8 +1158,9 @@ def _create( iam: Optional[SandboxIamOpts] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[list] = None, - sidecars: Optional[List[SidecarAttachment]] = None, logger: Optional[logging.Logger] = None, + *, + sidecars: Optional[List[SidecarAttachment]] = None, **opts: Unpack[ApiParams], ) -> Self: params = cls._resolve_api_params(**opts) diff --git a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py index 8316352dcc..f8bbaeaf0e 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py +++ b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py @@ -1,3 +1,4 @@ +import logging from types import SimpleNamespace from typing import Any, Dict, List, cast from unittest.mock import AsyncMock, Mock @@ -397,3 +398,112 @@ def test_update_network_404_still_wins_over_the_sidecar_mapping( with pytest.raises(SandboxNotFoundException): Sandbox.update_network("sbx-test", {}, api_key=test_api_key) + + +def _create_response(): + return SimpleNamespace( + sandbox_id="sbx-test", + sandbox_domain=None, + envd_version="0.2.4", + envd_access_token=None, + traffic_access_token=None, + ) + + +def test_create_keeps_logger_positional_and_sidecars_keyword_only( + monkeypatch, test_api_key +): + from e2b.sandbox_sync.sandbox_api import SandboxApi + + create_sandbox = Mock(return_value=_create_response()) + monkeypatch.setattr(SandboxApi, "_create_sandbox", create_sandbox) + logger = logging.getLogger("sidecar-test") + + # The twelve positional parameters create() had before sidecars existed. + Sandbox.create( + "template-id", + 60, + None, + None, + True, + True, + None, + None, + None, + None, + None, + logger, + api_key=test_api_key, + ) + + assert create_sandbox.call_args.kwargs["logger"] is logger + assert create_sandbox.call_args.kwargs["sidecars"] is None + + Sandbox.create(api_key=test_api_key, sidecars=[{"entry": "redis"}]) + assert create_sandbox.call_args.kwargs["sidecars"] == [{"entry": "redis"}] + + # cast: the extra positional argument is the point; ty would reject it statically. + with pytest.raises(TypeError): + cast(Any, Sandbox.create)( + "template-id", + 60, + None, + None, + True, + True, + None, + None, + None, + None, + None, + logger, + [{"entry": "redis"}], + api_key=test_api_key, + ) + + +async def test_async_create_keeps_logger_positional_and_sidecars_keyword_only( + monkeypatch, test_api_key +): + from e2b.sandbox_async.sandbox_api import SandboxApi + + create_sandbox = AsyncMock(return_value=_create_response()) + monkeypatch.setattr(SandboxApi, "_create_sandbox", create_sandbox) + logger = logging.getLogger("sidecar-test") + + await AsyncSandbox.create( + "template-id", + 60, + None, + None, + True, + True, + None, + None, + None, + None, + None, + logger, + api_key=test_api_key, + ) + + assert create_sandbox.call_args.kwargs["logger"] is logger + assert create_sandbox.call_args.kwargs["sidecars"] is None + + with pytest.raises(TypeError): + await cast(Any, AsyncSandbox.create)( + "template-id", + 60, + None, + None, + True, + True, + None, + None, + None, + None, + None, + logger, + [{"entry": "redis"}], + api_key=test_api_key, + ) From 58945b172a8769338cdbf02dfa69a212f154e1d8 Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Sat, 12 Sep 2026 07:07:00 +0000 Subject: [PATCH 15/17] python-sdk: a null sidecar ports value reads as no ports from_client_sidecars guarded ports against Unset only, so a wire null raised TypeError inside get_info()/list(); it is now guarded like address and last_error. The forcing test fails on the previous code. --- .../python-sdk/e2b/sandbox/sandbox_api.py | 35 ++++++++++--------- .../tests/shared/sandbox/test_sidecars.py | 18 ++++++++++ 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index df18965bf9..6812bb2dbd 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -962,28 +962,31 @@ def build_sidecars_body( return body +def _from_client_sidecar(sidecar: ClientSidecarInfo) -> SidecarInfo: + # A wire null is neither Unset nor a value for the optional fields. + ports: List[int] = [] + if not isinstance(sidecar.ports, Unset) and sidecar.ports is not None: + ports = list(sidecar.ports) + return SidecarInfo( + entry=sidecar.entry, + version=sidecar.version, + role=cast(SidecarRole, sidecar.role.value), + class_=cast(SidecarClass, sidecar.class_.value), + state=sidecar.state, + name=sidecar.name, + address=sidecar.address if isinstance(sidecar.address, str) else None, + ports=ports, + last_error=sidecar.last_error if isinstance(sidecar.last_error, str) else None, + ) + + def from_client_sidecars( sidecars: Union[Unset, List[ClientSidecarInfo]], ) -> List[SidecarInfo]: if isinstance(sidecars, Unset): return [] - return [ - SidecarInfo( - entry=sidecar.entry, - version=sidecar.version, - role=cast(SidecarRole, sidecar.role.value), - class_=cast(SidecarClass, sidecar.class_.value), - state=sidecar.state, - name=sidecar.name, - address=sidecar.address if isinstance(sidecar.address, str) else None, - ports=list(sidecar.ports) if not isinstance(sidecar.ports, Unset) else [], - last_error=( - sidecar.last_error if isinstance(sidecar.last_error, str) else None - ), - ) - for sidecar in sidecars - ] + return [_from_client_sidecar(sidecar) for sidecar in sidecars] def sidecar_api_exception(res: Any) -> Optional[Exception]: diff --git a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py index f8bbaeaf0e..d91fef34ea 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py +++ b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py @@ -217,6 +217,24 @@ def test_info_passes_through_a_state_value_the_sdk_does_not_know(): assert info.sidecars[0].state == "restarting" +def test_info_treats_null_optional_fields_as_absent(): + # A wire `null` is neither Unset nor a value; it must not raise inside get_info()/list(). + detail = SandboxDetail.from_dict( + { + **SANDBOX_DETAIL, + "sidecars": [ + {**REDIS_INFO, "ports": None, "address": None, "lastError": None} + ], + } + ) + + [info] = SandboxInfo._from_sandbox_detail(detail).sidecars + + assert info.ports == [] + assert info.address is None + assert info.last_error is None + + def test_info_returns_an_empty_sidecar_list_when_the_api_sends_none(): info = SandboxInfo._from_sandbox_detail(SandboxDetail.from_dict(SANDBOX_DETAIL)) From d0133f13e0addb50819d072896bf9228df3731c2 Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Sat, 12 Sep 2026 07:08:43 +0000 Subject: [PATCH 16/17] python-sdk: name the field when a sidecar config or secrets is malformed build_sidecars_body passed config and secrets straight to dict(), so a list or string escaped as a bare ValueError that named nothing, against the helper's own docstring. config must be a mapping and secrets a mapping of str to str; either failure is InvalidArgumentException naming sidecars[i].config or sidecars[i].secrets. The five forcing cases fail on the previous code (ValueError / TypeError instead). --- .../python-sdk/e2b/sandbox/sandbox_api.py | 13 ++++++++ .../tests/shared/sandbox/test_sidecars.py | 32 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 6812bb2dbd..c17e994293 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -950,10 +950,23 @@ def build_sidecars_body( if sidecar.get("version") is not None: attachment.version = sidecar["version"] if sidecar.get("config") is not None: + if not isinstance(sidecar["config"], Mapping): + raise InvalidArgumentException( + f"sidecars[{i}].config must be a dict of entry-specific settings, " + f"got {type(sidecar['config']).__name__}." + ) config = ClientSidecarAttachmentConfig() config.additional_properties = dict(sidecar["config"]) attachment.config = config if sidecar.get("secrets") is not None: + if not isinstance(sidecar["secrets"], Mapping) or not all( + isinstance(k, str) and isinstance(v, str) + for k, v in sidecar["secrets"].items() + ): + raise InvalidArgumentException( + f"sidecars[{i}].secrets must be a dict of slot name to secret " + "reference string (e.g. {'upstream': '${e2b.secrets.}'})." + ) secrets = ClientSidecarAttachmentSecrets() secrets.additional_properties = dict(sidecar["secrets"]) attachment.secrets = secrets diff --git a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py index d91fef34ea..df5b40d94f 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py +++ b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py @@ -173,6 +173,38 @@ def test_create_rejects_a_malformed_sidecar_list(monkeypatch, test_api_key, side request.assert_not_called() +@pytest.mark.parametrize( + "sidecar, field", + [ + pytest.param( + {"entry": "redis", "config": ["maxmemory"]}, "config", id="config-list" + ), + pytest.param( + {"entry": "redis", "config": "maxmemory=64mb"}, "config", id="config-str" + ), + pytest.param( + {"entry": "iron-proxy", "secrets": ["upstream"]}, + "secrets", + id="secrets-list", + ), + pytest.param( + {"entry": "iron-proxy", "secrets": {"upstream": 1}}, + "secrets", + id="secrets-non-str-value", + ), + pytest.param( + {"entry": "iron-proxy", "secrets": {1: "x"}}, + "secrets", + id="secrets-non-str-key", + ), + ], +) +def test_create_rejects_a_malformed_sidecar_config_or_secrets_by_name(sidecar, field): + # dict() on a bad value used to escape as a bare ValueError that named nothing. + with pytest.raises(InvalidArgumentException, match=rf"sidecars\[0\]\.{field}"): + build_sidecars_body(cast(Any, [sidecar])) + + def _expected_infos() -> List[SidecarInfo]: return [ SidecarInfo( From 0b69ea738782beec7b46620ae538ce833ded441f Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Sat, 12 Sep 2026 14:44:20 +0000 Subject: [PATCH 17/17] spec, sdks, cli: the cache entry is valkey Operator decision 2026-09-12: the cache ships as Valkey (BSD-3, wire-compatible); the catalog entry is `valkey` at valkey.sidecar.e2b.local:6379 with no `redis` alias. Renamed in the SidecarAttachment docs and examples, the create() param docs, error message examples, the changeset, every JS/Python/CLI test fixture, and the spec's entry description (clients regenerated). Each SDK's SidecarAttachment doc says "Valkey (Redis-compatible)" once so a search for redis still lands. --- .changeset/sandbox-sidecars.md | 2 +- .../cli/tests/commands/sandbox/info.test.ts | 8 +-- .../cli/tests/commands/sandbox/list.test.ts | 6 +- packages/js-sdk/src/api/schema.gen.ts | 2 +- packages/js-sdk/src/sandbox/sandboxApi.ts | 9 +-- .../js-sdk/tests/sandbox/sidecars.test.ts | 36 ++++++------ .../api/client/models/sidecar_attachment.py | 2 +- .../python-sdk/e2b/sandbox/sandbox_api.py | 11 ++-- packages/python-sdk/e2b/sandbox_async/main.py | 2 +- packages/python-sdk/e2b/sandbox_sync/main.py | 2 +- .../tests/shared/sandbox/test_sidecars.py | 56 +++++++++---------- spec/openapi.yml | 2 +- 12 files changed, 70 insertions(+), 68 deletions(-) diff --git a/.changeset/sandbox-sidecars.md b/.changeset/sandbox-sidecars.md index bf50c38d8c..c0ad6c7e1b 100644 --- a/.changeset/sandbox-sidecars.md +++ b/.changeset/sandbox-sidecars.md @@ -3,4 +3,4 @@ '@e2b/python-sdk': minor --- -Add `sidecars` to sandbox creation: companion microVMs from the E2B sidecar catalog that run next to the sandbox inside its private network and are reached by the name `{entry}.sidecar.e2b.local`. Each entry names a catalog item — `iron-proxy`, a proxy that swaps a placeholder for the real secret value on egress so the secret never enters the sandbox; `redis`, a cache; `sqlite`, libsql-server over HTTP at `http://sqlite.sidecar.e2b.local:8080`; `iroh`, a peer-to-peer tunnel with `publish`/`connect` pipes whose tickets are read from `http://iroh.sidecar.e2b.local:8080/tickets.json` once `status` is `ready` — with an optional `version`, entry-specific `config`, and `secrets` slots holding `${e2b.secrets.}` references. A sidecar follows the sandbox's lifecycle: it is paused and snapshotted with the sandbox, comes back exactly as it was on resume (its data included), is forked with it (a forked sandbox's `iroh` sidecar starts with a fresh peer identity), and is terminated with it; a sidecar that crashes is restarted once from its clean image and then reported `failed` while the sandbox keeps running. Sandbox info and list return the attached sidecars with their role, class, state, name, address and ports. Sidecar rejections surface as `InvalidArgumentError` / `InvalidArgumentException` with the API's `sidecar_*` code in the message; a sidecar that fails to start keeps its entry name in the error. Requires the team's `sandbox-sidecars` feature. +Add `sidecars` to sandbox creation: companion microVMs from the E2B sidecar catalog that run next to the sandbox inside its private network and are reached by the name `{entry}.sidecar.e2b.local`. Each entry names a catalog item — `iron-proxy`, a proxy that swaps a placeholder for the real secret value on egress so the secret never enters the sandbox; `valkey`, a Valkey (Redis-compatible) cache; `sqlite`, libsql-server over HTTP at `http://sqlite.sidecar.e2b.local:8080`; `iroh`, a peer-to-peer tunnel with `publish`/`connect` pipes whose tickets are read from `http://iroh.sidecar.e2b.local:8080/tickets.json` once `status` is `ready` — with an optional `version`, entry-specific `config`, and `secrets` slots holding `${e2b.secrets.}` references. A sidecar follows the sandbox's lifecycle: it is paused and snapshotted with the sandbox, comes back exactly as it was on resume (its data included), is forked with it (a forked sandbox's `iroh` sidecar starts with a fresh peer identity), and is terminated with it; a sidecar that crashes is restarted once from its clean image and then reported `failed` while the sandbox keeps running. Sandbox info and list return the attached sidecars with their role, class, state, name, address and ports. Sidecar rejections surface as `InvalidArgumentError` / `InvalidArgumentException` with the API's `sidecar_*` code in the message; a sidecar that fails to start keeps its entry name in the error. Requires the team's `sandbox-sidecars` feature. diff --git a/packages/cli/tests/commands/sandbox/info.test.ts b/packages/cli/tests/commands/sandbox/info.test.ts index 0d2aa14bcf..4ee7b13411 100644 --- a/packages/cli/tests/commands/sandbox/info.test.ts +++ b/packages/cli/tests/commands/sandbox/info.test.ts @@ -8,12 +8,12 @@ import { const sidecars: SidecarInfo[] = [ { - entry: 'redis', + entry: 'valkey', version: '7.4.1', role: 'service', class: 'stateful', state: 'running', - name: 'redis.sidecar.e2b.local', + name: 'valkey.sidecar.e2b.local', address: '169.254.0.25', ports: [6379], }, @@ -45,7 +45,7 @@ describe('sandbox info sidecars', () => { test('formats a table with entry, version, role, class, state, name, address, ports and last error', () => { expect(formatSidecarTable(sidecars)).toEqual([ 'ENTRY VERSION ROLE CLASS STATE NAME ADDRESS PORTS LAST ERROR', - 'redis 7.4.1 service stateful running redis.sidecar.e2b.local 169.254.0.25 6379', + 'valkey 7.4.1 service stateful running valkey.sidecar.e2b.local 169.254.0.25 6379', 'iron-proxy 0.4.1 proxy stateful failed iron-proxy.sidecar.e2b.local readiness probe timed out', ]) }) @@ -68,7 +68,7 @@ describe('sandbox info sidecars', () => { const start = lines.findIndex((line) => line.includes('Sidecars')) expect(start).toBeGreaterThan(0) expect(lines[start + 1]).toMatch(/^ ENTRY\s+VERSION/) - expect(lines[start + 2]).toMatch(/^ redis\s+7\.4\.1/) + expect(lines[start + 2]).toMatch(/^ valkey\s+7\.4\.1/) expect(lines[start + 3]).toMatch(/^ iron-proxy\s+0\.4\.1/) }) diff --git a/packages/cli/tests/commands/sandbox/list.test.ts b/packages/cli/tests/commands/sandbox/list.test.ts index 1a7f18fe11..73699e0e35 100644 --- a/packages/cli/tests/commands/sandbox/list.test.ts +++ b/packages/cli/tests/commands/sandbox/list.test.ts @@ -68,12 +68,12 @@ describe('sandbox list table rows', () => { ...sandbox('sbx-a', startedAt), sidecars: [ { - entry: 'redis', + entry: 'valkey', version: '7.4.1', role: 'service', class: 'stateful', state: 'running', - name: 'redis.sidecar.e2b.local', + name: 'valkey.sidecar.e2b.local', }, { entry: 'iron-proxy', @@ -88,7 +88,7 @@ describe('sandbox list table rows', () => { sandbox('sbx-b', startedAt), ]) - expect(withSidecars.sidecars).toBe('redis:running,iron-proxy:failed') + expect(withSidecars.sidecars).toBe('valkey:running,iron-proxy:failed') expect(without.sidecars).toBe('') expect(formatSidecars(undefined)).toBe('') }) diff --git a/packages/js-sdk/src/api/schema.gen.ts b/packages/js-sdk/src/api/schema.gen.ts index 56802a783c..ccb9ecdf94 100644 --- a/packages/js-sdk/src/api/schema.gen.ts +++ b/packages/js-sdk/src/api/schema.gen.ts @@ -2749,7 +2749,7 @@ export interface components { config?: { [key: string]: unknown; }; - /** @description Catalog entry name (for example "iron-proxy" or "redis"). The sandbox reaches the sidecar at "{entry}.sidecar.e2b.local". */ + /** @description Catalog entry name (for example "iron-proxy" or "valkey"). The sandbox reaches the sidecar at "{entry}.sidecar.e2b.local". */ entry: string; /** @description Secret slots the entry declares, keyed by slot name, each holding a secret reference the platform resolves at injection time. The secret value never enters the sandbox. */ secrets?: { diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index c2c29c115f..b09d83312d 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -585,7 +585,8 @@ type SandboxForkResponse = * - `'iron-proxy'` (proxy role): the sandbox's egress is steered through it * and it swaps a placeholder token for the real secret value on the way * out, so the secret never enters the sandbox. - * - `'redis'` (service role): a cache the sandbox talks to directly. + * - `'valkey'` (service role): a Valkey (Redis-compatible) cache the sandbox + * talks to directly, on port 6379. * - `'sqlite'` (service role): libsql-server over HTTP at * `http://sqlite.sidecar.e2b.local:8080`. * - `'iroh'` (service role): a peer-to-peer tunnel configured with `pipes` of @@ -605,7 +606,7 @@ type SandboxForkResponse = * ```ts * const sandbox = await Sandbox.create({ * sidecars: [ - * { entry: 'redis' }, + * { entry: 'valkey' }, * { * entry: 'iron-proxy', * // Slot names and config keys are defined by the catalog entry. @@ -616,7 +617,7 @@ type SandboxForkResponse = * ``` */ export type SidecarAttachment = { - /** Catalog entry name: `'iron-proxy'`, `'redis'`, `'sqlite'` or `'iroh'`. */ + /** Catalog entry name: `'iron-proxy'`, `'valkey'`, `'sqlite'` or `'iroh'`. */ entry: string /** Catalog entry version. Defaults to the entry's current version. */ @@ -1282,7 +1283,7 @@ function buildSidecarsBody( return sidecars.map((sidecar, i) => { if (!isPlainObject(sidecar) || typeof sidecar.entry !== 'string') { throw new InvalidArgumentError( - `sidecars[${i}] must be an object with a string 'entry' naming a catalog entry (e.g. 'redis').` + `sidecars[${i}] must be an object with a string 'entry' naming a catalog entry (e.g. 'valkey').` ) } diff --git a/packages/js-sdk/tests/sandbox/sidecars.test.ts b/packages/js-sdk/tests/sandbox/sidecars.test.ts index c24da192a6..ed7ebf5ab1 100644 --- a/packages/js-sdk/tests/sandbox/sidecars.test.ts +++ b/packages/js-sdk/tests/sandbox/sidecars.test.ts @@ -7,13 +7,13 @@ import { TEST_API_KEY, apiUrl } from '../setup' const sandboxId = 'test-sandbox-id' -const redisInfo = { - entry: 'redis', +const valkeyInfo = { + entry: 'valkey', version: '7.4.1', role: 'service', class: 'stateful', state: 'running', - name: 'redis.sidecar.e2b.local', + name: 'valkey.sidecar.e2b.local', address: '169.254.0.25', ports: [6379], } @@ -98,7 +98,7 @@ test('Sandbox.create sends the sidecars in the request body', async () => { await Sandbox.create('base', { apiKey: TEST_API_KEY, sidecars: [ - { entry: 'redis' }, + { entry: 'valkey' }, { entry: 'iron-proxy', version: '0.4.1', @@ -109,7 +109,7 @@ test('Sandbox.create sends the sidecars in the request body', async () => { }) expect(lastCreateBody?.sidecars).toEqual([ - { entry: 'redis' }, + { entry: 'valkey' }, { entry: 'iron-proxy', version: '0.4.1', @@ -136,11 +136,11 @@ test('Sandbox.create strips unknown sidecar properties', async () => { sidecars: [ // An untyped caller can copy an extra key out of a config file; the // API rejects unknown properties. - { entry: 'redis', image: 'redis:7' } as any, + { entry: 'valkey', image: 'valkey:7' } as any, ], }) - expect(lastCreateBody?.sidecars).toEqual([{ entry: 'redis' }]) + expect(lastCreateBody?.sidecars).toEqual([{ entry: 'valkey' }]) }) test('Sandbox.create rejects a sidecar without an entry before any request', async () => { @@ -154,7 +154,7 @@ test('Sandbox.create rejects a sidecar without an entry before any request', asy await expect( Sandbox.create('base', { apiKey: TEST_API_KEY, - sidecars: { entry: 'redis' } as any, + sidecars: { entry: 'valkey' } as any, }) ).rejects.toThrowError(InvalidArgumentError) @@ -162,15 +162,15 @@ test('Sandbox.create rejects a sidecar without an entry before any request', asy }) test('Sandbox.getInfo returns the sidecars with their state', async () => { - infoSidecars = [redisInfo, failedProxyInfo] + infoSidecars = [valkeyInfo, failedProxyInfo] const info = await Sandbox.getInfo(sandboxId, { apiKey: TEST_API_KEY }) - expect(info.sidecars).toEqual([redisInfo, failedProxyInfo]) + expect(info.sidecars).toEqual([valkeyInfo, failedProxyInfo]) }) test('Sandbox.getInfo passes through a state value the SDK does not know', async () => { - infoSidecars = [{ ...redisInfo, state: 'restarting' }] + infoSidecars = [{ ...valkeyInfo, state: 'restarting' }] const info = await Sandbox.getInfo(sandboxId, { apiKey: TEST_API_KEY }) @@ -184,11 +184,11 @@ test('Sandbox.getInfo returns an empty sidecar list when the API sends none', as }) test('Sandbox.list returns the sidecars of each sandbox', async () => { - infoSidecars = [redisInfo] + infoSidecars = [valkeyInfo] const [info] = await Sandbox.list({ apiKey: TEST_API_KEY }).nextItems() - expect(info.sidecars).toEqual([redisInfo]) + expect(info.sidecars).toEqual([valkeyInfo]) }) test('a sidecar_* 400 surfaces as InvalidArgumentError with the code preserved', async () => { @@ -219,21 +219,21 @@ test('sidecar_failed keeps the entry name and is not an argument error', async ( { code: 500, error_code: 'sidecar_failed', - message: 'sidecar "redis" failed to become ready', + message: 'sidecar "valkey" failed to become ready', }, { status: 500 } ) const err = await Sandbox.create('base', { apiKey: TEST_API_KEY, - sidecars: [{ entry: 'redis' }], + sidecars: [{ entry: 'valkey' }], }).catch((e: unknown) => e) expect(err).toBeInstanceOf(SandboxError) expect(err).not.toBeInstanceOf(InvalidArgumentError) expect((err as SandboxError).statusCode).toBe(500) expect((err as Error).message).toContain('sidecar_failed') - expect((err as Error).message).toContain('redis') + expect((err as Error).message).toContain('valkey') }) test.each([ @@ -257,7 +257,7 @@ test.each([ const err = await Sandbox.create('base', { apiKey: TEST_API_KEY, - sidecars: [{ entry: 'redis' }], + sidecars: [{ entry: 'valkey' }], }).catch((e: unknown) => e) expect(err).toBeInstanceOf(InvalidArgumentError) @@ -301,7 +301,7 @@ test.each([ [ 'sidecar_version_unavailable', 409, - 'catalog version redis@7.4.0 is no longer available; sandbox stays paused', + 'catalog version valkey@7.4.0 is no longer available; sandbox stays paused', ], ['sidecar_snapshot_mismatch', 500, 'snapshot for iroh has no declaration'], ])( diff --git a/packages/python-sdk/e2b/api/client/models/sidecar_attachment.py b/packages/python-sdk/e2b/api/client/models/sidecar_attachment.py index 50959b66a0..d54cda43d2 100644 --- a/packages/python-sdk/e2b/api/client/models/sidecar_attachment.py +++ b/packages/python-sdk/e2b/api/client/models/sidecar_attachment.py @@ -19,7 +19,7 @@ class SidecarAttachment: """A sidecar microVM to attach to the sandbox, declared from the E2B sidecar catalog. Attributes: - entry (str): Catalog entry name (for example "iron-proxy" or "redis"). The sandbox reaches the sidecar at + entry (str): Catalog entry name (for example "iron-proxy" or "valkey"). The sandbox reaches the sidecar at "{entry}.sidecar.e2b.local". version (Union[Unset, str]): Catalog entry version. Defaults to the entry's current version. config (Union[Unset, SidecarAttachmentConfig]): Entry-specific configuration, validated against the entry's diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index c17e994293..1909fa5fb8 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -518,7 +518,8 @@ class SidecarAttachment(TypedDict): - ``"iron-proxy"`` (proxy role): the sandbox's egress is steered through it and it swaps a placeholder token for the real secret value on the way out, so the secret never enters the sandbox. - - ``"redis"`` (service role): a cache the sandbox talks to directly. + - ``"valkey"`` (service role): a Valkey (Redis-compatible) cache the sandbox + talks to directly, on port 6379. - ``"sqlite"`` (service role): libsql-server over HTTP at ``http://sqlite.sidecar.e2b.local:8080``. - ``"iroh"`` (service role): a peer-to-peer tunnel configured with @@ -536,7 +537,7 @@ class SidecarAttachment(TypedDict): sandbox = Sandbox.create( sidecars=[ - {"entry": "redis"}, + {"entry": "valkey"}, { "entry": "iron-proxy", # Slot names and config keys are defined by the catalog entry. @@ -547,7 +548,7 @@ class SidecarAttachment(TypedDict): """ entry: str - """Catalog entry name: ``"iron-proxy"``, ``"redis"``, ``"sqlite"`` or ``"iroh"``.""" + """Catalog entry name: ``"iron-proxy"``, ``"valkey"``, ``"sqlite"`` or ``"iroh"``.""" version: NotRequired[str] """Catalog entry version. Defaults to the entry's current version.""" @@ -933,7 +934,7 @@ def build_sidecars_body( ): raise InvalidArgumentException( "sidecars must be a list of dicts with a string 'entry' " - "(e.g. [{'entry': 'redis'}])." + "(e.g. [{'entry': 'valkey'}])." ) body: List[ClientSidecarAttachment] = [] @@ -943,7 +944,7 @@ def build_sidecars_body( ): raise InvalidArgumentException( f"sidecars[{i}] must be a dict with a string 'entry' naming a " - "catalog entry (e.g. 'redis')." + "catalog entry (e.g. 'valkey')." ) attachment = ClientSidecarAttachment(entry=sidecar["entry"]) diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 90c6c71946..70102c9440 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -204,7 +204,7 @@ async def create( :param iam: Sandbox workload identity configuration. A non-empty ``tokens`` map enables workload identity for the sandbox; token definitions can be created with :meth:`Secret.iam_token`. Example: ``{"tokens": {"aws": Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID")}}``. Registered tokens are exposed to ``network.rules`` ``transform`` callables as ``ctx.iam.tokens[name]`` placeholders, which the egress proxy resolves per request :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` or ``"pause"`` (omitted from the request when unset, leaving the API's default, currently ``"kill"``, in effect), or an object ``{"action": "pause"|"kill", "keep_memory": bool}`` where ``keep_memory`` set to ``False`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); an omitted ``keep_memory`` leaves the snapshot kind to the API; ``auto_resume``: leave unset to let the API pick the behavior, set ``False`` to opt out explicitly, or ``True`` (only when ``on_timeout`` action is ``"pause"``). Example: ``{"on_timeout": {"action": "pause", "keep_memory": False}}`` :param volume_mounts: Dictionary mapping mount paths to AsyncVolume instances or volume names - :param sidecars: Sidecar microVMs to attach to the sandbox from the E2B catalog — at most four, at most one with the proxy role. Each is a :class:`SidecarAttachment`: ``{"entry": "redis"}`` for a service sidecar the sandbox reaches at ``redis.sidecar.e2b.local``, or ``{"entry": "iron-proxy", "secrets": {"": "${e2b.secrets.}"}}`` for the proxy sidecar that substitutes the real secret value on egress so it never enters the sandbox. A sidecar follows the sandbox's lifecycle (paused, snapshotted, resumed as it was, forked and terminated with it; a crashed sidecar is restarted once and then reported ``"failed"`` while the sandbox keeps running) and needs the team's ``sandbox-sidecars`` feature + :param sidecars: Sidecar microVMs to attach to the sandbox from the E2B catalog — at most four, at most one with the proxy role. Each is a :class:`SidecarAttachment`: ``{"entry": "valkey"}`` for a service sidecar the sandbox reaches at ``valkey.sidecar.e2b.local``, or ``{"entry": "iron-proxy", "secrets": {"": "${e2b.secrets.}"}}`` for the proxy sidecar that substitutes the real secret value on egress so it never enters the sandbox. A sidecar follows the sandbox's lifecycle (paused, snapshotted, resumed as it was, forked and terminated with it; a crashed sidecar is restarted once and then reported ``"failed"`` while the sandbox keeps running) and needs the team's ``sandbox-sidecars`` feature :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: A Sandbox instance for the new sandbox diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 080bae5da9..91bbc40c7a 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -200,7 +200,7 @@ def create( :param iam: Sandbox workload identity configuration. A non-empty ``tokens`` map enables workload identity for the sandbox; token definitions can be created with :meth:`Secret.iam_token`. Example: ``{"tokens": {"aws": Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID")}}``. Registered tokens are exposed to ``network.rules`` ``transform`` callables as ``ctx.iam.tokens[name]`` placeholders, which the egress proxy resolves per request :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` or ``"pause"`` (omitted from the request when unset, leaving the API's default, currently ``"kill"``, in effect), or an object ``{"action": "pause"|"kill", "keep_memory": bool}`` where ``keep_memory`` set to ``False`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); an omitted ``keep_memory`` leaves the snapshot kind to the API; ``auto_resume``: leave unset to let the API pick the behavior, set ``False`` to opt out explicitly, or ``True`` (only when ``on_timeout`` action is ``"pause"``). Example: ``{"on_timeout": {"action": "pause", "keep_memory": False}}`` :param volume_mounts: Dictionary mapping mount paths to Volume instances or volume names - :param sidecars: Sidecar microVMs to attach to the sandbox from the E2B catalog — at most four, at most one with the proxy role. Each is a :class:`SidecarAttachment`: ``{"entry": "redis"}`` for a service sidecar the sandbox reaches at ``redis.sidecar.e2b.local``, or ``{"entry": "iron-proxy", "secrets": {"": "${e2b.secrets.}"}}`` for the proxy sidecar that substitutes the real secret value on egress so it never enters the sandbox. A sidecar follows the sandbox's lifecycle (paused, snapshotted, resumed as it was, forked and terminated with it; a crashed sidecar is restarted once and then reported ``"failed"`` while the sandbox keeps running) and needs the team's ``sandbox-sidecars`` feature + :param sidecars: Sidecar microVMs to attach to the sandbox from the E2B catalog — at most four, at most one with the proxy role. Each is a :class:`SidecarAttachment`: ``{"entry": "valkey"}`` for a service sidecar the sandbox reaches at ``valkey.sidecar.e2b.local``, or ``{"entry": "iron-proxy", "secrets": {"": "${e2b.secrets.}"}}`` for the proxy sidecar that substitutes the real secret value on egress so it never enters the sandbox. A sidecar follows the sandbox's lifecycle (paused, snapshotted, resumed as it was, forked and terminated with it; a crashed sidecar is restarted once and then reported ``"failed"`` while the sandbox keeps running) and needs the team's ``sandbox-sidecars`` feature :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: A Sandbox instance for the new sandbox diff --git a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py index df5b40d94f..340a075af7 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py +++ b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py @@ -20,13 +20,13 @@ ) from e2b.sandbox.sandbox_api import build_sidecars_body, sidecar_api_exception -REDIS_INFO: Dict[str, Any] = { - "entry": "redis", +VALKEY_INFO: Dict[str, Any] = { + "entry": "valkey", "version": "7.4.1", "role": "service", "class": "stateful", "state": "running", - "name": "redis.sidecar.e2b.local", + "name": "valkey.sidecar.e2b.local", "address": "169.254.0.25", "ports": [6379], } @@ -92,7 +92,7 @@ async def _async_request_body(monkeypatch, api_key: str, **kwargs) -> Dict[str, SIDECARS: List[Any] = [ - {"entry": "redis"}, + {"entry": "valkey"}, { "entry": "iron-proxy", "version": "0.4.1", @@ -102,7 +102,7 @@ async def _async_request_body(monkeypatch, api_key: str, **kwargs) -> Dict[str, ] SIDECARS_WIRE = [ - {"entry": "redis"}, + {"entry": "valkey"}, { "entry": "iron-proxy", "version": "0.4.1", @@ -147,10 +147,10 @@ async def test_async_create_omits_an_empty_sidecar_list(monkeypatch, test_api_ke def test_create_strips_unknown_sidecar_keys(): # An untyped caller can copy an extra key out of a config file; the API # rejects unknown properties. - body = build_sidecars_body(cast(Any, [{"entry": "redis", "image": "redis:7"}])) + body = build_sidecars_body(cast(Any, [{"entry": "valkey", "image": "valkey:7"}])) assert body is not None - assert [s.to_dict() for s in body] == [{"entry": "redis"}] + assert [s.to_dict() for s in body] == [{"entry": "valkey"}] @pytest.mark.parametrize( @@ -158,9 +158,9 @@ def test_create_strips_unknown_sidecar_keys(): [ pytest.param([{"version": "7.4.1"}], id="missing-entry"), pytest.param([{"entry": 6379}], id="non-string-entry"), - pytest.param(["redis"], id="string-item"), - pytest.param({"entry": "redis"}, id="dict-instead-of-list"), - pytest.param("redis", id="string"), + pytest.param(["valkey"], id="string-item"), + pytest.param({"entry": "valkey"}, id="dict-instead-of-list"), + pytest.param("valkey", id="string"), ], ) def test_create_rejects_a_malformed_sidecar_list(monkeypatch, test_api_key, sidecars): @@ -177,10 +177,10 @@ def test_create_rejects_a_malformed_sidecar_list(monkeypatch, test_api_key, side "sidecar, field", [ pytest.param( - {"entry": "redis", "config": ["maxmemory"]}, "config", id="config-list" + {"entry": "valkey", "config": ["maxmemory"]}, "config", id="config-list" ), pytest.param( - {"entry": "redis", "config": "maxmemory=64mb"}, "config", id="config-str" + {"entry": "valkey", "config": "maxmemory=64mb"}, "config", id="config-str" ), pytest.param( {"entry": "iron-proxy", "secrets": ["upstream"]}, @@ -208,12 +208,12 @@ def test_create_rejects_a_malformed_sidecar_config_or_secrets_by_name(sidecar, f def _expected_infos() -> List[SidecarInfo]: return [ SidecarInfo( - entry="redis", + entry="valkey", version="7.4.1", role="service", class_="stateful", state="running", - name="redis.sidecar.e2b.local", + name="valkey.sidecar.e2b.local", address="169.254.0.25", ports=[6379], ), @@ -231,7 +231,7 @@ def _expected_infos() -> List[SidecarInfo]: def test_info_returns_the_sidecars_with_their_state(): detail = SandboxDetail.from_dict( - {**SANDBOX_DETAIL, "sidecars": [REDIS_INFO, FAILED_PROXY_INFO]} + {**SANDBOX_DETAIL, "sidecars": [VALKEY_INFO, FAILED_PROXY_INFO]} ) info = SandboxInfo._from_sandbox_detail(detail) @@ -241,7 +241,7 @@ def test_info_returns_the_sidecars_with_their_state(): def test_info_passes_through_a_state_value_the_sdk_does_not_know(): detail = SandboxDetail.from_dict( - {**SANDBOX_DETAIL, "sidecars": [{**REDIS_INFO, "state": "restarting"}]} + {**SANDBOX_DETAIL, "sidecars": [{**VALKEY_INFO, "state": "restarting"}]} ) info = SandboxInfo._from_sandbox_detail(detail) @@ -255,7 +255,7 @@ def test_info_treats_null_optional_fields_as_absent(): { **SANDBOX_DETAIL, "sidecars": [ - {**REDIS_INFO, "ports": None, "address": None, "lastError": None} + {**VALKEY_INFO, "ports": None, "address": None, "lastError": None} ], } ) @@ -274,7 +274,7 @@ def test_info_returns_an_empty_sidecar_list_when_the_api_sends_none(): def test_list_returns_the_sidecars_of_each_sandbox(): - listed = ListedSandbox.from_dict({**SANDBOX_DETAIL, "sidecars": [REDIS_INFO]}) + listed = ListedSandbox.from_dict({**SANDBOX_DETAIL, "sidecars": [VALKEY_INFO]}) info = SandboxInfo._from_listed_sandbox(listed) @@ -310,7 +310,7 @@ async def test_async_sidecar_400_is_an_argument_error(monkeypatch, test_api_key) monkeypatch.setattr(post_sandboxes, "asyncio_detailed", request) with pytest.raises(InvalidArgumentException, match="sidecar_flag_off"): - await AsyncSandbox.create(api_key=test_api_key, sidecars=[{"entry": "redis"}]) + await AsyncSandbox.create(api_key=test_api_key, sidecars=[{"entry": "valkey"}]) def test_sidecar_failed_keeps_the_entry_name_and_is_not_an_argument_error( @@ -320,18 +320,18 @@ def test_sidecar_failed_keeps_the_entry_name_and_is_not_an_argument_error( return_value=_response( 500, b'{"code":500,"error_code":"sidecar_failed",' - b'"message":"sidecar \\"redis\\" failed to become ready"}', + b'"message":"sidecar \\"valkey\\" failed to become ready"}', ) ) monkeypatch.setattr(post_sandboxes, "sync_detailed", request) with pytest.raises(SandboxException) as excinfo: - Sandbox.create(api_key=test_api_key, sidecars=[{"entry": "redis"}]) + Sandbox.create(api_key=test_api_key, sidecars=[{"entry": "valkey"}]) assert not isinstance(excinfo.value, InvalidArgumentException) assert excinfo.value.status_code == 500 assert "sidecar_failed" in str(excinfo.value) - assert "redis" in str(excinfo.value) + assert "valkey" in str(excinfo.value) @pytest.mark.parametrize( @@ -404,7 +404,7 @@ def test_connect_surfaces_a_sidecar_version_unavailable_conflict( return_value=_response( 409, b'{"code":409,"error_code":"sidecar_version_unavailable",' - b'"message":"catalog version redis@7.4.0 is no longer available"}', + b'"message":"catalog version valkey@7.4.0 is no longer available"}', ) ) monkeypatch.setattr(post_sandboxes_sandbox_id_connect, "sync_detailed", request) @@ -415,7 +415,7 @@ def test_connect_surfaces_a_sidecar_version_unavailable_conflict( assert not isinstance(excinfo.value, InvalidArgumentException) assert excinfo.value.status_code == 409 assert "sidecar_version_unavailable" in str(excinfo.value) - assert "redis@7.4.0" in str(excinfo.value) + assert "valkey@7.4.0" in str(excinfo.value) def test_update_network_surfaces_a_rule_collision_as_an_argument_error( @@ -489,8 +489,8 @@ def test_create_keeps_logger_positional_and_sidecars_keyword_only( assert create_sandbox.call_args.kwargs["logger"] is logger assert create_sandbox.call_args.kwargs["sidecars"] is None - Sandbox.create(api_key=test_api_key, sidecars=[{"entry": "redis"}]) - assert create_sandbox.call_args.kwargs["sidecars"] == [{"entry": "redis"}] + Sandbox.create(api_key=test_api_key, sidecars=[{"entry": "valkey"}]) + assert create_sandbox.call_args.kwargs["sidecars"] == [{"entry": "valkey"}] # cast: the extra positional argument is the point; ty would reject it statically. with pytest.raises(TypeError): @@ -507,7 +507,7 @@ def test_create_keeps_logger_positional_and_sidecars_keyword_only( None, None, logger, - [{"entry": "redis"}], + [{"entry": "valkey"}], api_key=test_api_key, ) @@ -554,6 +554,6 @@ async def test_async_create_keeps_logger_positional_and_sidecars_keyword_only( None, None, logger, - [{"entry": "redis"}], + [{"entry": "valkey"}], api_key=test_api_key, ) diff --git a/spec/openapi.yml b/spec/openapi.yml index a42d36ef05..adeb350165 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -745,7 +745,7 @@ components: properties: entry: type: string - description: Catalog entry name (for example "iron-proxy" or "redis"). The sandbox reaches the sidecar at "{entry}.sidecar.e2b.local". + description: Catalog entry name (for example "iron-proxy" or "valkey"). The sandbox reaches the sidecar at "{entry}.sidecar.e2b.local". version: type: string description: Catalog entry version. Defaults to the entry's current version.