Commit Graph

290 Commits

Author SHA1 Message Date
devin-ai-integration[bot] 8787dfec9b feat(sdk,cli): add sandbox list sorting and filters (#1735)
## Summary

SDK follow-up to the merged API change (e2b-dev/belt#1713) that added
`order`, `startedAfter`, and `template` to `GET /v2/sandboxes`; the
earlier SDK PR for this was closed unfinished. The generated API clients
already had the parameters — this wires them through the public
`Sandbox.list` surface in JS and both Python SDKs (sync + async), plus
the `e2b sandbox list` CLI command, so ordering and filtering happen
server-side across the whole paginated dataset instead of per loaded
page.

New options (mirrored across all three SDK surfaces):
- `order: 'asc' | 'desc'` (default `'desc'`, newest first) — sorts by
sandbox start time; exposed as `SandboxListOrder`
- `query.startedAfter` / `SandboxQuery.started_after` — inclusive lower
bound on start time
- `query.template` / `SandboxQuery.template` — exact template ID or
alias (unknown template ⇒ empty list)

### Usage

JavaScript:
```ts
const paginator = Sandbox.list({
  query: {
    metadata: { env: 'ci' },
    startedAfter: new Date(Date.now() - 60 * 60 * 1000),
    template: 'base',
  },
  order: 'asc',
})
const sandboxes = await paginator.nextItems()
```

Python (sync; async is identical with `AsyncSandbox` / `await`):
```python
paginator = Sandbox.list(
    query=SandboxQuery(
        metadata={"env": "ci"},
        started_after=datetime.now(timezone.utc) - timedelta(hours=1),
        template="base",
    ),
    order="asc",
)
sandboxes = paginator.next_items()
```

CLI:
```sh
e2b sandbox list --template base --started-after 2025-01-01T00:00:00Z --order desc
```
The CLI table respects `--order` when rendering (previously it always
re-sorted ascending by start time; that remains the default).

Includes integration tests for order, `startedAfter`, and template
filtering in JS and both Python test suites, a unit test for CLI table
ordering, plus a minor changeset for `e2b`, `@e2b/python-sdk`, and
`@e2b/cli`.

Link to Devin session:
https://app.devin.ai/sessions/44dcb4b0ca9143b8b023ef6fb8554c72
Requested by: @mishushakov

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mish@e2b.dev <mish@e2b.dev>
2026-08-21 14:19:41 +02:00
devin-ai-integration[bot] d000bbd2db test: mock all volume tests and remove ENABLE_VOLUME_TESTS skip flag (#1734)
## Summary

Volume tests always run now — the `ENABLE_VOLUME_TESTS` skip flag is
removed and every volume CRUD and file-operation test runs against
deterministic in-process mocks, requiring no live volume infra or
credentials:

- **JS** (`tests/volume/`): `createMockVolumeApi()` returns MSW handlers
with per-instance state — a stateful in-memory filesystem per volume ID
plus the control-plane `POST/DELETE /volumes` used by the `volumeTest`
fixture, which passes the placeholder `TEST_API_KEY` so `file.test.ts`
runs in isolation without ambient credentials.
- **Python** (`tests/mock_volume_content.py` + `conftest.py`):
`MockVolumeContentAPI` implements the same filesystem semantics behind
`httpx.MockTransport`, injected via `attrs.evolve(client,
httpx_args={"transport": ...})` on both the regular and streaming volume
client factories so it survives `with_timeout`. The
`volume`/`async_volume` fixtures go through `Volume.create()` /
`AsyncVolume.create()` with the control-plane calls mocked, matching the
JS fixture entry point.

Both mocks cover write/read (text/bytes/blob/stream/empty),
force-overwrite conflicts, metadata (`uid`/`gid`/`mode`),
nested/recursive `makeDir` (with parent-error propagation), `list`,
`getInfo`, `exists`, `updateMetadata`, and recursive `remove`; entry
types use the `VolumeFileType` enum.

Also fixes a `js-sdk` bug the always-running tests surfaced (introduced
by #1730): `Volume.exists()` caught the deprecated `NotFoundError`, but
`getInfo()` now throws `VolumePathNotFoundError` (a `VolumeError`
subclass), so `exists()` rethrew instead of returning `false` for
missing paths. `exists()` now catches `VolumePathNotFoundError`
(changeset included; Python was already correct since
`VolumePathNotFoundException` subclasses `NotFoundException`).

Link to Devin session:
https://app.devin.ai/sessions/80a50c2aba6441368d775b52a24cd1af
Requested by: @mishushakov

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mish@e2b.dev <mish@e2b.dev>
Co-authored-by: Mish Ushakov <10400064+mishushakov@users.noreply.github.com>
2026-08-20 21:16:34 +02:00
devin-ai-integration[bot] 5759f17e56 feat(sdk): add E2B client for multiple bound connection configs (#1720)
## Summary

Adds an `E2B` client to both SDKs so a process can talk to several API
keys / domains / deployments without going through environment
variables. The client binds a connection config once and exposes the
resource surfaces off it; the named top-level exports are untouched and
keep reading the environment.

Nothing existing changes (changeset is `minor`): the default export is
still `Sandbox`, `Template(...)` keeps working, and `E2B` is a new named
export. Two follow-ups are tracked for v3: making `E2B` the default
export
([SDK-341](https://linear.app/e2b/issue/SDK-341/sdk-v3-js-make-e2b-the-default-export-instead-of-sandbox))
and dropping the `Template` Proxy in favour of `new Template()`
([SDK-342](https://linear.app/e2b/issue/SDK-342/sdk-v3-js-drop-the-template-proxy-require-new-template)).

```ts
import { E2B } from 'e2b'

const { Sandbox, Volume, Template, Secret } = new E2B({
  apiKey: 'e2b_***',
  domain: 'e2b.dev',
})

const sandbox = await Sandbox.create()
const volume = await Volume.create('my-volume')
const exists = await Template.exists('my-template')
await Template.build(Template().fromPythonImage('3'), 'my-env')
await Secret.create('openai-api-key', 'sk-***')

// Per-call options still win over the client's options.
await Sandbox.create({ apiKey: 'e2b_other***' })
```

```python
from e2b import E2B

client = E2B(api_key="e2b_***", domain="e2b.dev")
Sandbox, Volume, Template = client.Sandbox, client.Volume, client.Template
Secret = client.Secret

sandbox = Sandbox.create()
volume = Volume.create("my-volume")
exists = Template.exists("my-template")
secret = Secret.create("openai-api-key", "sk-***")

# Async variants are exposed too.
AsyncSandbox, AsyncTemplate = client.AsyncSandbox, client.AsyncTemplate
async_sandbox = await AsyncSandbox.create()
await AsyncTemplate.exists("my-template")
```

### Mechanism

`client.Sandbox` / `client.Volume` / `client.Template` / `client.Secret`
(plus the `Async*` variants in Python) are per-client subclasses of the
real classes, carrying the bound opts as class-level state. Nothing
process-global is mutated, so clients are isolated from each other and
from the default path, and `cls`/`this` dispatch is preserved (`create`
on a client class returns an instance of that client class).

```ts
// sandboxApi.ts / volume/index.ts / template/index.ts / secret.ts — one hook per class hierarchy
protected static readonly boundOpts?: ConnectionOpts // undefined on the base classes
protected static resolveOpts<T extends ConnectionOpts>(opts?: T) {
  return ConnectionConfig.mergeOpts(this.boundOpts, opts) // { ...bound, ...definedPerCall }
}

// every static method that built a config from raw opts now does
- const config = new ConnectionConfig(opts)
+ const apiOpts = this.resolveOpts(opts)
+ const config = new ConnectionConfig(apiOpts)
```

```py
# sandbox/main.py, volume_sync.py, volume_async.py, template_{sync,async}/main.py, secret/base.py
_bound_api_params: ApiParams = {}  # empty on the base classes

@classmethod
def _resolve_api_params(cls, **opts: Unpack[ApiParams]) -> ApiParams:
    return merge_api_params(cls._bound_api_params, opts)

- config = ConnectionConfig(**opts)
+ config = ConnectionConfig(**cls._resolve_api_params(**opts))
```

`Template` used to be a factory function whose statics were pre-bound to
`TemplateBase`, which left no class for a client to subclass. It is now
the `TemplateBase` class itself, wrapped in a `Proxy` whose only trap
makes it callable without `new`, so `Template(...)` keeps working (no
breaking change) while `client.Template` is a plain subclass like
Sandbox/Volume and `Template.build(...)` resolves `this` naturally:

```ts
export function callableTemplate<T extends typeof TemplateBase>(cls: T) {
  return new Proxy(cls, { apply: (target, _this, args) => new target(...args) })
}
export const Template = callableTemplate(TemplateBase)          // Template() still returns a builder
this.Template = callableTemplate(class extends TemplateBase { boundOpts })  // client.Template
```

Because the trap only intercepts calls, `new Template()`, statics,
`instanceof` and subclassing all go straight to the class, and the
builder's default file context (`getCallerDirectory()`) still resolves
to the user's frame (the trap's frame is inside the SDK and filtered
like the old factory's).

Two side effects of routing everything through the hook:

- Static methods that resolved config off the base class had to move to
`this`/`cls`: `SandboxApi.createSandbox(...)` →
`this.createSandbox(...)` in JS, `new Volume(...)` → `new this(...)`,
and several Python `@staticmethod`s (`SandboxApi.list`,
`_cls_list_snapshots`, `delete_snapshot`, `Volume._class_get_info` /
`_class_list` / `destroy`, and the `Secret` operations) became
`@classmethod`s. Behavior for the top-level classes is unchanged since
their bound opts are empty.
- `DualMethod.__get__` (the descriptor behind `Volume.get_info` /
`Volume.list` working both on the class and on instances) now binds the
class-level function to the accessed class, so `client.Volume.list()`
sees the subclass' bound params instead of `Volume`'s.

Per-call values explicitly set to `undefined` / `None` are dropped when
merging, so they fall back to the client's opts rather than clearing
them into the env-var path.

### Tests

`packages/js-sdk/tests/client.test.ts` (MSW) and
`packages/python-sdk/tests/test_client.py` (local HTTP server, sync +
async) cover: the client's API key/domain being used instead of the env
vars, per-call precedence, rebinding the class (`const S =
client.Sandbox`), rebound `client.Template`, the client template builder
producing the same Dockerfile as the top-level one, two clients staying
isolated, generated-subclass instances, `client.Secret` (sync + async)
using the bound config, the top-level classes still using the env config
with empty bound opts and the default export still being `Sandbox`.



Link to Devin session:
https://app.devin.ai/sessions/772afa048b814ad784b5dde0a599df46
Requested by: @mishushakov

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mish@e2b.dev <mish@e2b.dev>
Co-authored-by: Mish Ushakov <10400064+mishushakov@users.noreply.github.com>
2026-08-20 18:03:15 +00:00
devin-ai-integration[bot] f89f8c3f96 Add secrets management to JS and Python SDKs (#1728)
## Summary

Implements Secrets Management in the SDK per the [Secrets Vault SDK
proposal](https://app.notion.com/p/3bab8c29687380b6a8f3e2ecae3f1b50) and
the backend Secrets API. Linear:
[SDK-133](https://linear.app/e2b/issue/SDK-133/sdk-for-managing-secrets).
Docs: [e2b-dev/docs#379](https://github.com/e2b-dev/docs/pull/379).

Spec sync: bumps `spec/infra-ref` to `e19a12b8` (the commit that adds
the Secrets API), adds the `secrets` tag to the `redocly.yaml` filters,
and regenerates via `make codegen` (the regen also pulls in unrelated
upstream spec updates, e.g. the `Error.errorCode` field). The js-sdk
envd schema generation now bundles through a new `envd` redocly api that
filters out operations the upstream spec marks `x-internal: true`
(orchestrator control plane: `/init`, `/freeze`, `/unfreeze`,
`/collapse`, `/fsfreeze`, `/fsthaw`) plus their now-unused component
schemas, so they no longer appear in `src/envd/schema.gen.ts`.

The existing `Secret` class (previously only the `iamToken`/`iam_token`
workload-identity helper) becomes the secrets management surface,
equivalent across JS, sync Python (`Secret`), and async Python
(`AsyncSecret`):

```typescript
Secret.create(name, value, opts?): Promise<SecretInfo>   // POST /secrets
Secret.update(secret, value, opts?): Promise<SecretInfo> // POST /secrets/{secretID} (rotates to a new version)
Secret.getInfo(secret, opts?): Promise<SecretInfo>       // GET /secrets/{secretID}
Secret.list(opts?): SecretPaginator                      // GET /secrets (cursor-paginated)
Secret.exists(secret, opts?): Promise<boolean>           // 200 → true, 404 → false
Secret.destroy(secret, opts?): Promise<boolean>          // 204 → true, 404 → false
Secret.fill(secret): string                              // local marker formatting, no network call
```

Design decisions per the proposal:
- **Values are write-only**: `SecretInfo` carries only metadata
(`secretId`, `name`, `version`, `metadata`, `createdAt`, `updatedAt`);
no read surface or error message includes a value.
- `update`/`getInfo` throw `SecretNotFoundError` /
`SecretNotFoundException` on 404 (subclass of `NotFoundError` /
`NotFoundException`, so generic not-found catches keep working; general
failures throw the new `SecretError` / `SecretException`);
`exists`/`destroy` map 404 to `false` instead.
- `secret` selector accepts either the `sec_` ID or the canonical
lowercase name (backend resolves both).
- `fill` returns the `${e2b.secrets.name}` marker for use in a network
rule's request transform — always the current version, purely local. The
egress proxy replaces the marker with the secret's current value when it
forwards a matching request; unresolvable markers fail open (the request
is forwarded with the affected headers omitted).
- Version-management endpoints from the proposal are marked TBD and not
in the committed backend contract, so they are intentionally not
implemented.

Python moves `e2b/secret.py` to an `e2b/secret/` package (`base.py`
shares `fill`/`iam_token`, `secret_sync.py` / `secret_async.py` mirror
each other); `from e2b import Secret` is unchanged.

Usage:

```typescript
import { Sandbox, Secret } from 'e2b'

const info = await Secret.create('stripe_api_key', 'sk_live_...', { metadata: { env: 'prod' } })
await Secret.update('stripe_api_key', 'sk_live_new...') // rotate → version 2

// Inject into matching outbound requests via a network rule's transform:
const sandbox = await Sandbox.create({
  network: {
    allowOut: ({ rules }) => [...rules.keys()],
    denyOut: ({ allTraffic }) => [allTraffic],
    rules: {
      'api.stripe.com': [
        {
          transform: {
            headers: { Authorization: `Bearer ${Secret.fill('stripe_api_key')}` },
          },
        },
      ],
    },
  },
})

await Secret.destroy('stripe_api_key')
```

```python
from e2b import AsyncSecret

info = await AsyncSecret.create("stripe_api_key", "sk_live_...", metadata={"env": "prod"})
paginator = AsyncSecret.list(limit=100)
while paginator.has_next:
    secrets = await paginator.next_items()
print(AsyncSecret.fill("stripe_api_key"))  # ${e2b.secrets.stripe_api_key}
```

Tests: msw-mocked JS suite (`tests/secret/secret.test.ts`) and
monkeypatched sync/async Python suites covering CRUD, pagination, 404
semantics, and `fill`. `pnpm run format/lint/typecheck` pass; changeset
included (minor for `e2b` and `@e2b/python-sdk`).

Link to Devin session:
https://app.devin.ai/sessions/175095f75cbe42df8710718a1ff2a6a3
Requested by: @mishushakov

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mish@e2b.dev <mish@e2b.dev>
2026-08-20 14:49:14 +00:00
devin-ai-integration[bot] 05aa03c35c Add typed not-found errors for volumes (#1730)
## Summary

Volumes threw the plain (JS-deprecated) `NotFoundError` /
`NotFoundException` everywhere. This adds typed subclasses, matching
`SecretNotFoundError` from #1728:

- `VolumeNotFoundError` / `VolumeNotFoundException` — the volume itself
doesn't exist (`Volume.getInfo` / `Volume.get_info`).
- `VolumePathNotFoundError` / `VolumePathNotFoundException` — a
file/directory path inside a volume doesn't exist
(read/write/list/remove/stat content operations).

Both subclass the existing `NotFoundError` / `NotFoundException`, so
existing generic catches keep working. Applied equivalently to the JS
SDK and the sync + async Python SDKs, with tests asserting both the
specific type and the base-class relationship, and a changeset.

```typescript
import { Volume, VolumeNotFoundError, VolumePathNotFoundError } from 'e2b'

try {
  await Volume.getInfo('non-existent-id')
} catch (err) {
  if (err instanceof VolumeNotFoundError) {
    // volume doesn't exist
  }
}

try {
  await vol.readFile('missing.txt')
} catch (err) {
  if (err instanceof VolumePathNotFoundError) {
    // path inside the volume doesn't exist
  }
}
```

```python
from e2b import Volume, VolumeNotFoundException, VolumePathNotFoundException

try:
    Volume.get_info("non-existent-id")
except VolumeNotFoundException:
    ...  # volume doesn't exist

try:
    volume.read_file("missing.txt")
except VolumePathNotFoundException:
    ...  # path inside the volume doesn't exist
```


Link to Devin session:
https://app.devin.ai/sessions/175095f75cbe42df8710718a1ff2a6a3
Requested by: @mishushakov

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mish@e2b.dev <mish@e2b.dev>
2026-08-20 16:21:21 +02:00
devin-ai-integration[bot] 2be6c12f79 refactor(sdk): resolve template config through a bound-opts class hook (#1721)
## Summary

Preparatory refactor so a per-client `client.Template` can subclass
`TemplateBase` and inject a bound `ConnectionConfig`, the way
`Sandbox`/`Volume` will. No public behavior change: the top-level
`Template()` factory, `Template.build(...)`, `AsyncTemplate.*` etc.
still resolve config from per-call opts + env vars (the bound field is
empty on the base class).

**JS** — terminal statics build their config through a class-level hook
instead of `new ConnectionConfig(opts)` directly:

```ts
class TemplateBase {
  protected static boundConnectionOpts: ConnectionOpts = {}
  protected static resolveConnectionConfig(opts?: ConnectionOpts) {
    return new ConnectionConfig({ ...this.boundConnectionOpts, ...definedEntriesOf(opts) })
  }
}

- const config = new ConnectionConfig(buildOptions)
+ const config = this.resolveConnectionConfig(buildOptions)
```

That only works if `this` is a template class, and the top-level surface
copies the statics off the class (`Template.build =
TemplateBase.build`), where `this` would be the factory function. So the
copies are now bound:

```ts
function boundToBase<T extends (...args: never[]) => unknown>(fn: T): T {
  return fn.bind(TemplateBase) as T  // the cast is only because `bind` collapses overloads
}

- Template.build = TemplateBase.build
+ Template.build = boundToBase(TemplateBase.build)
```

Top-level calls therefore resolve against `TemplateBase` (no bound opts
→ per-call opts + env, unchanged), while `MyTemplate.build(...)` keeps
`this === MyTemplate` and picks up its bound opts. `exists` likewise
dispatches via `this.aliasExists(...)` instead of
`TemplateBase.aliasExists(...)`. `toJSON`/`toDockerfile` untouched.

**Python** — `build`, `build_in_background`, `get_build_status`,
`exists`, `alias_exists`, `assign_tags`, `remove_tags`, `get_tags` went
from `@staticmethod` to `@classmethod` (signatures otherwise identical,
so call sites are unaffected), and the hardcoded lookups now go through
`cls`:

```python
-        config = ConnectionConfig(**opts)
-        data = Template._build(...)                                  # AsyncTemplate._build in the async SDK
-        logs_refresh_frequency=TemplateBase._logs_refresh_frequency,
+        config = cls._resolve_connection_config(**opts)
+        data = cls._build(...)
+        logs_refresh_frequency=cls._logs_refresh_frequency,
```

with the hook on the shared `TemplateBase`:

```python
_bound_api_params: ApiParams = {}

@classmethod
def _resolve_connection_config(cls, **opts: Unpack[ApiParams]) -> ConnectionConfig:
    return ConnectionConfig(**{**cls._bound_api_params, **{k: v for k, v in opts.items() if v is not None}})
```

Precedence is per-call opts > bound opts > env vars; explicitly passed
`undefined`/`None` per-call values are dropped so they don't wipe bound
opts. No `ConnectionConfig` process-global state is touched.

## Usage

```ts
import { TemplateBase } from 'e2b'

class MyTemplate extends TemplateBase {
  protected static boundConnectionOpts = { apiKey: 'e2b_...', domain: 'my.e2b.dev' }
}

await MyTemplate.exists('my-template')                        // bound config
await MyTemplate.exists('my-template', { apiKey: 'e2b_x' })   // per-call wins
```

```python
class MyTemplate(Template):
    _bound_api_params = {"api_key": "e2b_...", "domain": "my.e2b.dev"}

MyTemplate.exists("my-template")
MyTemplate.exists("my-template", api_key="e2b_x")
```

## Tests

New `tests/template/boundConnectionOpts.test.ts` (msw, asserts the
request URL + `X-API-KEY` per operation) and `test_bound_api_params.py`
for sync and async, covering: top-level path unchanged (per-call opts
and env fallback), bound opts as defaults for
`build_in_background`/`exists`/tag ops, per-call override, and
`None`/`undefined` not clearing bound opts.


Link to Devin session:
https://app.devin.ai/sessions/f15b0cecd1fd40e297334ac8ce154af1
Requested by: @mishushakov

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mish@e2b.dev <mish@e2b.dev>
2026-08-20 00:50:31 +00:00
cursor[bot] d55ddb8b5c test(python-sdk): drop build API path encoding integration tests (#1713)
Supersedes #1709 — claimed via `/sdk claim` by @mishushakov.

This is a clone of #1709: the original commit (`65c96289`) is applied
unmodified, so the tree here is byte-identical to that PR's head and the
original commit authorship and `Co-authored-by` trailer are preserved.
The branch was already current with `main` (1 commit ahead, 0 behind),
so no merge was needed. The contents were not reviewed or changed.
Please close #1709 in favour of this PR.

The original description follows verbatim.

---

## Summary

Removes `tests/shared/template/test_build_api_path_encoding.py`. Path
encoding is already covered by
`tests/shared/api/test_encode_path_param.py`.

## Verification

```bash
cd packages/python-sdk
uv run pytest tests/shared/api/test_encode_path_param.py tests/shared -q
# 175 passed, 1 skipped
```

[Slack
Thread](https://e2b-team.slack.com/archives/D0962B9UKEE/p1786973222264879?thread_ts=1786973222.264879&cid=D0962B9UKEE)

<div><a
href="https://cursor.com/agents/bc-57d27899-5769-437a-a242-7956988cdb1f?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/automations/3b1a5376-9bd3-11f1-ba66-0e7d0216e441"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/view-automation-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/view-automation-light.png"><img
alt="View Automation" width="141" height="28"
src="https://cursor.com/assets/images/view-automation-dark.png"></picture></a>&nbsp;</div>

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Mish Ushakov <mishushakov@users.noreply.github.com>
2026-08-19 18:09:31 +00:00
cursor[bot] 53676931b8 fix(sdk): omit autoResume from the create request when unset (#1694)
## Summary

When a caller does not configure `lifecycle.autoResume` /
`lifecycle["auto_resume"]`, the SDKs resolved the value to their own
local default and always serialized `{"autoResume": {"enabled": false}}`
in `POST /sandboxes`. That made an omitted preference indistinguishable
from an explicit opt-out, so the API could not own or evolve its own
default without SDK clients unintentionally overriding it.

The field is now left out of the request when it is not configured, in
the JavaScript SDK and in both Python paths (sync and async):

| caller | wire |
| --- | --- |
| no `autoResume` configured | field omitted |
| `autoResume: false` / `auto_resume: False` | `{"autoResume":
{"enabled": false}}` |
| `autoResume: true` / `auto_resume: True` | `{"autoResume": {"enabled":
true}}` |

Explicit choices keep exactly their previous wire shape, and the
existing client-side validation is untouched: `autoResume: true` still
requires `onTimeout: 'pause'` and is still rejected together with
`keepMemory: false`. An explicit `null` / `None` from an untyped caller
is treated as "not configured" rather than as an opt-out, matching how
`keepMemory` / `keep_memory` already normalizes `null`.

`autoResume` is absent from the `NewSandbox` `required` list in
`spec/openapi.yml`, so omitting it is spec-legal and needs no codegen
change.

Closes #1677. This is the same request-construction problem as #1669,
which covers the sibling `autoPause` field; that field is deliberately
left alone here so the two changes stay reviewable on their own.

## Usage

No application changes are required — only the request built for callers
who never expressed a preference changes.

```ts
import { Sandbox } from 'e2b'

// autoResume is left out of the request entirely, so the API's default applies
await Sandbox.create({ lifecycle: { onTimeout: 'pause' } })

// an explicit choice is sent exactly as before
await Sandbox.create({ lifecycle: { onTimeout: 'pause', autoResume: true } })
await Sandbox.create({ lifecycle: { onTimeout: 'pause', autoResume: false } })
```

```python
from e2b import Sandbox

# auto_resume is left out of the request entirely, so the API's default applies
Sandbox.create(lifecycle={"on_timeout": "pause"})

# an explicit choice is sent exactly as before
Sandbox.create(lifecycle={"on_timeout": "pause", "auto_resume": True})
Sandbox.create(lifecycle={"on_timeout": "pause", "auto_resume": False})
```

The `AsyncSandbox` surface behaves identically. The CLI already only
passed `autoResume` when `--lifecycle.autoresume` was given, so `e2b
sandbox create --lifecycle.ontimeout pause` now leaves the preference
unset as well.

## Tests

New request-level coverage asserts the body of `POST /sandboxes` for
five cases (nothing configured, only `onTimeout` configured, explicit
`false`, explicit `true`, explicit `null`/`None`) in all three
implementations:

- `packages/js-sdk/tests/sandbox/lifecycleRequest.test.ts` (new, msw) —
5 passed
- `packages/python-sdk/tests/sync/sandbox_sync/test_create.py` — 5 added
- `packages/python-sdk/tests/async/sandbox_async/test_create.py` — 5
added

These need no credentials. Re-running them with the source change
stashed fails exactly the three omission cases per SDK (3 in JS, 6
across sync and async Python) while the explicit `true`/`false` cases
pass both before and after, which is the evidence that existing behavior
is preserved.

Also run, all green:

- `pnpm run format`, `pnpm run lint`, `pnpm run typecheck` from the repo
root
- `packages/python-sdk`: `tests/shared/sandbox`,
`tests/{sync,async}/sandbox_*/test_create.py`,
`tests/{sync,async}/sandbox_*/test_connect.py` — 77 passed. An
`E2B_API_KEY` was available in this environment, so the live lifecycle
tests (auto-pause requiring `connect`, auto-resume waking on HTTP,
filesystem-only snapshot rebooting) really did create sandboxes and
pass.
- `packages/js-sdk`: `tests/sandbox/lifecyclePayload.test.ts` (live, 5
passed) plus the `iam` and `networkTransform` msw suites
- `packages/cli`: full suite, 109 passed — the CLI builds its own
`lifecycle` object, so its tests are relevant here

No integration test pins the API's current default for an unset
`autoResume`: letting the service own that default is the point of the
change, so the new tests assert only that the SDKs omit the field.

## Notes

- A changeset is included (`patch` for `e2b` and `@e2b/python-sdk`) with
both usage examples.
- The `TASTE.md` referenced in the task prompt
(`raw.cursorusercontent.com/e2b/sdk-harness/main/TASTE.md`) returns 404,
and `e2b/sdk-harness` is not reachable via `gh` either, so this follows
the conventions already established in the repo (nested option bags,
normalizing wire `null` to absent, no new client-side validation,
request-level regression tests next to the existing ones).
- No Linear MCP is available in this environment, so no Linear issue is
linked; the GitHub issue is referenced above instead.

<div><a
href="https://cursor.com/agents/bc-32acab7b-7ff0-4e36-8019-ca9901ac3ec0?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/automations/8e94ee92-9b0d-11f1-ba66-0e7d0216e441"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/view-automation-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/view-automation-light.png"><img
alt="View Automation" width="141" height="28"
src="https://cursor.com/assets/images/view-automation-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Mish Ushakov <mishushakov@users.noreply.github.com>
2026-08-19 19:16:01 +02:00
cursor[bot] 15bd48b73d fix(sdk): omit autoPause when no timeout lifecycle is configured (#1693)
Closes #1669.

## Problem

Both SDKs serialized `autoPause: false` in `POST /sandboxes` whenever
the caller left `lifecycle.onTimeout` / `lifecycle["on_timeout"]` unset,
because the local default (`kill`) was folded into the payload before
the request was built. That collapsed two distinct states at the API
boundary — "no preference expressed" and "explicitly chose `kill`" — so
the service could not own or evolve its own default without SDKs
silently overriding it.

## Change

`autoPause` is now sent only when a timeout action was actually chosen:

| `lifecycle` | wire |
| --- | --- |
| not configured | `autoPause` omitted |
| `onTimeout: 'kill'` | `autoPause: false` |
| `onTimeout: 'pause'` | `autoPause: true` |

An omitted `onTimeout` still resolves to `kill` locally for the existing
`keepMemory` / `autoResume` validation, so no error paths change. A
`null` `onTimeout` from an untyped caller counts as "not configured",
matching how the SDKs already treat nullish option values.

On the Python side the lifecycle normalization was duplicated verbatim
between `sandbox_sync` and `sandbox_async`. It is now a single
`build_lifecycle_config` in `e2b/sandbox/sandbox_api.py`, alongside the
existing `build_iam_config` / `build_network_config` builders, so the
two create paths cannot drift.

## Usage

Nothing changes for callers that configure a lifecycle; the difference
is only visible to callers that do not.

```ts
import { Sandbox } from 'e2b'

// No timeout lifecycle: autoPause is omitted and the API applies its default.
await Sandbox.create()

// Explicit action: autoPause: false / autoPause: true, as before.
await Sandbox.create({ lifecycle: { onTimeout: 'kill' } })
await Sandbox.create({ lifecycle: { onTimeout: 'pause' } })
```

```python
from e2b import Sandbox

# No timeout lifecycle: auto_pause is omitted and the API applies its default.
Sandbox.create()

# Explicit action: autoPause: false / autoPause: true, as before.
Sandbox.create(lifecycle={"on_timeout": "kill"})
Sandbox.create(lifecycle={"on_timeout": "pause"})
```

The async Python SDK behaves identically via `AsyncSandbox.create`.

## Tests

Request-level regression coverage for all three cases, plus the two
"lifecycle present but no action" shapes an untyped caller can produce:

- `packages/js-sdk/tests/sandbox/lifecycleRequest.test.ts` — msw
captures the create body; needs no credentials.
- `packages/python-sdk/tests/shared/sandbox/test_lifecycle_request.py` —
parametrized over the sync and async create paths, asserting the
serialized `NewSandbox` payload.

Both also assert that `autoPauseMemory` still accompanies an explicit
pause, since it is built from the same normalized action.

Run locally: `pnpm run format`, `pnpm run lint` and `pnpm run typecheck`
are clean. `packages/js-sdk` `tests/sandbox` is 250/251 (the one
failure, `network.test.ts > injected header is reflected by the httpbin
sidecar`, fails on `404: template 'httpbin' not found` — the sidecar
template is unavailable in this environment and is unrelated to this
change). `packages/python-sdk` `tests/shared/sandbox`,
`tests/sync/sandbox_sync/test_create.py` and
`tests/async/sandbox_async/test_create.py` pass, including the suites
that create real sandboxes.

Against the live API, creating a sandbox with no lifecycle, with
`on_timeout: "kill"` and with `on_timeout: "pause"` reports `on_timeout`
of `kill`, `kill` and `pause` respectively — so the API's current
default matches the previous client-side default and there is no
observable behavior change today, while the default now lives on the
server.

## Notes for review

- `autoResume` is still always sent (`{ enabled: false }` when unset),
which is the same class of question for that field. I left it alone to
keep this change to what the issue describes — happy to follow up if the
API should own that default too.
- No Linear MCP was available in this environment, so no Linear issue is
linked; the GitHub issue above is the tracking item.

<div><a
href="https://cursor.com/agents/bc-4a366453-8e1e-4a02-97f4-f38f2a302c7b?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/automations/8e94ee92-9b0d-11f1-ba66-0e7d0216e441"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/view-automation-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/view-automation-light.png"><img
alt="View Automation" width="141" height="28"
src="https://cursor.com/assets/images/view-automation-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Mish Ushakov <mishushakov@users.noreply.github.com>
2026-08-19 18:51:28 +02:00
cursor[bot] 666241d474 refactor(python-sdk): unify the pyqwest connection pools (#1692)
Claimed from #1659 on `/sdk claim` by the PR's own author (@mishushakov,
org member). The original commit is carried over untouched, so
authorship and the `Co-Authored-By` trailer are preserved — only PR
ownership moves. **Please close #1659 in favour of this PR** (`Closes`
does not auto-close pull requests, and this automation has no write
access to do it).

Closes
[SDK-291](https://linear.app/e2b/issue/SDK-291/python-sdk-unify-pyqwest-connection-pools-once-all-http-traffic-is-off).

## What changes

Every persistent HTTP stack in the Python SDK — control-plane REST, the
envd HTTP API, the envd RPC clients, and the volume content API — now
draws its connection pool from `e2b.api.client_sync`/`client_async`
keyed on `(proxy, idle read bound, HTTP version)`, instead of each
caching one of its own; reqwest pools per host internally, so one pool
serves the API host and every per-sandbox host without interference, and
because envd RPC and the envd HTTP API hit the same host an active
sandbox needs a single HTTP/2 connection instead of one per stack. Two
accessors expose it (`get_pyqwest_transport` for connectrpc,
`get_httpx_transport` for the generated httpx clients) while per-layer
concerns stay above the pool, so `PlainHTTPErrorTransport` becomes a
stateless per-client wrapper and Connect-error normalization stays
RPC-only. Streamed downloads keep a pool of their own — the only one
carrying the idle `read_timeout`, since reqwest's read timer runs during
body send and TTFB and would otherwise cut off long uploads.

Sharing puts the sandbox health probe on the connection the failed RPC
was using, so `tests/test_shared_transport_pool.py` pins that at the
frame level with a new multi-connection HTTP/2 server serving both
routes on one pool: an `RST_STREAM` kills only the stream and the probe
reuses the same connection (which is also the proof the pool is
genuinely shared), while a dropped TCP connection makes reqwest redial —
both still answer, so `handle_rpc_exception_with_health` keeps telling a
wedged connection apart from a dead sandbox.

**No user-facing API change**, so there are no usage examples to add —
the public surface, timeouts, retry policy, and proxy handling are all
unchanged, and JS has no counterpart since pyqwest pools are
Python-only.

## Added while claiming

One regression test the original was missing
(`test_{sync,async}_closing_one_client_leaves_the_shared_pool_open` in
`tests/test_api_client_transport.py`). The refactor's docstrings promise
that "closing an httpx client leaves the pool intact for the other
clients on it", and that promise is now load-bearing process-wide rather
than per-stack, but nothing asserted it: pyqwest pools *are* closable
(`SyncHTTPTransport.close`/`HTTPTransport.aclose`) and every httpx
client in the SDK holds the same cached adapter over one. The existing
tests all close their clients inside `finally` and then reset the
caches, so a close that reached the pool would go unnoticed.

The new tests round-trip against the local echo server, then close the
control-plane client and assert that both a sibling client (the envd
HTTP API) and the pool the envd RPC stack executes on directly still
work. Verified in the pinned dependency that
`PyqwestTransport`/`AsyncPyqwestTransport` inherit httpx's no-op
`close`/`aclose` and never touch the wrapped pool, and confirmed the
assertions are not vacuous: forwarding the adapter's `close()` to the
pool makes both of them fail with `RuntimeError: Executing request on
already closed transport`.

## Verification

- `uv run pytest tests/*.py -q` — 264 passed (262 before the added
test).
- `uv run pytest tests/shared -q` — 128 passed, 1 skipped.
- `pnpm run format`, `pnpm run lint`, `pnpm run typecheck` — clean.
- Checked the two claims from the original description that a reader
would have to take on trust: pyqwest's retry middleware does mirror
non-`bytes` request bodies in RAM (`RetryingRequestContent` accumulates
every chunk into a `bytearray` to make the body replayable), which is
why template context uploads deliberately keep their own non-retrying
transport — and why the same buffering applies to volume uploads and
envd `files.write` on the shared retrying pool, a pre-existing issue on
`main` filed as
[SDK-332](https://linear.app/e2b/issue/SDK-332/python-sdk-streamed-uploads-are-mirrored-in-ram-by-the-pyqwest-retry)
rather than something this PR introduces.

## Notes for review

- RPC and envd HTTP now multiplex on one HTTP/2 connection and share its
concurrent-stream budget (Go's default is 250, and hyper dials a second
connection when one saturates) — low risk, but a real behavior change
under heavy per-sandbox concurrency.
- `get_envd_transport` survives only as an alias of `get_transport`
because external consumers (`e2b-code-interpreter`) call it; prefer
`get_transport` inside the SDK.
- The changeset from the original PR is carried over unchanged
(`@e2b/python-sdk` patch); the added test needs none of its own.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<div><a
href="https://cursor.com/agents/bc-bfb51e4b-1116-42f4-8622-ba3bdeacf11a?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/automations/3b1a5376-9bd3-11f1-ba66-0e7d0216e441"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/view-automation-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/view-automation-light.png"><img
alt="View Automation" width="141" height="28"
src="https://cursor.com/assets/images/view-automation-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Mish Ushakov <10400064+mishushakov@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Mish Ushakov <mishushakov@users.noreply.github.com>
2026-08-19 18:35:20 +02:00
cursor[bot] 6824cdf313 feat(sdk): route sandbox egress through your own SOCKS5 proxy (BYOP) (#1688)
Drafts the SDK surface for [bring your own
proxy](https://e2b-docs-byop-egress-proxy.mintlify.site/network/byop):
`network.egressProxy` / `network["egress_proxy"]` on sandbox create, on
`updateNetwork` / `update_network`, and in what `getInfo` / `get_info`
reports back. Tunneling happens on the host after the allow and deny
lists are evaluated, so nothing runs inside the sandbox and code running
there can neither see the proxy nor route around it.

## The spec pin comes first

The pinned infra spec marked `egressProxy` `x-not-implemented: true`,
which Redocly's `filter-out` decorator drops from both generated clients
— so the field did not exist in `schema.gen.ts` or in the Python client
models, and no handwritten surface could reach it.
[infra@0716edb9e8](https://github.com/e2b-dev/infra/commit/0716edb9e840f110c5f87c186876c01e61553098)
removes the flag, so the first commit bumps `spec/infra-ref` and re-runs
codegen rather than hand-writing the wire types.

The pin picks up three other spec changes, and all of them are invisible
to the SDKs: `AdminTeamRunningSandboxCounts`, the dead
`NodeDetail.cachedBuilds` field, and `/admin/sandboxes/running-counts`
are admin-tagged, and the envd spec is byte-identical between the two
commits (verified by comparing the `packages/envd/spec` trees at both
refs). `make codegen` could not run here because the VM has no Docker,
so the spec was replaced with the byte-identical upstream file at the
new pin and the two REST generators were run natively with the pinned
`@redocly/cli` and `e2b-openapi-python-client`.

## Usage

Create a sandbox that tunnels its egress:

```ts
import { Sandbox } from 'e2b'

const sandbox = await Sandbox.create({
  network: {
    egressProxy: {
      address: 'proxy.example.com:1080',
      username: 'proxy-user',
      password: 'proxy-password',
    },
  },
})
```

```python
from e2b import Sandbox

sandbox = Sandbox.create(
    network={
        "egress_proxy": {
            "address": "proxy.example.com:1080",
            "username": "proxy-user",
            "password": "proxy-password",
        },
    },
)
```

It composes with the rest of the network configuration — here everything
except `api.example.com` is denied, and what is allowed goes through
your proxy:

```ts
await Sandbox.create({
  network: {
    allowOut: ['api.example.com'],
    denyOut: ({ allTraffic }) => [allTraffic],
    egressProxy: { address: 'proxy.example.com:1080' },
  },
})
```

```python
Sandbox.create(
    network={
        "allow_out": ["api.example.com"],
        "deny_out": lambda ctx: [ctx.all_traffic],
        "egress_proxy": {"address": "proxy.example.com:1080"},
    },
)
```

Set or replace it on a sandbox that is already running, with no restart.
The update replaces the whole configuration instead of merging into it,
so an update that leaves the proxy out stops tunneling:

```ts
await sandbox.updateNetwork({
  allowOut: ['api.example.com'],
  denyOut: ({ allTraffic }) => [allTraffic],
  egressProxy: { address: 'proxy.example.com:1080' },
})

// Stop tunneling: an update without egressProxy clears it
await sandbox.updateNetwork({})
```

```python
sandbox.update_network({
    "allow_out": ["api.example.com"],
    "deny_out": lambda ctx: [ctx.all_traffic],
    "egress_proxy": {"address": "proxy.example.com:1080"},
})

# Stop tunneling: an update without egress_proxy clears it
sandbox.update_network({})
```

Read the active proxy back:

```ts
const info = await sandbox.getInfo()
console.log(info.network?.egressProxy)
// { address: 'proxy.example.com:1080', username: 'proxy-user' }
```

```python
info = sandbox.get_info()
print(info.network["egress_proxy"])
# {'address': 'proxy.example.com:1080', 'username': 'proxy-user'}
```

## Design notes

- **`SandboxEgressProxyOpts` in, `SandboxEgressProxyInfo` out.** The API
never returns the password, so the result type does not have the field —
the same split as `SandboxNetworkRule` / `SandboxNetworkRuleInfo`.
`fromApiEgressProxy` / `_from_client_egress_proxy` map the generated
type at the boundary and drop a password even if a future API version
starts echoing one back, so the type cannot quietly become a lie.
- **The body is rebuilt from known fields**, as `buildIamBody` already
does, so stray keys on the caller's object never reach the wire and a
later mutation of it cannot alter an in-flight request.
- **No client-side validation.** Address form, port range, hostname
resolution, the internal-range rejection and the
password-without-username rule are all the server's — it is the only
side that can check them, and each already comes back as a readable API
error.
- **`null` never reaches a consumer.** The wire field is nullable; both
SDKs normalize it (absent key in Python, `undefined` in JS), and an
explicit `null` / `None` from an untyped caller is treated as "no proxy"
on the way in.
- Both types are exported from the flat entry points (`index.ts`,
`__all__`).

## Testing

Unit-level in both SDKs — msw in JS (12 tests), the shared builders in
Python (11 tests, covering sync and async since they share the
builders). Integration coverage is not included on purpose: tunneling
needs a SOCKS5 proxy reachable from E2B's infrastructure, which CI has
no way to stand up, and the feature is gated behind a private-beta team
flag.

`pnpm run format`, `pnpm run lint` and `pnpm run typecheck` are clean
repo-wide. The remaining test failures in this environment are all
`AuthenticationException` / missing `E2B_API_KEY` in pre-existing
integration suites; no credentials were available on the VM.

## Notes

- BYOP is available on E2B Cloud and in BYOC. A sandbox that names a
proxy on a deployment built from open source `e2b-dev/infra` is rejected
as unsupported by the orchestrator, which is why the field carried
`x-not-implemented` upstream for a while.
- No Linear MCP was available in this run, so no issue is linked.


<div><a
href="https://cursor.com/agents/bc-653eef78-87bb-5c9c-92d8-e573cd7ba5be?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/automations/8e94ee92-9b0d-11f1-ba66-0e7d0216e441"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/view-automation-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/view-automation-light.png"><img
alt="View Automation" width="141" height="28"
src="https://cursor.com/assets/images/view-automation-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Mish Ushakov <mishushakov@users.noreply.github.com>
2026-08-19 17:54:11 +02:00
cursor[bot] e2eebd570f fix(python-sdk): URL-encode namespaced template IDs and aliases (#1691)
Claimed clone of #1520 (EN-1379), rebuilt on current `main`. Please
close #1520 in favour of this PR.

## Summary

Namespaced template IDs and aliases contain a slash, but the Python SDK
interpolated them into the request path unencoded, so
`Template.exists("namespace/name")` requested
`/templates/aliases/namespace/name` instead of
`/templates/aliases/namespace%2Fname` — the slash split the route rather
than staying inside one path segment.

A new `encode_path_param` helper percent-encodes the `template_id` and
`alias` path params across every template build-API call site, in both
the sync and async implementations. This matches the JS SDK, which
already encodes path params: `openapi-fetch`'s default path serializer
runs each value through `encodeURIComponent`, so no JS change is needed.

## Usage

```python
from e2b import Template

# Namespaced templates now resolve to /templates/aliases/my-team%2Fmy-template
Template.exists("my-team/my-template")

# ... and to /templates/my-team%2Fmy-template/tags
Template.get_tags("my-team/my-template")
```

```python
from e2b import AsyncTemplate

await AsyncTemplate.exists("my-team/my-template")
```

## Changes on top of #1520

- Merged current `main` (the original branch was 26 commits behind), and
confirmed the fix still covers every path-param call site after the
merge.
- Added `tests/shared/template/test_build_api_path_encoding.py`: the
original PR only unit-tested the helper, which would not catch a call
site that forgot to encode, nor httpx decoding `%2F` back into a
separator. The new tests drive the sync and async build APIs through an
`httpx.MockTransport` and assert the raw request path for both a
namespaced alias and a namespaced template ID. Verified they fail when
the encoding is removed.

## Tests

- `pnpm run format`, `pnpm run lint`, `pnpm run typecheck` — all clean.
- `uv run pytest tests/shared` in `packages/python-sdk` — 139 passed, 1
skipped.

A changeset is included (`@e2b/python-sdk` patch); this is a Python-only
change, so the JS SDK is not bumped.

<div><a
href="https://cursor.com/agents/bc-538dde5d-ca2f-4beb-8471-be70e265337d?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/automations/3b1a5376-9bd3-11f1-ba66-0e7d0216e441"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/view-automation-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/view-automation-light.png"><img
alt="View Automation" width="141" height="28"
src="https://cursor.com/assets/images/view-automation-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Mish Ushakov <10400064+mishushakov@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Tomas Valenta <49156497+ValentaTomas@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Mish Ushakov <mishushakov@users.noreply.github.com>
2026-08-19 15:44:16 +00:00
cursor[bot] fc34961205 test(python-sdk): drop httpcore-era stream reader tests after the pyqwest migration (#1690)
Claimed from #1656 via `/sdk claim` (requested by @mishushakov, the
original author). Same single commit, original authorship preserved.
**Supersedes #1656, which should be closed in favor of this PR** — I
don't have write access to close it myself.

---

`tests/test_file_stream_reader.py` was written against httpcore and
never migrated with the rest of the pyqwest stack — it builds bare
`httpx.Client()` instances, so it still passes green while exercising a
transport the SDK no longer ships. Both of its load-bearing premises are
dead:

- `_active_connections()` read `client._transport._pool.connections`, an
httpcore-only internal. `PyqwestTransport` has no `_pool` at all.
- `request.extensions["timeout"]["read"]` is no longer a per-chunk idle
bound. The pyqwest adapter collapses read/write into one whole-operation
deadline and exits the timeout scope before the body streams, so it
bounds nothing after the response head.

This deletes the five tests that asserted only httpcore behavior (both
idle-timeout tests, the slow-consumer test, both abandoned-reader tests)
plus the helper, and re-anchors the remaining eight on
`response.is_closed` — `FileStreamReader.close()`'s actual contract,
transport-agnostic and stronger than the pool check, since the
context-manager tests now also assert the response stays open
mid-stream.

Also removes `tests/bugs/`, whose sole file was a permanently
`@pytest.mark.skip`'d pyautogui repro against the `desktop` template.

The real streaming-idle coverage against actual pyqwest transports
already lives in `tests/test_volume_client.py`; the SDK stopped sending
per-request timeouts on streamed reads for this same reason in
`e2b/sandbox_sync/filesystem/filesystem.py`.

Test-only, so no changeset — matching the repo convention for
`test(...)` PRs.

## Usage examples

None — this PR touches only `packages/python-sdk/tests/`. There is no
change to any public API, so no user-facing usage differs.

## Verification

Re-ran the original PR's checks on this branch:

```
$ uv run pytest tests/test_file_stream_reader.py -v
8 passed in 0.31s          # was 13

$ uv run pytest tests/*.py -q
245 passed in 15.16s       # full python-sdk unit suite

$ uv run make format       # ruff format . -> 403 files left unchanged
$ uv run make lint         # ruff check . -> All checks passed!
$ uv run make typecheck    # ty check -> All checks passed!
```

Also confirmed nothing else in the repo references the deleted
`tests/bugs/`, `test_envelope_decode`, or `_active_connections`.

Closes SDK-324

<div><a
href="https://cursor.com/agents/bc-11701ccd-9302-40dc-b58b-33e570bed5c4?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/automations/3b1a5376-9bd3-11f1-ba66-0e7d0216e441"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/view-automation-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/view-automation-light.png"><img
alt="View Automation" width="141" height="28"
src="https://cursor.com/assets/images/view-automation-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Mish Ushakov <10400064+mishushakov@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Mish Ushakov <mishushakov@users.noreply.github.com>
2026-08-19 17:30:52 +02:00
Mish Ushakov 6248b12a5e feat(sdk): remove the deprecated accessToken option (#1680)
Removes the deprecated `accessToken` / `access_token` option from both
SDKs, along with its `E2B_ACCESS_TOKEN` environment fallback and the
`Authorization: Bearer` header it produced. The option was already
deprecated in both SDKs — `connectionConfig.ts` and
`connection_config.py` both pointed at `apiHeaders` / `api_headers` as
the replacement — and E2B access tokens are no longer accepted for API
authentication, so resolving one and putting it on the wire was dead
weight. Requests now authenticate with the API key alone.

Callers who need a bearer token for a custom deployment pass it
explicitly, which is what the deprecation notice already told them to
do:

```ts
// Before
const sandbox = await Sandbox.create({ accessToken: token })

// After
const sandbox = await Sandbox.create({
  apiHeaders: { Authorization: `Bearer ${token}` },
})
```

```python
# Before
config = ConnectionConfig(access_token=token)

# After
config = ConnectionConfig(api_headers={"Authorization": f"Bearer {token}"})
```

`Sandbox.envd_access_token` / `traffic_access_token` are unrelated
per-sandbox tokens and are unaffected, as is the volume client's `token`
(which never read the env var — there's a test asserting exactly that).

Part of
[SDK-6](https://linear.app/e2b/issue/SDK-6/mark-e2b-access-token-as-deprecated-inside-all-code-references).
The CLI half is stacked on top in #1679.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 14:01:51 +02:00
Mish Ushakov 0d507cd53d fix(python-sdk): restore the http2 parameter on the transport factories (#1671)
The pyqwest migration in 2.38.0 dropped the `http2` parameter from
`get_transport` and `get_envd_transport` (added deliberately in #1347,
2.32.0) and collapsed the transport cache key to the proxy alone, so
`e2b-code-interpreter`'s Jupyter requests —
`get_transport(self.connection_config, http2=False)` — now raise
`TypeError: get_transport() got an unexpected keyword argument 'http2'`;
that is already live, since `e2b = "^2.26.0"` resolves to 2.38.x, and it
blocks the Python half of code-interpreter
[#328](https://github.com/e2b-dev/code-interpreter/pull/328). pyqwest
supports the capability, it just was not threaded through: this restores
the pre-2.38.0 signature (so no consumer code changes, only an `e2b`
floor bump) by passing `http_version=None if http2 else
HTTPVersion.HTTP1` into the pyqwest transports, and puts the HTTP
version back into both cache keys — without that, whichever caller asks
second is handed a transport of the wrong version. The default is
unchanged: `None` leaves TLS connections to ALPN (HTTP/2 against the E2B
API) and uses HTTP/1 for plaintext, exactly as today. HTTP/1.1 is not
cosmetic for the consumer — with HTTP/2 multiplexing, abandoning a
request only resets its stream, so the code-interpreter server never
sees the `http.disconnect` it needs to interrupt the kernel, while
HTTP/1.1's one connection per request closes the connection and the
server observes it.

## Usage

Both factories are internal (nothing is exported from
`e2b/__init__.py`), so there is no public API change; consumers reaching
into them get the 2.32.0 call back:

```python
from e2b.api.client_sync import get_transport, get_envd_transport

# Unchanged: ALPN negotiates the version (HTTP/2 against the E2B API).
transport = get_transport(config)

# Its own pool, pinned to HTTP/1.1, so a cancelled request closes the
# connection and the server observes the disconnect.
http1 = get_transport(config, http2=False)
envd_http1 = get_envd_transport(config, http2=False)
```

The async mirror (`e2b.api.client_async`) is identical.

## Tests

Six new cases in
`packages/python-sdk/tests/test_api_client_transport.py`, sync and
async: cache separation and identity across `http2` / proxy /
`for_streaming`, the `http_version` value actually reaching the pyqwest
transport (`[None, HTTP1, HTTP1]`), and a round trip proving the pinned
transport works. The negotiated version can't be observed locally — the
test echo server is plaintext, where both settings speak HTTP/1 — so it
is asserted at the constructor, with the reason in a comment; it was
verified by hand against `https://api.e2b.app/health` via the
`pyqwest.access` logger, which shows `"HTTP/2 200 OK"` on the default
and `"HTTP/1.1 200 OK"` with `http2=False` on both factories (and
confirms `httpx.Response.http_version` is unreliable through the adapter
— it reports HTTP/1.1 either way). 256 unit tests pass, plus `make
lint`, `make typecheck` and `make format`. No JS change: its transport
is an undici-dispatcher `fetch` with no HTTP-version knob, and the JS
half of code-interpreter #328 is a clean bump.

Closes
[SDK-335](https://linear.app/e2b/issue/SDK-335/python-sdk-get-transport-lost-its-http2-parameter-in-2380-breaking-e2b)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 17:56:26 +02:00
Mish Ushakov 07eb9be196 feat(sdk): resolve iam token placeholders in network transform callbacks (#1616)
Stacked on #1606 (`iam-sdk-feature`) — merge that one first. This is the
second half of SDK-245: it makes the workload tokens registered by
`Sandbox.create`'s `iam` option usable, by letting a network rule's
`transform` be a **callback** that receives placeholder strings the
egress proxy resolves per request.

`iam.tokens.aws` is the literal string `${e2b.identity.tokens.aws}` (the
frozen backend spelling — a placeholder can only select a persisted
named token, never an inline audience or claim). The SDK never resolves
it: the wire payload carries the placeholder and the proxy substitutes a
freshly minted JWT-SVID when it forwards the request, so the token value
never reaches SDK-side code or the sandbox.

Referencing a name that isn't registered in `iam.tokens` fails with
`InvalidArgumentError` / `InvalidArgumentException` listing the names
that are — the proxy never turns an unregistered name into a token, so a
typo would otherwise surface as a confusing auth failure at the
destination. `updateNetwork` / `update_network` accepts the same
callbacks, but its payload carries no `iam` config, so token names can't
be validated client-side there and any name resolves to its placeholder.

Static `transform: { headers }` objects keep working unchanged
(including hand-written `${e2b.identity.tokens.<name>}` strings, which
stay the escape hatch for tokens the SDK doesn't know about).

Only `{ iam }` is exposed on the context for now — `${e2b.sandboxId}` /
`${e2b.teamId}` / `${e2b.executionId}` from the older prototype are not
part of the current backend design, so `sandbox` can be added later when
there is something to resolve.

## Usage

```ts
import { Sandbox, Secret } from 'e2b'

const sandbox = await Sandbox.create({
  iam: {
    tokens: {
      aws: Secret.iamToken({ audience: 'sts.amazonaws.com', tokenType: 'JWT-SVID' }),
    },
  },
  network: {
    // Only allow egress to hosts that have rules registered.
    allowOut: ({ rules }) => [...rules.keys()],
    rules: {
      'api.internal.example.com': [
        {
          transform: ({ iam }) => ({
            headers: { Authorization: `Bearer ${iam.tokens.aws}` },
          }),
        },
      ],
    },
  },
})
```

```python
from e2b import Sandbox, Secret

sandbox = Sandbox.create(
    iam={
        "tokens": {
            "aws": Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID"),
        },
    },
    network={
        "allow_out": lambda ctx: list(ctx.rules.keys()),
        "rules": {
            "api.internal.example.com": [
                {
                    "transform": lambda ctx: {
                        "headers": {"Authorization": f"Bearer {ctx.iam.tokens['aws']}"},
                    },
                },
            ],
        },
    },
)
```

Both send:

```json
{
  "iam": { "tokens": { "aws": { "audience": "sts.amazonaws.com", "tokenType": "JWT-SVID" } } },
  "network": {
    "allowOut": ["api.internal.example.com"],
    "rules": {
      "api.internal.example.com": [
        { "transform": { "headers": { "Authorization": "Bearer ${e2b.identity.tokens.aws}" } } }
      ]
    }
  }
}
```

## Notes

- `allowOut` / `deny_out` selectors run **before** transforms are
resolved, so `ctx.rules` still hands back the rules you passed — a
rule's `transform` there is the union (object or callback), not the
materialized object. The `getInfo` view keeps its own narrowed
`SandboxNetworkRuleInfo` type.
- Token names are validated where they are registered and again before
interpolation: a name cannot be empty or contain `{`, `}` or control
characters. The proxy reads a placeholder up to its first `}`, so `a}b`
would mint the unrelated token `a` and leave `b}` as literal text, and a
`{` in a name can open a second placeholder. The interpolation check is
what covers `updateNetwork`, where any name the callback looks up
becomes a placeholder without passing through the `iam` config.
- Every lookup form on `iam.tokens` is guarded, not just `[name]`:
Python's map is a `Mapping` whose `__getitem__` owns resolution (so
`.get('typo')` raises instead of returning `None`), and membership
(`'aws' in ctx.iam.tokens` / `'aws' in iam.tokens`) answers "is it
registered?" without raising so a callback can branch on it. Lookup
checks own keys only, so an unregistered name colliding with an object
member (`constructor`, `__proto__`) reports as unregistered instead of
resolving to a built-in; the four properties the runtime itself reads
(`toJSON`, `then`, `toString`, `valueOf`) still resolve normally, so
serializing, awaiting or coercing the map does not trip the guard.
- A callback must be synchronous and return a plain transform object; a
promise (from an `async` callback), an array, a `Map`/`Date`/class
instance, or a missing return value is rejected with an actionable error
rather than silently creating a rule with no headers. The awaitable is
closed/caught so you don't also get an unawaited-coroutine warning or an
unhandled rejection.

## Tests

New payload-level tests: JS `tests/sandbox/networkTransform.test.ts`
(msw), Python `tests/shared/sandbox/test_network_transform.py` — shared
rather than mirrored into the sync and async suites, since they only
exercise the shared builders. They cover placeholder resolution,
enumerating and membership-testing registered tokens, `JSON.stringify`
of the context not tripping the guard, static transforms staying
byte-identical, `transform: null`, the unregistered-name rejection
through both `[name]` and `.get()`, the no-`iam` rejection,
non-transform and `async` return values, unusable token names (both
braces, a smuggled placeholder, a newline, empty) at registration and on
the update path, and the permissive `updateNetwork` path.

Verified against production on all three surfaces (JS, sync Python,
async Python), where:

1. a static transform carrying `Bearer ${e2b.identity.tokens.aws}` is
accepted by `validateNetworkRules` and round-trips through `getInfo` /
`get_info` unchanged;
2. the callback-resolved payload reaches the API and is answered with
the expected team-gating error (`400: Sandbox IAM workload tokens are
not available for your team.`), since `iam` is still feature-flagged;
3. a misspelled or unusable token name is rejected client-side before
any request is made.

Proxy-side substitution of the placeholder ships separately in belt
(EN-1864); until then the header value is forwarded verbatim, which is
why there is no end-to-end injection test here.

Part of SDK-245.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-13 16:58:44 +02:00
Mish Ushakov 64b25bb37b feat(sdk): add iam workload identity option and Secret.iamToken helper (#1606)
Implements the sandbox workload identity (IAM) feature from the [infra
spec](https://github.com/e2b-dev/belt/blob/main/spec/openapi-infra.yml)
(`SandboxIam` / `SandboxIamTokens` / `SandboxIamToken`, already present
in the pinned spec and generated clients) across the JS SDK and the sync
and async Python SDKs. `Sandbox.create` gains an `iam` option whose
non-empty `tokens` map enables workload identity, and a new `Secret`
class (exported from both main packages) provides `iamToken` /
`iam_token` to define the token values, per the SDK design. The design
doc's `filePath` field is deliberately omitted until it lands in the
OpenAPI spec, and plain `{ audience, tokenType }` objects are accepted
alongside `Secret.iamToken` results. The SDK builds the request body
from only the known token fields (stray properties never reach the wire,
undefined-valued map entries count as empty) and rejects tokens missing
`audience`/`tokenType` (`token_type` in Python) with
`InvalidArgumentError` / `InvalidArgumentException`. Covered by
request-body tests (msw in JS, `NewSandbox` payload tests in Python)
since the backend feature is team-gated; all three surfaces were also
smoke-tested end-to-end against production, where the payload is parsed
and answered with the expected team-gating error.

Fixes SDK-245.

## Usage

```ts
import { Sandbox, Secret } from 'e2b'

const sandbox = await Sandbox.create({
  iam: {
    tokens: {
      aws: Secret.iamToken({ audience: 'sts.amazonaws.com', tokenType: 'JWT-SVID' }),
    },
  },
})
```

```python
from e2b import Sandbox, Secret

sandbox = Sandbox.create(
    iam={
        "tokens": {
            "aws": Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID"),
        },
    },
)
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 16:58:43 +02:00
Mish Ushakov 11912ffa04 refactor(python-sdk): share one envd HTTP client across the sync sandbox modules (#1655)
The sync flavor built four envd HTTP clients per sandbox — `Filesystem`,
`Commands` and `Pty` each constructed their own — while the async flavor
built one in `Sandbox.__init__` and threaded it down; this builds it
once on the sync side too and passes it into the three modules. No
functional change: `get_envd_transport` already caches the pyqwest
transport per `(proxy, for_streaming)` process-wide, so those four
clients already shared one connection pool — the cost was a few
`httpx.Client` wrappers per sandbox, plus a sync/async divergence that
CLAUDE.md and TASTE.md both ask us to avoid. It also clears the last
cosmetic differences between the two flavors: async `Commands`/`Pty`
swap their `_check_health` lambda closure for the sync side's attribute
+ method, sync `Commands`/`Pty` drop a write-only `_envd_api_url`, and
async `Filesystem` builds its RPC client first to match sync — the three
constructor pairs now differ only in the sync/async client and RPC class
names. All constructors touched are internal, so there is no public API
change and nothing to show as a usage example.

Verified with 336 unit tests, 92 sync and 89 async integration tests
against prod (`commands`, `pty`, `files`), plus `make lint` and `make
typecheck`.

Closes
[SDK-322](https://linear.app/e2b/issue/SDK-322/python-sdk-share-one-envd-http-client-across-the-sync-sandbox-modules)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 18:40:42 +00:00
Mish Ushakov b048369307 feat(python-sdk): move the envd HTTP API client onto pyqwest (#1623)
## What

Tracked in [SDK-265](https://linear.app/e2b/issue/SDK-265) (part of the
[SDK-268](https://linear.app/e2b/issue/SDK-268) stack). Stacked on
#1603, at the top of the pyqwest stack (#1601#1602#1603 → this).
Migrate the envd HTTP API client — sandbox file transfers
(`files.read`/`write`), health checks — from httpx-native transports to
pyqwest via the httpx adapter, and dedupe the transport plumbing that
#1558 (envd RPC) and #1601 (REST) each carried a copy of. With this, all
Python SDK traffic runs on pyqwest: REST control plane (#1601), envd RPC
(#1558, connectrpc), envd HTTP API (this PR); the volume content client
(#1602) and template build uploads (#1603) sit below this one in the
stack.

## How

**Shared plumbing** (first commit): `e2b.api` becomes the canonical home
for the proxy narrowing (`proxy_to_config`, with stack-neutral error
messages), the pool tuning, and the flavor `ConnectionRetryTransport` +
a new `retrying_http_transport(proxy, read_timeout=None)` factory;
`e2b.envd.client_sync/client_async` import them instead of defining
their own (envd RPC behavior unchanged, pools stay separate —
unification is SDK-291).

**envd HTTP API** (second commit):

- `get_envd_transport(config, for_streaming=False)` returns
pyqwest-adapter transports cached per `(proxy, streaming)`;
`get_envd_api(config, base_url, for_streaming=False)` builds the httpx
client with sandbox headers + logging hooks. The per-thread (sync) /
per-loop (async) client caching in
`Filesystem`/`Commands`/`Pty`/`AsyncSandbox` is gone — one shared client
per module, same rationale as the `ApiClient` simplification in #1601.
- **Streamed downloads**: the streaming transport carries a 60s
`read_timeout` — an idle bound that resets on every read, capping stalls
without limiting total transfer time. It gets a dedicated pool because
reqwest's read timer keeps ticking while a request body is sent and
while waiting for the response head, so on the shared transport it would
cut off uploads and slow unary responses. An explicit `request_timeout`
becomes the whole-transfer deadline (adapter semantics) and is sent only
when the caller set one; `stream_idle_timeout` stays honored on the
async client via `wait_for` per read (so values above 60s work and `0`
disables), and is documented as ignored on the sync client, which cannot
interrupt a blocking read. Mirrors #1602's volume design.
- **Uploads**: buffered uploads keep `request_timeout` as a
whole-request deadline; streamed (file-like) uploads carry no
client-side timeout and are bounded server-side (envd's idle read
timeout) — both exactly the JS SDK's behavior (`getSignal` for buffered,
no signal for streams).
- **Multipart**: `files=` uploads go out as httpx's `MultipartStream`,
which implements *both* `SyncByteStream` and `AsyncByteStream`. The
pyqwest 0.7 adapter's sync content conversion matched `AsyncByteStream`
first and raised `TypeError("unreachable")` from inside the body
iterator, surfacing as a `WriteError` mid-request ("http2 error: stream
error sent by user"). Fixed upstream in
[pyqwest#196](https://github.com/curioswitch/pyqwest/pull/196), which
matches the sync case first — so this PR carries no workaround (the
stack requires **pyqwest 0.9**, set in #1601). The regression test
stays, now covering the upstream fix.
- The stream readers map the transport's idle timeout (builtin
`TimeoutError` under pyqwest) to the documented `httpx.ReadTimeout`;
`handle_envd_api_transport_exception`'s health-probe path keeps working
because the adapter maps HTTP/2 stream resets to
`httpx.RemoteProtocolError`.

- **RPC logging**: the `LoggingInterceptor` docstring no longer promises
its own removal. pyqwest does log requests
([pyqwest#197](https://github.com/curioswitch/pyqwest/pull/197)), but on
process-wide `pyqwest`/`pyqwest.access` loggers that can't carry the
per-sandbox `logger` and don't see streamed messages or the Connect
error code of a stream that fails inside a `200 OK` — so the interceptor
stays, with those loggers below it.
[pyqwest#192](https://github.com/curioswitch/pyqwest/pull/192), the
middleware it referenced, was closed in favor of #197.

- **Transports**: rebased onto #1603 on pyqwest 0.9, so the envd HTTP
API transports are the stock `PyqwestTransport`/`AsyncPyqwestTransport`
(the SDK's adapter subclasses are gone as of #1601 — 0.9 strips the
`Host` header and maps timeouts itself) with `follow_redirects=False`
and, for the streaming pool, the transport-wide `read_timeout`.

## Testing

- Unit: envd transport keying (streaming vs regular vs REST pools),
`get_envd_api` wiring (headers, transports), multipart regression
through a local server, stream-reader timeout mapping + per-read idle
bound (`tests/test_file_stream_reader.py`), rewritten client-lifecycle
tests (shared across threads). 236 unit tests green; lint + typecheck
green.
- Integration against production sandboxes: full `files` suites
sync+async (123 tests — these caught the multipart bug), `commands` +
`pty` suites both flavors (57 tests). All green.

## Usage example

No API changes:

```python
sbx = Sandbox.create()
sbx.files.write("hello.txt", "hi")            # multipart/octet-stream over pyqwest
with sbx.files.read("hello.txt", format="stream") as stream:
    for chunk in stream:                       # stalls bounded by 60s idle read timeout
        ...
```

Only visible behavior shift: on the **sync** client, `files.read(...,
format="stream", stream_idle_timeout=...)` is now a documented no-op
(the transport-wide 60s idle bound applies); the async client honors it
as before.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 19:46:38 +02:00
Mish Ushakov b3a7c9f44a feat(python-sdk): move template build-context uploads onto pyqwest (#1603)
## What

Stacked on #1602 (which is stacked on #1601). Migrates the **template
build-context uploads** (streaming the build archive to S3 presigned
URLs in `build_api.upload_file`) onto
[pyqwest](https://github.com/curioswitch/pyqwest) via its
httpx-compatible transport adapter.

Originally deferred from #1601 because S3 presigned URLs reject chunked
transfer encoding and Content-Length framing through reqwest was
unverified. Verified at the wire level (raw-socket capture server):
httpx's Content-Length — derived from the spooled archive (sync) or set
explicitly on the async-iterator body (async) — is forwarded by the
adapter and reqwest keeps Content-Length framing for streamed bodies, no
chunked fallback.


> [!NOTE]
> Rebased onto #1601, which locks **pyqwest 0.9.0**. Two knock-on
changes here: the upload client uses the stock
`PyqwestTransport`/`AsyncPyqwestTransport` (0.9.0's adapter subsumes
what the SDK's transport subclasses did, so #1601 deleted them), and it
builds its proxy from `proxy_to_config(...)` following #1601's rename.

## How

- `e2b/template_sync/build_api.py` / `template_async/build_api.py`:
`upload_file` uses a one-off pyqwest transport instead of the generated
client's httpx transport.
- **Redirects stay with the httpx client.** pyqwest 0.9.0 makes
reqwest's internal redirect following configurable, so it's turned off
on the upload transport: otherwise reqwest would replay the entire
archive body against a new location without httpx knowing. The httpx
client inherits the API client's `follow_redirects` (off), matching the
httpx transport this replaced — so an unexpected hop surfaces as a
failed upload rather than a silent re-upload.
- `verify_ssl=False` on the generated client is no longer honored for
uploads (pyqwest has no insecure-TLS option), and `http2=False` is gone
(S3 negotiates HTTP/1.1 via ALPN anyway).
- The 1-hour upload timeout now bounds the entire upload rather than
each socket write — arguably the intended meaning for that endpoint.

## Testing

- `tests/{sync,async}/*/test_upload_file.py` (the #1243 regression tests
— Content-Length present and equal to the body, no chunked encoding)
pass through pyqwest; the capture handlers now compare header names
case-insensitively since hyper lowercases them where httpcore
title-cased.
- New in both mirrors: `test_upload_file_leaves_redirects_to_httpx` — a
307 on the upload URL surfaces as `FileUploadException` and the capture
server sees exactly one PUT, guarding against reqwest silently following
the hop and replaying the archive.
- Lint (`ruff`), typecheck (`ty`), upload-file suites: green (10/10).

## Usage example

No API changes — template builds upload their context exactly as before:

```python
from e2b import Template

template = Template().from_image("ubuntu:22.04").copy("data/", "/data")
Template.build(template, alias="my-template")   # archive upload now goes through pyqwest
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 19:46:38 +02:00
Mish Ushakov 458c2c4362 feat(python-sdk): move the volume content client onto pyqwest (#1602)
## What

Stacked on #1601. Migrates the **volume content client**
(`Volume`/`AsyncVolume` file operations) onto
[pyqwest](https://github.com/curioswitch/pyqwest) via its
httpx-compatible transport adapter — the same stock httpx transport
adapter + connection-retry stack the REST API client uses after #1601.

Originally deferred from #1601 because
`Volume.read_file(format="stream")` relied on httpx's per-read `read`
timeout as an *idle* timeout, which the adapter can't express per
request (it converts the httpx timeout dict into a whole-request
deadline, and the sync adapter doesn't bound body reads at all).
Unblocked by pyqwest's transport-constructor `read_timeout`, which maps
to reqwest's `ClientBuilder::read_timeout` — verified behaviorally
(local slow-chunk server, sync + async) to be a true per-read idle
timeout: it resets after each successful read, covers body reads, and a
healthy stream longer than the timeout completes untouched.


> [!NOTE]
> Rebased onto #1601, which maps `httpx.Proxy` onto pyqwest's `Proxy`
object and locks pyqwest 0.9.0. Following that: this PR builds its
transport from `proxy_to_config(...)` instead of `proxy_to_url(...)`,
uses the stock `PyqwestTransport`/`AsyncPyqwestTransport` (0.9.0's
adapter drops the redundant `Host` header and maps pyqwest's timeouts
and connection, network, and protocol failures to their httpx
counterparts, so the SDK's transport subclasses are gone), and turns
reqwest's internal redirects off so httpx owns them, as the generated
volume client expects.

## How

- `e2b/volume/client_sync/__init__.py` / `client_async/__init__.py` move
to the same stock adapter + connection-retry stack as the API client.
Caches become process-global, keyed by (proxy, streaming) — previously
one pool per thread (sync) / per event loop (async).
- Streamed downloads go through a **dedicated streaming transport** with
`read_timeout=60s`. It can't live on the shared transport: reqwest's
read timer keeps running while a request body is sent and while waiting
for the response head (verified empirically — a 2.4 s upload against a
0.5 s `read_timeout` dies mid-send), so a shared `read_timeout` would
cut off `write_file` uploads and slow unary responses longer than the
idle bound. Uploads and unary calls stay on a transport without it,
bounded by their whole-request deadlines as before.
- The 60 s default matches the JS SDK exactly: JS bounds stream start by
`requestTimeoutMs` (60 s default) and idle gaps by `streamIdleTimeoutMs
?? requestTimeoutMs`; the Python streaming transport's `read_timeout`
bounds the response head and each idle gap at 60 s, resetting on every
chunk, wire-only (a slow consumer doesn't trip it — verified).
- `AsyncVolume.read_file` keeps honoring an explicit
`stream_idle_timeout` **per call**, the same way JS honors
`streamIdleTimeoutMs` and #1558 bounds stream setup: `asyncio.wait_for`
around each read (response head and every chunk). Explicit values run on
the *regular* transport, so a value above the 60 s transport bound isn't
capped by it and `0` disables idle bounding entirely, restoring the
previous contract. The sync client keeps the parameter but **ignores**
it — it has no way to interrupt a blocking read into the Rust transport,
so its bound must live in the transport.
- Streamed reads are sent without a per-request timeout so the adapter
imposes no whole-request deadline on long downloads; an explicitly
passed `request_timeout` becomes the total-transfer deadline.
- A stalled read surfaces as `httpx.ReadTimeout`, keeping the
established contract: the 0.9.0 adapter maps its own timeouts, and the
async flavor remaps the per-read `stream_idle_timeout` (an
`asyncio.wait_for` expiry) to match.
- Proxy narrowing follows #1601: `str`, `httpx.URL`, and reducible
`httpx.Proxy` values work; inexpressible extras raise
`InvalidArgumentException`.

## Testing

- `tests/test_volume_client.py` rewritten: process-global transport
caching (shared across threads and event loops), streaming vs regular
transport separation, plus end-to-end streamed reads through
`Volume.read_file`/`AsyncVolume.read_file` against a local chunked
server — a healthy stream longer than the idle timeout completes (proves
the timeout resets per read), a mid-body stall raises
`httpx.ReadTimeout`, a slow response head on a *non-streamed* read is
not cut off by the idle bound, and a slow response head on a streamed
read is (JS handshake-timeout parity). Async `stream_idle_timeout`: an
explicit value aborts a stall, a value above the transport bound isn't
capped by it, and `0` disables idle bounding.
- Volume content integration tests couldn't run end-to-end (the test
team's key gets `403: use of volumes is not enabled`); the
mock-transport volume content tests and the local-server stream tests
cover that path.
- Lint (`ruff`), typecheck (`ty`), unit suite: green.

## Usage example

No API changes for the common path:

```python
volume = Volume.connect(volume_id, token=token)
stream = volume.read_file("big.bin", format="stream")     # stalls bounded by the
for chunk in stream:                                      # transport-wide idle read
    ...                                                   # timeout (httpx.ReadTimeout)

volume.read_file("big.bin", format="stream", stream_idle_timeout=5)  # sync: accepted, ignored

async_volume = await AsyncVolume.connect(volume_id, token=token)
stream = await async_volume.read_file(
    "big.bin", format="stream", stream_idle_timeout=5     # async: honored per read,
)                                                         # 0 disables idle bounding
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 19:46:37 +02:00
Mish Ushakov a874ced97a feat(python-sdk): move the REST API client onto pyqwest's httpx transport adapter (#1601)
## What

Migrate all httpx REST API client traffic in the Python SDK — the E2B
control plane (sandbox lifecycle, listing, templates, volumes control
plane) — to [pyqwest](https://github.com/curioswitch/pyqwest) (Rust
reqwest/hyper), using its httpx-compatible transport adapter
(`pyqwest.httpx.PyqwestTransport` / `AsyncPyqwestTransport`). The
generated openapi client and `ApiClient`/`AsyncApiClient` keep their
httpx surface — logging event hooks, per-request timeouts, headers, and
redirects behave as before — only the transport underneath is swapped.

envd RPC already runs on pyqwest via connectrpc (#1558). This PR touches
only the control-plane client; the rest of the stack builds on it: #1623
(envd HTTP API client), #1602 (volume content client), #1603 (template
uploads).

Requires **pyqwest 0.9** — pinned in `pyproject.toml` (`>=0.9.0,<0.10`)
with `uv.lock` refreshed. 0.8 brought the `Proxy` object
([pyqwest#194](https://github.com/curioswitch/pyqwest/pull/194)) and
request loggers
([pyqwest#197](https://github.com/curioswitch/pyqwest/pull/197)); 0.9
([release
notes](https://github.com/curioswitch/pyqwest/discussions/214)) folds
the two adapter workarounds this PR used to carry into the adapter
itself and makes redirect handling configurable, so the SDK no longer
subclasses the adapter at all.

## How

- `e2b/api/client_sync/__init__.py` / `client_async/__init__.py`:
`get_transport` now returns a pyqwest-backed httpx transport — a
`SyncHTTPTransport`/`HTTPTransport` (`tls_include_system_certs=True`,
proxy, pool tuning mapped from
`E2B_KEEPALIVE_EXPIRY`/`E2B_MAX_KEEPALIVE_CONNECTIONS`), wrapped in a
`ConnectionRetryTransport` for connect-only retries honoring
`E2B_CONNECTION_RETRIES`, wrapped in the stock
`PyqwestTransport`/`AsyncPyqwestTransport` httpx adapter.
- pyqwest transports are thread-safe and loop-independent (I/O runs on a
Rust tokio runtime), so the caches are process-global keyed by proxy —
previously one pool per thread (sync) / per event loop (async).
- **`ApiClient` sheds its threading machinery**: the
`transport_factory`/`async_transport_factory` plumbing, the thread-local
`httpx.Client` cache, and the per-loop `WeakKeyDictionary` of
`AsyncClient`s are gone. A single lazily-created httpx client (the
generated base behavior, the same shape the volume client already uses)
serves all threads and event loops; `httpx.Client` is documented
thread-safe and nothing below it is loop-bound. Closing that client
can't tear down the shared pool — the adapter transports don't override
`close()`/`aclose()`.
- **Host header** (upstream in 0.9): sending the `Host` header httpx
auto-adds on an HTTP/2 connection makes the E2B API edge reset the
stream with `PROTOCOL_ERROR` (reproduced with plain pyqwest against
`api.e2b.app`); hyper derives `Host`/`:authority` from the URL. The
adapter now skips a `host` header matching the URL, so the SDK-side
strip is gone — and unlike that strip, a genuinely custom `Host`
override is still forwarded.
- **Timeout exceptions** (upstream in 0.9): pyqwest raises the builtin
`TimeoutError`; the adapter maps it to `httpx.ReadTimeout` both while
awaiting the response head and while reading the body, preserving the
`httpx.TimeoutException` contract for callers. Connection, network, and
protocol failures likewise arrive as
`httpx.ConnectError`/`ConnectTimeout`, `httpx.ReadError`/`WriteError`,
and `httpx.RemoteProtocolError` instead of leaking pyqwest/builtin
types.
- **Redirects**: the pyqwest transports are built with
`follow_redirects=False` (0.9 made it configurable; reqwest's default is
to follow). Otherwise redirects are followed inside the transport,
hiding 3xx responses from httpx and leaving `response.history` empty —
even though the generated clients ask for no redirect following. httpx
owns them again, as with the transports this replaced.
- **Proxy**: `proxy=` accepts a URL string, `httpx.URL`, or an
`httpx.Proxy` — including its credentials (sent as
`Proxy-Authorization`) and any headers configured for the proxy, via
pyqwest's `Proxy` object. `proxy_to_config` normalizes all three into a
`ProxyConfig` tuple that both keys the transport cache and builds the
`pyqwest.Proxy`, so the same proxy URL with different credentials or
headers gets its own pool. A per-proxy `ssl_context` has no counterpart
and raises `InvalidArgumentException` rather than being silently
dropped. (`ProxyConfig` is a `NamedTuple`, not a frozen dataclass:
`tests/test_env_var_parsing.py` reloads `e2b.api`, and a dataclass
`__eq__` compares class identity, so keys built before and after a
reload would silently stop matching.)
- **`ProxyTypes` is ours now**: the public type of the `proxy` option
(already exported from `e2b`) used to be imported at runtime from
httpx's private `_types` module in eleven modules. It is defined there
as `Union[str, URL, Proxy]` — exactly the three forms the SDK's two
narrowers accept — so it's spelled out once in `e2b.connection_config`
and imported from there. Same public name, same type to a type checker,
no private-module dependency, and a place for a pyqwest proxy type to
land as the remaining transports move off httpx.
`e2b.envd.client_shared.proxy_to_url` took a bare `object` while
`e2b.api.proxy_to_config` took `Optional[ProxyTypes]`; both now say the
same thing. `isinstance` narrowing stays rather than duck-typing
`.url`/`.auth` — httpx is a required dependency here (the generated REST
client *is* an httpx client, and envd file transfers use httpx
directly), so probing attributes would trade a clear
`InvalidArgumentException` on a mistyped argument for no dependency
savings.
- **Request logs**: pyqwest logs one line per request on the
`pyqwest.access` logger and lifecycle records on `pyqwest`, both at
`DEBUG` — the transport-level diagnostics httpcore used to provide, now
that httpcore is out of the path. Noted on `get_transport`; the SDK's
own `logger` option is unchanged and sits above it on the httpx client.
- **HTTP/2**: negotiated via ALPN for TLS connections (reqwest default),
equivalent to the `http2=True` transports this replaces.

## What stays behind (handled by the stacked PRs)

- **envd HTTP API client** (file transfers, health checks): #1623, which
also dedupes the transport plumbing this PR and #1558 each carry a copy
of (the proxy narrowing, pool tuning, retry transport — envd keeps
byte-identical duplicates until then).
- **Volume content client**: its streaming download relies on httpx's
per-read `read` timeout as an *idle* timeout, which the adapter can't
express per request — #1602.
- **Template build context upload**: one-off httpx client PUTing to S3
presigned URLs — #1603.

## Timeout semantics note

`request_timeout` was previously httpx's per-phase timeout
(connect/read/write each bounded separately, so a slow multi-phase
request could exceed it in total). Through the adapter it becomes an
overall deadline per API call (async: headers + body; sync: up to
response headers). For the SDK's REST calls — all unary with small JSON
bodies — this is a tightening, arguably closer to what `request_timeout`
promises.

## Testing

- `tests/test_api_client_transport.py` rewritten for the new semantics:
global per-proxy transport caching, a single httpx client shared across
threads/loops (including 32-way concurrent request tests against a local
server), timeout → `httpx.ReadTimeout` mapping for both the response
head and a stalled body (slow/stalling local server), redirects
surfacing to httpx (302 returned as-is, `response.history` populated
when the caller opts in), the connection-only retry policy,
`proxy_to_config` conversion, and sync+async round-trips through a real
local HTTP server exercising pyqwest end to end. The two host-header
unit tests are gone with the subclasses they tested — that behavior is
the adapter's now.
- Two tests cover the pyqwest proxy/logging surface: an echo server
standing in for a proxy asserts that the absolute-form request target,
`Proxy-Authorization`, and the extra proxy header actually arrive, and
the `pyqwest.access` record is asserted for an API call.
- On pyqwest 0.9.0 from PyPI: `uv sync --locked`, unit suite
(`tests/*.py`, 238 passed), `ruff check`, `ty check` — all green.
- Integration against the production API (real key) was run on 0.8.0:
`tests/sync/api_sync`, `tests/async/api_async`,
create/kill/timeout/connect — all green. (These initially failed with
`RemoteProtocolError: StreamReset` until the host header stopped being
forwarded, so they genuinely exercise the new stack; that fix now comes
from the adapter.)

## Usage example

No API changes for the common path:

```python
from e2b import Sandbox

sbx = Sandbox.create()          # control-plane calls now go through pyqwest
Sandbox.list()
sbx.kill()
```

Proxy handling — URL strings and `httpx.Proxy` objects work, credentials
and proxy headers included:

```python
Sandbox.create(proxy="http://user:pass@localhost:8030")            # ok (unchanged)
Sandbox.create(proxy=httpx.Proxy("http://localhost:8030",
                                 auth=("user", "pass")))           # sent as Proxy-Authorization
Sandbox.create(proxy=httpx.Proxy("http://localhost:8030",
                                 headers={"X-Auth": "t"}))         # sent to the proxy
Sandbox.create(proxy=httpx.Proxy("https://localhost:8030",
                                 ssl_context=ctx))                 # raises InvalidArgumentException
```

`ProxyTypes` — already exported from `e2b` — is now defined by the SDK
rather than re-exported from `httpx._types`, with the same three
members:

```python
from e2b import ProxyTypes   # Union[str, httpx.URL, httpx.Proxy]
```

Transport-level HTTP logs, replacing the httpcore records this migration
removes:

```python
import logging

logging.basicConfig()
logging.getLogger("pyqwest.access").setLevel(logging.DEBUG)

Sandbox.create()
# DEBUG pyqwest.access - HTTP Request: POST https://api.e2b.app/sandboxes "HTTP/2 201 Created"
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 19:46:37 +02:00
Mish Ushakov cab27aa6fa fix(sdk): clean up sandbox when MCP gateway startup fails (#1548)
## Problem

Fixes #1498.

`Sandbox.create` allocates a remote sandbox before starting
`mcp-gateway`. If gateway startup fails, creation throws before the
sandbox object is returned. As a result, the caller has no sandbox ID to
clean up, and the orphaned sandbox continues consuming resources until
it times out.

This state transition exists in synchronous Python, asynchronous Python,
and JavaScript/TypeScript.

## Changes

- Add a rollback boundary around MCP gateway startup in all three SDK
implementations: on failure, best-effort kill the newly allocated
sandbox, then re-raise.
- Surface gateway startup failure as `SandboxError` (JS) /
`SandboxException` (Python) with a `Failed to start MCP gateway:
<stderr>` message. Previously the intended message was unreachable dead
code — foreground `commands.run` already throws on non-zero exit — so
callers got a bare `CommandExitError`/`CommandExitException`.
- In async Python, re-raise `asyncio.CancelledError` from the
best-effort `kill()` so caller cancellation (e.g. `asyncio.timeout`) is
honored; only ordinary cleanup failures are suppressed and never mask
the original error.
- Add integration coverage for synchronous Python, asynchronous Python,
and TypeScript. The tests pin the sandbox to the base template (which
has no `mcp-gateway` binary) so gateway startup genuinely fails after
allocation.
- Add a patch changeset for `e2b` and `@e2b/python-sdk`.

## Usage Behavior

No API changes. A failed creation no longer leaves a sandbox behind, and
the error is now descriptive:

```ts
try {
  const sandbox = await Sandbox.create({ mcp: { ... } })
} catch (err) {
  // err is SandboxError: "Failed to start MCP gateway: <stderr>"
  // the allocated sandbox has already been killed — no orphan is left running
}
```

## Validation

All three integration tests verified against real infra: creation
rejects with the documented error and no sandbox remains.

## Notes

Supersedes #1547 by @hxaxd (squash-merged into this branch to preserve
attribution).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: 苏紫辰 <155808914+hxaxd@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 17:26:08 +02:00
Mish Ushakov 88f41f3927 fix(python-sdk): port current JS stripAnsi regex to strip_ansi_escape_codes (#1545)
## Summary

The Python SDK's `strip_ansi_escape_codes` (used to clean template build
log messages) still used the old ansi-regex pattern, while the JS SDK's
`stripAnsi` was rewritten in #895. This ports the current JS regex to
Python so both SDKs clean logs identically: OSC sequences (hyperlinks,
window titles) are matched non-greedily up to the first string
terminator — including content spanning newlines — and CSI sequences are
stripped without requiring a terminator.

Following review feedback, both implementations now also strip the
remaining ECMA-48 string controls — DCS (Sixel, tmux passthrough), SOS,
PM, and APC — through their string terminator, so control payloads don't
leak into cleaned logs. This goes beyond upstream `chalk/ansi-regex`,
click, and Rich, none of which fully strip DCS payloads, and restores
what the old Python pattern handled.

Also mirrors the Python test suite into the JS SDK (which previously had
no `stripAnsi` tests) — 20 identical cases per side — and verified
byte-for-byte identical output between the two implementations on all of
them. Includes a patch changeset for `e2b` and `@e2b/python-sdk`.

## Example

Log messages that previously leaked OSC or DCS sequences into template
build output are now cleaned:

```python
from e2b.template.utils import strip_ansi_escape_codes

strip_ansi_escape_codes("\x1b]8;;https://e2b.dev\x07E2B\x1b]8;;\x07")  # "E2B"
strip_ansi_escape_codes("\x1b]0;title\nstill title\x07done")           # "done"
strip_ansi_escape_codes("\x1b[38:2::255:0:0mRED\x1b[0m")               # "RED"
strip_ansi_escape_codes("\x1bPq#0;2;0;0;0~~@@\x1b\\image")             # "image" (Sixel DCS)
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:04:40 -07:00
Mish Ushakov 998e560a1a fix(python-sdk): relax wcmatch constraint to >=10.1,<12 (#1638) 2026-08-05 19:08:11 +02:00
Joe Lombrozo 2821fb0b69 feat(sdk): route volume content to BYOC cluster domain (#1634)
When a team is connected to a custom (BYOC) cluster, the volume API now
returns that cluster's domain in the create and get responses. The JS
and Python (sync + async) SDKs use this domain as the destination for
volume content requests instead of the default api.<E2B_DOMAIN> host,
falling back to the configured domain when none is returned.

The domain field is read defensively from the response until
spec/infra-ref is bumped to the infra commit that adds it and `make
codegen` regenerates the typed schema.


Claude-Session: https://claude.ai/code/session_01212WCmNz1prPKrjhTv2PDj

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Matt Brockman <matt.brockman@e2b.dev>
2026-08-03 10:24:15 -07:00
Mish Ushakov 2df7651ee6 test(sdk): run firewall transform tests against an httpbin sidecar sandbox (#1631)
Follow-up to #1632, which added the template this depends on. Now
rebased onto `main`, so this is just the test change.

## Problem

The firewall transform tests asserted header injection by curling
`httpbin.e2b.team`, an externally hosted service the suite had to keep
alive.

## Fix

Starts a sidecar sandbox from the `httpbin` template instead: the rule
is keyed on the sidecar's `getHost(8080)` and the assertion reads the
injected header back from `/headers`, in the JS, sync Python, and async
Python suites. The sidecar's ready command has already passed by the
time `create` resolves, so the server is serving and no readiness
polling is needed. The template name lives in one fixture per SDK —
`httpbinTemplate` in `tests/template.ts` and the `httpbin_template`
fixture in `conftest.py`.

Also drops two comments merged in #1632 that claimed the tests spawn
`e2b/httpbin`. The bare alias is what resolves, same as `base` — the
team slug only appears in the display name.

⚠️ Do not merge before **Build and push prepared templates** has been
dispatched with `template: httpbin` — the tests resolve the template by
name and fail until it exists on the E2B team.

Verified against production: all three tests pass with the injected
header reflected by the sidecar, spawning the template by its bare alias
with a key that owns it — the same situation as CI.

SDK-304

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-30 23:37:14 +02:00
Mish Ushakov 4fcf7cb150 feat: sync API specs from infra and belt with Copybara (#1564)
The specs in `spec/` were copied from their source repos by hand and had
drifted ~2,400 lines behind infra, so they are now imported with
Copybara (`copy.bara.sky`, run in a pinned Docker image by
`scripts/fetch-spec.sh`): `make codegen` re-fetches them at the commits
pinned in `spec/infra-ref` and `spec/belt-ref` before generating, and
the generated-files CI check fails if the tracked copies don't match the
pins. Regenerating from the current pins picks up the accumulated spec
changes in the generated JS/Python clients (renamed request schemas,
`SandboxNetworkConfig`, `SandboxIam` workload identity,
`FILE_TYPE_SYMLINK`, access-token auth deprecation, volume path-metadata
tweaks). The one handwritten SDK change follows from that: the public
`FileType` enums gain a `SYMLINK` member (JS and both Python surfaces)
so entries envd reports as symlinks show up in `files.list()` and
`getInfo()`/`get_info()` instead of being silently skipped as unknown
types. The custom `spec/remove_extra_tags.py` tag-filtering script is
replaced by Redocly CLI's `filter-in` decorator (`redocly.yaml`), which
produces identical generated JS output; a `filter-out` decorator
additionally drops any operation or component schema the upstream specs
mark `x-not-implemented: true` (currently the SOCKS5
`SandboxEgressProxyConfig`/`egressProxy` surface, which infra flagged as
spec-only); each SDK's bundle now goes to its own gitignored
`spec/openapi_generated.<api>.yml` instead of both pipelines overwriting
one shared file; Python client models now list fields in spec order
instead of alphabetical (mechanical reordering only — construct models
with keyword args). Spec fetches try whatever GitHub token is available
and fall back to the tracked copies with a warning (the public infra
specs also fetch anonymously); in CI a short-lived belt-scoped token is
minted from the org-wide Autofixer GitHub App (no new secrets), so fork
PRs simply fall back for the belt spec; the CI workflows also cache the
Copybara image alongside the codegen image, and the previously ignored
`CODEGEN_IMAGE` env is honored by the Makefile.

## Usage

```sh
# update the specs: bump a pin, then regenerate
echo <infra-commit-sha> > spec/infra-ref
make codegen

# fetch a single spec without regenerating
pnpm fetch:api-spec     # spec/openapi.yml from infra
pnpm fetch:envd-spec    # spec/envd/ from infra
pnpm fetch:volume-spec  # spec/openapi-volumecontent.yml from belt

# try the latest spec without touching the pin
E2B_INFRA_REF=main pnpm fetch:api-spec

# change which endpoint tags an SDK exposes
$EDITOR redocly.yaml && make codegen
```

```ts
// symlinks are now visible in the filesystem API (JS; same shape in Python)
const entries = await sandbox.files.list('/home/user')
const link = entries.find((e) => e.type === FileType.SYMLINK)
console.log(link?.symlinkTarget)
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:37:02 +02:00
Mish Ushakov 3f46d56026 fix(sdk): select stack-trace frames by SDK boundary instead of fixed depth (#1599)
## Description

Template build stack traces were captured by walking a fixed number of
frames (`STACK_TRACE_DEPTH` plus `±1` arithmetic at ~15 call sites),
which broke whenever the frame count between `new Error()` and user code
shifted — TS class-field initializer frames (#1539) and Bun's tail-call
frame elision were both this bug. This PR makes two related changes:

1. **Boundary-based frame selection.** The caller's frame is now the
first one whose file lies outside the SDK package, making extra
transpiler frames and elided delegating frames irrelevant. In the JS
SDK, frame parsing is delegated to `error-stack-parser-es` (ESM-only, so
it's a devDependency inlined into both dist formats via tsdown
`noExternal` — the engines range includes Node versions without
`require(esm)`); the Python SDK equivalently walks `f_back` until
`co_filename` leaves the `e2b` package root, in the shared builder used
by both sync and async. If no user frame is identifiable (e.g. the SDK
is bundled into the caller's own file), capture degrades to no trace
rather than a wrong frame.
2. **Dead machinery removed.** Because boundary capture resolves through
SDK-internal delegation (`remove()` → `runCmd()`, `fromDockerfile()` →
parser) to the user's call site on its own, the suppress/override
collection machinery (`runInNewStackTraceContext`,
`runInStackTraceOverrideContext`, the enabled/override flags, and their
Python equivalents) became redundant and is removed — superseding the
approach in #1596.

Error `.stack` synthesis (keeping the `Name: message` header and the
throw site on `cause`) was prototyped here and backed out — it will come
as a follow-up PR.

## Usage

No API changes — build errors now point at the user's call site
regardless of runtime or transpiler:

```ts
const template = Template()
  .fromBaseImage()
  .runCmd('./does-not-exist') // ← build failures point exactly here

await Template.build(template, 'my-template')
```

## Testing

- JS: `unit` + `template` vitest projects green against the real API
(incl. 27 per-method stacktrace tests pinning exact call-site
line/columns, `bunInstall` now covered); edge-compat bundle test and CLI
build verified; built CJS/ESM dists smoke-tested with
`require()`/`import()`.
- Python: all 184 template tests green (shared + sync + async, incl.
both `test_stacktrace.py` suites, `bun_install` now covered); `ruff` and
`ty` clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:42:40 +02:00
Mish Ushakov 00253c39cc feat(python-sdk): migrate envd RPC to the official connectrpc client (#1558)
Replaces the vendored `e2b_connect` client and the custom Go
`protoc-gen-connect-python` plugin with the official Connect RPC client
for Python ([`connectrpc`](https://github.com/connectrpc/connect-py),
transport: `pyqwest`/Rust hyper), and switches the envd messages from
Google's `protobuf` runtime to Buf's
[`protobuf-py`](https://github.com/bufbuild/protobuf-py) (which
`connectrpc` already requires) — the SDK no longer depends on the
conflict-prone `protobuf` package at all, and the protoc binary drops
out of the codegen image. The wire format (same protos, same JSON) is
unchanged. Closing a command or watch stream early now sends
`RST_STREAM`, fixing abandoned streams leaking on the shared HTTP/2
connection, and peer resets surface as typed `ConnectError`s. The
plumbing mirrors the `e2b.api` layout: shared pieces (a JSON codec that
ignores unknown response fields, proxy narrowing, pool tuning) live in
`e2b/envd/client_shared.py`, the flavor-specific pyqwest transports
(wrapped in pyqwest's retry middleware, see the retry note below) and
`create_rpc_client` factories in `e2b/envd/client_sync/` and
`e2b/envd/client_async/`, and the default-header/logging interceptors in
`e2b/envd/interceptors.py`; `e2b/envd/rpc.py` maps `connectrpc` error
codes onto the existing SDK exceptions, so the public API is unchanged
(`sandbox.commands.run(...)`, `files.watch_dir(...)`, etc. work exactly
as before). The REST API and file upload/download keep using `httpx`.

The `proxy` connection option now applies to sandbox RPC calls too —
[pyqwest
0.7.0](https://github.com/curioswitch/pyqwest/releases/tag/v0.7.0) added
an httpx-style `proxy` parameter to its transports, so commands, PTY,
and filesystem watch traffic follow the same proxy as the REST API and
file transfers (an earlier revision of this PR could only fall back to
`http_proxy`/`https_proxy` env vars for RPC):

```python
sandbox = Sandbox.create(proxy="http://user:pass@localhost:8030")
# REST *and* RPC (commands, PTY, watch) traffic goes through the proxy
result = sandbox.commands.run("echo through-the-proxy")
```

Notes:
- `e2b_connect` is no longer shipped in the wheel; code importing it
directly should switch to `connectrpc` (`ConnectError`, `Code`) — SDK
exception types are unchanged.
- The generated `e2b.envd.*.*_pb2` modules are replaced by `protobuf-py`
equivalents (`process_pb`, `filesystem_pb`) with a different message API
(`Oneof` objects, `has_field`); these are internal modules —
`e2b-code-interpreter` and `e2b-desktop` were verified not to import
them.
- RPC transports are cached per proxy URL. `httpx.URL` and `httpx.Proxy`
proxies keep working for RPC calls when they reduce to a proxy URL
(`httpx.Proxy` auth is folded back into the URL userinfo); `httpx.Proxy`
extras that pyqwest can't express — custom headers, an `ssl_context` —
raise `InvalidArgumentException` rather than being silently dropped.
- Plain (non-Connect-encoded) HTTP error responses — an edge proxy or
gateway answering for envd — keep the vendored client's status mapping
even when they carry a JSON body that isn't a valid Connect error (e.g.
a gateway's `{"code": 429}` raises `RateLimitException`, not a
misleading sandbox-timeout); only JSON bodies with a valid Connect
`code` string are left to connectrpc to parse. An envd response that
fails to decode surfaces as a `SandboxException` with a clear message —
the SDK's JSON codec raises a typed `ConnectError(INTERNAL)` at the
source (connectrpc re-raises codec-raised `ConnectError`s unchanged),
rather than the error being reconstructed from `__cause__` heuristics in
the exception mapper.
- pyqwest 0.7.0 explicit transports default to an **empty TLS root
store** (0.6.2 used reqwest's defaults), so the envd transports pass
`tls_include_system_certs=True`; the dependency floor is
`pyqwest>=0.7.0` accordingly.
- Connection retries (`E2B_CONNECTION_RETRIES`, default 3) use pyqwest's
transport-level retry middleware (`pyqwest.middleware.retry`), narrowed
to retry only the builtin `ConnectionError` — raised solely while
establishing the connection, before the request could have reached envd
— with exponential backoff. A retry can therefore never replay a
delivered request, for unary and streaming RPCs alike; the previous
stack's replay of unary calls whose connection dropped mid-request is
dropped deliberately, since it could re-execute a delivered call (e.g.
`SendInput`). Pinned by unit tests plus end-to-end tests driving the
generated stubs through the middleware
(`tests/test_envd_retry_transport.py`).
- For async streaming calls (`commands.run`/`connect`, PTY,
`watch_dir`), `request_timeout` bounds opening the stream — the wait
until envd confirms with a start event, matching the JS SDK's
`requestTimeoutMs` — raising `TimeoutException` and cancelling the
HTTP/2 stream when exceeded (pinned frame-level in
`tests/test_envd_stream_reset.py`). The running stream stays bounded by
the command/watch `timeout`. The sync SDK cannot interrupt its blocking
wait, so `request_timeout` is not applied to sync stream setup — both
setup and the running stream are bounded by `timeout` (unlimited when
`0`).
- The RPC logging interceptor was upstreamed to pyqwest as a logging
middleware
([curioswitch/pyqwest#192](https://github.com/curioswitch/pyqwest/pull/192));
the SDK keeps its own `LoggingInterceptor` until that merges and ships
in a release the SDK can depend on.
- `pyqwest` ships binary wheels for manylinux/musllinux (x86_64,
aarch64), macOS arm64 + x86_64 (Intel wheels landed in 0.7.0), Windows
x64, and PyPy.
- The `RST_STREAM`-on-early-close behavior is pinned by frame-level
regression tests (`tests/test_envd_stream_reset.py`): a plaintext HTTP/2
server records the frames the real generated clients (with the SDK's
codec and interceptors) send — early close via `disconnect()`, close
through the logging interceptor, and abandoning the stream must all send
`RST_STREAM(CANCEL)`; normal completion must send none (sync + async).
- `E2B_MAX_CONNECTIONS` no longer applies to sandbox RPC traffic:
reqwest's pool bounds only idle connections per host
(`E2B_KEEPALIVE_EXPIRY`, `E2B_MAX_KEEPALIVE_CONNECTIONS`), not the total
number of open connections. It still applies to the REST API and file
transfers.
- The sync sandbox modules build one RPC client each and share it across
threads — the connectrpc sync client is stateless per call over the
process-global transport (verified with a 16-thread frame-level test);
only the httpx envd API clients stay per-thread with their transports.
- Also fixes numeric env-var parsing (`E2B_KEEPALIVE_EXPIRY`,
`E2B_MAX_KEEPALIVE_CONNECTIONS`, `E2B_MAX_CONNECTIONS`,
`E2B_CONNECTION_RETRIES`): an empty-string value now falls back to the
default instead of raising `ValueError` at import time.
2026-07-24 05:41:04 -07:00
Mish Ushakov 95e4dc2832 feat(sdk): add sandbox fork to JS and Python SDKs (#1554)
## Summary

Adds SDK support for the new `POST /sandboxes/{sandboxID}/fork` endpoint
(e2b-dev/infra#3202): checkpoint a running sandbox in place (briefly
paused, snapshotted with full memory state, and resumed — its ID and
expiration stay untouched) and boot `count` new sandboxes from that
snapshot.

- **spec**: adds `SandboxForkRequest` / `SandboxForkResult` schemas and
the `/sandboxes/{sandboxID}/fork` path (mirroring the infra spec); JS
and Python API clients regenerated via `make codegen`.
- **js-sdk**: `sandbox.fork(opts)` instance method and
`Sandbox.fork(sandboxId, opts)` static method. Returns
`Promise<Array<Sandbox | Error>>` — one entry per requested fork, each
either a connected `Sandbox` instance or an `Error` describing why that
fork failed to start (`Promise.allSettled`-style, matching the per-fork
results of the API). Per-fork error codes go through the same code→class
mapping as other API errors (extracted from `handleApiError` into
`apiErrorFromCode`), so e.g. a per-fork 429 (sandbox limit) surfaces as
`RateLimitError`. `SandboxForkOpts` extends the full `ConnectionOpts`
(like `SandboxConnectOpts`), so `proxy`, `logger`, `apiUrl`, etc. work
with fork-by-ID. `timeoutMs` defaults to 5 minutes like
`create`/`connect`; `count` defaults to 1 and is validated client-side
(`InvalidArgumentError` for `count < 1`); a whole-request 404 maps to
`SandboxNotFoundError` (the source sandbox is the missing resource —
same semantics as `pause`/`connect`/`setTimeout`), carrying the API
error message when present; per-fork 404 error codes map to generic
`NotFoundError` (the missing resource is fork-internal, e.g. the
snapshot).
- **python-sdk**: `sandbox.fork(timeout=..., count=...)` /
`Sandbox.fork(sandbox_id, ...)` and the `AsyncSandbox` equivalents (same
`@class_method_variant` instance/static pattern as `connect`/`pause`),
returning `List[Union[Sandbox, Exception]]`. Per-fork errors map through
the shared `api_exception_from_code` (extracted from
`handle_api_exception`). `timeout` is in seconds per Python SDK
convention; an explicit `timeout=0` is preserved. Whole-request 404
raises `SandboxNotFoundException`; per-fork 404 codes map to generic
`NotFoundException`.
- **changesets**: minor bumps for `e2b` and `@e2b/python-sdk`.

## Usage

JS:

```ts
const sandbox = await Sandbox.create()

const [fork1, fork2] = await sandbox.fork({ count: 2, timeoutMs: 60_000 })
if (fork1 instanceof Sandbox) {
  await fork1.commands.run('echo "hello from fork"')
}

// or by ID
const forks = await Sandbox.fork(sandbox.sandboxId, { count: 2 })
```

Python (sync / async):

```python
sandbox = Sandbox.create()

fork1, fork2 = sandbox.fork(count=2, timeout=60)
if isinstance(fork1, Sandbox):
    fork1.commands.run('echo "hello from fork"')

# or by ID
forks = Sandbox.fork(sandbox.sandbox_id, count=2)
```

```python
sandbox = await AsyncSandbox.create()
fork1, fork2 = await sandbox.fork(count=2)
```

## Notes

- The JS option is named `timeoutMs` (milliseconds) to match
`SandboxOpts.timeoutMs` / `SandboxConnectOpts.timeoutMs`; the API
receives seconds via `timeoutToSeconds` as elsewhere.
- Failed forks are returned as error **values** in the array rather than
rejected promises, so a partial failure doesn't throw away the
successful forks and there are no unhandled-rejection hazards. A
per-fork error message includes the API error code only when the API
returned one.

## Test plan

- [x] `pnpm run format`, `pnpm run lint`, `pnpm run typecheck` pass at
the repo root (`ty` diagnostics identical to baseline)
- [x] Offline tests pass: `count < 1` → `InvalidArgumentError` /
`InvalidArgumentException` in JS, Python sync, and Python async;
`handleApiError` suite passes after the `apiErrorFromCode` extraction
(plus a behavior-parity check of the Python `handle_api_exception`
refactor)
- [ ] Integration tests (single fork with FS state inheritance +
independence, multi-fork with unique IDs, fork-by-ID, fork of killed
sandbox → `SandboxNotFoundError`) are written but currently fail against
prod with 404 because the fork endpoint (e2b-dev/infra#3202) is not
deployed yet — they should pass once it lands.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 09:50:34 +00:00
Mish Ushakov 2c77fc00bb feat(sdk): add name filter to snapshot list (#1523)
Adds an optional `name` filter to `Sandbox.listSnapshots()` /
`Sandbox.list_snapshots()`, mirroring the infra snapshots list endpoint
([e2b-dev/infra#3184](https://github.com/e2b-dev/infra/pull/3184)). The
filter accepts a snapshot name or ID, optionally tag-qualified (e.g.
`"my-snapshot"`, `"my-team/my-snapshot"` or `"my-snapshot:v1"`); unknown
names return an empty list. It's a flat top-level option alongside the
existing `sandboxId` filter (non-breaking) and can be combined with it —
the backend applies both with AND, matching the `metadata`+`state`
behavior of `Sandbox.list()`. Applied equivalently across the OpenAPI
spec, generated clients, and the JS + Python sync/async SDKs, with tests
and a changeset.

## Usage

```ts
// JS/TS
const paginator = Sandbox.listSnapshots({ name: 'my-snapshot' })
const snapshots = await paginator.nextItems()

// combine filters (snapshots from a sandbox matching a name)
Sandbox.listSnapshots({ sandboxId: 'sandbox-id', name: 'my-snapshot' })
```

```python
# Python (sync)
paginator = Sandbox.list_snapshots(name="my-snapshot")
snapshots = paginator.next_items()

# Python (async)
paginator = AsyncSandbox.list_snapshots(name="my-snapshot")
snapshots = await paginator.next_items()
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:06:37 +02:00
Mish Ushakov 09e12b3f65 feat(sdk): set-once integration attribution via ConnectionConfig.setIntegration (#1524)
Replaces the per-call `integration` connection option with a set-once,
process-wide setter — `ConnectionConfig.setIntegration()` in JS and
`ConnectionConfig.set_integration()` in Python — so integrations
wrapping the SDK tag themselves once at startup and every request
carries the identifier in the `User-Agent` header, with no threading
through individual SDK calls. The setter is internal and hidden from
generated docs; the `integration` option is removed from
`ConnectionConfigOpts` (kept as a deprecated alias of `ConnectionOpts`)
and from the Python constructor, and the round-trip machinery from #1459
is no longer needed since rebuilt configs read the process-wide value.
User-Agent handling now follows a single rule in both SDKs via one
shared helper per SDK: an explicitly provided `User-Agent` always wins,
otherwise the SDK sends its own tagged with the current integration —
and SDK-built values are recomputed whenever a config is rebuilt, so
clearing or changing the integration propagates. Tests cover
attribution, clearing, config rebuilds, and custom User-Agent precedence
in both SDKs, with changesets for `e2b` and `@e2b/python-sdk` (minor).
CLI attribution using this setter will follow in a separate PR.

Usage (internal integrations only):

```ts
import { ConnectionConfig } from 'e2b'
ConnectionConfig.setIntegration('e2b-code-interpreter/0.1.0') // once at startup
```

```python
from e2b import ConnectionConfig
ConnectionConfig.set_integration("e2b-code-interpreter/0.1.0")  # once at startup
```

A caller-supplied `User-Agent` (via `headers`/`apiHeaders`) is preserved
in both SDKs:

```ts
const sbx = await Sandbox.create({ apiHeaders: { 'User-Agent': 'my-app/1.0' } })
// requests carry: my-app/1.0
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 15:52:09 +02:00
Mish Ushakov 07041ccffc test: skip live volume tests unless ENABLE_VOLUME_TESTS is set (#1526)
Live volume tests create real volumes against the API; this gates them
behind an `ENABLE_VOLUME_TESTS` env var so they skip by default. In the
JS SDK, the `volumeTest` fixture is chained with
`.skipIf(process.env.ENABLE_VOLUME_TESTS === undefined)`, skipping all
of `tests/volume/file.test.ts`. In the Python SDK, the `volume` and
`async_volume` fixtures call `pytest.skip` when the env var is unset,
gating `tests/{sync/volume_sync,async/volume_async}/test_file.py`.
Mocked and unit volume tests (msw-based `volume.test.ts`,
`test_volume.py`, `test_volume_content.py`, `test_volume_client.py`,
`test_volume_connection_config.py`) still run unconditionally. To run
the live tests: `ENABLE_VOLUME_TESTS=1 pnpm run test` or
`ENABLE_VOLUME_TESTS=1 poetry run pytest`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:38:05 -07:00
Mish Ushakov a6b1cf4bcf fix(python-sdk): strip colon-separated SGR escape codes in build logs (#1522)
### What

Cherry-picks the fix from #1519.

`strip_ansi_escape_codes` in the Python SDK only matched
semicolon-separated CSI
parameters, so colon-separated SGR sequences leaked literal escape
garbage into
template build-log messages. This widens the parameter class from `;` to
`[;:]`
so colon-separated sequences are stripped too, matching the JS SDK's
`stripAnsi`.

Modern terminals emit colon-separated SGR sequences:

- 256-color: `\x1b[38:5:82m`
- truecolor: `\x1b[38:2::255:0:0m`
- curly underline: `\x1b[4:3m`

The two SDKs share one source (chalk/ansi-regex) and the JS twin was
already
updated to support colons (`packages/js-sdk/src/utils.ts:95`, comment:
"supports
; and :"); the Python port lagged behind. `strip_ansi_escape_codes` is
consumed
by `LogEntry.__post_init__`
(`packages/python-sdk/e2b/template/logger.py`), so
the leftover escape bytes showed up in Python build logs only.

### The one-line fix

```python
# packages/python-sdk/e2b/template/utils.py:319
- r"(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))",
+ r"(?:(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))",
```

### Usage example (before / after)

```python
from e2b.template.utils import strip_ansi_escape_codes

# 256-color, colon-separated
strip_ansi_escape_codes("\x1b[38:5:82mX\x1b[0m")
# before: ":5:82mX"   after: "X"

# truecolor, colon-separated
strip_ansi_escape_codes("\x1b[38:2::255:0:0mRED\x1b[0m")
# before: ":2::255:0:0mRED"   after: "RED"

# semicolon variants already worked and still do
strip_ansi_escape_codes("\x1b[38;5;82mX\x1b[0m")  # "X"  (unchanged)
```

### Tests

Unit tests at

`packages/python-sdk/tests/shared/template/utils/test_strip_ansi_escape_codes.py`
(no API key / sandbox): colon-256, colon-truecolor, curly-underline,
plus
basic/semicolon regressions. All 7 pass locally.

### Changeset

`.changeset/python-strip-ansi-colon.md` (patch on `@e2b/python-sdk`).

### Notes

Original PR: #1519 (by @anxkhn). Opened against a fresh branch off
`main` per
request, rather than merging #1519 directly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Anas Khan <83116240+anxkhn@users.noreply.github.com>
2026-07-02 10:45:49 -07:00
Mish Ushakov 2b7dd17f10 feat(sdk): add gzip option to template copy layer (#1482)
Adds a `gzip` option to the template `.copy()` / `copyItems` layer that
controls whether copied files are gzipped before upload, threaded from
the copy call through the build-time tar stream in both the JS SDK and
the sync/async Python SDKs. It is enabled by default to preserve
existing behavior, so passing `gzip: false` (`gzip=False`) uploads an
uncompressed tar — useful for already-compressed payloads where gzip
adds CPU cost without shrinking the upload. The option name matches
node-tar's own `gzip` option and the existing sandbox filesystem `gzip`
kwarg. Gzip is deliberately excluded from the file cache hash, so
toggling it does not bust the build cache. Tests in both SDKs were
updated for the new argument and extended with `gzip: false` cases
asserting the archive is not gzipped yet still extracts, and a changeset
(`minor` for both packages) is included.

> [!NOTE]
> The server that extracts these uploaded archives lives in another repo
and must auto-detect compression (peek the gzip `0x1f 0x8b` magic)
rather than assuming gzip; confirm it handles plain tars before release.

## Usage

```ts
// JS/TS
template.copy('model.bin', '/app/', { gzip: false })
template.copyItems([{ src: 'a.bin', dest: '/app/', gzip: false }])
```

```python
# Python (sync & async)
template.copy('model.bin', '/app/', gzip=False)
template.copy_items([{ 'src': 'a.bin', 'dest': '/app/', 'gzip': False }])
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:50:21 +02:00
Matt Brockman f160f08c7b Keep integration attribution on connection config (#1459)
moves integration attirbution to more private thing to avoid confusing people with first class kwargs
2026-06-26 18:30:35 -07:00
Lukáš Huvar bb45f185f1 Introduce generic paginator base class for JS and Python SDKs (#1491)
Extracts the cursor-based pagination state machine into a reusable base
class — `Paginator` in the JS SDK's `utils`, `PaginatorBase` in
`e2b/utils.py` — that owns `hasNext`/`nextToken` and the `x-next-token`
header handling, and migrates the sandbox and snapshot paginators onto
it. Each concrete paginator now just implements `nextItems`/`next_items`
to fetch its own page, so future list endpoints (templates, builds,
etc.) can add pagination by subclassing without reimplementing the
bookkeeping. Applied equivalently to the JS SDK and both Python sync and
async implementations, with unit tests covering the shared base. There
are no public API changes — `Sandbox.list()` / `listSnapshots()` and the
existing paginator types behave identically.

## Usage (unchanged)

```ts
const paginator = Sandbox.list()
while (paginator.hasNext) {
  const sandboxes = await paginator.nextItems()
  console.log(sandboxes)
}
```

```python
paginator = Sandbox.list()
while paginator.has_next:
    sandboxes = paginator.next_items()
    print(sandboxes)
```
2026-06-26 14:53:56 +02:00
Mish Ushakov bb1696871b Stream template build-context upload from disk instead of buffering in memory (#1435)
## Summary

Template builds previously buffered the entire gzipped build-context tar
archive in memory before uploading it. This PR spools the archive to a
temporary file and streams it from disk during upload — in the JS SDK
and both sync and async Python SDKs — so memory usage no longer scales
with the size of the build context.

The upload keeps an explicit `Content-Length` header (taken from the
spooled file's size), which S3 presigned PUT URLs require — they reject
`Transfer-Encoding: chunked` with `501 NotImplemented` (#1243).

## Changes

- **JS** (`packages/js-sdk/src/template/`):
`tarFileStream`/`tarFileStreamUpload` are replaced by `tarFileToStream`,
which writes the archive to a temp file and returns a self-cleaning read
stream plus its `size`. The spooled temp file deletes itself once the
stream is closed (consumed, errored, or destroyed) via the stream's
`close` event — mirroring the Python SDK's `tar_file_stream`. `buildApi`
streams this body with `duplex: 'half'` and an explicit `Content-Length`
from `size`; if `fetch` throws before consuming the body, it destroys
the stream to trigger the same cleanup. There is no separate cleanup
callback, so a cleanup failure can no longer mask the upload result.
- **Python** (`packages/python-sdk/e2b/template/utils.py`,
`template_async/build_api.py`, `template_sync/build_api.py`):
`tar_file_stream` now writes to a `tempfile.TemporaryFile` instead of
`io.BytesIO` and returns the file object positioned at the start; the
upload streams from it with an explicit `Content-Length` and closes it
(deleting the temp file) when done.
- Tests updated for the new return shapes (JS `tarFileToStream.test.ts`,
`uploadFile.test.ts`; Python upload/tar tests), including assertions
that the spooled archive is removed on both the consume and destroy
paths.

## Usage

No API changes — `Template.build()` / template builds behave the same,
just without holding the build context in memory:

```ts
await Template.build(template, { alias: 'my-template' })
```

```python
Template.build(template, alias="my-template")
```

Split out of #1433, which covers streaming for sandbox/volume file
uploads and downloads.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-25 20:56:10 +02:00
Mish Ushakov 8b8a224f8b feat(python-sdk): add logger option for request/debug logging (#1409)
Adds a `logger` option (a standard library `logging.Logger`) to
`Sandbox.create`/`AsyncSandbox.create` and the static
`Sandbox.connect(sandbox_id, ...)`, wired into the API client, the envd
client, the volume content client, and the RPC (ConnectRPC) path. The
logger is stored on the sandbox and propagates to all of its later
operations — including control-plane calls like
`kill`/`pause`/`set_timeout`/`get_info` (via `get_api_params`) — so
logging keeps working after construction; mirroring the JS SDK, `logger`
is a construction-time option and not a public per-request parameter
those methods accept from the caller, and nothing is logged unless a
logger is supplied. The stdlib `logging.Logger` is used directly as the
adapter (no ported JS `Logger` interface), and log levels match JS:
requests at `INFO`, successful API and unary RPC responses at `INFO`,
streamed RPC messages at `DEBUG`, failed API responses (status >= 400)
at `ERROR`. The always-on module-level (`e2b.*`) request logging at the
transport layer was removed in favor of this opt-in client-layer
logging, and volume content operations continue to accept `logger` per
call via `VolumeApiParams` to match the JS Volume API. Includes a
changeset and unit tests in `tests/test_logging_option.py`.

## Usage

```python
import logging
from e2b import Sandbox

logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger("my-app.e2b")

sbx = Sandbox.create(logger=logger)
sbx.commands.run("echo hello")   # RPC logged via `logger`
sbx.set_timeout(60)              # control-plane call also logged via `logger`
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Matt Brockman <matt.brockman@e2b.dev>
2026-06-25 20:43:04 +02:00
Mish Ushakov de0c401626 fix(sdk): correct filesystem watch handle callback and timeout behavior (#1480) 2026-06-25 19:51:53 +02:00
Babis Chalios 7e7e9514df feat(sdk): filesystem-only auto-pause via lifecycle.onTimeout object form (#1471)
## Filesystem-only auto-pause (`onTimeout` object form)

Adds an object form to the sandbox **lifecycle** `onTimeout`
(`on_timeout` in Python) that controls the snapshot kind taken when a
sandbox auto-pauses on timeout, via `keepMemory` (`keep_memory`).

`onTimeout` now accepts either the existing bare action (`'pause'` /
`'kill'`) or the object form `{ action, keepMemory }`. When `keepMemory`
is `false` (with `action: 'pause'`), a timeout auto-pause takes a
**filesystem-only** snapshot (no memory) instead of a full memory one,
so the sandbox cold-boots (reboots) from disk on resume — losing running
processes and open connections. Defaults to `true` (full memory
snapshot), so existing callers are unaffected. **The bare string form is
unchanged.**

It's the create-time / auto-pause counterpart to the explicit
`pause(keepMemory=false)` from #1465: same `keepMemory` naming, mapped
onto the `autoPauseMemory` create field.

### Type safety
The object form is a **discriminated union** on `action`: `keepMemory`
is only valid with `action: 'pause'`. Pairing it with `action: 'kill'`
is a **compile-time type error** (TS) / static error (`ty`), and is
additionally rejected at runtime (`InvalidArgumentError` /
`InvalidArgumentException`) for untyped callers.

### Behavior & validation
- `keepMemory` only applies to a `pause` action.
- **Incompatible with auto-resume** — auto-resume wakes a paused sandbox
on inbound traffic by restoring its memory snapshot in place; a
filesystem-only snapshot has no memory to restore (resuming cold-boots
it), so it must be resumed explicitly via `connect()`. Combining
`keepMemory: false` with `autoResume` is rejected client-side.

### Usage
```ts
// JS/TS — filesystem-only auto-pause on timeout
const sbx = await Sandbox.create({
  lifecycle: { onTimeout: { action: 'pause', keepMemory: false } },
})

// bare string form still works (full memory snapshot)
const sbx2 = await Sandbox.create({ lifecycle: { onTimeout: 'pause' } })
```
```python
# Python
sbx = Sandbox.create(
    lifecycle={"on_timeout": {"action": "pause", "keep_memory": False}}
)
```

### Changes
- `spec/openapi.yml`: `autoPauseMemory` on the create body (+
regenerated JS/Python clients).
- JS `SandboxOnTimeout` discriminated union (`'pause' | 'kill' | {
action: 'pause'; keepMemory? } | { action: 'kill' }`) and the Python
`SandboxOnTimeoutPause` / `SandboxOnTimeoutKill` TypedDicts, wired
through `createSandbox` / `_create_sandbox` (sync + async) to
`autoPauseMemory`, with the client-side guards.
- Tests: payload serialization + validation (offline, incl. the `action:
'kill'` type/runtime guard) and live cold-boot e2e in both SDKs;
changeset (`e2b` + `@e2b/python-sdk`, minor).

### Backend dependency
The live e2e tests exercise the real auto-pause→cold-boot path and
require the infra-side `autoPauseMemory` support (e2b-dev/infra#3055),
now merged and deployed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Babis Chalios <babis.chalios@e2b.dev>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:16:56 +00:00
Babis Chalios cb5a3870b6 feat(sdk): filesystem-only snapshots (pause memory:false) (#1465)
## Summary

Adds an optional **`memory`** flag to `pause` in both the JS and Python
SDKs. When `memory` is `false`, the pause captures **only the
filesystem** (no memory snapshot); resuming such a snapshot **cold-boots
(reboots)** the sandbox from disk — losing in-memory state, running
processes, and open connections. Defaults to `true` (full memory
snapshot), so existing callers are unaffected.

This is the SDK surface for the filesystem-only snapshot feature on the
infra side.

## Usage

```ts
// JS / TS
const sbx = await Sandbox.create()
await sbx.pause({ memory: false })   // filesystem-only snapshot
const resumed = await sbx.connect()  // resumes by cold-booting from disk
```

```python
# Python (sync)
sbx = Sandbox()
sbx.pause(memory=False)              # filesystem-only snapshot
resumed = sbx.connect()              # resumes by cold-booting from disk

# Python (async)
sbx = await AsyncSandbox.create()
await sbx.pause(memory=False)
resumed = await sbx.connect()
```

`memory` defaults to `true` — `pause()` / `pause({})` behave exactly as
before.

## What changed

- **spec**: optional `memory: boolean` (default `true`) on `POST
/sandboxes/{sandboxID}/pause` (`SandboxPauseRequest`); both API clients
regenerated via `make codegen`.
- **JS**: `Sandbox.pause` / `betaPause` accept `{ memory }` →
`SandboxApi.pause` sends the request body.
- **Python**: `pause(memory=...)` / `beta_pause` → `_cls_pause` (sync +
async) sends `SandboxPauseRequest(memory=...)`.
- **Tests**: filesystem-only pause+resume reboots the guest while the
filesystem survives — JS (`tests/sandbox/snapshot.test.ts`) and Python
sync + async. All pass against a local stack; `format` / `lint` /
`typecheck` clean.
- **Changeset**: `minor` for `e2b` and `@e2b/python-sdk`.

## Note (related infra observation, not addressed here)

While testing, a filesystem-only **resume cold-boots into a different
default exec context** (`root` / `/root`) than a memory resume (`user` /
`/home/user`). The filesystem itself is fully intact; tests use absolute
paths to be robust to this. Worth confirming on the infra reboot path
whether the template's default user should be restored after a cold
boot.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Babis Chalios <babis.chalios@e2b.dev>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:41:57 +00:00
Mish Ushakov 2a98cce8c7 fix(js-sdk): stop CommandHandle.disconnect() leaking the output subscription (#1474)
## Description

This PR fixes two related issues in the command handle's event handling.

### 1. JS `CommandHandle.disconnect()` leaked the output subscription

`disconnect()` was fire-and-forget — it only triggered the transport
abort and relied entirely on HTTP/2 abort propagation to stop events,
which is unreliable under keepalive: `onStdout`/`onStderr`/`onPty` could
keep firing for output produced after `disconnect()` returned.

`disconnect()` now sets a cooperative `disconnected` flag and aborts the
transport. The flag is checked before every callback dispatch in the
event loop, so once `disconnect()` returns no callback fires for output
that arrives (or was buffered) after the call — even if the underlying
abort hasn't torn the stream down yet. It does **not** wait for the
event handler to drain, so it returns promptly even for an idle command
(e.g. `sleep`) whose stream produces no further output, never blocks on
an in-flight callback, and does not deadlock when awaited from inside a
callback.

The async Python SDK was already correct here (`disconnect()` cancels
the event-handling task), and the sync Python SDK has no background
subscription (events are consumed only while the caller iterates). The
added Python tests confirm both.

### 2. Exit code was lost when a disconnected consumer stopped on a
flushed `end`-event chunk

When the `end` event flushes trailing decoder bytes (an incomplete
multibyte sequence → replacement character) and the consumer stops
iterating on the first flushed chunk, the generator was aborted before
the result was assigned, so `wait()` failed as if the process never
produced a result. The `end` handler now records the result **before**
yielding the flushed chunks, across the JS, async Python, and sync
Python SDKs.

## Usage

```js
const handle = await sandbox.commands.run(daemon, { background: true, stdin: true, onStdout })
await sandbox.commands.sendStdin(handle.pid, 'turn1\n')
await handle.disconnect() // resolves promptly; onStdout will not fire again
await sandbox.commands.sendStdin(handle.pid, 'turn2\n') // turn2 output never reaches onStdout
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:03:56 +00:00
Mish Ushakov c1415f3ec7 Stream volume file uploads and downloads instead of buffering in memory (#1453)
Follow-up to #1433. Builds on the shared streaming infrastructure
introduced there (`FILE_TIMEOUT_MS`, request-controller/stream-cleanup
helpers in `connectionConfig`, `io_utils` chunk iterators, the `runtime`
guard) and applies the same streaming model to volumes.

> [!NOTE]
> Based on `mishushakov/stream-write-file-upload` (#1433). Merge that PR
first; this PR's diff will then retarget to `main` automatically.

## What changed

- **`Volume.writeFile()` / `Volume.write_file()`** — stream the request
body instead of buffering it in memory.
- JS: `ReadableStream` data is streamed outside the browser
(half-duplex); browsers still buffer since they can't stream request
bodies.
- Python: file-like objects are streamed in chunks (async wraps them in
an async iterator; sync passes them to httpx directly, text-mode IO is
encoded chunk-by-chunk).
- **`Volume.readFile(format="stream")` / `read_file(format="stream")`**
— the request timeout now bounds only the initial handshake, not the
body read, matching the sandbox `files.read` stream path. A dropped
connection during the handshake surfaces the same typed, health-checked
error; JS supports `signal` to cancel an in-flight stream and cancels
unconsumed bodies on error so the pooled connection is released.

## Usage

JS — stream a file straight to a volume without buffering:
```ts
import { createReadStream } from 'node:fs'
import { Readable } from 'node:stream'

const stream = Readable.toWeb(createReadStream('large-input.bin'))
await volume.writeFile('/data/large-input.bin', stream)

// read back as a stream; the body lives until consumed/cancelled
const out = await volume.readFile('/data/large-input.bin', { format: 'stream' })
for await (const chunk of out) {
  // process chunk
}
```

Python — stream a file-like object:
```python
with open("large-input.bin", "rb") as f:
    volume.write_file("/data/large-input.bin", f)  # streamed, not read() into memory

for chunk in volume.read_file("/data/large-input.bin", format="stream"):
    ...  # process chunk
```

## Testing

- `pnpm run format`, `pnpm run lint`, `pnpm run typecheck` pass.
- Added volume streaming tests (JS `tests/volume/file.test.ts`; Python
sync/async `test_file.py` text-stream cases).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 12:11:03 -07:00
Mish Ushakov 60feee3cf6 Stream SDK file uploads and downloads instead of buffering in memory (#1433)
## Description

Removes full in-memory buffering from the SDK **sandbox** file-transfer
paths, in both JS and Python (sync + async).

**Streamed uploads** — `Sandbox.files.write` / `write_files` streams
`ReadableStream` (JS, outside the browser) and file-like (Python) input
to the sandbox with chunk-by-chunk gzip compression, instead of
buffering the whole body in memory. `useOctetStream`/`use_octet_stream`
now defaults to auto-detect — octet-stream when any entry is streamable
(so streamed uploads aren't silently buffered), `multipart/form-data`
otherwise; browsers always use `multipart/form-data` since streaming
request bodies aren't supported there. A streamed upload is bounded by a
per-chunk timeout on the wire (Python's per-write `httpx` timeout,
default the request timeout); a stalled upload the wire can't observe is
bounded server-side. On Python's `AsyncSandbox`, the blocking file reads
and gzip compression of a streamed upload now run in a worker thread so
a large upload doesn't stall the event loop.

**Streamed downloads** — `Sandbox.files.read(format="stream")` now
streams the response body from the sandbox instead of downloading it
into memory before iterating (Python sync + async), and the 60s request
timeout no longer kills the stream while it's being consumed:
- The request timeout now bounds only the initial handshake.
- The body is bounded by a per-chunk **idle-read timeout** on the wire —
a per-`read()` option (`streamIdleTimeoutMs` in JS,
`stream_idle_timeout` in Python; default the request timeout — 60s —
`0`/`None` to disable). It's armed only while waiting on a network read
and cleared the moment a chunk arrives, so it aborts only when the
server stops sending mid-stream; a slow or paused consumer never trips
it (a held-but-unread stream is reclaimed server-side, not by this
timer).
- A dropped connection during the handshake surfaces the same typed,
health-checked error as non-stream reads. In JS, `signal` can still
cancel an in-flight stream.
- The stream holds its pooled connection until it is consumed to the
end, cancelled/closed, errors, or the idle timeout fires — consume it
fully, use the context manager, or close it. (This replaces the earlier
GC-finalizer net.) Python returns a
`FileStreamReader`/`AsyncFileStreamReader` supporting deterministic
cleanup via `close()`/`aclose()` and (async) context-manager use; both
still satisfy `Iterator[bytes]`/`AsyncIterator[bytes]`, so existing
iteration is unchanged.

**Empty files** — JS `Sandbox.files.read()` with `blob` or `stream`
format now returns a format-correct empty value (empty `Blob` / empty
`ReadableStream`) for empty files instead of `""`.

> [!NOTE]
> The equivalent **volume** streaming changes
(`Volume.writeFile`/`write_file`, `Volume.readFile`/`read_file` streams)
live in a follow-up PR, #1453, which is based on this branch.

## Usage

```ts
// JS: upload a large file without holding it in memory
const file = createReadStream('large.bin')
await sandbox.files.write('large.bin', Readable.toWeb(file), { gzip: true })

// JS: consume a download for longer than 60s without it being killed
const stream = await sandbox.files.read('large.bin', { format: 'stream' })
for await (const chunk of stream) { /* ... */ }

// JS: tune (or disable) the per-chunk idle-read timeout for a read
const stream = await sandbox.files.read('large.bin', {
  format: 'stream',
  streamIdleTimeoutMs: 120_000, // 0 to disable
})

// JS: empty files now return format-correct empty values
const blob = await sandbox.files.read('empty.txt', { format: 'blob' }) // Blob (size 0), not ''
```

```python
# Python: streamed upload and download
with open("large.bin", "rb") as f:
    sandbox.files.write("large.bin", f, gzip=True)

for chunk in sandbox.files.read("large.bin", format="stream"):
    ...

# Python: deterministic cleanup when not reading the stream to the end
with sandbox.files.read("large.bin", format="stream") as stream:
    first_chunk = next(iter(stream))  # connection released on block exit

# Python: tune (or disable) the per-chunk idle-read timeout for a read
for chunk in sandbox.files.read(
    "large.bin", format="stream", stream_idle_timeout=120.0  # None to disable
):
    ...
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 19:45:32 +02:00
Mish Ushakov f3e7f33973 refactor(sdks): tidy SDK auth and deprecate ConnectionConfig access token (#1452)
## Summary

The access token was only ever used by the CLI, never by any SDK
operation — sandbox, template, and volume calls all authenticate with
the API key. This cleans up the auth plumbing and **deprecates** (rather
than removes) the access token on `ConnectionConfig`, so there's no
breaking change for direct SDK consumers.

## Changes

- **Deprecated** the `accessToken` (JS) / `access_token` (Python) option
on `ConnectionConfig`. It still works exactly as before — when set (or
via `E2B_ACCESS_TOKEN`) the `Authorization: Bearer` header is still sent
— but `apiHeaders` is now the recommended way to pass custom auth.
- **Clear error when the API key is missing**, pointing to the API Keys
tab (`https://e2b.dev/dashboard?tab=keys`). In JS this is gated by a
`requireApiKey` option (default `true`) so callers that authenticate
differently — like the CLI hitting `/teams` with an access token — can
opt out; in Python the API key is always required.
- Removed the unused access-token toggle from the API clients:
`requireAccessToken` (JS) / `require_access_token` (Python). No caller
ever set it to a non-default value, so behavior is unchanged.
- The CLI now passes the access token to the `/teams` endpoint via
`apiHeaders` instead of the deprecated option, and opts out of the
API-key requirement on its own clients.
- Decoupled the sandbox-scoped envd access token from
`ConnectionConfig`: `EnvdApiClient` now owns its own `envdAccessToken`
field and sets the `X-Access-Token` header itself, removing a redundant
manually-set header.

## Recommended usage

```ts
// Deprecated
new ConnectionConfig({ accessToken: 'my-token' })

// Preferred
new ConnectionConfig({ apiHeaders: { Authorization: 'Bearer my-token' } })
```

```python
# Deprecated
ConnectionConfig(access_token="my-token")

# Preferred
ConnectionConfig(api_headers={"Authorization": "Bearer my-token"})
```

## Verification

`pnpm run typecheck`, `pnpm run lint`, Python `make typecheck`, and the
unit tests all pass — including new tests for the API-key requirement
(and its opt-out) in both SDKs. Confirmed the `Authorization: Bearer`
header is still sent for both the deprecated option and
`E2B_ACCESS_TOKEN`.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:20:21 +02:00
Matt Brockman 4619f8ca11 cicd/add wait for status for public traffic network tests (#1456)
when running server in sandbox, sometimes slow to start (>3s) so need to wait for status instead
2026-06-17 15:47:34 -07:00
Matt Brockman 75e27420a2 cicd/fix test_commit_creates_commit timeout (#1455)
does a bunch of actions and times out sometimes
2026-06-17 22:13:56 +00:00
Matt Brockman 432c0913c8 Add integration user agent composibility (#1454)
user agent is now composable, improving attribution
2026-06-17 14:21:50 -07:00