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>
This commit is contained in:
Joe Lombrozo
2026-08-03 10:24:15 -07:00
committed by GitHub
parent 9ef3f1dbbe
commit 2821fb0b69
13 changed files with 291 additions and 17 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'e2b': minor
'@e2b/python-sdk': minor
---
Route volume content requests to a team's custom (BYOC) cluster. When a team is connected to a custom cluster, the volume create and get endpoints now return that cluster's `domain`, and the SDK uses it as the destination for volume content requests instead of the default `api.<E2B_DOMAIN>` host. Teams on the default cluster are unaffected and keep their configured domain.
+18 -3
View File
@@ -716,6 +716,7 @@ export interface paths {
/** @description Successfully returned snapshots */
200: {
headers: {
"X-Next-Token": components["headers"]["XNextToken"];
[name: string]: unknown;
};
content: {
@@ -980,6 +981,7 @@ export interface paths {
/** @description Successfully returned the template with its builds */
200: {
headers: {
"X-Next-Token": components["headers"]["XNextToken"];
[name: string]: unknown;
};
content: {
@@ -1478,6 +1480,8 @@ export interface paths {
/** @description Successfully returned all running sandboxes */
200: {
headers: {
"X-Next-Token": components["headers"]["XNextToken"];
"X-Total-Running": components["headers"]["XTotalRunning"];
[name: string]: unknown;
};
content: {
@@ -1581,8 +1585,7 @@ export interface paths {
/** @description Successfully returned all templates */
200: {
headers: {
/** @description Cursor to fetch the next page of results, if more exist */
"X-Next-Token"?: string;
"X-Next-Token": components["headers"]["XNextToken"];
[name: string]: unknown;
};
content: {
@@ -3068,6 +3071,12 @@ export interface components {
volumeID: string;
};
VolumeAndToken: {
/** @description Domain to use as the destination for volume content requests,
* replacing the default `api.<E2B_DOMAIN>`. Only returned when the
* team is connected to a custom (BYOC) cluster; absent otherwise, in
* which case the default domain is used.
* */
domain?: string;
/** @description Name of the volume */
name: string;
/** @description Auth token to use for interacting with volume content */
@@ -3161,7 +3170,13 @@ export interface components {
volumeID: string;
};
requestBodies: never;
headers: never;
headers: {
/** @description Cursor to fetch the next page of results, if more exist */
XNextToken: string;
/** @description Number of running sandboxes matching the filters, before pagination is applied. Only present when running sandboxes were requested.
* */
XTotalRunning: number;
};
pathItems: never;
}
export type $defs = Record<string, never>;
+4 -3
View File
@@ -135,7 +135,7 @@ export class Volume {
res.data.volumeID,
res.data.name,
res.data.token,
config.domain,
res.data.domain || config.domain,
config.debug,
config.proxy
)
@@ -154,12 +154,12 @@ export class Volume {
opts?: ConnectionOpts
): Promise<Volume> {
const config = new ConnectionConfig(opts)
const { name, token } = await Volume.getInfo(volumeId, opts)
const { name, token, domain } = await Volume.getInfo(volumeId, opts)
return new Volume(
volumeId,
name,
token,
config.domain,
domain ?? config.domain,
config.debug,
config.proxy
)
@@ -202,6 +202,7 @@ export class Volume {
volumeId: res.data!.volumeID,
name: res.data!.name,
token: res.data!.token,
domain: res.data!.domain || undefined,
}
}
+7
View File
@@ -33,6 +33,13 @@ export type VolumeAndToken = VolumeInfo & {
* Volume auth token.
*/
token: string
/**
* Domain to use as the destination for volume content requests, replacing
* the default `api.<domain>` host. Only set for teams connected to a custom
* (BYOC) cluster; `undefined` otherwise.
*/
domain?: string
}
/**
@@ -209,6 +209,68 @@ describe('Volume CRUD', () => {
})
})
describe('Volume BYOC domain', () => {
const byocDomain = 'cluster.example.com'
const defaultDomain = process.env.E2B_DOMAIN || 'e2b.app'
it('uses the domain returned by create for content requests', async () => {
server.use(
http.post(apiUrl('/volumes'), async ({ request }) => {
const { name } = (await request.clone().json()) as { name: string }
const volumeID = randomUUID()
const token = `vol-token-${randomUUID()}`
return HttpResponse.json(
{ volumeID, name, token, domain: byocDomain },
{ status: 201 }
)
})
)
const vol = await Volume.create('byoc-volume')
// The BYOC domain is stored on the instance and drives the content API
// destination (https://api.<domain>), replacing the default domain.
expect(vol.domain).toBe(byocDomain)
const config = new VolumeConnectionConfig(vol)
expect(config.domain).toBe(byocDomain)
expect(config.apiUrl).toBe(`https://api.${byocDomain}`)
})
it('falls back to the default domain when create returns no domain', async () => {
const vol = await Volume.create('default-volume')
expect(vol.domain).toBe(defaultDomain)
expect(new VolumeConnectionConfig(vol).domain).toBe(defaultDomain)
})
it('propagates the domain through getInfo and connect', async () => {
const created = await Volume.create('connect-volume')
server.use(
http.get<{ volumeID: string }>(
apiUrl('/volumes/:volumeID'),
({ params }) => {
const vol = volumes.get(params.volumeID)
if (!vol) {
return HttpResponse.json(
{ code: 404, message: 'Not found' },
{ status: 404 }
)
}
return HttpResponse.json({ ...vol, domain: byocDomain })
}
)
)
const info = await Volume.getInfo(created.volumeId)
expect(info.domain).toBe(byocDomain)
const connected = await Volume.connect(created.volumeId)
expect(connected.domain).toBe(byocDomain)
expect(new VolumeConnectionConfig(connected).domain).toBe(byocDomain)
})
})
describe('Volume content readFile', () => {
it('should return content for a non-empty file in every format', async () => {
volumeFiles.set('hello.txt', 'hello world')
+15 -1
View File
@@ -1,9 +1,11 @@
from collections.abc import Mapping
from typing import Any, TypeVar
from typing import Any, TypeVar, Union
from attrs import define as _attrs_define
from attrs import field as _attrs_field
from ..types import UNSET, Unset
T = TypeVar("T", bound="VolumeAndToken")
@@ -14,11 +16,16 @@ class VolumeAndToken:
volume_id (str): ID of the volume
name (str): Name of the volume
token (str): Auth token to use for interacting with volume content
domain (Union[Unset, str]): Domain to use as the destination for volume content requests,
replacing the default `api.<E2B_DOMAIN>`. Only returned when the
team is connected to a custom (BYOC) cluster; absent otherwise, in
which case the default domain is used.
"""
volume_id: str
name: str
token: str
domain: Union[Unset, str] = UNSET
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
def to_dict(self) -> dict[str, Any]:
@@ -28,6 +35,8 @@ class VolumeAndToken:
token = self.token
domain = self.domain
field_dict: dict[str, Any] = {}
field_dict.update(self.additional_properties)
field_dict.update(
@@ -37,6 +46,8 @@ class VolumeAndToken:
"token": token,
}
)
if domain is not UNSET:
field_dict["domain"] = domain
return field_dict
@@ -49,10 +60,13 @@ class VolumeAndToken:
token = d.pop("token")
domain = d.pop("domain", UNSET)
volume_and_token = cls(
volume_id=volume_id,
name=name,
token=token,
domain=domain,
)
volume_and_token.additional_properties = d
+4
View File
@@ -24,6 +24,10 @@ class VolumeAndToken(VolumeInfo):
token: str
"""Volume auth token."""
domain: Optional[str] = None
"""Domain to use as the destination for volume content requests, replacing
the default ``api.<domain>`` host. Only set for teams connected to a custom
(BYOC) cluster; ``None`` otherwise."""
@dataclass
+17 -3
View File
@@ -47,7 +47,10 @@ from e2b.volume.types import (
VolumeEntryStat,
)
from e2b.io_utils import aiter_io_chunks
from e2b.volume.utils import DualMethod, convert_volume_entry_stat
from e2b.volume.utils import (
DualMethod,
convert_volume_entry_stat,
)
class AsyncVolume:
@@ -121,11 +124,16 @@ class AsyncVolume:
if isinstance(res.parsed, Error):
raise Exception(f"{res.parsed.message}: Request failed")
domain = (
res.parsed.domain
if isinstance(res.parsed.domain, str) and res.parsed.domain
else None
)
vol = cls(
volume_id=res.parsed.volume_id,
name=res.parsed.name,
token=res.parsed.token,
domain=config.domain,
domain=domain or config.domain,
debug=config.debug,
proxy=config.proxy,
)
@@ -146,7 +154,7 @@ class AsyncVolume:
volume_id=volume_id,
name=info.name,
token=info.token,
domain=config.domain,
domain=info.domain or config.domain,
debug=config.debug,
proxy=config.proxy,
)
@@ -182,10 +190,16 @@ class AsyncVolume:
if isinstance(res.parsed, Error):
raise Exception(f"{res.parsed.message}: Request failed")
domain = (
res.parsed.domain
if isinstance(res.parsed.domain, str) and res.parsed.domain
else None
)
return VolumeAndToken(
volume_id=res.parsed.volume_id,
name=res.parsed.name,
token=res.parsed.token,
domain=domain,
)
@staticmethod
+17 -3
View File
@@ -48,7 +48,10 @@ from e2b.volume.types import (
VolumeEntryStat,
)
from e2b.io_utils import iter_io_chunks
from e2b.volume.utils import DualMethod, convert_volume_entry_stat
from e2b.volume.utils import (
DualMethod,
convert_volume_entry_stat,
)
class Volume:
@@ -122,11 +125,16 @@ class Volume:
if isinstance(res.parsed, Error):
raise Exception(f"{res.parsed.message}: Request failed")
domain = (
res.parsed.domain
if isinstance(res.parsed.domain, str) and res.parsed.domain
else None
)
vol = cls(
volume_id=res.parsed.volume_id,
name=res.parsed.name,
token=res.parsed.token,
domain=config.domain,
domain=domain or config.domain,
debug=config.debug,
proxy=config.proxy,
)
@@ -147,7 +155,7 @@ class Volume:
volume_id=volume_id,
name=info.name,
token=info.token,
domain=config.domain,
domain=info.domain or config.domain,
debug=config.debug,
proxy=config.proxy,
)
@@ -181,10 +189,16 @@ class Volume:
if isinstance(res.parsed, Error):
raise Exception(f"{res.parsed.message}: Request failed")
domain = (
res.parsed.domain
if isinstance(res.parsed.domain, str) and res.parsed.domain
else None
)
return VolumeAndToken(
volume_id=res.parsed.volume_id,
name=res.parsed.name,
token=res.parsed.token,
domain=domain,
)
@staticmethod
@@ -4,6 +4,7 @@ from uuid import uuid4
import pytest
from e2b import AsyncVolume
from e2b.connection_config import ConnectionConfig
from e2b.exceptions import NotFoundException
from e2b.api.client.models.volume_and_token import VolumeAndToken
from e2b.api.client.types import Response
@@ -154,6 +155,55 @@ async def test_volume_per_call_proxy_overrides_instance():
assert config.proxy == "http://127.0.0.1:9090"
async def test_create_volume_uses_byoc_domain(monkeypatch):
byoc_domain = "cluster.example.com"
async def mock_post(*, client, body):
vol_id = str(uuid4())
vol = VolumeAndToken.from_dict(
{
"volumeID": vol_id,
"name": body.name,
"token": f"vol-token-{uuid4()}",
"domain": byoc_domain,
}
)
_volumes[vol_id] = vol
return Response(
status_code=HTTPStatus(201), content=b"", headers={}, parsed=vol
)
monkeypatch.setattr(post_volumes_mod, "asyncio_detailed", mock_post)
vol = await AsyncVolume.create("byoc-volume")
# The BYOC domain drives the content API destination (https://api.<domain>),
# replacing the default domain.
assert vol._domain == byoc_domain
assert vol._get_volume_config().domain == byoc_domain
assert vol._get_volume_config().api_url == f"https://api.{byoc_domain}"
async def test_create_volume_falls_back_to_default_domain():
vol = await AsyncVolume.create("default-volume")
# No domain in the response -> fall back to the connection default.
assert vol._domain == ConnectionConfig().domain
async def test_connect_propagates_byoc_domain():
byoc_domain = "cluster.example.com"
created = await AsyncVolume.create("connect-volume")
_volumes[created.volume_id].domain = byoc_domain
info = await AsyncVolume.get_info(created.volume_id)
assert info.domain == byoc_domain
connected = await AsyncVolume.connect(created.volume_id)
assert connected._domain == byoc_domain
assert connected._get_volume_config().domain == byoc_domain
async def test_volume_full_lifecycle():
# Create
vol = await AsyncVolume.create("lifecycle-vol")
@@ -4,6 +4,7 @@ from uuid import uuid4
import pytest
from e2b import Volume
from e2b.connection_config import ConnectionConfig
from e2b.exceptions import NotFoundException
from e2b.api.client.models.volume_and_token import VolumeAndToken
from e2b.api.client.types import Response
@@ -152,6 +153,55 @@ def test_volume_per_call_proxy_overrides_instance():
assert config.proxy == "http://127.0.0.1:9090"
def test_create_volume_uses_byoc_domain(monkeypatch):
byoc_domain = "cluster.example.com"
def mock_post(*, client, body):
vol_id = str(uuid4())
vol = VolumeAndToken.from_dict(
{
"volumeID": vol_id,
"name": body.name,
"token": f"vol-token-{uuid4()}",
"domain": byoc_domain,
}
)
_volumes[vol_id] = vol
return Response(
status_code=HTTPStatus(201), content=b"", headers={}, parsed=vol
)
monkeypatch.setattr(post_volumes_mod, "sync_detailed", mock_post)
vol = Volume.create("byoc-volume")
# The BYOC domain drives the content API destination (https://api.<domain>),
# replacing the default domain.
assert vol._domain == byoc_domain
assert vol._get_volume_config().domain == byoc_domain
assert vol._get_volume_config().api_url == f"https://api.{byoc_domain}"
def test_create_volume_falls_back_to_default_domain():
vol = Volume.create("default-volume")
# No domain in the response -> fall back to the connection default.
assert vol._domain == ConnectionConfig().domain
def test_connect_propagates_byoc_domain():
byoc_domain = "cluster.example.com"
created = Volume.create("connect-volume")
_volumes[created.volume_id].domain = byoc_domain
info = Volume.get_info(created.volume_id)
assert info.domain == byoc_domain
connected = Volume.connect(created.volume_id)
assert connected._domain == byoc_domain
assert connected._get_volume_config().domain == byoc_domain
def test_volume_full_lifecycle():
# Create
vol = Volume.create("lifecycle-vol")
+1 -1
View File
@@ -1 +1 @@
97782d4d2812ffaa88fce2eb640dac645ccbdbd4
24a054bca26ec50a6d59031d9360c1582612b3f8
+40 -3
View File
@@ -123,6 +123,19 @@ components:
schema:
type: string
headers:
XNextToken:
description: Cursor to fetch the next page of results, if more exist
schema:
type: string
XTotalRunning:
description: >
Number of running sandboxes matching the filters, before pagination is applied.
Only present when running sandboxes were requested.
schema:
type: integer
format: int32
responses:
"400":
description: Bad request
@@ -300,6 +313,10 @@ components:
egressProxy:
allOf:
- $ref: "#/components/schemas/SandboxEgressProxyConfig"
description: >-
SOCKS5 proxy for sandbox egress. Outbound TCP is tunneled through the
proxy after allow/deny filtering; the sandbox is unaware. Domain-matched
flows use remote DNS (ATYP=domain).
x-not-implemented: true
maskRequestHost:
type: string
@@ -332,6 +349,10 @@ components:
egressProxy:
allOf:
- $ref: "#/components/schemas/SandboxEgressProxyConfig"
description: >-
SOCKS5 proxy for sandbox egress. Outbound TCP is tunneled through the
proxy after allow/deny filtering; the sandbox is unaware. Domain-matched
flows use remote DNS (ATYP=domain).
x-not-implemented: true
rules:
type: object
@@ -2037,6 +2058,13 @@ components:
token:
type: string
description: Auth token to use for interacting with volume content
domain:
type: string
description: |
Domain to use as the destination for volume content requests,
replacing the default `api.<E2B_DOMAIN>`. Only returned when the
team is connected to a custom (BYOC) cluster; absent otherwise, in
which case the default domain is used.
required:
- volumeID
- name
@@ -2286,6 +2314,11 @@ paths:
responses:
"200":
description: Successfully returned all running sandboxes
headers:
X-Next-Token:
$ref: "#/components/headers/XNextToken"
X-Total-Running:
$ref: "#/components/headers/XTotalRunning"
content:
application/json:
schema:
@@ -2837,6 +2870,9 @@ paths:
responses:
"200":
description: Successfully returned snapshots
headers:
X-Next-Token:
$ref: "#/components/headers/XNextToken"
content:
application/json:
schema:
@@ -2908,9 +2944,7 @@ paths:
description: Successfully returned all templates
headers:
X-Next-Token:
description: Cursor to fetch the next page of results, if more exist
schema:
type: string
$ref: "#/components/headers/XNextToken"
content:
application/json:
schema:
@@ -3075,6 +3109,9 @@ paths:
responses:
"200":
description: Successfully returned the template with its builds
headers:
X-Next-Token:
$ref: "#/components/headers/XNextToken"
content:
application/json:
schema: