Skip to content

fix(sdk): let callers omit lifecycle.onTimeout - #1703

Closed
mishushakov wants to merge 1 commit into
mainfrom
fix/lifecycle-optional-on-timeout
Closed

fix(sdk): let callers omit lifecycle.onTimeout#1703
mishushakov wants to merge 1 commit into
mainfrom
fix/lifecycle-optional-on-timeout

Conversation

@mishushakov

Copy link
Copy Markdown
Member

Follow-up to #1693 and #1694.

Problem

#1693 stopped sending autoPause when no timeout action was configured, and #1694 did the same for autoResume. Both describe an "onTimeout not configured" state in their docs — but onTimeout stayed a required member of SandboxLifecycle in both SDKs, so that state is unreachable for a typed caller except by omitting lifecycle entirely. Every test the two PRs added for it has to cast through the type (as never, cast(Any, ...)).

Concretely, lifecycle: { autoResume: false } — "no preference about the timeout action, but keep auto-resume off" — is exactly the shape #1694 made meaningful on the wire, and it does not typecheck.

Three related cleanups fall out of that:

  • A nullish keepMemory / keep_memory was still treated as an explicit choice ('keepMemory' in onTimeout), so it sent autoPauseMemory: true and tripped the pause-only guard on a kill action — the one place the two PRs' own "nullish means not configured" rule didn't apply. Spreading an optional value in ({ action: 'pause', keepMemory: maybeUndefined }) is a normal call shape.
  • The auto-resume guard's message named a default the SDK deliberately stopped deciding.
  • fix(sdk): omit autoPause when no timeout lifecycle is configured #1693's changeset says "Explicit values are still always sent", which is not true of autoPauseMemory for a bare pause after that PR. Corrected in place, since it hasn't been released yet.

Change

caller before after
lifecycle: { autoResume: false } type error typechecks; autoPause omitted, autoResume: { enabled: false }
lifecycle: {} type error typechecks; both omitted
{ action: 'pause', keepMemory: undefined } autoPauseMemory: true autoPauseMemory omitted
{ action: 'kill', keepMemory: undefined } InvalidArgumentError accepted; autoPause: false
{ action: 'kill', keepMemory: false } InvalidArgumentError unchanged
{ autoResume: true } with no action InvalidArgumentError unchanged, clearer message

autoResume: true still requires an explicit onTimeout: 'pause' — auto-resume only has meaning for a sandbox that pauses, and sending it alongside an unset action would ask the API for a combination it has no way to honor today. The error now points at the knob to turn instead of asserting what an unset onTimeout resolves to:

autoResume can only be true when onTimeout action is 'pause'. Set lifecycle.onTimeout to 'pause': leaving it unset defers the action to the API.

Usage

import { Sandbox } from 'e2b'

// Opt out of auto-resume without expressing a preference about the timeout
// action. Previously a type error.
await Sandbox.create({ lifecycle: { autoResume: false } })

// A keepMemory that spreads in as undefined is no longer sent as `true`.
const keepMemory: boolean | undefined = readFromConfig()
await Sandbox.create({
  lifecycle: { onTimeout: { action: 'pause', keepMemory } },
})
from e2b import Sandbox

# Opt out of auto-resume without expressing a preference about the timeout
# action. Previously a type error.
Sandbox.create(lifecycle={"auto_resume": False})

# A keep_memory that is None is no longer sent as True.
Sandbox.create(
    lifecycle={"on_timeout": {"action": "pause", "keep_memory": read_from_config()}}
)

AsyncSandbox.create behaves identically.

Tests

Extended the two request-level suites the previous PRs added; the cases that used to need a cast are now written as typed calls, and the new nullish-keepMemory behavior is covered on both sides.

  • packages/js-sdk/tests/sandbox/lifecycleRequest.test.ts — 12 passed (msw, no credentials)
  • packages/python-sdk/tests/shared/sandbox/test_lifecycle_request.py — 31 passed (no credentials)

Also run: pnpm run format, pnpm run lint, pnpm run typecheck clean from the repo root; packages/python-sdk tests/shared 163 passed (the 25 errors are tests/shared/git, which needs E2B_API_KEY to create sandboxes); packages/cli 108 passed (the one failing suite is the template-create backend test, which also needs E2B_API_KEY).

