diff --git a/.changeset/great-moons-agree.md b/.changeset/great-moons-agree.md new file mode 100644 index 00000000..6f5397fd --- /dev/null +++ b/.changeset/great-moons-agree.md @@ -0,0 +1,6 @@ +--- +'@e2b/code-interpreter': minor +'@e2b/code-interpreter-python': minor +--- + +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. diff --git a/js/src/sandbox.ts b/js/src/sandbox.ts index 14aef88e..8d3752b9 100644 --- a/js/src/sandbox.ts +++ b/js/src/sandbox.ts @@ -138,9 +138,10 @@ export class Sandbox extends BaseSandbox { 'code-interpreter-v1' protected get jupyterUrl(): string { - return `${this.connectionConfig.debug ? 'http' : 'https'}://${this.getHost( - JUPYTER_PORT - )}` + return this.connectionConfig.getSandboxDirectUrl(this.sandboxId, { + sandboxDomain: this.sandboxDomain, + envdPort: JUPYTER_PORT, + }) } /** @@ -214,6 +215,8 @@ export class Sandbox extends BaseSandbox { const headers: Record = { 'Content-Type': 'application/json', + 'E2b-Sandbox-Id': this.sandboxId, + 'E2b-Sandbox-Port': JUPYTER_PORT.toString(), } if (this.trafficAccessToken) { @@ -294,11 +297,16 @@ export class Sandbox extends BaseSandbox { try { const headers: Record = { 'Content-Type': 'application/json', + 'E2b-Sandbox-Id': this.sandboxId, + 'E2b-Sandbox-Port': JUPYTER_PORT.toString(), } if (this.trafficAccessToken) { headers['E2B-Traffic-Access-Token'] = this.trafficAccessToken } + if (this.envdAccessToken) { + headers['X-Access-Token'] = this.envdAccessToken + } const res = await fetch(`${this.jupyterUrl}/contexts`, { method: 'POST', @@ -334,11 +342,16 @@ export class Sandbox extends BaseSandbox { const id = typeof context === 'string' ? context : context.id const headers: Record = { 'Content-Type': 'application/json', + 'E2b-Sandbox-Id': this.sandboxId, + 'E2b-Sandbox-Port': JUPYTER_PORT.toString(), } if (this.trafficAccessToken) { headers['E2B-Traffic-Access-Token'] = this.trafficAccessToken } + if (this.envdAccessToken) { + headers['X-Access-Token'] = this.envdAccessToken + } const res = await fetch(`${this.jupyterUrl}/contexts/${id}`, { method: 'DELETE', @@ -367,11 +380,16 @@ export class Sandbox extends BaseSandbox { try { const headers: Record = { 'Content-Type': 'application/json', + 'E2b-Sandbox-Id': this.sandboxId, + 'E2b-Sandbox-Port': JUPYTER_PORT.toString(), } if (this.trafficAccessToken) { headers['E2B-Traffic-Access-Token'] = this.trafficAccessToken } + if (this.envdAccessToken) { + headers['X-Access-Token'] = this.envdAccessToken + } const res = await fetch(`${this.jupyterUrl}/contexts`, { method: 'GET', @@ -405,11 +423,16 @@ export class Sandbox extends BaseSandbox { const id = typeof context === 'string' ? context : context.id const headers: Record = { 'Content-Type': 'application/json', + 'E2b-Sandbox-Id': this.sandboxId, + 'E2b-Sandbox-Port': JUPYTER_PORT.toString(), } if (this.trafficAccessToken) { headers['E2B-Traffic-Access-Token'] = this.trafficAccessToken } + if (this.envdAccessToken) { + headers['X-Access-Token'] = this.envdAccessToken + } const res = await fetch(`${this.jupyterUrl}/contexts/${id}/restart`, { method: 'POST', diff --git a/js/tests/sandboxUrl.test.ts b/js/tests/sandboxUrl.test.ts new file mode 100644 index 00000000..4566f6cd --- /dev/null +++ b/js/tests/sandboxUrl.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, expect, test } from 'vitest' + +import { Sandbox } from '../src' + +// Constructing a sandbox instance makes no network requests, so URL +// resolution can be tested without a live sandbox. +function createSandbox(opts: object = {}) { + const SandboxClass = Sandbox as unknown as new (opts: object) => Sandbox + const sandbox = new SandboxClass({ + sandboxId: 'test-sandbox-id', + envdVersion: '0.2.0', + ...opts, + }) + return sandbox as unknown as { jupyterUrl: string } +} + +const savedEnv: Record = {} + +beforeEach(() => { + for (const key of ['E2B_SANDBOX_URL', 'E2B_DEBUG']) { + savedEnv[key] = process.env[key] + delete process.env[key] + } +}) + +afterEach(() => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } +}) + +test('jupyterUrl points directly to the sandbox host by default', () => { + const sandbox = createSandbox({ domain: 'example.dev' }) + expect(sandbox.jupyterUrl).toBe('https://49999-test-sandbox-id.example.dev') +}) + +test('jupyterUrl honors the sandboxUrl option', () => { + const sandbox = createSandbox({ sandboxUrl: 'https://proxy.example.com' }) + expect(sandbox.jupyterUrl).toBe('https://proxy.example.com') +}) + +test('jupyterUrl honors the E2B_SANDBOX_URL environment variable', () => { + process.env.E2B_SANDBOX_URL = 'https://env.example.com' + const sandbox = createSandbox() + expect(sandbox.jupyterUrl).toBe('https://env.example.com') +}) diff --git a/python/e2b_code_interpreter/code_interpreter_async.py b/python/e2b_code_interpreter/code_interpreter_async.py index fcc2737a..db1d2b26 100644 --- a/python/e2b_code_interpreter/code_interpreter_async.py +++ b/python/e2b_code_interpreter/code_interpreter_async.py @@ -61,6 +61,11 @@ class AsyncSandbox(BaseAsyncSandbox): @property def _jupyter_url(self) -> str: + # Honors the `sandbox_url` option and the `E2B_SANDBOX_URL` environment + # variable, same as the base SDK does for envd requests. + sandbox_url = self.connection_config._sandbox_url + if sandbox_url: + return sandbox_url return f"{'http' if self.connection_config.debug else 'https'}://{self.get_host(JUPYTER_PORT)}" @property @@ -196,6 +201,8 @@ async def run_code( try: headers = { "Content-Type": "application/json", + "E2b-Sandbox-Id": self.sandbox_id, + "E2b-Sandbox-Port": str(JUPYTER_PORT), } if self._envd_access_token: headers["X-Access-Token"] = self._envd_access_token @@ -265,7 +272,11 @@ async def create_code_context( try: headers = { "Content-Type": "application/json", + "E2b-Sandbox-Id": self.sandbox_id, + "E2b-Sandbox-Port": str(JUPYTER_PORT), } + if self._envd_access_token: + headers["X-Access-Token"] = self._envd_access_token if self.traffic_access_token: headers["E2B-Traffic-Access-Token"] = self.traffic_access_token @@ -304,7 +315,11 @@ async def remove_code_context( try: headers = { "Content-Type": "application/json", + "E2b-Sandbox-Id": self.sandbox_id, + "E2b-Sandbox-Port": str(JUPYTER_PORT), } + if self._envd_access_token: + headers["X-Access-Token"] = self._envd_access_token if self.traffic_access_token: headers["E2B-Traffic-Access-Token"] = self.traffic_access_token @@ -332,7 +347,11 @@ async def list_code_contexts(self) -> List[Context]: try: headers = { "Content-Type": "application/json", + "E2b-Sandbox-Id": self.sandbox_id, + "E2b-Sandbox-Port": str(JUPYTER_PORT), } + if self._envd_access_token: + headers["X-Access-Token"] = self._envd_access_token if self.traffic_access_token: headers["E2B-Traffic-Access-Token"] = self.traffic_access_token @@ -369,7 +388,11 @@ async def restart_code_context( try: headers = { "Content-Type": "application/json", + "E2b-Sandbox-Id": self.sandbox_id, + "E2b-Sandbox-Port": str(JUPYTER_PORT), } + if self._envd_access_token: + headers["X-Access-Token"] = self._envd_access_token if self.traffic_access_token: headers["E2B-Traffic-Access-Token"] = self.traffic_access_token diff --git a/python/e2b_code_interpreter/code_interpreter_sync.py b/python/e2b_code_interpreter/code_interpreter_sync.py index 35850e56..ddfe4aad 100644 --- a/python/e2b_code_interpreter/code_interpreter_sync.py +++ b/python/e2b_code_interpreter/code_interpreter_sync.py @@ -58,6 +58,11 @@ class Sandbox(BaseSandbox): @property def _jupyter_url(self) -> str: + # Honors the `sandbox_url` option and the `E2B_SANDBOX_URL` environment + # variable, same as the base SDK does for envd requests. + sandbox_url = self.connection_config._sandbox_url + if sandbox_url: + return sandbox_url return f"{'http' if self.connection_config.debug else 'https'}://{self.get_host(JUPYTER_PORT)}" @property @@ -189,7 +194,11 @@ def run_code( context_id = context.id if context else None try: - headers: Dict[str, str] = {"Content-Type": "application/json"} + headers: Dict[str, str] = { + "Content-Type": "application/json", + "E2b-Sandbox-Id": self.sandbox_id, + "E2b-Sandbox-Port": str(JUPYTER_PORT), + } if self._envd_access_token: headers["X-Access-Token"] = self._envd_access_token if self.traffic_access_token: @@ -256,7 +265,11 @@ def create_code_context( data["cwd"] = cwd try: - headers: Dict[str, str] = {"Content-Type": "application/json"} + headers: Dict[str, str] = { + "Content-Type": "application/json", + "E2b-Sandbox-Id": self.sandbox_id, + "E2b-Sandbox-Port": str(JUPYTER_PORT), + } if self._envd_access_token: headers["X-Access-Token"] = self._envd_access_token if self.traffic_access_token: @@ -295,7 +308,11 @@ def remove_code_context( context_id = context.id if isinstance(context, Context) else context try: - headers: Dict[str, str] = {"Content-Type": "application/json"} + headers: Dict[str, str] = { + "Content-Type": "application/json", + "E2b-Sandbox-Id": self.sandbox_id, + "E2b-Sandbox-Port": str(JUPYTER_PORT), + } if self._envd_access_token: headers["X-Access-Token"] = self._envd_access_token if self.traffic_access_token: @@ -323,7 +340,11 @@ def list_code_contexts(self) -> List[Context]: :return: List of contexts. """ try: - headers: Dict[str, str] = {"Content-Type": "application/json"} + headers: Dict[str, str] = { + "Content-Type": "application/json", + "E2b-Sandbox-Id": self.sandbox_id, + "E2b-Sandbox-Port": str(JUPYTER_PORT), + } if self._envd_access_token: headers["X-Access-Token"] = self._envd_access_token if self.traffic_access_token: @@ -361,7 +382,11 @@ def restart_code_context( context_id = context.id if isinstance(context, Context) else context try: - headers: Dict[str, str] = {"Content-Type": "application/json"} + headers: Dict[str, str] = { + "Content-Type": "application/json", + "E2b-Sandbox-Id": self.sandbox_id, + "E2b-Sandbox-Port": str(JUPYTER_PORT), + } if self._envd_access_token: headers["X-Access-Token"] = self._envd_access_token if self.traffic_access_token: diff --git a/python/tests/test_sandbox_url.py b/python/tests/test_sandbox_url.py new file mode 100644 index 00000000..422cc502 --- /dev/null +++ b/python/tests/test_sandbox_url.py @@ -0,0 +1,38 @@ +import pytest + +from e2b.connection_config import ConnectionConfig + +from e2b_code_interpreter import AsyncSandbox, Sandbox + + +def make_sandbox(cls, **config_kwargs): + # Constructing a sandbox instance makes no network requests, so URL + # resolution can be tested without a live sandbox. + return cls( + sandbox_id="test-sandbox-id", + sandbox_domain=None, + envd_version="0.2.0", + envd_access_token=None, + connection_config=ConnectionConfig(**config_kwargs), + ) + + +@pytest.mark.parametrize("cls", [Sandbox, AsyncSandbox]) +async def test_jupyter_url_points_to_sandbox_host_by_default(cls, monkeypatch): + monkeypatch.delenv("E2B_SANDBOX_URL", raising=False) + monkeypatch.delenv("E2B_DEBUG", raising=False) + sandbox = make_sandbox(cls, domain="example.dev") + assert sandbox._jupyter_url == "https://49999-test-sandbox-id.example.dev" + + +@pytest.mark.parametrize("cls", [Sandbox, AsyncSandbox]) +async def test_jupyter_url_honors_sandbox_url_option(cls): + sandbox = make_sandbox(cls, sandbox_url="https://proxy.example.com") + assert sandbox._jupyter_url == "https://proxy.example.com" + + +@pytest.mark.parametrize("cls", [Sandbox, AsyncSandbox]) +async def test_jupyter_url_honors_sandbox_url_env_var(cls, monkeypatch): + monkeypatch.setenv("E2B_SANDBOX_URL", "https://env.example.com") + sandbox = make_sandbox(cls) + assert sandbox._jupyter_url == "https://env.example.com"