feat(sdk): sidecars on sandbox create, info and list - #1872
Conversation
Add SidecarAttachment and SidecarInfo to the monorepo copy of the OpenAPI spec, NewSandbox.sidecars (maxItems 4) and a sidecars array on Sandbox, SandboxDetail and ListedSandbox, then regenerate the JS schema and the Python client models. Shapes follow IMPL-D039 00-overview; ListedSandbox is an addition (recorded in qa.md QA7) so the list endpoint can carry the sidecar state the CLI column and R9 need. The tracked spec is Copybara-synced from e2b-dev/runtime at spec/runtime-ref, so the generated-files CI check stays red until the upstream pin advances past belt's W3 change; the edit is a stand-in for that fetch.
Add the `sidecars` create option (SidecarAttachment: entry, version, config, secrets) serialised into NewSandbox.sidecars from the known keys only, and `sidecars` on SandboxInfo from both inspect and list (SidecarInfo: entry, version, role, class, state, name, address, ports, lastError; empty list when the API sends none). Sidecar rejections carry a SIDECAR_* semantic code in the Error body's error_code: 400s become InvalidArgumentError, SIDECAR_FAILED stays a SandboxError, and both keep the code and the API message (which names the entry) in the message and the HTTP status on statusCode. The same mapping covers SIDECAR_RULE_COLLISION on updateNetwork. The count limit is left to the API (SIDECAR_LIMIT); the generated tuple type from maxItems is cast rather than re-validated client-side.
Add the `sidecars` create option (SidecarAttachment TypedDict: entry, version, config, secrets) on Sandbox.create and AsyncSandbox.create, built into NewSandbox.sidecars from the known keys only, and SandboxInfo.sidecars (SidecarInfo dataclass: entry, version, role, class_, state, name, address, ports, last_error) from both inspect and list, empty when the API sends none. `class_` carries the wire's `class`, the same rename the generated client uses. SIDECAR_* rejections are read off the Error body's error_code: 400s raise InvalidArgumentException, SIDECAR_FAILED stays a SandboxException, both keep the code and the API message (which names the entry) and the HTTP status. The same mapping covers SIDECAR_RULE_COLLISION on update_network; a 404 there still wins. Any other body falls through to the existing handle_api_exception.
`sandbox list` gains a SIDECARS column (entry:state, comma-separated, empty when none) and `sandbox info` prints the sandbox's sidecars as a kubectl-style table (entry, version, role, class, state, name, address, ports), indented under its label and omitted when the list is empty. The JSON output of both carries the field unchanged. formatTable is split out of renderTable so the info view can embed the table lines in its own output; renderTable keeps printing them.
Drop the enum on SidecarInfo.state in the spec (known values listed in the description; role and class keep their closed enums) and regenerate both clients, so a state a newer api adds no longer makes the generated Python model raise ValueError and take get_info()/list() down with it. The SDK-level SidecarState unions stay as documentation and admit any string, the SandboxIamTokenType pattern. sidecar_api_exception also swallows a non-UTF-8 error body (ValueError covers JSONDecodeError and UnicodeDecodeError) instead of raising from inside the error path. Review round 1, fix 1 + optional.
An empty list, null/None or an omitted option all leave `sidecars` out of NewSandbox, matching how volumeMounts is sent; shape validation still runs first so a non-list is rejected before the request. Review round 1, fix 2.
A failed sidecar shows its reason; values longer than 60 characters are cut to 59 plus an ellipsis. Review round 1, fix 3.
The api's sidecar codes follow every existing error_code (sidecar_unknown_entry, sidecar_deprecated_entry, sidecar_limit, sidecar_one_proxy, sidecar_config_invalid, sidecar_secret_missing, sidecar_rule_collision, sidecar_flag_off; sidecar_failed on the create failure). Both mappers match the `sidecar_` prefix case-sensitively; an uppercase variant falls through to the generic mapping, and a test in each SDK pins that. Review round 1, architect contract decision.
The api rejects a proxy-role sidecar attached together with a service sidecar whose egress is `sandbox` with sidecar_egress_conflict. The prefix match already covers it; the mapper docs now list all nine 400 codes and a parametrized test in each SDK pins each one to the argument error.
The catalog has four entries; the SidecarAttachment docs and the changeset now name sqlite (libsql-server over HTTP on port 8080) and iroh (peer-to-peer tunnel: publish/connect pipes, tickets.json polled until ready, optional node_secret slot) next to iron-proxy and redis. Lifecycle wording is unchanged pending the architect's final text.
Operator decision 2026-09-11: the ephemeral class is withdrawn. A sidecar is paused and snapshotted with the sandbox, comes back as it was on resume (data included), is forked with it and terminated with it; a crashed sidecar is restarted once from its clean image and then reported failed while the sandbox keeps running. A forked sandbox's iroh sidecar starts with a fresh peer identity. SidecarAttachment and SidecarClass docs in both SDKs, the create() param docs, and the changeset carry that text; SidecarClass keeps both wire values for stability with every catalog entry reported stateful. Test fixtures report stateful, so the CLI table expectations shift one column.
The connect/resume endpoint can now reject with sidecar_version_unavailable (409: the catalog version the sidecar was snapshotted with has been removed; the sandbox stays paused) and sidecar_snapshot_mismatch (500: a stored sidecar snapshot has no matching declaration). Route the connect response through the sidecar mapper in JS and both Python variants so the code and status are preserved; with no conflict error type in either SDK both stay SandboxError / SandboxException. Docs list them; tests pin both codes and the 404 path.
sidecars had been inserted before the existing positional logger parameter of Sandbox.create / AsyncSandbox.create (and _create), which shifted positional callers. It now follows logger behind a `*`, so the twelve pre-existing positional parameters bind as before and sidecars can only be passed by keyword; tests cover both bindings in each variant.
from_client_sidecars guarded ports against Unset only, so a wire null raised TypeError inside get_info()/list(); it is now guarded like address and last_error. The forcing test fails on the previous code.
build_sidecars_body passed config and secrets straight to dict(), so a list or string escaped as a bare ValueError that named nothing, against the helper's own docstring. config must be a mapping and secrets a mapping of str to str; either failure is InvalidArgumentException naming sidecars[i].config or sidecars[i].secrets. The five forcing cases fail on the previous code (ValueError / TypeError instead).
Operator decision 2026-09-12: the cache ships as Valkey (BSD-3, wire-compatible); the catalog entry is `valkey` at valkey.sidecar.e2b.local:6379 with no `redis` alias. Renamed in the SidecarAttachment docs and examples, the create() param docs, error message examples, the changeset, every JS/Python/CLI test fixture, and the spec's entry description (clients regenerated). Each SDK's SidecarAttachment doc says "Valkey (Redis-compatible)" once so a search for redis still lands.
🦋 Changeset detectedLatest commit: 0b69ea7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
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 |
PR SummaryMedium Risk Overview Reviewed by Cursor Bugbot for commit 0b69ea7. Bugbot is set up for automated code reviews on this repo. Configure here. |
Package ArtifactsBuilt from ba587d1. Download artifacts from this workflow run. JS SDK ( npm install ./e2b-2.49.2-impl-d039-sdk-sidecars.0.tgzCLI ( npm install ./e2b-cli-2.19.1-impl-d039-sdk-sidecars.0.tgzCode Interpreter JS SDK ( npm install ./e2b-code-interpreter-2.8.1-impl-d039-sdk-sidecars.0.tgzDesktop JS SDK ( npm install ./e2b-desktop-2.4.1-impl-d039-sdk-sidecars.0.tgzPython SDK ( pip install ./e2b-2.49.1+impl.d039.sdk.sidecars-py3-none-any.whlCode Interpreter Python SDK ( pip install ./e2b_code_interpreter-2.10.0+impl.d039.sdk.sidecars-py3-none-any.whlDesktop Python SDK ( pip install ./e2b_desktop-2.5.0+impl.d039.sdk.sidecars-py3-none-any.whl |
There was a problem hiding this comment.
TASTE.md compliance review (e2b-dev/sdk-harness) of the sidecar surface added in this PR — JS SandboxOpts.sidecars / SandboxInfo.sidecars, the Python sync + async create(sidecars=…) kwarg and SidecarInfo dataclass, and the sidecar_* error mapping.
Checked: parity (T-1/T-2/T-10), API shape (T-3/T-3a, T-16, T-18, T-19, T-20, T-22/T-23), validation scope (T-52), error mapping and messages (T-57, T-59, T-60, T-62), exports (T-54), docs (T-69–T-72).
3 violations, all with inline comments:
- T-60 — the
sidecar_*status/code → error-class mapping lives in a new helper that is short-circuited in front ofhandleApiError/handle_api_exceptionat six call sites (JSgetInfo/create/connect, Python sync + async equivalents) instead of inside the centralized mapper. Commented once per language; the same fix covers every?? handleApiError(res)/or handle_api_exception(res)site. - T-19 —
SidecarAttachmentandSidecarInfoare object shapes declared withtyperather thaninterface;SandboxOpts/SandboxInfo, which they plug into, are interfaces. - T-1 —
SidecarInfo.portsisports?: number[](absent when the API omits it) in JS butList[int] = []in Python, so the same response readsundefinedin one SDK and[]in the other.SandboxInfo.sidecarshas the same split (sidecars?:in JS,List[SidecarInfo] = []in Python) even thoughfromApiSidecarsalways yields an array — dropping the?there makes the two match.
What looks right: sidecars is keyword-only from day one in Python (T-3a) and lives in the trailing options object in JS (T-3); options are a TypedDict and results a @dataclass (T-16); generated ClientSidecar* models are mapped at the boundary (T-18); client-side checks are shape-only and leave catalog/limit/proxy rules to the API (T-52); 400s go to InvalidArgumentError/InvalidArgumentException and the rest stay in the SandboxError hierarchy (T-57/T-59); all new names are exported from index.ts / __all__ (T-54).
| }) | ||
|
|
||
| const err = handleApiError(res) | ||
| const err = sidecarApiError(res) ?? handleApiError(res) |
There was a problem hiding this comment.
T-60 — HTTP status → error class mapping is centralized in handleApiError (api/index.ts), not scattered across call sites. sidecarApiError is a second mapper spliced in front of the central one at three sites in this file (here, getInfo L1705, connect/resume L2090), so a future call site that adds sidecars — or a new *_code family — has to remember the ?? chain.
Compliant form: teach handleApiError to read error_code (it already has the body and status) and dispatch sidecar_* there, leaving every call site as the unchanged single call:
| const err = sidecarApiError(res) ?? handleApiError(res) | |
| const err = handleApiError(res) |
(Same applies to the Python sidecar_api_exception(res) or handle_api_exception(res) sites — see the comment in sandbox_sync/sandbox_api.py.)
|
|
||
| if res.status_code >= 300: | ||
| raise handle_api_exception(res) | ||
| raise sidecar_api_exception(res) or handle_api_exception(res) |
There was a problem hiding this comment.
T-60 — status → exception mapping is centralized in handle_api_exception (e2b/api/__init__.py), not decided per call site. This or chain is repeated at three sites in this file (L209, L263, L374) and mirrored in sandbox_async/sandbox_api.py. Move the sidecar_* error_code dispatch into handle_api_exception and keep the call sites as they were:
| raise sidecar_api_exception(res) or handle_api_exception(res) | |
| raise handle_api_exception(res) |
| * }) | ||
| * ``` | ||
| */ | ||
| export type SidecarAttachment = { |
There was a problem hiding this comment.
T-19 — object shapes are interfaces (SandboxOpts, SandboxInfo); type is reserved for unions and intersections (SandboxState, SidecarRole). SidecarAttachment is an extensible options shape that sits inside SandboxOpts, so it follows the interface convention:
| export type SidecarAttachment = { | |
| export interface SidecarAttachment { |
| * A sidecar attached to a sandbox, as returned by the sandbox info and list | ||
| * endpoints. | ||
| */ | ||
| export type SidecarInfo = { |
There was a problem hiding this comment.
T-19 — result shapes are interfaces like the SandboxInfo they are embedded in; type is for unions/intersections.
| export type SidecarInfo = { | |
| export interface SidecarInfo { |
| /** Address of the sidecar inside the sandbox network. */ | ||
| address?: string | ||
| /** Ports the sidecar listens on. */ | ||
| ports?: number[] |
There was a problem hiding this comment.
T-1 — the JS and Python surfaces mirror each other in semantics, not just names. Python SidecarInfo.ports is List[int] = field(default_factory=list) and _from_client_sidecar normalizes an omitted/null wire value to [], while here ports is optional and fromApiSidecars leaves it undefined. The same response therefore reads [] in Python and undefined in JS. Pick one — given volumeMounts and SandboxInfo.sidecars already normalize to [], the array is the consistent choice:
| ports?: number[] | |
| ports: number[] |
and have fromApiSidecars emit ports: sidecar.ports ?? []. (SandboxInfo.sidecars?: SidecarInfo[] has the same split against Python's List[SidecarInfo] = [] — fromApiSidecars always returns an array, so the ? can go.)
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 0b69ea7. Configure here.
| ...(sidecar.ports !== undefined ? { ports: sidecar.ports } : {}), | ||
| ...(sidecar.lastError !== undefined | ||
| ? { lastError: sidecar.lastError } | ||
| : {}), |
There was a problem hiding this comment.
Null sidecar fields leak through mapping
Medium Severity
fromApiSidecars keeps wire null on address, ports, and lastError because it only skips undefined. That puts null on SidecarInfo (typed as optional, not nullable) and truncate then reads .length on lastError, so e2b sandbox info can throw when the API sends null for those fields.
Additional Locations (1)
Triggered by learned rule: JS SDK: prefer undefined over null for absent values
Reviewed by Cursor Bugbot for commit 0b69ea7. Configure here.


Summary
Adds the sidecar plane (DES-039 / IMPL-D039 W6) to the JS SDK, the Python SDK and the CLI.
sidecarsonSandbox.create(JSSandboxOpts.sidecars: SidecarAttachment[]; Python keyword-onlysidecars: Optional[List[SidecarAttachment]]on sync and asynccreate). Each attachment names a catalog entry —iron-proxy(proxy role),valkey(Valkey, Redis-compatible cache),sqlite(libsql-server over HTTP),iroh(peer-to-peer tunnel) — with optionalversion, entry-specificconfig, andsecretsslots holding${e2b.secrets.<name>}references. The body is built from the known keys only; an empty list,null/Noneor an omitted option leaves the field out.SandboxInfo.sidecars(JSSidecarInfo[], PythonList[SidecarInfo]) from inspect and list — entry, version, role, class, state, name, address, ports, last error; empty when the API sends none.stateis an open string on the wire; the SDK unions document the known values.sidecar_*semantic codes read from the APIError.error_code, case-sensitive. The 400 codes (sidecar_unknown_entry,sidecar_deprecated_entry,sidecar_limit,sidecar_one_proxy,sidecar_config_invalid,sidecar_secret_missing,sidecar_rule_collision,sidecar_egress_conflict,sidecar_flag_off) surface asInvalidArgumentError/InvalidArgumentException;sidecar_failed(create),sidecar_version_unavailable(409, resume) andsidecar_snapshot_mismatch(500, resume) staySandboxError/SandboxException. All keep the code and the API message and carry the HTTP status. Applied on create, connect/resume and network update.e2b sandbox listgains aSIDECARScolumn (entry:state);e2b sandbox infoprints a sidecar table (entry, version, role, class, state, name, address, ports, last error truncated to 60 chars).SidecarAttachment,SidecarInfo,NewSandbox.sidecars(maxItems 4),sidecarsonSandbox,SandboxDetailandListedSandbox; both clients regenerated (pnpm -C packages/js-sdk generate:api,uv run make generate-api).Usage:
Changesets:
e2bminor,@e2b/python-sdkminor,@e2b/climinor.Verification
Run on this branch (Node v22.23.2, pnpm 10.34.5, uv 0.12.13, Python 3.10.21):
lint.yml)pnpm install --frozen-lockfile && (cd packages/python-sdk && uv sync --locked) && pnpm run lint && pnpm run format && git status --porcelaintypecheck.yml)pnpm run typecheckty checkper Python package)pnpm -C packages/js-sdk build && npx vitest run --project unit tests/sandbox/sidecars.test.ts tests/sandbox/egressProxy.test.ts tests/api/handleApiError.test.tssidecars.test.ts, msw-mocked)pnpm -C packages/js-sdk testmainat 67c2e07 with noE2B_API_KEY(integration suites and the chromium project)cd packages/python-sdk && uv run ruff check . && uv run ruff format --check . && uv run ty check && uv run pytest -q tests/shared tests/test_api_exception.pytests/shared/sandbox/test_sidecars.pyuv build && uv run pytest --numprocesses=4 testshttpx.ConnectErrorin the sync/async integration trees (no API access)pnpm -C packages/cli build && pnpm -C packages/cli testtemplate/create.test.tsthrows atbeforeAllfor the missingE2B_API_KEYnpx vitest run tests/commands/sandbox/info.test.ts tests/commands/sandbox/list.test.ts tests/utils/table.test.tsNot run here:
test:bun,test:deno,test:cf(runtimes not installed) andgenerated_files(see below).Risk & rollout
spec/openapi.ymlin this repo is a Copybara mirror of e2b-dev/runtime atspec/runtime-ref. This PR edits the mirror directly, so thegenerated_filescheck stays red until the runtime mirror carries the same schema (landing in belt first) andspec/runtime-refis bumped — at which point the regenerated clients must be byte-identical to what is committed here. Land order: belt → runtime mirror → pin bump → this PR.SidecarInfo.stateis an open string so a state a newer API adds cannot breakget_info()/list().create()keeps its positional parameters:sidecarssits afterloggerand is keyword-only.sandbox-sidecarsfeature server-side; without it the API answerssidecar_flag_offand nothing else in the create path changes.Follow-ups
SidecarInfo.role/class_are typed as closedLiteralunions mirroring the generated enums; if the API ever widens them, the generatedSidecarInfoRole/SidecarInfoClassenums raise on parse likestateused to. Open them the same way if that becomes a possibility.sidecars(array of objects with a stringentry) but not thesecretsvalues (Python checks str→str); the API rejects malformed references either way.egressProxyreachability).