No live-API coverage was added: nothing here changes what an explicitly-configured lifecycle puts on the wire, and the existing lifecyclePayload.test.ts suite still covers the real pause/resume behavior.

Open question for infra

spec/openapi.yml declares autoPause: default: false and autoPauseMemory: default: true on NewSandbox. The premise of #1693/#1694 — that the API can now tell "unset" from an explicit choice and own its own default — only holds if the server distinguishes an absent field from the spec default rather than filling it in at decode time. Worth confirming on the infra side; if it doesn't, those default: keys need to come out of the spec for the change to have teeth.

🤖 Generated with Claude Code

#1693 stopped sending `autoPause` when no timeout action was configured and
#1694 did the same for `autoResume`, but `onTimeout` stayed a required member
of `SandboxLifecycle`. The "not configured" state the SDKs now put on the wire
was therefore unreachable for a typed caller except by omitting `lifecycle`
entirely, and every test for it had to cast through the type.

Make `onTimeout` optional in both SDKs, apply the same nullish-is-unconfigured
rule to `keepMemory` / `keep_memory`, and stop phrasing the auto-resume guard in
terms of a default the SDK no longer decides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cla-bot cla-bot Bot added the cla-signed label Aug 19, 2026
@changeset-bot

changeset-bot Bot commented Aug 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b46326a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
e2b Patch
@e2b/python-sdk Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cursor

cursor Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

PR Summary

Low Risk
SDK typing and create-request field omission only; explicit lifecycle values on the wire are unchanged aside from nullish keep-memory handling.

Overview
lifecycle.onTimeout / on_timeout is optional in both SDKs so typed callers can pass partial lifecycle (e.g. only autoResume: false) without omitting the whole lifecycle object.

Nullish keepMemory / keep_memory is no longer treated as an explicit choice: autoPauseMemory stays off the create body and keep_memory on a kill action no longer raises the pause-only validation error.

autoResume: true without a configured timeout action still fails; the error text now tells callers to set onTimeout to pause instead of implying a default kill action.

Reviewed by Cursor Bugbot for commit b46326a. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

Copy link
Copy Markdown
Contributor

Package Artifacts

Built from c23edbe. Download artifacts from this workflow run.

JS SDK (e2b@2.41.1-fix-lifecycle-optional-on-timeout.0):

npm install ./e2b-2.41.1-fix-lifecycle-optional-on-timeout.0.tgz

CLI (@e2b/cli@2.16.3-fix-lifecycle-optional-on-timeout.0):

npm install ./e2b-cli-2.16.3-fix-lifecycle-optional-on-timeout.0.tgz

Python SDK (e2b==2.41.0+fix.lifecycle.optional.on.timeout):

pip install ./e2b-2.41.0+fix.lifecycle.optional.on.timeout-py3-none-any.whl

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b46326afe5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".


# 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

Choose a reason for hiding this comment

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

P2 Badge Make the Python nullish example type-check

When users copy this new example into a type-checked Python project, Pyright rejects it because SandboxOnTimeoutPause.keep_memory remains NotRequired[bool], so None is not accepted even though the runtime now treats it as unset. Either widen the public type to accept None, matching the advertised JS/Python behavior, or avoid documenting None as a supported typed call.

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

Useful? React with 👍 / 👎.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM — a clean, consistent follow-up to the onTimeout/autoResume changes in #1693/#1694, and no bugs were found.

Extended reasoning...

Overview

This PR relaxes SandboxLifecycle.onTimeout (on_timeout in Python) from required to optional in both the JS and Python SDKs, and applies the same "nullish means unconfigured" treatment that #1693/#1694 established for onTimeout/autoResume to the nested keepMemory/keep_memory field. It also tweaks the autoResume-without-a-pause-action error message to point at the knob to fix rather than naming a default the SDK no longer decides, and corrects a changeset wording inaccuracy from #1693 (not yet released). Touches: sandboxApi.ts and sandbox_api.py (the request-building logic), the two SDKs' main.py docstrings, and both request-level test suites.

Security risks

None. This is purely SDK-side request-shaping logic (which fields get included in the create-sandbox payload) — no auth, crypto, or permission logic involved, and no user input is parsed or executed differently.

Level of scrutiny

Low-to-moderate is appropriate: it's a type-relaxation plus a bug fix (nullish keepMemory previously behaved inconsistently with the sibling onTimeout nullish-handling), not a new capability or behavior change for typed callers who were already setting these fields explicitly. The JS and Python implementations were changed in parallel and use the same reasoning (requestedKeepMemory ?? undefined / keep_memory is not None), so the two SDKs stay in sync as required by this repo's conventions.

Other factors

I traced the hasKeepMemory/keep_memory_provided guard logic against each of the PR's documented before/after table entries (bare pause with keepMemory: undefined, kill with keepMemory: undefined, autoResume alongside an unconfigured keepMemory) and the code matches the described behavior in both languages. I also grepped for other call sites relying on onTimeout/on_timeout being required and found none outside the generated API-response models (which are a separate, unrelated type for server responses, not this input type). Test coverage is thorough and directly exercises the changed branches; CI (format/lint/typecheck, both test suites) is reported green in the PR description.

@mishushakov

Copy link
Copy Markdown
Member Author

/sdk claim

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claimed. This PR has been cloned into #1711, which now carries the work.

  • The commit b46326a is applied unmodified, so your authorship and the Co-Authored-By trailer are preserved — only PR ownership moves.
  • The branch was already current with main, so no merge was needed; the tree on the new branch is byte-identical to this PR's head.
  • Contents were neither reviewed nor modified, and the description was carried over verbatim.

Please close this PR in favour of #1711 — I don't have write access to close it myself.

View PR

Open in Web View Automation 

Sent by Cursor Automation: /claim Claim SDK PR by SDK Factory

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

SDK test coverage — base 43c28b15f → head b46326afe

Measured both SDKs at both ends with E2B_API_KEY present, so the live suites really ran. The quoted diff base is the true merge-base here, so the A/B is clean.

Diff coverage is 100%, and it is the least informative number in this report. Only 5 of the 219 added lines are executable (3 python, 2 JS) and all 5 execute. The PR's headline change — making onTimeout / on_timeout optional — contributes zero executable lines, so coverage cannot see it at all. I mutation-tested the PR's own suites instead.

Mutation results

Revert one piece of the change, run only the two request-level suites the PR extends (0.6 s JS / 0.11 s python), restore.

mutant PR's tests typechecker verdict
JS onTimeout? → required again 12/12 pass tsc --noEmit clean survived
py NotRequired[...] → required again 31/31 pass ty check clean survived
JS hasKeepMemory back to the in operator 2 fail caught
py keep_memory_provided back to the in operator 3 fail caught
py hint appended unconditionally (if True:) 31/31 pass survived
JS hint appended unconditionally 12/12 pass survived
py hint never appended (if False:) 4 fail caught
JS hint never appended (const hint = '') 1 fail caught

The nullish-keepMemory fix — the one genuinely behavioural change here — is well guarded on both sides, and the split is exactly right: reverting it fails only the new nullish tests and leaves the explicit-value tests green. Both new suites assert at wire level (msw request.json() in JS, to_dict() in python), which is the correct target.

The four survivors are two findings, both symmetric across the SDKs, both detailed inline:

  1. The new conditional hint is half-tested. Every autoResume: true in either repo is paired with 'pause' or with an unset/None action — nothing anywhere passes an explicit 'kill' alongside autoResume: true. So the arm that must not append the hint never runs. Coverage agrees precisely: python gains one uncovered branch arc (853 → 860) in a function that had none on base, and JS's hint ternary reads [0, 2]. I wrote the missing test for each side, confirmed it passes on head and fails against the surviving mutant.

  2. The type change has no automated guard in either SDK — for two different reasons. Neither typechecker is incapable; both catch it when the value reaches a checked call site. tsc never looks at tests/, and ty does look but the PR's values arrive through pytest.parametrize, which erases the type.

Answering the "Open question for infra"

I probed the live API rather than reasoning about it — created sandboxes and read get_info().lifecycle:

