diff --git a/.changeset/fast-dogs-brush.md b/.changeset/fast-dogs-brush.md new file mode 100644 index 00000000..74bc63ff --- /dev/null +++ b/.changeset/fast-dogs-brush.md @@ -0,0 +1,6 @@ +--- +'@e2b/code-interpreter-python': patch +'@e2b/code-interpreter': patch +--- + +secure traffic access diff --git a/js/src/sandbox.ts b/js/src/sandbox.ts index 0a87d20f..a02b9d9e 100644 --- a/js/src/sandbox.ts +++ b/js/src/sandbox.ts @@ -223,6 +223,10 @@ export class Sandbox extends BaseSandbox { const headers: Record = { 'Content-Type': 'application/json', } + + if (this.trafficAccessToken) { + headers['E2B-Traffic-Access-Token'] = this.trafficAccessToken + } if (this.envdAccessToken) { headers['X-Access-Token'] = this.envdAccessToken } @@ -296,12 +300,17 @@ export class Sandbox extends BaseSandbox { */ async createCodeContext(opts?: CreateCodeContextOpts): Promise { try { + const headers: Record = { + 'Content-Type': 'application/json', + } + + if (this.trafficAccessToken) { + headers['E2B-Traffic-Access-Token'] = this.trafficAccessToken + } + const res = await fetch(`${this.jupyterUrl}/contexts`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...this.connectionConfig.headers, - }, + headers, body: JSON.stringify({ language: opts?.language, cwd: opts?.cwd, @@ -331,12 +340,17 @@ export class Sandbox extends BaseSandbox { async removeCodeContext(context: Context | string): Promise { try { const id = typeof context === 'string' ? context : context.id + const headers: Record = { + 'Content-Type': 'application/json', + } + + if (this.trafficAccessToken) { + headers['E2B-Traffic-Access-Token'] = this.trafficAccessToken + } + const res = await fetch(`${this.jupyterUrl}/contexts/${id}`, { method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - ...this.connectionConfig.headers, - }, + headers, keepalive: true, signal: this.connectionConfig.getSignal( this.connectionConfig.requestTimeoutMs @@ -359,12 +373,17 @@ export class Sandbox extends BaseSandbox { */ async listCodeContexts(): Promise { try { + const headers: Record = { + 'Content-Type': 'application/json', + } + + if (this.trafficAccessToken) { + headers['E2B-Traffic-Access-Token'] = this.trafficAccessToken + } + const res = await fetch(`${this.jupyterUrl}/contexts`, { method: 'GET', - headers: { - 'Content-Type': 'application/json', - ...this.connectionConfig.headers, - }, + headers, keepalive: true, signal: this.connectionConfig.getSignal( this.connectionConfig.requestTimeoutMs @@ -392,12 +411,17 @@ export class Sandbox extends BaseSandbox { async restartCodeContext(context: Context | string): Promise { try { const id = typeof context === 'string' ? context : context.id + const headers: Record = { + 'Content-Type': 'application/json', + } + + if (this.trafficAccessToken) { + headers['E2B-Traffic-Access-Token'] = this.trafficAccessToken + } + const res = await fetch(`${this.jupyterUrl}/contexts/${id}/restart`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...this.connectionConfig.headers, - }, + headers, keepalive: true, signal: this.connectionConfig.getSignal( this.connectionConfig.requestTimeoutMs diff --git a/js/tests/basic.test.ts b/js/tests/basic.test.ts index c0360d02..2f0b9837 100644 --- a/js/tests/basic.test.ts +++ b/js/tests/basic.test.ts @@ -1,9 +1,14 @@ import { expect } from 'vitest' - -import { sandboxTest } from './setup' +import { isDebug, sandboxTest, secureSandboxTest } from './setup' sandboxTest('basic', async ({ sandbox }) => { const result = await sandbox.runCode('x =1; x') expect(result.text).toEqual('1') }) + +secureSandboxTest.skipIf(isDebug)('secure access', async ({ sandbox }) => { + const result = await sandbox.runCode('x =1; x') + + expect(result.text).toEqual('1') +}) diff --git a/js/tests/contexts.test.ts b/js/tests/contexts.test.ts index 7dda2090..b0cf9567 100644 --- a/js/tests/contexts.test.ts +++ b/js/tests/contexts.test.ts @@ -1,6 +1,6 @@ import { expect } from 'vitest' -import { sandboxTest } from './setup' +import { isDebug, sandboxTest, secureSandboxTest } from './setup' sandboxTest('create context with no options', async ({ sandbox }) => { const context = await sandbox.createCodeContext() @@ -61,3 +61,65 @@ sandboxTest('restart context', async ({ sandbox }) => { expect(execution.error?.name).toBe('NameError') expect(execution.error?.value).toBe("name 'x' is not defined") }) + +secureSandboxTest.skipIf(isDebug)( + 'create context (secure traffic)', + async ({ sandbox }) => { + const context = await sandbox.createCodeContext() + + const contexts = await sandbox.listCodeContexts() + const lastContext = contexts[contexts.length - 1] + + expect(lastContext.id).toBe(context.id) + expect(lastContext.language).toBe(context.language) + expect(lastContext.cwd).toBe(context.cwd) + } +) + +secureSandboxTest.skipIf(isDebug)( + 'remove context (secure traffic)', + async ({ sandbox }) => { + const context = await sandbox.createCodeContext() + + await sandbox.removeCodeContext(context.id) + const contexts = await sandbox.listCodeContexts() + + expect(contexts.map((context) => context.id)).not.toContain(context.id) + + await sandbox.kill() + } +) + +secureSandboxTest.skipIf(isDebug)( + 'list contexts (secure traffic)', + async ({ sandbox }) => { + const contexts = await sandbox.listCodeContexts() + + // default contexts should include python and javascript + expect(contexts.map((context) => context.language)).toContain('python') + expect(contexts.map((context) => context.language)).toContain('javascript') + + await sandbox.kill() + } +) + +secureSandboxTest.skipIf(isDebug)( + 'restart context (secure traffic)', + async ({ sandbox }) => { + const context = await sandbox.createCodeContext() + + // set a variable in the context + await sandbox.runCode('x = 1', { context: context }) + + // restart the context + await sandbox.restartCodeContext(context.id) + + // check that the variable no longer exists + const execution = await sandbox.runCode('x', { context: context }) + + // check for an NameError with message "name 'x' is not defined" + expect(execution.error).toBeDefined() + expect(execution.error?.name).toBe('NameError') + expect(execution.error?.value).toBe("name 'x' is not defined") + } +) diff --git a/js/tests/setup.ts b/js/tests/setup.ts index b11ce4fa..2f4be79c 100644 --- a/js/tests/setup.ts +++ b/js/tests/setup.ts @@ -1,21 +1,31 @@ -import { Sandbox } from '../src' import { test as base } from 'vitest' - -const timeoutMs = 60_000 - -const template = process.env.E2B_TESTS_TEMPLATE || 'code-interpreter-v1' +import { Sandbox, SandboxOpts } from '../src' interface SandboxFixture { sandbox: Sandbox template: string + sandboxTestId: string + sandboxOpts: Partial } +const template = process.env.E2B_TESTS_TEMPLATE || 'code-interpreter-v1' + export const sandboxTest = base.extend({ - sandbox: [ + template, + sandboxTestId: [ // eslint-disable-next-line no-empty-pattern async ({}, use) => { + const id = `test-${generateRandomString()}` + await use(id) + }, + { auto: true }, + ], + sandboxOpts: {}, + sandbox: [ + async ({ sandboxTestId, sandboxOpts }, use) => { const sandbox = await Sandbox.create(template, { - timeoutMs, + metadata: { sandboxTestId }, + ...sandboxOpts, }) try { await use(sandbox) @@ -31,13 +41,30 @@ export const sandboxTest = base.extend({ } } }, - { auto: true }, + { auto: false }, ], - template, }) export const isDebug = process.env.E2B_DEBUG !== undefined +export const isIntegrationTest = process.env.E2B_INTEGRATION_TEST !== undefined + +export const secureSandboxTest = sandboxTest.extend({ + sandboxOpts: { + secure: true, + network: { + allowPublicTraffic: false, + }, + }, +}) + +function generateRandomString(length: number = 8): string { + return Math.random() + .toString(36) + .substring(2, length + 2) +} export async function wait(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } + +export { template } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e3f8587..c1caf8b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1575,6 +1575,7 @@ packages: source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} diff --git a/python/e2b_code_interpreter/code_interpreter_async.py b/python/e2b_code_interpreter/code_interpreter_async.py index 98f59dd0..de938240 100644 --- a/python/e2b_code_interpreter/code_interpreter_async.py +++ b/python/e2b_code_interpreter/code_interpreter_async.py @@ -190,12 +190,15 @@ async def run_code( timeout = None if timeout == 0 else (timeout or DEFAULT_TIMEOUT) request_timeout = request_timeout or self.connection_config.request_timeout context_id = context.id if context else None - - headers: Dict[str, str] = {} - if self._envd_access_token: - headers = {"X-Access-Token": self._envd_access_token} - try: + headers = { + "Content-Type": "application/json", + } + 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 + async with self._client.stream( "POST", f"{self._jupyter_url}/execute", @@ -253,11 +256,13 @@ async def create_code_context( if cwd: data["cwd"] = cwd - headers: Dict[str, str] = {} - if self._envd_access_token: - headers = {"X-Access-Token": self._envd_access_token} - try: + headers = { + "Content-Type": "application/json", + } + if self.traffic_access_token: + headers["E2B-Traffic-Access-Token"] = self.traffic_access_token + response = await self._client.post( f"{self._jupyter_url}/contexts", headers=headers, @@ -287,11 +292,13 @@ async def remove_code_context( """ context_id = context.id if isinstance(context, Context) else context - headers: Dict[str, str] = {} - if self._envd_access_token: - headers = {"X-Access-Token": self._envd_access_token} - try: + headers = { + "Content-Type": "application/json", + } + if self.traffic_access_token: + headers["E2B-Traffic-Access-Token"] = self.traffic_access_token + response = await self._client.delete( f"{self._jupyter_url}/contexts/{context_id}", headers=headers, @@ -310,11 +317,13 @@ async def list_code_contexts(self) -> List[Context]: :return: List of contexts. """ - headers: Dict[str, str] = {} - if self._envd_access_token: - headers = {"X-Access-Token": self._envd_access_token} - try: + headers = { + "Content-Type": "application/json", + } + if self.traffic_access_token: + headers["E2B-Traffic-Access-Token"] = self.traffic_access_token + response = await self._client.get( f"{self._jupyter_url}/contexts", headers=headers, @@ -342,12 +351,13 @@ async def restart_code_context( :return: None """ context_id = context.id if isinstance(context, Context) else context - - headers: Dict[str, str] = {} - if self._envd_access_token: - headers = {"X-Access-Token": self._envd_access_token} - try: + headers = { + "Content-Type": "application/json", + } + if self.traffic_access_token: + headers["E2B-Traffic-Access-Token"] = self.traffic_access_token + response = await self._client.post( f"{self._jupyter_url}/contexts/{context_id}/restart", headers=headers, diff --git a/python/e2b_code_interpreter/code_interpreter_sync.py b/python/e2b_code_interpreter/code_interpreter_sync.py index 67492398..3adb2804 100644 --- a/python/e2b_code_interpreter/code_interpreter_sync.py +++ b/python/e2b_code_interpreter/code_interpreter_sync.py @@ -188,11 +188,13 @@ def run_code( request_timeout = request_timeout or self.connection_config.request_timeout context_id = context.id if context else None - headers: Dict[str, str] = {} - if self._envd_access_token: - headers = {"X-Access-Token": self._envd_access_token} - try: + headers: Dict[str, str] = {"Content-Type": "application/json"} + 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 + with self._client.stream( "POST", f"{self._jupyter_url}/execute", @@ -250,11 +252,13 @@ def create_code_context( if cwd: data["cwd"] = cwd - headers: Dict[str, str] = {} - if self._envd_access_token: - headers = {"X-Access-Token": self._envd_access_token} - try: + headers: Dict[str, str] = {"Content-Type": "application/json"} + 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 + response = self._client.post( f"{self._jupyter_url}/contexts", json=data, @@ -284,11 +288,13 @@ def remove_code_context( """ context_id = context.id if isinstance(context, Context) else context - headers: Dict[str, str] = {} - if self._envd_access_token: - headers = {"X-Access-Token": self._envd_access_token} - try: + headers: Dict[str, str] = {"Content-Type": "application/json"} + 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 + response = self._client.delete( f"{self._jupyter_url}/contexts/{context_id}", headers=headers, @@ -307,11 +313,13 @@ def list_code_contexts(self) -> List[Context]: :return: List of contexts. """ - headers: Dict[str, str] = {} - if self._envd_access_token: - headers = {"X-Access-Token": self._envd_access_token} - try: + headers: Dict[str, str] = {"Content-Type": "application/json"} + 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 + response = self._client.get( f"{self._jupyter_url}/contexts", headers=headers, @@ -340,11 +348,13 @@ def restart_code_context( """ context_id = context.id if isinstance(context, Context) else context - headers: Dict[str, str] = {} - if self._envd_access_token: - headers = {"X-Access-Token": self._envd_access_token} - try: + headers: Dict[str, str] = {"Content-Type": "application/json"} + 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 + response = self._client.post( f"{self._jupyter_url}/contexts/{context_id}/restart", headers=headers, diff --git a/python/tests/async/test_async_basic.py b/python/tests/async/test_async_basic.py index 486fd219..cf1b0bb1 100644 --- a/python/tests/async/test_async_basic.py +++ b/python/tests/async/test_async_basic.py @@ -1,6 +1,16 @@ +import pytest + from e2b_code_interpreter.code_interpreter_async import AsyncSandbox async def test_basic(async_sandbox: AsyncSandbox): result = await async_sandbox.run_code("x =1; x") assert result.text == "1" + + +@pytest.mark.skip_debug +async def test_secure_access(async_sandbox_factory): + # Create sandbox with public traffic disabled (secure access) + async_sandbox = await async_sandbox_factory(network={"allow_public_traffic": False}) + result = await async_sandbox.run_code("x =1; x") + assert result.text == "1" diff --git a/python/tests/async/test_async_contexts.py b/python/tests/async/test_async_contexts.py index 6be07734..0f533f8f 100644 --- a/python/tests/async/test_async_contexts.py +++ b/python/tests/async/test_async_contexts.py @@ -1,3 +1,5 @@ +import pytest + from e2b_code_interpreter.code_interpreter_async import AsyncSandbox @@ -60,3 +62,66 @@ async def test_restart_context(async_sandbox: AsyncSandbox): assert execution.error is not None assert execution.error.name == "NameError" assert execution.error.value == "name 'x' is not defined" + + +@pytest.mark.skip_debug +async def test_create_context_secure_traffic(async_sandbox_factory): + async_sandbox = await async_sandbox_factory( + secure=True, network={"allow_public_traffic": False} + ) + context = await async_sandbox.create_code_context() + + contexts = await async_sandbox.list_code_contexts() + last_context = contexts[-1] + + assert last_context.id == context.id + assert last_context.language == context.language + assert last_context.cwd == context.cwd + + +@pytest.mark.skip_debug +async def test_remove_context_secure_traffic(async_sandbox_factory): + async_sandbox = await async_sandbox_factory( + secure=True, network={"allow_public_traffic": False} + ) + context = await async_sandbox.create_code_context() + + await async_sandbox.remove_code_context(context.id) + + contexts = await async_sandbox.list_code_contexts() + assert context.id not in [ctx.id for ctx in contexts] + + +@pytest.mark.skip_debug +async def test_list_contexts_secure_traffic(async_sandbox_factory): + async_sandbox = await async_sandbox_factory( + secure=True, network={"allow_public_traffic": False} + ) + contexts = await async_sandbox.list_code_contexts() + + # default contexts should include python and javascript + languages = [context.language for context in contexts] + assert "python" in languages + assert "javascript" in languages + + +@pytest.mark.skip_debug +async def test_restart_context_secure_traffic(async_sandbox_factory): + async_sandbox = await async_sandbox_factory( + secure=True, network={"allow_public_traffic": False} + ) + context = await async_sandbox.create_code_context() + + # set a variable in the context + await async_sandbox.run_code("x = 1", context=context) + + # restart the context + await async_sandbox.restart_code_context(context.id) + + # check that the variable no longer exists + execution = await async_sandbox.run_code("x", context=context) + + # check for a NameError with message "name 'x' is not defined" + assert execution.error is not None + assert execution.error.name == "NameError" + assert execution.error.value == "name 'x' is not defined" diff --git a/python/tests/conftest.py b/python/tests/conftest.py index bc853e85..a571e00a 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -1,27 +1,18 @@ -import pytest -import pytest_asyncio -import os import asyncio +import os -from logging import warning - -from e2b_code_interpreter.code_interpreter_async import AsyncSandbox -from e2b_code_interpreter.code_interpreter_sync import Sandbox +import pytest -timeout = 60 +from e2b_code_interpreter import ( + AsyncSandbox, + Sandbox, +) +import uuid -# Override the event loop so it never closes during test execution -# This helps with pytest-xdist and prevents "Event loop is closed" errors @pytest.fixture(scope="session") -def event_loop(): - """Create a session-scoped event loop for all async tests.""" - try: - loop = asyncio.get_running_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - yield loop - loop.close() +def sandbox_test_id(): + return f"test_{uuid.uuid4()}" @pytest.fixture() @@ -30,45 +21,58 @@ def template(): @pytest.fixture() -def sandbox(template, debug): - sandbox = Sandbox.create(template, timeout=timeout, debug=debug) +def sandbox_factory(request, template, sandbox_test_id): + def factory(*, template_name: str = template, **kwargs): + kwargs.setdefault("secure", False) + kwargs.setdefault("timeout", 60) + metadata = kwargs.setdefault("metadata", dict()) + metadata.setdefault("sandbox_test_id", sandbox_test_id) + + sandbox = Sandbox.create(template_name, **kwargs) + + request.addfinalizer(lambda: sandbox.kill()) + + return sandbox + + return factory + + +@pytest.fixture() +def sandbox(sandbox_factory): + return sandbox_factory() + + +# override the event loop so it never closes +# this helps us with the global-scoped async http transport +@pytest.fixture(scope="session") +def event_loop(): try: - yield sandbox - finally: - try: - sandbox.kill() - except: # noqa: E722 - if not debug: - warning( - "Failed to kill sandbox — this is expected if the test runs with local envd." - ) + loop = asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + yield loop + loop.close() @pytest.fixture -def async_sandbox_factory(request, template, debug, event_loop): - """Factory for creating async sandboxes with proper cleanup.""" +def async_sandbox_factory(request, template, sandbox_test_id, event_loop): + async def factory(*, template_name: str = template, **kwargs): + kwargs.setdefault("timeout", 60) - async def factory(template_override=None, **kwargs): - template_name = template_override or template - kwargs.setdefault("timeout", timeout) - kwargs.setdefault("debug", debug) + metadata = kwargs.setdefault("metadata", dict()) + metadata.setdefault("sandbox_test_id", sandbox_test_id) sandbox = await AsyncSandbox.create(template_name, **kwargs) def kill(): async def _kill(): - try: - await sandbox.kill() - except: # noqa: E722 - if not debug: - warning( - "Failed to kill sandbox — this is expected if the test runs with local envd." - ) + await sandbox.kill() event_loop.run_until_complete(_kill()) request.addfinalizer(kill) + return sandbox return factory @@ -76,7 +80,6 @@ async def _kill(): @pytest.fixture async def async_sandbox(async_sandbox_factory): - """Default async sandbox fixture.""" return await async_sandbox_factory() diff --git a/python/tests/sync/test_basic.py b/python/tests/sync/test_basic.py index 4bfc503d..30f94983 100644 --- a/python/tests/sync/test_basic.py +++ b/python/tests/sync/test_basic.py @@ -1,6 +1,16 @@ +import pytest + from e2b_code_interpreter.code_interpreter_sync import Sandbox def test_basic(sandbox: Sandbox): result = sandbox.run_code("x =1; x") assert result.text == "1" + + +@pytest.mark.skip_debug +def test_secure_access(sandbox_factory): + sandbox = sandbox_factory(secure=True, network={"allow_public_traffic": False}) + # Create sandbox with public traffic disabled (secure access) + result = sandbox.run_code("x =1; x") + assert result.text == "1" diff --git a/python/tests/sync/test_contexts.py b/python/tests/sync/test_contexts.py index a7cbd884..0b8278f5 100644 --- a/python/tests/sync/test_contexts.py +++ b/python/tests/sync/test_contexts.py @@ -1,3 +1,5 @@ +import pytest + from e2b_code_interpreter.code_interpreter_sync import Sandbox @@ -60,3 +62,59 @@ def test_restart_context(sandbox: Sandbox): assert execution.error is not None assert execution.error.name == "NameError" assert execution.error.value == "name 'x' is not defined" + + +# Secure traffic tests (public traffic disabled) +@pytest.mark.skip_debug +def test_create_context_secure_traffic(sandbox_factory): + sandbox = sandbox_factory(secure=True, network={"allow_public_traffic": False}) + context = sandbox.create_code_context() + + contexts = sandbox.list_code_contexts() + last_context = contexts[-1] + + assert last_context.id == context.id + assert last_context.language == context.language + assert last_context.cwd == context.cwd + + +@pytest.mark.skip_debug +def test_remove_context_secure_traffic(sandbox_factory): + sandbox = sandbox_factory(secure=True, network={"allow_public_traffic": False}) + context = sandbox.create_code_context() + + sandbox.remove_code_context(context.id) + + contexts = sandbox.list_code_contexts() + assert context.id not in [ctx.id for ctx in contexts] + + +@pytest.mark.skip_debug +def test_list_contexts_secure_traffic(sandbox_factory): + sandbox = sandbox_factory(secure=True, network={"allow_public_traffic": False}) + contexts = sandbox.list_code_contexts() + + # default contexts should include python and javascript + languages = [context.language for context in contexts] + assert "python" in languages + assert "javascript" in languages + + +@pytest.mark.skip_debug +def test_restart_context_secure_traffic(sandbox_factory): + sandbox = sandbox_factory(secure=True, network={"allow_public_traffic": False}) + context = sandbox.create_code_context() + + # set a variable in the context + sandbox.run_code("x = 1", context=context) + + # restart the context + sandbox.restart_code_context(context.id) + + # check that the variable no longer exists + execution = sandbox.run_code("x", context=context) + + # check for a NameError with message "name 'x' is not defined" + assert execution.error is not None + assert execution.error.name == "NameError" + assert execution.error.value == "name 'x' is not defined"