Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/lifecycle-optional-on-timeout.md
Original file line number Diff line number Diff line change
@@ -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}})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sample does not typecheck, and the realistic version of it doesn't either. Written verbatim into a file and checked with the repo's own checker from packages/python-sdk, ty reports two diagnostics for this line (invalid-argument-type on the create call, plus one naming the on_timeout key), because NotRequired[bool] means "may be absent", not "may be None". The same is true of the call shape the PR is actually motivated by — a keep_memory: Optional[bool] variable that happens to be None — which adds two more. The JS twin twelve lines above compiles clean, so the fix this PR ships is reachable by a typed JS caller and not by a typed Python one. All three lifecycle keys share the trait: {"on_timeout": None}, {"auto_resume": None} and {"keep_memory": None} are each runtime-tolerated and statically rejected (6 diagnostics across those three calls).

Codex's P2 suggests widening the field to accept None. I'd push back on that, on two grounds. TASTE's spelling of absence is omission, not a null sentinel; and the surface is currently uniform about it — of the ~330 NotRequired[...] fields in the option TypedDicts (sandbox_api.py 17, mcp.py 302), zero accept None. The only nine NotRequired[Optional[...]] fields in the package are in CopyItem / Instruction (e2b/template/types.py:112-130), which are the camelCase serialized wire shapes rather than the option surface. Widening one key would also just move the asymmetry, since this PR made all three runtime-lenient — you'd need on_timeout and auto_resume too.

The smaller fix is the sample. Either spelling below typechecks clean, and I verified both produce a request body byte-identical to what the current sample produces at runtime (autoPause: true, autoPauseMemory absent):

# when there is genuinely nothing to say about the snapshot kind
Sandbox.create(lifecycle={"on_timeout": "pause"})

# when the value is conditional
Sandbox.create(
    lifecycle={
        "on_timeout": {
            "action": "pause",
            **({"keep_memory": keep_memory} if keep_memory is not None else {}),
        }
    }
)

Worth keeping the sentence about None being treated as unconfigured — the runtime leniency is still the right defensive behavior for untyped callers — but the published sample shouldn't be a snippet CI would reject.

```

`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.
2 changes: 1 addition & 1 deletion .changeset/omit-auto-pause-when-lifecycle-unset.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
31 changes: 20 additions & 11 deletions packages/js-sdk/src/sandbox/sandboxApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TASTE treats docstrings as part of the API, and the newly accepted spelling is documented only in the changeset. For a typed JS caller, { action: 'pause', keepMemory: undefined } changed behavior in this PR — it used to send autoPauseMemory: true and now omits it — but keepMemory's JSDoc (line 442) still says only "Left unset, the flag is omitted from the create request", which reads as "key absent" rather than "absent or explicitly undefined, e.g. spread in from an optional value". Same for this property: "Omitted from the create request when unset" doesn't tell the reader that an explicit undefined counts as unset. One clause on each would put the PR's own headline example in the published API docs, where the changeset won't be after the release.

While you're in this neighbourhood: this type has no type-level JSDoc at all, and SandboxOpts.lifecycle (line 662) is documented as "Sandbox lifecycle configuration." — whereas the Python mirror carries both a SandboxLifecycle class docstring stating the omitted-on_timeout semantics and a full :param lifecycle: paragraph in sandbox_sync/main.py and sandbox_async/main.py. Since the PR updates the Python paragraph in both mirrors, the JS side is the one that ends up saying less about the exact semantics this PR introduces.


/**
* 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()`).
*/
Expand Down Expand Up @@ -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.
Expand All @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same gap as the python twin, and istanbul shows it precisely: this cond-expr has counts [0, 2] — the hint arm runs twice, the '' arm never runs. It is the only uncovered branch arm in the block this PR touches (the neighbouring guards are healthy: hasKeepMemory && action !== 'pause' is [1, 258], autoResume && action !== 'pause' is [2, 256], !keepMemory && autoResume is [1, 255]).

Mutating this to a plain const hint = " Set lifecycle.onTimeout to 'pause': ..." keeps all 17 tests in lifecycleRequest.test.ts + lifecyclePayload.test.ts green.

One test closes it. I verified this passes at head and fails against that mutant:

test('the unset-onTimeout hint is omitted once an action was configured', async () => {
  await expect(
    Sandbox.create('base', {
      apiKey: TEST_API_KEY,
      lifecycle: { onTimeout: 'kill', autoResume: true },
    })
  ).rejects.toThrowError(
    /^autoResume can only be true when onTimeout action is 'pause'\.$/
  )
})

The anchored $ is what does the work — it is what makes the assertion about the hint's absence rather than just the base message.

? ''
: " 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}`
)
}

Expand Down
69 changes: 63 additions & 6 deletions packages/js-sdk/tests/sandbox/lifecycleRequest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} })

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing typechecks this file, so dropping the as never casts asserts nothing. packages/js-sdk/tsconfig.json has "include": ["src"], so pnpm run typecheck (tsc --noEmit) never sees tests/; I appended const _deliberate: number = "not a number" to this file and tsc --noEmit still exited 0. vitest transpiles without typechecking and oxlint has no type information, so no CI leg would notice either.

