Skip to content

Commit eb70a98

Browse files
mishushakovclaude
andauthored
Support E2B_SANDBOX_URL env var for Jupyter requests (#310)
* Support E2B_SANDBOX_URL env var and sandbox URL option for Jupyter requests Resolve the Jupyter server URL through the sandbox URL override (sandboxUrl/sandbox_url option or E2B_SANDBOX_URL environment variable), matching the base E2B SDK, and send E2b-Sandbox-Id and E2b-Sandbox-Port headers on Jupyter requests so a gateway or proxy can route them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review: strip trailing slash from sandbox URL, send X-Access-Token on context requests Strip trailing slashes when the Jupyter URL comes from the sandbox URL override so path concatenation can't produce double slashes, and send X-Access-Token on the context-CRUD requests in the JS SDK and Python AsyncSandbox, matching run_code and the Python sync Sandbox. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Remove trailing-slash stripping from sandbox URL override Callers are expected to pass a well-formed sandbox URL, matching the base SDK, which also returns the override verbatim. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Only send X-Access-Token on execute requests The Jupyter server only reads X-Access-Token on /execute, where it forwards the token to envd to fetch the sandbox's global env vars for the execution. The context endpoints never read it, so drop the header there — including from the sync Python SDK, which had been sending it on all requests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 91e7cee commit eb70a98

6 files changed

Lines changed: 153 additions & 16 deletions

File tree

.changeset/great-moons-agree.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@e2b/code-interpreter': minor
3+
'@e2b/code-interpreter-python': minor
4+
---
5+
6+
Honor the `sandboxUrl`/`sandbox_url` option and the `E2B_SANDBOX_URL` environment variable when connecting to the Jupyter server, matching the base E2B SDK. Jupyter requests now also send the `E2b-Sandbox-Id` and `E2b-Sandbox-Port` headers so a custom sandbox URL (e.g. a gateway or proxy) can route them to the right sandbox and port.

js/src/sandbox.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,10 @@ export class Sandbox extends BaseSandbox {
138138
'code-interpreter-v1'
139139

140140
protected get jupyterUrl(): string {
141-
return `${this.connectionConfig.debug ? 'http' : 'https'}://${this.getHost(
142-
JUPYTER_PORT
143-
)}`
141+
return this.connectionConfig.getSandboxDirectUrl(this.sandboxId, {
142+
sandboxDomain: this.sandboxDomain,
143+
envdPort: JUPYTER_PORT,
144+
})
144145
}
145146

146147
/**
@@ -214,6 +215,8 @@ export class Sandbox extends BaseSandbox {
214215

215216
const headers: Record<string, string> = {
216217
'Content-Type': 'application/json',
218+
'E2b-Sandbox-Id': this.sandboxId,
219+
'E2b-Sandbox-Port': JUPYTER_PORT.toString(),
217220
}
218221

219222
if (this.trafficAccessToken) {
@@ -294,6 +297,8 @@ export class Sandbox extends BaseSandbox {
294297
try {
295298
const headers: Record<string, string> = {
296299
'Content-Type': 'application/json',
300+
'E2b-Sandbox-Id': this.sandboxId,
301+
'E2b-Sandbox-Port': JUPYTER_PORT.toString(),
297302
}
298303

299304
if (this.trafficAccessToken) {
@@ -334,6 +339,8 @@ export class Sandbox extends BaseSandbox {
334339
const id = typeof context === 'string' ? context : context.id
335340
const headers: Record<string, string> = {
336341
'Content-Type': 'application/json',
342+
'E2b-Sandbox-Id': this.sandboxId,
343+
'E2b-Sandbox-Port': JUPYTER_PORT.toString(),
337344
}
338345

339346
if (this.trafficAccessToken) {
@@ -367,6 +374,8 @@ export class Sandbox extends BaseSandbox {
367374
try {
368375
const headers: Record<string, string> = {
369376
'Content-Type': 'application/json',
377+
'E2b-Sandbox-Id': this.sandboxId,
378+
'E2b-Sandbox-Port': JUPYTER_PORT.toString(),
370379
}
371380

372381
if (this.trafficAccessToken) {
@@ -405,6 +414,8 @@ export class Sandbox extends BaseSandbox {
405414
const id = typeof context === 'string' ? context : context.id
406415
const headers: Record<string, string> = {
407416
'Content-Type': 'application/json',
417+
'E2b-Sandbox-Id': this.sandboxId,
418+
'E2b-Sandbox-Port': JUPYTER_PORT.toString(),
408419
}
409420

410421
if (this.trafficAccessToken) {

js/tests/sandboxUrl.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { afterEach, beforeEach, expect, test } from 'vitest'
2+
3+
import { Sandbox } from '../src'
4+
5+
// Constructing a sandbox instance makes no network requests, so URL
6+
// resolution can be tested without a live sandbox.
7+
function createSandbox(opts: object = {}) {
8+
const SandboxClass = Sandbox as unknown as new (opts: object) => Sandbox
9+
const sandbox = new SandboxClass({
10+
sandboxId: 'test-sandbox-id',
11+
envdVersion: '0.2.0',
12+
...opts,
13+
})
14+
return sandbox as unknown as { jupyterUrl: string }
15+
}
16+
17+
const savedEnv: Record<string, string | undefined> = {}
18+
19+
beforeEach(() => {
20+
for (const key of ['E2B_SANDBOX_URL', 'E2B_DEBUG']) {
21+
savedEnv[key] = process.env[key]
22+
delete process.env[key]
23+
}
24+
})
25+
26+
afterEach(() => {
27+
for (const [key, value] of Object.entries(savedEnv)) {
28+
if (value === undefined) {
29+
delete process.env[key]
30+
} else {
31+
process.env[key] = value
32+
}
33+
}
34+
})
35+
36+
test('jupyterUrl points directly to the sandbox host by default', () => {
37+
const sandbox = createSandbox({ domain: 'example.dev' })
38+
expect(sandbox.jupyterUrl).toBe('https://49999-test-sandbox-id.example.dev')
39+
})
40+
41+
test('jupyterUrl honors the sandboxUrl option', () => {
42+
const sandbox = createSandbox({ sandboxUrl: 'https://proxy.example.com' })
43+
expect(sandbox.jupyterUrl).toBe('https://proxy.example.com')
44+
})
45+
46+
test('jupyterUrl honors the E2B_SANDBOX_URL environment variable', () => {
47+
process.env.E2B_SANDBOX_URL = 'https://env.example.com'
48+
const sandbox = createSandbox()
49+
expect(sandbox.jupyterUrl).toBe('https://env.example.com')
50+
})

python/e2b_code_interpreter/code_interpreter_async.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ class AsyncSandbox(BaseAsyncSandbox):
6161

6262
@property
6363
def _jupyter_url(self) -> str:
64+
# Honors the `sandbox_url` option and the `E2B_SANDBOX_URL` environment
65+
# variable, same as the base SDK does for envd requests.
66+
sandbox_url = self.connection_config._sandbox_url
67+
if sandbox_url:
68+
return sandbox_url
6469
return f"{'http' if self.connection_config.debug else 'https'}://{self.get_host(JUPYTER_PORT)}"
6570

6671
@property
@@ -196,6 +201,8 @@ async def run_code(
196201
try:
197202
headers = {
198203
"Content-Type": "application/json",
204+
"E2b-Sandbox-Id": self.sandbox_id,
205+
"E2b-Sandbox-Port": str(JUPYTER_PORT),
199206
}
200207
if self._envd_access_token:
201208
headers["X-Access-Token"] = self._envd_access_token
@@ -265,6 +272,8 @@ async def create_code_context(
265272
try:
266273
headers = {
267274
"Content-Type": "application/json",
275+
"E2b-Sandbox-Id": self.sandbox_id,
276+
"E2b-Sandbox-Port": str(JUPYTER_PORT),
268277
}
269278
if self.traffic_access_token:
270279
headers["E2B-Traffic-Access-Token"] = self.traffic_access_token
@@ -304,6 +313,8 @@ async def remove_code_context(
304313
try:
305314
headers = {
306315
"Content-Type": "application/json",
316+
"E2b-Sandbox-Id": self.sandbox_id,
317+
"E2b-Sandbox-Port": str(JUPYTER_PORT),
307318
}
308319
if self.traffic_access_token:
309320
headers["E2B-Traffic-Access-Token"] = self.traffic_access_token
@@ -332,6 +343,8 @@ async def list_code_contexts(self) -> List[Context]:
332343
try:
333344
headers = {
334345
"Content-Type": "application/json",
346+
"E2b-Sandbox-Id": self.sandbox_id,
347+
"E2b-Sandbox-Port": str(JUPYTER_PORT),
335348
}
336349
if self.traffic_access_token:
337350
headers["E2B-Traffic-Access-Token"] = self.traffic_access_token
@@ -369,6 +382,8 @@ async def restart_code_context(
369382
try:
370383
headers = {
371384
"Content-Type": "application/json",
385+
"E2b-Sandbox-Id": self.sandbox_id,
386+
"E2b-Sandbox-Port": str(JUPYTER_PORT),
372387
}
373388
if self.traffic_access_token:
374389
headers["E2B-Traffic-Access-Token"] = self.traffic_access_token

python/e2b_code_interpreter/code_interpreter_sync.py

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ class Sandbox(BaseSandbox):
5858

5959
@property
6060
def _jupyter_url(self) -> str:
61+
# Honors the `sandbox_url` option and the `E2B_SANDBOX_URL` environment
62+
# variable, same as the base SDK does for envd requests.
63+
sandbox_url = self.connection_config._sandbox_url
64+
if sandbox_url:
65+
return sandbox_url
6166
return f"{'http' if self.connection_config.debug else 'https'}://{self.get_host(JUPYTER_PORT)}"
6267

6368
@property
@@ -189,7 +194,11 @@ def run_code(
189194
context_id = context.id if context else None
190195

191196
try:
192-
headers: Dict[str, str] = {"Content-Type": "application/json"}
197+
headers: Dict[str, str] = {
198+
"Content-Type": "application/json",
199+
"E2b-Sandbox-Id": self.sandbox_id,
200+
"E2b-Sandbox-Port": str(JUPYTER_PORT),
201+
}
193202
if self._envd_access_token:
194203
headers["X-Access-Token"] = self._envd_access_token
195204
if self.traffic_access_token:
@@ -256,9 +265,11 @@ def create_code_context(
256265
data["cwd"] = cwd
257266

258267
try:
259-
headers: Dict[str, str] = {"Content-Type": "application/json"}
260-
if self._envd_access_token:
261-
headers["X-Access-Token"] = self._envd_access_token
268+
headers: Dict[str, str] = {
269+
"Content-Type": "application/json",
270+
"E2b-Sandbox-Id": self.sandbox_id,
271+
"E2b-Sandbox-Port": str(JUPYTER_PORT),
272+
}
262273
if self.traffic_access_token:
263274
headers["E2B-Traffic-Access-Token"] = self.traffic_access_token
264275

@@ -295,9 +306,11 @@ def remove_code_context(
295306
context_id = context.id if isinstance(context, Context) else context
296307

297308
try:
298-
headers: Dict[str, str] = {"Content-Type": "application/json"}
299-
if self._envd_access_token:
300-
headers["X-Access-Token"] = self._envd_access_token
309+
headers: Dict[str, str] = {
310+
"Content-Type": "application/json",
311+
"E2b-Sandbox-Id": self.sandbox_id,
312+
"E2b-Sandbox-Port": str(JUPYTER_PORT),
313+
}
301314
if self.traffic_access_token:
302315
headers["E2B-Traffic-Access-Token"] = self.traffic_access_token
303316

@@ -323,9 +336,11 @@ def list_code_contexts(self) -> List[Context]:
323336
:return: List of contexts.
324337
"""
325338
try:
326-
headers: Dict[str, str] = {"Content-Type": "application/json"}
327-
if self._envd_access_token:
328-
headers["X-Access-Token"] = self._envd_access_token
339+
headers: Dict[str, str] = {
340+
"Content-Type": "application/json",
341+
"E2b-Sandbox-Id": self.sandbox_id,
342+
"E2b-Sandbox-Port": str(JUPYTER_PORT),
343+
}
329344
if self.traffic_access_token:
330345
headers["E2B-Traffic-Access-Token"] = self.traffic_access_token
331346

@@ -361,9 +376,11 @@ def restart_code_context(
361376
context_id = context.id if isinstance(context, Context) else context
362377

363378
try:
364-
headers: Dict[str, str] = {"Content-Type": "application/json"}
365-
if self._envd_access_token:
366-
headers["X-Access-Token"] = self._envd_access_token
379+
headers: Dict[str, str] = {
380+
"Content-Type": "application/json",
381+
"E2b-Sandbox-Id": self.sandbox_id,
382+
"E2b-Sandbox-Port": str(JUPYTER_PORT),
383+
}
367384
if self.traffic_access_token:
368385
headers["E2B-Traffic-Access-Token"] = self.traffic_access_token
369386

python/tests/test_sandbox_url.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import pytest
2+
3+
from e2b.connection_config import ConnectionConfig
4+
5+
from e2b_code_interpreter import AsyncSandbox, Sandbox
6+
7+
8+
def make_sandbox(cls, **config_kwargs):
9+
# Constructing a sandbox instance makes no network requests, so URL
10+
# resolution can be tested without a live sandbox.
11+
return cls(
12+
sandbox_id="test-sandbox-id",
13+
sandbox_domain=None,
14+
envd_version="0.2.0",
15+
envd_access_token=None,
16+
connection_config=ConnectionConfig(**config_kwargs),
17+
)
18+
19+
20+
@pytest.mark.parametrize("cls", [Sandbox, AsyncSandbox])
21+
async def test_jupyter_url_points_to_sandbox_host_by_default(cls, monkeypatch):
22+
monkeypatch.delenv("E2B_SANDBOX_URL", raising=False)
23+
monkeypatch.delenv("E2B_DEBUG", raising=False)
24+
sandbox = make_sandbox(cls, domain="example.dev")
25+
assert sandbox._jupyter_url == "https://49999-test-sandbox-id.example.dev"
26+
27+
28+
@pytest.mark.parametrize("cls", [Sandbox, AsyncSandbox])
29+
async def test_jupyter_url_honors_sandbox_url_option(cls):
30+
sandbox = make_sandbox(cls, sandbox_url="https://proxy.example.com")
31+
assert sandbox._jupyter_url == "https://proxy.example.com"
32+
33+
34+
@pytest.mark.parametrize("cls", [Sandbox, AsyncSandbox])
35+
async def test_jupyter_url_honors_sandbox_url_env_var(cls, monkeypatch):
36+
monkeypatch.setenv("E2B_SANDBOX_URL", "https://env.example.com")
37+
sandbox = make_sandbox(cls)
38+
assert sandbox._jupyter_url == "https://env.example.com"

0 commit comments

Comments
 (0)