From 0e32bf1584629865f5c849d4c4982e6c33199225 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:02:17 +0000 Subject: [PATCH 1/4] feat(sdk): support TLS on the BYOP egress proxy connection Co-Authored-By: mish@e2b.dev --- .changeset/egress-proxy-tls.md | 6 + packages/js-sdk/src/api/schema.gen.ts | 12 +- packages/js-sdk/src/index.ts | 2 + packages/js-sdk/src/sandbox/sandboxApi.ts | 79 +++++++++- .../js-sdk/tests/sandbox/egressProxy.test.ts | 133 +++++++++++++++++ packages/python-sdk/e2b/__init__.py | 4 + .../get_templates_template_id_files_hash.py | 8 +- .../e2b/api/client/models/__init__.py | 2 + .../sandbox_egress_proxy_config_type_0.py | 54 ++++++- .../sandbox_egress_proxy_tls_config_type_0.py | 87 +++++++++++ .../e2b/api/client/models/template_step.py | 2 +- .../python-sdk/e2b/sandbox/sandbox_api.py | 75 +++++++++- .../tests/shared/sandbox/test_egress_proxy.py | 141 +++++++++++++++++- spec/openapi.yml | 43 +++++- spec/runtime-ref | 2 +- 15 files changed, 635 insertions(+), 15 deletions(-) create mode 100644 .changeset/egress-proxy-tls.md create mode 100644 packages/python-sdk/e2b/api/client/models/sandbox_egress_proxy_tls_config_type_0.py diff --git a/.changeset/egress-proxy-tls.md b/.changeset/egress-proxy-tls.md new file mode 100644 index 0000000000..20144284f8 --- /dev/null +++ b/.changeset/egress-proxy-tls.md @@ -0,0 +1,6 @@ +--- +"e2b": minor +"@e2b/python-sdk": minor +--- + +Add `tls` to the egress proxy options (`SandboxEgressProxyTLSOpts`) to connect to a BYOP SOCKS5 proxy over TLS, with optional `serverName`/`server_name` and `caCert`/`ca_cert`; `getInfo`/`get_info` reports it (without the CA). diff --git a/packages/js-sdk/src/api/schema.gen.ts b/packages/js-sdk/src/api/schema.gen.ts index cb39bcc138..31285a5ca6 100644 --- a/packages/js-sdk/src/api/schema.gen.ts +++ b/packages/js-sdk/src/api/schema.gen.ts @@ -2525,9 +2525,19 @@ export interface components { address: string; /** @description Optional SOCKS5 password (RFC 1929), max 255 bytes. */ password?: string; + tls?: components["schemas"]["SandboxEgressProxyTLSConfig"]; /** @description Optional SOCKS5 username (RFC 1929), max 255 bytes. */ username?: string; } | null; + /** @description TLS for the connection to the SOCKS5 proxy. The SOCKS5 negotiation and the tunneled traffic both run inside the TLS session, so the proxy credentials are not sent in the clear. This secures only the hop to the proxy; what the proxy does onward is its own concern. A half-close from the sandbox reaches the proxy as a TLS close_notify, not a TCP FIN, and a proxy that treats close_notify as a full close cuts the reply short. */ + SandboxEgressProxyTLSConfig: { + /** @description One or more PEM-encoded certificates to verify the proxy against, for a proxy fronted by a private CA. These replace the system trust store, which is what is used when this is omitted. The system trust store depends on the host the orchestrator runs on, so set this to get the same verification everywhere. */ + caCert?: string; + /** @description Connect to the proxy over TLS. When false, no other field in this object may be set. */ + enabled: boolean; + /** @description Name to verify the proxy certificate against, and to send as SNI. Defaults to the host part of address. Set this only when the certificate does not match the address the proxy is reached at. */ + serverName?: string; + } | null; SandboxesWithMetrics: { sandboxes: { [key: string]: components["schemas"]["SandboxMetric"]; @@ -3015,7 +3025,7 @@ export interface components { * @default [] */ args?: string[]; - /** @description Hash of the files used in the step */ + /** @description Hash of the files used in the step (lowercase hex SHA-256) */ filesHash?: string; /** * @description Whether the step should be forced to run regardless of the cache diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index 55a74e6af3..5de45f1226 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -76,7 +76,9 @@ export type { SandboxIamToken, SandboxIamTokenType, SandboxEgressProxyOpts, + SandboxEgressProxyTLSOpts, SandboxEgressProxyInfo, + SandboxEgressProxyTLSInfo, SandboxNetworkOpts, SandboxNetworkInfo, SandboxNetworkSelector, diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 50872b4881..39430ad0f1 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -167,6 +167,7 @@ export type SandboxNetworkSelector = * address: 'proxy.example.com:1080', * username: 'proxy-user', * password: 'proxy-password', + * tls: { enabled: true }, * }, * }, * }) @@ -198,18 +199,60 @@ export type SandboxEgressProxyOpts = { * {@link SandboxEgressProxyOpts.username}. */ password?: string + + /** TLS settings for the connection to the SOCKS5 proxy. */ + tls?: SandboxEgressProxyTLSOpts +} + +/** + * TLS settings for the connection to a BYOP SOCKS5 proxy. + */ +export type SandboxEgressProxyTLSOpts = { + /** + * Connect to the proxy over TLS. The SOCKS5 handshake, credentials, and + * tunneled traffic run inside the TLS session; TLS protects only the hop to + * the proxy. The certificate is always verified, TLS 1.2 or newer is + * required, and failures never fall back to plaintext. + * + * When `false`, `serverName` and `caCert` must not be set. + */ + enabled: boolean + + /** + * Name to verify the proxy certificate against and send as SNI, up to 253 + * characters. Defaults to the host part of `address`; set this only when the + * certificate does not match the address. + */ + serverName?: string + + /** + * One or more PEM CA certificates, up to 8192 bytes. This replaces the + * system trust store rather than adding to it. + */ + caCert?: string } /** * Egress proxy as returned by the sandbox info endpoint. Mirrors - * {@link SandboxEgressProxyOpts} without `password` — the API never returns - * it. + * {@link SandboxEgressProxyOpts} without `password` or `caCert` — the API + * never returns them. */ export type SandboxEgressProxyInfo = { /** See {@link SandboxEgressProxyOpts.address}. */ address: string /** See {@link SandboxEgressProxyOpts.username}. */ username?: string + /** TLS status for the connection to the proxy. */ + tls?: SandboxEgressProxyTLSInfo +} + +/** + * TLS status for the egress proxy connection. The CA certificate is never + * returned by the API. + */ +export type SandboxEgressProxyTLSInfo = { + enabled: boolean + serverName?: string } export type SandboxNetworkOpts = { @@ -377,6 +420,9 @@ export type SandboxNetworkUpdate = { * traffic out directly — even when the update was only meant to change the * allow and deny lists. Repeat it in every update that should keep * tunneling. + * + * The same replace semantics apply to `tls`: an update that repeats + * `address` but omits `tls` keeps tunneling without TLS. */ egressProxy?: SandboxEgressProxyOpts /** @@ -1157,6 +1203,16 @@ function buildEgressProxyBody( ) } + const tls = egressProxy.tls + if ( + tls != null && + (!isPlainObject(tls) || typeof tls.enabled !== 'boolean') + ) { + throw new InvalidArgumentError( + "network egressProxy tls must be an object with a boolean 'enabled'." + ) + } + // `!= null` also skips a `null` credential, which is what reading one out of // an unset environment variable yields and what "this proxy takes no // credentials" means; the API only accepts a string. @@ -1164,6 +1220,15 @@ function buildEgressProxyBody( address: egressProxy.address, ...(egressProxy.username != null ? { username: egressProxy.username } : {}), ...(egressProxy.password != null ? { password: egressProxy.password } : {}), + ...(tls != null + ? { + tls: { + enabled: tls.enabled, + ...(tls.serverName != null ? { serverName: tls.serverName } : {}), + ...(tls.caCert != null ? { caCert: tls.caCert } : {}), + }, + } + : {}), } } @@ -1222,6 +1287,16 @@ function fromApiEgressProxy( ...(egressProxy.username !== undefined ? { username: egressProxy.username } : {}), + ...(egressProxy.tls != null + ? { + tls: { + enabled: egressProxy.tls.enabled, + ...(egressProxy.tls.serverName !== undefined + ? { serverName: egressProxy.tls.serverName } + : {}), + }, + } + : {}), } } diff --git a/packages/js-sdk/tests/sandbox/egressProxy.test.ts b/packages/js-sdk/tests/sandbox/egressProxy.test.ts index e4c137055a..7b0db32e26 100644 --- a/packages/js-sdk/tests/sandbox/egressProxy.test.ts +++ b/packages/js-sdk/tests/sandbox/egressProxy.test.ts @@ -2,6 +2,7 @@ import { afterAll, afterEach, beforeAll, expect, test } from 'vitest' import { http, HttpResponse } from 'msw' import { Sandbox } from '../../src' +import { InvalidArgumentError } from '../../src/errors' import { TEST_API_KEY, apiUrl } from '../setup' import { setupMockApi } from '../mockApi' @@ -84,6 +85,77 @@ test('Sandbox.create sends an address-only egress proxy without credentials', as }) }) +test('Sandbox.create sends egress proxy TLS options', async () => { + await Sandbox.create('base', { + apiKey: TEST_API_KEY, + network: { + egressProxy: { + address: 'proxy.example.com:1080', + tls: { + enabled: true, + serverName: 'proxy.example.com', + caCert: '-----BEGIN CERTIFICATE-----', + ignored: 'not sent', + } as never, + }, + }, + }) + + expect(lastCreateBody?.network.egressProxy).toEqual({ + address: 'proxy.example.com:1080', + tls: { + enabled: true, + serverName: 'proxy.example.com', + caCert: '-----BEGIN CERTIFICATE-----', + }, + }) +}) + +test.for([ + ['enabled', { enabled: true }], + ['disabled', { enabled: false }], +])( + 'Sandbox.create sends TLS settings when %s', + async ([, tls]: [string, object]) => { + await Sandbox.create('base', { + apiKey: TEST_API_KEY, + network: { + egressProxy: { + address: 'proxy.example.com:1080', + tls, + }, + }, + }) + + expect(lastCreateBody?.network.egressProxy).toEqual({ + address: 'proxy.example.com:1080', + tls, + }) + } +) + +test('Sandbox.create omits null TLS options', async () => { + await Sandbox.create('base', { + apiKey: TEST_API_KEY, + network: { + egressProxy: { + address: 'proxy.example.com:1080', + tls: { + enabled: true, + serverName: null, + caCert: null, + ignored: 'not sent', + } as never, + }, + }, + }) + + expect(lastCreateBody?.network.egressProxy).toEqual({ + address: 'proxy.example.com:1080', + tls: { enabled: true }, + }) +}) + test('Sandbox.create combines the egress proxy with allow and deny lists', async () => { await Sandbox.create('base', { apiKey: TEST_API_KEY, @@ -148,6 +220,27 @@ test.for([ } ) +test.for([ + ['a string', 'tls'], + ['an empty object', {}], + ['a non-boolean enabled value', { enabled: 'true' }], +])( + 'Sandbox.create rejects %s as the egress proxy TLS settings', + async ([, tls]: [string, unknown]) => { + await expect( + Sandbox.create('base', { + apiKey: TEST_API_KEY, + network: { + egressProxy: { + address: 'proxy.example.com:1080', + tls: tls as never, + }, + }, + }) + ).rejects.toBeInstanceOf(InvalidArgumentError) + } +) + test('Sandbox.create omits null credentials', async () => { // Reading a credential out of an unset environment variable is how a caller // lands here, and it means the proxy takes no credentials — the API only @@ -199,6 +292,26 @@ test('updateNetwork sets the egress proxy on a running sandbox', async () => { }) }) +test('updateNetwork sends egress proxy TLS options', async () => { + await Sandbox.updateNetwork( + sandboxId, + { + egressProxy: { + address: 'proxy.example.com:1080', + tls: { enabled: true, serverName: 'proxy.example.com' }, + }, + }, + { apiKey: TEST_API_KEY } + ) + + expect(lastUpdateBody).toEqual({ + egressProxy: { + address: 'proxy.example.com:1080', + tls: { enabled: true, serverName: 'proxy.example.com' }, + }, + }) +}) + test('an update without the egress proxy clears it', async () => { // The update replaces the whole configuration instead of merging into it, so // omitting the proxy stops tunneling rather than leaving it in place. @@ -221,6 +334,26 @@ test('getInfo reports the active egress proxy without the password', async () => }) }) +test('getInfo reports egress proxy TLS without the CA certificate', async () => { + sandboxNetwork = { + egressProxy: { + address: 'proxy.example.com:1080', + tls: { + enabled: true, + serverName: 'proxy.example.com', + caCert: '-----BEGIN CERTIFICATE-----', + }, + }, + } + + const info = await Sandbox.getInfo(sandboxId, { apiKey: TEST_API_KEY }) + + expect(info.network?.egressProxy).toEqual({ + address: 'proxy.example.com:1080', + tls: { enabled: true, serverName: 'proxy.example.com' }, + }) +}) + test('getInfo drops a password the API unexpectedly returns', async () => { // SandboxEgressProxyInfo says the password is not there, so it must not be // there even if a future API version starts echoing it back. diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index b4f1d0984a..53830c87c2 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -85,6 +85,8 @@ McpServer, SandboxEgressProxyInfo, SandboxEgressProxyOpts, + SandboxEgressProxyTLSInfo, + SandboxEgressProxyTLSOpts, SandboxIamOpts, SandboxIamToken, SandboxIamTokenType, @@ -232,6 +234,8 @@ # Network "SandboxEgressProxyOpts", "SandboxEgressProxyInfo", + "SandboxEgressProxyTLSOpts", + "SandboxEgressProxyTLSInfo", "SandboxNetworkOpts", "SandboxNetworkInfo", "SandboxNetworkSelector", diff --git a/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_files_hash.py b/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_files_hash.py index 834de12cf7..74e72dad54 100644 --- a/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_files_hash.py +++ b/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_files_hash.py @@ -78,7 +78,7 @@ def sync_detailed( Args: template_id (str): - hash_ (str): Hash of the files + hash_ (str): Hash of the files (lowercase hex SHA-256) Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -112,7 +112,7 @@ def sync( Args: template_id (str): - hash_ (str): Hash of the files + hash_ (str): Hash of the files (lowercase hex SHA-256) Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -141,7 +141,7 @@ async def asyncio_detailed( Args: template_id (str): - hash_ (str): Hash of the files + hash_ (str): Hash of the files (lowercase hex SHA-256) Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -173,7 +173,7 @@ async def asyncio( Args: template_id (str): - hash_ (str): Hash of the files + hash_ (str): Hash of the files (lowercase hex SHA-256) Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/packages/python-sdk/e2b/api/client/models/__init__.py b/packages/python-sdk/e2b/api/client/models/__init__.py index 8aba151880..a2ab66b8ed 100644 --- a/packages/python-sdk/e2b/api/client/models/__init__.py +++ b/packages/python-sdk/e2b/api/client/models/__init__.py @@ -29,6 +29,7 @@ from .sandbox_auto_resume_config import SandboxAutoResumeConfig from .sandbox_detail import SandboxDetail from .sandbox_egress_proxy_config_type_0 import SandboxEgressProxyConfigType0 +from .sandbox_egress_proxy_tls_config_type_0 import SandboxEgressProxyTLSConfigType0 from .sandbox_fork_request import SandboxForkRequest from .sandbox_fork_result import SandboxForkResult from .sandbox_iam import SandboxIam @@ -110,6 +111,7 @@ "SandboxAutoResumeConfig", "SandboxDetail", "SandboxEgressProxyConfigType0", + "SandboxEgressProxyTLSConfigType0", "SandboxesWithMetrics", "SandboxForkRequest", "SandboxForkResult", diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_egress_proxy_config_type_0.py b/packages/python-sdk/e2b/api/client/models/sandbox_egress_proxy_config_type_0.py index 8f5c32e33f..cc2dbdebb9 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_egress_proxy_config_type_0.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_egress_proxy_config_type_0.py @@ -1,11 +1,17 @@ from collections.abc import Mapping -from typing import Any, TypeVar, Union +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast from attrs import define as _attrs_define from attrs import field as _attrs_field from ..types import UNSET, Unset +if TYPE_CHECKING: + from ..models.sandbox_egress_proxy_tls_config_type_0 import ( + SandboxEgressProxyTLSConfigType0, + ) + + T = TypeVar("T", bound="SandboxEgressProxyConfigType0") @@ -18,20 +24,38 @@ class SandboxEgressProxyConfigType0: address (str): SOCKS5 proxy address in host:port format (e.g. "proxy.example.com:1080"). username (Union[Unset, str]): Optional SOCKS5 username (RFC 1929), max 255 bytes. password (Union[Unset, str]): Optional SOCKS5 password (RFC 1929), max 255 bytes. + tls (Union['SandboxEgressProxyTLSConfigType0', None, Unset]): TLS for the connection to the SOCKS5 proxy. The + SOCKS5 negotiation and the tunneled traffic both run inside the TLS session, so the proxy credentials are not + sent in the clear. This secures only the hop to the proxy; what the proxy does onward is its own concern. A + half-close from the sandbox reaches the proxy as a TLS close_notify, not a TCP FIN, and a proxy that treats + close_notify as a full close cuts the reply short. """ address: str username: Union[Unset, str] = UNSET password: Union[Unset, str] = UNSET + tls: Union["SandboxEgressProxyTLSConfigType0", None, Unset] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: + from ..models.sandbox_egress_proxy_tls_config_type_0 import ( + SandboxEgressProxyTLSConfigType0, + ) + address = self.address username = self.username password = self.password + tls: Union[None, Unset, dict[str, Any]] + if isinstance(self.tls, Unset): + tls = UNSET + elif isinstance(self.tls, SandboxEgressProxyTLSConfigType0): + tls = self.tls.to_dict() + else: + tls = self.tls + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -43,11 +67,17 @@ def to_dict(self) -> dict[str, Any]: field_dict["username"] = username if password is not UNSET: field_dict["password"] = password + if tls is not UNSET: + field_dict["tls"] = tls return field_dict @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_egress_proxy_tls_config_type_0 import ( + SandboxEgressProxyTLSConfigType0, + ) + d = dict(src_dict) address = d.pop("address") @@ -55,10 +85,32 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: password = d.pop("password", UNSET) + def _parse_tls( + data: object, + ) -> Union["SandboxEgressProxyTLSConfigType0", None, Unset]: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + componentsschemas_sandbox_egress_proxy_tls_config_type_0 = ( + SandboxEgressProxyTLSConfigType0.from_dict(data) + ) + + return componentsschemas_sandbox_egress_proxy_tls_config_type_0 + except: # noqa: E722 + pass + return cast(Union["SandboxEgressProxyTLSConfigType0", None, Unset], data) + + tls = _parse_tls(d.pop("tls", UNSET)) + sandbox_egress_proxy_config_type_0 = cls( address=address, username=username, password=password, + tls=tls, ) sandbox_egress_proxy_config_type_0.additional_properties = d diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_egress_proxy_tls_config_type_0.py b/packages/python-sdk/e2b/api/client/models/sandbox_egress_proxy_tls_config_type_0.py new file mode 100644 index 0000000000..7ed3c80264 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_egress_proxy_tls_config_type_0.py @@ -0,0 +1,87 @@ +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="SandboxEgressProxyTLSConfigType0") + + +@_attrs_define +class SandboxEgressProxyTLSConfigType0: + """TLS for the connection to the SOCKS5 proxy. The SOCKS5 negotiation and the tunneled traffic both run inside the TLS + session, so the proxy credentials are not sent in the clear. This secures only the hop to the proxy; what the proxy + does onward is its own concern. A half-close from the sandbox reaches the proxy as a TLS close_notify, not a TCP + FIN, and a proxy that treats close_notify as a full close cuts the reply short. + + Attributes: + enabled (bool): Connect to the proxy over TLS. When false, no other field in this object may be set. + server_name (Union[Unset, str]): Name to verify the proxy certificate against, and to send as SNI. Defaults to + the host part of address. Set this only when the certificate does not match the address the proxy is reached at. + ca_cert (Union[Unset, str]): One or more PEM-encoded certificates to verify the proxy against, for a proxy + fronted by a private CA. These replace the system trust store, which is what is used when this is omitted. The + system trust store depends on the host the orchestrator runs on, so set this to get the same verification + everywhere. + """ + + enabled: bool + server_name: Union[Unset, str] = UNSET + ca_cert: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + enabled = self.enabled + + server_name = self.server_name + + ca_cert = self.ca_cert + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "enabled": enabled, + } + ) + if server_name is not UNSET: + field_dict["serverName"] = server_name + if ca_cert is not UNSET: + field_dict["caCert"] = ca_cert + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + enabled = d.pop("enabled") + + server_name = d.pop("serverName", UNSET) + + ca_cert = d.pop("caCert", UNSET) + + sandbox_egress_proxy_tls_config_type_0 = cls( + enabled=enabled, + server_name=server_name, + ca_cert=ca_cert, + ) + + sandbox_egress_proxy_tls_config_type_0.additional_properties = d + return sandbox_egress_proxy_tls_config_type_0 + + @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/template_step.py b/packages/python-sdk/e2b/api/client/models/template_step.py index 45daaecd06..b2c13d7083 100644 --- a/packages/python-sdk/e2b/api/client/models/template_step.py +++ b/packages/python-sdk/e2b/api/client/models/template_step.py @@ -16,7 +16,7 @@ class TemplateStep: Attributes: type_ (str): Type of the step args (Union[Unset, list[str]]): Arguments for the step - files_hash (Union[Unset, str]): Hash of the files used in the step + files_hash (Union[Unset, str]): Hash of the files used in the step (lowercase hex SHA-256) force (Union[Unset, bool]): Whether the step should be forced to run regardless of the cache Default: False. """ diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 7b75c8d3f3..9583a79772 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -31,6 +31,9 @@ from e2b.api.client.models import ( SandboxEgressProxyConfigType0 as ClientSandboxEgressProxyConfig, ) +from e2b.api.client.models import ( + SandboxEgressProxyTLSConfigType0 as ClientSandboxEgressProxyTLSConfig, +) from e2b.api.client.models import ( SandboxNetworkConfig as ClientSandboxNetworkConfig, ) @@ -260,6 +263,35 @@ class SandboxNetworkSelectorContext: """ +class SandboxEgressProxyTLSOpts(TypedDict): + """ + TLS settings for the connection to a BYOP SOCKS5 proxy. + """ + + enabled: bool + """ + Connect to the proxy over TLS. The SOCKS5 handshake, credentials, and + tunneled traffic run inside the TLS session; TLS protects only the hop to + the proxy. The certificate is always verified, TLS 1.2 or newer is + required, and failures never fall back to plaintext. + + When ``False``, ``server_name`` and ``ca_cert`` must not be set. + """ + + server_name: NotRequired[str] + """ + Name to verify the proxy certificate against and send as SNI, up to 253 + characters. Defaults to the host part of ``address``; set this only when + the certificate does not match the address. + """ + + ca_cert: NotRequired[str] + """ + One or more PEM CA certificates, up to 8192 bytes. This replaces the + system trust store rather than adding to it. + """ + + class SandboxEgressProxyOpts(TypedDict): """ SOCKS5 proxy the sandbox's outbound TCP is tunneled through — "bring your @@ -286,6 +318,8 @@ class SandboxEgressProxyOpts(TypedDict): }, }, ) + + Enable TLS to the proxy with ``"tls": {"enabled": True}``. """ address: str @@ -313,12 +347,25 @@ class SandboxEgressProxyOpts(TypedDict): :attr:`username`. """ + tls: NotRequired[SandboxEgressProxyTLSOpts] + """TLS settings for the connection to the SOCKS5 proxy.""" + + +class SandboxEgressProxyTLSInfo(TypedDict): + """ + TLS status for the egress proxy connection. The CA certificate is never + returned by the API. + """ + + enabled: bool + server_name: NotRequired[str] + class SandboxEgressProxyInfo(TypedDict): """ Egress proxy as returned by the sandbox info endpoint. Mirrors - :class:`SandboxEgressProxyOpts` without ``password`` — the API never - returns it. + :class:`SandboxEgressProxyOpts` without ``password`` or ``ca_cert`` — the + API never returns them. """ address: str @@ -327,6 +374,9 @@ class SandboxEgressProxyInfo(TypedDict): username: NotRequired[str] """See :attr:`SandboxEgressProxyOpts.username`.""" + tls: NotRequired[SandboxEgressProxyTLSInfo] + """TLS status for the connection to the proxy.""" + class SandboxNetworkOpts(TypedDict): """ @@ -469,6 +519,9 @@ class SandboxNetworkUpdate(TypedDict, total=False): an update that leaves this out stops tunneling and sends the sandbox's traffic out directly — even when the update was only meant to change the allow and deny lists. Repeat it in every update that should keep tunneling. + + The same replace semantics apply to ``tls``: an update that repeats + ``address`` but omits ``tls`` keeps tunneling without TLS. """ allow_internet_access: bool @@ -810,6 +863,19 @@ def _build_egress_proxy( body.username = egress_proxy["username"] if egress_proxy.get("password") is not None: body.password = egress_proxy["password"] + tls = egress_proxy.get("tls") + if tls is not None: + if not isinstance(tls, Mapping) or not isinstance(tls.get("enabled"), bool): + raise InvalidArgumentException( + "network egress_proxy tls must be a dict with a boolean 'enabled'." + ) + + client_tls = ClientSandboxEgressProxyTLSConfig(enabled=tls["enabled"]) + if tls.get("server_name") is not None: + client_tls.server_name = tls["server_name"] + if tls.get("ca_cert") is not None: + client_tls.ca_cert = tls["ca_cert"] + body.tls = client_tls return body @@ -1049,6 +1115,11 @@ def _from_client_egress_proxy( result: SandboxEgressProxyInfo = {"address": egress_proxy.address} if not isinstance(egress_proxy.username, Unset): result["username"] = egress_proxy.username + if isinstance(egress_proxy.tls, ClientSandboxEgressProxyTLSConfig): + tls: SandboxEgressProxyTLSInfo = {"enabled": egress_proxy.tls.enabled} + if not isinstance(egress_proxy.tls.server_name, Unset): + tls["server_name"] = egress_proxy.tls.server_name + result["tls"] = tls return result diff --git a/packages/python-sdk/tests/shared/sandbox/test_egress_proxy.py b/packages/python-sdk/tests/shared/sandbox/test_egress_proxy.py index 75f3f1bc3c..449995253f 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_egress_proxy.py +++ b/packages/python-sdk/tests/shared/sandbox/test_egress_proxy.py @@ -2,11 +2,14 @@ import pytest -from e2b import SandboxNetworkOpts, SandboxNetworkUpdate +from e2b import SandboxEgressProxyTLSOpts, SandboxNetworkOpts, SandboxNetworkUpdate from e2b.api.client.models import SandboxNetworkConfig from e2b.api.client.models import ( SandboxEgressProxyConfigType0 as ClientSandboxEgressProxyConfig, ) +from e2b.api.client.models import ( + SandboxEgressProxyTLSConfigType0 as ClientSandboxEgressProxyTLSConfig, +) from e2b.api.client.types import UNSET from e2b.exceptions import InvalidArgumentException from e2b.sandbox.sandbox_api import ( @@ -40,6 +43,71 @@ def test_create_sends_an_address_only_egress_proxy(): assert body["egress_proxy"].to_dict() == {"address": "proxy.example.com:1080"} +def test_create_sends_egress_proxy_tls_options(): + tls: SandboxEgressProxyTLSOpts = { + "enabled": True, + "server_name": "proxy.example.com", + "ca_cert": "-----BEGIN CERTIFICATE-----", + } + body = build_network_config( + {"egress_proxy": {"address": "proxy.example.com:1080", "tls": tls}} + ) + + assert body is not None + assert body["egress_proxy"].to_dict() == { + "address": "proxy.example.com:1080", + "tls": { + "enabled": True, + "serverName": "proxy.example.com", + "caCert": "-----BEGIN CERTIFICATE-----", + }, + } + + +@pytest.mark.parametrize( + "tls", + [ + pytest.param({"enabled": True}, id="enabled"), + pytest.param({"enabled": False}, id="disabled"), + ], +) +def test_create_sends_tls_settings_with_enabled_only(tls): + body = build_network_config( + {"egress_proxy": {"address": "proxy.example.com:1080", "tls": tls}} + ) + + assert body is not None + assert body["egress_proxy"].to_dict() == { + "address": "proxy.example.com:1080", + "tls": tls, + } + + +def test_create_omits_none_tls_options_and_strips_unknown_keys(): + network = cast( + Any, + { + "egress_proxy": { + "address": "proxy.example.com:1080", + "tls": { + "enabled": True, + "server_name": None, + "ca_cert": None, + "protocol": "tls", + }, + }, + }, + ) + + body = build_network_config(network) + + assert body is not None + assert body["egress_proxy"].to_dict() == { + "address": "proxy.example.com:1080", + "tls": {"enabled": True}, + } + + def test_create_combines_the_egress_proxy_with_allow_and_deny_lists(): network: SandboxNetworkOpts = { "allow_out": ["api.example.com"], @@ -85,6 +153,32 @@ def test_create_rejects_a_malformed_egress_proxy(egress_proxy): build_network_config(cast(Any, {"egress_proxy": egress_proxy})) +@pytest.mark.parametrize( + "tls", + [ + pytest.param("tls", id="string"), + pytest.param({}, id="missing-enabled"), + pytest.param({"enabled": "true"}, id="non-boolean-enabled"), + ], +) +def test_create_rejects_malformed_egress_proxy_tls(tls): + with pytest.raises( + InvalidArgumentException, + match="network egress_proxy tls must be a dict with a boolean 'enabled'", + ): + build_network_config( + cast( + Any, + { + "egress_proxy": { + "address": "proxy.example.com:1080", + "tls": tls, + } + }, + ) + ) + + def test_create_omits_none_credentials(): # Reading a credential out of an unset environment variable is how a caller # lands here, and it means the proxy takes no credentials — the API only @@ -137,6 +231,30 @@ def test_update_sets_the_egress_proxy(): } +def test_update_sends_egress_proxy_tls_options(): + network: SandboxNetworkUpdate = { + "egress_proxy": { + "address": "proxy.example.com:1080", + "tls": { + "enabled": True, + "server_name": "proxy.example.com", + "ca_cert": "-----BEGIN CERTIFICATE-----", + }, + }, + } + + assert build_network_update_body(network).to_dict() == { + "egressProxy": { + "address": "proxy.example.com:1080", + "tls": { + "enabled": True, + "serverName": "proxy.example.com", + "caCert": "-----BEGIN CERTIFICATE-----", + }, + }, + } + + def test_update_without_the_egress_proxy_clears_it(): # The update replaces the whole configuration instead of merging into it, so # omitting the proxy stops tunneling rather than leaving it in place. @@ -165,6 +283,27 @@ def test_get_info_reports_the_active_egress_proxy_without_the_password(): } +def test_get_info_reports_egress_proxy_tls_without_the_ca_certificate(): + info = from_client_network_config( + SandboxNetworkConfig( + egress_proxy=ClientSandboxEgressProxyConfig( + address="proxy.example.com:1080", + tls=ClientSandboxEgressProxyTLSConfig( + enabled=True, + server_name="proxy.example.com", + ca_cert="-----BEGIN CERTIFICATE-----", + ), + ), + ) + ) + + assert info is not None + assert info["egress_proxy"] == { + "address": "proxy.example.com:1080", + "tls": {"enabled": True, "server_name": "proxy.example.com"}, + } + + @pytest.mark.parametrize( "egress_proxy", [ diff --git a/spec/openapi.yml b/spec/openapi.yml index f293777631..e10df04307 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -592,6 +592,43 @@ components: maxLength: 255 description: >- Optional SOCKS5 password (RFC 1929), max 255 bytes. + tls: + $ref: "#/components/schemas/SandboxEgressProxyTLSConfig" + + SandboxEgressProxyTLSConfig: + type: object + nullable: true + description: >- + TLS for the connection to the SOCKS5 proxy. The SOCKS5 negotiation and + the tunneled traffic both run inside the TLS session, so the proxy + credentials are not sent in the clear. This secures only the hop to the + proxy; what the proxy does onward is its own concern. A half-close from + the sandbox reaches the proxy as a TLS close_notify, not a TCP FIN, and + a proxy that treats close_notify as a full close cuts the reply short. + required: + - enabled + properties: + enabled: + type: boolean + description: >- + Connect to the proxy over TLS. When false, no other field in this + object may be set. + serverName: + type: string + maxLength: 253 + description: >- + Name to verify the proxy certificate against, and to send as SNI. + Defaults to the host part of address. Set this only when the + certificate does not match the address the proxy is reached at. + caCert: + type: string + maxLength: 8192 + description: >- + One or more PEM-encoded certificates to verify the proxy against, + for a proxy fronted by a private CA. These replace the system trust + store, which is what is used when this is omitted. The system trust + store depends on the host the orchestrator runs on, so set this to + get the same verification everywhere. SandboxAutoResumeEnabled: type: boolean @@ -1506,7 +1543,8 @@ components: type: string filesHash: type: string - description: Hash of the files used in the step + pattern: "^[0-9a-f]{64}$" + description: Hash of the files used in the step (lowercase hex SHA-256) force: default: false type: boolean @@ -3816,7 +3854,8 @@ paths: required: true schema: type: string - description: Hash of the files + pattern: "^[0-9a-f]{64}$" + description: Hash of the files (lowercase hex SHA-256) responses: "201": diff --git a/spec/runtime-ref b/spec/runtime-ref index abf34cf2b2..aa343f64db 100644 --- a/spec/runtime-ref +++ b/spec/runtime-ref @@ -1 +1 @@ -90ad3bdbb1a4ad78161463dc90b66b4f8303897b +9cc9ca757b7cfa011874b6a04b82deff7ca771cf From fad2d0ff5b6ef251f5b26a064996781345bb10f8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:14:16 +0000 Subject: [PATCH 2/4] chore(codegen): sync envd generated files Co-Authored-By: mish@e2b.dev --- packages/js-sdk/src/envd/schema.gen.ts | 13 ++++++++++++- spec/envd/envd.yaml | 24 +++++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/js-sdk/src/envd/schema.gen.ts b/packages/js-sdk/src/envd/schema.gen.ts index c00443dc6e..74e72ca420 100644 --- a/packages/js-sdk/src/envd/schema.gen.ts +++ b/packages/js-sdk/src/envd/schema.gen.ts @@ -283,13 +283,24 @@ export interface components { }; /** @description Resource usage metrics */ Metrics: { - /** @description Number of CPU cores */ + /** @description Number of CPU cores online */ cpu_count?: number; + /** @description Number of CPUs the guest can bring online */ + cpu_possible?: number; + /** @description Online count last requested, 0 when none was */ + cpu_target?: number; + /** @description Attempts at reaching cpu_target since it was set */ + cpu_target_attempts?: number; /** * Format: float * @description CPU usage percentage */ cpu_used_pct?: number; + /** + * Format: int64 + * @description How long a CPU online or offline write has been running in the guest kernel, 0 when none is. A value that keeps growing means CPU hotplug is stuck. + */ + cpu_write_pending_ms?: number; /** @description Total disk space in bytes */ disk_total?: number; /** @description Used disk space in bytes */ diff --git a/spec/envd/envd.yaml b/spec/envd/envd.yaml index 9e1a304ef1..670f8ac3f0 100644 --- a/spec/envd/envd.yaml +++ b/spec/envd/envd.yaml @@ -77,6 +77,13 @@ paths: caBundle: type: string description: PEM-encoded CA certificates to install into the system trust store (may contain multiple concatenated PEM blocks) + cpuCount: + type: integer + minimum: 1 + description: >- + Number of guest CPUs that should be online. envd attempts to bring + the set of requested CPUs online asynchronously; the outcome is + reported on the X-Envd-Cpus header and on /metrics. responses: "204": @@ -521,7 +528,22 @@ components: description: Unix timestamp in UTC for current sandbox time cpu_count: type: integer - description: Number of CPU cores + description: Number of CPU cores online + cpu_possible: + type: integer + description: Number of CPUs the guest can bring online + cpu_target: + type: integer + description: Online count last requested, 0 when none was + cpu_target_attempts: + type: integer + description: Attempts at reaching cpu_target since it was set + cpu_write_pending_ms: + type: integer + format: int64 + description: >- + How long a CPU online or offline write has been running in the guest kernel, + 0 when none is. A value that keeps growing means CPU hotplug is stuck. cpu_used_pct: type: number format: float From 09ea97f236947d377aebc1be10824e36d37937a7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:15:23 +0000 Subject: [PATCH 3/4] docs(sdk): document egress proxy TLS info fields Co-Authored-By: mish@e2b.dev --- packages/js-sdk/src/sandbox/sandboxApi.ts | 2 ++ packages/python-sdk/e2b/sandbox/sandbox_api.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 39430ad0f1..e26e9f4fed 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -251,7 +251,9 @@ export type SandboxEgressProxyInfo = { * returned by the API. */ export type SandboxEgressProxyTLSInfo = { + /** See {@link SandboxEgressProxyTLSOpts.enabled}. */ enabled: boolean + /** See {@link SandboxEgressProxyTLSOpts.serverName}. */ serverName?: string } diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 9583a79772..646bac8879 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -358,7 +358,10 @@ class SandboxEgressProxyTLSInfo(TypedDict): """ enabled: bool + """See :attr:`SandboxEgressProxyTLSOpts.enabled`.""" + server_name: NotRequired[str] + """See :attr:`SandboxEgressProxyTLSOpts.server_name`.""" class SandboxEgressProxyInfo(TypedDict): From c16cb9639af66c5a3fbfe909fa78d4033b82c4e1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:07:27 +0000 Subject: [PATCH 4/4] feat(sdk): take tls: true | { serverName, caCert } for the egress proxy Co-Authored-By: mish@e2b.dev --- .changeset/egress-proxy-tls.md | 2 +- packages/js-sdk/src/sandbox/sandboxApi.ts | 60 +++--- .../js-sdk/tests/sandbox/egressProxy.test.ts | 180 +++++++++++++++--- .../python-sdk/e2b/sandbox/sandbox_api.py | 71 ++++--- .../tests/shared/sandbox/test_egress_proxy.py | 145 ++++++++++++-- 5 files changed, 359 insertions(+), 99 deletions(-) diff --git a/.changeset/egress-proxy-tls.md b/.changeset/egress-proxy-tls.md index 20144284f8..d5f20b302b 100644 --- a/.changeset/egress-proxy-tls.md +++ b/.changeset/egress-proxy-tls.md @@ -3,4 +3,4 @@ "@e2b/python-sdk": minor --- -Add `tls` to the egress proxy options (`SandboxEgressProxyTLSOpts`) to connect to a BYOP SOCKS5 proxy over TLS, with optional `serverName`/`server_name` and `caCert`/`ca_cert`; `getInfo`/`get_info` reports it (without the CA). +Add `tls` to the egress proxy options as `true` or `{ serverName?, caCert? }` (`server_name`/`ca_cert` in Python); `getInfo`/`get_info` reports it without the CA. diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index e26e9f4fed..a7025be4c3 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -167,7 +167,7 @@ export type SandboxNetworkSelector = * address: 'proxy.example.com:1080', * username: 'proxy-user', * password: 'proxy-password', - * tls: { enabled: true }, + * tls: true, * }, * }, * }) @@ -200,24 +200,23 @@ export type SandboxEgressProxyOpts = { */ password?: string - /** TLS settings for the connection to the SOCKS5 proxy. */ - tls?: SandboxEgressProxyTLSOpts -} - -/** - * TLS settings for the connection to a BYOP SOCKS5 proxy. - */ -export type SandboxEgressProxyTLSOpts = { /** * Connect to the proxy over TLS. The SOCKS5 handshake, credentials, and * tunneled traffic run inside the TLS session; TLS protects only the hop to * the proxy. The certificate is always verified, TLS 1.2 or newer is * required, and failures never fall back to plaintext. * - * When `false`, `serverName` and `caCert` must not be set. + * Set to `true` to use the system trust store and the host part of `address` + * for SNI. Passing an object also enables TLS, with optional custom + * `serverName` and `caCert`. */ - enabled: boolean + tls?: boolean | SandboxEgressProxyTLSOpts +} +/** + * TLS settings for the connection to a BYOP SOCKS5 proxy. + */ +export type SandboxEgressProxyTLSOpts = { /** * Name to verify the proxy certificate against and send as SNI, up to 253 * characters. Defaults to the host part of `address`; set this only when the @@ -242,7 +241,7 @@ export type SandboxEgressProxyInfo = { address: string /** See {@link SandboxEgressProxyOpts.username}. */ username?: string - /** TLS status for the connection to the proxy. */ + /** Set when TLS to the proxy is on. */ tls?: SandboxEgressProxyTLSInfo } @@ -251,8 +250,6 @@ export type SandboxEgressProxyInfo = { * returned by the API. */ export type SandboxEgressProxyTLSInfo = { - /** See {@link SandboxEgressProxyTLSOpts.enabled}. */ - enabled: boolean /** See {@link SandboxEgressProxyTLSOpts.serverName}. */ serverName?: string } @@ -1206,15 +1203,31 @@ function buildEgressProxyBody( } const tls = egressProxy.tls + if (tls != null && typeof tls !== 'boolean' && !isPlainObject(tls)) { + throw new InvalidArgumentError( + 'network egressProxy tls must be a boolean or an object.' + ) + } if ( - tls != null && - (!isPlainObject(tls) || typeof tls.enabled !== 'boolean') + isPlainObject(tls) && + Object.prototype.hasOwnProperty.call(tls, 'enabled') ) { throw new InvalidArgumentError( - "network egressProxy tls must be an object with a boolean 'enabled'." + "network egressProxy tls takes no 'enabled'; pass tls: true or tls: false instead." ) } + const tlsBody = + tls === true + ? { enabled: true } + : tls != null && typeof tls === 'object' + ? { + enabled: true, + ...(tls.serverName != null ? { serverName: tls.serverName } : {}), + ...(tls.caCert != null ? { caCert: tls.caCert } : {}), + } + : undefined + // `!= null` also skips a `null` credential, which is what reading one out of // an unset environment variable yields and what "this proxy takes no // credentials" means; the API only accepts a string. @@ -1222,15 +1235,7 @@ function buildEgressProxyBody( address: egressProxy.address, ...(egressProxy.username != null ? { username: egressProxy.username } : {}), ...(egressProxy.password != null ? { password: egressProxy.password } : {}), - ...(tls != null - ? { - tls: { - enabled: tls.enabled, - ...(tls.serverName != null ? { serverName: tls.serverName } : {}), - ...(tls.caCert != null ? { caCert: tls.caCert } : {}), - }, - } - : {}), + ...(tlsBody !== undefined ? { tls: tlsBody } : {}), } } @@ -1289,10 +1294,9 @@ function fromApiEgressProxy( ...(egressProxy.username !== undefined ? { username: egressProxy.username } : {}), - ...(egressProxy.tls != null + ...(egressProxy.tls?.enabled === true ? { tls: { - enabled: egressProxy.tls.enabled, ...(egressProxy.tls.serverName !== undefined ? { serverName: egressProxy.tls.serverName } : {}), diff --git a/packages/js-sdk/tests/sandbox/egressProxy.test.ts b/packages/js-sdk/tests/sandbox/egressProxy.test.ts index 7b0db32e26..15bd76e3fa 100644 --- a/packages/js-sdk/tests/sandbox/egressProxy.test.ts +++ b/packages/js-sdk/tests/sandbox/egressProxy.test.ts @@ -85,14 +85,13 @@ test('Sandbox.create sends an address-only egress proxy without credentials', as }) }) -test('Sandbox.create sends egress proxy TLS options', async () => { +test('Sandbox.create sends egress proxy TLS options and strips unknown fields', async () => { await Sandbox.create('base', { apiKey: TEST_API_KEY, network: { egressProxy: { address: 'proxy.example.com:1080', tls: { - enabled: true, serverName: 'proxy.example.com', caCert: '-----BEGIN CERTIFICATE-----', ignored: 'not sent', @@ -112,24 +111,24 @@ test('Sandbox.create sends egress proxy TLS options', async () => { }) test.for([ - ['enabled', { enabled: true }], - ['disabled', { enabled: false }], + ['true', true, { enabled: true }], + ['an empty options object', {}, { enabled: true }], ])( - 'Sandbox.create sends TLS settings when %s', - async ([, tls]: [string, object]) => { + 'Sandbox.create sends TLS settings when %s is passed', + async ([, tls, wireTls]: [string, unknown, object]) => { await Sandbox.create('base', { apiKey: TEST_API_KEY, network: { egressProxy: { address: 'proxy.example.com:1080', - tls, + tls: tls as never, }, }, }) expect(lastCreateBody?.network.egressProxy).toEqual({ address: 'proxy.example.com:1080', - tls, + tls: wireTls, }) } ) @@ -141,7 +140,6 @@ test('Sandbox.create omits null TLS options', async () => { egressProxy: { address: 'proxy.example.com:1080', tls: { - enabled: true, serverName: null, caCert: null, ignored: 'not sent', @@ -156,6 +154,26 @@ test('Sandbox.create omits null TLS options', async () => { }) }) +test.for([ + ['false', false], + ['null', null], +])( + 'Sandbox.create omits %s TLS options', + async ([, tls]: [string, unknown]) => { + await Sandbox.create('base', { + apiKey: TEST_API_KEY, + network: { + egressProxy: { + address: 'proxy.example.com:1080', + tls: tls as never, + }, + }, + }) + + expect(lastCreateBody?.network.egressProxy).not.toHaveProperty('tls') + } +) + test('Sandbox.create combines the egress proxy with allow and deny lists', async () => { await Sandbox.create('base', { apiKey: TEST_API_KEY, @@ -221,23 +239,56 @@ test.for([ ) test.for([ - ['a string', 'tls'], - ['an empty object', {}], - ['a non-boolean enabled value', { enabled: 'true' }], + [ + 'a string', + 'tls', + 'network egressProxy tls must be a boolean or an object.', + ], + ['an array', [], 'network egressProxy tls must be a boolean or an object.'], ])( - 'Sandbox.create rejects %s as the egress proxy TLS settings', - async ([, tls]: [string, unknown]) => { - await expect( - Sandbox.create('base', { - apiKey: TEST_API_KEY, - network: { - egressProxy: { - address: 'proxy.example.com:1080', - tls: tls as never, - }, + 'Sandbox.create rejects %s as egress proxy TLS settings', + async ([, tls, message]: [string, unknown, string]) => { + const error = await Sandbox.create('base', { + apiKey: TEST_API_KEY, + network: { + egressProxy: { + address: 'proxy.example.com:1080', + tls: tls as never, }, - }) - ).rejects.toBeInstanceOf(InvalidArgumentError) + }, + }).catch((error: unknown) => error) + + expect(error).toBeInstanceOf(InvalidArgumentError) + expect(error).toHaveProperty('message', message) + } +) + +test.for([ + [ + 'enabled true', + { enabled: true }, + "network egressProxy tls takes no 'enabled'; pass tls: true or tls: false instead.", + ], + [ + 'enabled false', + { enabled: false }, + "network egressProxy tls takes no 'enabled'; pass tls: true or tls: false instead.", + ], +])( + 'Sandbox.create rejects TLS options with %s', + async ([, tls, message]: [string, unknown, string]) => { + const error = await Sandbox.create('base', { + apiKey: TEST_API_KEY, + network: { + egressProxy: { + address: 'proxy.example.com:1080', + tls: tls as never, + }, + }, + }).catch((error: unknown) => error) + + expect(error).toBeInstanceOf(InvalidArgumentError) + expect(error).toHaveProperty('message', message) } ) @@ -298,7 +349,11 @@ test('updateNetwork sends egress proxy TLS options', async () => { { egressProxy: { address: 'proxy.example.com:1080', - tls: { enabled: true, serverName: 'proxy.example.com' }, + tls: { + serverName: 'proxy.example.com', + caCert: '-----BEGIN CERTIFICATE-----', + ignored: 'not sent', + } as never, }, }, { apiKey: TEST_API_KEY } @@ -307,11 +362,67 @@ test('updateNetwork sends egress proxy TLS options', async () => { expect(lastUpdateBody).toEqual({ egressProxy: { address: 'proxy.example.com:1080', - tls: { enabled: true, serverName: 'proxy.example.com' }, + tls: { + enabled: true, + serverName: 'proxy.example.com', + caCert: '-----BEGIN CERTIFICATE-----', + }, }, }) }) +test('updateNetwork sends true TLS settings as enabled', async () => { + await Sandbox.updateNetwork( + sandboxId, + { + egressProxy: { + address: 'proxy.example.com:1080', + tls: true, + }, + }, + { apiKey: TEST_API_KEY } + ) + + expect(lastUpdateBody?.egressProxy.tls).toEqual({ enabled: true }) +}) + +test('updateNetwork rejects TLS options with an enabled key', async () => { + const error = await Sandbox.updateNetwork( + sandboxId, + { + egressProxy: { + address: 'proxy.example.com:1080', + tls: { enabled: false } as never, + }, + }, + { apiKey: TEST_API_KEY } + ).catch((error: unknown) => error) + + expect(error).toBeInstanceOf(InvalidArgumentError) + expect(error).toHaveProperty( + 'message', + "network egressProxy tls takes no 'enabled'; pass tls: true or tls: false instead." + ) +}) + +test.for([ + ['false', false], + ['null', null], +])('updateNetwork omits %s TLS options', async ([, tls]: [string, unknown]) => { + await Sandbox.updateNetwork( + sandboxId, + { + egressProxy: { + address: 'proxy.example.com:1080', + tls: tls as never, + }, + }, + { apiKey: TEST_API_KEY } + ) + + expect(lastUpdateBody?.egressProxy).not.toHaveProperty('tls') +}) + test('an update without the egress proxy clears it', async () => { // The update replaces the whole configuration instead of merging into it, so // omitting the proxy stops tunneling rather than leaving it in place. @@ -350,7 +461,22 @@ test('getInfo reports egress proxy TLS without the CA certificate', async () => expect(info.network?.egressProxy).toEqual({ address: 'proxy.example.com:1080', - tls: { enabled: true, serverName: 'proxy.example.com' }, + tls: { serverName: 'proxy.example.com' }, + }) +}) + +test('getInfo omits egress proxy TLS when it is disabled', async () => { + sandboxNetwork = { + egressProxy: { + address: 'proxy.example.com:1080', + tls: { enabled: false, serverName: 'proxy.example.com' }, + }, + } + + const info = await Sandbox.getInfo(sandboxId, { apiKey: TEST_API_KEY }) + + expect(info.network?.egressProxy).toEqual({ + address: 'proxy.example.com:1080', }) }) diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 646bac8879..aaff962885 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -263,29 +263,19 @@ class SandboxNetworkSelectorContext: """ -class SandboxEgressProxyTLSOpts(TypedDict): +class SandboxEgressProxyTLSOpts(TypedDict, total=False): """ TLS settings for the connection to a BYOP SOCKS5 proxy. """ - enabled: bool - """ - Connect to the proxy over TLS. The SOCKS5 handshake, credentials, and - tunneled traffic run inside the TLS session; TLS protects only the hop to - the proxy. The certificate is always verified, TLS 1.2 or newer is - required, and failures never fall back to plaintext. - - When ``False``, ``server_name`` and ``ca_cert`` must not be set. - """ - - server_name: NotRequired[str] + server_name: str """ Name to verify the proxy certificate against and send as SNI, up to 253 characters. Defaults to the host part of ``address``; set this only when the certificate does not match the address. """ - ca_cert: NotRequired[str] + ca_cert: str """ One or more PEM CA certificates, up to 8192 bytes. This replaces the system trust store rather than adding to it. @@ -319,7 +309,8 @@ class SandboxEgressProxyOpts(TypedDict): }, ) - Enable TLS to the proxy with ``"tls": {"enabled": True}``. + Set ``"tls": True`` to enable TLS with the system trust store, or pass a + mapping to set a custom server name or CA certificate. """ address: str @@ -347,8 +338,17 @@ class SandboxEgressProxyOpts(TypedDict): :attr:`username`. """ - tls: NotRequired[SandboxEgressProxyTLSOpts] - """TLS settings for the connection to the SOCKS5 proxy.""" + tls: NotRequired[Union[bool, SandboxEgressProxyTLSOpts]] + """ + Connect to the proxy over TLS. The SOCKS5 handshake, credentials, and + tunneled traffic run inside the TLS session; TLS protects only the hop to + the proxy. The certificate is always verified, TLS 1.2 or newer is + required, and failures never fall back to plaintext. + + Set to ``True`` to use the system trust store and the host part of + ``address`` for SNI. Passing a mapping also enables TLS, with optional + custom ``server_name`` and ``ca_cert``. + """ class SandboxEgressProxyTLSInfo(TypedDict): @@ -357,9 +357,6 @@ class SandboxEgressProxyTLSInfo(TypedDict): returned by the API. """ - enabled: bool - """See :attr:`SandboxEgressProxyTLSOpts.enabled`.""" - server_name: NotRequired[str] """See :attr:`SandboxEgressProxyTLSOpts.server_name`.""" @@ -378,7 +375,7 @@ class SandboxEgressProxyInfo(TypedDict): """See :attr:`SandboxEgressProxyOpts.username`.""" tls: NotRequired[SandboxEgressProxyTLSInfo] - """TLS status for the connection to the proxy.""" + """Set when TLS to the proxy is on.""" class SandboxNetworkOpts(TypedDict): @@ -868,18 +865,27 @@ def _build_egress_proxy( body.password = egress_proxy["password"] tls = egress_proxy.get("tls") if tls is not None: - if not isinstance(tls, Mapping) or not isinstance(tls.get("enabled"), bool): + if isinstance(tls, bool): + if tls: + body.tls = ClientSandboxEgressProxyTLSConfig(enabled=True) + elif isinstance(tls, Mapping): + if "enabled" in tls: + raise InvalidArgumentException( + "network egress_proxy tls takes no 'enabled'; pass " + "tls=True or tls=False instead." + ) + + client_tls = ClientSandboxEgressProxyTLSConfig(enabled=True) + if tls.get("server_name") is not None: + client_tls.server_name = tls["server_name"] + if tls.get("ca_cert") is not None: + client_tls.ca_cert = tls["ca_cert"] + body.tls = client_tls + else: raise InvalidArgumentException( - "network egress_proxy tls must be a dict with a boolean 'enabled'." + "network egress_proxy tls must be a bool or a dict." ) - client_tls = ClientSandboxEgressProxyTLSConfig(enabled=tls["enabled"]) - if tls.get("server_name") is not None: - client_tls.server_name = tls["server_name"] - if tls.get("ca_cert") is not None: - client_tls.ca_cert = tls["ca_cert"] - body.tls = client_tls - return body @@ -1118,8 +1124,11 @@ def _from_client_egress_proxy( result: SandboxEgressProxyInfo = {"address": egress_proxy.address} if not isinstance(egress_proxy.username, Unset): result["username"] = egress_proxy.username - if isinstance(egress_proxy.tls, ClientSandboxEgressProxyTLSConfig): - tls: SandboxEgressProxyTLSInfo = {"enabled": egress_proxy.tls.enabled} + if ( + isinstance(egress_proxy.tls, ClientSandboxEgressProxyTLSConfig) + and egress_proxy.tls.enabled is True + ): + tls: SandboxEgressProxyTLSInfo = {} if not isinstance(egress_proxy.tls.server_name, Unset): tls["server_name"] = egress_proxy.tls.server_name result["tls"] = tls diff --git a/packages/python-sdk/tests/shared/sandbox/test_egress_proxy.py b/packages/python-sdk/tests/shared/sandbox/test_egress_proxy.py index 449995253f..58351ad918 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_egress_proxy.py +++ b/packages/python-sdk/tests/shared/sandbox/test_egress_proxy.py @@ -45,7 +45,6 @@ def test_create_sends_an_address_only_egress_proxy(): def test_create_sends_egress_proxy_tls_options(): tls: SandboxEgressProxyTLSOpts = { - "enabled": True, "server_name": "proxy.example.com", "ca_cert": "-----BEGIN CERTIFICATE-----", } @@ -65,13 +64,13 @@ def test_create_sends_egress_proxy_tls_options(): @pytest.mark.parametrize( - "tls", + ("tls", "wire_tls"), [ - pytest.param({"enabled": True}, id="enabled"), - pytest.param({"enabled": False}, id="disabled"), + pytest.param(True, {"enabled": True}, id="true"), + pytest.param({}, {"enabled": True}, id="empty-options"), ], ) -def test_create_sends_tls_settings_with_enabled_only(tls): +def test_create_sends_tls_settings(tls, wire_tls): body = build_network_config( {"egress_proxy": {"address": "proxy.example.com:1080", "tls": tls}} ) @@ -79,7 +78,7 @@ def test_create_sends_tls_settings_with_enabled_only(tls): assert body is not None assert body["egress_proxy"].to_dict() == { "address": "proxy.example.com:1080", - "tls": tls, + "tls": wire_tls, } @@ -90,7 +89,6 @@ def test_create_omits_none_tls_options_and_strips_unknown_keys(): "egress_proxy": { "address": "proxy.example.com:1080", "tls": { - "enabled": True, "server_name": None, "ca_cert": None, "protocol": "tls", @@ -108,6 +106,25 @@ def test_create_omits_none_tls_options_and_strips_unknown_keys(): } +@pytest.mark.parametrize( + "tls", + [ + pytest.param(False, id="false"), + pytest.param(None, id="none"), + ], +) +def test_create_omits_false_or_none_tls_options(tls): + body = build_network_config( + cast( + Any, + {"egress_proxy": {"address": "proxy.example.com:1080", "tls": tls}}, + ) + ) + + assert body is not None + assert "tls" not in body["egress_proxy"].to_dict() + + def test_create_combines_the_egress_proxy_with_allow_and_deny_lists(): network: SandboxNetworkOpts = { "allow_out": ["api.example.com"], @@ -157,14 +174,38 @@ def test_create_rejects_a_malformed_egress_proxy(egress_proxy): "tls", [ pytest.param("tls", id="string"), - pytest.param({}, id="missing-enabled"), - pytest.param({"enabled": "true"}, id="non-boolean-enabled"), + pytest.param([], id="array"), ], ) def test_create_rejects_malformed_egress_proxy_tls(tls): with pytest.raises( InvalidArgumentException, - match="network egress_proxy tls must be a dict with a boolean 'enabled'", + match="network egress_proxy tls must be a bool or a dict\\.", + ): + build_network_config( + cast( + Any, + { + "egress_proxy": { + "address": "proxy.example.com:1080", + "tls": tls, + } + }, + ) + ) + + +@pytest.mark.parametrize( + "tls", + [ + pytest.param({"enabled": False}, id="enabled-false"), + pytest.param({"enabled": True}, id="enabled-true"), + ], +) +def test_create_rejects_egress_proxy_tls_enabled_key(tls): + with pytest.raises( + InvalidArgumentException, + match="network egress_proxy tls takes no 'enabled'; pass tls=True or tls=False instead\\.", ): build_network_config( cast( @@ -236,7 +277,6 @@ def test_update_sends_egress_proxy_tls_options(): "egress_proxy": { "address": "proxy.example.com:1080", "tls": { - "enabled": True, "server_name": "proxy.example.com", "ca_cert": "-----BEGIN CERTIFICATE-----", }, @@ -255,6 +295,70 @@ def test_update_sends_egress_proxy_tls_options(): } +def test_update_sends_true_tls_as_enabled(): + network: SandboxNetworkUpdate = { + "egress_proxy": { + "address": "proxy.example.com:1080", + "tls": True, + }, + } + + assert build_network_update_body(network).to_dict() == { + "egressProxy": { + "address": "proxy.example.com:1080", + "tls": {"enabled": True}, + }, + } + + +@pytest.mark.parametrize( + "tls", + [ + pytest.param({"enabled": False}, id="enabled-false"), + pytest.param({"enabled": True}, id="enabled-true"), + ], +) +def test_update_rejects_egress_proxy_tls_enabled_key(tls): + with pytest.raises( + InvalidArgumentException, + match="network egress_proxy tls takes no 'enabled'; pass tls=True or tls=False instead\\.", + ): + build_network_update_body( + cast( + Any, + { + "egress_proxy": { + "address": "proxy.example.com:1080", + "tls": tls, + } + }, + ) + ) + + +@pytest.mark.parametrize( + "tls", + [ + pytest.param(False, id="false"), + pytest.param(None, id="none"), + ], +) +def test_update_omits_false_or_none_tls_options(tls): + network = cast( + Any, + { + "egress_proxy": { + "address": "proxy.example.com:1080", + "tls": tls, + }, + }, + ) + + assert build_network_update_body(network).to_dict() == { + "egressProxy": {"address": "proxy.example.com:1080"}, + } + + def test_update_without_the_egress_proxy_clears_it(): # The update replaces the whole configuration instead of merging into it, so # omitting the proxy stops tunneling rather than leaving it in place. @@ -300,10 +404,27 @@ def test_get_info_reports_egress_proxy_tls_without_the_ca_certificate(): assert info is not None assert info["egress_proxy"] == { "address": "proxy.example.com:1080", - "tls": {"enabled": True, "server_name": "proxy.example.com"}, + "tls": {"server_name": "proxy.example.com"}, } +def test_get_info_omits_egress_proxy_tls_when_disabled(): + info = from_client_network_config( + SandboxNetworkConfig( + egress_proxy=ClientSandboxEgressProxyConfig( + address="proxy.example.com:1080", + tls=ClientSandboxEgressProxyTLSConfig( + enabled=False, + server_name="proxy.example.com", + ), + ), + ) + ) + + assert info is not None + assert info["egress_proxy"] == {"address": "proxy.example.com:1080"} + + @pytest.mark.parametrize( "egress_proxy", [