That matters because the type relaxation is this PR's headline change, and the runtime assertions can't see it: with the source reverted to base, this test — including the lifecycle: {} row on this line — still passes, since #1693 already omitted autoPause for a missing onTimeout. So the JS half of the change currently has no automated verification at all.

Cheapest options, in order of intrusiveness: a *.test-d.ts file under vitest's typecheck mode (no precedent in the repo yet), or a second tsconfig that includes tests for the typecheck script. Either one would also start pinning the negatives the file already relies on — { onTimeout: null } and { action: 'kill', keepMemory: undefined } are both still type errors today, which I confirmed with a @ts-expect-error table in src, but only by hand.


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,
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding to the TASTE review's finding on this file (which reproduces — I confirmed tsc --noEmit never reads tests/): here is a config that actually works, plus the trap in the obvious version.

The naive fix, adding these files to include, reports 3 TS4023 errors in tests/setup.ts that have nothing to do with the tests — volumeTest's inferred type references unnameable vitest internals. They appear only because tsconfig.json sets "declaration": true, which a --noEmit type-check pass does not need. Turning it off clears them:

{
  "extends": "./tsconfig.json",
  "compilerOptions": { "declaration": false },
  "include": [
    "src",
    "tests/sandbox/lifecycleRequest.test.ts",
    "tests/sandbox/lifecyclePayload.test.ts"
  ]
}

Measured both ways: 0 errors at head, and 4 TS2741 errors (lines 100, 108, 132, 143 of this file) once onTimeout is made required again. So it is a real guard on this PR's headline change, and it opts in only files that are already clean — unlike including all of tests/, which surfaces 37 pre-existing errors across 18 files.

It would also revive lifecyclePayload.test.ts:33, whose comment claims the discriminated union is "asserted by @ts-expect-error" — one of 34 such directives in js-sdk tests that are currently never evaluated.

// 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 })
})
30 changes: 20 additions & 10 deletions packages/python-sdk/e2b/sandbox/sandbox_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,7 @@
`"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
Expand All @@ -548,8 +548,9 @@
"""
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()`).
"""

Expand Down Expand Up @@ -811,20 +812,23 @@
"""
# on_timeout accepts a bare action or {"action", "keep_memory"}; normalize.
# Only the object form carries keep_memory; anything else (a bare action
# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept None in the Python keep_memory type

When keep_memory comes from an Optional[bool] configuration value and is None, this normalization now supports it at runtime, but the public SandboxOnTimeoutPause.keep_memory field remains NotRequired[bool]. Python type checkers therefore reject the newly documented call shape unless users cast to Any, unlike the equivalent JS field, which accepts an optional value. Widen the Python field to accept None so typed callers can use this behavior.

AGENTS.md reference: AGENTS.md:L3-L3

Useful? React with 👍 / 👎.

else:
# Only fall back when unconfigured, not on other falsy-but-present
# values an untyped caller might pass.

Check warning on line 831 in packages/python-sdk/e2b/sandbox/sandbox_api.py

View check run for this annotation

Claude / Claude Code Review

Python keep_memory typed as NotRequired[bool] rejects the Optional[bool] spread the PR advertises