lifecycle sent observed back
omitted entirely on_timeout: "kill", auto_resume: false
{} on_timeout: "kill", auto_resume: false
{"auto_resume": False} on_timeout: "kill", auto_resume: false
{"on_timeout": "kill"} on_timeout: "kill", auto_resume: false
{"on_timeout": "pause"} on_timeout: "pause", auto_resume: false

Two things follow. The new shapes work end to end — lifecycle: {} and lifecycle: {"auto_resume": False} are accepted, create a sandbox, and behave as kill/no-auto-resume, so nothing regresses. But omitting is currently observationally identical to sending the old explicit default, so this change buys future-proofing and type ergonomics, not a behaviour change today; the probe can't distinguish "server honors absence" from "server default happens to match the spec default". Worth stating plainly so nobody reads the green suite as confirmation that the premise holds.

Also worth flagging for whoever picks that question up: SandboxInfoLifecycle exposes only on_timeout and auto_resume — there is no auto_pause_memory on the read path. So the field this PR actually changes behaviour for is unobservable end to end; it can only ever be asserted at the request-body level, which is what the new tests do. That is the right call, and it is also the ceiling.

Numbers

base head
python stmts 69.44% (10303/14838) 69.44% (10306/14841)
python branches 46.69% (1328/2844) 46.63% (1327/2846)
python hand-written 83.56% (5267/6303) 83.57% (5270/6306)
python generated 59.00% (5036/8535) 59.00% (5036/8535)
python tests 960 pass / 2 fail / 57 skip 965 / 2 / 57
js-sdk stmts 82.13% (2216/2698) 82.14% (2218/2700)
js-sdk lines 82.04% (2166/2640) 82.05% (2168/2642)
js-sdk branches 73.23% (1338/1827) 73.23% (1338/1827) — identical
js-sdk funcs 88.86% (463/521) 88.86% (463/521)
js-sdk tests 634 pass / 1 fail / 32 skip 636 / 1 / 32
cli 17.05% lines (236/1384), 109 pass byte-identical

The two test_firewall_transform_injects_headers failures and the js httpbin sidecar failure reproduce identically on base — the test org lacks the httpbin template. Not regressions.

Inventory: +5 python, +2 JS, 0 removed, 0 status changes, and every added test runs (none arrived skipped). Note the inventory undercounts the work: several of the PR's best additions are new assertions inside existing tests (the {} case, the two message-regex assertions), which no test-name diff can see.

Two notes on why the totals are useless for this PR, beyond the 5-executable-lines point:

  • js-sdk branch counts are byte-identical on both sides, which hides the newly-uncovered arm completely. The keepMemory refactor removed exactly as many branch arms as it added (44 → 42 in the lifecycle block, 4 zero-count arms at both ends), so the new dead arm is masked by a coincidence. Only the per-arm comparison shows it.
  • The one per-file mover among untouched files is noise I can attribute: e2b/envd/api.py +2 lines, and template_{sync,async}/build_api.py −1 line/−1 branch each (the live template suite). That −2 against sandbox_api's +1 is the whole −1 in the python branch total.

One genuinely nice piece of evidence for the fix being real: on base, the ?? true fallback inside the old keepMemory ternary had counts [5, 0] — a present-but-nullish keepMemory was never once exercised. That dead arm is exactly why the bug this PR fixes went unnoticed, and the PR deletes it.

Verdict

