From 1c4dc53bca14822ab236ac1fc4277f9dd957a86c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:25:15 +0000 Subject: [PATCH 01/10] Remove SDK-side defaults from API request payloads Co-Authored-By: mish@e2b.dev --- .changeset/olive-poets-hammer.md | 6 + packages/js-sdk/src/sandbox/index.ts | 8 +- packages/js-sdk/src/sandbox/sandboxApi.ts | 35 ++-- packages/js-sdk/src/template/buildApi.ts | 4 +- packages/js-sdk/src/template/index.ts | 4 +- packages/js-sdk/src/template/types.ts | 4 +- .../js-sdk/tests/sandbox/apiDefaults.test.ts | 105 ++++++++++ packages/python-sdk/e2b/sandbox_async/main.py | 46 ++--- .../e2b/sandbox_async/sandbox_api.py | 34 ++-- packages/python-sdk/e2b/sandbox_sync/main.py | 46 ++--- .../e2b/sandbox_sync/sandbox_api.py | 34 ++-- .../e2b/template_async/build_api.py | 8 +- .../python-sdk/e2b/template_async/main.py | 24 +-- .../python-sdk/e2b/template_sync/build_api.py | 8 +- packages/python-sdk/e2b/template_sync/main.py | 24 +-- .../tests/shared/sandbox/test_api_defaults.py | 179 ++++++++++++++++++ 16 files changed, 431 insertions(+), 138 deletions(-) create mode 100644 .changeset/olive-poets-hammer.md create mode 100644 packages/js-sdk/tests/sandbox/apiDefaults.test.ts create mode 100644 packages/python-sdk/tests/shared/sandbox/test_api_defaults.py diff --git a/.changeset/olive-poets-hammer.md b/.changeset/olive-poets-hammer.md new file mode 100644 index 0000000000..053a52d8e2 --- /dev/null +++ b/.changeset/olive-poets-hammer.md @@ -0,0 +1,6 @@ +--- +'e2b': minor +'@e2b/python-sdk': minor +--- + +Remove SDK-side defaults from API request payloads so the API defaults apply when options are omitted. Sandbox create/fork no longer preset a 5-minute timeout, fork no longer presets `count: 1`, create no longer presets `secure` and `allow_internet_access`, pause no longer presets keeping memory, and template builds no longer preset CPU/memory. Explicitly provided values are still sent unchanged. diff --git a/packages/js-sdk/src/sandbox/index.ts b/packages/js-sdk/src/sandbox/index.ts index f22de94e07..26f30a4e74 100644 --- a/packages/js-sdk/src/sandbox/index.ts +++ b/packages/js-sdk/src/sandbox/index.ts @@ -3,7 +3,6 @@ import { createConnectTransport } from '@connectrpc/connect-web' import { ConnectionConfig, ConnectionOpts, - DEFAULT_SANDBOX_TIMEOUT_MS, defaultUsername, Username, } from '../connectionConfig' @@ -76,7 +75,6 @@ export interface SandboxUrlOpts { export class Sandbox extends SandboxApi { protected static readonly defaultTemplate: string = 'base' protected static readonly defaultMcpTemplate: string = 'mcp-gateway' - protected static readonly defaultSandboxTimeoutMs = DEFAULT_SANDBOX_TIMEOUT_MS /** * Module for interacting with the sandbox filesystem @@ -317,7 +315,7 @@ export class Sandbox extends SandboxApi { const sandboxInfo = await this.createSandbox( template, - apiOpts?.timeoutMs ?? this.defaultSandboxTimeoutMs, + apiOpts?.timeoutMs, apiOpts ) @@ -433,8 +431,8 @@ export class Sandbox extends SandboxApi { const results = await this.forkSandbox( sandboxId, - apiOpts?.timeoutMs ?? this.defaultSandboxTimeoutMs, - apiOpts?.count ?? 1, + apiOpts?.timeoutMs, + apiOpts?.count, apiOpts ) diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 3af6b1bace..37730597bd 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -514,7 +514,7 @@ export interface SandboxPauseOpts extends SandboxApiOpts { * persisted (a filesystem-only snapshot); resuming such a sandbox cold-boots * (reboots) it from disk, losing running processes and open connections. * - * @default true + * When not set, the API default (currently a full memory snapshot) applies. */ keepMemory?: boolean } @@ -530,7 +530,7 @@ export interface SandboxForkOpts extends ConnectionOpts { * regardless of count. Each fork succeeds or fails independently; the * outcome of each is reported in its entry of the returned array. * - * @default 1 + * When not set, the API default (currently 1) applies. */ count?: number @@ -538,7 +538,7 @@ export interface SandboxForkOpts extends ConnectionOpts { * Timeout for the forked sandboxes in **milliseconds**. * Maximum time a sandbox can be kept alive is 24 hours (86_400_000 milliseconds) for Pro users and 1 hour (3_600_000 milliseconds) for Hobby users. * - * @default 300_000 // 5 minutes + * When not set, the API default timeout applies. */ timeoutMs?: number } @@ -591,21 +591,21 @@ export interface SandboxOpts extends ConnectionOpts { * Timeout for the sandbox in **milliseconds**. * Maximum time a sandbox can be kept alive is 24 hours (86_400_000 milliseconds) for Pro users and 1 hour (3_600_000 milliseconds) for Hobby users. * - * @default 300_000 // 5 minutes + * When not set, the API default timeout applies. */ timeoutMs?: number /** * Secure all traffic coming to the sandbox controller with auth token * - * @default true + * When not set, the API default (currently enabled) applies. */ secure?: boolean /** * Allow sandbox to access the internet. If set to `False`, it works the same as setting network `denyOut` to `[0.0.0.0/0]`. * - * @default true + * When not set, the API default (currently allowed) applies. */ allowInternetAccess?: boolean @@ -714,8 +714,7 @@ export interface SandboxListOpts extends Omit { /** * Sort order of the list of sandboxes by start time, applied across the * whole result set before pagination (not within a page). - * - * @default 'desc' + * When not set, the API default (currently `'desc'`, newest first) applies. */ order?: SandboxListOrder @@ -1475,7 +1474,7 @@ export class SandboxApi extends ClientFactory { }, }, body: { - memory: apiOpts?.keepMemory ?? true, + memory: apiOpts?.keepMemory, }, signal: config.getSignal(apiOpts?.requestTimeoutMs, apiOpts?.signal), }) @@ -1602,7 +1601,7 @@ export class SandboxApi extends ClientFactory { protected static async createSandbox( template: string, - timeoutMs: number, + timeoutMs?: number, opts?: SandboxOpts ) { const apiOpts = this.resolveOpts(opts) @@ -1656,9 +1655,10 @@ export class SandboxApi extends ClientFactory { metadata: opts?.metadata, mcp: opts?.mcp as Record | undefined, envVars: opts?.envs, - timeout: timeoutToSeconds(timeoutMs), - secure: opts?.secure ?? true, - allow_internet_access: opts?.allowInternetAccess ?? true, + timeout: + timeoutMs === undefined ? undefined : timeoutToSeconds(timeoutMs), + secure: opts?.secure, + allow_internet_access: opts?.allowInternetAccess, network: buildNetworkBody(opts?.network, iam), iam, autoPause: onTimeoutConfigured ? action === 'pause' : undefined, @@ -1705,11 +1705,11 @@ export class SandboxApi extends ClientFactory { protected static async forkSandbox( sandboxId: string, - timeoutMs: number, - count: number, + timeoutMs?: number, + count?: number, opts?: SandboxApiOpts ): Promise { - if (count < 1) { + if (count !== undefined && count < 1) { throw new InvalidArgumentError('count must be at least 1') } @@ -1724,7 +1724,8 @@ export class SandboxApi extends ClientFactory { }, }, body: { - timeout: timeoutToSeconds(timeoutMs), + timeout: + timeoutMs === undefined ? undefined : timeoutToSeconds(timeoutMs), count, }, signal: config.getSignal(apiOpts?.requestTimeoutMs, apiOpts?.signal), diff --git a/packages/js-sdk/src/template/buildApi.ts b/packages/js-sdk/src/template/buildApi.ts index f79321166e..11153db5aa 100644 --- a/packages/js-sdk/src/template/buildApi.ts +++ b/packages/js-sdk/src/template/buildApi.ts @@ -19,8 +19,8 @@ import { type RequestBuildInput = { name: string tags?: string[] - cpuCount: number - memoryMB: number + cpuCount?: number + memoryMB?: number } type GetFileUploadLinkInput = { diff --git a/packages/js-sdk/src/template/index.ts b/packages/js-sdk/src/template/index.ts index 1b712c18ae..b8611a3f03 100644 --- a/packages/js-sdk/src/template/index.ts +++ b/packages/js-sdk/src/template/index.ts @@ -1076,8 +1076,8 @@ export class TemplateBase { name, tags: options.tags, - cpuCount: options.cpuCount ?? 2, - memoryMB: options.memoryMB ?? 1024, + cpuCount: options.cpuCount, + memoryMB: options.memoryMB, }, config.getSignal(undefined, options.signal) ) diff --git a/packages/js-sdk/src/template/types.ts b/packages/js-sdk/src/template/types.ts index 87ff44441d..8a65112d31 100644 --- a/packages/js-sdk/src/template/types.ts +++ b/packages/js-sdk/src/template/types.ts @@ -34,12 +34,12 @@ export type BasicBuildOptions = { tags?: string[] /** * Number of CPUs allocated to the sandbox. - * @default 2 + * When not set, the API default applies. */ cpuCount?: number /** * Amount of memory in MB allocated to the sandbox. - * @default 1024 + * When not set, the API default applies. */ memoryMB?: number /** diff --git a/packages/js-sdk/tests/sandbox/apiDefaults.test.ts b/packages/js-sdk/tests/sandbox/apiDefaults.test.ts new file mode 100644 index 0000000000..098e3b2d10 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/apiDefaults.test.ts @@ -0,0 +1,105 @@ +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 lastCreateBody: Record | undefined +let lastForkBody: Record | undefined +let lastPauseBody: Record | undefined + +const server = setupServer( + http.post(apiUrl('/sandboxes'), async ({ request }) => { + lastCreateBody = (await request.json()) as Record + return HttpResponse.json({ + sandboxID: 'test-sandbox-id', + templateID: 'base', + envdVersion: '0.2.4', + }) + }), + http.post(apiUrl('/sandboxes/:sandboxID/fork'), async ({ request }) => { + lastForkBody = (await request.json()) as Record + return HttpResponse.json([ + { + sandbox: { + sandboxID: 'forked-sandbox-id', + templateID: 'base', + envdVersion: '0.2.4', + }, + }, + ]) + }), + http.post(apiUrl('/sandboxes/:sandboxID/pause'), async ({ request }) => { + lastPauseBody = (await request.json()) as Record + return new HttpResponse(null, { status: 204 }) + }) +) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + +afterAll(() => server.close()) + +afterEach(() => { + lastCreateBody = undefined + lastForkBody = undefined + lastPauseBody = undefined + server.resetHandlers() +}) + +test('Sandbox.create omits timeout, secure and allow_internet_access when unset', async () => { + await Sandbox.create('base', { apiKey: TEST_API_KEY }) + + expect(lastCreateBody).toBeDefined() + expect(lastCreateBody).not.toHaveProperty('timeout') + expect(lastCreateBody).not.toHaveProperty('secure') + expect(lastCreateBody).not.toHaveProperty('allow_internet_access') +}) + +test('Sandbox.create sends explicit timeout, secure and allow_internet_access', async () => { + await Sandbox.create('base', { + apiKey: TEST_API_KEY, + timeoutMs: 60_000, + secure: false, + allowInternetAccess: false, + }) + + expect(lastCreateBody?.timeout).toBe(60) + expect(lastCreateBody?.secure).toBe(false) + expect(lastCreateBody?.allow_internet_access).toBe(false) +}) + +test('Sandbox.fork omits timeout and count when unset', async () => { + await Sandbox.fork('test-sandbox-id', { apiKey: TEST_API_KEY }) + + expect(lastForkBody).toBeDefined() + expect(lastForkBody).not.toHaveProperty('timeout') + expect(lastForkBody).not.toHaveProperty('count') +}) + +test('Sandbox.fork sends explicit timeout and count', async () => { + await Sandbox.fork('test-sandbox-id', { + apiKey: TEST_API_KEY, + timeoutMs: 60_000, + count: 2, + }) + + expect(lastForkBody?.timeout).toBe(60) + expect(lastForkBody?.count).toBe(2) +}) + +test('Sandbox.pause omits memory when keepMemory is unset', async () => { + await Sandbox.pause('test-sandbox-id', { apiKey: TEST_API_KEY }) + + expect(lastPauseBody).toBeDefined() + expect(lastPauseBody).not.toHaveProperty('memory') +}) + +test('Sandbox.pause sends an explicit keepMemory', async () => { + await Sandbox.pause('test-sandbox-id', { + apiKey: TEST_API_KEY, + keepMemory: false, + }) + + expect(lastPauseBody?.memory).toBe(false) +}) diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 3196008757..2b0ed8a50b 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -172,8 +172,8 @@ async def create( timeout: Optional[int] = None, metadata: Optional[Dict[str, str]] = None, envs: Optional[Dict[str, str]] = None, - secure: bool = True, - allow_internet_access: bool = True, + secure: Optional[bool] = None, + allow_internet_access: Optional[bool] = None, mcp: Optional[McpServer] = None, network: Optional[SandboxNetworkOpts] = None, iam: Optional[SandboxIamOpts] = None, @@ -188,11 +188,11 @@ async def create( By default, the sandbox is created from the default `base` sandbox template. :param template: Sandbox template name or ID - :param timeout: Timeout for the sandbox in **seconds**, default to 300 seconds. The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + :param timeout: Timeout for the sandbox in **seconds**. When not set, the API default timeout applies. The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. :param metadata: Custom metadata for the sandbox :param envs: Custom environment variables for the sandbox - :param secure: Envd is secured with access token and cannot be used without it, defaults to `True`. - :param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`. + :param secure: Envd is secured with access token and cannot be used without it. When not set, the API default (currently enabled) applies. + :param allow_internet_access: Allow sandbox to access the internet. When not set, the API default (currently allowed) applies. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`. :param mcp: MCP server to enable in the sandbox :param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``; a rule's ``transform`` may be a callable receiving a :class:`SandboxNetworkTransformContext` of placeholder strings (``ctx.iam.tokens[name]``). :param iam: Sandbox workload identity configuration. A non-empty ``tokens`` map enables workload identity for the sandbox; token definitions can be created with :meth:`Secret.iam_token`. Example: ``{"tokens": {"aws": Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID")}}``. Registered tokens are exposed to ``network.rules`` ``transform`` callables as ``ctx.iam.tokens[name]`` placeholders, which the egress proxy resolves per request @@ -377,8 +377,8 @@ async def fork( error codes map to the same exception classes as other API errors (e.g. 429 to `RateLimitException`). - :param timeout: Timeout for the forked sandboxes in **seconds**, defaults to 300 seconds - :param count: Number of forked sandboxes to create, defaults to 1 + :param timeout: Timeout for the forked sandboxes in **seconds**. When not set, the API default timeout applies + :param count: Number of forked sandboxes to create. When not set, the API default (currently 1) applies :return: List with one entry per requested fork — a sandbox instance or an exception @@ -416,8 +416,8 @@ async def fork( (e.g. 429 to `RateLimitException`). :param sandbox_id: Sandbox ID - :param timeout: Timeout for the forked sandboxes in **seconds**, defaults to 300 seconds - :param count: Number of forked sandboxes to create, defaults to 1 + :param timeout: Timeout for the forked sandboxes in **seconds**. When not set, the API default timeout applies + :param count: Number of forked sandboxes to create. When not set, the API default (currently 1) applies :param logger: Logger used for request and response logging for the forked sandboxes. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: List with one entry per requested fork — a sandbox instance or an exception @@ -453,8 +453,8 @@ async def fork( error codes map to the same exception classes as other API errors (e.g. 429 to `RateLimitException`). - :param timeout: Timeout for the forked sandboxes in **seconds**, defaults to 300 seconds - :param count: Number of forked sandboxes to create, defaults to 1 + :param timeout: Timeout for the forked sandboxes in **seconds**. When not set, the API default timeout applies + :param count: Number of forked sandboxes to create. When not set, the API default (currently 1) applies :return: List with one entry per requested fork — a sandbox instance or an exception @@ -749,13 +749,13 @@ async def get_metrics( @overload async def pause( self, - keep_memory: bool = True, + keep_memory: Optional[bool] = None, **opts: Unpack[ApiParams], ) -> bool: """ Pause the sandbox. - :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. Defaults to `True`. + :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. When not set, the API default (currently a full memory snapshot) applies. :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ @@ -765,14 +765,14 @@ async def pause( @staticmethod async def pause( sandbox_id: str, - keep_memory: bool = True, + keep_memory: Optional[bool] = None, **opts: Unpack[ApiParams], ) -> bool: """ Pause the sandbox specified by sandbox ID. :param sandbox_id: Sandbox ID - :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. Defaults to `True`. + :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. When not set, the API default (currently a full memory snapshot) applies. :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ @@ -781,13 +781,13 @@ async def pause( @class_method_variant("_cls_pause") async def pause( self, - keep_memory: bool = True, + keep_memory: Optional[bool] = None, **opts: Unpack[ApiParams], ) -> bool: """ Pause the sandbox. - :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk, losing running processes and open connections. Defaults to `True` (full memory snapshot). + :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk, losing running processes and open connections. When not set, the API default (currently a full memory snapshot) applies. :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ @@ -801,7 +801,7 @@ async def pause( @overload async def beta_pause( self, - keep_memory: bool = True, + keep_memory: Optional[bool] = None, **opts: Unpack[ApiParams], ) -> bool: ... @@ -809,14 +809,14 @@ async def beta_pause( @staticmethod async def beta_pause( sandbox_id: str, - keep_memory: bool = True, + keep_memory: Optional[bool] = None, **opts: Unpack[ApiParams], ) -> bool: ... @class_method_variant("_cls_pause") async def beta_pause( self, - keep_memory: bool = True, + keep_memory: Optional[bool] = None, **opts: Unpack[ApiParams], ) -> bool: """ @@ -1110,8 +1110,8 @@ async def _create( timeout: Optional[int], metadata: Optional[Dict[str, str]], envs: Optional[Dict[str, str]], - secure: bool, - allow_internet_access: bool, + secure: Optional[bool], + allow_internet_access: Optional[bool], mcp: Optional[McpServer] = None, network: Optional[SandboxNetworkOpts] = None, iam: Optional[SandboxIamOpts] = None, @@ -1133,7 +1133,7 @@ async def _create( else: response = await SandboxApi._create_sandbox( template=template or cls.default_template, - timeout=timeout or cls.default_sandbox_timeout, + timeout=timeout, metadata=metadata, env_vars=envs, secure=secure, diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index b6a208d6a8..e66b1f603b 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -84,7 +84,7 @@ def list( :param query: Filter the list of sandboxes by metadata, state, start time, or template, e.g. `SandboxQuery(metadata={"key": "value"})` or `SandboxQuery(state=[SandboxState.RUNNING])` :param limit: Maximum number of sandboxes to return per page :param next_token: Token for pagination - :param order: Sort order of the list of sandboxes by start time, applied across the whole result set before pagination (not within a page), defaults to `"desc"` (newest first) + :param order: Sort order of the list of sandboxes by start time, applied across the whole result set before pagination (not within a page), when not set, the API default (currently `"desc"`, newest first) applies :return: An `AsyncSandboxPaginator` that yields pages of sandboxes (running and paused by default). Iterate pages via `await paginator.next_items()` while `paginator.has_next` is True. """ @@ -208,11 +208,11 @@ async def _cls_update_network( async def _create_sandbox( cls, template: str, - timeout: int, - allow_internet_access: bool, + timeout: Optional[int], + allow_internet_access: Optional[bool], metadata: Optional[Dict[str, str]], env_vars: Optional[Dict[str, str]], - secure: bool, + secure: Optional[bool], mcp: Optional[McpServer] = None, network: Optional[SandboxNetworkOpts] = None, iam: Optional[SandboxIamOpts] = None, @@ -236,11 +236,13 @@ async def _create_sandbox( auto_pause_memory=lifecycle_body.auto_pause_memory, auto_resume=lifecycle_body.auto_resume, metadata=metadata or {}, - timeout=timeout, + timeout=timeout if timeout is not None else UNSET, env_vars=env_vars or {}, mcp=cast(Any, mcp) or UNSET, - secure=secure, - allow_internet_access=allow_internet_access, + secure=secure if secure is not None else UNSET, + allow_internet_access=( + allow_internet_access if allow_internet_access is not None else UNSET + ), network=SandboxNetworkConfig(**network_body) if network_body else UNSET, iam=iam_body or UNSET, volume_mounts=volume_mounts if volume_mounts else UNSET, @@ -405,7 +407,7 @@ async def _cls_delete_snapshot( async def _cls_pause( cls, sandbox_id: str, - keep_memory: bool = True, + keep_memory: Optional[bool] = None, **opts: Unpack[ApiParams], ) -> bool: config = ConnectionConfig(**cls._resolve_api_params(**opts)) @@ -414,7 +416,9 @@ async def _cls_pause( res = await post_sandboxes_sandbox_id_pause.asyncio_detailed( sandbox_id, client=api_client, - body=SandboxPauseRequest(memory=keep_memory), + body=SandboxPauseRequest( + memory=keep_memory if keep_memory is not None else UNSET + ), ) if res.status_code == 404: @@ -442,12 +446,7 @@ async def _cls_fork( logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> List[Union[SandboxCreateResponse, Exception]]: - timeout = ( - timeout if timeout is not None else SandboxBase.default_sandbox_timeout - ) - count = count if count is not None else 1 - - if count < 1: + if count is not None and count < 1: raise InvalidArgumentException("count must be at least 1") config = ConnectionConfig(logger=logger, **cls._resolve_api_params(**opts)) @@ -456,7 +455,10 @@ async def _cls_fork( res = await post_sandboxes_sandbox_id_fork.asyncio_detailed( sandbox_id, client=api_client, - body=SandboxForkRequest(timeout=timeout, count=count), + body=SandboxForkRequest( + timeout=timeout if timeout is not None else UNSET, + count=count if count is not None 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 2ebda95aab..f68db89b18 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -168,8 +168,8 @@ def create( timeout: Optional[int] = None, metadata: Optional[Dict[str, str]] = None, envs: Optional[Dict[str, str]] = None, - secure: bool = True, - allow_internet_access: bool = True, + secure: Optional[bool] = None, + allow_internet_access: Optional[bool] = None, mcp: Optional[McpServer] = None, network: Optional[SandboxNetworkOpts] = None, iam: Optional[SandboxIamOpts] = None, @@ -184,11 +184,11 @@ def create( By default, the sandbox is created from the default `base` sandbox template. :param template: Sandbox template name or ID - :param timeout: Timeout for the sandbox in **seconds**, default to 300 seconds. The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + :param timeout: Timeout for the sandbox in **seconds**. When not set, the API default timeout applies. The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. :param metadata: Custom metadata for the sandbox :param envs: Custom environment variables for the sandbox - :param secure: Envd is secured with access token and cannot be used without it, defaults to `True`. - :param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`. + :param secure: Envd is secured with access token and cannot be used without it. When not set, the API default (currently enabled) applies. + :param allow_internet_access: Allow sandbox to access the internet. When not set, the API default (currently allowed) applies. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`. :param mcp: MCP server to enable in the sandbox :param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``; a rule's ``transform`` may be a callable receiving a :class:`SandboxNetworkTransformContext` of placeholder strings (``ctx.iam.tokens[name]``). :param iam: Sandbox workload identity configuration. A non-empty ``tokens`` map enables workload identity for the sandbox; token definitions can be created with :meth:`Secret.iam_token`. Example: ``{"tokens": {"aws": Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID")}}``. Registered tokens are exposed to ``network.rules`` ``transform`` callables as ``ctx.iam.tokens[name]`` placeholders, which the egress proxy resolves per request @@ -372,8 +372,8 @@ def fork( error codes map to the same exception classes as other API errors (e.g. 429 to `RateLimitException`). - :param timeout: Timeout for the forked sandboxes in **seconds**, defaults to 300 seconds - :param count: Number of forked sandboxes to create, defaults to 1 + :param timeout: Timeout for the forked sandboxes in **seconds**. When not set, the API default timeout applies + :param count: Number of forked sandboxes to create. When not set, the API default (currently 1) applies :return: List with one entry per requested fork — a sandbox instance or an exception @@ -411,8 +411,8 @@ def fork( (e.g. 429 to `RateLimitException`). :param sandbox_id: Sandbox ID - :param timeout: Timeout for the forked sandboxes in **seconds**, defaults to 300 seconds - :param count: Number of forked sandboxes to create, defaults to 1 + :param timeout: Timeout for the forked sandboxes in **seconds**. When not set, the API default timeout applies + :param count: Number of forked sandboxes to create. When not set, the API default (currently 1) applies :param logger: Logger used for request and response logging for the forked sandboxes. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: List with one entry per requested fork — a sandbox instance or an exception @@ -448,8 +448,8 @@ def fork( error codes map to the same exception classes as other API errors (e.g. 429 to `RateLimitException`). - :param timeout: Timeout for the forked sandboxes in **seconds**, defaults to 300 seconds - :param count: Number of forked sandboxes to create, defaults to 1 + :param timeout: Timeout for the forked sandboxes in **seconds**. When not set, the API default timeout applies + :param count: Number of forked sandboxes to create. When not set, the API default (currently 1) applies :return: List with one entry per requested fork — a sandbox instance or an exception @@ -747,13 +747,13 @@ def get_metrics( @overload def pause( self, - keep_memory: bool = True, + keep_memory: Optional[bool] = None, **opts: Unpack[ApiParams], ) -> bool: """ Pause the sandbox. - :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. Defaults to `True`. + :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. When not set, the API default (currently a full memory snapshot) applies. :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ @@ -763,14 +763,14 @@ def pause( @staticmethod def pause( sandbox_id: str, - keep_memory: bool = True, + keep_memory: Optional[bool] = None, **opts: Unpack[ApiParams], ) -> bool: """ Pause the sandbox specified by sandbox ID. :param sandbox_id: Sandbox ID - :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. Defaults to `True`. + :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. When not set, the API default (currently a full memory snapshot) applies. :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ @@ -779,13 +779,13 @@ def pause( @class_method_variant("_cls_pause") def pause( self, - keep_memory: bool = True, + keep_memory: Optional[bool] = None, **opts: Unpack[ApiParams], ) -> bool: """ Pause the sandbox. - :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk, losing running processes and open connections. Defaults to `True` (full memory snapshot). + :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk, losing running processes and open connections. When not set, the API default (currently a full memory snapshot) applies. :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ @@ -799,7 +799,7 @@ def pause( @overload def beta_pause( self, - keep_memory: bool = True, + keep_memory: Optional[bool] = None, **opts: Unpack[ApiParams], ) -> bool: ... @@ -807,14 +807,14 @@ def beta_pause( @staticmethod def beta_pause( sandbox_id: str, - keep_memory: bool = True, + keep_memory: Optional[bool] = None, **opts: Unpack[ApiParams], ) -> bool: ... @class_method_variant("_cls_pause") def beta_pause( self, - keep_memory: bool = True, + keep_memory: Optional[bool] = None, **opts: Unpack[ApiParams], ) -> bool: """ @@ -1106,8 +1106,8 @@ def _create( timeout: Optional[int], metadata: Optional[Dict[str, str]], envs: Optional[Dict[str, str]], - secure: bool, - allow_internet_access: bool, + secure: Optional[bool], + allow_internet_access: Optional[bool], mcp: Optional[McpServer] = None, network: Optional[SandboxNetworkOpts] = None, iam: Optional[SandboxIamOpts] = None, @@ -1129,7 +1129,7 @@ def _create( else: response = SandboxApi._create_sandbox( template=template or cls.default_template, - timeout=timeout or cls.default_sandbox_timeout, + timeout=timeout, metadata=metadata, env_vars=envs, secure=secure, diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index ff8dc4a3e8..84fc8b8688 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -83,7 +83,7 @@ def list( :param query: Filter the list of sandboxes by metadata, state, start time, or template, e.g. `SandboxQuery(metadata={"key": "value"})` or `SandboxQuery(state=[SandboxState.RUNNING])` :param limit: Maximum number of sandboxes to return per page :param next_token: Token for pagination - :param order: Sort order of the list of sandboxes by start time, applied across the whole result set before pagination (not within a page), defaults to `"desc"` (newest first) + :param order: Sort order of the list of sandboxes by start time, applied across the whole result set before pagination (not within a page), when not set, the API default (currently `"desc"`, newest first) applies :return: A `SandboxPaginator` that yields pages of sandboxes (running and paused by default). Iterate pages via `paginator.next_items()` while `paginator.has_next` is True. """ @@ -207,11 +207,11 @@ def _cls_update_network( def _create_sandbox( cls, template: str, - timeout: int, - allow_internet_access: bool, + timeout: Optional[int], + allow_internet_access: Optional[bool], metadata: Optional[Dict[str, str]], env_vars: Optional[Dict[str, str]], - secure: bool, + secure: Optional[bool], mcp: Optional[McpServer] = None, network: Optional[SandboxNetworkOpts] = None, iam: Optional[SandboxIamOpts] = None, @@ -235,11 +235,13 @@ def _create_sandbox( auto_pause_memory=lifecycle_body.auto_pause_memory, auto_resume=lifecycle_body.auto_resume, metadata=metadata or {}, - timeout=timeout, + timeout=timeout if timeout is not None else UNSET, env_vars=env_vars or {}, mcp=cast(Any, mcp) or UNSET, - secure=secure, - allow_internet_access=allow_internet_access, + secure=secure if secure is not None else UNSET, + allow_internet_access=( + allow_internet_access if allow_internet_access is not None else UNSET + ), network=SandboxNetworkConfig(**network_body) if network_body else UNSET, iam=iam_body or UNSET, volume_mounts=volume_mounts if volume_mounts else UNSET, @@ -395,12 +397,7 @@ def _cls_fork( logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> List[Union[SandboxCreateResponse, Exception]]: - timeout = ( - timeout if timeout is not None else SandboxBase.default_sandbox_timeout - ) - count = count if count is not None else 1 - - if count < 1: + if count is not None and count < 1: raise InvalidArgumentException("count must be at least 1") config = ConnectionConfig(logger=logger, **cls._resolve_api_params(**opts)) @@ -409,7 +406,10 @@ def _cls_fork( res = post_sandboxes_sandbox_id_fork.sync_detailed( sandbox_id, client=api_client, - body=SandboxForkRequest(timeout=timeout, count=count), + body=SandboxForkRequest( + timeout=timeout if timeout is not None else UNSET, + count=count if count is not None else UNSET, + ), ) if res.status_code == 404: @@ -532,7 +532,7 @@ def _cls_delete_snapshot( def _cls_pause( cls, sandbox_id: str, - keep_memory: bool = True, + keep_memory: Optional[bool] = None, **opts: Unpack[ApiParams], ) -> bool: config = ConnectionConfig(**cls._resolve_api_params(**opts)) @@ -541,7 +541,9 @@ def _cls_pause( res = post_sandboxes_sandbox_id_pause.sync_detailed( sandbox_id, client=api_client, - body=SandboxPauseRequest(memory=keep_memory), + body=SandboxPauseRequest( + memory=keep_memory if keep_memory is not None else UNSET + ), ) if res.status_code == 404: diff --git a/packages/python-sdk/e2b/template_async/build_api.py b/packages/python-sdk/e2b/template_async/build_api.py index 9a822cd516..b51494a093 100644 --- a/packages/python-sdk/e2b/template_async/build_api.py +++ b/packages/python-sdk/e2b/template_async/build_api.py @@ -49,16 +49,16 @@ async def request_build( client: AuthenticatedClient, name: str, tags: Optional[List[str]], - cpu_count: int, - memory_mb: int, + cpu_count: Optional[int], + memory_mb: Optional[int], ): res = await post_v3_templates.asyncio_detailed( client=client, body=TemplateBuildRequestV3( name=name, tags=tags if tags else UNSET, - cpu_count=cpu_count, - memory_mb=memory_mb, + cpu_count=cpu_count if cpu_count is not None else UNSET, + memory_mb=memory_mb if memory_mb is not None else UNSET, ), ) diff --git a/packages/python-sdk/e2b/template_async/main.py b/packages/python-sdk/e2b/template_async/main.py index 250d8aea32..1e88d9a92e 100644 --- a/packages/python-sdk/e2b/template_async/main.py +++ b/packages/python-sdk/e2b/template_async/main.py @@ -37,8 +37,8 @@ async def _build( template: TemplateClass, name: str, tags: Optional[List[str]] = None, - cpu_count: int = 2, - memory_mb: int = 1024, + cpu_count: Optional[int] = None, + memory_mb: Optional[int] = None, skip_cache: bool = False, on_build_logs: Optional[Callable[[LogEntry], None]] = None, request_timeout: Optional[float] = None, @@ -50,8 +50,8 @@ async def _build( :param template: The template to build :param name: Name for the template :param tags: Optional tags for the template - :param cpu_count: Number of CPUs allocated to the sandbox - :param memory_mb: Amount of memory in MB allocated to the sandbox + :param cpu_count: Number of CPUs allocated to the sandbox. When not set, the API default applies + :param memory_mb: Amount of memory in MB allocated to the sandbox. When not set, the API default applies :param skip_cache: If True, forces a complete rebuild ignoring cache :param on_build_logs: Callback function to receive build logs during the build process """ @@ -196,8 +196,8 @@ async def build( *, alias: Optional[str] = None, tags: Optional[List[str]] = None, - cpu_count: int = 2, - memory_mb: int = 1024, + cpu_count: Optional[int] = None, + memory_mb: Optional[int] = None, skip_cache: bool = False, on_build_logs: Optional[Callable[[LogEntry], None]] = None, **opts: Unpack[ApiParams], @@ -209,8 +209,8 @@ async def build( :param name: Template name in 'name' or 'name:tag' format :param alias: (Deprecated) Alias name for the template. Use name instead. :param tags: Optional additional tags to assign to the template - :param cpu_count: Number of CPUs allocated to the sandbox - :param memory_mb: Amount of memory in MB allocated to the sandbox + :param cpu_count: Number of CPUs allocated to the sandbox. When not set, the API default applies + :param memory_mb: Amount of memory in MB allocated to the sandbox. When not set, the API default applies :param skip_cache: If True, forces a complete rebuild ignoring cache :param on_build_logs: Callback function to receive build logs during the build process @@ -299,8 +299,8 @@ async def build_in_background( *, alias: Optional[str] = None, tags: Optional[List[str]] = None, - cpu_count: int = 2, - memory_mb: int = 1024, + cpu_count: Optional[int] = None, + memory_mb: Optional[int] = None, skip_cache: bool = False, on_build_logs: Optional[Callable[[LogEntry], None]] = None, **opts: Unpack[ApiParams], @@ -312,8 +312,8 @@ async def build_in_background( :param name: Template name in 'name' or 'name:tag' format :param alias: (Deprecated) Alias name for the template. Use name instead. :param tags: Optional additional tags to assign to the template - :param cpu_count: Number of CPUs allocated to the sandbox - :param memory_mb: Amount of memory in MB allocated to the sandbox + :param cpu_count: Number of CPUs allocated to the sandbox. When not set, the API default applies + :param memory_mb: Amount of memory in MB allocated to the sandbox. When not set, the API default applies :param skip_cache: If True, forces a complete rebuild ignoring cache :return: BuildInfo containing the template ID and build ID diff --git a/packages/python-sdk/e2b/template_sync/build_api.py b/packages/python-sdk/e2b/template_sync/build_api.py index 2bc918ab2f..0fcd4ddc67 100644 --- a/packages/python-sdk/e2b/template_sync/build_api.py +++ b/packages/python-sdk/e2b/template_sync/build_api.py @@ -47,16 +47,16 @@ def request_build( client: AuthenticatedClient, name: str, tags: Optional[List[str]], - cpu_count: int, - memory_mb: int, + cpu_count: Optional[int], + memory_mb: Optional[int], ): res = post_v3_templates.sync_detailed( client=client, body=TemplateBuildRequestV3( name=name, tags=tags if tags else UNSET, - cpu_count=cpu_count, - memory_mb=memory_mb, + cpu_count=cpu_count if cpu_count is not None else UNSET, + memory_mb=memory_mb if memory_mb is not None else UNSET, ), ) diff --git a/packages/python-sdk/e2b/template_sync/main.py b/packages/python-sdk/e2b/template_sync/main.py index 9d397b41b4..af13f70959 100644 --- a/packages/python-sdk/e2b/template_sync/main.py +++ b/packages/python-sdk/e2b/template_sync/main.py @@ -37,8 +37,8 @@ def _build( template: TemplateClass, name: str, tags: Optional[List[str]] = None, - cpu_count: int = 2, - memory_mb: int = 1024, + cpu_count: Optional[int] = None, + memory_mb: Optional[int] = None, skip_cache: bool = False, on_build_logs: Optional[Callable[[LogEntry], None]] = None, request_timeout: Optional[float] = None, @@ -50,8 +50,8 @@ def _build( :param template: The template to build :param name: Name for the template :param tags: Optional tags for the template - :param cpu_count: Number of CPUs allocated to the sandbox - :param memory_mb: Amount of memory in MB allocated to the sandbox + :param cpu_count: Number of CPUs allocated to the sandbox. When not set, the API default applies + :param memory_mb: Amount of memory in MB allocated to the sandbox. When not set, the API default applies :param skip_cache: If True, forces a complete rebuild ignoring cache :param on_build_logs: Callback function to receive build logs during the build process """ @@ -196,8 +196,8 @@ def build( *, alias: Optional[str] = None, tags: Optional[List[str]] = None, - cpu_count: int = 2, - memory_mb: int = 1024, + cpu_count: Optional[int] = None, + memory_mb: Optional[int] = None, skip_cache: bool = False, on_build_logs: Optional[Callable[[LogEntry], None]] = None, **opts: Unpack[ApiParams], @@ -209,8 +209,8 @@ def build( :param name: Template name in 'name' or 'name:tag' format :param alias: (Deprecated) Alias name for the template. Use name instead. :param tags: Optional additional tags to assign to the template - :param cpu_count: Number of CPUs allocated to the sandbox - :param memory_mb: Amount of memory in MB allocated to the sandbox + :param cpu_count: Number of CPUs allocated to the sandbox. When not set, the API default applies + :param memory_mb: Amount of memory in MB allocated to the sandbox. When not set, the API default applies :param skip_cache: If True, forces a complete rebuild ignoring cache :param on_build_logs: Callback function to receive build logs during the build process @@ -300,8 +300,8 @@ def build_in_background( *, alias: Optional[str] = None, tags: Optional[List[str]] = None, - cpu_count: int = 2, - memory_mb: int = 1024, + cpu_count: Optional[int] = None, + memory_mb: Optional[int] = None, skip_cache: bool = False, on_build_logs: Optional[Callable[[LogEntry], None]] = None, **opts: Unpack[ApiParams], @@ -313,8 +313,8 @@ def build_in_background( :param name: Template name in 'name' or 'name:tag' format :param alias: (Deprecated) Alias name for the template. Use name instead. :param tags: Optional additional tags to assign to the template - :param cpu_count: Number of CPUs allocated to the sandbox - :param memory_mb: Amount of memory in MB allocated to the sandbox + :param cpu_count: Number of CPUs allocated to the sandbox. When not set, the API default applies + :param memory_mb: Amount of memory in MB allocated to the sandbox. When not set, the API default applies :param skip_cache: If True, forces a complete rebuild ignoring cache :return: BuildInfo containing the template ID and build ID diff --git a/packages/python-sdk/tests/shared/sandbox/test_api_defaults.py b/packages/python-sdk/tests/shared/sandbox/test_api_defaults.py new file mode 100644 index 0000000000..a76beb2e99 --- /dev/null +++ b/packages/python-sdk/tests/shared/sandbox/test_api_defaults.py @@ -0,0 +1,179 @@ +from types import SimpleNamespace +from typing import Any, Dict +from unittest.mock import AsyncMock, Mock + +from e2b import AsyncSandbox, Sandbox +from e2b.api.client.api.sandboxes import ( + post_sandboxes, + post_sandboxes_sandbox_id_fork, + post_sandboxes_sandbox_id_pause, +) +from e2b.api.client.models import Sandbox as SandboxModel + + +def _created_sandbox(): + return SimpleNamespace( + status_code=200, + parsed=SandboxModel( + client_id="client-id", + envd_version="0.2.4", + sandbox_id="sbx-test", + template_id="template-id", + ), + ) + + +def _sync_create_body(monkeypatch, api_key: str, **kwargs) -> Dict[str, Any]: + request = Mock(return_value=_created_sandbox()) + monkeypatch.setattr(post_sandboxes, "sync_detailed", request) + + Sandbox.create(api_key=api_key, **kwargs) + + return request.call_args.kwargs["body"].to_dict() + + +async def _async_create_body(monkeypatch, api_key: str, **kwargs) -> Dict[str, Any]: + request = AsyncMock(return_value=_created_sandbox()) + monkeypatch.setattr(post_sandboxes, "asyncio_detailed", request) + + await AsyncSandbox.create(api_key=api_key, **kwargs) + + return request.call_args.kwargs["body"].to_dict() + + +def test_create_omits_api_owned_fields_when_unset(monkeypatch, test_api_key): + body = _sync_create_body(monkeypatch, test_api_key) + + assert "timeout" not in body + assert "secure" not in body + assert "allow_internet_access" not in body + + +def test_create_sends_explicit_values(monkeypatch, test_api_key): + body = _sync_create_body( + monkeypatch, + test_api_key, + timeout=60, + secure=False, + allow_internet_access=False, + ) + + assert body["timeout"] == 60 + assert body["secure"] is False + assert body["allow_internet_access"] is False + + +async def test_async_create_omits_api_owned_fields_when_unset( + monkeypatch, test_api_key +): + body = await _async_create_body(monkeypatch, test_api_key) + + assert "timeout" not in body + assert "secure" not in body + assert "allow_internet_access" not in body + + +async def test_async_create_sends_explicit_values(monkeypatch, test_api_key): + body = await _async_create_body( + monkeypatch, + test_api_key, + timeout=60, + secure=False, + allow_internet_access=False, + ) + + assert body["timeout"] == 60 + assert body["secure"] is False + assert body["allow_internet_access"] is False + + +def _sync_fork_body(monkeypatch, api_key: str, **kwargs) -> Dict[str, Any]: + request = Mock(return_value=SimpleNamespace(status_code=200, parsed=[])) + monkeypatch.setattr(post_sandboxes_sandbox_id_fork, "sync_detailed", request) + + Sandbox.fork("sbx-test", api_key=api_key, **kwargs) + + return request.call_args.kwargs["body"].to_dict() + + +async def _async_fork_body(monkeypatch, api_key: str, **kwargs) -> Dict[str, Any]: + request = AsyncMock(return_value=SimpleNamespace(status_code=200, parsed=[])) + monkeypatch.setattr(post_sandboxes_sandbox_id_fork, "asyncio_detailed", request) + + await AsyncSandbox.fork("sbx-test", api_key=api_key, **kwargs) + + return request.call_args.kwargs["body"].to_dict() + + +def test_fork_omits_timeout_and_count_when_unset(monkeypatch, test_api_key): + body = _sync_fork_body(monkeypatch, test_api_key) + + assert "timeout" not in body + assert "count" not in body + + +def test_fork_sends_explicit_timeout_and_count(monkeypatch, test_api_key): + body = _sync_fork_body(monkeypatch, test_api_key, timeout=60, count=2) + + assert body["timeout"] == 60 + assert body["count"] == 2 + + +async def test_async_fork_omits_timeout_and_count_when_unset( + monkeypatch, test_api_key +): + body = await _async_fork_body(monkeypatch, test_api_key) + + assert "timeout" not in body + assert "count" not in body + + +async def test_async_fork_sends_explicit_timeout_and_count(monkeypatch, test_api_key): + body = await _async_fork_body(monkeypatch, test_api_key, timeout=60, count=2) + + assert body["timeout"] == 60 + assert body["count"] == 2 + + +def _sync_pause_body(monkeypatch, api_key: str, **kwargs) -> Dict[str, Any]: + request = Mock(return_value=SimpleNamespace(status_code=204, parsed=None)) + monkeypatch.setattr(post_sandboxes_sandbox_id_pause, "sync_detailed", request) + + Sandbox.pause("sbx-test", api_key=api_key, **kwargs) + + return request.call_args.kwargs["body"].to_dict() + + +async def _async_pause_body(monkeypatch, api_key: str, **kwargs) -> Dict[str, Any]: + request = AsyncMock(return_value=SimpleNamespace(status_code=204, parsed=None)) + monkeypatch.setattr(post_sandboxes_sandbox_id_pause, "asyncio_detailed", request) + + await AsyncSandbox.pause("sbx-test", api_key=api_key, **kwargs) + + return request.call_args.kwargs["body"].to_dict() + + +def test_pause_omits_memory_when_keep_memory_unset(monkeypatch, test_api_key): + body = _sync_pause_body(monkeypatch, test_api_key) + + assert "memory" not in body + + +def test_pause_sends_explicit_keep_memory(monkeypatch, test_api_key): + body = _sync_pause_body(monkeypatch, test_api_key, keep_memory=False) + + assert body["memory"] is False + + +async def test_async_pause_omits_memory_when_keep_memory_unset( + monkeypatch, test_api_key +): + body = await _async_pause_body(monkeypatch, test_api_key) + + assert "memory" not in body + + +async def test_async_pause_sends_explicit_keep_memory(monkeypatch, test_api_key): + body = await _async_pause_body(monkeypatch, test_api_key, keep_memory=False) + + assert body["memory"] is False From b92ce2b99d5cc9a34c1a4a7b99440c2f8dbe299b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:19:45 +0000 Subject: [PATCH 02/10] Fix ruff formatting in test_api_defaults.py Co-Authored-By: mish@e2b.dev --- packages/python-sdk/tests/shared/sandbox/test_api_defaults.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/python-sdk/tests/shared/sandbox/test_api_defaults.py b/packages/python-sdk/tests/shared/sandbox/test_api_defaults.py index a76beb2e99..9aba290689 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_api_defaults.py +++ b/packages/python-sdk/tests/shared/sandbox/test_api_defaults.py @@ -119,9 +119,7 @@ def test_fork_sends_explicit_timeout_and_count(monkeypatch, test_api_key): assert body["count"] == 2 -async def test_async_fork_omits_timeout_and_count_when_unset( - monkeypatch, test_api_key -): +async def test_async_fork_omits_timeout_and_count_when_unset(monkeypatch, test_api_key): body = await _async_fork_body(monkeypatch, test_api_key) assert "timeout" not in body From 996c67914ac477c2ec79978a4025297ae4c98d52 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:24:45 +0000 Subject: [PATCH 03/10] Keep SDK-side secure=true default for sandbox creation Co-Authored-By: mish@e2b.dev --- .changeset/olive-poets-hammer.md | 2 +- packages/js-sdk/src/sandbox/sandboxApi.ts | 4 ++-- packages/js-sdk/tests/sandbox/apiDefaults.test.ts | 4 ++-- packages/python-sdk/e2b/sandbox_async/main.py | 2 +- packages/python-sdk/e2b/sandbox_async/sandbox_api.py | 2 +- packages/python-sdk/e2b/sandbox_sync/main.py | 2 +- packages/python-sdk/e2b/sandbox_sync/sandbox_api.py | 2 +- packages/python-sdk/tests/shared/sandbox/test_api_defaults.py | 4 ++-- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.changeset/olive-poets-hammer.md b/.changeset/olive-poets-hammer.md index 053a52d8e2..60390a1477 100644 --- a/.changeset/olive-poets-hammer.md +++ b/.changeset/olive-poets-hammer.md @@ -3,4 +3,4 @@ '@e2b/python-sdk': minor --- -Remove SDK-side defaults from API request payloads so the API defaults apply when options are omitted. Sandbox create/fork no longer preset a 5-minute timeout, fork no longer presets `count: 1`, create no longer presets `secure` and `allow_internet_access`, pause no longer presets keeping memory, and template builds no longer preset CPU/memory. Explicitly provided values are still sent unchanged. +Remove SDK-side defaults from API request payloads so the API defaults apply when options are omitted. Sandbox create/fork no longer preset a 5-minute timeout, fork no longer presets `count: 1`, create no longer presets `allow_internet_access` (sandboxes remain `secure` by default), pause no longer presets keeping memory, and template builds no longer preset CPU/memory. Explicitly provided values are still sent unchanged. diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 37730597bd..6e4a96a9d9 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -598,7 +598,7 @@ export interface SandboxOpts extends ConnectionOpts { /** * Secure all traffic coming to the sandbox controller with auth token * - * When not set, the API default (currently enabled) applies. + * @default true */ secure?: boolean @@ -1657,7 +1657,7 @@ export class SandboxApi extends ClientFactory { envVars: opts?.envs, timeout: timeoutMs === undefined ? undefined : timeoutToSeconds(timeoutMs), - secure: opts?.secure, + secure: opts?.secure ?? true, allow_internet_access: opts?.allowInternetAccess, network: buildNetworkBody(opts?.network, iam), iam, diff --git a/packages/js-sdk/tests/sandbox/apiDefaults.test.ts b/packages/js-sdk/tests/sandbox/apiDefaults.test.ts index 098e3b2d10..cc8ce3526a 100644 --- a/packages/js-sdk/tests/sandbox/apiDefaults.test.ts +++ b/packages/js-sdk/tests/sandbox/apiDefaults.test.ts @@ -47,12 +47,12 @@ afterEach(() => { server.resetHandlers() }) -test('Sandbox.create omits timeout, secure and allow_internet_access when unset', async () => { +test('Sandbox.create omits timeout and allow_internet_access when unset and defaults secure to true', async () => { await Sandbox.create('base', { apiKey: TEST_API_KEY }) expect(lastCreateBody).toBeDefined() expect(lastCreateBody).not.toHaveProperty('timeout') - expect(lastCreateBody).not.toHaveProperty('secure') + expect(lastCreateBody?.secure).toBe(true) expect(lastCreateBody).not.toHaveProperty('allow_internet_access') }) diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 2b0ed8a50b..5480917680 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -191,7 +191,7 @@ async def create( :param timeout: Timeout for the sandbox in **seconds**. When not set, the API default timeout applies. The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. :param metadata: Custom metadata for the sandbox :param envs: Custom environment variables for the sandbox - :param secure: Envd is secured with access token and cannot be used without it. When not set, the API default (currently enabled) applies. + :param secure: Envd is secured with access token and cannot be used without it. Defaults to `True`. :param allow_internet_access: Allow sandbox to access the internet. When not set, the API default (currently allowed) applies. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`. :param mcp: MCP server to enable in the sandbox :param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``; a rule's ``transform`` may be a callable receiving a :class:`SandboxNetworkTransformContext` of placeholder strings (``ctx.iam.tokens[name]``). diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index e66b1f603b..525aae8446 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -239,7 +239,7 @@ async def _create_sandbox( timeout=timeout if timeout is not None else UNSET, env_vars=env_vars or {}, mcp=cast(Any, mcp) or UNSET, - secure=secure if secure is not None else UNSET, + secure=secure if secure is not None else True, allow_internet_access=( allow_internet_access if allow_internet_access is not None else UNSET ), diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index f68db89b18..dd0f51dff1 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -187,7 +187,7 @@ def create( :param timeout: Timeout for the sandbox in **seconds**. When not set, the API default timeout applies. The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. :param metadata: Custom metadata for the sandbox :param envs: Custom environment variables for the sandbox - :param secure: Envd is secured with access token and cannot be used without it. When not set, the API default (currently enabled) applies. + :param secure: Envd is secured with access token and cannot be used without it. Defaults to `True`. :param allow_internet_access: Allow sandbox to access the internet. When not set, the API default (currently allowed) applies. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`. :param mcp: MCP server to enable in the sandbox :param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``; a rule's ``transform`` may be a callable receiving a :class:`SandboxNetworkTransformContext` of placeholder strings (``ctx.iam.tokens[name]``). diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index 84fc8b8688..33f835ae93 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -238,7 +238,7 @@ def _create_sandbox( timeout=timeout if timeout is not None else UNSET, env_vars=env_vars or {}, mcp=cast(Any, mcp) or UNSET, - secure=secure if secure is not None else UNSET, + secure=secure if secure is not None else True, allow_internet_access=( allow_internet_access if allow_internet_access is not None else UNSET ), diff --git a/packages/python-sdk/tests/shared/sandbox/test_api_defaults.py b/packages/python-sdk/tests/shared/sandbox/test_api_defaults.py index 9aba290689..e9f9f7a6b9 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_api_defaults.py +++ b/packages/python-sdk/tests/shared/sandbox/test_api_defaults.py @@ -45,7 +45,7 @@ def test_create_omits_api_owned_fields_when_unset(monkeypatch, test_api_key): body = _sync_create_body(monkeypatch, test_api_key) assert "timeout" not in body - assert "secure" not in body + assert body["secure"] is True assert "allow_internet_access" not in body @@ -69,7 +69,7 @@ async def test_async_create_omits_api_owned_fields_when_unset( body = await _async_create_body(monkeypatch, test_api_key) assert "timeout" not in body - assert "secure" not in body + assert body["secure"] is True assert "allow_internet_access" not in body From ef24b6424273d61dead16b928e77866f232c3c1a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:27:28 +0000 Subject: [PATCH 04/10] Remove redundant API-default doc mentions Co-Authored-By: mish@e2b.dev --- packages/js-sdk/src/sandbox/sandboxApi.ts | 11 ---------- packages/js-sdk/src/template/types.ts | 2 -- packages/python-sdk/e2b/sandbox_async/main.py | 22 +++++++++---------- packages/python-sdk/e2b/sandbox_sync/main.py | 22 +++++++++---------- .../python-sdk/e2b/template_async/main.py | 12 +++++----- packages/python-sdk/e2b/template_sync/main.py | 12 +++++----- 6 files changed, 34 insertions(+), 47 deletions(-) diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 6e4a96a9d9..04fc4effa8 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -513,8 +513,6 @@ export interface SandboxPauseOpts extends SandboxApiOpts { * When `false`, the in-memory state is dropped and only the filesystem is * persisted (a filesystem-only snapshot); resuming such a sandbox cold-boots * (reboots) it from disk, losing running processes and open connections. - * - * When not set, the API default (currently a full memory snapshot) applies. */ keepMemory?: boolean } @@ -529,16 +527,12 @@ export interface SandboxForkOpts extends ConnectionOpts { * All forks boot from the same snapshot — the snapshot is captured once * regardless of count. Each fork succeeds or fails independently; the * outcome of each is reported in its entry of the returned array. - * - * When not set, the API default (currently 1) applies. */ count?: number /** * Timeout for the forked sandboxes in **milliseconds**. * Maximum time a sandbox can be kept alive is 24 hours (86_400_000 milliseconds) for Pro users and 1 hour (3_600_000 milliseconds) for Hobby users. - * - * When not set, the API default timeout applies. */ timeoutMs?: number } @@ -590,8 +584,6 @@ export interface SandboxOpts extends ConnectionOpts { /** * Timeout for the sandbox in **milliseconds**. * Maximum time a sandbox can be kept alive is 24 hours (86_400_000 milliseconds) for Pro users and 1 hour (3_600_000 milliseconds) for Hobby users. - * - * When not set, the API default timeout applies. */ timeoutMs?: number @@ -604,8 +596,6 @@ export interface SandboxOpts extends ConnectionOpts { /** * Allow sandbox to access the internet. If set to `False`, it works the same as setting network `denyOut` to `[0.0.0.0/0]`. - * - * When not set, the API default (currently allowed) applies. */ allowInternetAccess?: boolean @@ -714,7 +704,6 @@ export interface SandboxListOpts extends Omit { /** * Sort order of the list of sandboxes by start time, applied across the * whole result set before pagination (not within a page). - * When not set, the API default (currently `'desc'`, newest first) applies. */ order?: SandboxListOrder diff --git a/packages/js-sdk/src/template/types.ts b/packages/js-sdk/src/template/types.ts index 8a65112d31..e7b7c70efd 100644 --- a/packages/js-sdk/src/template/types.ts +++ b/packages/js-sdk/src/template/types.ts @@ -34,12 +34,10 @@ export type BasicBuildOptions = { tags?: string[] /** * Number of CPUs allocated to the sandbox. - * When not set, the API default applies. */ cpuCount?: number /** * Amount of memory in MB allocated to the sandbox. - * When not set, the API default applies. */ memoryMB?: number /** diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 5480917680..4257fbb511 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -188,11 +188,11 @@ async def create( By default, the sandbox is created from the default `base` sandbox template. :param template: Sandbox template name or ID - :param timeout: Timeout for the sandbox in **seconds**. When not set, the API default timeout applies. The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + :param timeout: Timeout for the sandbox in **seconds**. The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. :param metadata: Custom metadata for the sandbox :param envs: Custom environment variables for the sandbox :param secure: Envd is secured with access token and cannot be used without it. Defaults to `True`. - :param allow_internet_access: Allow sandbox to access the internet. When not set, the API default (currently allowed) applies. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`. + :param allow_internet_access: Allow sandbox to access the internet. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`. :param mcp: MCP server to enable in the sandbox :param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``; a rule's ``transform`` may be a callable receiving a :class:`SandboxNetworkTransformContext` of placeholder strings (``ctx.iam.tokens[name]``). :param iam: Sandbox workload identity configuration. A non-empty ``tokens`` map enables workload identity for the sandbox; token definitions can be created with :meth:`Secret.iam_token`. Example: ``{"tokens": {"aws": Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID")}}``. Registered tokens are exposed to ``network.rules`` ``transform`` callables as ``ctx.iam.tokens[name]`` placeholders, which the egress proxy resolves per request @@ -377,8 +377,8 @@ async def fork( error codes map to the same exception classes as other API errors (e.g. 429 to `RateLimitException`). - :param timeout: Timeout for the forked sandboxes in **seconds**. When not set, the API default timeout applies - :param count: Number of forked sandboxes to create. When not set, the API default (currently 1) applies + :param timeout: Timeout for the forked sandboxes in **seconds**. + :param count: Number of forked sandboxes to create. :return: List with one entry per requested fork — a sandbox instance or an exception @@ -416,8 +416,8 @@ async def fork( (e.g. 429 to `RateLimitException`). :param sandbox_id: Sandbox ID - :param timeout: Timeout for the forked sandboxes in **seconds**. When not set, the API default timeout applies - :param count: Number of forked sandboxes to create. When not set, the API default (currently 1) applies + :param timeout: Timeout for the forked sandboxes in **seconds**. + :param count: Number of forked sandboxes to create. :param logger: Logger used for request and response logging for the forked sandboxes. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: List with one entry per requested fork — a sandbox instance or an exception @@ -453,8 +453,8 @@ async def fork( error codes map to the same exception classes as other API errors (e.g. 429 to `RateLimitException`). - :param timeout: Timeout for the forked sandboxes in **seconds**. When not set, the API default timeout applies - :param count: Number of forked sandboxes to create. When not set, the API default (currently 1) applies + :param timeout: Timeout for the forked sandboxes in **seconds**. + :param count: Number of forked sandboxes to create. :return: List with one entry per requested fork — a sandbox instance or an exception @@ -755,7 +755,7 @@ async def pause( """ Pause the sandbox. - :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. When not set, the API default (currently a full memory snapshot) applies. + :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ @@ -772,7 +772,7 @@ async def pause( Pause the sandbox specified by sandbox ID. :param sandbox_id: Sandbox ID - :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. When not set, the API default (currently a full memory snapshot) applies. + :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ @@ -787,7 +787,7 @@ async def pause( """ Pause the sandbox. - :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk, losing running processes and open connections. When not set, the API default (currently a full memory snapshot) applies. + :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk, losing running processes and open connections. :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index dd0f51dff1..208a7049dc 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -184,11 +184,11 @@ def create( By default, the sandbox is created from the default `base` sandbox template. :param template: Sandbox template name or ID - :param timeout: Timeout for the sandbox in **seconds**. When not set, the API default timeout applies. The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + :param timeout: Timeout for the sandbox in **seconds**. The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. :param metadata: Custom metadata for the sandbox :param envs: Custom environment variables for the sandbox :param secure: Envd is secured with access token and cannot be used without it. Defaults to `True`. - :param allow_internet_access: Allow sandbox to access the internet. When not set, the API default (currently allowed) applies. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`. + :param allow_internet_access: Allow sandbox to access the internet. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`. :param mcp: MCP server to enable in the sandbox :param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``; a rule's ``transform`` may be a callable receiving a :class:`SandboxNetworkTransformContext` of placeholder strings (``ctx.iam.tokens[name]``). :param iam: Sandbox workload identity configuration. A non-empty ``tokens`` map enables workload identity for the sandbox; token definitions can be created with :meth:`Secret.iam_token`. Example: ``{"tokens": {"aws": Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID")}}``. Registered tokens are exposed to ``network.rules`` ``transform`` callables as ``ctx.iam.tokens[name]`` placeholders, which the egress proxy resolves per request @@ -372,8 +372,8 @@ def fork( error codes map to the same exception classes as other API errors (e.g. 429 to `RateLimitException`). - :param timeout: Timeout for the forked sandboxes in **seconds**. When not set, the API default timeout applies - :param count: Number of forked sandboxes to create. When not set, the API default (currently 1) applies + :param timeout: Timeout for the forked sandboxes in **seconds**. + :param count: Number of forked sandboxes to create. :return: List with one entry per requested fork — a sandbox instance or an exception @@ -411,8 +411,8 @@ def fork( (e.g. 429 to `RateLimitException`). :param sandbox_id: Sandbox ID - :param timeout: Timeout for the forked sandboxes in **seconds**. When not set, the API default timeout applies - :param count: Number of forked sandboxes to create. When not set, the API default (currently 1) applies + :param timeout: Timeout for the forked sandboxes in **seconds**. + :param count: Number of forked sandboxes to create. :param logger: Logger used for request and response logging for the forked sandboxes. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: List with one entry per requested fork — a sandbox instance or an exception @@ -448,8 +448,8 @@ def fork( error codes map to the same exception classes as other API errors (e.g. 429 to `RateLimitException`). - :param timeout: Timeout for the forked sandboxes in **seconds**. When not set, the API default timeout applies - :param count: Number of forked sandboxes to create. When not set, the API default (currently 1) applies + :param timeout: Timeout for the forked sandboxes in **seconds**. + :param count: Number of forked sandboxes to create. :return: List with one entry per requested fork — a sandbox instance or an exception @@ -753,7 +753,7 @@ def pause( """ Pause the sandbox. - :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. When not set, the API default (currently a full memory snapshot) applies. + :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ @@ -770,7 +770,7 @@ def pause( Pause the sandbox specified by sandbox ID. :param sandbox_id: Sandbox ID - :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. When not set, the API default (currently a full memory snapshot) applies. + :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ @@ -785,7 +785,7 @@ def pause( """ Pause the sandbox. - :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk, losing running processes and open connections. When not set, the API default (currently a full memory snapshot) applies. + :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk, losing running processes and open connections. :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ diff --git a/packages/python-sdk/e2b/template_async/main.py b/packages/python-sdk/e2b/template_async/main.py index 1e88d9a92e..531cb0fbcb 100644 --- a/packages/python-sdk/e2b/template_async/main.py +++ b/packages/python-sdk/e2b/template_async/main.py @@ -50,8 +50,8 @@ async def _build( :param template: The template to build :param name: Name for the template :param tags: Optional tags for the template - :param cpu_count: Number of CPUs allocated to the sandbox. When not set, the API default applies - :param memory_mb: Amount of memory in MB allocated to the sandbox. When not set, the API default applies + :param cpu_count: Number of CPUs allocated to the sandbox. + :param memory_mb: Amount of memory in MB allocated to the sandbox. :param skip_cache: If True, forces a complete rebuild ignoring cache :param on_build_logs: Callback function to receive build logs during the build process """ @@ -209,8 +209,8 @@ async def build( :param name: Template name in 'name' or 'name:tag' format :param alias: (Deprecated) Alias name for the template. Use name instead. :param tags: Optional additional tags to assign to the template - :param cpu_count: Number of CPUs allocated to the sandbox. When not set, the API default applies - :param memory_mb: Amount of memory in MB allocated to the sandbox. When not set, the API default applies + :param cpu_count: Number of CPUs allocated to the sandbox. + :param memory_mb: Amount of memory in MB allocated to the sandbox. :param skip_cache: If True, forces a complete rebuild ignoring cache :param on_build_logs: Callback function to receive build logs during the build process @@ -312,8 +312,8 @@ async def build_in_background( :param name: Template name in 'name' or 'name:tag' format :param alias: (Deprecated) Alias name for the template. Use name instead. :param tags: Optional additional tags to assign to the template - :param cpu_count: Number of CPUs allocated to the sandbox. When not set, the API default applies - :param memory_mb: Amount of memory in MB allocated to the sandbox. When not set, the API default applies + :param cpu_count: Number of CPUs allocated to the sandbox. + :param memory_mb: Amount of memory in MB allocated to the sandbox. :param skip_cache: If True, forces a complete rebuild ignoring cache :return: BuildInfo containing the template ID and build ID diff --git a/packages/python-sdk/e2b/template_sync/main.py b/packages/python-sdk/e2b/template_sync/main.py index af13f70959..af302fbd0c 100644 --- a/packages/python-sdk/e2b/template_sync/main.py +++ b/packages/python-sdk/e2b/template_sync/main.py @@ -50,8 +50,8 @@ def _build( :param template: The template to build :param name: Name for the template :param tags: Optional tags for the template - :param cpu_count: Number of CPUs allocated to the sandbox. When not set, the API default applies - :param memory_mb: Amount of memory in MB allocated to the sandbox. When not set, the API default applies + :param cpu_count: Number of CPUs allocated to the sandbox. + :param memory_mb: Amount of memory in MB allocated to the sandbox. :param skip_cache: If True, forces a complete rebuild ignoring cache :param on_build_logs: Callback function to receive build logs during the build process """ @@ -209,8 +209,8 @@ def build( :param name: Template name in 'name' or 'name:tag' format :param alias: (Deprecated) Alias name for the template. Use name instead. :param tags: Optional additional tags to assign to the template - :param cpu_count: Number of CPUs allocated to the sandbox. When not set, the API default applies - :param memory_mb: Amount of memory in MB allocated to the sandbox. When not set, the API default applies + :param cpu_count: Number of CPUs allocated to the sandbox. + :param memory_mb: Amount of memory in MB allocated to the sandbox. :param skip_cache: If True, forces a complete rebuild ignoring cache :param on_build_logs: Callback function to receive build logs during the build process @@ -313,8 +313,8 @@ def build_in_background( :param name: Template name in 'name' or 'name:tag' format :param alias: (Deprecated) Alias name for the template. Use name instead. :param tags: Optional additional tags to assign to the template - :param cpu_count: Number of CPUs allocated to the sandbox. When not set, the API default applies - :param memory_mb: Amount of memory in MB allocated to the sandbox. When not set, the API default applies + :param cpu_count: Number of CPUs allocated to the sandbox. + :param memory_mb: Amount of memory in MB allocated to the sandbox. :param skip_cache: If True, forces a complete rebuild ignoring cache :return: BuildInfo containing the template ID and build ID From b984e34ecfd246e421c135f0f0eafc6cd47b6265 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:44:13 +0200 Subject: [PATCH 05/10] fix(sdk): match JS and Python on malformed and null egress proxy input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BYOP surface from #1688 diverged for callers that bypass the types. Python raised InvalidArgumentException on a proxy without a string address; JS rebuilt the body from the known fields, so `egressProxy` passed as a bare string sent `{}` and the caller got an API error naming a field they never left out. Mirror the guard in buildEgressProxyBody, the way buildIamBody already does for untyped token maps. Both SDKs also forwarded a null/None username or password as a JSON null, which the API rejects — `{"username": os.environ.get(...)}` on an unset variable is the way that happens. Read it as "no credentials", the same reading both already gave `egressProxy: null` itself, and normalize a null username coming back out of getInfo so SandboxEgressProxyInfo.username cannot be a null its type forbids. The get_info example published in both CHANGELOGs for 2.41.0 subscripts `info.network["egress_proxy"]`, which KeyErrors on every sandbox without a proxy — SandboxNetworkInfo is total=False and the key is only set when one is configured. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/egress-proxy-untyped-callers.md | 43 +++++++++++ packages/js-sdk/CHANGELOG.md | 2 +- packages/js-sdk/src/sandbox/sandboxApi.ts | 35 +++++---- .../js-sdk/tests/sandbox/egressProxy.test.ts | 74 +++++++++++++++++-- packages/python-sdk/CHANGELOG.md | 2 +- .../python-sdk/e2b/sandbox/sandbox_api.py | 16 ++-- .../tests/shared/sandbox/test_egress_proxy.py | 35 +++++++++ 7 files changed, 181 insertions(+), 26 deletions(-) create mode 100644 .changeset/egress-proxy-untyped-callers.md diff --git a/.changeset/egress-proxy-untyped-callers.md b/.changeset/egress-proxy-untyped-callers.md new file mode 100644 index 0000000000..efd7eaab53 --- /dev/null +++ b/.changeset/egress-proxy-untyped-callers.md @@ -0,0 +1,43 @@ +--- +'e2b': patch +'@e2b/python-sdk': patch +--- + +Bring the JS and Python halves of `network.egressProxy` / `network["egress_proxy"]` back in line for callers that bypass the types, and stop `null` credentials from reaching the wire. + +A malformed proxy now raises `InvalidArgumentError` / `InvalidArgumentException` in both SDKs. Before, only Python did; JS rebuilt the body from the known fields, so a proxy passed as a bare string sent `{}` and the caller got an API error about a field they never left out: + +```ts +// Now: InvalidArgumentError, naming the option you typed. +// Before: sent `"egressProxy": {}` and failed at the API. +await Sandbox.create({ + network: { egressProxy: 'proxy.example.com:1080' as never }, +}) +``` + +A `username` or `password` that is `null` / `None` is treated as "no credentials" rather than serialized as a JSON null the API rejects — the same reading both SDKs already gave `egressProxy: null` itself: + +```ts +await Sandbox.create({ + network: { + egressProxy: { + address: 'proxy.example.com:1080', + // Unset in the environment; the proxy takes no credentials. + username: process.env.PROXY_USER, + }, + }, +}) +``` + +```python +Sandbox.create( + network={ + "egress_proxy": { + "address": "proxy.example.com:1080", + "username": os.environ.get("PROXY_USER"), + }, + }, +) +``` + +`getInfo` / `get_info` normalizes a `null` `username` the same way, so `SandboxEgressProxyInfo.username` is `undefined` / an absent key rather than a null that its type says cannot be there. diff --git a/packages/js-sdk/CHANGELOG.md b/packages/js-sdk/CHANGELOG.md index c0002e840f..9b7e102b25 100644 --- a/packages/js-sdk/CHANGELOG.md +++ b/packages/js-sdk/CHANGELOG.md @@ -267,7 +267,7 @@ ```python info = sandbox.get_info() - print(info.network["egress_proxy"]) + print(info.network.get("egress_proxy")) # {'address': 'proxy.example.com:1080', 'username': 'proxy-user'} ``` diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 04fc4effa8..918d4c656e 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -1009,20 +1009,30 @@ function resolveRulesForBody( /** * Rebuild the proxy config from the known fields so stray properties on the * caller's object never reach the wire and a later mutation of it cannot alter - * the in-flight request. Validation is the server's — it is the only side that - * can tell whether the address resolves, and to where. + * the in-flight request. Address reachability is the server's — it is the only + * side that can tell whether the address resolves, and to where. */ function buildEgressProxyBody( egressProxy: SandboxEgressProxyOpts ): components['schemas']['SandboxEgressProxyConfig'] { + // Re-check at runtime for callers that bypass the type — rebuilding from the + // known fields drops an address that isn't there, and the API error for the + // resulting `{}` names neither the option the caller typed nor the mistake. + // Python raises `InvalidArgumentException` on the same input. + if (typeof egressProxy.address !== 'string') { + throw new InvalidArgumentError( + "network egressProxy must be an object with a string 'address' " + + "(e.g. 'proxy.example.com:1080')." + ) + } + return { address: egressProxy.address, - ...(egressProxy.username !== undefined - ? { username: egressProxy.username } - : {}), - ...(egressProxy.password !== undefined - ? { password: egressProxy.password } - : {}), + // `!= null` so a credential read out of an unset environment variable + // reads as "no credentials" rather than reaching the wire as JSON null, + // which the API rejects. Same reasoning as `egressProxy: null` itself. + ...(egressProxy.username != null ? { username: egressProxy.username } : {}), + ...(egressProxy.password != null ? { password: egressProxy.password } : {}), } } @@ -1066,8 +1076,9 @@ function buildNetworkEgress( /** * Map the wire proxy config into the SDK-owned shape: `password` is dropped - * because the API never returns it, and the wire's `null` for "no proxy" is - * normalized so the union never reaches a consumer. + * because the API never returns it, and the wire's `null` — for "no proxy" and + * for an anonymous proxy's `username` alike — is normalized so it never reaches + * a consumer typed to see `undefined`. */ function fromApiEgressProxy( egressProxy: components['schemas']['SandboxEgressProxyConfig'] | undefined @@ -1078,9 +1089,7 @@ function fromApiEgressProxy( return { address: egressProxy.address, - ...(egressProxy.username !== undefined - ? { username: egressProxy.username } - : {}), + ...(egressProxy.username != null ? { username: egressProxy.username } : {}), } } diff --git a/packages/js-sdk/tests/sandbox/egressProxy.test.ts b/packages/js-sdk/tests/sandbox/egressProxy.test.ts index b861fd2df7..d6087bc77e 100644 --- a/packages/js-sdk/tests/sandbox/egressProxy.test.ts +++ b/packages/js-sdk/tests/sandbox/egressProxy.test.ts @@ -2,7 +2,7 @@ import { afterAll, afterEach, beforeAll, expect, test } from 'vitest' import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' -import { Sandbox } from '../../src' +import { InvalidArgumentError, Sandbox } from '../../src' import { TEST_API_KEY, apiUrl } from '../setup' const sandboxId = 'test-sandbox-id' @@ -101,14 +101,64 @@ test('Sandbox.create combines the egress proxy with allow and deny lists', async }) }) -test('Sandbox.create omits the egress proxy when not provided', async () => { +test.for([ + ['omitted', { allowOut: ['api.example.com'] }], + // Untyped callers spell "no proxy" as null; Python treats an explicit None + // the same way. + ['null', { egressProxy: null }], +])( + 'Sandbox.create omits the egress proxy when it is %s', + async ([, network]: [string, Record]) => { + await Sandbox.create('base', { + apiKey: TEST_API_KEY, + network, + }) + + expect(lastCreateBody?.network).toBeDefined() + expect(lastCreateBody?.network).not.toHaveProperty('egressProxy') + } +) + +test.for([ + // An empty object is falsy but present — it must not silently disable + // tunneling. Match Python: fail loudly. + ['empty', {}], + ['missing-address', { username: 'proxy-user' }], + ['non-string-address', { address: 1080 }], + ['string', 'proxy.example.com:1080'], +])( + 'Sandbox.create rejects a %s egress proxy', + async ([, egressProxy]: [string, unknown]) => { + // Rebuilding from the known fields drops an address that isn't there, so + // without this the caller gets an API error about a `{}` they never wrote. + await expect( + Sandbox.create('base', { + apiKey: TEST_API_KEY, + network: { egressProxy } as never, + }) + ).rejects.toThrow(InvalidArgumentError) + + expect(lastCreateBody).toBeUndefined() + } +) + +test('Sandbox.create omits credentials that are null', async () => { + // `{ username: process.env.PROXY_USER }` on an unset variable is the way + // this happens; a JSON null is rejected by the API. await Sandbox.create('base', { apiKey: TEST_API_KEY, - network: { allowOut: ['api.example.com'] }, + network: { + egressProxy: { + address: 'proxy.example.com:1080', + username: null, + password: undefined, + } as never, + }, }) - expect(lastCreateBody?.network).toBeDefined() - expect(lastCreateBody?.network).not.toHaveProperty('egressProxy') + expect(lastCreateBody?.network.egressProxy).toEqual({ + address: 'proxy.example.com:1080', + }) }) test('Sandbox.create strips unknown egress proxy properties', async () => { @@ -199,6 +249,20 @@ test('getInfo drops a password the API unexpectedly returns', async () => { }) }) +test('getInfo drops a null username', async () => { + // `username?: string` says absence is `undefined`, so a null from the wire + // has to be normalized rather than handed to a consumer. + sandboxNetwork = { + egressProxy: { address: 'proxy.example.com:1080', username: null }, + } + + const info = await Sandbox.getInfo(sandboxId, { apiKey: TEST_API_KEY }) + + expect(info.network?.egressProxy).toEqual({ + address: 'proxy.example.com:1080', + }) +}) + test.for([ ['omitted', {}], ['null', { egressProxy: null }], diff --git a/packages/python-sdk/CHANGELOG.md b/packages/python-sdk/CHANGELOG.md index c19b593dee..5b1b2895fd 100644 --- a/packages/python-sdk/CHANGELOG.md +++ b/packages/python-sdk/CHANGELOG.md @@ -243,7 +243,7 @@ ```python info = sandbox.get_info() - print(info.network["egress_proxy"]) + print(info.network.get("egress_proxy")) # {'address': 'proxy.example.com:1080', 'username': 'proxy-user'} ``` diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index d53c84915a..a9177f07e8 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -671,9 +671,12 @@ def _build_egress_proxy( ) body = ClientSandboxEgressProxyConfig(address=egress_proxy["address"]) - if "username" in egress_proxy: + # `is not None` so a credential read out of an unset environment variable + # reads as "no credentials" rather than reaching the wire as JSON null, + # which the API rejects. Same reasoning as ``"egress_proxy": None`` itself. + if egress_proxy.get("username") is not None: body.username = egress_proxy["username"] - if "password" in egress_proxy: + if egress_proxy.get("password") is not None: body.password = egress_proxy["password"] return body @@ -899,15 +902,16 @@ def _from_client_egress_proxy( ) -> Optional[SandboxEgressProxyInfo]: """ Map the wire proxy config into the SDK-owned shape: ``password`` is dropped - because the API never returns it, and the wire's ``None`` for "no proxy" - becomes an absent key. + because the API never returns it, and the wire's ``None`` — for "no proxy" + and for an anonymous proxy's ``username`` alike — becomes an absent key. """ if not isinstance(egress_proxy, ClientSandboxEgressProxyConfig): return None result: SandboxEgressProxyInfo = {"address": egress_proxy.address} - if not isinstance(egress_proxy.username, Unset): - result["username"] = egress_proxy.username + username = egress_proxy.username + if not isinstance(username, Unset) and username is not None: + result["username"] = username 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 e097ee1711..182167699b 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_egress_proxy.py +++ b/packages/python-sdk/tests/shared/sandbox/test_egress_proxy.py @@ -85,6 +85,25 @@ def test_create_rejects_a_malformed_egress_proxy(egress_proxy): build_network_config(cast(Any, {"egress_proxy": egress_proxy})) +def test_create_omits_credentials_that_are_none(): + # ``{"username": os.environ.get("PROXY_USER")}`` on an unset variable is the + # way this happens; a JSON null is rejected by the API. + body = build_network_config( + cast( + Any, + { + "egress_proxy": { + "address": "proxy.example.com:1080", + "username": None, + "password": None, + }, + }, + ) + ) + assert body is not None + assert body["egress_proxy"].to_dict() == {"address": "proxy.example.com:1080"} + + def test_create_strips_unknown_egress_proxy_keys(): # An untyped caller can copy an extra key out of a config file; the API # rejects unknown properties. @@ -145,6 +164,22 @@ def test_get_info_reports_the_active_egress_proxy_without_the_password(): } +def test_get_info_drops_a_none_username(): + # ``username`` is ``NotRequired[str]``, so absence is a missing key — a None + # from the wire has to be normalized rather than handed to a caller. + info = from_client_network_config( + SandboxNetworkConfig( + egress_proxy=ClientSandboxEgressProxyConfig( + address="proxy.example.com:1080", + username=cast(Any, None), + ) + ) + ) + + assert info is not None + assert info["egress_proxy"] == {"address": "proxy.example.com:1080"} + + @pytest.mark.parametrize( "egress_proxy", [ From 6d991cb64bef0e249b2738566569fd4cf89dbd62 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:43:55 +0000 Subject: [PATCH 06/10] Revert "fix(sdk): match JS and Python on malformed and null egress proxy input" This reverts commit b984e34ecfd246e421c135f0f0eafc6cd47b6265. --- .changeset/egress-proxy-untyped-callers.md | 43 ----------- packages/js-sdk/CHANGELOG.md | 2 +- packages/js-sdk/src/sandbox/sandboxApi.ts | 35 ++++----- .../js-sdk/tests/sandbox/egressProxy.test.ts | 74 ++----------------- packages/python-sdk/CHANGELOG.md | 2 +- .../python-sdk/e2b/sandbox/sandbox_api.py | 16 ++-- .../tests/shared/sandbox/test_egress_proxy.py | 35 --------- 7 files changed, 26 insertions(+), 181 deletions(-) delete mode 100644 .changeset/egress-proxy-untyped-callers.md diff --git a/.changeset/egress-proxy-untyped-callers.md b/.changeset/egress-proxy-untyped-callers.md deleted file mode 100644 index efd7eaab53..0000000000 --- a/.changeset/egress-proxy-untyped-callers.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'e2b': patch -'@e2b/python-sdk': patch ---- - -Bring the JS and Python halves of `network.egressProxy` / `network["egress_proxy"]` back in line for callers that bypass the types, and stop `null` credentials from reaching the wire. - -A malformed proxy now raises `InvalidArgumentError` / `InvalidArgumentException` in both SDKs. Before, only Python did; JS rebuilt the body from the known fields, so a proxy passed as a bare string sent `{}` and the caller got an API error about a field they never left out: - -```ts -// Now: InvalidArgumentError, naming the option you typed. -// Before: sent `"egressProxy": {}` and failed at the API. -await Sandbox.create({ - network: { egressProxy: 'proxy.example.com:1080' as never }, -}) -``` - -A `username` or `password` that is `null` / `None` is treated as "no credentials" rather than serialized as a JSON null the API rejects — the same reading both SDKs already gave `egressProxy: null` itself: - -```ts -await Sandbox.create({ - network: { - egressProxy: { - address: 'proxy.example.com:1080', - // Unset in the environment; the proxy takes no credentials. - username: process.env.PROXY_USER, - }, - }, -}) -``` - -```python -Sandbox.create( - network={ - "egress_proxy": { - "address": "proxy.example.com:1080", - "username": os.environ.get("PROXY_USER"), - }, - }, -) -``` - -`getInfo` / `get_info` normalizes a `null` `username` the same way, so `SandboxEgressProxyInfo.username` is `undefined` / an absent key rather than a null that its type says cannot be there. diff --git a/packages/js-sdk/CHANGELOG.md b/packages/js-sdk/CHANGELOG.md index 9b7e102b25..c0002e840f 100644 --- a/packages/js-sdk/CHANGELOG.md +++ b/packages/js-sdk/CHANGELOG.md @@ -267,7 +267,7 @@ ```python info = sandbox.get_info() - print(info.network.get("egress_proxy")) + print(info.network["egress_proxy"]) # {'address': 'proxy.example.com:1080', 'username': 'proxy-user'} ``` diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 918d4c656e..04fc4effa8 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -1009,30 +1009,20 @@ function resolveRulesForBody( /** * Rebuild the proxy config from the known fields so stray properties on the * caller's object never reach the wire and a later mutation of it cannot alter - * the in-flight request. Address reachability is the server's — it is the only - * side that can tell whether the address resolves, and to where. + * the in-flight request. Validation is the server's — it is the only side that + * can tell whether the address resolves, and to where. */ function buildEgressProxyBody( egressProxy: SandboxEgressProxyOpts ): components['schemas']['SandboxEgressProxyConfig'] { - // Re-check at runtime for callers that bypass the type — rebuilding from the - // known fields drops an address that isn't there, and the API error for the - // resulting `{}` names neither the option the caller typed nor the mistake. - // Python raises `InvalidArgumentException` on the same input. - if (typeof egressProxy.address !== 'string') { - throw new InvalidArgumentError( - "network egressProxy must be an object with a string 'address' " + - "(e.g. 'proxy.example.com:1080')." - ) - } - return { address: egressProxy.address, - // `!= null` so a credential read out of an unset environment variable - // reads as "no credentials" rather than reaching the wire as JSON null, - // which the API rejects. Same reasoning as `egressProxy: null` itself. - ...(egressProxy.username != null ? { username: egressProxy.username } : {}), - ...(egressProxy.password != null ? { password: egressProxy.password } : {}), + ...(egressProxy.username !== undefined + ? { username: egressProxy.username } + : {}), + ...(egressProxy.password !== undefined + ? { password: egressProxy.password } + : {}), } } @@ -1076,9 +1066,8 @@ function buildNetworkEgress( /** * Map the wire proxy config into the SDK-owned shape: `password` is dropped - * because the API never returns it, and the wire's `null` — for "no proxy" and - * for an anonymous proxy's `username` alike — is normalized so it never reaches - * a consumer typed to see `undefined`. + * because the API never returns it, and the wire's `null` for "no proxy" is + * normalized so the union never reaches a consumer. */ function fromApiEgressProxy( egressProxy: components['schemas']['SandboxEgressProxyConfig'] | undefined @@ -1089,7 +1078,9 @@ function fromApiEgressProxy( return { address: egressProxy.address, - ...(egressProxy.username != null ? { username: egressProxy.username } : {}), + ...(egressProxy.username !== undefined + ? { username: egressProxy.username } + : {}), } } diff --git a/packages/js-sdk/tests/sandbox/egressProxy.test.ts b/packages/js-sdk/tests/sandbox/egressProxy.test.ts index d6087bc77e..b861fd2df7 100644 --- a/packages/js-sdk/tests/sandbox/egressProxy.test.ts +++ b/packages/js-sdk/tests/sandbox/egressProxy.test.ts @@ -2,7 +2,7 @@ import { afterAll, afterEach, beforeAll, expect, test } from 'vitest' import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' -import { InvalidArgumentError, Sandbox } from '../../src' +import { Sandbox } from '../../src' import { TEST_API_KEY, apiUrl } from '../setup' const sandboxId = 'test-sandbox-id' @@ -101,64 +101,14 @@ test('Sandbox.create combines the egress proxy with allow and deny lists', async }) }) -test.for([ - ['omitted', { allowOut: ['api.example.com'] }], - // Untyped callers spell "no proxy" as null; Python treats an explicit None - // the same way. - ['null', { egressProxy: null }], -])( - 'Sandbox.create omits the egress proxy when it is %s', - async ([, network]: [string, Record]) => { - await Sandbox.create('base', { - apiKey: TEST_API_KEY, - network, - }) - - expect(lastCreateBody?.network).toBeDefined() - expect(lastCreateBody?.network).not.toHaveProperty('egressProxy') - } -) - -test.for([ - // An empty object is falsy but present — it must not silently disable - // tunneling. Match Python: fail loudly. - ['empty', {}], - ['missing-address', { username: 'proxy-user' }], - ['non-string-address', { address: 1080 }], - ['string', 'proxy.example.com:1080'], -])( - 'Sandbox.create rejects a %s egress proxy', - async ([, egressProxy]: [string, unknown]) => { - // Rebuilding from the known fields drops an address that isn't there, so - // without this the caller gets an API error about a `{}` they never wrote. - await expect( - Sandbox.create('base', { - apiKey: TEST_API_KEY, - network: { egressProxy } as never, - }) - ).rejects.toThrow(InvalidArgumentError) - - expect(lastCreateBody).toBeUndefined() - } -) - -test('Sandbox.create omits credentials that are null', async () => { - // `{ username: process.env.PROXY_USER }` on an unset variable is the way - // this happens; a JSON null is rejected by the API. +test('Sandbox.create omits the egress proxy when not provided', async () => { await Sandbox.create('base', { apiKey: TEST_API_KEY, - network: { - egressProxy: { - address: 'proxy.example.com:1080', - username: null, - password: undefined, - } as never, - }, + network: { allowOut: ['api.example.com'] }, }) - expect(lastCreateBody?.network.egressProxy).toEqual({ - address: 'proxy.example.com:1080', - }) + expect(lastCreateBody?.network).toBeDefined() + expect(lastCreateBody?.network).not.toHaveProperty('egressProxy') }) test('Sandbox.create strips unknown egress proxy properties', async () => { @@ -249,20 +199,6 @@ test('getInfo drops a password the API unexpectedly returns', async () => { }) }) -test('getInfo drops a null username', async () => { - // `username?: string` says absence is `undefined`, so a null from the wire - // has to be normalized rather than handed to a consumer. - sandboxNetwork = { - egressProxy: { address: 'proxy.example.com:1080', username: null }, - } - - const info = await Sandbox.getInfo(sandboxId, { apiKey: TEST_API_KEY }) - - expect(info.network?.egressProxy).toEqual({ - address: 'proxy.example.com:1080', - }) -}) - test.for([ ['omitted', {}], ['null', { egressProxy: null }], diff --git a/packages/python-sdk/CHANGELOG.md b/packages/python-sdk/CHANGELOG.md index 5b1b2895fd..c19b593dee 100644 --- a/packages/python-sdk/CHANGELOG.md +++ b/packages/python-sdk/CHANGELOG.md @@ -243,7 +243,7 @@ ```python info = sandbox.get_info() - print(info.network.get("egress_proxy")) + print(info.network["egress_proxy"]) # {'address': 'proxy.example.com:1080', 'username': 'proxy-user'} ``` diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index a9177f07e8..d53c84915a 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -671,12 +671,9 @@ def _build_egress_proxy( ) body = ClientSandboxEgressProxyConfig(address=egress_proxy["address"]) - # `is not None` so a credential read out of an unset environment variable - # reads as "no credentials" rather than reaching the wire as JSON null, - # which the API rejects. Same reasoning as ``"egress_proxy": None`` itself. - if egress_proxy.get("username") is not None: + if "username" in egress_proxy: body.username = egress_proxy["username"] - if egress_proxy.get("password") is not None: + if "password" in egress_proxy: body.password = egress_proxy["password"] return body @@ -902,16 +899,15 @@ def _from_client_egress_proxy( ) -> Optional[SandboxEgressProxyInfo]: """ Map the wire proxy config into the SDK-owned shape: ``password`` is dropped - because the API never returns it, and the wire's ``None`` — for "no proxy" - and for an anonymous proxy's ``username`` alike — becomes an absent key. + because the API never returns it, and the wire's ``None`` for "no proxy" + becomes an absent key. """ if not isinstance(egress_proxy, ClientSandboxEgressProxyConfig): return None result: SandboxEgressProxyInfo = {"address": egress_proxy.address} - username = egress_proxy.username - if not isinstance(username, Unset) and username is not None: - result["username"] = username + if not isinstance(egress_proxy.username, Unset): + result["username"] = egress_proxy.username 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 182167699b..e097ee1711 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_egress_proxy.py +++ b/packages/python-sdk/tests/shared/sandbox/test_egress_proxy.py @@ -85,25 +85,6 @@ def test_create_rejects_a_malformed_egress_proxy(egress_proxy): build_network_config(cast(Any, {"egress_proxy": egress_proxy})) -def test_create_omits_credentials_that_are_none(): - # ``{"username": os.environ.get("PROXY_USER")}`` on an unset variable is the - # way this happens; a JSON null is rejected by the API. - body = build_network_config( - cast( - Any, - { - "egress_proxy": { - "address": "proxy.example.com:1080", - "username": None, - "password": None, - }, - }, - ) - ) - assert body is not None - assert body["egress_proxy"].to_dict() == {"address": "proxy.example.com:1080"} - - def test_create_strips_unknown_egress_proxy_keys(): # An untyped caller can copy an extra key out of a config file; the API # rejects unknown properties. @@ -164,22 +145,6 @@ def test_get_info_reports_the_active_egress_proxy_without_the_password(): } -def test_get_info_drops_a_none_username(): - # ``username`` is ``NotRequired[str]``, so absence is a missing key — a None - # from the wire has to be normalized rather than handed to a caller. - info = from_client_network_config( - SandboxNetworkConfig( - egress_proxy=ClientSandboxEgressProxyConfig( - address="proxy.example.com:1080", - username=cast(Any, None), - ) - ) - ) - - assert info is not None - assert info["egress_proxy"] == {"address": "proxy.example.com:1080"} - - @pytest.mark.parametrize( "egress_proxy", [ From aa59e1f68c8c71ed003a35d5bde139bb444a635b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:52:18 +0000 Subject: [PATCH 07/10] Pin explicit 300s timeout in test sandbox fixtures Fixture sandboxes previously inherited the SDK's 300s create default; after removing SDK-side defaults they would fall back to the API's 15s default, making long-running integration tests flaky. Co-Authored-By: mish@e2b.dev --- packages/js-sdk/tests/setup.ts | 1 + packages/python-sdk/tests/conftest.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/js-sdk/tests/setup.ts b/packages/js-sdk/tests/setup.ts index 91d427c3cc..90a1774788 100644 --- a/packages/js-sdk/tests/setup.ts +++ b/packages/js-sdk/tests/setup.ts @@ -82,6 +82,7 @@ export const sandboxTest = base.extend({ async ({ sandboxTestId, sandboxOpts }, use) => { const sandbox = await Sandbox.create(template, { metadata: { sandboxTestId }, + timeoutMs: 300_000, ...sandboxOpts, }) onTestFailed(() => { diff --git a/packages/python-sdk/tests/conftest.py b/packages/python-sdk/tests/conftest.py index fa8f4a0949..9847f460e7 100644 --- a/packages/python-sdk/tests/conftest.py +++ b/packages/python-sdk/tests/conftest.py @@ -72,6 +72,7 @@ def sandbox_factory(request, template, sandbox_test_id): def factory(*, template_name: str = template, **kwargs): metadata = kwargs.setdefault("metadata", dict()) metadata.setdefault("sandbox_test_id", sandbox_test_id) + kwargs.setdefault("timeout", 300) sandbox = Sandbox.create(template_name, **kwargs) @@ -99,6 +100,7 @@ async def async_sandbox_factory(request, template, sandbox_test_id): async def factory(*, template_name: str = template, **kwargs): metadata = kwargs.setdefault("metadata", dict()) metadata.setdefault("sandbox_test_id", sandbox_test_id) + kwargs.setdefault("timeout", 300) sandbox = await AsyncSandbox.create(template_name, **kwargs) sandboxes.append(sandbox) From e4d7b95e8c47b50fe3f1304073502b9c5e79dbd6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:15:36 +0000 Subject: [PATCH 08/10] Address review: add template build payload tests, mirror connect timeout default, simplify order docs Co-Authored-By: mish@e2b.dev --- .changeset/olive-poets-hammer.md | 2 +- packages/js-sdk/src/sandbox/sandboxApi.ts | 4 +- .../js-sdk/tests/template/apiDefaults.test.ts | 52 ++++++++++++++ .../e2b/sandbox_async/sandbox_api.py | 2 +- .../e2b/sandbox_sync/sandbox_api.py | 2 +- .../shared/template/test_api_defaults.py | 68 +++++++++++++++++++ 6 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 packages/js-sdk/tests/template/apiDefaults.test.ts create mode 100644 packages/python-sdk/tests/shared/template/test_api_defaults.py diff --git a/.changeset/olive-poets-hammer.md b/.changeset/olive-poets-hammer.md index 60390a1477..b5715e5fb8 100644 --- a/.changeset/olive-poets-hammer.md +++ b/.changeset/olive-poets-hammer.md @@ -3,4 +3,4 @@ '@e2b/python-sdk': minor --- -Remove SDK-side defaults from API request payloads so the API defaults apply when options are omitted. Sandbox create/fork no longer preset a 5-minute timeout, fork no longer presets `count: 1`, create no longer presets `allow_internet_access` (sandboxes remain `secure` by default), pause no longer presets keeping memory, and template builds no longer preset CPU/memory. Explicitly provided values are still sent unchanged. +Remove SDK-side defaults from API request payloads so the API defaults apply when options are omitted. Sandbox create/fork no longer preset a 5-minute timeout, fork no longer presets `count: 1`, create no longer presets `allow_internet_access` (sandboxes remain `secure` by default), pause no longer presets keeping memory, and template builds no longer preset CPU/memory. Explicitly provided values are still sent unchanged. `connect` keeps the SDK's 5-minute default because the API requires the `timeout` field in the connect request. diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 04fc4effa8..5523263d9b 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -1160,6 +1160,8 @@ function buildNetworkUpdateBody( } } export class SandboxApi extends ClientFactory { + protected static readonly defaultSandboxTimeoutMs = DEFAULT_SANDBOX_TIMEOUT_MS + protected constructor() { super() } @@ -1767,7 +1769,7 @@ export class SandboxApi extends ClientFactory { opts?: SandboxConnectOpts ) { const apiOpts = this.resolveOpts(opts) - const timeoutMs = apiOpts?.timeoutMs ?? DEFAULT_SANDBOX_TIMEOUT_MS + const timeoutMs = apiOpts?.timeoutMs ?? this.defaultSandboxTimeoutMs const config = new ConnectionConfig(apiOpts) const client = new ApiClient(config) diff --git a/packages/js-sdk/tests/template/apiDefaults.test.ts b/packages/js-sdk/tests/template/apiDefaults.test.ts new file mode 100644 index 0000000000..0c1fcbb7f7 --- /dev/null +++ b/packages/js-sdk/tests/template/apiDefaults.test.ts @@ -0,0 +1,52 @@ +import { afterAll, afterEach, beforeAll, expect, test } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' + +import { ApiClient } from '../../src/api' +import { ConnectionConfig } from '../../src/connectionConfig' +import { requestBuild } from '../../src/template/buildApi' +import { TEST_API_KEY, apiUrl } from '../setup' + +let lastBuildBody: Record | undefined + +const server = setupServer( + http.post(apiUrl('/v3/templates'), async ({ request }) => { + lastBuildBody = (await request.json()) as Record + return HttpResponse.json({ + templateID: 'test-template-id', + buildID: 'test-build-id', + }) + }) +) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + +afterAll(() => server.close()) + +afterEach(() => { + lastBuildBody = undefined + server.resetHandlers() +}) + +function client() { + return new ApiClient(new ConnectionConfig({ apiKey: TEST_API_KEY })) +} + +test('template build request omits cpuCount and memoryMB when unset', async () => { + await requestBuild(client(), { name: 'test-template' }) + + expect(lastBuildBody).toBeDefined() + expect(lastBuildBody).not.toHaveProperty('cpuCount') + expect(lastBuildBody).not.toHaveProperty('memoryMB') +}) + +test('template build request sends explicit cpuCount and memoryMB', async () => { + await requestBuild(client(), { + name: 'test-template', + cpuCount: 1, + memoryMB: 512, + }) + + expect(lastBuildBody?.cpuCount).toBe(1) + expect(lastBuildBody?.memoryMB).toBe(512) +}) diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index 525aae8446..41dd446df2 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -84,7 +84,7 @@ def list( :param query: Filter the list of sandboxes by metadata, state, start time, or template, e.g. `SandboxQuery(metadata={"key": "value"})` or `SandboxQuery(state=[SandboxState.RUNNING])` :param limit: Maximum number of sandboxes to return per page :param next_token: Token for pagination - :param order: Sort order of the list of sandboxes by start time, applied across the whole result set before pagination (not within a page), when not set, the API default (currently `"desc"`, newest first) applies + :param order: Sort order of the list of sandboxes by start time, applied across the whole result set before pagination (not within a page) :return: An `AsyncSandboxPaginator` that yields pages of sandboxes (running and paused by default). Iterate pages via `await paginator.next_items()` while `paginator.has_next` is True. """ diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index 33f835ae93..f950595ace 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -83,7 +83,7 @@ def list( :param query: Filter the list of sandboxes by metadata, state, start time, or template, e.g. `SandboxQuery(metadata={"key": "value"})` or `SandboxQuery(state=[SandboxState.RUNNING])` :param limit: Maximum number of sandboxes to return per page :param next_token: Token for pagination - :param order: Sort order of the list of sandboxes by start time, applied across the whole result set before pagination (not within a page), when not set, the API default (currently `"desc"`, newest first) applies + :param order: Sort order of the list of sandboxes by start time, applied across the whole result set before pagination (not within a page) :return: A `SandboxPaginator` that yields pages of sandboxes (running and paused by default). Iterate pages via `paginator.next_items()` while `paginator.has_next` is True. """ diff --git a/packages/python-sdk/tests/shared/template/test_api_defaults.py b/packages/python-sdk/tests/shared/template/test_api_defaults.py new file mode 100644 index 0000000000..da061fbf90 --- /dev/null +++ b/packages/python-sdk/tests/shared/template/test_api_defaults.py @@ -0,0 +1,68 @@ +from types import SimpleNamespace +from typing import Any, Dict +from unittest.mock import AsyncMock, Mock + +from e2b.api.client.api.templates import post_v3_templates +from e2b.api.client.models import TemplateRequestResponseV3 +from e2b.template_async.build_api import request_build as async_request_build +from e2b.template_sync.build_api import request_build as sync_request_build + + +def _build_response(): + return SimpleNamespace( + status_code=200, + parsed=TemplateRequestResponseV3( + template_id="template-id", + build_id="build-id", + public=False, + names=[], + tags=[], + aliases=[], + ), + ) + + +def _sync_build_body(monkeypatch, **kwargs) -> Dict[str, Any]: + request = Mock(return_value=_build_response()) + monkeypatch.setattr(post_v3_templates, "sync_detailed", request) + + sync_request_build(Mock(), name="test-template", tags=None, **kwargs) + + return request.call_args.kwargs["body"].to_dict() + + +async def _async_build_body(monkeypatch, **kwargs) -> Dict[str, Any]: + request = AsyncMock(return_value=_build_response()) + monkeypatch.setattr(post_v3_templates, "asyncio_detailed", request) + + await async_request_build(Mock(), name="test-template", tags=None, **kwargs) + + return request.call_args.kwargs["body"].to_dict() + + +def test_build_omits_cpu_and_memory_when_unset(monkeypatch): + body = _sync_build_body(monkeypatch, cpu_count=None, memory_mb=None) + + assert "cpuCount" not in body + assert "memoryMB" not in body + + +def test_build_sends_explicit_cpu_and_memory(monkeypatch): + body = _sync_build_body(monkeypatch, cpu_count=1, memory_mb=512) + + assert body["cpuCount"] == 1 + assert body["memoryMB"] == 512 + + +async def test_async_build_omits_cpu_and_memory_when_unset(monkeypatch): + body = await _async_build_body(monkeypatch, cpu_count=None, memory_mb=None) + + assert "cpuCount" not in body + assert "memoryMB" not in body + + +async def test_async_build_sends_explicit_cpu_and_memory(monkeypatch): + body = await _async_build_body(monkeypatch, cpu_count=1, memory_mb=512) + + assert body["cpuCount"] == 1 + assert body["memoryMB"] == 512 From a06670b02a314a9bbf862b92df70c82e716f8a05 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:55:53 +0000 Subject: [PATCH 09/10] Drop SDK-side connect timeout and secure defaults Co-Authored-By: mish@e2b.dev --- .changeset/olive-poets-hammer.md | 2 +- packages/js-sdk/src/sandbox/sandboxApi.ts | 17 +++---- .../js-sdk/tests/sandbox/apiDefaults.test.ts | 30 +++++++++++- .../python-sdk/e2b/sandbox/sandbox_api.py | 16 +++++++ packages/python-sdk/e2b/sandbox_async/main.py | 2 +- .../e2b/sandbox_async/sandbox_api.py | 8 ++-- packages/python-sdk/e2b/sandbox_sync/main.py | 2 +- .../e2b/sandbox_sync/sandbox_api.py | 8 ++-- .../tests/shared/sandbox/test_api_defaults.py | 47 ++++++++++++++++++- 9 files changed, 104 insertions(+), 28 deletions(-) diff --git a/.changeset/olive-poets-hammer.md b/.changeset/olive-poets-hammer.md index b5715e5fb8..9050d567f2 100644 --- a/.changeset/olive-poets-hammer.md +++ b/.changeset/olive-poets-hammer.md @@ -3,4 +3,4 @@ '@e2b/python-sdk': minor --- -Remove SDK-side defaults from API request payloads so the API defaults apply when options are omitted. Sandbox create/fork no longer preset a 5-minute timeout, fork no longer presets `count: 1`, create no longer presets `allow_internet_access` (sandboxes remain `secure` by default), pause no longer presets keeping memory, and template builds no longer preset CPU/memory. Explicitly provided values are still sent unchanged. `connect` keeps the SDK's 5-minute default because the API requires the `timeout` field in the connect request. +Remove SDK-side defaults from API request payloads so the API defaults apply when options are omitted. Sandbox create/fork/connect no longer preset a 5-minute timeout, fork no longer presets `count: 1`, create no longer presets `secure: true` or `allow_internet_access`, pause no longer presets keeping memory, and template builds no longer preset CPU/memory. Explicitly provided values are still sent unchanged. Note: until the API-side defaults for `secure` and connect `timeout` are deployed, omitting them changes behavior (sandboxes are created unsecured and connect requests without a timeout are rejected). diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 5523263d9b..4c39d98ef9 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -3,7 +3,6 @@ import { ClientFactory, ConnectionConfig, ConnectionOpts, - DEFAULT_SANDBOX_TIMEOUT_MS, } from '../connectionConfig' import { compareVersions } from 'compare-versions' import { ALL_TRAFFIC } from './network' @@ -589,8 +588,6 @@ export interface SandboxOpts extends ConnectionOpts { /** * Secure all traffic coming to the sandbox controller with auth token - * - * @default true */ secure?: boolean @@ -663,8 +660,6 @@ export type SandboxConnectOpts = ConnectionOpts & { * Timeout for the sandbox in **milliseconds**. * For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. * Maximum time a sandbox can be kept alive is 24 hours (86_400_000 milliseconds) for Pro users and 1 hour (3_600_000 milliseconds) for Hobby users. - * - * @default 300_000 // 5 minutes */ timeoutMs?: number } @@ -1160,8 +1155,6 @@ function buildNetworkUpdateBody( } } export class SandboxApi extends ClientFactory { - protected static readonly defaultSandboxTimeoutMs = DEFAULT_SANDBOX_TIMEOUT_MS - protected constructor() { super() } @@ -1648,7 +1641,7 @@ export class SandboxApi extends ClientFactory { envVars: opts?.envs, timeout: timeoutMs === undefined ? undefined : timeoutToSeconds(timeoutMs), - secure: opts?.secure ?? true, + secure: opts?.secure, allow_internet_access: opts?.allowInternetAccess, network: buildNetworkBody(opts?.network, iam), iam, @@ -1769,7 +1762,7 @@ export class SandboxApi extends ClientFactory { opts?: SandboxConnectOpts ) { const apiOpts = this.resolveOpts(opts) - const timeoutMs = apiOpts?.timeoutMs ?? this.defaultSandboxTimeoutMs + const timeoutMs = apiOpts?.timeoutMs const config = new ConnectionConfig(apiOpts) const client = new ApiClient(config) @@ -1780,9 +1773,11 @@ export class SandboxApi extends ClientFactory { sandboxID: sandboxId, }, }, + // TODO: drop the cast once the API spec makes `timeout` optional body: { - timeout: timeoutToSeconds(timeoutMs), - }, + timeout: + timeoutMs === undefined ? undefined : timeoutToSeconds(timeoutMs), + } as components['schemas']['ConnectSandbox'], signal: config.getSignal(apiOpts?.requestTimeoutMs, apiOpts?.signal), }) diff --git a/packages/js-sdk/tests/sandbox/apiDefaults.test.ts b/packages/js-sdk/tests/sandbox/apiDefaults.test.ts index cc8ce3526a..0783744318 100644 --- a/packages/js-sdk/tests/sandbox/apiDefaults.test.ts +++ b/packages/js-sdk/tests/sandbox/apiDefaults.test.ts @@ -8,6 +8,7 @@ import { TEST_API_KEY, apiUrl } from '../setup' let lastCreateBody: Record | undefined let lastForkBody: Record | undefined let lastPauseBody: Record | undefined +let lastConnectBody: Record | undefined const server = setupServer( http.post(apiUrl('/sandboxes'), async ({ request }) => { @@ -18,6 +19,14 @@ const server = setupServer( envdVersion: '0.2.4', }) }), + 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', + }) + }), http.post(apiUrl('/sandboxes/:sandboxID/fork'), async ({ request }) => { lastForkBody = (await request.json()) as Record return HttpResponse.json([ @@ -44,15 +53,16 @@ afterEach(() => { lastCreateBody = undefined lastForkBody = undefined lastPauseBody = undefined + lastConnectBody = undefined server.resetHandlers() }) -test('Sandbox.create omits timeout and allow_internet_access when unset and defaults secure to true', async () => { +test('Sandbox.create omits timeout, secure and allow_internet_access when unset', async () => { await Sandbox.create('base', { apiKey: TEST_API_KEY }) expect(lastCreateBody).toBeDefined() expect(lastCreateBody).not.toHaveProperty('timeout') - expect(lastCreateBody?.secure).toBe(true) + expect(lastCreateBody).not.toHaveProperty('secure') expect(lastCreateBody).not.toHaveProperty('allow_internet_access') }) @@ -103,3 +113,19 @@ test('Sandbox.pause sends an explicit keepMemory', async () => { expect(lastPauseBody?.memory).toBe(false) }) + +test('Sandbox.connect omits timeout when unset', async () => { + await Sandbox.connect('test-sandbox-id', { apiKey: TEST_API_KEY }) + + expect(lastConnectBody).toBeDefined() + expect(lastConnectBody).not.toHaveProperty('timeout') +}) + +test('Sandbox.connect sends an explicit timeout', async () => { + await Sandbox.connect('test-sandbox-id', { + apiKey: TEST_API_KEY, + timeoutMs: 60_000, + }) + + expect(lastConnectBody?.timeout).toBe(60) +}) diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index d53c84915a..9b80d05163 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -18,6 +18,7 @@ from typing_extensions import NotRequired, Unpack from e2b.api.client.models import ( + ConnectSandbox, ListedSandbox, SandboxDetail, SandboxState, @@ -73,6 +74,21 @@ from e2b.paginator import PaginatorBase +class ConnectSandboxBody(ConnectSandbox): + """Connect request body that omits `timeout` when not provided so the + API default applies. The generated model still requires `timeout`; + remove this once the spec makes it optional.""" + + def __init__(self, timeout: Optional[int] = None): + super().__init__(timeout=cast(int, timeout)) + + def to_dict(self) -> Dict[str, Any]: + result = super().to_dict() + if result["timeout"] is None: + del result["timeout"] + return result + + class GitHubMcpServerConfig(TypedDict): """ Configuration for a GitHub-based MCP server. diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 4257fbb511..ed578f746e 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -191,7 +191,7 @@ async def create( :param timeout: Timeout for the sandbox in **seconds**. The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. :param metadata: Custom metadata for the sandbox :param envs: Custom environment variables for the sandbox - :param secure: Envd is secured with access token and cannot be used without it. Defaults to `True`. + :param secure: Envd is secured with access token and cannot be used without it. :param allow_internet_access: Allow sandbox to access the internet. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`. :param mcp: MCP server to enable in the sandbox :param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``; a rule's ``transform`` may be a callable receiving a :class:`SandboxNetworkTransformContext` of placeholder strings (``ctx.iam.tokens[name]``). diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index 41dd446df2..035053e3cb 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -25,7 +25,6 @@ ) from e2b.api.client.api.templates import delete_templates_template_id from e2b.api.client.models import ( - ConnectSandbox, Error, NewSandbox, SandboxSnapshotRequest, @@ -48,6 +47,7 @@ from e2b.sandbox.main import SandboxBase from e2b.sandbox.sandbox_api import ( build_network_update_body, + ConnectSandboxBody, McpServer, SandboxIamOpts, SandboxInfo, @@ -239,7 +239,7 @@ async def _create_sandbox( timeout=timeout if timeout is not None else UNSET, env_vars=env_vars or {}, mcp=cast(Any, mcp) or UNSET, - secure=secure if secure is not None else True, + secure=secure if secure is not None else UNSET, allow_internet_access=( allow_internet_access if allow_internet_access is not None else UNSET ), @@ -530,8 +530,6 @@ async def _cls_connect( logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> SandboxCreateResponse: - timeout = timeout or SandboxBase.default_sandbox_timeout - # Sandbox is not running, resume it config = ConnectionConfig(logger=logger, **cls._resolve_api_params(**opts)) @@ -539,7 +537,7 @@ async def _cls_connect( res = await post_sandboxes_sandbox_id_connect.asyncio_detailed( sandbox_id, client=api_client, - body=ConnectSandbox(timeout=timeout), + body=ConnectSandboxBody(timeout=timeout), ) 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 208a7049dc..31567e9909 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -187,7 +187,7 @@ def create( :param timeout: Timeout for the sandbox in **seconds**. The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. :param metadata: Custom metadata for the sandbox :param envs: Custom environment variables for the sandbox - :param secure: Envd is secured with access token and cannot be used without it. Defaults to `True`. + :param secure: Envd is secured with access token and cannot be used without it. :param allow_internet_access: Allow sandbox to access the internet. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`. :param mcp: MCP server to enable in the sandbox :param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``; a rule's ``transform`` may be a callable receiving a :class:`SandboxNetworkTransformContext` of placeholder strings (``ctx.iam.tokens[name]``). diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index f950595ace..10eee48f14 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -25,7 +25,6 @@ ) from e2b.api.client.api.templates import delete_templates_template_id from e2b.api.client.models import ( - ConnectSandbox, Error, NewSandbox, SandboxSnapshotRequest, @@ -47,6 +46,7 @@ from e2b.sandbox.main import SandboxBase from e2b.sandbox.sandbox_api import ( build_network_update_body, + ConnectSandboxBody, McpServer, SandboxIamOpts, SandboxInfo, @@ -238,7 +238,7 @@ def _create_sandbox( timeout=timeout if timeout is not None else UNSET, env_vars=env_vars or {}, mcp=cast(Any, mcp) or UNSET, - secure=secure if secure is not None else True, + secure=secure if secure is not None else UNSET, allow_internet_access=( allow_internet_access if allow_internet_access is not None else UNSET ), @@ -345,15 +345,13 @@ def _cls_connect( logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> SandboxCreateResponse: - timeout = timeout or SandboxBase.default_sandbox_timeout - config = ConnectionConfig(logger=logger, **cls._resolve_api_params(**opts)) api_client = get_api_client(config) res = post_sandboxes_sandbox_id_connect.sync_detailed( sandbox_id, client=api_client, - body=ConnectSandbox(timeout=timeout), + body=ConnectSandboxBody(timeout=timeout), ) if res.status_code == 404: diff --git a/packages/python-sdk/tests/shared/sandbox/test_api_defaults.py b/packages/python-sdk/tests/shared/sandbox/test_api_defaults.py index e9f9f7a6b9..785f8d6b4b 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_api_defaults.py +++ b/packages/python-sdk/tests/shared/sandbox/test_api_defaults.py @@ -5,6 +5,7 @@ from e2b import AsyncSandbox, Sandbox from e2b.api.client.api.sandboxes import ( post_sandboxes, + post_sandboxes_sandbox_id_connect, post_sandboxes_sandbox_id_fork, post_sandboxes_sandbox_id_pause, ) @@ -45,7 +46,7 @@ def test_create_omits_api_owned_fields_when_unset(monkeypatch, test_api_key): body = _sync_create_body(monkeypatch, test_api_key) assert "timeout" not in body - assert body["secure"] is True + assert "secure" not in body assert "allow_internet_access" not in body @@ -69,7 +70,7 @@ async def test_async_create_omits_api_owned_fields_when_unset( body = await _async_create_body(monkeypatch, test_api_key) assert "timeout" not in body - assert body["secure"] is True + assert "secure" not in body assert "allow_internet_access" not in body @@ -175,3 +176,45 @@ async def test_async_pause_sends_explicit_keep_memory(monkeypatch, test_api_key) body = await _async_pause_body(monkeypatch, test_api_key, keep_memory=False) assert body["memory"] is False + + +def _sync_connect_body(monkeypatch, api_key: str, **kwargs) -> Dict[str, Any]: + request = Mock(return_value=_created_sandbox()) + monkeypatch.setattr(post_sandboxes_sandbox_id_connect, "sync_detailed", request) + + Sandbox.connect("sbx-test", api_key=api_key, **kwargs) + + return request.call_args.kwargs["body"].to_dict() + + +async def _async_connect_body(monkeypatch, api_key: str, **kwargs) -> Dict[str, Any]: + request = AsyncMock(return_value=_created_sandbox()) + monkeypatch.setattr(post_sandboxes_sandbox_id_connect, "asyncio_detailed", request) + + await AsyncSandbox.connect("sbx-test", api_key=api_key, **kwargs) + + return request.call_args.kwargs["body"].to_dict() + + +def test_connect_omits_timeout_when_unset(monkeypatch, test_api_key): + body = _sync_connect_body(monkeypatch, test_api_key) + + assert "timeout" not in body + + +def test_connect_sends_explicit_timeout(monkeypatch, test_api_key): + body = _sync_connect_body(monkeypatch, test_api_key, timeout=60) + + assert body["timeout"] == 60 + + +async def test_async_connect_omits_timeout_when_unset(monkeypatch, test_api_key): + body = await _async_connect_body(monkeypatch, test_api_key) + + assert "timeout" not in body + + +async def test_async_connect_sends_explicit_timeout(monkeypatch, test_api_key): + body = await _async_connect_body(monkeypatch, test_api_key, timeout=60) + + assert body["timeout"] == 60 From e9963a5dd7d46c6c1bc020984d926f0ea90a983a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:30:44 +0000 Subject: [PATCH 10/10] Remove client-side fork count validation per T-52 Co-Authored-By: mish@e2b.dev --- .changeset/witty-parrots-decide.md | 6 ++++++ packages/js-sdk/src/sandbox/sandboxApi.ts | 4 ---- packages/js-sdk/tests/sandbox/fork.test.ts | 10 ++-------- packages/python-sdk/e2b/sandbox_async/sandbox_api.py | 4 ---- packages/python-sdk/e2b/sandbox_sync/sandbox_api.py | 4 ---- .../python-sdk/tests/async/sandbox_async/test_fork.py | 7 +------ .../python-sdk/tests/sync/sandbox_sync/test_fork.py | 7 +------ 7 files changed, 10 insertions(+), 32 deletions(-) create mode 100644 .changeset/witty-parrots-decide.md diff --git a/.changeset/witty-parrots-decide.md b/.changeset/witty-parrots-decide.md new file mode 100644 index 0000000000..8b7793a719 --- /dev/null +++ b/.changeset/witty-parrots-decide.md @@ -0,0 +1,6 @@ +--- +'e2b': patch +'@e2b/python-sdk': patch +--- + +Remove client-side validation of the fork `count` argument. The API validates the requested fork count and rejects invalid values. diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 4c39d98ef9..761b7a734d 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -1693,10 +1693,6 @@ export class SandboxApi extends ClientFactory { count?: number, opts?: SandboxApiOpts ): Promise { - if (count !== undefined && count < 1) { - throw new InvalidArgumentError('count must be at least 1') - } - const apiOpts = this.resolveOpts(opts) const config = new ConnectionConfig(apiOpts) const client = new ApiClient(config) diff --git a/packages/js-sdk/tests/sandbox/fork.test.ts b/packages/js-sdk/tests/sandbox/fork.test.ts index da3d30b9a6..20e8a227a7 100644 --- a/packages/js-sdk/tests/sandbox/fork.test.ts +++ b/packages/js-sdk/tests/sandbox/fork.test.ts @@ -1,8 +1,8 @@ import { assert, expect, test } from 'vitest' -import { sandboxTest, isDebug, TEST_API_KEY } from '../setup.js' +import { sandboxTest, isDebug } from '../setup.js' import { Sandbox } from '../../src' -import { InvalidArgumentError, SandboxNotFoundError } from '../../src/errors' +import { SandboxNotFoundError } from '../../src/errors' sandboxTest.skipIf(isDebug)('fork a sandbox', async ({ sandbox }) => { await sandbox.files.write('/home/user/state.txt', 'state before fork') @@ -86,9 +86,3 @@ test.skipIf(isDebug)('fork a killed sandbox fails', async () => { await expect(sandbox.fork()).rejects.toThrowError(SandboxNotFoundError) }) - -test('fork with count lower than 1 fails', async () => { - await expect( - Sandbox.fork('sbx-test', { count: 0, apiKey: TEST_API_KEY }) - ).rejects.toThrowError(InvalidArgumentError) -}) diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index 035053e3cb..8866dafffe 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -38,7 +38,6 @@ from e2b.api.client_async import get_api_client from e2b.connection_config import ApiParams, ConnectionConfig from e2b.exceptions import ( - InvalidArgumentException, NotFoundException, SandboxException, SandboxNotFoundException, @@ -446,9 +445,6 @@ async def _cls_fork( logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> List[Union[SandboxCreateResponse, Exception]]: - if count is not None and count < 1: - raise InvalidArgumentException("count must be at least 1") - config = ConnectionConfig(logger=logger, **cls._resolve_api_params(**opts)) api_client = get_api_client(config) diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index 10eee48f14..0f9806d878 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -37,7 +37,6 @@ from e2b.api.client.types import UNSET, Unset from e2b.connection_config import ApiParams, ConnectionConfig from e2b.exceptions import ( - InvalidArgumentException, NotFoundException, SandboxException, SandboxNotFoundException, @@ -395,9 +394,6 @@ def _cls_fork( logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> List[Union[SandboxCreateResponse, Exception]]: - if count is not None and count < 1: - raise InvalidArgumentException("count must be at least 1") - config = ConnectionConfig(logger=logger, **cls._resolve_api_params(**opts)) api_client = get_api_client(config) diff --git a/packages/python-sdk/tests/async/sandbox_async/test_fork.py b/packages/python-sdk/tests/async/sandbox_async/test_fork.py index 19f8c7ef23..ab15402dc7 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_fork.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_fork.py @@ -1,7 +1,7 @@ import pytest from e2b import AsyncSandbox -from e2b.exceptions import InvalidArgumentException, SandboxNotFoundException +from e2b.exceptions import SandboxNotFoundException @pytest.mark.skip_debug() @@ -76,8 +76,3 @@ async def test_fork_killed_sandbox(async_sandbox_factory): with pytest.raises(SandboxNotFoundException): await sandbox.fork() - - -async def test_fork_invalid_count(): - with pytest.raises(InvalidArgumentException): - await AsyncSandbox.fork("sbx-test", count=0) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_fork.py b/packages/python-sdk/tests/sync/sandbox_sync/test_fork.py index 7159a53102..903431591b 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_fork.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_fork.py @@ -1,7 +1,7 @@ import pytest from e2b import Sandbox -from e2b.exceptions import InvalidArgumentException, SandboxNotFoundException +from e2b.exceptions import SandboxNotFoundException @pytest.mark.skip_debug() @@ -73,8 +73,3 @@ def test_fork_killed_sandbox(sandbox_factory): with pytest.raises(SandboxNotFoundException): sandbox.fork() - - -def test_fork_invalid_count(): - with pytest.raises(InvalidArgumentException): - Sandbox.fork("sbx-test", count=0)