From 21a34cc3cc45e7674d0740cf3f09cb2d3e14be26 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:48:07 +0000 Subject: [PATCH 1/4] feat: authenticate interpreter requests and use private Jupyter transport Co-Authored-By: mish@e2b.dev --- .github/workflows/lint.yml | 3 + template/README.md | 27 +++++ template/jupyter-healthcheck.sh | 2 +- template/jupyter_server_config.py | 7 +- template/pytest.ini | 4 + template/requirements-test.txt | 5 + template/server/auth.py | 94 +++++++++++++++++ template/server/consts.py | 3 +- template/server/envs.py | 13 ++- template/server/main.py | 23 ++++- template/server/messaging.py | 18 ++-- template/start-up.sh | 2 + template/systemd/code-interpreter.service | 2 + template/systemd/jupyter.service | 2 + template/tests/test_auth.py | 119 ++++++++++++++++++++++ template/tests/test_jupyter_socket.py | 96 +++++++++++++++++ 16 files changed, 402 insertions(+), 18 deletions(-) create mode 100644 template/pytest.ini create mode 100644 template/requirements-test.txt create mode 100644 template/server/auth.py create mode 100644 template/tests/test_auth.py create mode 100644 template/tests/test_jupyter_socket.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3343ed6d..64d2a3c8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -57,6 +57,9 @@ jobs: run: | pnpm run lint + - name: Test interpreter authentication and Jupyter transport + run: uv run --with-requirements template/requirements-test.txt pytest template/tests + - name: Run formatting run: | pnpm run format diff --git a/template/README.md b/template/README.md index 4f64fabe..b606371a 100644 --- a/template/README.md +++ b/template/README.md @@ -1,5 +1,32 @@ # Code Interpreter +## Authentication and networking + +The template requires envd 0.9.1 or later with the local token delivery socket. +The server receives a SHA-512 token verifier over `/run/e2b-auth/token.sock` and +validates `X-Access-Token` locally. It rejects requests before provisioning and +after disconnecting. `/health` needs no bearer token, but remains unavailable +until the auth connection is established. Tokenless sandboxes cannot execute code. + +The systemd service creates `/etc/e2b/code-interpreter-auth` when first started. +This marker contains no credentials; it makes envd wait for token delivery to be +acknowledged during initialization, including resume and fork. The auth connection +is also reestablished after an interpreter or envd restart. + +Jupyter listens only on `/run/e2b-jupyter/server.sock` (mode `0600`, directory +mode `0700`), including kernel WebSocket channels. Port `49999` remains the +authenticated interpreter API; Jupyter no longer listens on port `8888`. + +Deploy envd and SDK context-auth updates before rebuilding this template. Local +Docker development explicitly sets `E2B_LOCAL=true`, which disables auth; do not +use that setting in deployed templates. + +Run the server tests locally with: + +```sh +uv run --with-requirements template/requirements-test.txt pytest template/tests +``` + ## Building the production template To build the official `code-interpreter-v1` template from this repo, use diff --git a/template/jupyter-healthcheck.sh b/template/jupyter-healthcheck.sh index a4c00131..e604a06c 100644 --- a/template/jupyter-healthcheck.sh +++ b/template/jupyter-healthcheck.sh @@ -6,7 +6,7 @@ MAX_RETRIES=50 RETRY_INTERVAL=0.2 for i in $(seq 1 $MAX_RETRIES); do - status_code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:8888/api/status") + status_code=$(curl --unix-socket /run/e2b-jupyter/server.sock -s -o /dev/null -w "%{http_code}" "http://localhost/api/status") if [ "$status_code" -eq 200 ]; then echo "Jupyter Server is healthy" diff --git a/template/jupyter_server_config.py b/template/jupyter_server_config.py index 663682cc..4d6985dc 100644 --- a/template/jupyter_server_config.py +++ b/template/jupyter_server_config.py @@ -2,6 +2,9 @@ c = get_config() # noqa +c.ServerApp.sock = "/run/e2b-jupyter/server.sock" +c.ServerApp.sock_mode = "0600" + # Pin the contents root directory. # @@ -22,7 +25,7 @@ # # Takes precedence over allow_origin_pat. # Default: '' -c.ServerApp.allow_origin = "*" +c.ServerApp.allow_origin = "" # Allow requests where the Host header doesn't point to a local server @@ -38,7 +41,7 @@ # Local IP addresses (such as 127.0.0.1 and ::1) are allowed as local, # along with hostnames configured in local_hostnames. # Default: False -c.ServerApp.allow_remote_access = True +c.ServerApp.allow_remote_access = False # Disable cross-site-request-forgery protection # diff --git a/template/pytest.ini b/template/pytest.ini new file mode 100644 index 00000000..e2378df8 --- /dev/null +++ b/template/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +pythonpath = server +asyncio_mode = auto +testpaths = tests diff --git a/template/requirements-test.txt b/template/requirements-test.txt new file mode 100644 index 00000000..134baa42 --- /dev/null +++ b/template/requirements-test.txt @@ -0,0 +1,5 @@ +-r server/requirements.txt +pytest==9.0.3 +pytest-asyncio==1.3.0 +jupyter-server==2.20.0 +ipykernel==6.31.0 diff --git a/template/server/auth.py b/template/server/auth.py new file mode 100644 index 00000000..574ecc83 --- /dev/null +++ b/template/server/auth.py @@ -0,0 +1,94 @@ +import asyncio +import hashlib +import hmac +import logging +from contextlib import suppress + +from starlette.requests import Request +from starlette.responses import PlainTextResponse + +TOKEN_SOCKET = "/run/e2b-auth/token.sock" +logger = logging.getLogger(__name__) + + +class AccessToken: + def __init__(self): + self.digest = None + self.connected = False + + def accepts(self, token: str) -> bool: + return bool( + self.digest + and token + and hmac.compare_digest( + self.digest, hashlib.sha512(token.encode("utf-8")).digest() + ) + ) + + async def subscribe(self): + while True: + writer = None + try: + reader, writer = await asyncio.open_unix_connection( + TOKEN_SOCKET, limit=129 + ) + while True: + line = await reader.readline() + if line == b"\n": + self.digest = None + elif len(line) == 129 and line.endswith(b"\n"): + digest = bytes.fromhex(line[:-1].decode("ascii")) + if len(digest) != 64: + raise ValueError("Invalid token verifier") + self.digest = digest + else: + raise ValueError("Invalid token verifier") + self.connected = True + writer.write(b"+") + await writer.drain() + except (OSError, ValueError, UnicodeError): + logger.warning("Local authentication connection unavailable") + finally: + self.digest = None + self.connected = False + if writer is not None: + writer.close() + with suppress(OSError): + await writer.wait_closed() + await asyncio.sleep(1) + + +class EnvdAuthMiddleware: + def __init__(self, app, access_token: AccessToken, local: bool = False): + self.app = app + self.access_token = access_token + self.local = local + + async def __call__(self, scope, receive, send): + if ( + self.local + or scope["type"] not in ("http", "websocket") + or ( + scope["type"] == "http" + and scope["method"] == "GET" + and scope["path"] == "/health" + ) + ): + await self.app(scope, receive, send) + return + + if scope["type"] == "websocket": + await send({"type": "websocket.close", "code": 1008}) + return + + token = Request(scope).headers.get("X-Access-Token", "") + if not self.access_token.accepts(token): + status = 401 if self.access_token.digest else 503 + response = PlainTextResponse( + "Unauthorized" if status == 401 else "Authentication unavailable", + status_code=status, + ) + await response(scope, receive, send) + return + + await self.app(scope, receive, send) diff --git a/template/server/consts.py b/template/server/consts.py index e1b92a06..c861dc78 100644 --- a/template/server/consts.py +++ b/template/server/consts.py @@ -1 +1,2 @@ -JUPYTER_BASE_URL = "http://localhost:8888" +JUPYTER_SOCKET_PATH = "/run/e2b-jupyter/server.sock" +JUPYTER_BASE_URL = "http://localhost" diff --git a/template/server/envs.py b/template/server/envs.py index 8752e7b9..b12ebb5f 100644 --- a/template/server/envs.py +++ b/template/server/envs.py @@ -3,18 +3,25 @@ import httpx -LOCAL = os.getenv("E2B_LOCAL", False) +LOCAL = os.getenv("E2B_LOCAL", "").lower() == "true" ENVD_PORT = 49983 async def get_envs(access_token: Optional[str]) -> dict: if LOCAL: return {"E2B_TEST_VARIABLE": "true"} - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(trust_env=False) as client: headers = {} if access_token: headers["X-Access-Token"] = f"{access_token}" response = await client.get( f"http://localhost:{ENVD_PORT}/envs", headers=headers ) - return response.json() + response.raise_for_status() + envs = response.json() + if not isinstance(envs, dict) or not all( + isinstance(key, str) and isinstance(value, str) + for key, value in envs.items() + ): + raise ValueError("Invalid environment variables from envd") + return envs diff --git a/template/server/main.py b/template/server/main.py index 59215f57..1ee3fd40 100644 --- a/template/server/main.py +++ b/template/server/main.py @@ -1,10 +1,11 @@ +import asyncio import logging import sys import httpx from typing import Dict, Union, Literal, List -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, suppress from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import PlainTextResponse @@ -12,8 +13,10 @@ from api.models.context import Context from api.models.create_context import CreateContext from api.models.execution_request import ExecutionRequest -from consts import JUPYTER_BASE_URL +from auth import AccessToken, EnvdAuthMiddleware +from consts import JUPYTER_BASE_URL, JUPYTER_SOCKET_PATH from contexts import create_context, normalize_language +from envs import LOCAL from messaging import ContextWebSocket from stream import StreamingListJsonResponse from utils.locks import LockedMap @@ -27,12 +30,16 @@ websockets: Dict[Union[str, Literal["default"]], ContextWebSocket] = {} default_websockets = LockedMap() global client +access_token = AccessToken() @asynccontextmanager async def lifespan(app: FastAPI): global client - client = httpx.AsyncClient() + auth_task = None if LOCAL else asyncio.create_task(access_token.subscribe()) + client = httpx.AsyncClient( + transport=httpx.AsyncHTTPTransport(uds=JUPYTER_SOCKET_PATH), trust_env=False + ) try: python_context = await create_context( @@ -57,10 +64,16 @@ async def lifespan(app: FastAPI): except Exception as e: logger.error(f"Failed to initialize default context: {e}") raise + finally: + if auth_task is not None: + auth_task.cancel() + with suppress(asyncio.CancelledError): + await auth_task app = FastAPI(lifespan=lifespan) +app.add_middleware(EnvdAuthMiddleware, access_token=access_token, local=LOCAL) app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -73,6 +86,8 @@ async def lifespan(app: FastAPI): @app.get("/health") async def get_health(): + if not LOCAL and not access_token.connected: + return PlainTextResponse("Authentication unavailable", status_code=503) return "OK" @@ -122,7 +137,7 @@ async def post_execute(request: Request, exec_request: ExecutionRequest): ws.execute( exec_request.code, env_vars=exec_request.env_vars, - access_token=request.headers.get("X-Access-Token", None), + access_token=request.headers.get("X-Access-Token"), ) ) diff --git a/template/server/messaging.py b/template/server/messaging.py index c51f8b21..954dba7b 100644 --- a/template/server/messaging.py +++ b/template/server/messaging.py @@ -13,7 +13,7 @@ Union, ) from pydantic import StrictStr -from websockets.client import WebSocketClientProtocol, connect +from websockets.client import WebSocketClientProtocol, unix_connect from websockets.exceptions import ( ConnectionClosedError, WebSocketException, @@ -28,7 +28,7 @@ OutputType, UnexpectedEndOfExecution, ) -from consts import JUPYTER_BASE_URL +from consts import JUPYTER_BASE_URL, JUPYTER_SOCKET_PATH from errors import ExecutionError from envs import get_envs @@ -67,7 +67,7 @@ def __init__(self, context_id: str, session_id: str, language: str, cwd: str): self.language = language self.cwd = cwd self.context_id = context_id - self.url = f"ws://localhost:8888/api/kernels/{context_id}/channels" + self.url = f"ws://localhost/api/kernels/{context_id}/channels" self.session_id = session_id self._executions: Dict[str, Execution] = {} self._lock = asyncio.Lock() @@ -87,8 +87,9 @@ async def connect(self): ws_logger = logger.getChild("websockets.client") ws_logger.setLevel(logging.ERROR) - self._ws = await connect( - self.url, + self._ws = await unix_connect( + JUPYTER_SOCKET_PATH, + uri=self.url, ping_timeout=PING_TIMEOUT, max_size=None, max_queue=None, @@ -104,7 +105,10 @@ async def connect(self): async def interrupt(self): """Interrupt the current kernel execution via the Jupyter REST API.""" try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient( + transport=httpx.AsyncHTTPTransport(uds=JUPYTER_SOCKET_PATH), + trust_env=False, + ) as client: response = await client.post( f"{JUPYTER_BASE_URL}/api/kernels/{self.context_id}/interrupt" ) @@ -319,7 +323,7 @@ async def execute( self, code: Union[str, StrictStr], env_vars: Dict[StrictStr, str], - access_token: str, + access_token: Optional[str], ): if self._ws is None: raise Exception("WebSocket not connected") diff --git a/template/start-up.sh b/template/start-up.sh index 5786b039..0ea0494d 100755 --- a/template/start-up.sh +++ b/template/start-up.sh @@ -1,5 +1,7 @@ #!/bin/bash +install -d -m 0700 /run/e2b-jupyter + function start_code_interpreter() { /root/.jupyter/jupyter-healthcheck.sh if [ $? -ne 0 ]; then diff --git a/template/systemd/code-interpreter.service b/template/systemd/code-interpreter.service index 04c165b4..ba04808c 100644 --- a/template/systemd/code-interpreter.service +++ b/template/systemd/code-interpreter.service @@ -9,6 +9,8 @@ StartLimitBurst=0 [Service] Type=simple WorkingDirectory=/root/.server +ExecStartPre=/usr/bin/install -d -m 0755 /etc/e2b +ExecStartPre=/usr/bin/touch /etc/e2b/code-interpreter-auth ExecStartPre=/root/.jupyter/jupyter-healthcheck.sh ExecStart=/root/.server/.venv/bin/uvicorn main:app --host 0.0.0.0 --port 49999 --workers 1 --no-access-log --no-use-colors --timeout-keep-alive 640 Restart=on-failure diff --git a/template/systemd/jupyter.service b/template/systemd/jupyter.service index 37b83f29..178124bd 100644 --- a/template/systemd/jupyter.service +++ b/template/systemd/jupyter.service @@ -6,6 +6,8 @@ StartLimitBurst=0 [Service] Type=simple +RuntimeDirectory=e2b-jupyter +RuntimeDirectoryMode=0700 Environment=MATPLOTLIBRC=/root/.config/matplotlib/.matplotlibrc ExecStart=/usr/local/bin/jupyter server --IdentityProvider.token="" ExecStartPost=-/usr/bin/systemctl reset-failed code-interpreter diff --git a/template/tests/test_auth.py b/template/tests/test_auth.py new file mode 100644 index 00000000..d95a63dd --- /dev/null +++ b/template/tests/test_auth.py @@ -0,0 +1,119 @@ +import asyncio +import hashlib +from contextlib import suppress + +import httpx +import pytest +from anyio import fail_after +from starlette.testclient import TestClient +from starlette.websockets import WebSocketDisconnect + +import auth +import main + + +@pytest.fixture +async def client(monkeypatch): + monkeypatch.setattr(main.access_token, "digest", None) + monkeypatch.setattr(main.access_token, "connected", False) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=main.app), base_url="http://interpreter" + ) as client: + yield client + + +@pytest.mark.parametrize( + "method,path", + [ + ("POST", "/execute"), + ("POST", "/contexts"), + ("GET", "/contexts"), + ("DELETE", "/contexts/missing"), + ("POST", "/contexts/missing/restart"), + ("GET", "/docs"), + ], +) +async def test_endpoints_require_current_token(client, method, path): + response = await client.request(method, path) + assert response.status_code == 503 + main.access_token.digest = hashlib.sha512(b"sandbox-token").digest() + for token in ("", "incorrect", main.access_token.digest.hex()): + response = await client.request(method, path, headers={"X-Access-Token": token}) + assert response.status_code == 401 + + +async def test_health_requires_delivery_connection_but_no_bearer(client): + assert (await client.get("/health")).status_code == 503 + main.access_token.connected = True + assert (await client.get("/health")).status_code == 200 + assert (await client.get("/contexts")).status_code == 503 + + +def test_public_websockets_are_not_exposed(): + with pytest.raises(WebSocketDisconnect) as exc: + with TestClient(main.app).websocket_connect("/api/kernels/kernel/channels"): + pytest.fail("Jupyter must not be exposed through the public server") + assert exc.value.code == 1008 + + +async def test_socket_updates_revoke_old_token_and_disconnect_fails_closed( + client, tmp_path, monkeypatch +): + path = str(tmp_path / "auth.sock") + monkeypatch.setattr(auth, "TOKEN_SOCKET", path) + connections = asyncio.Queue() + + async def connected(reader, writer): + await connections.put((reader, writer)) + + server = await asyncio.start_unix_server(connected, path=path) + task = asyncio.create_task(main.access_token.subscribe()) + try: + reader, writer = await asyncio.wait_for(connections.get(), 2) + for current, previous in ( + ("parent-token", "wrong"), + ("child-token", "parent-token"), + ): + writer.write(hashlib.sha512(current.encode()).hexdigest().encode() + b"\n") + await writer.drain() + assert await asyncio.wait_for(reader.readexactly(1), 2) == b"+" + assert ( + await client.get("/contexts", headers={"X-Access-Token": current}) + ).status_code == 200 + assert ( + await client.get("/contexts", headers={"X-Access-Token": previous}) + ).status_code == 401 + + writer.close() + await writer.wait_closed() + with fail_after(2): + while main.access_token.connected: + await asyncio.sleep(0.01) + assert ( + await client.get("/contexts", headers={"X-Access-Token": "child-token"}) + ).status_code == 503 + + reader, writer = await asyncio.wait_for(connections.get(), 3) + writer.write(hashlib.sha512(b"child-token").hexdigest().encode() + b"\n") + await writer.drain() + assert await asyncio.wait_for(reader.readexactly(1), 2) == b"+" + assert ( + await client.get("/contexts", headers={"X-Access-Token": "child-token"}) + ).status_code == 200 + + writer.write(b"\n") + await writer.drain() + assert await asyncio.wait_for(reader.readexactly(1), 2) == b"+" + assert (await client.get("/contexts")).status_code == 503 + writer.write(b"invalid verifier\n") + await writer.drain() + assert await asyncio.wait_for(reader.read(), 2) == b"" + assert (await client.get("/health")).status_code == 503 + writer.close() + await writer.wait_closed() + finally: + task.cancel() + with suppress(asyncio.CancelledError): + await task + server.close() + await server.wait_closed() diff --git a/template/tests/test_jupyter_socket.py b/template/tests/test_jupyter_socket.py new file mode 100644 index 00000000..250aed45 --- /dev/null +++ b/template/tests/test_jupyter_socket.py @@ -0,0 +1,96 @@ +import asyncio +import os +import socket +import sys +from pathlib import Path + +import httpx +from anyio import fail_after + +import envs +import messaging +from api.models.output import OutputType +from messaging import ContextWebSocket + + +async def test_jupyter_http_and_execution_use_unix_socket_only(tmp_path, monkeypatch): + path = str(tmp_path / "jupyter.sock") + config = Path(__file__).resolve().parents[1] / "jupyter_server_config.py" + monkeypatch.setattr(messaging, "JUPYTER_SOCKET_PATH", path) + monkeypatch.setattr(envs, "LOCAL", True) + with (tmp_path / "jupyter.log").open("w+") as logs: + process = await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "jupyter_server", + f"--config={config}", + f"--ServerApp.sock={path}", + f"--ServerApp.root_dir={tmp_path}", + "--IdentityProvider.token=", + stdout=logs, + stderr=logs, + env={ + **os.environ, + "IPYTHONDIR": str(tmp_path / "ipython"), + "JUPYTER_RUNTIME_DIR": str(tmp_path), + }, + ) + ws = None + try: + async with httpx.AsyncClient( + transport=httpx.AsyncHTTPTransport(uds=path), + base_url="http://localhost", + trust_env=False, + ) as client: + with fail_after(30): + while True: + if process.returncode is not None: + logs.seek(0) + raise AssertionError(logs.read()) + try: + response = await client.get("/api/status") + if response.status_code == 200: + break + except httpx.RequestError: + pass + await asyncio.sleep(0.1) + + assert Path(path).stat().st_mode & 0o777 == 0o600 + with socket.socket() as tcp: + assert tcp.connect_ex(("127.0.0.1", 8888)) != 0 + + response = await client.post("/api/kernels", json={"name": "python3"}) + response.raise_for_status() + kernel_id = response.json()["id"] + ws = ContextWebSocket(kernel_id, "session", "python", str(tmp_path)) + await ws.connect() + with fail_after(30): + outputs = [item async for item in ws.execute("6 * 7", {}, None)] + assert any( + item["type"] == OutputType.RESULT and item["text"] == "42" + for item in outputs + ), outputs + outputs = [] + with fail_after(10): + async for item in ws.execute( + 'print("started", flush=True)\nimport time\ntime.sleep(60)', + {}, + None, + ): + outputs.append(item) + if item["type"] == OutputType.STDOUT: + await ws.interrupt() + assert any( + item["type"] == OutputType.ERROR + and item["name"] == "KeyboardInterrupt" + for item in outputs + ), outputs + assert ( + await client.delete(f"/api/kernels/{kernel_id}") + ).status_code == 204 + finally: + if ws is not None: + await ws.close() + if process.returncode is None: + process.terminate() + await process.wait() From 0e1d452dddb82a8e8ca60ef853d54e6737b04131 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:50:29 +0000 Subject: [PATCH 2/4] refactor: split Jupyter isolation into a separate change Co-Authored-By: mish@e2b.dev --- .github/workflows/lint.yml | 2 +- template/README.md | 52 +++++++-------- template/jupyter-healthcheck.sh | 2 +- template/jupyter_server_config.py | 7 +- template/server/consts.py | 3 +- template/server/main.py | 6 +- template/server/messaging.py | 16 ++--- template/start-up.sh | 2 - template/systemd/jupyter.service | 2 - template/tests/test_jupyter_socket.py | 96 --------------------------- 10 files changed, 38 insertions(+), 150 deletions(-) delete mode 100644 template/tests/test_jupyter_socket.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 64d2a3c8..10bcc55d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -57,7 +57,7 @@ jobs: run: | pnpm run lint - - name: Test interpreter authentication and Jupyter transport + - name: Test interpreter server run: uv run --with-requirements template/requirements-test.txt pytest template/tests - name: Run formatting diff --git a/template/README.md b/template/README.md index b606371a..209f5a9a 100644 --- a/template/README.md +++ b/template/README.md @@ -1,32 +1,5 @@ # Code Interpreter -## Authentication and networking - -The template requires envd 0.9.1 or later with the local token delivery socket. -The server receives a SHA-512 token verifier over `/run/e2b-auth/token.sock` and -validates `X-Access-Token` locally. It rejects requests before provisioning and -after disconnecting. `/health` needs no bearer token, but remains unavailable -until the auth connection is established. Tokenless sandboxes cannot execute code. - -The systemd service creates `/etc/e2b/code-interpreter-auth` when first started. -This marker contains no credentials; it makes envd wait for token delivery to be -acknowledged during initialization, including resume and fork. The auth connection -is also reestablished after an interpreter or envd restart. - -Jupyter listens only on `/run/e2b-jupyter/server.sock` (mode `0600`, directory -mode `0700`), including kernel WebSocket channels. Port `49999` remains the -authenticated interpreter API; Jupyter no longer listens on port `8888`. - -Deploy envd and SDK context-auth updates before rebuilding this template. Local -Docker development explicitly sets `E2B_LOCAL=true`, which disables auth; do not -use that setting in deployed templates. - -Run the server tests locally with: - -```sh -uv run --with-requirements template/requirements-test.txt pytest template/tests -``` - ## Building the production template To build the official `code-interpreter-v1` template from this repo, use @@ -149,3 +122,28 @@ Inside a running sandbox you can also inspect things directly: journalctl -u jupyter -u code-interpreter systemctl status code-interpreter ``` + +## Interpreter authentication + +The template requires envd 0.9.1 or later with the local token delivery socket. +The server receives a SHA-512 token verifier over `/run/e2b-auth/token.sock` and +validates `X-Access-Token` locally. It rejects requests before provisioning and +after disconnecting. `/health` needs no bearer token, but remains unavailable +until the auth connection is established. Tokenless sandboxes cannot execute code. + +The systemd service creates `/etc/e2b/code-interpreter-auth` when first started. +This marker contains no credentials; it makes envd wait for token delivery to be +acknowledged during initialization, including resume and fork. The auth connection +is also reestablished after an interpreter or envd restart. + +Deploy envd and SDK context-auth updates before rebuilding this template. Local +Docker development explicitly sets `E2B_LOCAL=true`, which disables auth; do not +use that setting in deployed templates. Authentication protects the interpreter +API on port `49999`; it does not change Jupyter's listener or secure direct +access to Jupyter. Jupyter isolation is a separate transport change. + +Run the authentication tests locally with: + +```sh +uv run --with-requirements template/requirements-test.txt pytest template/tests/test_auth.py +``` diff --git a/template/jupyter-healthcheck.sh b/template/jupyter-healthcheck.sh index e604a06c..a4c00131 100644 --- a/template/jupyter-healthcheck.sh +++ b/template/jupyter-healthcheck.sh @@ -6,7 +6,7 @@ MAX_RETRIES=50 RETRY_INTERVAL=0.2 for i in $(seq 1 $MAX_RETRIES); do - status_code=$(curl --unix-socket /run/e2b-jupyter/server.sock -s -o /dev/null -w "%{http_code}" "http://localhost/api/status") + status_code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:8888/api/status") if [ "$status_code" -eq 200 ]; then echo "Jupyter Server is healthy" diff --git a/template/jupyter_server_config.py b/template/jupyter_server_config.py index 4d6985dc..663682cc 100644 --- a/template/jupyter_server_config.py +++ b/template/jupyter_server_config.py @@ -2,9 +2,6 @@ c = get_config() # noqa -c.ServerApp.sock = "/run/e2b-jupyter/server.sock" -c.ServerApp.sock_mode = "0600" - # Pin the contents root directory. # @@ -25,7 +22,7 @@ # # Takes precedence over allow_origin_pat. # Default: '' -c.ServerApp.allow_origin = "" +c.ServerApp.allow_origin = "*" # Allow requests where the Host header doesn't point to a local server @@ -41,7 +38,7 @@ # Local IP addresses (such as 127.0.0.1 and ::1) are allowed as local, # along with hostnames configured in local_hostnames. # Default: False -c.ServerApp.allow_remote_access = False +c.ServerApp.allow_remote_access = True # Disable cross-site-request-forgery protection # diff --git a/template/server/consts.py b/template/server/consts.py index c861dc78..e1b92a06 100644 --- a/template/server/consts.py +++ b/template/server/consts.py @@ -1,2 +1 @@ -JUPYTER_SOCKET_PATH = "/run/e2b-jupyter/server.sock" -JUPYTER_BASE_URL = "http://localhost" +JUPYTER_BASE_URL = "http://localhost:8888" diff --git a/template/server/main.py b/template/server/main.py index 1ee3fd40..c50a1385 100644 --- a/template/server/main.py +++ b/template/server/main.py @@ -14,7 +14,7 @@ from api.models.create_context import CreateContext from api.models.execution_request import ExecutionRequest from auth import AccessToken, EnvdAuthMiddleware -from consts import JUPYTER_BASE_URL, JUPYTER_SOCKET_PATH +from consts import JUPYTER_BASE_URL from contexts import create_context, normalize_language from envs import LOCAL from messaging import ContextWebSocket @@ -37,9 +37,7 @@ async def lifespan(app: FastAPI): global client auth_task = None if LOCAL else asyncio.create_task(access_token.subscribe()) - client = httpx.AsyncClient( - transport=httpx.AsyncHTTPTransport(uds=JUPYTER_SOCKET_PATH), trust_env=False - ) + client = httpx.AsyncClient() try: python_context = await create_context( diff --git a/template/server/messaging.py b/template/server/messaging.py index 954dba7b..08aa0e16 100644 --- a/template/server/messaging.py +++ b/template/server/messaging.py @@ -13,7 +13,7 @@ Union, ) from pydantic import StrictStr -from websockets.client import WebSocketClientProtocol, unix_connect +from websockets.client import WebSocketClientProtocol, connect from websockets.exceptions import ( ConnectionClosedError, WebSocketException, @@ -28,7 +28,7 @@ OutputType, UnexpectedEndOfExecution, ) -from consts import JUPYTER_BASE_URL, JUPYTER_SOCKET_PATH +from consts import JUPYTER_BASE_URL from errors import ExecutionError from envs import get_envs @@ -67,7 +67,7 @@ def __init__(self, context_id: str, session_id: str, language: str, cwd: str): self.language = language self.cwd = cwd self.context_id = context_id - self.url = f"ws://localhost/api/kernels/{context_id}/channels" + self.url = f"ws://localhost:8888/api/kernels/{context_id}/channels" self.session_id = session_id self._executions: Dict[str, Execution] = {} self._lock = asyncio.Lock() @@ -87,9 +87,8 @@ async def connect(self): ws_logger = logger.getChild("websockets.client") ws_logger.setLevel(logging.ERROR) - self._ws = await unix_connect( - JUPYTER_SOCKET_PATH, - uri=self.url, + self._ws = await connect( + self.url, ping_timeout=PING_TIMEOUT, max_size=None, max_queue=None, @@ -105,10 +104,7 @@ async def connect(self): async def interrupt(self): """Interrupt the current kernel execution via the Jupyter REST API.""" try: - async with httpx.AsyncClient( - transport=httpx.AsyncHTTPTransport(uds=JUPYTER_SOCKET_PATH), - trust_env=False, - ) as client: + async with httpx.AsyncClient() as client: response = await client.post( f"{JUPYTER_BASE_URL}/api/kernels/{self.context_id}/interrupt" ) diff --git a/template/start-up.sh b/template/start-up.sh index 0ea0494d..5786b039 100755 --- a/template/start-up.sh +++ b/template/start-up.sh @@ -1,7 +1,5 @@ #!/bin/bash -install -d -m 0700 /run/e2b-jupyter - function start_code_interpreter() { /root/.jupyter/jupyter-healthcheck.sh if [ $? -ne 0 ]; then diff --git a/template/systemd/jupyter.service b/template/systemd/jupyter.service index 178124bd..37b83f29 100644 --- a/template/systemd/jupyter.service +++ b/template/systemd/jupyter.service @@ -6,8 +6,6 @@ StartLimitBurst=0 [Service] Type=simple -RuntimeDirectory=e2b-jupyter -RuntimeDirectoryMode=0700 Environment=MATPLOTLIBRC=/root/.config/matplotlib/.matplotlibrc ExecStart=/usr/local/bin/jupyter server --IdentityProvider.token="" ExecStartPost=-/usr/bin/systemctl reset-failed code-interpreter diff --git a/template/tests/test_jupyter_socket.py b/template/tests/test_jupyter_socket.py deleted file mode 100644 index 250aed45..00000000 --- a/template/tests/test_jupyter_socket.py +++ /dev/null @@ -1,96 +0,0 @@ -import asyncio -import os -import socket -import sys -from pathlib import Path - -import httpx -from anyio import fail_after - -import envs -import messaging -from api.models.output import OutputType -from messaging import ContextWebSocket - - -async def test_jupyter_http_and_execution_use_unix_socket_only(tmp_path, monkeypatch): - path = str(tmp_path / "jupyter.sock") - config = Path(__file__).resolve().parents[1] / "jupyter_server_config.py" - monkeypatch.setattr(messaging, "JUPYTER_SOCKET_PATH", path) - monkeypatch.setattr(envs, "LOCAL", True) - with (tmp_path / "jupyter.log").open("w+") as logs: - process = await asyncio.create_subprocess_exec( - sys.executable, - "-m", - "jupyter_server", - f"--config={config}", - f"--ServerApp.sock={path}", - f"--ServerApp.root_dir={tmp_path}", - "--IdentityProvider.token=", - stdout=logs, - stderr=logs, - env={ - **os.environ, - "IPYTHONDIR": str(tmp_path / "ipython"), - "JUPYTER_RUNTIME_DIR": str(tmp_path), - }, - ) - ws = None - try: - async with httpx.AsyncClient( - transport=httpx.AsyncHTTPTransport(uds=path), - base_url="http://localhost", - trust_env=False, - ) as client: - with fail_after(30): - while True: - if process.returncode is not None: - logs.seek(0) - raise AssertionError(logs.read()) - try: - response = await client.get("/api/status") - if response.status_code == 200: - break - except httpx.RequestError: - pass - await asyncio.sleep(0.1) - - assert Path(path).stat().st_mode & 0o777 == 0o600 - with socket.socket() as tcp: - assert tcp.connect_ex(("127.0.0.1", 8888)) != 0 - - response = await client.post("/api/kernels", json={"name": "python3"}) - response.raise_for_status() - kernel_id = response.json()["id"] - ws = ContextWebSocket(kernel_id, "session", "python", str(tmp_path)) - await ws.connect() - with fail_after(30): - outputs = [item async for item in ws.execute("6 * 7", {}, None)] - assert any( - item["type"] == OutputType.RESULT and item["text"] == "42" - for item in outputs - ), outputs - outputs = [] - with fail_after(10): - async for item in ws.execute( - 'print("started", flush=True)\nimport time\ntime.sleep(60)', - {}, - None, - ): - outputs.append(item) - if item["type"] == OutputType.STDOUT: - await ws.interrupt() - assert any( - item["type"] == OutputType.ERROR - and item["name"] == "KeyboardInterrupt" - for item in outputs - ), outputs - assert ( - await client.delete(f"/api/kernels/{kernel_id}") - ).status_code == 204 - finally: - if ws is not None: - await ws.close() - if process.returncode is None: - process.terminate() - await process.wait() From faa5c1f64f586bcb061fa3c91086ed53c5ebf656 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:52:02 +0000 Subject: [PATCH 3/4] refactor: keep authentication wiring separate from Jupyter transport Co-Authored-By: mish@e2b.dev --- template/server/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/template/server/main.py b/template/server/main.py index c50a1385..2a818bba 100644 --- a/template/server/main.py +++ b/template/server/main.py @@ -13,9 +13,9 @@ from api.models.context import Context from api.models.create_context import CreateContext from api.models.execution_request import ExecutionRequest -from auth import AccessToken, EnvdAuthMiddleware from consts import JUPYTER_BASE_URL from contexts import create_context, normalize_language +from auth import AccessToken, EnvdAuthMiddleware from envs import LOCAL from messaging import ContextWebSocket from stream import StreamingListJsonResponse @@ -36,9 +36,9 @@ @asynccontextmanager async def lifespan(app: FastAPI): global client - auth_task = None if LOCAL else asyncio.create_task(access_token.subscribe()) client = httpx.AsyncClient() + auth_task = None if LOCAL else asyncio.create_task(access_token.subscribe()) try: python_context = await create_context( client, websockets, "python", "/home/user" From d9816d2df6c9965522aaf23eec74c13fcda1f878 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:55:48 +0000 Subject: [PATCH 4/4] ci: run interpreter tests in a separate workflow Co-Authored-By: mish@e2b.dev --- .github/workflows/lint.yml | 3 --- .github/workflows/server_tests.yml | 33 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/server_tests.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 10bcc55d..3343ed6d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -57,9 +57,6 @@ jobs: run: | pnpm run lint - - name: Test interpreter server - run: uv run --with-requirements template/requirements-test.txt pytest template/tests - - name: Run formatting run: | pnpm run format diff --git a/.github/workflows/server_tests.yml b/.github/workflows/server_tests.yml new file mode 100644 index 00000000..0ba67302 --- /dev/null +++ b/.github/workflows/server_tests.yml @@ -0,0 +1,33 @@ +name: Interpreter Server Tests + +on: + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Interpreter Server Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Parse .tool-versions + uses: wistia/parse-tool-versions@32f568a4ffd4bfa7720ebf93f171597d1ebc979a # v2.1.1 + with: + filename: '.tool-versions' + uppercase: 'true' + prefix: 'tool_version_' + + - name: Install uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 + with: + version: '${{ env.TOOL_VERSION_UV }}' + python-version: '${{ env.TOOL_VERSION_PYTHON }}' + enable-cache: true + + - name: Test interpreter server + run: uv run --with-requirements template/requirements-test.txt pytest template/tests