Strong test PR for the behavioural half — the nullish-keepMemory change is properly pinned, on both the sync and async python paths (the three new keep_memory tests are sync-only, but build_lifecycle_config is shared and both sandbox_{sync,async}/sandbox_api.py call it, and the async twin covers the wiring, so that matches the file's existing convention rather than being a gap). The two real gaps are the untested no-hint arm and the entirely unguarded type change, both cheap to close and both with verified fixes inline.

Open in Web View Automation 

Sent by Cursor Automation: /coverage SDK Test Coverage Report

"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

Choose a reason for hiding this comment

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

This conditional is half-tested, and it is the only uncovered thing the PR adds.

On base, build_lifecycle_config had zero uncovered lines and zero uncovered branch arcs. On head it has exactly one: arc 853 → 860, the false side of this if — i.e. auto_resume: True alongside an action the caller did configure but that isn't pause. The JS twin is identical: the hint ternary at sandboxApi.ts:1608 reads [0, 2].

The reason is that no test in either repo pairs autoResume: true with an explicit 'kill'. Every one of the 8 python and 7 JS occurrences uses 'pause', or an unset/None action. So making the hint unconditional passes the whole suite — I ran it: if True: here → 31/31 python still green, and the JS equivalent → 12/12 still green.

That matters because the failure mode is the exact confusion this PR set out to remove. The behaviour on head is correct:

{'on_timeout': 'kill',              'auto_resume': True} -> "...action is 'pause'."
{'on_timeout': {'action': 'kill'},  'auto_resume': True} -> "...action is 'pause'."
{'auto_resume': True}                                    -> "...action is 'pause'. Set lifecycle['on_timeout'] to 'pause': leaving it unset defers the action to the API."

But nothing holds it there, so a later edit could tell a caller who did set on_timeout to "leave it unset", with CI green.

Verified fix — passes on head, fails against the surviving mutant:

@pytest.mark.parametrize(
    "on_timeout",
    [
        pytest.param("kill", id="bare-kill"),
        pytest.param({"action": "kill"}, id="object-kill"),
    ],
)
def test_explicit_kill_auto_resume_error_does_not_suggest_leaving_on_timeout_unset(
    test_api_key, on_timeout
):
    with pytest.raises(InvalidArgumentException) as excinfo:
        Sandbox.create(
            api_key=test_api_key,
            lifecycle={"on_timeout": on_timeout, "auto_resume": True},
        )

    assert "leaving it unset" not in str(excinfo.value)

This also closes the arc, taking the function back to fully covered.

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

Choose a reason for hiding this comment

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

Same half-covered conditional as the python side (see the comment on build_lifecycle_config): istanbul records this ternary as [0, 2] — the '' arm, taken when the caller did configure a non-pause action, never runs.

It is invisible in the summary because this file's branch total is byte-identical on base and head (1338/1827 overall, 44 → 42 arms in this block, 4 zero-count arms at both ends): the keepMemory refactor happened to remove as many arms as the hint ternary added. Only a per-arm comparison surfaces it.

Making the hint unconditional keeps all 12 tests in lifecycleRequest.test.ts green. Verified fix — passes on head, fails against that mutant:

test('an explicit kill action is not told to leave onTimeout unset', 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 regex is what does the work — it fails if the hint is appended when an action was given.

* (currently `kill`) in effect.
*/
onTimeout: SandboxOnTimeout
onTimeout?: SandboxOnTimeout

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This line — the PR's headline change — has no automated guard whatsoever in the JS SDK. I reverted it to onTimeout: SandboxOnTimeout and got: lifecycleRequest.test.ts 12/12 still passing, pnpm run typecheck clean, pnpm run lint unaffected. Nothing in CI notices.

It is not that the contract is unexpressed — your tests express it perfectly, that is precisely what dropping the as never casts did. They are just never typechecked:

  • packages/js-sdk/tsconfig.json is the only tsconfig and sets "include": ["src"], and typecheck is a bare tsc --noEmit, so tests/** is excluded.
  • vitest.config.mts declares no typecheck block, so vitest strips types via esbuild without checking them.
  • oxlint isn't type-aware.

Adding tests to the include list makes the guard appear immediately — 4 errors, all in this PR's own test file, and nothing else:

tests/sandbox/lifecycleRequest.test.ts(100,5): error TS2741: Property 'onTimeout' is missing in type '{ autoResume: false; }' but required in type 'SandboxLifecycle'.
tests/sandbox/lifecycleRequest.test.ts(108,56): error TS2741: Property 'onTimeout' is missing in type '{}' but required in type 'SandboxLifecycle'.
tests/sandbox/lifecycleRequest.test.ts(132,7): error TS2741: ...
tests/sandbox/lifecycleRequest.test.ts(143,7): error TS2741: ...

The honest cost: flipping include is not free, because tests/ has 37 pre-existing errors across 18 files today (worst offender 5, in tests/sandbox/commands/commandHandle.test.ts) — bounded, but not a one-liner, and out of scope here. A tsconfig.tests.json plus a typecheck:tests script scoped to a growing allowlist would let this PR's file be guarded now without blocking on that cleanup.

Not blocking for this PR — but as long as it holds, "the tests cover the optional onTimeout" is true only of the source text, not of anything CI runs.

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"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Unlike the JS side, ty check does cover tests/ (python-sdk's typecheck is uv run make typecheck → bare ty check, no path filter, and the Typecheck workflow runs on this PR). But it still doesn't guard the NotRequired change, because pytest.parametrize erases the type before it reaches Sandbox.create — these params are consumed through an unannotated lifecycle argument, so no checked call site ever sees the new shape.

I verified both halves. Reverting on_timeout to required leaves this file at 31/31 passing and ty check reporting All checks passed!. But the same shape written as a direct call is caught immediately:

error[missing-typed-dict-key]: Missing required key 'on_timeout' in TypedDict `SandboxLifecycle` constructor
  --> Sandbox.create(api_key="x", lifecycle={"auto_resume": False})

3 diagnostics with the revert, 0 on head. So one directly-written call anywhere under packages/python-sdk turns CI's existing ty check into a real guard for this PR's headline change, at zero runtime cost — something like:

def test_optional_on_timeout_typechecks(monkeypatch, test_api_key):
    # Written inline rather than via parametrize so `ty` actually checks the shape.
    body = _sync_request_body(monkeypatch, test_api_key, {"auto_resume": False})

    assert "autoPause" not in body
    assert body["autoResume"] == {"enabled": False}

(The _sync_request_body signature would need lifecycle: SandboxLifecycle for the check to bite — it's currently unannotated.)

Dropping the casts here is still the right change; it just documents the contract rather than enforcing it.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

TASTE review

The design is compliant on the points that matter, and the premise this PR flagged as unresolved checks out — I verified it against the pinned infra server, so the "Open question for infra" can be closed. One finding worth fixing before merge (in the changeset, not the code) and four non-blocking notes; details inline.

What holds up under TASTE: the option type and the result type stay separate, so relaxing SandboxLifecycle cannot weaken what getInfo() guarantees (SandboxInfoLifecycle keeps both members required). Absence is spelled undefined rather than null in the JS types, with null normalized at the boundary for untyped callers — I confirmed with a compiled probe that onTimeout: null and keepMemory: null are both type errors while lifecycle: {}, onTimeout: undefined and keepMemory: someOptional all typecheck. The pre-operation guards throw InvalidArgumentError / InvalidArgumentException, which is what TASTE asks for validation that runs before any operation starts. All four lifecycle docstring sites were updated, and the two main.py :param lifecycle: lines are byte-identical.

The open question for infra: answered, and no spec change is needed

The PR body asks whether the server distinguishes an absent field from the spec default:, and warns that the default: keys may need to come out of spec/openapi.yml for #1693/#1694/#1703 to have teeth. They do not. At the pinned ref (spec/infra-ref = 0716edb9e8):

  1. packages/api/internal/api/api.gen.go declares AutoPause *bool and AutoPauseMemory *bool with omitempty. oapi-codegen emits a pointer for every non-required field and does not materialize default: at decode time, so an omitted key reaches the handler as a nil pointer rather than as the spec default.
  2. packages/api/internal/handlers/sandbox_create.go applies the default itself, from server-side constants: line 146 is autoPause := sharedUtils.DerefOrDefault(body.AutoPause, sandbox.AutoPauseDefault) with AutoPauseDefault = false (sandbox/sandboxtypes/states.go:104), and line 149 is autoPauseFilesystemOnly := !sharedUtils.DerefOrDefault(body.AutoPauseMemory, true).

So the server both distinguishes nil and resolves it to exactly the values the SDK used to send, which is the "distinguishes, and treats it the same today" test these three PRs need. The two 400s on this surface also fall on the safe side of the new omission: an omitted autoPauseMemory makes autoPauseFilesystemOnly false, so neither the filesystemOnly && !autoPause check (line 171) nor the filesystemOnly && autoResume check (line 179) can fire — which is what makes the newly-permitted { action: 'kill', keepMemory: undefined } case safe. And since sandboxLifecycleToAPI only maps onTimeout and autoResume, no autoPauseMemory value is handed back, so the result surface is untouched.

Worth noting for the record: AutoPauseDefault = false means the @default kill / "currently kill" wording in the docstrings is still accurate.

Non-blocking

  • keepMemory is now a tri-state boolean. After this PR it means unset (API default), true (memory snapshot) and false (filesystem-only), and it takes three docstring paragraphs to explain that unset is not false. TASTE names this exact case as the anti-pattern to avoid — Sandbox.pause(sandboxId, { mode: 'memory' }), not { keepMemory: true }. This is inherited, not introduced (SandboxPauseOpts.keepMemory has the same shape and predates the lifecycle option), so it is not this PR's to fix; but if the option is ever revisited, a snapshot?: 'memory' | 'filesystem' union would express the three states without the prose, and absence would be the natural spelling of "let the API decide".
  • The object form of onTimeout isn't exported. Python names SandboxOnTimeoutPause / SandboxOnTimeoutKill but omits them from e2b.__all__ (hasattr(e2b, 'SandboxOnTimeoutPause') is False), and JS leaves both shapes anonymous inside the union. That matters more after this PR, because building the dict incrementally is the workaround for the typing gap below, and there is no importable name to annotate it with. Pre-existing.
  • An in-repo consumer can now drop dead code. packages/cli/src/commands/sandbox/create.ts:187-191 throws --lifecycle.ontimeout is required when using --lifecycle.autoresume, but that branch is unreachable: line 177 already returns when neither option is set, and line 181 already throws when autoResume is set with a non-pause action, so reaching 187 would require autoResume truthy, onTimeout === 'pause' and !onTimeout simultaneously. It exists only to satisfy the then-required onTimeout when constructing the return value, and nothing tests that message (tests/commands/sandbox/create_lifecycle.test.ts asserts only the line-181 one). Nice follow-up that shows the change paying off.
  • Two prose nits. The changeset's closing line writes autoResume: True, mixing the JS option name with Python casing — changeset text is copied verbatim into both published CHANGELOGs. Separately, the keepMemory-on-kill guard reads onTimeout.keepMemory is only allowed when action is 'pause'. in JS but bare keep_memory is only allowed when on_timeout action is 'pause'. in Python; TASTE's own example is the JS wording, so the Python mirror is missing the parent path. Both pre-existing, and this PR changes when that guard fires, so it is a cheap thing to bring in scope.

What I verified

  • The PR's numbers reproduce. 12 JS tests in lifecycleRequest.test.ts, 31 Python tests in test_lifecycle_request.py, and root format / lint / typecheck all clean (format leaves no diff). The tests/shared figure reconciles too: the body's "163 passed, 25 errors" is the no-credentials run, and with a key present the same directory is 188 passed / 1 skipped (163 + 25 = 188).
  • The new tests have teeth. Reverting only the two source files to the base commit and leaving the tests at HEAD fails 3 JS and 7 Python tests. The JS cases that still pass are the ones where only the type changed and #1693 already produced the right runtime behavior, which is the expected result rather than a gap.
  • The wire behavior matches the table in the description, checked by capturing the request body directly rather than reading the ternaries: a nullish keepMemory sends autoPause: true with autoPauseMemory omitted, and { action: 'kill', keepMemory: undefined } now sends autoPause: false with autoPauseMemory omitted instead of raising.
  • No sibling conflict. #1702 touches the same two files and merges into this branch with zero conflicts (different regions — network builders versus lifecycle).
  • One unrelated failure in the js-sdk unit project on my VM: network.test.ts "injected header is reflected by the httpbin sidecar" fails with 404: template 'httpbin' not found. That is the test key's team lacking the fixture template, not anything from this PR (459 passed, 30 skipped otherwise).

Codex posted no suggestions and claude[bot] posted an LGTM at self-described "Low" scrutiny; I did not find a bug in the request-building logic either, so those verdicts hold as far as behavior goes. The finding below is about the typed surface the PR advertises rather than about what it puts on the wire.

Open in Web View Automation 

Sent by Cursor Automation: /check SDK complies with TASTE.md


# 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

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 changeset is the one artifact here that ships verbatim to users (into both packages/js-sdk/CHANGELOG.md and packages/python-sdk/CHANGELOG.md). Written to a file and run through the repo's own checker from packages/python-sdk:

$ uv run ty check --output-format concise /tmp/x.py
/tmp/x.py:3:16: error[invalid-argument-type] Argument to bound method `create` is incorrect: Expected `SandboxLifecycle | None`, found `dict[...]`
/tmp/x.py:3:41: error[invalid-argument-type] Invalid argument to key "on_timeout" with declared type `Literal["pause", "kill"] | SandboxOnTimeoutPause | SandboxOnTimeoutKill` on TypedDict `SandboxLifecycle`

The JS twin above it compiles clean, so this is the asymmetry described in the other comment rather than a typo. The read_from_config() variant in the PR description has the same problem when the function returns Optional[bool].

Since the recommendation is to leave the type strict, the sample is what should change. This version typechecks, and I confirmed with a request-capture probe that it produces exactly the wire behavior the changeset describes — keep_memory=None gives autoPause: true with autoPauseMemory omitted, True and False send autoPauseMemory accordingly:

# A keep_memory the caller does not have an opinion about is left out of the
# request entirely, so the API's default applies.
Sandbox.create(
    lifecycle={
        "on_timeout": {
            "action": "pause",
            **({"keep_memory": keep_memory} if keep_memory is not None else {}),
        }
    }
)

One more prose nit further down: line 33 writes autoResume: True, mixing the JS option name with Python's capitalization.

"""

on_timeout: SandboxOnTimeout
on_timeout: NotRequired[SandboxOnTimeout]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The JS half of this change fully delivers what the PR sets out to do; the Python half delivers only one of the two states, and I think the right fix is in the docs rather than here.

NotRequired[T] makes the key optional but still rejects None as a value, whereas the JS mirror onTimeout?: SandboxOnTimeout accepts undefined (exactOptionalPropertyTypes is off). All three lifecycle keys — on_timeout, keep_memory and auto_resume — are now runtime-lenient toward None but type-strict against it. That is exactly why the JS test file could drop its as never casts in this PR while the Python test file still routes every None row through cast(Any, ...): those calls remain unexpressible for a typed caller. Python has no undefined, so a caller holding an Optional[bool] from config has no direct spelling.

I checked that widening works mechanically — changing this line and keep_memory to NotRequired[Optional[...]] makes the changeset's sample and all of ty pass with 31 tests still green, and turns 5 of the 7 cast sites in the test file into plain typed calls. I am not recommending it, because TASTE's "absence is undefined, never null" rules out the T | null shape in an optional property, and the same conclusion was reached for NotRequired[str] on the egressProxy credentials in #1702 — worth keeping the two consistent.

So the code here looks right as it stands. What needs to change is the advertising: the changeset and the PR description both present keep_memory=None as the Python mirror of the JS keepMemory: undefined idiom, and it isn't one. Concrete alternative in the changeset comment.

One small thing that follows from this: nothing typed can reach the None branches, so the cast(Any, ...) in the new tests is correct and should stay — it is documenting untyped-caller tolerance, not a shape users are meant to write. A sentence to that effect above NO_ACTION_AUTO_RESUME_CASES would keep a future reader from "fixing" the casts away.

// 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."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Gating the hint is right — an unconditional hint that tells the user to do the thing they already did was a real defect on #1687 — but this is gated the less useful way round. The branch that gets the hint is the one where onTimeout is unset, and the branch that gets nothing is { onTimeout: 'kill', autoResume: true }, where the user has a concrete line in front of them to change. TASTE asks error messages to say what to do, so if either case deserves the pointer it is arguably the configured one.

A single message covers both without a branch, and states the rule in the option names the caller typed:

autoResume can only be true when lifecycle.onTimeout is 'pause'. Leaving onTimeout unset defers the action to the API, which does not enable auto-resume.

Non-blocking, and if you keep the branch, note the Python side spells the same knob lifecycle['on_timeout'] while this one says lifecycle.onTimeout — correct per-language idiom, just worth a glance to confirm it was deliberate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant