diff --git a/.changeset/lifecycle-optional-on-timeout.md b/.changeset/lifecycle-optional-on-timeout.md new file mode 100644 index 0000000000..2e4348d856 --- /dev/null +++ b/.changeset/lifecycle-optional-on-timeout.md @@ -0,0 +1,33 @@ +--- +'e2b': patch +'@e2b/python-sdk': patch +--- + +Make `lifecycle.onTimeout` / `lifecycle["on_timeout"]` optional, so the "no timeout action configured" state the SDKs already put on the wire can actually be expressed by a typed caller, and treat a nullish `keepMemory` / `keep_memory` as unconfigured rather than as an explicit choice. + +```ts +import { Sandbox } from 'e2b' + +// Opt out of auto-resume without expressing a preference about the timeout +// action — previously a type error, since onTimeout was required. +await Sandbox.create({ lifecycle: { autoResume: false } }) + +// A keepMemory that spreads in as undefined is no longer sent as `true`, and no +// longer trips the pause-only guard on a kill action. +const keepMemory: boolean | undefined = undefined +await Sandbox.create({ lifecycle: { onTimeout: { action: 'pause', keepMemory } } }) +``` + +```python +from e2b import Sandbox + +# Opt out of auto-resume without expressing a preference about the timeout +# action — previously a type error, since on_timeout was required. +Sandbox.create(lifecycle={"auto_resume": False}) + +# A keep_memory that is None is no longer sent as True, and no longer trips the +# pause-only guard on a kill action. +Sandbox.create(lifecycle={"on_timeout": {"action": "pause", "keep_memory": None}}) +``` + +`autoResume: True` still requires an explicit `onTimeout` of `'pause'`; the error now names that knob instead of implying the SDK knows which action the API would have picked. diff --git a/.changeset/omit-auto-pause-when-lifecycle-unset.md b/.changeset/omit-auto-pause-when-lifecycle-unset.md index 618e398acf..97136f809e 100644 --- a/.changeset/omit-auto-pause-when-lifecycle-unset.md +++ b/.changeset/omit-auto-pause-when-lifecycle-unset.md @@ -3,7 +3,7 @@ '@e2b/python-sdk': patch --- -Omit `autoPause` from the create-sandbox request when no timeout lifecycle is configured, and omit `autoPauseMemory` unless `keepMemory` / `keep_memory` was chosen. Sending the SDK's local defaults for those fields was indistinguishable from an explicit choice, so the API could not tell "no preference" from a client choice and own its defaults. Explicit values are still always sent: +Omit `autoPause` from the create-sandbox request when no timeout lifecycle is configured, and omit `autoPauseMemory` unless `keepMemory` / `keep_memory` was chosen. Sending the SDK's local defaults for those fields was indistinguishable from an explicit choice, so the API could not tell "no preference" from a client choice and own its defaults. Explicit values are sent exactly as before: ```ts import { Sandbox } from 'e2b' diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 5e6f31177d..080ee9fb06 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -456,14 +456,15 @@ export type SandboxLifecycle = { * Omitted from the create request when unset, leaving the API's default * (currently `kill`) in effect. */ - onTimeout: SandboxOnTimeout + onTimeout?: SandboxOnTimeout /** * Auto-resume enabled flag. * * Leave unset to let the API pick the behavior. Set `false` to opt out * explicitly and keep auto-resume off even if the API's default changes. - * Can be `true` only when `onTimeout` is `pause`. Not supported when + * Can be `true` only alongside an explicit `onTimeout` of `pause`, because + * auto-resume only has meaning for a sandbox that pauses. Not supported when * `keepMemory` is `false` (a filesystem-only snapshot must be resumed * explicitly via `connect()`). */ @@ -1575,18 +1576,21 @@ export class SandboxApi { // `{ action, keepMemory }`. The discriminated union type forbids `keepMemory` // on `action: 'kill'`; re-check at runtime for untyped callers. const requestedOnTimeout = opts?.lifecycle?.onTimeout - // A missing (or explicitly nullish, for untyped callers) onTimeout is not a - // choice of `kill` — it leaves the timeout action to the API. Locally it - // still resolves to kill semantics for the validation below. + // A missing (or explicitly nullish) onTimeout is not a choice of `kill` — it + // leaves the timeout action to the API. The guards below therefore never + // assume what the API would pick; they only constrain what the caller said. const onTimeoutConfigured = requestedOnTimeout != null const onTimeout = requestedOnTimeout ?? 'kill' const action = typeof onTimeout === 'string' ? onTimeout : onTimeout.action - const hasKeepMemory = + // A nullish keepMemory is not a choice of snapshot kind, the same way a + // nullish onTimeout is not a choice of action: the field is left out of the + // request and the API's default applies. + const requestedKeepMemory = typeof onTimeout !== 'string' && 'keepMemory' in onTimeout - const keepMemory = - typeof onTimeout !== 'string' && 'keepMemory' in onTimeout - ? (onTimeout.keepMemory ?? true) - : true + ? onTimeout.keepMemory + : undefined + const hasKeepMemory = requestedKeepMemory != null + const keepMemory = requestedKeepMemory ?? true // A missing autoResume (or an explicit null from an untyped caller) is left // out of the request entirely, so the API keeps ownership of the default // instead of receiving the SDK's local one as an explicit opt-out. @@ -1599,8 +1603,13 @@ export class SandboxApi { } if (autoResume && action !== 'pause') { + // Without a configured action there is no `kill` to name — the SDK no + // longer decides what an unset onTimeout means — so point at the knob. + const hint = onTimeoutConfigured + ? '' + : " Set lifecycle.onTimeout to 'pause': leaving it unset defers the action to the API." throw new InvalidArgumentError( - "autoResume can only be true when onTimeout action is 'pause'." + `autoResume can only be true when onTimeout action is 'pause'.${hint}` ) } diff --git a/packages/js-sdk/tests/sandbox/lifecycleRequest.test.ts b/packages/js-sdk/tests/sandbox/lifecycleRequest.test.ts index e3c5bb6ce8..c910de88e5 100644 --- a/packages/js-sdk/tests/sandbox/lifecycleRequest.test.ts +++ b/packages/js-sdk/tests/sandbox/lifecycleRequest.test.ts @@ -93,17 +93,26 @@ test('Sandbox.create sends the pause snapshot kind alongside autoPause', async ( }) test('Sandbox.create omits autoPause for a lifecycle without onTimeout', async () => { - // Untyped callers can build the lifecycle conditionally and leave - // onTimeout out, or pass it as null; neither selects an action. + // onTimeout is optional, so opting out of auto-resume without expressing a + // preference about the timeout action is a typed call, not a cast. await Sandbox.create('base', { apiKey: TEST_API_KEY, - lifecycle: { autoResume: false } as never, + lifecycle: { autoResume: false }, }) expect(lastCreateBody).toBeDefined() expect(lastCreateBody).not.toHaveProperty('autoPause') expect(lastCreateBody?.autoResume).toEqual({ enabled: false }) + // An empty lifecycle expresses nothing at all. + await Sandbox.create('base', { apiKey: TEST_API_KEY, lifecycle: {} }) + + expect(lastCreateBody).toBeDefined() + expect(lastCreateBody).not.toHaveProperty('autoPause') + expect(lastCreateBody).not.toHaveProperty('autoResume') + + // Untyped callers can also pass onTimeout as null; that selects no action + // either. await Sandbox.create('base', { apiKey: TEST_API_KEY, lifecycle: { onTimeout: null } as never, @@ -115,16 +124,25 @@ test('Sandbox.create omits autoPause for a lifecycle without onTimeout', async ( }) test('Sandbox.create rejects autoResume without a timeout action', async () => { - // An unconfigured onTimeout still resolves to kill semantics locally, so - // autoResume has no pause to attach to. + // Auto-resume only has meaning for a sandbox that pauses, so it needs an + // explicit 'pause' rather than whichever action the API would have picked. await expect( Sandbox.create('base', { apiKey: TEST_API_KEY, - lifecycle: { autoResume: true } as never, + lifecycle: { autoResume: true }, }) ).rejects.toThrowError(InvalidArgumentError) expect(lastCreateBody).toBeUndefined() + + // The message points at the knob to turn instead of naming a default the SDK + // no longer decides. + await expect( + Sandbox.create('base', { + apiKey: TEST_API_KEY, + lifecycle: { autoResume: true }, + }) + ).rejects.toThrowError(/Set lifecycle.onTimeout to 'pause'/) }) test('an explicit autoResume: false is sent', async () => { @@ -154,3 +172,42 @@ test('an explicit null autoResume from an untyped caller is omitted', async () = expect(lastCreateBody).not.toHaveProperty('autoResume') }) + +test('a nullish keepMemory is not a choice of snapshot kind', async () => { + // Spreading an optional value in yields `keepMemory: undefined`, which is no + // more a choice than leaving the key out. + const unset: boolean | undefined = undefined + + await Sandbox.create('base', { + apiKey: TEST_API_KEY, + lifecycle: { onTimeout: { action: 'pause', keepMemory: unset } }, + }) + + expect(lastCreateBody?.autoPause).toBe(true) + expect(lastCreateBody).not.toHaveProperty('autoPauseMemory') + + // It therefore doesn't trip the pause-only guard on a kill action either. + await Sandbox.create('base', { + apiKey: TEST_API_KEY, + lifecycle: { onTimeout: { action: 'kill', keepMemory: unset } as never }, + }) + + expect(lastCreateBody?.autoPause).toBe(false) + expect(lastCreateBody).not.toHaveProperty('autoPauseMemory') +}) + +test('a nullish keepMemory still allows autoResume', async () => { + const unset: boolean | undefined = undefined + + await Sandbox.create('base', { + apiKey: TEST_API_KEY, + lifecycle: { + onTimeout: { action: 'pause', keepMemory: unset }, + autoResume: true, + }, + }) + + expect(lastCreateBody?.autoPause).toBe(true) + expect(lastCreateBody).not.toHaveProperty('autoPauseMemory') + expect(lastCreateBody?.autoResume).toEqual({ enabled: true }) +}) diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index c13d0d28b0..0d4978bc10 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -534,7 +534,7 @@ class SandboxLifecycle(TypedDict): `"kill"`); an omitted `auto_resume` leaves the choice to the API. """ - on_timeout: SandboxOnTimeout + on_timeout: NotRequired[SandboxOnTimeout] """ What should happen to the sandbox when timeout is reached. `"kill"` terminates the sandbox; `"pause"` pauses it for later resume. Accepts either the bare @@ -548,8 +548,9 @@ class SandboxLifecycle(TypedDict): """ Whether activity should cause the sandbox to resume when paused. Leave unset to let the API pick the behavior. Set `False` to opt out explicitly and keep - auto-resume off even if the API's default changes. Can be `True` only when - `on_timeout` is `pause`. Not supported when `keep_memory` is `False` + auto-resume off even if the API's default changes. Can be `True` only + alongside an explicit `on_timeout` of `pause`, because auto-resume only has + meaning for a sandbox that pauses. Not supported when `keep_memory` is `False` (a filesystem-only snapshot must be resumed explicitly via `connect()`). """ @@ -814,14 +815,17 @@ def build_lifecycle_config( # string, or an unexpected value from an untyped caller) passes through as # the action, so a non-"pause" value resolves to kill instead of crashing. on_timeout_raw = lifecycle.get("on_timeout") if lifecycle else None - # A missing on_timeout — or an explicit None from an untyped caller — is not - # a choice of kill. It only resolves to kill semantics locally, for the - # validation below and for keep_memory. + # A missing on_timeout — or an explicit None — is not a choice of kill. The + # guards below therefore never assume what the API would pick; they only + # constrain what the caller said. on_timeout_configured = on_timeout_raw is not None if isinstance(on_timeout_raw, dict): on_timeout = on_timeout_raw.get("action", "kill") - keep_memory_provided = "keep_memory" in on_timeout_raw + # A None keep_memory is not a choice of snapshot kind, the same way a + # None on_timeout is not a choice of action: the field is left out of + # the request and the API's default applies. keep_memory = on_timeout_raw.get("keep_memory") + keep_memory_provided = keep_memory is not None else: # Only fall back when unconfigured, not on other falsy-but-present # values an untyped caller might pass. @@ -845,9 +849,15 @@ def build_lifecycle_config( auto_resume = lifecycle.get("auto_resume") if lifecycle else None if auto_resume and on_timeout != "pause": - raise InvalidArgumentException( - "auto_resume can only be True when on_timeout action is 'pause'." - ) + message = "auto_resume can only be True when on_timeout action is 'pause'." + if not on_timeout_configured: + # Without a configured action there is no "kill" to name — the SDK no + # longer decides what an unset on_timeout means — so point at the knob. + message += ( + " Set lifecycle['on_timeout'] to 'pause': leaving it unset defers" + " the action to the API." + ) + raise InvalidArgumentException(message) if not keep_memory and auto_resume: raise InvalidArgumentException( diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 7e8cb3aa09..f9c6d0a37e 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -196,7 +196,7 @@ async def create( :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 - :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` or ``"pause"`` (omitted from the request when unset, leaving the API's default, currently ``"kill"``, in effect), or an object ``{"action": "pause"|"kill", "keep_memory": bool}`` where ``keep_memory`` set to ``False`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); an omitted ``keep_memory`` leaves the snapshot kind to the API; ``auto_resume``: leave unset to let the API pick the behavior, set ``False`` to opt out explicitly, or ``True`` (only when ``on_timeout`` action is ``"pause"``). Example: ``{"on_timeout": {"action": "pause", "keep_memory": False}}`` + :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` or ``"pause"`` (omitted from the request when unset, leaving the API's default, currently ``"kill"``, in effect), or an object ``{"action": "pause"|"kill", "keep_memory": bool}`` where ``keep_memory`` set to ``False`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); an omitted ``keep_memory`` leaves the snapshot kind to the API; ``auto_resume``: leave unset to let the API pick the behavior, set ``False`` to opt out explicitly, or ``True`` (only alongside an explicit ``on_timeout`` action of ``"pause"``). Example: ``{"on_timeout": {"action": "pause", "keep_memory": False}}`` :param volume_mounts: Dictionary mapping mount paths to AsyncVolume instances or volume names :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index d831b44cf0..5b5acfe0ce 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -192,7 +192,7 @@ def create( :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 - :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` or ``"pause"`` (omitted from the request when unset, leaving the API's default, currently ``"kill"``, in effect), or an object ``{"action": "pause"|"kill", "keep_memory": bool}`` where ``keep_memory`` set to ``False`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); an omitted ``keep_memory`` leaves the snapshot kind to the API; ``auto_resume``: leave unset to let the API pick the behavior, set ``False`` to opt out explicitly, or ``True`` (only when ``on_timeout`` action is ``"pause"``). Example: ``{"on_timeout": {"action": "pause", "keep_memory": False}}`` + :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` or ``"pause"`` (omitted from the request when unset, leaving the API's default, currently ``"kill"``, in effect), or an object ``{"action": "pause"|"kill", "keep_memory": bool}`` where ``keep_memory`` set to ``False`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); an omitted ``keep_memory`` leaves the snapshot kind to the API; ``auto_resume``: leave unset to let the API pick the behavior, set ``False`` to opt out explicitly, or ``True`` (only alongside an explicit ``on_timeout`` action of ``"pause"``). Example: ``{"on_timeout": {"action": "pause", "keep_memory": False}}`` :param volume_mounts: Dictionary mapping mount paths to Volume instances or volume names :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. diff --git a/packages/python-sdk/tests/shared/sandbox/test_lifecycle_request.py b/packages/python-sdk/tests/shared/sandbox/test_lifecycle_request.py index e52bab1eb3..54e6a8a8cd 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_lifecycle_request.py +++ b/packages/python-sdk/tests/shared/sandbox/test_lifecycle_request.py @@ -47,9 +47,10 @@ async def _async_request_body(monkeypatch, api_key: str, lifecycle) -> Dict[str, pytest.param(None, None, id="no-lifecycle"), pytest.param({"on_timeout": "kill"}, False, id="explicit-kill"), pytest.param({"on_timeout": "pause"}, True, id="explicit-pause"), - # Untyped callers can build the lifecycle conditionally and leave on_timeout - # out, or pass it as None; neither selects an action. - pytest.param(cast(Any, {"auto_resume": False}), None, id="no-on-timeout-key"), + # on_timeout is optional, so a lifecycle can leave it out entirely; untyped + # callers can also pass it as None. Neither selects an action. + pytest.param({}, None, id="empty-lifecycle"), + pytest.param({"auto_resume": False}, None, id="no-on-timeout-key"), pytest.param(cast(Any, {"on_timeout": None}), None, id="none-on-timeout"), ] @@ -115,38 +116,92 @@ def test_create_sends_the_pause_snapshot_kind_alongside_auto_pause( assert body["autoPauseMemory"] is True -@pytest.mark.parametrize( - "lifecycle", - [ - pytest.param(cast(Any, {"auto_resume": True}), id="no-on-timeout-key"), - pytest.param( - cast(Any, {"on_timeout": None, "auto_resume": True}), id="none-on-timeout" - ), - ], -) +NO_ACTION_AUTO_RESUME_CASES = [ + pytest.param({"auto_resume": True}, id="no-on-timeout-key"), + pytest.param( + cast(Any, {"on_timeout": None, "auto_resume": True}), id="none-on-timeout" + ), +] + + +@pytest.mark.parametrize("lifecycle", NO_ACTION_AUTO_RESUME_CASES) def test_create_rejects_auto_resume_without_a_timeout_action(test_api_key, lifecycle): - # An unconfigured on_timeout still resolves to kill semantics locally, so - # auto_resume has no pause to attach to. - with pytest.raises(InvalidArgumentException): + # Auto-resume only has meaning for a sandbox that pauses, so it needs an + # explicit "pause" rather than whichever action the API would have picked. + # The message points at the knob to turn instead of naming a default the SDK + # no longer decides. + with pytest.raises( + InvalidArgumentException, match=r"Set lifecycle\['on_timeout'\] to 'pause'" + ): Sandbox.create(api_key=test_api_key, lifecycle=lifecycle) -@pytest.mark.parametrize( - "lifecycle", - [ - pytest.param(cast(Any, {"auto_resume": True}), id="no-on-timeout-key"), - pytest.param( - cast(Any, {"on_timeout": None, "auto_resume": True}), id="none-on-timeout" - ), - ], -) +@pytest.mark.parametrize("lifecycle", NO_ACTION_AUTO_RESUME_CASES) async def test_async_create_rejects_auto_resume_without_a_timeout_action( test_api_key, lifecycle ): - with pytest.raises(InvalidArgumentException): + with pytest.raises( + InvalidArgumentException, match=r"Set lifecycle\['on_timeout'\] to 'pause'" + ): await AsyncSandbox.create(api_key=test_api_key, lifecycle=lifecycle) +def test_create_rejects_only_a_real_keep_memory_on_a_kill_action( + monkeypatch, test_api_key +): + with pytest.raises(InvalidArgumentException): + Sandbox.create( + api_key=test_api_key, + lifecycle=cast( + Any, {"on_timeout": {"action": "kill", "keep_memory": True}} + ), + ) + + # A None keep_memory is not a choice, so it doesn't trip the pause-only + # guard on a kill action. + body = _sync_request_body( + monkeypatch, + test_api_key, + cast(Any, {"on_timeout": {"action": "kill", "keep_memory": None}}), + ) + + assert body["autoPause"] is False + assert "autoPauseMemory" not in body + + +def test_create_treats_a_none_keep_memory_as_unconfigured(monkeypatch, test_api_key): + # Building the option dict from a value that happens to be None is no more a + # choice of snapshot kind than leaving the key out. + body = _sync_request_body( + monkeypatch, + test_api_key, + cast(Any, {"on_timeout": {"action": "pause", "keep_memory": None}}), + ) + + assert body["autoPause"] is True + assert "autoPauseMemory" not in body + + +def test_create_allows_auto_resume_with_an_unconfigured_keep_memory( + monkeypatch, test_api_key +): + body = _sync_request_body( + monkeypatch, + test_api_key, + cast( + Any, + { + "on_timeout": {"action": "pause", "keep_memory": None}, + "auto_resume": True, + }, + ), + ) + + assert body["autoPause"] is True + assert "autoPauseMemory" not in body + assert body["autoResume"] == {"enabled": True} + + # `None` expects autoResume to be absent from the payload: an unconfigured # preference is not an explicit opt-out, so the API keeps ownership of the # default instead of receiving {"enabled": False}.