`SandboxOnTimeoutPause.keep_memory` (line 492) is still typed `NotRequired[bool]`, but `build_lifecycle_config` now treats `keep_memory: None` as "unconfigured" and both the PR description and `.changeset/lifecycle-optional-on-timeout.md` advertise passing an `Optional[bool]` (e.g. `keep_memory: read_from_config()`) straight into `Sandbox.create(lifecycle=...)`. Under pyright that call fails type checking (`None is not assignable to bool`), forcing every None-`keep_memory` case — even for `act
Comment on lines 815 to 831

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 SandboxOnTimeoutPause.keep_memory (line 492) is still typed NotRequired[bool], but build_lifecycle_config now treats keep_memory: None as "unconfigured" and both the PR description and .changeset/lifecycle-optional-on-timeout.md advertise passing an Optional[bool] (e.g. keep_memory: read_from_config()) straight into Sandbox.create(lifecycle=...). Under pyright that call fails type checking (None is not assignable to bool), forcing every None-keep_memory case — even for action: "pause" — into cast(Any, ...) in the PR's own tests, unlike the JS side where keepMemory?: boolean widens to boolean | undefined with no cast. Fix: widen to NotRequired[Optional[bool]].

Extended reasoning...

This PR's runtime code in build_lifecycle_config (packages/python-sdk/e2b/sandbox/sandbox_api.py:815-831) explicitly treats a None keep_memory as "not configured" — it reads on_timeout_raw.get("keep_memory") and sets keep_memory_provided = keep_memory is not None, so the field is simply omitted from the wire request. That's the whole point of this PR: let a typed caller spread an Optional[bool] in without tripping validation. But the static type this flows through, SandboxOnTimeoutPause.keep_memory at line 492, is unchanged and still declared NotRequired[bool]. NotRequired only controls whether the key may be absent — it says nothing about the value being nullable. So a dict literal or variable typed Optional[bool] assigned to keep_memory is rejected by a static type checker even though the runtime happily accepts it.

I verified this empirically with pyright against the real package. Both of the exact patterns this PR's own description and changeset advertise fail to typecheck:

  • Sandbox.create(lifecycle={"on_timeout": {"action": "pause", "keep_memory": read_from_config()}}) where read_from_config() -> Optional[bool]
  • Sandbox.create(lifecycle={"on_timeout": {"action": "pause", "keep_memory": None}}) — the literal example from .changeset/lifecycle-optional-on-timeout.md

Both produce reportArgumentType/reportAssignmentType: "None is not assignable to bool". Meanwhile {"action": "pause", "keep_memory": True} and {"action": "pause"} (key omitted) both typecheck cleanly, confirming the problem is specifically the nullability of the value, not the presence of the key.

This is why the PR's own new Python tests (test_create_rejects_only_a_real_keep_memory_on_a_kill_action, test_create_treats_a_none_keep_memory_as_unconfigured, test_create_allows_auto_resume_with_an_unconfigured_keep_memory) all wrap their keep_memory: None dicts in cast(Any, ...), even for the action: "pause" case where the discriminated union should otherwise accept keep_memory directly without a cast. Compare this to the JS side: keepMemory?: boolean widens to boolean | undefined, so lifecycleRequest.test.ts's 'a nullish keepMemory is not a choice of snapshot kind' test passes an unset: boolean | undefined value with no cast at all for the pause case. That's a real JS/Python asymmetry in a PR whose explicit premise is parity between the SDKs (CLAUDE.md requires equivalent JS/Python SDK changes), and the PR description itself claims "the cases that used to need a cast are now written as typed calls" — which isn't true for this one.

Impact is limited to static type checking, not runtime: dicts still pass through and behave correctly regardless of the annotation, so nothing crashes and no data is lost. That's why this is a nit rather than a normal-severity finding — but it does directly undercut the stated purpose of the PR, which is to make these states expressible to typed callers without a cast.

Step-by-step proof:

  1. Open packages/python-sdk/e2b/sandbox/sandbox_api.py:488-503SandboxOnTimeoutPause declares keep_memory: NotRequired[bool].
  2. Write km: Optional[bool] = None; Sandbox.create(lifecycle={"on_timeout": {"action": "pause", "keep_memory": km}}) in a .py file and run pyright against the installed package.
  3. Pyright reports reportArgumentType: Argument of type "dict[str, dict[str, str | bool | None]]" cannot be assigned to parameter "lifecycle" ... "None" is not assignable to "bool".
  4. Change the annotation to keep_memory: NotRequired[Optional[bool]] and rerun — the same call now typechecks with zero errors, and no other type in the file needs to change (the discriminated union, build_lifecycle_config, and SandboxOnTimeoutKill are all unaffected).

Fix: change line 492 to keep_memory: NotRequired[Optional[bool]], matching the runtime "None means unconfigured" semantics this PR implements and the JS SDK's boolean | undefined widening.

on_timeout = on_timeout_raw if on_timeout_configured else "kill"
keep_memory = None
keep_memory_provided = False
Expand All @@ -845,9 +849,15 @@
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:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the one new gap: only the True arm of this if ever runs.

853 -> 860 (the false arm) is the sole missing branch left in build_lifecycle_config — coverage.py has every other arc in the function marked executed. Reaching it needs auto_resume truthy alongside an explicitly configured non-pause action, and no test in the suite does that: every auto_resume: True case either omits on_timeout, passes it as None, or pairs it with "pause".

Confirmed by mutation rather than by reading the percentage. Replacing this line with if True: — i.e. appending the hint unconditionally, which is exactly the bug the discriminator exists to prevent — leaves 65 python tests and 17 JS tests all passing. The match= assertions this PR adds only ever check that the hint is present; nothing checks it is absent.

Worth closing because the hint makes a claim about the caller's input ("you left it unset"), so getting it wrong would tell someone who wrote on_timeout: "kill" to go set on_timeout. Verified behaviour at head:

{'on_timeout': 'kill', 'auto_resume': True}              -> "...action is 'pause'."                      (no hint)
{'on_timeout': {'action': 'kill'}, 'auto_resume': True}  -> "...action is 'pause'."                      (no hint)
{'auto_resume': True}                                    -> "...action is 'pause'. Set lifecycle[...]"   (hint)

A drop-in test is in my comment on test_lifecycle_request.py.

# 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(
Expand Down
2 changes: 1 addition & 1 deletion packages/python-sdk/e2b/sandbox_async/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion packages/python-sdk/e2b/sandbox_sync/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading
Loading