From e89a4035bd7f469df4db84f451f39536bb0746d6 Mon Sep 17 00:00:00 2001 From: Babis Chalios Date: Thu, 3 Sep 2026 16:39:29 +0200 Subject: [PATCH 01/10] chore(spec): bump the infra spec pin and regenerate the clients Picks up `memory` on ConnectSandbox and ResumedSandbox, which the API serves and the spec no longer marks unimplemented. The rest is churn the pin had fallen behind: the removed access-token models, httpsPorts and the reworded transform rules on the network config, and Rig schemas that survive the tag filter as orphan components. Signed-off-by: Babis Chalios --- packages/js-sdk/src/api/schema.gen.ts | 117 +++- .../e2b/api/client/models/__init__.py | 12 +- .../e2b/api/client/models/connect_sandbox.py | 17 +- .../api/client/models/created_access_token.py | 100 --- .../e2b/api/client/models/resumed_sandbox.py | 13 + .../python-sdk/e2b/api/client/models/rig.py | 115 ++++ ...access_token.py => rig_capacity_change.py} | 25 +- .../e2b/api/client/models/rig_error.py | 98 +++ .../e2b/api/client/models/rig_instance.py | 98 +++ .../client/models/sandbox_network_config.py | 23 +- .../models/sandbox_network_config_rules.py | 8 +- .../models/sandbox_network_update_config.py | 9 +- .../sandbox_network_update_config_rules.py | 9 +- spec/infra-ref | 2 +- spec/openapi.yml | 576 ++++++++++++++---- 15 files changed, 947 insertions(+), 275 deletions(-) delete mode 100644 packages/python-sdk/e2b/api/client/models/created_access_token.py create mode 100644 packages/python-sdk/e2b/api/client/models/rig.py rename packages/python-sdk/e2b/api/client/models/{new_access_token.py => rig_capacity_change.py} (70%) create mode 100644 packages/python-sdk/e2b/api/client/models/rig_error.py create mode 100644 packages/python-sdk/e2b/api/client/models/rig_instance.py diff --git a/packages/js-sdk/src/api/schema.gen.ts b/packages/js-sdk/src/api/schema.gen.ts index 3bb6277943..15c0b018f3 100644 --- a/packages/js-sdk/src/api/schema.gen.ts +++ b/packages/js-sdk/src/api/schema.gen.ts @@ -524,7 +524,7 @@ export interface paths { }; cookie?: never; }; - requestBody: { + requestBody?: { content: { "application/json": components["schemas"]["ResumedSandbox"]; }; @@ -2220,6 +2220,8 @@ export interface components { step?: string; }; ConnectSandbox: { + /** @description Defaults to true. When false and the sandbox is paused, resume from disk state only: the sandbox cold-boots fresh and any memory in the snapshot is ignored, never modified or deleted. Disk state has crash-recovery semantics — writes not flushed before the pause may be lost. A no-op for snapshots that contain no memory. Rejected with an error in environments where this capability is not enabled, never silently downgraded to a memory restore. */ + memory?: boolean; /** * Format: int32 * @description Timeout in seconds from the current time after which the sandbox should expire @@ -2231,23 +2233,6 @@ export interface components { * @description CPU cores for the sandbox */ CPUCount: number; - CreatedAccessToken: { - /** - * Format: date-time - * @description Timestamp of access token creation - */ - createdAt: string; - /** - * Format: uuid - * @description Identifier of the access token - */ - id: string; - mask: components["schemas"]["IdentifierMaskingDetails"]; - /** @description Name of the access token */ - name: string; - /** @description The fully created access token */ - token: string; - }; CreatedTeamAPIKey: { /** * Format: date-time @@ -2427,10 +2412,6 @@ export interface components { * @description Memory for the sandbox in MiB */ MemoryMB: number; - NewAccessToken: { - /** @description Name of the access token */ - name: string; - }; NewSandbox: { /** @description Allow sandbox to access the internet. When set to false, it behaves the same as specifying denyOut to 0.0.0.0/0 in the network config. */ allow_internet_access?: boolean; @@ -2634,6 +2615,8 @@ export interface components { * @description Automatically pauses the sandbox after the timeout */ autoPause?: boolean; + /** @description Defaults to true. When false, resume from disk state only: the sandbox cold-boots fresh and any memory in the snapshot is ignored, never modified or deleted. Disk state has crash-recovery semantics — writes not flushed before the pause may be lost. A no-op for snapshots that contain no memory. Rejected with an error in environments where this capability is not enabled, never silently downgraded to a memory restore. */ + memory?: boolean; /** * Format: int32 * @description Time to live for the sandbox in seconds. @@ -2641,6 +2624,73 @@ export interface components { */ timeout?: number; }; + /** @description An orchestrator node pool backed by one cloud scaling group */ + Rig: { + /** + * Format: int32 + * @description Number of instances currently attached to the rig + */ + capacityCurrent: number; + /** + * Format: int32 + * @description Desired number of instances in the rig + */ + capacityDesired: number; + /** + * Format: int32 + * @description Maximum capacity enforced on the rig's scaling group. Omitted when nothing enforces bounds (GCP MIG without an active autoscaler). + */ + capacityMax?: number; + /** + * Format: int32 + * @description Minimum capacity enforced on the rig's scaling group. Omitted when nothing enforces bounds (GCP MIG without an active autoscaler). + */ + capacityMin?: number; + /** @description Rig identifier (e.g. "default") */ + id: string; + /** @description Cloud provider backing the rig ("aws" or "gcp") */ + provider: string; + /** @description Canonical cloud resource ID of the scaling group backing the rig (ARN on AWS, self-link on GCP) */ + resourceID: string; + }; + /** @description Desired capacity to set on the rig's scaling group */ + RigCapacityChange: { + /** + * Format: int32 + * @description Absolute desired number of instances in the rig + */ + desired: number; + }; + /** @description Scaling error on the rig's scaling group, e.g. a failed instance creation due to resource exhaustion */ + RigError: { + /** @description Action being performed when the error occurred (e.g. CREATING) */ + action?: string; + /** @description Provider-specific error code (e.g. ZONE_RESOURCE_POOL_EXHAUSTED, Failed) */ + code: string; + /** @description Instance the error relates to, if any */ + instance?: string; + /** @description Human-readable error message */ + message: string; + /** + * Format: date-time + * @description When the error occurred + */ + timestamp: string; + }; + /** @description An instance attached to a rig's scaling group */ + RigInstance: { + /** + * Format: date-time + * @description When the provider created the instance. Omitted while the instance is transitioning. + */ + createdAt?: string; + /** @description Provider instance ID (EC2 instance ID on AWS, instance name on GCP), also the node ID the orchestrator reports */ + id: string; + /** @description The instance is on its way out of the group and can never become healthy again */ + terminating: boolean; + /** @description The provider is creating, deleting, recreating or otherwise mutating the instance */ + transitioning: boolean; + }; Sandbox: { /** @description Alias of the template */ alias?: string; @@ -2860,9 +2910,11 @@ export interface components { /** @description List of denied CIDR blocks or IP addresses for egress traffic. Domain names are not supported for deny rules. */ denyOut?: string[]; egressProxy?: components["schemas"]["SandboxEgressProxyConfig"]; + /** @description Sandbox ports that serve HTTPS rather than plaintext HTTP. Affects how the proxy reaches the service inside the sandbox; the public URL is HTTPS either way. Certificates are not verified, so self-signed ones work. The envd port (49983) cannot be listed. */ + httpsPorts?: number[]; /** @description Specify host mask which will be used for all sandbox requests */ maskRequestHost?: string; - /** @description Per-domain transform rules applied to matching egress HTTP/HTTPS requests. Keys are domains (e.g. "api.example.com", "example.com"). A domain listed here is not automatically allowed - use allowOut to permit the traffic. */ + /** @description Per-domain transform rules applied to matching outbound HTTPS requests. Keys may be exact DNS names (for example, "api.example.com") or a leading wildcard (for example, "*.example.com"), and are normalized to lowercase on write. Wildcards match subdomains at any depth but not the apex domain; a bare "*" is invalid. Exact rules take precedence, followed by the longest matching wildcard suffix, and matching rule sets are not merged. Broad wildcards such as "*.com" are allowed and may expose transformed credentials to every matching destination the sandbox contacts. Rules do not grant network access; configure allowOut separately to permit the destination. */ rules?: { [key: string]: components["schemas"]["SandboxNetworkRule"][]; }; @@ -2887,7 +2939,7 @@ export interface components { /** @description List of denied CIDR blocks or IP addresses for egress traffic. Domain names are not supported for deny rules. */ denyOut?: string[]; egressProxy?: components["schemas"]["SandboxEgressProxyConfig"]; - /** @description Per-domain transform rules. Replaces all existing rules when provided. */ + /** @description Per-domain transform rules applied to matching outbound HTTPS requests. Replaces all existing rules when provided. Keys may be exact DNS names or a single leading wildcard (for example, "*.example.com"), and are normalized to lowercase on write. Wildcards match subdomains at any depth but not the apex domain; a bare "*" is invalid. Exact rules take precedence, followed by the longest matching wildcard suffix, and matching rule sets are not merged. Broad wildcards such as "*.com" are allowed and may expose transformed credentials to every matching destination the sandbox contacts. Rules do not grant network access; configure allowOut separately to permit the destination. */ rules?: { [key: string]: components["schemas"]["SandboxNetworkRule"][]; }; @@ -3421,8 +3473,8 @@ export interface components { "application/json": components["schemas"]["Error"]; }; }; - /** @description Gone */ - 410: { + /** @description Too many requests */ + 429: { headers: { [name: string]: unknown; }; @@ -3430,8 +3482,8 @@ export interface components { "application/json": components["schemas"]["Error"]; }; }; - /** @description Too many requests */ - 429: { + /** @description Server error */ + 500: { headers: { [name: string]: unknown; }; @@ -3439,8 +3491,8 @@ export interface components { "application/json": components["schemas"]["Error"]; }; }; - /** @description Server error */ - 500: { + /** @description Not implemented by this deployment */ + 501: { headers: { [name: string]: unknown; }; @@ -3477,14 +3529,17 @@ export interface components { }; }; parameters: { - accessTokenID: string; apiKeyID: string; buildID: string; + /** @description Identifier of the cluster */ + clusterID: string; nodeID: string; /** @description Maximum number of items to return per page */ paginationLimit: number; /** @description Cursor to start the list from */ paginationNextToken: string; + /** @description Rig identifier (e.g. "default") */ + rigID: string; sandboxID: string; secretID: string; snapshotID: string; diff --git a/packages/python-sdk/e2b/api/client/models/__init__.py b/packages/python-sdk/e2b/api/client/models/__init__.py index 135420b696..58f31ca0cc 100644 --- a/packages/python-sdk/e2b/api/client/models/__init__.py +++ b/packages/python-sdk/e2b/api/client/models/__init__.py @@ -10,7 +10,6 @@ from .build_log_entry import BuildLogEntry from .build_status_reason import BuildStatusReason from .connect_sandbox import ConnectSandbox -from .created_access_token import CreatedAccessToken from .created_team_api_key import CreatedTeamAPIKey from .delete_template_tags_request import DeleteTemplateTagsRequest from .disk_metrics import DiskMetrics @@ -27,7 +26,6 @@ from .machine_info import MachineInfo from .max_team_metric import MaxTeamMetric from .mcp_type_0 import McpType0 -from .new_access_token import NewAccessToken from .new_sandbox import NewSandbox from .new_secret import NewSecret from .new_team_api_key import NewTeamAPIKey @@ -39,6 +37,10 @@ from .node_status_change import NodeStatusChange from .order_direction import OrderDirection from .resumed_sandbox import ResumedSandbox +from .rig import Rig +from .rig_capacity_change import RigCapacityChange +from .rig_error import RigError +from .rig_instance import RigInstance from .sandbox import Sandbox from .sandbox_auto_resume_config import SandboxAutoResumeConfig from .sandbox_detail import SandboxDetail @@ -112,7 +114,6 @@ "BuildLogEntry", "BuildStatusReason", "ConnectSandbox", - "CreatedAccessToken", "CreatedTeamAPIKey", "DeleteTemplateTagsRequest", "DiskMetrics", @@ -129,7 +130,6 @@ "MachineInfo", "MaxTeamMetric", "McpType0", - "NewAccessToken", "NewSandbox", "NewSecret", "NewTeamAPIKey", @@ -141,6 +141,10 @@ "NodeStatusChange", "OrderDirection", "ResumedSandbox", + "Rig", + "RigCapacityChange", + "RigError", + "RigInstance", "Sandbox", "SandboxAutoResumeConfig", "SandboxDetail", diff --git a/packages/python-sdk/e2b/api/client/models/connect_sandbox.py b/packages/python-sdk/e2b/api/client/models/connect_sandbox.py index de7a8f8c19..f71f626414 100644 --- a/packages/python-sdk/e2b/api/client/models/connect_sandbox.py +++ b/packages/python-sdk/e2b/api/client/models/connect_sandbox.py @@ -1,9 +1,11 @@ from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, TypeVar, Union from attrs import define as _attrs_define from attrs import field as _attrs_field +from ..types import UNSET, Unset + T = TypeVar("T", bound="ConnectSandbox") @@ -12,14 +14,22 @@ class ConnectSandbox: """ Attributes: timeout (int): Timeout in seconds from the current time after which the sandbox should expire + memory (Union[Unset, bool]): Defaults to true. When false and the sandbox is paused, resume from disk state + only: the sandbox cold-boots fresh and any memory in the snapshot is ignored, never modified or deleted. Disk + state has crash-recovery semantics — writes not flushed before the pause may be lost. A no-op for snapshots that + contain no memory. Rejected with an error in environments where this capability is not enabled, never silently + downgraded to a memory restore. """ timeout: int + memory: Union[Unset, bool] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: timeout = self.timeout + memory = self.memory + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -27,6 +37,8 @@ def to_dict(self) -> dict[str, Any]: "timeout": timeout, } ) + if memory is not UNSET: + field_dict["memory"] = memory return field_dict @@ -35,8 +47,11 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: d = dict(src_dict) timeout = d.pop("timeout") + memory = d.pop("memory", UNSET) + connect_sandbox = cls( timeout=timeout, + memory=memory, ) connect_sandbox.additional_properties = d diff --git a/packages/python-sdk/e2b/api/client/models/created_access_token.py b/packages/python-sdk/e2b/api/client/models/created_access_token.py deleted file mode 100644 index 2b038a2d72..0000000000 --- a/packages/python-sdk/e2b/api/client/models/created_access_token.py +++ /dev/null @@ -1,100 +0,0 @@ -import datetime -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar -from uuid import UUID - -from attrs import define as _attrs_define -from attrs import field as _attrs_field -from dateutil.parser import isoparse - -if TYPE_CHECKING: - from ..models.identifier_masking_details import IdentifierMaskingDetails - - -T = TypeVar("T", bound="CreatedAccessToken") - - -@_attrs_define -class CreatedAccessToken: - """ - Attributes: - id (UUID): Identifier of the access token - name (str): Name of the access token - token (str): The fully created access token - mask (IdentifierMaskingDetails): - created_at (datetime.datetime): Timestamp of access token creation - """ - - id: UUID - name: str - token: str - mask: "IdentifierMaskingDetails" - created_at: datetime.datetime - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - id = str(self.id) - - name = self.name - - token = self.token - - mask = self.mask.to_dict() - - created_at = self.created_at.isoformat() - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "id": id, - "name": name, - "token": token, - "mask": mask, - "createdAt": created_at, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.identifier_masking_details import IdentifierMaskingDetails - - d = dict(src_dict) - id = UUID(d.pop("id")) - - name = d.pop("name") - - token = d.pop("token") - - mask = IdentifierMaskingDetails.from_dict(d.pop("mask")) - - created_at = isoparse(d.pop("createdAt")) - - created_access_token = cls( - id=id, - name=name, - token=token, - mask=mask, - created_at=created_at, - ) - - created_access_token.additional_properties = d - return created_access_token - - @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/resumed_sandbox.py b/packages/python-sdk/e2b/api/client/models/resumed_sandbox.py index 915e6ab676..f9e53a0e04 100644 --- a/packages/python-sdk/e2b/api/client/models/resumed_sandbox.py +++ b/packages/python-sdk/e2b/api/client/models/resumed_sandbox.py @@ -15,10 +15,16 @@ class ResumedSandbox: Attributes: timeout (Union[Unset, int]): Time to live for the sandbox in seconds. Default: 15. auto_pause (Union[Unset, bool]): Automatically pauses the sandbox after the timeout + memory (Union[Unset, bool]): Defaults to true. When false, resume from disk state only: the sandbox cold-boots + fresh and any memory in the snapshot is ignored, never modified or deleted. Disk state has crash-recovery + semantics — writes not flushed before the pause may be lost. A no-op for snapshots that contain no memory. + Rejected with an error in environments where this capability is not enabled, never silently downgraded to a + memory restore. """ timeout: Union[Unset, int] = 15 auto_pause: Union[Unset, bool] = UNSET + memory: Union[Unset, bool] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -26,6 +32,8 @@ def to_dict(self) -> dict[str, Any]: auto_pause = self.auto_pause + memory = self.memory + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update({}) @@ -33,6 +41,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["timeout"] = timeout if auto_pause is not UNSET: field_dict["autoPause"] = auto_pause + if memory is not UNSET: + field_dict["memory"] = memory return field_dict @@ -43,9 +53,12 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: auto_pause = d.pop("autoPause", UNSET) + memory = d.pop("memory", UNSET) + resumed_sandbox = cls( timeout=timeout, auto_pause=auto_pause, + memory=memory, ) resumed_sandbox.additional_properties = d diff --git a/packages/python-sdk/e2b/api/client/models/rig.py b/packages/python-sdk/e2b/api/client/models/rig.py new file mode 100644 index 0000000000..537dc8fd6d --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/rig.py @@ -0,0 +1,115 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="Rig") + + +@_attrs_define +class Rig: + """An orchestrator node pool backed by one cloud scaling group + + Attributes: + id (str): Rig identifier (e.g. "default") + provider (str): Cloud provider backing the rig ("aws" or "gcp") + resource_id (str): Canonical cloud resource ID of the scaling group backing the rig (ARN on AWS, self-link on + GCP) + capacity_desired (int): Desired number of instances in the rig + capacity_current (int): Number of instances currently attached to the rig + capacity_min (Union[Unset, int]): Minimum capacity enforced on the rig's scaling group. Omitted when nothing + enforces bounds (GCP MIG without an active autoscaler). + capacity_max (Union[Unset, int]): Maximum capacity enforced on the rig's scaling group. Omitted when nothing + enforces bounds (GCP MIG without an active autoscaler). + """ + + id: str + provider: str + resource_id: str + capacity_desired: int + capacity_current: int + capacity_min: Union[Unset, int] = UNSET + capacity_max: Union[Unset, int] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + id = self.id + + provider = self.provider + + resource_id = self.resource_id + + capacity_desired = self.capacity_desired + + capacity_current = self.capacity_current + + capacity_min = self.capacity_min + + capacity_max = self.capacity_max + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "id": id, + "provider": provider, + "resourceID": resource_id, + "capacityDesired": capacity_desired, + "capacityCurrent": capacity_current, + } + ) + if capacity_min is not UNSET: + field_dict["capacityMin"] = capacity_min + if capacity_max is not UNSET: + field_dict["capacityMax"] = capacity_max + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + id = d.pop("id") + + provider = d.pop("provider") + + resource_id = d.pop("resourceID") + + capacity_desired = d.pop("capacityDesired") + + capacity_current = d.pop("capacityCurrent") + + capacity_min = d.pop("capacityMin", UNSET) + + capacity_max = d.pop("capacityMax", UNSET) + + rig = cls( + id=id, + provider=provider, + resource_id=resource_id, + capacity_desired=capacity_desired, + capacity_current=capacity_current, + capacity_min=capacity_min, + capacity_max=capacity_max, + ) + + rig.additional_properties = d + return rig + + @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/new_access_token.py b/packages/python-sdk/e2b/api/client/models/rig_capacity_change.py similarity index 70% rename from packages/python-sdk/e2b/api/client/models/new_access_token.py rename to packages/python-sdk/e2b/api/client/models/rig_capacity_change.py index 642dac80d4..7854ff23e0 100644 --- a/packages/python-sdk/e2b/api/client/models/new_access_token.py +++ b/packages/python-sdk/e2b/api/client/models/rig_capacity_change.py @@ -4,27 +4,28 @@ from attrs import define as _attrs_define from attrs import field as _attrs_field -T = TypeVar("T", bound="NewAccessToken") +T = TypeVar("T", bound="RigCapacityChange") @_attrs_define -class NewAccessToken: - """ +class RigCapacityChange: + """Desired capacity to set on the rig's scaling group + Attributes: - name (str): Name of the access token + desired (int): Absolute desired number of instances in the rig """ - name: str + desired: int additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: - name = self.name + desired = self.desired field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( { - "name": name, + "desired": desired, } ) @@ -33,14 +34,14 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: d = dict(src_dict) - name = d.pop("name") + desired = d.pop("desired") - new_access_token = cls( - name=name, + rig_capacity_change = cls( + desired=desired, ) - new_access_token.additional_properties = d - return new_access_token + rig_capacity_change.additional_properties = d + return rig_capacity_change @property def additional_keys(self) -> list[str]: diff --git a/packages/python-sdk/e2b/api/client/models/rig_error.py b/packages/python-sdk/e2b/api/client/models/rig_error.py new file mode 100644 index 0000000000..5a098b2606 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/rig_error.py @@ -0,0 +1,98 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="RigError") + + +@_attrs_define +class RigError: + """Scaling error on the rig's scaling group, e.g. a failed instance creation due to resource exhaustion + + Attributes: + timestamp (datetime.datetime): When the error occurred + code (str): Provider-specific error code (e.g. ZONE_RESOURCE_POOL_EXHAUSTED, Failed) + message (str): Human-readable error message + instance (Union[Unset, str]): Instance the error relates to, if any + action (Union[Unset, str]): Action being performed when the error occurred (e.g. CREATING) + """ + + timestamp: datetime.datetime + code: str + message: str + instance: Union[Unset, str] = UNSET + action: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + timestamp = self.timestamp.isoformat() + + code = self.code + + message = self.message + + instance = self.instance + + action = self.action + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "timestamp": timestamp, + "code": code, + "message": message, + } + ) + if instance is not UNSET: + field_dict["instance"] = instance + if action is not UNSET: + field_dict["action"] = action + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + timestamp = isoparse(d.pop("timestamp")) + + code = d.pop("code") + + message = d.pop("message") + + instance = d.pop("instance", UNSET) + + action = d.pop("action", UNSET) + + rig_error = cls( + timestamp=timestamp, + code=code, + message=message, + instance=instance, + action=action, + ) + + rig_error.additional_properties = d + return rig_error + + @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/rig_instance.py b/packages/python-sdk/e2b/api/client/models/rig_instance.py new file mode 100644 index 0000000000..98cd9811c8 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/rig_instance.py @@ -0,0 +1,98 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="RigInstance") + + +@_attrs_define +class RigInstance: + """An instance attached to a rig's scaling group + + Attributes: + id (str): Provider instance ID (EC2 instance ID on AWS, instance name on GCP), also the node ID the orchestrator + reports + transitioning (bool): The provider is creating, deleting, recreating or otherwise mutating the instance + terminating (bool): The instance is on its way out of the group and can never become healthy again + created_at (Union[Unset, datetime.datetime]): When the provider created the instance. Omitted while the instance + is transitioning. + """ + + id: str + transitioning: bool + terminating: bool + created_at: Union[Unset, datetime.datetime] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + id = self.id + + transitioning = self.transitioning + + terminating = self.terminating + + created_at: Union[Unset, str] = UNSET + if not isinstance(self.created_at, Unset): + created_at = self.created_at.isoformat() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "id": id, + "transitioning": transitioning, + "terminating": terminating, + } + ) + if created_at is not UNSET: + field_dict["createdAt"] = created_at + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + id = d.pop("id") + + transitioning = d.pop("transitioning") + + terminating = d.pop("terminating") + + _created_at = d.pop("createdAt", UNSET) + created_at: Union[Unset, datetime.datetime] + if isinstance(_created_at, Unset): + created_at = UNSET + else: + created_at = isoparse(_created_at) + + rig_instance = cls( + id=id, + transitioning=transitioning, + terminating=terminating, + created_at=created_at, + ) + + rig_instance.additional_properties = d + return rig_instance + + @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/sandbox_network_config.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py index 970666efef..108a93b2d4 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py @@ -31,9 +31,16 @@ class SandboxNetworkConfig: TCP is tunneled through the proxy after allow/deny filtering; the sandbox is unaware. Domain-matched flows use remote DNS (ATYP=domain). mask_request_host (Union[Unset, str]): Specify host mask which will be used for all sandbox requests - rules (Union[Unset, SandboxNetworkConfigRules]): Per-domain transform rules applied to matching egress - HTTP/HTTPS requests. Keys are domains (e.g. "api.example.com", "example.com"). A domain listed here is not - automatically allowed - use allowOut to permit the traffic. + https_ports (Union[Unset, list[int]]): Sandbox ports that serve HTTPS rather than plaintext HTTP. Affects how + the proxy reaches the service inside the sandbox; the public URL is HTTPS either way. Certificates are not + verified, so self-signed ones work. The envd port (49983) cannot be listed. + rules (Union[Unset, SandboxNetworkConfigRules]): Per-domain transform rules applied to matching outbound HTTPS + requests. Keys may be exact DNS names (for example, "api.example.com") or a leading wildcard (for example, + "*.example.com"), and are normalized to lowercase on write. Wildcards match subdomains at any depth but not the + apex domain; a bare "*" is invalid. Exact rules take precedence, followed by the longest matching wildcard + suffix, and matching rule sets are not merged. Broad wildcards such as "*.com" are allowed and may expose + transformed credentials to every matching destination the sandbox contacts. Rules do not grant network access; + configure allowOut separately to permit the destination. """ allow_public_traffic: Union[Unset, bool] = True @@ -41,6 +48,7 @@ class SandboxNetworkConfig: deny_out: Union[Unset, list[str]] = UNSET egress_proxy: Union["SandboxEgressProxyConfigType0", None, Unset] = UNSET mask_request_host: Union[Unset, str] = UNSET + https_ports: Union[Unset, list[int]] = UNSET rules: Union[Unset, "SandboxNetworkConfigRules"] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) @@ -69,6 +77,10 @@ def to_dict(self) -> dict[str, Any]: mask_request_host = self.mask_request_host + https_ports: Union[Unset, list[int]] = UNSET + if not isinstance(self.https_ports, Unset): + https_ports = self.https_ports + rules: Union[Unset, dict[str, Any]] = UNSET if not isinstance(self.rules, Unset): rules = self.rules.to_dict() @@ -86,6 +98,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["egressProxy"] = egress_proxy if mask_request_host is not UNSET: field_dict["maskRequestHost"] = mask_request_host + if https_ports is not UNSET: + field_dict["httpsPorts"] = https_ports if rules is not UNSET: field_dict["rules"] = rules @@ -128,6 +142,8 @@ def _parse_egress_proxy( mask_request_host = d.pop("maskRequestHost", UNSET) + https_ports = cast(list[int], d.pop("httpsPorts", UNSET)) + _rules = d.pop("rules", UNSET) rules: Union[Unset, SandboxNetworkConfigRules] if isinstance(_rules, Unset): @@ -141,6 +157,7 @@ def _parse_egress_proxy( deny_out=deny_out, egress_proxy=egress_proxy, mask_request_host=mask_request_host, + https_ports=https_ports, rules=rules, ) diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_config_rules.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_config_rules.py index aeece3851b..e887c7b608 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_network_config_rules.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_config_rules.py @@ -13,8 +13,12 @@ @_attrs_define class SandboxNetworkConfigRules: - """Per-domain transform rules applied to matching egress HTTP/HTTPS requests. Keys are domains (e.g. "api.example.com", - "example.com"). A domain listed here is not automatically allowed - use allowOut to permit the traffic. + """Per-domain transform rules applied to matching outbound HTTPS requests. Keys may be exact DNS names (for example, + "api.example.com") or a leading wildcard (for example, "*.example.com"), and are normalized to lowercase on write. + Wildcards match subdomains at any depth but not the apex domain; a bare "*" is invalid. Exact rules take precedence, + followed by the longest matching wildcard suffix, and matching rule sets are not merged. Broad wildcards such as + "*.com" are allowed and may expose transformed credentials to every matching destination the sandbox contacts. Rules + do not grant network access; configure allowOut separately to permit the destination. """ diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_update_config.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_update_config.py index 1a47ddc7f6..3ea8371d83 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_network_update_config.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_update_config.py @@ -32,8 +32,13 @@ class SandboxNetworkUpdateConfig: egress_proxy (Union['SandboxEgressProxyConfigType0', None, Unset]): SOCKS5 proxy for sandbox egress. Outbound TCP is tunneled through the proxy after allow/deny filtering; the sandbox is unaware. Domain-matched flows use remote DNS (ATYP=domain). - rules (Union[Unset, SandboxNetworkUpdateConfigRules]): Per-domain transform rules. Replaces all existing rules - when provided. + rules (Union[Unset, SandboxNetworkUpdateConfigRules]): Per-domain transform rules applied to matching outbound + HTTPS requests. Replaces all existing rules when provided. Keys may be exact DNS names or a single leading + wildcard (for example, "*.example.com"), and are normalized to lowercase on write. Wildcards match subdomains at + any depth but not the apex domain; a bare "*" is invalid. Exact rules take precedence, followed by the longest + matching wildcard suffix, and matching rule sets are not merged. Broad wildcards such as "*.com" are allowed and + may expose transformed credentials to every matching destination the sandbox contacts. Rules do not grant + network access; configure allowOut separately to permit the destination. allow_internet_access (Union[Unset, bool]): Allow sandbox to access the internet. When set to false, it behaves the same as specifying denyOut to 0.0.0.0/0 in the network config. """ diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_update_config_rules.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_update_config_rules.py index 58d268a27d..0079742a87 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_network_update_config_rules.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_update_config_rules.py @@ -13,7 +13,14 @@ @_attrs_define class SandboxNetworkUpdateConfigRules: - """Per-domain transform rules. Replaces all existing rules when provided.""" + """Per-domain transform rules applied to matching outbound HTTPS requests. Replaces all existing rules when provided. + Keys may be exact DNS names or a single leading wildcard (for example, "*.example.com"), and are normalized to + lowercase on write. Wildcards match subdomains at any depth but not the apex domain; a bare "*" is invalid. Exact + rules take precedence, followed by the longest matching wildcard suffix, and matching rule sets are not merged. + Broad wildcards such as "*.com" are allowed and may expose transformed credentials to every matching destination the + sandbox contacts. Rules do not grant network access; configure allowOut separately to permit the destination. + + """ additional_properties: dict[str, list["SandboxNetworkRule"]] = _attrs_field( init=False, factory=dict diff --git a/spec/infra-ref b/spec/infra-ref index 40c51438b1..8954cf2653 100644 --- a/spec/infra-ref +++ b/spec/infra-ref @@ -1 +1 @@ -e19a12b8fc5d318c6e88a8edba0a94d1f153a841 +8614ec4624c9908f63ccc2882fa8de47a0b406a2 diff --git a/spec/openapi.yml b/spec/openapi.yml index 6ddde58ad7..7018094d2e 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -12,14 +12,6 @@ components: type: apiKey in: header name: X-API-Key - AccessTokenAuth: - type: http - scheme: bearer - bearerFormat: access_token - description: | - **Deprecated.** Access token authentication is deprecated and will be - removed in a future release. Use API key authentication (`X-API-Key`) - instead. # AuthProviderBearerAuth / AuthProviderTeamAuth: B before T in the name # so Bearer is validated before Team. AuthProviderBearerAuth: @@ -36,12 +28,31 @@ components: type: apiKey in: header name: X-Admin-Token + AdminJWTAuth: + type: http + scheme: bearer + bearerFormat: JWT AdminTeamAuth: type: apiKey in: header name: X-Team-ID parameters: + clusterID: + name: clusterID + in: path + required: true + schema: + type: string + format: uuid + description: Identifier of the cluster + rigID: + name: rigID + in: path + required: true + schema: + type: string + description: Rig identifier (e.g. "default") templateID: name: templateID in: path @@ -78,12 +89,6 @@ components: required: true schema: type: string - accessTokenID: - name: accessTokenID - in: path - required: true - schema: - type: string snapshotID: name: snapshotID in: path @@ -176,12 +181,6 @@ components: application/json: schema: $ref: "#/components/schemas/Error" - "410": - description: Gone - content: - application/json: - schema: - $ref: "#/components/schemas/Error" "429": description: Too many requests content: @@ -194,6 +193,12 @@ components: application/json: schema: $ref: "#/components/schemas/Error" + "501": + description: Not implemented by this deployment + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "502": description: Backend error content: @@ -214,6 +219,103 @@ components: $ref: "#/components/schemas/Error" schemas: + Rig: + description: An orchestrator node pool backed by one cloud scaling group + required: + - id + - provider + - resourceID + - capacityDesired + - capacityCurrent + properties: + id: + type: string + description: Rig identifier (e.g. "default") + provider: + type: string + description: Cloud provider backing the rig ("aws" or "gcp") + resourceID: + type: string + description: Canonical cloud resource ID of the scaling group backing the rig (ARN on AWS, self-link on GCP) + capacityDesired: + type: integer + format: int32 + description: Desired number of instances in the rig + capacityMin: + type: integer + format: int32 + description: > + Minimum capacity enforced on the rig's scaling group. Omitted when + nothing enforces bounds (GCP MIG without an active autoscaler). + capacityMax: + type: integer + format: int32 + description: > + Maximum capacity enforced on the rig's scaling group. Omitted when + nothing enforces bounds (GCP MIG without an active autoscaler). + capacityCurrent: + type: integer + format: int32 + description: Number of instances currently attached to the rig + + RigCapacityChange: + description: Desired capacity to set on the rig's scaling group + required: + - desired + properties: + desired: + type: integer + format: int32 + minimum: 0 + description: Absolute desired number of instances in the rig + + RigInstance: + description: An instance attached to a rig's scaling group + required: + - id + - transitioning + - terminating + properties: + id: + type: string + description: Provider instance ID (EC2 instance ID on AWS, instance name on GCP), also the node ID the orchestrator reports + createdAt: + type: string + format: date-time + description: When the provider created the instance. Omitted while the instance is transitioning. + transitioning: + type: boolean + description: The provider is creating, deleting, recreating or otherwise mutating the instance + terminating: + type: boolean + description: The instance is on its way out of the group and can never become healthy again + + RigError: + description: > + Scaling error on the rig's scaling group, e.g. a failed instance + creation due to resource exhaustion + required: + - timestamp + - code + - message + properties: + timestamp: + type: string + format: date-time + description: When the error occurred + code: + type: string + description: Provider-specific error code (e.g. ZONE_RESOURCE_POOL_EXHAUSTED, Failed) + message: + type: string + description: Human-readable error message + instance: + type: string + description: Instance the error relates to, if any + action: + type: string + description: Action being performed when the error occurred (e.g. CREATING) + Team: required: - teamID @@ -356,12 +458,27 @@ components: maskRequestHost: type: string description: Specify host mask which will be used for all sandbox requests + httpsPorts: + type: array + description: Sandbox ports that serve HTTPS rather than plaintext HTTP. Affects how the proxy reaches the service inside the sandbox; the public URL is HTTPS either way. Certificates are not verified, so self-signed ones work. The envd port (49983) cannot be listed. + maxItems: 128 + uniqueItems: true + items: + type: integer + format: uint32 + minimum: 1 + maximum: 65535 rules: type: object description: > - Per-domain transform rules applied to matching egress HTTP/HTTPS requests. - Keys are domains (e.g. "api.example.com", "example.com"). - A domain listed here is not automatically allowed - use allowOut to permit the traffic. + Per-domain transform rules applied to matching outbound HTTPS requests. + Keys may be exact DNS names (for example, "api.example.com") or a leading wildcard + (for example, "*.example.com"), and are normalized to lowercase on write. + Wildcards match subdomains at any depth but not the apex domain; a bare "*" is invalid. + Exact rules take precedence, followed by the longest matching wildcard suffix, and + matching rule sets are not merged. Broad wildcards such as "*.com" are allowed and may + expose transformed credentials to every matching destination the sandbox contacts. + Rules do not grant network access; configure allowOut separately to permit the destination. additionalProperties: type: array items: @@ -385,7 +502,15 @@ components: $ref: "#/components/schemas/SandboxEgressProxyConfig" rules: type: object - description: Per-domain transform rules. Replaces all existing rules when provided. + description: > + Per-domain transform rules applied to matching outbound HTTPS requests. Replaces all + existing rules when provided. Keys may be exact DNS names or a single leading wildcard + (for example, "*.example.com"), and are normalized to lowercase on write. Wildcards match + subdomains at any depth but not the apex domain; a bare "*" is invalid. Exact rules take + precedence, followed by the longest matching wildcard suffix, and matching rule sets are + not merged. Broad wildcards such as "*.com" are allowed and may expose transformed + credentials to every matching destination the sandbox contacts. Rules do not grant + network access; configure allowOut separately to permit the destination. additionalProperties: type: array items: @@ -854,6 +979,15 @@ components: type: boolean deprecated: true description: Automatically pauses the sandbox after the timeout + memory: + type: boolean + description: >- + Defaults to true. When false, resume from disk state only: the sandbox cold-boots fresh and + any memory in the snapshot is ignored, never modified or deleted. Disk + state has crash-recovery semantics — writes not flushed before the pause + may be lost. A no-op for snapshots that contain no memory. Rejected with + an error in environments where this capability is not enabled, never + silently downgraded to a memory restore. ConnectSandbox: type: object @@ -865,6 +999,15 @@ components: type: integer format: int32 minimum: 0 + memory: + type: boolean + description: >- + Defaults to true. When false and the sandbox is paused, resume from disk state only: the + sandbox cold-boots fresh and any memory in the snapshot is ignored, never + modified or deleted. Disk state has crash-recovery semantics — writes not + flushed before the pause may be lost. A no-op for snapshots that contain + no memory. Rejected with an error in environments where this capability + is not enabled, never silently downgraded to a memory restore. SandboxTimeoutRequest: type: object @@ -1859,39 +2002,6 @@ components: format: uint64 description: Number of sandbox create fails - CreatedAccessToken: - required: - - id - - name - - token - - mask - - createdAt - properties: - id: - type: string - format: uuid - description: Identifier of the access token - name: - type: string - description: Name of the access token - token: - type: string - description: The fully created access token - mask: - $ref: "#/components/schemas/IdentifierMaskingDetails" - createdAt: - type: string - format: date-time - description: Timestamp of access token creation - - NewAccessToken: - required: - - name - properties: - name: - type: string - description: Name of the access token - TeamAPIKey: required: - id @@ -2198,7 +2308,6 @@ tags: - name: templates - name: sandboxes - name: auth - - name: access-tokens - name: api-keys - name: tags - name: volumes @@ -2221,7 +2330,6 @@ paths: description: List all teams tags: [auth] security: - - AccessTokenAuth: [] - AuthProviderBearerAuth: [] responses: "200": @@ -2248,6 +2356,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/teamID" - in: query @@ -2293,6 +2403,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/teamID" - in: query @@ -2344,6 +2456,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - name: metadata in: query @@ -2376,6 +2490,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] requestBody: required: true content: @@ -2411,6 +2527,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - name: metadata in: query @@ -2481,6 +2599,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - name: sandbox_ids in: query @@ -2519,6 +2639,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/sandboxID" - in: query @@ -2561,6 +2683,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/sandboxID" - in: query @@ -2620,6 +2744,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/sandboxID" responses: @@ -2646,6 +2772,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/sandboxID" responses: @@ -2669,6 +2797,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/sandboxID" - in: query @@ -2716,6 +2846,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/sandboxID" requestBody: @@ -2748,10 +2880,12 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/sandboxID" requestBody: - required: true + required: false content: application/json: schema: @@ -2796,6 +2930,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/sandboxID" requestBody: @@ -2837,6 +2973,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/sandboxID" requestBody: @@ -2883,6 +3021,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] tags: [sandboxes] requestBody: content: @@ -2911,6 +3051,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] tags: [sandboxes] requestBody: required: true @@ -2942,6 +3084,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] tags: [sandboxes] requestBody: content: @@ -2969,6 +3113,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/sandboxID" requestBody: @@ -3004,6 +3150,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - name: sandboxID in: query @@ -3047,6 +3195,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] requestBody: required: true content: @@ -3077,11 +3227,12 @@ paths: tags: [templates] security: - ApiKeyAuth: [] - - AccessTokenAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - in: query required: false @@ -3122,6 +3273,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] requestBody: required: true content: @@ -3149,12 +3302,13 @@ paths: description: Get an upload link for a tar file containing build layer files tags: [templates] security: - - AccessTokenAuth: [] - ApiKeyAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/templateID" - in: path @@ -3188,11 +3342,12 @@ paths: tags: [templates] security: - ApiKeyAuth: [] - - AccessTokenAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - in: query required: false @@ -3219,7 +3374,6 @@ paths: deprecated: true tags: [templates] security: - - AccessTokenAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] requestBody: @@ -3254,6 +3408,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/templateID" - $ref: "#/components/parameters/paginationNextToken" @@ -3278,7 +3434,6 @@ paths: deprecated: true tags: [templates] security: - - AccessTokenAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] parameters: @@ -3307,11 +3462,12 @@ paths: tags: [templates] security: - ApiKeyAuth: [] - - AccessTokenAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/templateID" responses: @@ -3328,11 +3484,12 @@ paths: tags: [templates] security: - ApiKeyAuth: [] - - AccessTokenAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/templateID" requestBody: @@ -3358,7 +3515,6 @@ paths: deprecated: true tags: [templates] security: - - AccessTokenAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] parameters: @@ -3383,6 +3539,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/templateID" - $ref: "#/components/parameters/buildID" @@ -3407,11 +3565,12 @@ paths: tags: [templates] security: - ApiKeyAuth: [] - - AccessTokenAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/templateID" requestBody: @@ -3440,12 +3599,13 @@ paths: description: Get template build info tags: [templates] security: - - AccessTokenAuth: [] - ApiKeyAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/templateID" - $ref: "#/components/parameters/buildID" @@ -3490,12 +3650,13 @@ paths: description: Get template build logs tags: [templates] security: - - AccessTokenAuth: [] - ApiKeyAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/templateID" - $ref: "#/components/parameters/buildID" @@ -3553,6 +3714,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] requestBody: required: true content: @@ -3584,6 +3747,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] requestBody: required: true content: @@ -3613,6 +3778,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/templateID" responses: @@ -3644,6 +3811,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - name: alias in: path @@ -3674,6 +3843,7 @@ paths: tags: [admin] security: - AdminApiKeyAuth: [] + - AdminJWTAuth: [] parameters: - in: query name: clusterID @@ -3703,6 +3873,7 @@ paths: tags: [admin] security: - AdminApiKeyAuth: [] + - AdminJWTAuth: [] parameters: - $ref: "#/components/parameters/nodeID" - in: query @@ -3731,6 +3902,7 @@ paths: tags: [admin] security: - AdminApiKeyAuth: [] + - AdminJWTAuth: [] parameters: - $ref: "#/components/parameters/nodeID" requestBody: @@ -3757,6 +3929,7 @@ paths: tags: [admin] security: - AdminApiKeyAuth: [] + - AdminJWTAuth: [] parameters: - name: teamID in: path @@ -3788,6 +3961,7 @@ paths: tags: [admin] security: - AdminApiKeyAuth: [] + - AdminJWTAuth: [] responses: "200": description: Running sandbox counts keyed by team ID @@ -3807,6 +3981,7 @@ paths: tags: [admin] security: - AdminApiKeyAuth: [] + - AdminJWTAuth: [] parameters: - name: teamID in: path @@ -3836,6 +4011,7 @@ paths: tags: [admin] security: - AdminApiKeyAuth: [] + - AdminJWTAuth: [] parameters: - name: teamID in: path @@ -3875,6 +4051,7 @@ paths: tags: [admin] security: - AdminApiKeyAuth: [] + - AdminJWTAuth: [] parameters: - name: teamID in: path @@ -3896,53 +4073,6 @@ paths: "500": $ref: "#/components/responses/500" - /access-tokens: - post: - summary: Create access token - description: Create a new access token. Deprecated; use an API key (E2B_API_KEY) instead. - deprecated: true - tags: [access-tokens] - security: - - AuthProviderBearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/NewAccessToken" - responses: - "201": - description: Access token created successfully - content: - application/json: - schema: - $ref: "#/components/schemas/CreatedAccessToken" - "401": - $ref: "#/components/responses/401" - "410": - $ref: "#/components/responses/410" - "500": - $ref: "#/components/responses/500" - - /access-tokens/{accessTokenID}: - delete: - summary: Delete access token - description: Delete an access token - tags: [access-tokens] - security: - - AuthProviderBearerAuth: [] - parameters: - - $ref: "#/components/parameters/accessTokenID" - responses: - "204": - description: Access token deleted successfully - "401": - $ref: "#/components/responses/401" - "404": - $ref: "#/components/responses/404" - "500": - $ref: "#/components/responses/500" - /api-keys: get: summary: List team API keys @@ -3953,6 +4083,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] responses: "200": description: Successfully returned all team API keys @@ -4001,6 +4133,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/apiKeyID" requestBody: @@ -4027,6 +4161,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/apiKeyID" responses: @@ -4050,6 +4186,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] responses: "200": description: Successfully listed all team volumes @@ -4074,6 +4212,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] requestBody: required: true content: @@ -4105,6 +4245,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/volumeID" responses: @@ -4131,6 +4273,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/volumeID" responses: @@ -4154,6 +4298,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/paginationNextToken" - $ref: "#/components/parameters/paginationLimit" @@ -4198,6 +4344,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] requestBody: required: true content: @@ -4241,6 +4389,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/secretID" responses: @@ -4279,6 +4429,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/secretID" requestBody: @@ -4323,6 +4475,8 @@ paths: AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] AdminTeamAuth: [] + - AdminJWTAuth: [] + AdminTeamAuth: [] parameters: - $ref: "#/components/parameters/secretID" responses: @@ -4346,3 +4500,189 @@ paths: $ref: "#/components/responses/502" "504": $ref: "#/components/responses/504" + + /clusters/{clusterID}/rigs: + get: + summary: List rigs of a cluster + description: > + List the orchestrator node pools ("rigs") of a cluster with a snapshot + of their scaling groups. Forwarded to the cluster's edge service; a + cluster with no rig management configured returns an empty list, and + the local cluster answers 501. + tags: [admin] + security: + - AdminApiKeyAuth: [] + - AdminJWTAuth: [] + parameters: + - $ref: "#/components/parameters/clusterID" + responses: + "200": + description: Successfully returned the rigs of the cluster + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Rig" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + "501": + $ref: "#/components/responses/501" + + /clusters/{clusterID}/rigs/{rigID}/capacity: + put: + summary: Set the capacity of a rig + description: > + Set the desired instance count on the rig's scaling group. The value is + passed to the cloud provider unchanged; violations of the group's bounds + or conflicting concurrent operations surface as errors. + tags: [admin] + security: + - AdminApiKeyAuth: [] + - AdminJWTAuth: [] + parameters: + - $ref: "#/components/parameters/clusterID" + - $ref: "#/components/parameters/rigID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RigCapacityChange" + responses: + "202": + description: Capacity change accepted + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "409": + $ref: "#/components/responses/409" + "500": + $ref: "#/components/responses/500" + "501": + $ref: "#/components/responses/501" + + /clusters/{clusterID}/rigs/instances/{instanceID}: + delete: + summary: Terminate an instance of a rig + description: > + Terminate an instance in whichever rig's scaling group it belongs to. + The caller chooses whether the rig shrinks or the instance is replaced. + tags: [admin] + security: + - AdminApiKeyAuth: [] + - AdminJWTAuth: [] + parameters: + - $ref: "#/components/parameters/clusterID" + - name: instanceID + in: path + required: true + schema: + type: string + description: Provider instance ID + - name: decrementDesired + in: query + required: true + schema: + type: boolean + description: > + When true, desired capacity is decremented (rig shrinks); + when false, the scaling group launches a replacement instance + responses: + "202": + description: Instance termination accepted + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "409": + $ref: "#/components/responses/409" + "500": + $ref: "#/components/responses/500" + "501": + $ref: "#/components/responses/501" + + /clusters/{clusterID}/rigs/{rigID}/instances: + get: + summary: List the instances attached to a rig + description: > + List the instances attached to the rig's scaling group with their + creation time and transition state, sorted by instance ID. + tags: [admin] + security: + - AdminApiKeyAuth: [] + - AdminJWTAuth: [] + parameters: + - $ref: "#/components/parameters/clusterID" + - $ref: "#/components/parameters/rigID" + responses: + "200": + description: Successfully returned the instances of the rig + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/RigInstance" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + "501": + $ref: "#/components/responses/501" + + /clusters/{clusterID}/rigs/{rigID}/errors: + get: + summary: List recent scaling errors of a rig + description: > + List recent scaling errors on the rig's scaling group (e.g. failed + instance creations due to resource exhaustion), newest first. + tags: [admin] + security: + - AdminApiKeyAuth: [] + - AdminJWTAuth: [] + parameters: + - $ref: "#/components/parameters/clusterID" + - $ref: "#/components/parameters/rigID" + - name: limit + in: query + required: false + schema: + type: integer + format: int32 + minimum: 1 + maximum: 50 + default: 20 + description: Maximum number of errors to return + responses: + "200": + description: Successfully returned the scaling errors of the rig + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/RigError" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + "501": + $ref: "#/components/responses/501" From d0a8d4af9478271cbdf6a1d2fb61c6c9e1be8511 Mon Sep 17 00:00:00 2001 From: Babis Chalios Date: Thu, 3 Sep 2026 16:51:01 +0200 Subject: [PATCH 02/10] feat: onResume option on connect/resume Resuming a memory-inclusive snapshot whose memory image wedges the guest had no self-serve way out. `onResume: 'reboot'` (`on_resume` in Python) cold-boots from disk state instead, and the memory snapshot is never touched, so a later restore still works. 'restore' is the API's own default and travels as an absent `memory` field; only the 'reboot' literal sends `memory: false`, so an unrecognized value from an untyped caller falls back to a memory restore rather than silently cold-booting. Signed-off-by: Babis Chalios --- .changeset/on-resume-reboot.md | 6 + packages/js-sdk/src/index.ts | 1 + packages/js-sdk/src/sandbox/sandboxApi.ts | 28 +++++ .../tests/sandbox/onResumeRequest.test.ts | 80 ++++++++++++++ packages/python-sdk/e2b/__init__.py | 2 + .../python-sdk/e2b/sandbox/sandbox_api.py | 11 ++ packages/python-sdk/e2b/sandbox_async/main.py | 34 ++++++ .../e2b/sandbox_async/sandbox_api.py | 7 +- packages/python-sdk/e2b/sandbox_sync/main.py | 34 ++++++ .../e2b/sandbox_sync/sandbox_api.py | 7 +- .../shared/sandbox/test_on_resume_request.py | 103 ++++++++++++++++++ 11 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 .changeset/on-resume-reboot.md create mode 100644 packages/js-sdk/tests/sandbox/onResumeRequest.test.ts create mode 100644 packages/python-sdk/tests/shared/sandbox/test_on_resume_request.py diff --git a/.changeset/on-resume-reboot.md b/.changeset/on-resume-reboot.md new file mode 100644 index 0000000000..cef9bba318 --- /dev/null +++ b/.changeset/on-resume-reboot.md @@ -0,0 +1,6 @@ +--- +'e2b': minor +'@e2b/python-sdk': minor +--- + +Add `onResume` / `on_resume` to `Sandbox.connect()`: `'reboot'` resumes a paused sandbox from its disk state alone, leaving the memory snapshot untouched, for the case where restoring that memory wedges the guest. `'restore'` stays the default. Deployments without filesystem-only resume enabled reject `'reboot'` with an error rather than silently restoring the memory. diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index a803efcda9..8d1e15c28b 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -60,6 +60,7 @@ export type { SandboxOpts, SandboxApiOpts, SandboxConnectOpts, + SandboxOnResume, SandboxForkOpts, SandboxMetricsOpts, SandboxPauseOpts, diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 1fbc7a9fc2..1e66de515f 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -665,6 +665,16 @@ export interface SandboxOpts extends ConnectionOpts { lifecycle?: SandboxLifecycle } +/** + * How a paused sandbox comes back. + * + * `'restore'` restores the memory snapshot, so processes and open connections + * survive the pause. `'reboot'` ignores any memory in the snapshot and + * cold-boots from disk state alone — the rescue path for a snapshot whose + * memory image wedges the guest. + */ +export type SandboxOnResume = 'restore' | 'reboot' + /** * Options for connecting to a Sandbox. */ @@ -677,6 +687,23 @@ export type SandboxConnectOpts = ConnectionOpts & { * @default 300_000 // 5 minutes */ timeoutMs?: number + + /** + * How to bring a paused sandbox back. + * + * With `'reboot'` the sandbox cold-boots from its disk state and the memory + * snapshot is left untouched — never modified, never deleted — so the same + * snapshot can still be restored later. Disk state carries crash-recovery + * semantics: writes not flushed before the pause may be lost. A no-op for a + * snapshot that holds no memory, and ignored for a sandbox that is already + * running. + * + * Deployments that have not enabled filesystem-only resume reject + * `'reboot'` with an error rather than quietly restoring the memory. + * + * @default 'restore' + */ + onResume?: SandboxOnResume } /** @@ -1799,6 +1826,7 @@ export class SandboxApi extends ClientFactory { }, body: { timeout: timeoutToSeconds(timeoutMs), + memory: apiOpts?.onResume === 'reboot' ? false : undefined, }, signal: config.getSignal(apiOpts?.requestTimeoutMs, apiOpts?.signal), }) diff --git a/packages/js-sdk/tests/sandbox/onResumeRequest.test.ts b/packages/js-sdk/tests/sandbox/onResumeRequest.test.ts new file mode 100644 index 0000000000..f62a4dd356 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/onResumeRequest.test.ts @@ -0,0 +1,80 @@ +import { afterAll, afterEach, beforeAll, expect, test } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' + +import { Sandbox } from '../../src' +import { TEST_API_KEY, apiUrl } from '../setup' + +let lastConnectBody: Record | undefined + +const server = setupServer( + http.post(apiUrl('/sandboxes/:sandboxID/connect'), async ({ request }) => { + lastConnectBody = (await request.json()) as Record + return HttpResponse.json({ + sandboxID: 'test-sandbox-id', + templateID: 'base', + envdVersion: '0.2.4', + }) + }) +) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + +afterAll(() => server.close()) + +afterEach(() => { + lastConnectBody = undefined + server.resetHandlers() +}) + +test('Sandbox.connect omits memory when onResume is not given', async () => { + await Sandbox.connect('test-sandbox-id', { apiKey: TEST_API_KEY }) + + expect(lastConnectBody).toBeDefined() + expect(lastConnectBody).not.toHaveProperty('memory') +}) + +test("Sandbox.connect omits memory for onResume: 'restore'", async () => { + // 'restore' is the API's own default, so it must travel as an absent field + // rather than memory: true — the two are not interchangeable on the wire. + await Sandbox.connect('test-sandbox-id', { + apiKey: TEST_API_KEY, + onResume: 'restore', + }) + + expect(lastConnectBody).not.toHaveProperty('memory') +}) + +test("Sandbox.connect sends memory: false for onResume: 'reboot'", async () => { + await Sandbox.connect('test-sandbox-id', { + apiKey: TEST_API_KEY, + onResume: 'reboot', + }) + + expect(lastConnectBody?.memory).toBe(false) +}) + +test('sandbox.connect carries onResume on the instance form too', async () => { + const sandbox = await Sandbox.connect('test-sandbox-id', { + apiKey: TEST_API_KEY, + }) + lastConnectBody = undefined + + await sandbox.connect({ onResume: 'reboot' }) + expect(lastConnectBody?.memory).toBe(false) + + await sandbox.connect() + expect(lastConnectBody).not.toHaveProperty('memory') +}) + +test('an untyped onResume value never sends memory: false', async () => { + // Untyped callers can pass anything; only the 'reboot' literal opts into a + // cold boot, so an unrecognized value must fall back to a memory restore. + await Sandbox.connect('test-sandbox-id', { + apiKey: TEST_API_KEY, + // @ts-expect-error 'Reboot' is not a valid onResume value + onResume: 'Reboot', + }) + + expect(lastConnectBody).not.toHaveProperty('memory') +}) diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index 1d3cac9f4d..03ab1bcbc9 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -100,6 +100,7 @@ SandboxNetworkTransformContext, SandboxNetworkTransformResolver, SandboxNetworkUpdate, + SandboxOnResume, SandboxListOrder, SandboxQuery, SandboxState, @@ -233,6 +234,7 @@ "SandboxNetworkUpdate", "SandboxLifecycle", "SandboxOnTimeout", + "SandboxOnResume", "ALL_TRAFFIC", # IAM "SandboxIamOpts", diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 14a2c1f2e1..f8302eadc2 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -570,6 +570,17 @@ class SandboxInfoLifecycle(TypedDict): """ +SandboxOnResume = Literal["restore", "reboot"] +""" +How a paused sandbox comes back. + +``"restore"`` restores the memory snapshot, so processes and open connections +survive the pause. ``"reboot"`` ignores any memory in the snapshot and +cold-boots from disk state alone — the rescue path for a snapshot whose memory +image wedges the guest. +""" + + def _resolve_network_selector( selector: Optional[SandboxNetworkSelector], rules: Mapping[str, List[SandboxNetworkRule]], diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index df3b163871..ab17272dd3 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -29,6 +29,7 @@ SandboxMetrics, SandboxNetworkOpts, SandboxNetworkUpdate, + SandboxOnResume, SnapshotInfo, ) from e2b.sandbox.utils import class_method_variant @@ -266,6 +267,7 @@ async def create( async def connect( self, timeout: Optional[int] = None, + on_resume: SandboxOnResume = "restore", **opts: Unpack[ApiParams], ) -> Self: """ @@ -276,6 +278,15 @@ async def connect( :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) + restores the memory snapshot, so processes and open connections survive the + pause. `"reboot"` cold-boots from disk state alone and leaves the memory + snapshot untouched — never modified, never deleted — so the same snapshot can + still be restored later. Disk state carries crash-recovery semantics: writes + not flushed before the pause may be lost. A no-op for a snapshot that holds no + memory, and ignored for a sandbox that is already running. Deployments that + have not enabled filesystem-only resume reject `"reboot"` with an error rather + than quietly restoring the memory. :return: A running sandbox instance @example @@ -294,6 +305,7 @@ async def connect( async def connect( sandbox_id: str, timeout: Optional[int] = None, + on_resume: SandboxOnResume = "restore", logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> "AsyncSandbox": @@ -306,6 +318,15 @@ async def connect( :param sandbox_id: Sandbox ID :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) + restores the memory snapshot, so processes and open connections survive the + pause. `"reboot"` cold-boots from disk state alone and leaves the memory + snapshot untouched — never modified, never deleted — so the same snapshot can + still be restored later. Disk state carries crash-recovery semantics: writes + not flushed before the pause may be lost. A no-op for a snapshot that holds no + memory, and ignored for a sandbox that is already running. Deployments that + have not enabled filesystem-only resume reject `"reboot"` with an error rather + than quietly restoring the memory. :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 running sandbox instance @@ -324,6 +345,7 @@ async def connect( async def connect( self, timeout: Optional[int] = None, + on_resume: SandboxOnResume = "restore", **opts: Unpack[ApiParams], ) -> Self: """ @@ -334,6 +356,15 @@ async def connect( :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) + restores the memory snapshot, so processes and open connections survive the + pause. `"reboot"` cold-boots from disk state alone and leaves the memory + snapshot untouched — never modified, never deleted — so the same snapshot can + still be restored later. Disk state carries crash-recovery semantics: writes + not flushed before the pause may be lost. A no-op for a snapshot that holds no + memory, and ignored for a sandbox that is already running. Deployments that + have not enabled filesystem-only resume reject `"reboot"` with an error rather + than quietly restoring the memory. :return: A running sandbox instance @example @@ -352,6 +383,7 @@ async def connect( await SandboxApi._cls_connect( sandbox_id=self.sandbox_id, timeout=timeout, + on_resume=on_resume, **self.connection_config.get_api_params(**opts), ) @@ -1009,6 +1041,7 @@ async def _cls_connect_sandbox( cls, sandbox_id: str, timeout: Optional[int] = None, + on_resume: SandboxOnResume = "restore", logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Self: @@ -1023,6 +1056,7 @@ async def _cls_connect_sandbox( sandbox = await SandboxApi._cls_connect( sandbox_id=sandbox_id, timeout=timeout, + on_resume=on_resume, 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 b6a208d6a8..84830f12eb 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -56,6 +56,7 @@ SandboxMetrics, SandboxNetworkOpts, SandboxNetworkUpdate, + SandboxOnResume, SandboxQuery, SnapshotInfo, build_iam_config, @@ -525,6 +526,7 @@ async def _cls_connect( cls, sandbox_id: str, timeout: Optional[int] = None, + on_resume: SandboxOnResume = "restore", logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> SandboxCreateResponse: @@ -537,7 +539,10 @@ async def _cls_connect( res = await post_sandboxes_sandbox_id_connect.asyncio_detailed( sandbox_id, client=api_client, - body=ConnectSandbox(timeout=timeout), + body=ConnectSandbox( + timeout=timeout, + memory=False if on_resume == "reboot" else UNSET, + ), ) if res.status_code == 404: diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 9c5d40f0dd..fab5784d42 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -28,6 +28,7 @@ SandboxMetrics, SandboxNetworkOpts, SandboxNetworkUpdate, + SandboxOnResume, SnapshotInfo, ) from e2b.sandbox.utils import class_method_variant @@ -260,6 +261,7 @@ def create( def connect( self, timeout: Optional[int] = None, + on_resume: SandboxOnResume = "restore", **opts: Unpack[ApiParams], ) -> Self: """ @@ -270,6 +272,15 @@ def connect( :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) + restores the memory snapshot, so processes and open connections survive the + pause. `"reboot"` cold-boots from disk state alone and leaves the memory + snapshot untouched — never modified, never deleted — so the same snapshot can + still be restored later. Disk state carries crash-recovery semantics: writes + not flushed before the pause may be lost. A no-op for a snapshot that holds no + memory, and ignored for a sandbox that is already running. Deployments that + have not enabled filesystem-only resume reject `"reboot"` with an error rather + than quietly restoring the memory. :return: A running sandbox instance @example @@ -289,6 +300,7 @@ def connect( def connect( sandbox_id: str, timeout: Optional[int] = None, + on_resume: SandboxOnResume = "restore", logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> "Sandbox": @@ -301,6 +313,15 @@ def connect( :param sandbox_id: Sandbox ID :param timeout: Timeout for the sandbox in **seconds**. For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) + restores the memory snapshot, so processes and open connections survive the + pause. `"reboot"` cold-boots from disk state alone and leaves the memory + snapshot untouched — never modified, never deleted — so the same snapshot can + still be restored later. Disk state carries crash-recovery semantics: writes + not flushed before the pause may be lost. A no-op for a snapshot that holds no + memory, and ignored for a sandbox that is already running. Deployments that + have not enabled filesystem-only resume reject `"reboot"` with an error rather + than quietly restoring the memory. :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 running sandbox instance @@ -319,6 +340,7 @@ def connect( def connect( self, timeout: Optional[int] = None, + on_resume: SandboxOnResume = "restore", **opts: Unpack[ApiParams], ) -> Self: """ @@ -329,6 +351,15 @@ def connect( :param timeout: Timeout for the sandbox in **seconds**. For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) + restores the memory snapshot, so processes and open connections survive the + pause. `"reboot"` cold-boots from disk state alone and leaves the memory + snapshot untouched — never modified, never deleted — so the same snapshot can + still be restored later. Disk state carries crash-recovery semantics: writes + not flushed before the pause may be lost. A no-op for a snapshot that holds no + memory, and ignored for a sandbox that is already running. Deployments that + have not enabled filesystem-only resume reject `"reboot"` with an error rather + than quietly restoring the memory. :return: A running sandbox instance @example @@ -347,6 +378,7 @@ def connect( SandboxApi._cls_connect( sandbox_id=self.sandbox_id, timeout=timeout, + on_resume=on_resume, **self.connection_config.get_api_params(**opts), ) @@ -1005,6 +1037,7 @@ def _cls_connect_sandbox( cls, sandbox_id: str, timeout: Optional[int] = None, + on_resume: SandboxOnResume = "restore", logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> Self: @@ -1019,6 +1052,7 @@ def _cls_connect_sandbox( sandbox = SandboxApi._cls_connect( sandbox_id=sandbox_id, timeout=timeout, + on_resume=on_resume, 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 ff8dc4a3e8..e838ead98a 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -55,6 +55,7 @@ SandboxMetrics, SandboxNetworkOpts, SandboxNetworkUpdate, + SandboxOnResume, SandboxQuery, SnapshotInfo, build_iam_config, @@ -340,6 +341,7 @@ def _cls_connect( cls, sandbox_id: str, timeout: Optional[int] = None, + on_resume: SandboxOnResume = "restore", logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> SandboxCreateResponse: @@ -351,7 +353,10 @@ def _cls_connect( res = post_sandboxes_sandbox_id_connect.sync_detailed( sandbox_id, client=api_client, - body=ConnectSandbox(timeout=timeout), + body=ConnectSandbox( + timeout=timeout, + memory=False if on_resume == "reboot" else UNSET, + ), ) if res.status_code == 404: diff --git a/packages/python-sdk/tests/shared/sandbox/test_on_resume_request.py b/packages/python-sdk/tests/shared/sandbox/test_on_resume_request.py new file mode 100644 index 0000000000..4788f4c8f4 --- /dev/null +++ b/packages/python-sdk/tests/shared/sandbox/test_on_resume_request.py @@ -0,0 +1,103 @@ +from types import SimpleNamespace +from typing import Any, Dict, Optional, cast +from unittest.mock import AsyncMock, Mock + +import pytest + +from e2b import AsyncSandbox, Sandbox +from e2b.api.client.api.sandboxes import post_sandboxes_sandbox_id_connect +from e2b.api.client.models import Sandbox as SandboxModel + +SANDBOX_ID = "sbx-test" + + +def _connected_sandbox(): + return SimpleNamespace( + status_code=200, + parsed=SandboxModel( + client_id="client-id", + envd_version="0.2.4", + sandbox_id=SANDBOX_ID, + template_id="template-id", + ), + ) + + +def _sync_request_body(monkeypatch, api_key: str, **kwargs) -> Dict[str, Any]: + request = Mock(return_value=_connected_sandbox()) + monkeypatch.setattr(post_sandboxes_sandbox_id_connect, "sync_detailed", request) + + Sandbox.connect(SANDBOX_ID, 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=_connected_sandbox()) + monkeypatch.setattr(post_sandboxes_sandbox_id_connect, "asyncio_detailed", request) + + await AsyncSandbox.connect(SANDBOX_ID, api_key=api_key, **kwargs) + + return request.call_args.kwargs["body"].to_dict() + + +# `None` expects memory to be absent from the payload: "restore" is the API's +# own default, so it travels as an omitted field rather than memory: True. +MEMORY_CASES = [ + pytest.param({}, None, id="default"), + pytest.param({"on_resume": "restore"}, None, id="explicit-restore"), + pytest.param({"on_resume": "reboot"}, False, id="reboot"), + # Untyped callers can pass anything; only the "reboot" literal opts into a + # cold boot, so an unrecognized value must fall back to a memory restore. + pytest.param(cast(Any, {"on_resume": "Reboot"}), None, id="unrecognized-value"), +] + + +@pytest.mark.parametrize("kwargs, memory", MEMORY_CASES) +def test_connect_sends_memory_only_for_reboot( + monkeypatch, test_api_key, kwargs, memory: Optional[bool] +): + body = _sync_request_body(monkeypatch, test_api_key, **kwargs) + + if memory is None: + assert "memory" not in body + else: + assert body["memory"] is memory + + +@pytest.mark.parametrize("kwargs, memory", MEMORY_CASES) +async def test_async_connect_sends_memory_only_for_reboot( + monkeypatch, test_api_key, kwargs, memory: Optional[bool] +): + body = await _async_request_body(monkeypatch, test_api_key, **kwargs) + + if memory is None: + assert "memory" not in body + else: + assert body["memory"] is memory + + +def test_instance_connect_carries_on_resume(monkeypatch, test_api_key): + request = Mock(return_value=_connected_sandbox()) + monkeypatch.setattr(post_sandboxes_sandbox_id_connect, "sync_detailed", request) + + sandbox = Sandbox.connect(SANDBOX_ID, api_key=test_api_key) + + sandbox.connect(on_resume="reboot") + assert request.call_args.kwargs["body"].to_dict()["memory"] is False + + sandbox.connect() + assert "memory" not in request.call_args.kwargs["body"].to_dict() + + +async def test_async_instance_connect_carries_on_resume(monkeypatch, test_api_key): + request = AsyncMock(return_value=_connected_sandbox()) + monkeypatch.setattr(post_sandboxes_sandbox_id_connect, "asyncio_detailed", request) + + sandbox = await AsyncSandbox.connect(SANDBOX_ID, api_key=test_api_key) + + await sandbox.connect(on_resume="reboot") + assert request.call_args.kwargs["body"].to_dict()["memory"] is False + + await sandbox.connect() + assert "memory" not in request.call_args.kwargs["body"].to_dict() From 515538885256d04bffbb5997a5db0ecd3f20565f Mon Sep 17 00:00:00 2001 From: Babis Chalios Date: Thu, 3 Sep 2026 17:17:55 +0200 Subject: [PATCH 03/10] fix(python): make on_resume keyword-only on connect Landing it after `timeout` made `Sandbox.connect(id, 300, "reboot")` a valid call, while JS only accepts the option through SandboxConnectOpts. Placing it after `logger` behind a `*` separator keeps both pre-existing parameters positional, so nothing existing moves. Signed-off-by: Babis Chalios --- packages/python-sdk/e2b/sandbox_async/main.py | 10 +++++++--- .../e2b/sandbox_async/sandbox_api.py | 3 ++- packages/python-sdk/e2b/sandbox_sync/main.py | 10 +++++++--- .../python-sdk/e2b/sandbox_sync/sandbox_api.py | 3 ++- .../shared/sandbox/test_on_resume_request.py | 18 ++++++++++++++++++ 5 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index ab17272dd3..43254218bc 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -267,6 +267,7 @@ async def create( async def connect( self, timeout: Optional[int] = None, + *, on_resume: SandboxOnResume = "restore", **opts: Unpack[ApiParams], ) -> Self: @@ -305,8 +306,9 @@ async def connect( async def connect( sandbox_id: str, timeout: Optional[int] = None, - on_resume: SandboxOnResume = "restore", logger: Optional[logging.Logger] = None, + *, + on_resume: SandboxOnResume = "restore", **opts: Unpack[ApiParams], ) -> "AsyncSandbox": """ @@ -318,6 +320,7 @@ async def connect( :param sandbox_id: Sandbox ID :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :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. :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) restores the memory snapshot, so processes and open connections survive the pause. `"reboot"` cold-boots from disk state alone and leaves the memory @@ -327,7 +330,6 @@ async def connect( memory, and ignored for a sandbox that is already running. Deployments that have not enabled filesystem-only resume reject `"reboot"` with an error rather than quietly restoring the memory. - :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 running sandbox instance @example @@ -345,6 +347,7 @@ async def connect( async def connect( self, timeout: Optional[int] = None, + *, on_resume: SandboxOnResume = "restore", **opts: Unpack[ApiParams], ) -> Self: @@ -1041,8 +1044,9 @@ async def _cls_connect_sandbox( cls, sandbox_id: str, timeout: Optional[int] = None, - on_resume: SandboxOnResume = "restore", logger: Optional[logging.Logger] = None, + *, + on_resume: SandboxOnResume = "restore", **opts: Unpack[ApiParams], ) -> Self: params = cls._resolve_api_params(**opts) diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index 84830f12eb..b4171327f3 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -526,8 +526,9 @@ async def _cls_connect( cls, sandbox_id: str, timeout: Optional[int] = None, - on_resume: SandboxOnResume = "restore", logger: Optional[logging.Logger] = None, + *, + on_resume: SandboxOnResume = "restore", **opts: Unpack[ApiParams], ) -> SandboxCreateResponse: timeout = timeout or SandboxBase.default_sandbox_timeout diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index fab5784d42..0686bdd495 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -261,6 +261,7 @@ def create( def connect( self, timeout: Optional[int] = None, + *, on_resume: SandboxOnResume = "restore", **opts: Unpack[ApiParams], ) -> Self: @@ -300,8 +301,9 @@ def connect( def connect( sandbox_id: str, timeout: Optional[int] = None, - on_resume: SandboxOnResume = "restore", logger: Optional[logging.Logger] = None, + *, + on_resume: SandboxOnResume = "restore", **opts: Unpack[ApiParams], ) -> "Sandbox": """ @@ -313,6 +315,7 @@ def connect( :param sandbox_id: Sandbox ID :param timeout: Timeout for the sandbox in **seconds**. For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :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. :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) restores the memory snapshot, so processes and open connections survive the pause. `"reboot"` cold-boots from disk state alone and leaves the memory @@ -322,7 +325,6 @@ def connect( memory, and ignored for a sandbox that is already running. Deployments that have not enabled filesystem-only resume reject `"reboot"` with an error rather than quietly restoring the memory. - :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 running sandbox instance @example @@ -340,6 +342,7 @@ def connect( def connect( self, timeout: Optional[int] = None, + *, on_resume: SandboxOnResume = "restore", **opts: Unpack[ApiParams], ) -> Self: @@ -1037,8 +1040,9 @@ def _cls_connect_sandbox( cls, sandbox_id: str, timeout: Optional[int] = None, - on_resume: SandboxOnResume = "restore", logger: Optional[logging.Logger] = None, + *, + on_resume: SandboxOnResume = "restore", **opts: Unpack[ApiParams], ) -> Self: params = cls._resolve_api_params(**opts) diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index e838ead98a..d0d9d1656b 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -341,8 +341,9 @@ def _cls_connect( cls, sandbox_id: str, timeout: Optional[int] = None, - on_resume: SandboxOnResume = "restore", logger: Optional[logging.Logger] = None, + *, + on_resume: SandboxOnResume = "restore", **opts: Unpack[ApiParams], ) -> SandboxCreateResponse: timeout = timeout or SandboxBase.default_sandbox_timeout diff --git a/packages/python-sdk/tests/shared/sandbox/test_on_resume_request.py b/packages/python-sdk/tests/shared/sandbox/test_on_resume_request.py index 4788f4c8f4..057e75ac71 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_on_resume_request.py +++ b/packages/python-sdk/tests/shared/sandbox/test_on_resume_request.py @@ -1,3 +1,4 @@ +import inspect from types import SimpleNamespace from typing import Any, Dict, Optional, cast from unittest.mock import AsyncMock, Mock @@ -101,3 +102,20 @@ async def test_async_instance_connect_carries_on_resume(monkeypatch, test_api_ke await sandbox.connect() assert "memory" not in request.call_args.kwargs["body"].to_dict() + + +@pytest.mark.parametrize("sandbox", [Sandbox, AsyncSandbox], ids=["sync", "async"]) +def test_on_resume_is_keyword_only(sandbox): + # An optional parameter must not extend connect()'s positional chain, or + # `connect(sandbox_id, 300, "reboot")` becomes a valid call. + signature = inspect.signature(sandbox.connect) + + assert signature.parameters["on_resume"].kind is inspect.Parameter.KEYWORD_ONLY + assert signature.parameters["timeout"].kind is not inspect.Parameter.KEYWORD_ONLY + + with pytest.raises(TypeError): + signature.bind("sbx-test", 300, "reboot") + + assert signature.bind("sbx-test", on_resume="reboot").arguments["on_resume"] == ( + "reboot" + ) From 39424d665d075d070694d782ef45044a603ae68e Mon Sep 17 00:00:00 2001 From: Babis Chalios Date: Thu, 3 Sep 2026 17:44:36 +0200 Subject: [PATCH 04/10] docs: qualify when a reboot resume is rejected The gate short-circuits a memoryless snapshot before it consults the feature flag, and a running sandbox never reaches the gate, so "not enabled rejects reboot" was only true for a paused snapshot that holds memory. Signed-off-by: Babis Chalios --- .changeset/on-resume-reboot.md | 2 +- packages/js-sdk/src/sandbox/sandboxApi.ts | 5 +++-- packages/python-sdk/e2b/sandbox_async/main.py | 21 +++++++++++-------- packages/python-sdk/e2b/sandbox_sync/main.py | 21 +++++++++++-------- 4 files changed, 28 insertions(+), 21 deletions(-) diff --git a/.changeset/on-resume-reboot.md b/.changeset/on-resume-reboot.md index cef9bba318..7cf9fa2d54 100644 --- a/.changeset/on-resume-reboot.md +++ b/.changeset/on-resume-reboot.md @@ -3,4 +3,4 @@ '@e2b/python-sdk': minor --- -Add `onResume` / `on_resume` to `Sandbox.connect()`: `'reboot'` resumes a paused sandbox from its disk state alone, leaving the memory snapshot untouched, for the case where restoring that memory wedges the guest. `'restore'` stays the default. Deployments without filesystem-only resume enabled reject `'reboot'` with an error rather than silently restoring the memory. +Add `onResume` / `on_resume` to `Sandbox.connect()`: `'reboot'` resumes a paused sandbox from its disk state alone, leaving the memory snapshot untouched, for the case where restoring that memory wedges the guest. `'restore'` stays the default. Where filesystem-only resume is not enabled, a `'reboot'` that would actually drop memory is rejected with an error rather than silently restoring it. diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 1e66de515f..faf5c6753d 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -698,8 +698,9 @@ export type SandboxConnectOpts = ConnectionOpts & { * snapshot that holds no memory, and ignored for a sandbox that is already * running. * - * Deployments that have not enabled filesystem-only resume reject - * `'reboot'` with an error rather than quietly restoring the memory. + * Where filesystem-only resume is not enabled, a `'reboot'` that would + * actually drop memory is rejected with an error rather than quietly + * restoring it; the no-op cases above are unaffected. * * @default 'restore' */ diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 43254218bc..eebf8b1be2 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -285,9 +285,10 @@ async def connect( snapshot untouched — never modified, never deleted — so the same snapshot can still be restored later. Disk state carries crash-recovery semantics: writes not flushed before the pause may be lost. A no-op for a snapshot that holds no - memory, and ignored for a sandbox that is already running. Deployments that - have not enabled filesystem-only resume reject `"reboot"` with an error rather - than quietly restoring the memory. + memory, and ignored for a sandbox that is already running. Where + filesystem-only resume is not enabled, a `"reboot"` that would actually drop + memory is rejected with an error rather than quietly restoring it; the no-op + cases above are unaffected. :return: A running sandbox instance @example @@ -327,9 +328,10 @@ async def connect( snapshot untouched — never modified, never deleted — so the same snapshot can still be restored later. Disk state carries crash-recovery semantics: writes not flushed before the pause may be lost. A no-op for a snapshot that holds no - memory, and ignored for a sandbox that is already running. Deployments that - have not enabled filesystem-only resume reject `"reboot"` with an error rather - than quietly restoring the memory. + memory, and ignored for a sandbox that is already running. Where + filesystem-only resume is not enabled, a `"reboot"` that would actually drop + memory is rejected with an error rather than quietly restoring it; the no-op + cases above are unaffected. :return: A running sandbox instance @example @@ -365,9 +367,10 @@ async def connect( snapshot untouched — never modified, never deleted — so the same snapshot can still be restored later. Disk state carries crash-recovery semantics: writes not flushed before the pause may be lost. A no-op for a snapshot that holds no - memory, and ignored for a sandbox that is already running. Deployments that - have not enabled filesystem-only resume reject `"reboot"` with an error rather - than quietly restoring the memory. + memory, and ignored for a sandbox that is already running. Where + filesystem-only resume is not enabled, a `"reboot"` that would actually drop + memory is rejected with an error rather than quietly restoring it; the no-op + cases above are unaffected. :return: A running sandbox instance @example diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 0686bdd495..17996ca4dd 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -279,9 +279,10 @@ def connect( snapshot untouched — never modified, never deleted — so the same snapshot can still be restored later. Disk state carries crash-recovery semantics: writes not flushed before the pause may be lost. A no-op for a snapshot that holds no - memory, and ignored for a sandbox that is already running. Deployments that - have not enabled filesystem-only resume reject `"reboot"` with an error rather - than quietly restoring the memory. + memory, and ignored for a sandbox that is already running. Where + filesystem-only resume is not enabled, a `"reboot"` that would actually drop + memory is rejected with an error rather than quietly restoring it; the no-op + cases above are unaffected. :return: A running sandbox instance @example @@ -322,9 +323,10 @@ def connect( snapshot untouched — never modified, never deleted — so the same snapshot can still be restored later. Disk state carries crash-recovery semantics: writes not flushed before the pause may be lost. A no-op for a snapshot that holds no - memory, and ignored for a sandbox that is already running. Deployments that - have not enabled filesystem-only resume reject `"reboot"` with an error rather - than quietly restoring the memory. + memory, and ignored for a sandbox that is already running. Where + filesystem-only resume is not enabled, a `"reboot"` that would actually drop + memory is rejected with an error rather than quietly restoring it; the no-op + cases above are unaffected. :return: A running sandbox instance @example @@ -360,9 +362,10 @@ def connect( snapshot untouched — never modified, never deleted — so the same snapshot can still be restored later. Disk state carries crash-recovery semantics: writes not flushed before the pause may be lost. A no-op for a snapshot that holds no - memory, and ignored for a sandbox that is already running. Deployments that - have not enabled filesystem-only resume reject `"reboot"` with an error rather - than quietly restoring the memory. + memory, and ignored for a sandbox that is already running. Where + filesystem-only resume is not enabled, a `"reboot"` that would actually drop + memory is rejected with an error rather than quietly restoring it; the no-op + cases above are unaffected. :return: A running sandbox instance @example From 9489ac4acb89b8b445ab954059b499192a0639a6 Mon Sep 17 00:00:00 2001 From: Babis Chalios Date: Fri, 4 Sep 2026 10:43:18 +0200 Subject: [PATCH 05/10] chore(spec): pin the infra spec to the exported marker drop Replaces the placeholder pin with the commit that actually carries it, so the tracked specs come from the Copybara fetch. Also picks up the freeze endpoint's mode parameter and vanished counts on the envd spec; both are x-internal, so no SDK client changes. Signed-off-by: Babis Chalios --- spec/envd/envd.yaml | 56 +++++++++++++++++++++++++++++++++++++++++---- spec/infra-ref | 2 +- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/spec/envd/envd.yaml b/spec/envd/envd.yaml index a6c0500090..d385e6d587 100644 --- a/spec/envd/envd.yaml +++ b/spec/envd/envd.yaml @@ -96,14 +96,43 @@ paths: what this call caused. Best-effort by design: a workload that will not quiesce within the budget is - reported as unconfirmed rather than failing the call, and cgroups that reject the - write are counted in failed, because neither an unfreezable customer task nor a - cgroup disappearing mid-sweep may block their pause. + reported as unconfirmed rather than failing the call, cgroups that reject the write + are counted in failed, and cgroups the guest removed while the sweep was working on + them are counted in vanished. None of the three may block a pause, which is why all + three are counts in the body rather than an error. Whether this endpoint waits is the caller's choice, expressed by supplying maxWaitMs: see that parameter. x-internal: true parameters: + - name: mode + in: query + required: false + description: | + Which cgroups to freeze. "hierarchy" freezes the complement of envd's own + ancestor chain, so cgroups the customer created anywhere in the tree are + covered; "legacy" freezes only the user and pty cgroups envd itself creates. + Omitted means legacy, which is what an orchestrator predating this parameter + gets. + + The mode is chosen by the caller because the feature flag that selects it is + evaluated there — envd has no access to it. FreezeResult echoes the mode back + so the caller can confirm envd honoured the request rather than inferring it + from the flag's value: an envd too old to know about modes reports legacy + while the flag reads on. + schema: + type: string + enum: [legacy, hierarchy] + - name: maxCgroups + in: query + required: false + description: | + Bounds how many cgroups a hierarchy sweep may visit. A safety guard against a + pathological or hostile hierarchy rather than a performance knob — the guest is + the threat model. Omitted or non-positive means envd's own default. Ignored in + legacy mode. + schema: + type: integer - name: maxWaitMs in: query required: false @@ -522,6 +551,22 @@ components: type: object description: Per-call statistics from a pre-pause workload freeze properties: + mode: + type: string + enum: [legacy, hierarchy] + description: Which sweep actually ran. Echoed back rather than inferred from the flag, so a caller can tell that envd honoured what it asked for + visited: + type: integer + description: Cgroups the walk examined, whether or not it froze them. The input for sizing the bound; meaningless in legacy mode + allowlisted: + type: integer + description: Cgroups skipped because the resume path depends on them (systemd, journald, envd's port forwarding). Reported because the allowlist is expected to grow, and a distro that routes journald differently changes this count + truncated: + type: boolean + description: True when the walk stopped because it hit the bound rather than because it ran out of tree, so coverage is incomplete + preFrozen: + type: integer + description: Cgroups the guest itself had already frozen before the sweep ran (docker pause writes cgroup.freeze). Not written to and deliberately left frozen by the resume thaw, so the guest's own suspension survives the snapshot requested: type: integer description: Cgroups this call wrote cgroup.freeze to @@ -533,7 +578,10 @@ components: description: Cgroups still reading "frozen 0" when the budget expired; their tasks may still be running, so a snapshot taken now can capture a live workload failed: type: integer - description: Cgroups whose freeze write or state read errored (expected for a threaded cgroup, and for one removed mid-sweep) + description: Cgroups that errored for a reason that is not simply being gone - the write was refused, or the state could not be read. A cgroup the hierarchy walk discovered that merely went away is counted vanished instead; one of envd's own static cgroups that goes away is counted here, by design + vanished: + type: integer + description: Cgroups the hierarchy walk enumerated that the guest then removed before the sweep finished with them - envd's own static cgroups are excluded and report failed instead. A race rather than a failure, and a claim about the cgroup only - tasks migrated out of it before its removal can still be running. Like failed it spans both phases, so it does not reconcile against requested on its own - one removed during the settle poll was counted in requested, one removed before its write never was unobservable: type: integer description: Cgroups whose freeze state cannot be read because this guest has no cgroup manager; the write was accepted but nothing can be read back, so these are neither frozen nor notFrozen diff --git a/spec/infra-ref b/spec/infra-ref index 8954cf2653..564edb1f32 100644 --- a/spec/infra-ref +++ b/spec/infra-ref @@ -1 +1 @@ -8614ec4624c9908f63ccc2882fa8de47a0b406a2 +8a3f69da6f822c2de2b310dd1076d2c309eef919 From 0e5788d88c77ae67d21fbb58846cfc54656b9739 Mon Sep 17 00:00:00 2001 From: Babis Chalios Date: Fri, 4 Sep 2026 11:34:11 +0200 Subject: [PATCH 06/10] fix: correct the reboot docs and the keyword-only guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs said a reboot leaves the memory snapshot restorable later. It does not: while the sandbox runs, connect is a keep-alive; a later pause writes a new snapshot over the row; kill ends the resume path. The guarantee that does hold is for a reboot that fails to start, so the docs now say that and point at pausing again to keep a snapshot. The keyword-only guard asserted the wrong signature. class_method_variant wraps the instance method, so inspect.signature resolved to `(self, timeout, *, on_resume)` and the static-by-id chain was never inspected — dropping the `*` from _cls_connect_sandbox left the whole suite green while `connect(id, 300, logger, "reboot")` became valid again. Each form is now asserted directly. Signed-off-by: Babis Chalios --- packages/js-sdk/src/sandbox/sandboxApi.ts | 13 +++--- packages/python-sdk/e2b/sandbox_async/main.py | 33 ++++++++------- packages/python-sdk/e2b/sandbox_sync/main.py | 33 ++++++++------- .../shared/sandbox/test_on_resume_request.py | 41 +++++++++++++++---- 4 files changed, 76 insertions(+), 44 deletions(-) diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index faf5c6753d..0496c66191 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -691,12 +691,13 @@ export type SandboxConnectOpts = ConnectionOpts & { /** * How to bring a paused sandbox back. * - * With `'reboot'` the sandbox cold-boots from its disk state and the memory - * snapshot is left untouched — never modified, never deleted — so the same - * snapshot can still be restored later. Disk state carries crash-recovery - * semantics: writes not flushed before the pause may be lost. A no-op for a - * snapshot that holds no memory, and ignored for a sandbox that is already - * running. + * With `'reboot'` the sandbox cold-boots from its disk state. The reboot + * neither modifies nor deletes the memory snapshot, so one that fails to + * start leaves the sandbox paused and the resume retryable; after a + * successful reboot, pause the sandbox again to keep a snapshot. Disk state + * carries crash-recovery semantics: writes not flushed before the pause may + * be lost. A no-op for a snapshot that holds no memory, and ignored for a + * sandbox that is already running. * * Where filesystem-only resume is not enabled, a `'reboot'` that would * actually drop memory is rejected with an error rather than quietly diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index eebf8b1be2..6ea244b48f 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -281,11 +281,12 @@ async def connect( For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) restores the memory snapshot, so processes and open connections survive the - pause. `"reboot"` cold-boots from disk state alone and leaves the memory - snapshot untouched — never modified, never deleted — so the same snapshot can - still be restored later. Disk state carries crash-recovery semantics: writes - not flushed before the pause may be lost. A no-op for a snapshot that holds no - memory, and ignored for a sandbox that is already running. Where + pause. `"reboot"` cold-boots from disk state alone. The reboot neither + modifies nor deletes the memory snapshot, so one that fails to start leaves the + sandbox paused and the resume retryable; after a successful reboot, pause the + sandbox again to keep a snapshot. Disk state carries crash-recovery semantics: + writes not flushed before the pause may be lost. A no-op for a snapshot that + holds no memory, and ignored for a sandbox that is already running. Where filesystem-only resume is not enabled, a `"reboot"` that would actually drop memory is rejected with an error rather than quietly restoring it; the no-op cases above are unaffected. @@ -324,11 +325,12 @@ async def connect( :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. :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) restores the memory snapshot, so processes and open connections survive the - pause. `"reboot"` cold-boots from disk state alone and leaves the memory - snapshot untouched — never modified, never deleted — so the same snapshot can - still be restored later. Disk state carries crash-recovery semantics: writes - not flushed before the pause may be lost. A no-op for a snapshot that holds no - memory, and ignored for a sandbox that is already running. Where + pause. `"reboot"` cold-boots from disk state alone. The reboot neither + modifies nor deletes the memory snapshot, so one that fails to start leaves the + sandbox paused and the resume retryable; after a successful reboot, pause the + sandbox again to keep a snapshot. Disk state carries crash-recovery semantics: + writes not flushed before the pause may be lost. A no-op for a snapshot that + holds no memory, and ignored for a sandbox that is already running. Where filesystem-only resume is not enabled, a `"reboot"` that would actually drop memory is rejected with an error rather than quietly restoring it; the no-op cases above are unaffected. @@ -363,11 +365,12 @@ async def connect( For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) restores the memory snapshot, so processes and open connections survive the - pause. `"reboot"` cold-boots from disk state alone and leaves the memory - snapshot untouched — never modified, never deleted — so the same snapshot can - still be restored later. Disk state carries crash-recovery semantics: writes - not flushed before the pause may be lost. A no-op for a snapshot that holds no - memory, and ignored for a sandbox that is already running. Where + pause. `"reboot"` cold-boots from disk state alone. The reboot neither + modifies nor deletes the memory snapshot, so one that fails to start leaves the + sandbox paused and the resume retryable; after a successful reboot, pause the + sandbox again to keep a snapshot. Disk state carries crash-recovery semantics: + writes not flushed before the pause may be lost. A no-op for a snapshot that + holds no memory, and ignored for a sandbox that is already running. Where filesystem-only resume is not enabled, a `"reboot"` that would actually drop memory is rejected with an error rather than quietly restoring it; the no-op cases above are unaffected. diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 17996ca4dd..dd3a9de232 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -275,11 +275,12 @@ def connect( For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) restores the memory snapshot, so processes and open connections survive the - pause. `"reboot"` cold-boots from disk state alone and leaves the memory - snapshot untouched — never modified, never deleted — so the same snapshot can - still be restored later. Disk state carries crash-recovery semantics: writes - not flushed before the pause may be lost. A no-op for a snapshot that holds no - memory, and ignored for a sandbox that is already running. Where + pause. `"reboot"` cold-boots from disk state alone. The reboot neither + modifies nor deletes the memory snapshot, so one that fails to start leaves the + sandbox paused and the resume retryable; after a successful reboot, pause the + sandbox again to keep a snapshot. Disk state carries crash-recovery semantics: + writes not flushed before the pause may be lost. A no-op for a snapshot that + holds no memory, and ignored for a sandbox that is already running. Where filesystem-only resume is not enabled, a `"reboot"` that would actually drop memory is rejected with an error rather than quietly restoring it; the no-op cases above are unaffected. @@ -319,11 +320,12 @@ def connect( :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. :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) restores the memory snapshot, so processes and open connections survive the - pause. `"reboot"` cold-boots from disk state alone and leaves the memory - snapshot untouched — never modified, never deleted — so the same snapshot can - still be restored later. Disk state carries crash-recovery semantics: writes - not flushed before the pause may be lost. A no-op for a snapshot that holds no - memory, and ignored for a sandbox that is already running. Where + pause. `"reboot"` cold-boots from disk state alone. The reboot neither + modifies nor deletes the memory snapshot, so one that fails to start leaves the + sandbox paused and the resume retryable; after a successful reboot, pause the + sandbox again to keep a snapshot. Disk state carries crash-recovery semantics: + writes not flushed before the pause may be lost. A no-op for a snapshot that + holds no memory, and ignored for a sandbox that is already running. Where filesystem-only resume is not enabled, a `"reboot"` that would actually drop memory is rejected with an error rather than quietly restoring it; the no-op cases above are unaffected. @@ -358,11 +360,12 @@ def connect( For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) restores the memory snapshot, so processes and open connections survive the - pause. `"reboot"` cold-boots from disk state alone and leaves the memory - snapshot untouched — never modified, never deleted — so the same snapshot can - still be restored later. Disk state carries crash-recovery semantics: writes - not flushed before the pause may be lost. A no-op for a snapshot that holds no - memory, and ignored for a sandbox that is already running. Where + pause. `"reboot"` cold-boots from disk state alone. The reboot neither + modifies nor deletes the memory snapshot, so one that fails to start leaves the + sandbox paused and the resume retryable; after a successful reboot, pause the + sandbox again to keep a snapshot. Disk state carries crash-recovery semantics: + writes not flushed before the pause may be lost. A no-op for a snapshot that + holds no memory, and ignored for a sandbox that is already running. Where filesystem-only resume is not enabled, a `"reboot"` that would actually drop memory is rejected with an error rather than quietly restoring it; the no-op cases above are unaffected. diff --git a/packages/python-sdk/tests/shared/sandbox/test_on_resume_request.py b/packages/python-sdk/tests/shared/sandbox/test_on_resume_request.py index 057e75ac71..b52dd5021d 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_on_resume_request.py +++ b/packages/python-sdk/tests/shared/sandbox/test_on_resume_request.py @@ -8,6 +8,8 @@ from e2b import AsyncSandbox, Sandbox from e2b.api.client.api.sandboxes import post_sandboxes_sandbox_id_connect from e2b.api.client.models import Sandbox as SandboxModel +from e2b.sandbox_async.sandbox_api import SandboxApi as AsyncSandboxApi +from e2b.sandbox_sync.sandbox_api import SandboxApi as SyncSandboxApi SANDBOX_ID = "sbx-test" @@ -104,18 +106,41 @@ async def test_async_instance_connect_carries_on_resume(monkeypatch, test_api_ke assert "memory" not in request.call_args.kwargs["body"].to_dict() -@pytest.mark.parametrize("sandbox", [Sandbox, AsyncSandbox], ids=["sync", "async"]) -def test_on_resume_is_keyword_only(sandbox): - # An optional parameter must not extend connect()'s positional chain, or - # `connect(sandbox_id, 300, "reboot")` becomes a valid call. - signature = inspect.signature(sandbox.connect) +# `class_method_variant` wraps the instance method with `functools.wraps`, so +# `inspect.signature(Sandbox.connect)` resolves to `(self, timeout, *, on_resume)` +# and never sees the static-by-id chain. Each form is asserted directly. +KEYWORD_ONLY_FORMS = [ + pytest.param(Sandbox, "_cls_connect_sandbox", id="sync-by-id"), + pytest.param(AsyncSandbox, "_cls_connect_sandbox", id="async-by-id"), + pytest.param(SyncSandboxApi, "_cls_connect", id="sync-api"), + pytest.param(AsyncSandboxApi, "_cls_connect", id="async-api"), +] + + +@pytest.mark.parametrize("owner, attr", KEYWORD_ONLY_FORMS) +def test_on_resume_is_keyword_only(owner, attr): + # An optional parameter must not extend the positional chain, or + # `connect(sandbox_id, 300, logger, "reboot")` becomes a valid call. + signature = inspect.signature(getattr(owner, attr)) assert signature.parameters["on_resume"].kind is inspect.Parameter.KEYWORD_ONLY - assert signature.parameters["timeout"].kind is not inspect.Parameter.KEYWORD_ONLY + for pre_existing in ("timeout", "logger"): + assert ( + signature.parameters[pre_existing].kind + is not inspect.Parameter.KEYWORD_ONLY + ) with pytest.raises(TypeError): - signature.bind("sbx-test", 300, "reboot") + signature.bind(SANDBOX_ID, 300, None, "reboot") - assert signature.bind("sbx-test", on_resume="reboot").arguments["on_resume"] == ( + assert signature.bind(SANDBOX_ID, on_resume="reboot").arguments["on_resume"] == ( "reboot" ) + + +@pytest.mark.parametrize("sandbox", [Sandbox, AsyncSandbox], ids=["sync", "async"]) +def test_on_resume_is_keyword_only_on_the_instance_form(sandbox): + signature = inspect.signature(sandbox.connect) + + assert signature.parameters["on_resume"].kind is inspect.Parameter.KEYWORD_ONLY + assert signature.parameters["timeout"].kind is not inspect.Parameter.KEYWORD_ONLY From 0380d8d5ec38ff746450e4a57982528b20bfd4e7 Mon Sep 17 00:00:00 2001 From: Babis Chalios Date: Fri, 4 Sep 2026 11:49:28 +0200 Subject: [PATCH 07/10] fix: scope the reboot failure claim to accepted reboots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saying a reboot that fails to start leaves the sandbox paused held for the three failures after dispatch, but not for the two refusals before it. A sandbox whose start is already in flight is refused outright and is not paused, so a caller following that advice retries into a keep-alive that returns 200 without rebooting — the server's own message on that path says to retry once it is running or paused. The docs now scope the retryable claim to a reboot that started, and say a running or starting sandbox has to be paused before it can be rebooted, which nothing said before. Signed-off-by: Babis Chalios --- packages/js-sdk/src/sandbox/sandboxApi.ts | 22 +++---- packages/python-sdk/e2b/sandbox_async/main.py | 57 ++++++++++--------- packages/python-sdk/e2b/sandbox_sync/main.py | 57 ++++++++++--------- .../shared/sandbox/test_on_resume_request.py | 2 +- 4 files changed, 72 insertions(+), 66 deletions(-) diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 0496c66191..c7b242f5bb 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -691,17 +691,17 @@ export type SandboxConnectOpts = ConnectionOpts & { /** * How to bring a paused sandbox back. * - * With `'reboot'` the sandbox cold-boots from its disk state. The reboot - * neither modifies nor deletes the memory snapshot, so one that fails to - * start leaves the sandbox paused and the resume retryable; after a - * successful reboot, pause the sandbox again to keep a snapshot. Disk state - * carries crash-recovery semantics: writes not flushed before the pause may - * be lost. A no-op for a snapshot that holds no memory, and ignored for a - * sandbox that is already running. - * - * Where filesystem-only resume is not enabled, a `'reboot'` that would - * actually drop memory is rejected with an error rather than quietly - * restoring it; the no-op cases above are unaffected. + * With `'reboot'` the sandbox cold-boots from its disk state. A reboot that + * starts and then fails to boot leaves the sandbox paused with its memory + * snapshot neither modified nor deleted, so the resume stays retryable; + * after a successful reboot, pause the sandbox again to keep a snapshot. + * Disk state carries crash-recovery semantics: writes not flushed before the + * pause may be lost. A no-op for a snapshot that holds no memory. + * + * Rebooting reaches only a paused sandbox: one already running is returned + * unchanged and one still starting is refused, so pause it first. Where + * filesystem-only resume is not enabled, a `'reboot'` that would actually + * drop memory is rejected with an error rather than quietly restoring it. * * @default 'restore' */ diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 6ea244b48f..9b2b040910 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -281,15 +281,16 @@ async def connect( For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) restores the memory snapshot, so processes and open connections survive the - pause. `"reboot"` cold-boots from disk state alone. The reboot neither - modifies nor deletes the memory snapshot, so one that fails to start leaves the - sandbox paused and the resume retryable; after a successful reboot, pause the - sandbox again to keep a snapshot. Disk state carries crash-recovery semantics: - writes not flushed before the pause may be lost. A no-op for a snapshot that - holds no memory, and ignored for a sandbox that is already running. Where - filesystem-only resume is not enabled, a `"reboot"` that would actually drop - memory is rejected with an error rather than quietly restoring it; the no-op - cases above are unaffected. + pause. `"reboot"` cold-boots from its disk state. A reboot that starts and + then fails to boot leaves the sandbox paused with its memory snapshot neither + modified nor deleted, so the resume stays retryable; after a successful reboot, + pause the sandbox again to keep a snapshot. Disk state carries crash-recovery + semantics: writes not flushed before the pause may be lost. A no-op for a + snapshot that holds no memory. Rebooting reaches only a paused sandbox: one + already running is returned unchanged and one still starting is refused, so + pause it first. Where filesystem-only resume is not enabled, a `"reboot"` that + would actually drop memory is rejected with an error rather than quietly + restoring it. :return: A running sandbox instance @example @@ -325,15 +326,16 @@ async def connect( :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. :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) restores the memory snapshot, so processes and open connections survive the - pause. `"reboot"` cold-boots from disk state alone. The reboot neither - modifies nor deletes the memory snapshot, so one that fails to start leaves the - sandbox paused and the resume retryable; after a successful reboot, pause the - sandbox again to keep a snapshot. Disk state carries crash-recovery semantics: - writes not flushed before the pause may be lost. A no-op for a snapshot that - holds no memory, and ignored for a sandbox that is already running. Where - filesystem-only resume is not enabled, a `"reboot"` that would actually drop - memory is rejected with an error rather than quietly restoring it; the no-op - cases above are unaffected. + pause. `"reboot"` cold-boots from its disk state. A reboot that starts and + then fails to boot leaves the sandbox paused with its memory snapshot neither + modified nor deleted, so the resume stays retryable; after a successful reboot, + pause the sandbox again to keep a snapshot. Disk state carries crash-recovery + semantics: writes not flushed before the pause may be lost. A no-op for a + snapshot that holds no memory. Rebooting reaches only a paused sandbox: one + already running is returned unchanged and one still starting is refused, so + pause it first. Where filesystem-only resume is not enabled, a `"reboot"` that + would actually drop memory is rejected with an error rather than quietly + restoring it. :return: A running sandbox instance @example @@ -365,15 +367,16 @@ async def connect( For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) restores the memory snapshot, so processes and open connections survive the - pause. `"reboot"` cold-boots from disk state alone. The reboot neither - modifies nor deletes the memory snapshot, so one that fails to start leaves the - sandbox paused and the resume retryable; after a successful reboot, pause the - sandbox again to keep a snapshot. Disk state carries crash-recovery semantics: - writes not flushed before the pause may be lost. A no-op for a snapshot that - holds no memory, and ignored for a sandbox that is already running. Where - filesystem-only resume is not enabled, a `"reboot"` that would actually drop - memory is rejected with an error rather than quietly restoring it; the no-op - cases above are unaffected. + pause. `"reboot"` cold-boots from its disk state. A reboot that starts and + then fails to boot leaves the sandbox paused with its memory snapshot neither + modified nor deleted, so the resume stays retryable; after a successful reboot, + pause the sandbox again to keep a snapshot. Disk state carries crash-recovery + semantics: writes not flushed before the pause may be lost. A no-op for a + snapshot that holds no memory. Rebooting reaches only a paused sandbox: one + already running is returned unchanged and one still starting is refused, so + pause it first. Where filesystem-only resume is not enabled, a `"reboot"` that + would actually drop memory is rejected with an error rather than quietly + restoring it. :return: A running sandbox instance @example diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index dd3a9de232..7b49a41990 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -275,15 +275,16 @@ def connect( For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) restores the memory snapshot, so processes and open connections survive the - pause. `"reboot"` cold-boots from disk state alone. The reboot neither - modifies nor deletes the memory snapshot, so one that fails to start leaves the - sandbox paused and the resume retryable; after a successful reboot, pause the - sandbox again to keep a snapshot. Disk state carries crash-recovery semantics: - writes not flushed before the pause may be lost. A no-op for a snapshot that - holds no memory, and ignored for a sandbox that is already running. Where - filesystem-only resume is not enabled, a `"reboot"` that would actually drop - memory is rejected with an error rather than quietly restoring it; the no-op - cases above are unaffected. + pause. `"reboot"` cold-boots from its disk state. A reboot that starts and + then fails to boot leaves the sandbox paused with its memory snapshot neither + modified nor deleted, so the resume stays retryable; after a successful reboot, + pause the sandbox again to keep a snapshot. Disk state carries crash-recovery + semantics: writes not flushed before the pause may be lost. A no-op for a + snapshot that holds no memory. Rebooting reaches only a paused sandbox: one + already running is returned unchanged and one still starting is refused, so + pause it first. Where filesystem-only resume is not enabled, a `"reboot"` that + would actually drop memory is rejected with an error rather than quietly + restoring it. :return: A running sandbox instance @example @@ -320,15 +321,16 @@ def connect( :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. :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) restores the memory snapshot, so processes and open connections survive the - pause. `"reboot"` cold-boots from disk state alone. The reboot neither - modifies nor deletes the memory snapshot, so one that fails to start leaves the - sandbox paused and the resume retryable; after a successful reboot, pause the - sandbox again to keep a snapshot. Disk state carries crash-recovery semantics: - writes not flushed before the pause may be lost. A no-op for a snapshot that - holds no memory, and ignored for a sandbox that is already running. Where - filesystem-only resume is not enabled, a `"reboot"` that would actually drop - memory is rejected with an error rather than quietly restoring it; the no-op - cases above are unaffected. + pause. `"reboot"` cold-boots from its disk state. A reboot that starts and + then fails to boot leaves the sandbox paused with its memory snapshot neither + modified nor deleted, so the resume stays retryable; after a successful reboot, + pause the sandbox again to keep a snapshot. Disk state carries crash-recovery + semantics: writes not flushed before the pause may be lost. A no-op for a + snapshot that holds no memory. Rebooting reaches only a paused sandbox: one + already running is returned unchanged and one still starting is refused, so + pause it first. Where filesystem-only resume is not enabled, a `"reboot"` that + would actually drop memory is rejected with an error rather than quietly + restoring it. :return: A running sandbox instance @example @@ -360,15 +362,16 @@ def connect( For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) restores the memory snapshot, so processes and open connections survive the - pause. `"reboot"` cold-boots from disk state alone. The reboot neither - modifies nor deletes the memory snapshot, so one that fails to start leaves the - sandbox paused and the resume retryable; after a successful reboot, pause the - sandbox again to keep a snapshot. Disk state carries crash-recovery semantics: - writes not flushed before the pause may be lost. A no-op for a snapshot that - holds no memory, and ignored for a sandbox that is already running. Where - filesystem-only resume is not enabled, a `"reboot"` that would actually drop - memory is rejected with an error rather than quietly restoring it; the no-op - cases above are unaffected. + pause. `"reboot"` cold-boots from its disk state. A reboot that starts and + then fails to boot leaves the sandbox paused with its memory snapshot neither + modified nor deleted, so the resume stays retryable; after a successful reboot, + pause the sandbox again to keep a snapshot. Disk state carries crash-recovery + semantics: writes not flushed before the pause may be lost. A no-op for a + snapshot that holds no memory. Rebooting reaches only a paused sandbox: one + already running is returned unchanged and one still starting is refused, so + pause it first. Where filesystem-only resume is not enabled, a `"reboot"` that + would actually drop memory is rejected with an error rather than quietly + restoring it. :return: A running sandbox instance @example diff --git a/packages/python-sdk/tests/shared/sandbox/test_on_resume_request.py b/packages/python-sdk/tests/shared/sandbox/test_on_resume_request.py index b52dd5021d..7338ab3d2a 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_on_resume_request.py +++ b/packages/python-sdk/tests/shared/sandbox/test_on_resume_request.py @@ -108,7 +108,7 @@ async def test_async_instance_connect_carries_on_resume(monkeypatch, test_api_ke # `class_method_variant` wraps the instance method with `functools.wraps`, so # `inspect.signature(Sandbox.connect)` resolves to `(self, timeout, *, on_resume)` -# and never sees the static-by-id chain. Each form is asserted directly. +# and never sees the static-by-id chain. KEYWORD_ONLY_FORMS = [ pytest.param(Sandbox, "_cls_connect_sandbox", id="sync-by-id"), pytest.param(AsyncSandbox, "_cls_connect_sandbox", id="async-by-id"), From a42b3efc17d73260a191953b884936e789246605 Mon Sep 17 00:00:00 2001 From: Babis Chalios Date: Fri, 4 Sep 2026 12:06:40 +0200 Subject: [PATCH 08/10] docs: state what a reboot does to a running or starting sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two claims about reachability were wrong. A running sandbox is not returned unchanged — connect keeps it alive and extends its timeout, as the option above it already documents — and telling the caller to pause a sandbox whose start is in flight sends them into a second refusal, because a reservation holds no store entry and pause answers that it is already paused. The option is ignored for a running sandbox, so pausing first is the way to reboot one; a request that would drop memory is refused mid-start, so the start has to finish. Both bounds are scoped to requests that would actually drop memory, since a snapshot without memory joins a start and cold-boots regardless. Signed-off-by: Babis Chalios --- packages/js-sdk/src/sandbox/sandboxApi.ts | 24 +++---- packages/python-sdk/e2b/sandbox_async/main.py | 63 +++++++++---------- packages/python-sdk/e2b/sandbox_sync/main.py | 63 +++++++++---------- 3 files changed, 73 insertions(+), 77 deletions(-) diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index c7b242f5bb..53f1ebbc30 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -691,17 +691,19 @@ export type SandboxConnectOpts = ConnectionOpts & { /** * How to bring a paused sandbox back. * - * With `'reboot'` the sandbox cold-boots from its disk state. A reboot that - * starts and then fails to boot leaves the sandbox paused with its memory - * snapshot neither modified nor deleted, so the resume stays retryable; - * after a successful reboot, pause the sandbox again to keep a snapshot. - * Disk state carries crash-recovery semantics: writes not flushed before the - * pause may be lost. A no-op for a snapshot that holds no memory. - * - * Rebooting reaches only a paused sandbox: one already running is returned - * unchanged and one still starting is refused, so pause it first. Where - * filesystem-only resume is not enabled, a `'reboot'` that would actually - * drop memory is rejected with an error rather than quietly restoring it. + * With `'reboot'` the sandbox cold-boots from its disk state, which carries + * crash-recovery semantics: writes not flushed before the pause may be lost. + * A no-op for a snapshot that holds no memory. + * + * Only a paused sandbox is rebooted: for one already running the option is + * ignored, so pause it first; where it would drop memory it is refused while + * a start is in flight, so let that start finish. A reboot that starts and + * then fails to boot leaves the sandbox paused with its snapshot intact and + * the resume retryable; after a successful reboot, pause again to keep a + * snapshot. + * + * Where filesystem-only resume is not enabled, a `'reboot'` that would + * actually drop memory is rejected rather than quietly restoring it. * * @default 'restore' */ diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 9b2b040910..6c9acde27e 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -280,17 +280,16 @@ async def connect( :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) - restores the memory snapshot, so processes and open connections survive the - pause. `"reboot"` cold-boots from its disk state. A reboot that starts and - then fails to boot leaves the sandbox paused with its memory snapshot neither - modified nor deleted, so the resume stays retryable; after a successful reboot, - pause the sandbox again to keep a snapshot. Disk state carries crash-recovery - semantics: writes not flushed before the pause may be lost. A no-op for a - snapshot that holds no memory. Rebooting reaches only a paused sandbox: one - already running is returned unchanged and one still starting is refused, so - pause it first. Where filesystem-only resume is not enabled, a `"reboot"` that - would actually drop memory is rejected with an error rather than quietly - restoring it. + restores the memory snapshot. `"reboot"` cold-boots from its disk state, which + carries crash-recovery semantics: writes not flushed before the pause may be + lost. A no-op for a snapshot that holds no memory. Only a paused sandbox is + rebooted: for one already running the option is ignored, so pause it first; + where it would drop memory it is refused while a start is in flight, so let + that start finish. A reboot that starts and then fails to boot leaves the + sandbox paused with its snapshot intact and the resume retryable; after a + successful reboot, pause again to keep a snapshot. Where filesystem-only resume + is not enabled, a `"reboot"` that would actually drop memory is rejected rather + than quietly restoring it. :return: A running sandbox instance @example @@ -325,17 +324,16 @@ async def connect( For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. :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. :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) - restores the memory snapshot, so processes and open connections survive the - pause. `"reboot"` cold-boots from its disk state. A reboot that starts and - then fails to boot leaves the sandbox paused with its memory snapshot neither - modified nor deleted, so the resume stays retryable; after a successful reboot, - pause the sandbox again to keep a snapshot. Disk state carries crash-recovery - semantics: writes not flushed before the pause may be lost. A no-op for a - snapshot that holds no memory. Rebooting reaches only a paused sandbox: one - already running is returned unchanged and one still starting is refused, so - pause it first. Where filesystem-only resume is not enabled, a `"reboot"` that - would actually drop memory is rejected with an error rather than quietly - restoring it. + restores the memory snapshot. `"reboot"` cold-boots from its disk state, which + carries crash-recovery semantics: writes not flushed before the pause may be + lost. A no-op for a snapshot that holds no memory. Only a paused sandbox is + rebooted: for one already running the option is ignored, so pause it first; + where it would drop memory it is refused while a start is in flight, so let + that start finish. A reboot that starts and then fails to boot leaves the + sandbox paused with its snapshot intact and the resume retryable; after a + successful reboot, pause again to keep a snapshot. Where filesystem-only resume + is not enabled, a `"reboot"` that would actually drop memory is rejected rather + than quietly restoring it. :return: A running sandbox instance @example @@ -366,17 +364,16 @@ async def connect( :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) - restores the memory snapshot, so processes and open connections survive the - pause. `"reboot"` cold-boots from its disk state. A reboot that starts and - then fails to boot leaves the sandbox paused with its memory snapshot neither - modified nor deleted, so the resume stays retryable; after a successful reboot, - pause the sandbox again to keep a snapshot. Disk state carries crash-recovery - semantics: writes not flushed before the pause may be lost. A no-op for a - snapshot that holds no memory. Rebooting reaches only a paused sandbox: one - already running is returned unchanged and one still starting is refused, so - pause it first. Where filesystem-only resume is not enabled, a `"reboot"` that - would actually drop memory is rejected with an error rather than quietly - restoring it. + restores the memory snapshot. `"reboot"` cold-boots from its disk state, which + carries crash-recovery semantics: writes not flushed before the pause may be + lost. A no-op for a snapshot that holds no memory. Only a paused sandbox is + rebooted: for one already running the option is ignored, so pause it first; + where it would drop memory it is refused while a start is in flight, so let + that start finish. A reboot that starts and then fails to boot leaves the + sandbox paused with its snapshot intact and the resume retryable; after a + successful reboot, pause again to keep a snapshot. Where filesystem-only resume + is not enabled, a `"reboot"` that would actually drop memory is rejected rather + than quietly restoring it. :return: A running sandbox instance @example diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 7b49a41990..5f27236044 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -274,17 +274,16 @@ def connect( :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) - restores the memory snapshot, so processes and open connections survive the - pause. `"reboot"` cold-boots from its disk state. A reboot that starts and - then fails to boot leaves the sandbox paused with its memory snapshot neither - modified nor deleted, so the resume stays retryable; after a successful reboot, - pause the sandbox again to keep a snapshot. Disk state carries crash-recovery - semantics: writes not flushed before the pause may be lost. A no-op for a - snapshot that holds no memory. Rebooting reaches only a paused sandbox: one - already running is returned unchanged and one still starting is refused, so - pause it first. Where filesystem-only resume is not enabled, a `"reboot"` that - would actually drop memory is rejected with an error rather than quietly - restoring it. + restores the memory snapshot. `"reboot"` cold-boots from its disk state, which + carries crash-recovery semantics: writes not flushed before the pause may be + lost. A no-op for a snapshot that holds no memory. Only a paused sandbox is + rebooted: for one already running the option is ignored, so pause it first; + where it would drop memory it is refused while a start is in flight, so let + that start finish. A reboot that starts and then fails to boot leaves the + sandbox paused with its snapshot intact and the resume retryable; after a + successful reboot, pause again to keep a snapshot. Where filesystem-only resume + is not enabled, a `"reboot"` that would actually drop memory is rejected rather + than quietly restoring it. :return: A running sandbox instance @example @@ -320,17 +319,16 @@ def connect( For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. :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. :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) - restores the memory snapshot, so processes and open connections survive the - pause. `"reboot"` cold-boots from its disk state. A reboot that starts and - then fails to boot leaves the sandbox paused with its memory snapshot neither - modified nor deleted, so the resume stays retryable; after a successful reboot, - pause the sandbox again to keep a snapshot. Disk state carries crash-recovery - semantics: writes not flushed before the pause may be lost. A no-op for a - snapshot that holds no memory. Rebooting reaches only a paused sandbox: one - already running is returned unchanged and one still starting is refused, so - pause it first. Where filesystem-only resume is not enabled, a `"reboot"` that - would actually drop memory is rejected with an error rather than quietly - restoring it. + restores the memory snapshot. `"reboot"` cold-boots from its disk state, which + carries crash-recovery semantics: writes not flushed before the pause may be + lost. A no-op for a snapshot that holds no memory. Only a paused sandbox is + rebooted: for one already running the option is ignored, so pause it first; + where it would drop memory it is refused while a start is in flight, so let + that start finish. A reboot that starts and then fails to boot leaves the + sandbox paused with its snapshot intact and the resume retryable; after a + successful reboot, pause again to keep a snapshot. Where filesystem-only resume + is not enabled, a `"reboot"` that would actually drop memory is rejected rather + than quietly restoring it. :return: A running sandbox instance @example @@ -361,17 +359,16 @@ def connect( :param timeout: Timeout for the sandbox in **seconds**. For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) - restores the memory snapshot, so processes and open connections survive the - pause. `"reboot"` cold-boots from its disk state. A reboot that starts and - then fails to boot leaves the sandbox paused with its memory snapshot neither - modified nor deleted, so the resume stays retryable; after a successful reboot, - pause the sandbox again to keep a snapshot. Disk state carries crash-recovery - semantics: writes not flushed before the pause may be lost. A no-op for a - snapshot that holds no memory. Rebooting reaches only a paused sandbox: one - already running is returned unchanged and one still starting is refused, so - pause it first. Where filesystem-only resume is not enabled, a `"reboot"` that - would actually drop memory is rejected with an error rather than quietly - restoring it. + restores the memory snapshot. `"reboot"` cold-boots from its disk state, which + carries crash-recovery semantics: writes not flushed before the pause may be + lost. A no-op for a snapshot that holds no memory. Only a paused sandbox is + rebooted: for one already running the option is ignored, so pause it first; + where it would drop memory it is refused while a start is in flight, so let + that start finish. A reboot that starts and then fails to boot leaves the + sandbox paused with its snapshot intact and the resume retryable; after a + successful reboot, pause again to keep a snapshot. Where filesystem-only resume + is not enabled, a `"reboot"` that would actually drop memory is rejected rather + than quietly restoring it. :return: A running sandbox instance @example From 9ab04ef8a3c925cab5e680cde3a23d9dcd6bfa05 Mon Sep 17 00:00:00 2001 From: Babis Chalios Date: Fri, 4 Sep 2026 18:18:09 +0200 Subject: [PATCH 09/10] docs: shorten the onResume / on_resume docstrings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: the option docs carried the whole rescue contract (in-flight refusal, failed-boot state, pause-again guidance). Keep what a caller needs at the call site — the two values, the crash-consistency caveat, flag-off rejection, and the no-op cases — and leave the rest to the resume docs. Signed-off-by: Babis Chalios --- packages/js-sdk/src/sandbox/sandboxApi.ts | 20 +++------ packages/python-sdk/e2b/sandbox_async/main.py | 45 +++++-------------- packages/python-sdk/e2b/sandbox_sync/main.py | 45 +++++-------------- 3 files changed, 29 insertions(+), 81 deletions(-) diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 53f1ebbc30..adac86e363 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -689,21 +689,11 @@ export type SandboxConnectOpts = ConnectionOpts & { timeoutMs?: number /** - * How to bring a paused sandbox back. - * - * With `'reboot'` the sandbox cold-boots from its disk state, which carries - * crash-recovery semantics: writes not flushed before the pause may be lost. - * A no-op for a snapshot that holds no memory. - * - * Only a paused sandbox is rebooted: for one already running the option is - * ignored, so pause it first; where it would drop memory it is refused while - * a start is in flight, so let that start finish. A reboot that starts and - * then fails to boot leaves the sandbox paused with its snapshot intact and - * the resume retryable; after a successful reboot, pause again to keep a - * snapshot. - * - * Where filesystem-only resume is not enabled, a `'reboot'` that would - * actually drop memory is rejected rather than quietly restoring it. + * How to bring a paused sandbox back: `'restore'` (the default) restores the + * memory snapshot; `'reboot'` cold-boots from disk state, so writes not + * flushed before the pause may be lost. Rejected where filesystem-only resume + * is not enabled; a no-op for a snapshot without memory or a sandbox that is + * already running. * * @default 'restore' */ diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 6c9acde27e..7b1d7253d0 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -279,17 +279,10 @@ async def connect( :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. - :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) - restores the memory snapshot. `"reboot"` cold-boots from its disk state, which - carries crash-recovery semantics: writes not flushed before the pause may be - lost. A no-op for a snapshot that holds no memory. Only a paused sandbox is - rebooted: for one already running the option is ignored, so pause it first; - where it would drop memory it is refused while a start is in flight, so let - that start finish. A reboot that starts and then fails to boot leaves the - sandbox paused with its snapshot intact and the resume retryable; after a - successful reboot, pause again to keep a snapshot. Where filesystem-only resume - is not enabled, a `"reboot"` that would actually drop memory is rejected rather - than quietly restoring it. + :param on_resume: `"restore"` (the default) restores the memory snapshot; `"reboot"` + cold-boots from disk state, so writes not flushed before the pause may be lost. + Rejected where filesystem-only resume is not enabled; a no-op for a snapshot + without memory or a sandbox that is already running. :return: A running sandbox instance @example @@ -323,17 +316,10 @@ async def connect( :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. :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. - :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) - restores the memory snapshot. `"reboot"` cold-boots from its disk state, which - carries crash-recovery semantics: writes not flushed before the pause may be - lost. A no-op for a snapshot that holds no memory. Only a paused sandbox is - rebooted: for one already running the option is ignored, so pause it first; - where it would drop memory it is refused while a start is in flight, so let - that start finish. A reboot that starts and then fails to boot leaves the - sandbox paused with its snapshot intact and the resume retryable; after a - successful reboot, pause again to keep a snapshot. Where filesystem-only resume - is not enabled, a `"reboot"` that would actually drop memory is rejected rather - than quietly restoring it. + :param on_resume: `"restore"` (the default) restores the memory snapshot; `"reboot"` + cold-boots from disk state, so writes not flushed before the pause may be lost. + Rejected where filesystem-only resume is not enabled; a no-op for a snapshot + without memory or a sandbox that is already running. :return: A running sandbox instance @example @@ -363,17 +349,10 @@ async def connect( :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. - :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) - restores the memory snapshot. `"reboot"` cold-boots from its disk state, which - carries crash-recovery semantics: writes not flushed before the pause may be - lost. A no-op for a snapshot that holds no memory. Only a paused sandbox is - rebooted: for one already running the option is ignored, so pause it first; - where it would drop memory it is refused while a start is in flight, so let - that start finish. A reboot that starts and then fails to boot leaves the - sandbox paused with its snapshot intact and the resume retryable; after a - successful reboot, pause again to keep a snapshot. Where filesystem-only resume - is not enabled, a `"reboot"` that would actually drop memory is rejected rather - than quietly restoring it. + :param on_resume: `"restore"` (the default) restores the memory snapshot; `"reboot"` + cold-boots from disk state, so writes not flushed before the pause may be lost. + Rejected where filesystem-only resume is not enabled; a no-op for a snapshot + without memory or a sandbox that is already running. :return: A running sandbox instance @example diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 5f27236044..80b4ff1e97 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -273,17 +273,10 @@ def connect( :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. - :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) - restores the memory snapshot. `"reboot"` cold-boots from its disk state, which - carries crash-recovery semantics: writes not flushed before the pause may be - lost. A no-op for a snapshot that holds no memory. Only a paused sandbox is - rebooted: for one already running the option is ignored, so pause it first; - where it would drop memory it is refused while a start is in flight, so let - that start finish. A reboot that starts and then fails to boot leaves the - sandbox paused with its snapshot intact and the resume retryable; after a - successful reboot, pause again to keep a snapshot. Where filesystem-only resume - is not enabled, a `"reboot"` that would actually drop memory is rejected rather - than quietly restoring it. + :param on_resume: `"restore"` (the default) restores the memory snapshot; `"reboot"` + cold-boots from disk state, so writes not flushed before the pause may be lost. + Rejected where filesystem-only resume is not enabled; a no-op for a snapshot + without memory or a sandbox that is already running. :return: A running sandbox instance @example @@ -318,17 +311,10 @@ def connect( :param timeout: Timeout for the sandbox in **seconds**. For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. :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. - :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) - restores the memory snapshot. `"reboot"` cold-boots from its disk state, which - carries crash-recovery semantics: writes not flushed before the pause may be - lost. A no-op for a snapshot that holds no memory. Only a paused sandbox is - rebooted: for one already running the option is ignored, so pause it first; - where it would drop memory it is refused while a start is in flight, so let - that start finish. A reboot that starts and then fails to boot leaves the - sandbox paused with its snapshot intact and the resume retryable; after a - successful reboot, pause again to keep a snapshot. Where filesystem-only resume - is not enabled, a `"reboot"` that would actually drop memory is rejected rather - than quietly restoring it. + :param on_resume: `"restore"` (the default) restores the memory snapshot; `"reboot"` + cold-boots from disk state, so writes not flushed before the pause may be lost. + Rejected where filesystem-only resume is not enabled; a no-op for a snapshot + without memory or a sandbox that is already running. :return: A running sandbox instance @example @@ -358,17 +344,10 @@ def connect( :param timeout: Timeout for the sandbox in **seconds**. For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. - :param on_resume: How to bring a paused sandbox back. `"restore"` (the default) - restores the memory snapshot. `"reboot"` cold-boots from its disk state, which - carries crash-recovery semantics: writes not flushed before the pause may be - lost. A no-op for a snapshot that holds no memory. Only a paused sandbox is - rebooted: for one already running the option is ignored, so pause it first; - where it would drop memory it is refused while a start is in flight, so let - that start finish. A reboot that starts and then fails to boot leaves the - sandbox paused with its snapshot intact and the resume retryable; after a - successful reboot, pause again to keep a snapshot. Where filesystem-only resume - is not enabled, a `"reboot"` that would actually drop memory is rejected rather - than quietly restoring it. + :param on_resume: `"restore"` (the default) restores the memory snapshot; `"reboot"` + cold-boots from disk state, so writes not flushed before the pause may be lost. + Rejected where filesystem-only resume is not enabled; a no-op for a snapshot + without memory or a sandbox that is already running. :return: A running sandbox instance @example From d876b2ae59c339b9d15bfcf1779e9a8d393dd542 Mon Sep 17 00:00:00 2001 From: Babis Chalios Date: Mon, 7 Sep 2026 12:15:24 +0200 Subject: [PATCH 10/10] style: re-run prettier 3.9.6 on js-sdk An earlier codegen pass on this branch ran `pnpm run format` with the older prettier the worktree had installed, reformatting five files this change never touched. Main has since moved to 3.9.6 (#1790); this restores those files to main's formatting and formats sandboxApi.ts the same way. Signed-off-by: Babis Chalios --- packages/js-sdk/src/sandbox/commands/index.ts | 5 +-- packages/js-sdk/src/sandbox/commands/pty.ts | 6 ++-- .../js-sdk/src/sandbox/filesystem/index.ts | 16 +++------ packages/js-sdk/src/sandbox/git/index.ts | 13 ++++---- packages/js-sdk/src/sandbox/sandboxApi.ts | 33 +++++++++---------- packages/js-sdk/tests/envd/http2.test.ts | 10 +++--- 6 files changed, 37 insertions(+), 46 deletions(-) diff --git a/packages/js-sdk/src/sandbox/commands/index.ts b/packages/js-sdk/src/sandbox/commands/index.ts index bbc92241fb..22c9a4028d 100644 --- a/packages/js-sdk/src/sandbox/commands/index.ts +++ b/packages/js-sdk/src/sandbox/commands/index.ts @@ -37,8 +37,9 @@ export { Pty } from './pty' /** * Options for sending a command request. */ -export interface CommandRequestOpts - extends Partial> {} +export interface CommandRequestOpts extends Partial< + Pick +> {} /** * Options for starting a new command. diff --git a/packages/js-sdk/src/sandbox/commands/pty.ts b/packages/js-sdk/src/sandbox/commands/pty.ts index 0fb685a615..29928a92f3 100644 --- a/packages/js-sdk/src/sandbox/commands/pty.ts +++ b/packages/js-sdk/src/sandbox/commands/pty.ts @@ -30,8 +30,10 @@ import { handleProcessStartEvent, } from '../../envd/api' -export interface PtyCreateOpts - extends Pick { +export interface PtyCreateOpts extends Pick< + ConnectionOpts, + 'requestTimeoutMs' | 'signal' +> { /** * Number of columns for the PTY. */ diff --git a/packages/js-sdk/src/sandbox/filesystem/index.ts b/packages/js-sdk/src/sandbox/filesystem/index.ts index 453b0012ac..c07a12ae0b 100644 --- a/packages/js-sdk/src/sandbox/filesystem/index.ts +++ b/packages/js-sdk/src/sandbox/filesystem/index.ts @@ -256,8 +256,9 @@ export function mapEntryInfo(entry: FsEntryInfo): EntryInfo { /** * Options for the sandbox filesystem operations. */ -export interface FilesystemRequestOpts - extends Partial> { +export interface FilesystemRequestOpts extends Partial< + Pick +> { /** * User to use for the operation in the sandbox. * This affects the resolution of relative paths and ownership of the created filesystem objects. @@ -597,11 +598,7 @@ export class Filesystem { async write( pathOrFiles: string | WriteEntry[], dataOrOpts?: - | string - | ArrayBuffer - | Blob - | ReadableStream - | FilesystemWriteOpts, + string | ArrayBuffer | Blob | ReadableStream | FilesystemWriteOpts, opts?: FilesystemWriteOpts ): Promise { if (typeof pathOrFiles !== 'string' && !Array.isArray(pathOrFiles)) { @@ -622,10 +619,7 @@ export class Filesystem { writeFiles: [ { data: dataOrOpts as - | string - | ArrayBuffer - | Blob - | ReadableStream, + string | ArrayBuffer | Blob | ReadableStream, }, ], } diff --git a/packages/js-sdk/src/sandbox/git/index.ts b/packages/js-sdk/src/sandbox/git/index.ts index 82e664d1eb..ae9a1d2a70 100644 --- a/packages/js-sdk/src/sandbox/git/index.ts +++ b/packages/js-sdk/src/sandbox/git/index.ts @@ -35,13 +35,12 @@ const DEFAULT_GIT_ENV: Record = { * * @deprecated Run git with `sandbox.commands.run()` instead. The git module will be removed in the next major version. */ -export interface GitRequestOpts - extends Partial< - Pick< - CommandStartOpts, - 'envs' | 'user' | 'cwd' | 'timeoutMs' | 'requestTimeoutMs' - > - > {} +export interface GitRequestOpts extends Partial< + Pick< + CommandStartOpts, + 'envs' | 'user' | 'cwd' | 'timeoutMs' | 'requestTimeoutMs' + > +> {} /** * Options for cloning a repository. diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index adac86e363..be60ab1fbf 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -117,8 +117,7 @@ export type SandboxNetworkRule = { * also appear in {@link SandboxNetworkOpts.allowOut}. */ export type SandboxNetworkRules = - | Record - | Map + Record | Map /** * Per-domain rule as returned by the sandbox info endpoint. Mirrors @@ -146,8 +145,7 @@ export type SandboxNetworkSelectorContext = { * the same. */ export type SandboxNetworkSelector = - | string[] - | ((ctx: SandboxNetworkSelectorContext) => string[]) + string[] | ((ctx: SandboxNetworkSelectorContext) => string[]) /** * SOCKS5 proxy the sandbox's outbound TCP is tunneled through — "bring your @@ -488,20 +486,19 @@ export type SandboxInfoLifecycle = { /** * Options for request to the Sandbox API. */ -export interface SandboxApiOpts - extends Partial< - Pick< - ConnectionOpts, - | 'apiKey' - | 'validateApiKey' - | 'headers' - | 'apiHeaders' - | 'debug' - | 'domain' - | 'requestTimeoutMs' - | 'signal' - > - > {} +export interface SandboxApiOpts extends Partial< + Pick< + ConnectionOpts, + | 'apiKey' + | 'validateApiKey' + | 'headers' + | 'apiHeaders' + | 'debug' + | 'domain' + | 'requestTimeoutMs' + | 'signal' + > +> {} /** * Options for pausing a sandbox. diff --git a/packages/js-sdk/tests/envd/http2.test.ts b/packages/js-sdk/tests/envd/http2.test.ts index 9ad6e9ee0f..0b4c0b02fe 100644 --- a/packages/js-sdk/tests/envd/http2.test.ts +++ b/packages/js-sdk/tests/envd/http2.test.ts @@ -92,9 +92,8 @@ test('uses a ProxyAgent dispatcher when a proxy is configured', async () => { }) test('caches envd fetchers per proxy', async () => { - const { createEnvdFetch, createEnvdRpcFetch } = await import( - '../../src/envd/http2' - ) + const { createEnvdFetch, createEnvdRpcFetch } = + await import('../../src/envd/http2') const noProxy = createEnvdFetch() const proxyA = createEnvdFetch('http://127.0.0.1:8080') @@ -202,9 +201,8 @@ test('inflight limit env vars return 0 when explicitly disabled', async () => { process.env.E2B_ENVD_INFLIGHT_REQUESTS = '0' process.env.E2B_ENVD_RPC_INFLIGHT_REQUESTS = '0' - const { getEnvdInflightLimit, getEnvdRpcInflightLimit } = await import( - '../../src/envd/http2' - ) + const { getEnvdInflightLimit, getEnvdRpcInflightLimit } = + await import('../../src/envd/http2') expect(getEnvdInflightLimit()).toBe(0) expect(getEnvdRpcInflightLimit()).toBe(0)