From 4249a0d05291c7b72cb820cad58b5afd762ea734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9B=90=E7=B2=92=20Yanli?= Date: Mon, 17 Aug 2026 08:35:53 +0000 Subject: [PATCH] fix(dify-agent): require E2B traffic authentication (#40871) --- dify-agent/.example.env | 1 - dify-agent/docs/dify-agent/guide/index.md | 1 - .../user-manual/shell-layer/index.md | 7 ++- .../src/dify_agent/runtime_backend/e2b.py | 13 +++-- .../src/dify_agent/runtime_backend/profile.py | 2 - dify-agent/src/dify_agent/server/settings.py | 2 - .../dify_agent/runtime_backend/test_e2b.py | 54 +++++++++++++++++++ docker/.env.example | 1 - docker/docker-compose-template.yaml | 1 - docker/docker-compose.yaml | 1 - .../envs/core-services/dify-agent.env.example | 1 - 11 files changed, 67 insertions(+), 17 deletions(-) diff --git a/dify-agent/.example.env b/dify-agent/.example.env index bc7a12262df..80efaf1692c 100644 --- a/dify-agent/.example.env +++ b/dify-agent/.example.env @@ -46,7 +46,6 @@ DIFY_AGENT_E2B_TEMPLATE=difys-default-team/dify-agent-local-sandbox # Binding resources pause; temporary Home initialization resources are killed. # This is not a retention TTL for paused resources or immutable snapshots. DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS=3600 -DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN= DIFY_AGENT_E2B_SHELLCTL_PORT=5004 # JSON array of regex patterns to redact from shell output shown to the agent. DIFY_AGENT_SHELL_REDACT_PATTERNS= diff --git a/dify-agent/docs/dify-agent/guide/index.md b/dify-agent/docs/dify-agent/guide/index.md index 348d3ec97d6..fd436822d77 100644 --- a/dify-agent/docs/dify-agent/guide/index.md +++ b/dify-agent/docs/dify-agent/guide/index.md @@ -54,7 +54,6 @@ also reads `.env` and `dify-agent/.env` when present. | `DIFY_AGENT_E2B_API_KEY` | empty | E2B API key; required for E2B. | | `DIFY_AGENT_E2B_TEMPLATE` | `difys-default-team/dify-agent-local-sandbox` | Prepared E2B template containing shellctl and the deployment-default Home environment. | | `DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS` | `3600` | Maximum continuous active time for the RuntimeLease spanning one complete Agent run. Its default intentionally matches `DIFY_AGENT_RUN_TIMEOUT_SECONDS`, but the settings are independently configurable. Binding resources pause on timeout; this setting does not own the run terminal state and is not a retention TTL. | -| `DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN` | empty | Optional bearer token expected by shellctl inside the E2B template. | | `DIFY_AGENT_E2B_SHELLCTL_PORT` | `5004` | shellctl port exposed by the E2B template. | | `DIFY_AGENT_SHELL_REDACT_PATTERNS` | empty | JSON array of additional regex patterns redacted from Shell output. | | `DIFY_AGENT_STUB_API_BASE_URL` | empty | HTTP(S) Agent Stub API base URL reachable from the Sandbox. It may be the service root or `/agent-stub`. Enables `DIFY_AGENT_STUB_*` env injection for user `shell.run` jobs. | diff --git a/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md b/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md index 210e59f43dd..4066cc5c866 100644 --- a/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md +++ b/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md @@ -77,8 +77,11 @@ DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN=replace-with-shellctl-token # DIFY_AGENT_LOCAL_SANDBOX_HOME_SNAPSHOT_ROOT=/tmp/dify-agent/home-snapshots ``` -The auth token may be empty when shellctl authentication is disabled. E2B uses -`DIFY_AGENT_E2B_API_KEY`, the prepared template, and its shellctl settings. +The auth token may be empty when shellctl authentication is disabled. Dify-created +E2B Sandboxes disable public traffic at creation and access shellctl through the +E2B port proxy with its `traffic_access_token`. Acquiring a RuntimeLease fails if +E2B does not provide a non-empty token. This policy applies only to newly created +Sandboxes and does not retrofit existing ones. To let shell jobs call the Agent Stub with `dify-agent ...`, configure a Sandbox-reachable Agent Stub URL and a unique production secret. Remote diff --git a/dify-agent/src/dify_agent/runtime_backend/e2b.py b/dify-agent/src/dify_agent/runtime_backend/e2b.py index 930ea317dcf..376a43a54b7 100644 --- a/dify-agent/src/dify_agent/runtime_backend/e2b.py +++ b/dify-agent/src/dify_agent/runtime_backend/e2b.py @@ -126,6 +126,7 @@ class E2BSDKControlPlane: template, timeout=timeout, metadata=metadata, + network={"allow_public_traffic": False}, lifecycle={"on_timeout": on_timeout, "auto_resume": False}, **self._options(), ), @@ -209,7 +210,6 @@ class E2BExecutionBindingBackend: control_plane: E2BControlPlane template: str active_timeout_seconds: int - shellctl_auth_token: str = "" shellctl_port: int = 5004 layout: RuntimeLayout = field( default_factory=lambda: RuntimeLayout(home_dir="/home/dify", workspace_dir="/home/dify/workspace") @@ -308,14 +308,17 @@ class E2BExecutionBindingBackend: async def _lease(self, sandbox: _E2BSandbox) -> "E2BRuntimeLease": entrypoint = f"https://{sandbox.get_host(self.shellctl_port)}" traffic_token = sandbox.traffic_access_token - headers = {"X-Access-Token": traffic_token} if isinstance(traffic_token, str) and traffic_token else {} + if not isinstance(traffic_token, str) or not traffic_token: + raise BindingAcquireError("E2B sandbox did not provide a traffic access token") http_client = httpx.AsyncClient( base_url=entrypoint, - headers=headers, + headers={"X-Access-Token": traffic_token}, follow_redirects=True, timeout=httpx.Timeout(60.0), ) + # Explicit token="" prevents process-level SHELLCTL_AUTH_TOKEN fallback; + # E2B port access is authenticated only by X-Access-Token above. def client_factory() -> ShellctlClientProtocol: from shellctl.client import ShellctlClient @@ -323,7 +326,7 @@ class E2BExecutionBindingBackend: ShellctlClientProtocol, cast( object, - ShellctlClient(entrypoint, token=self.shellctl_auth_token, client=http_client), + ShellctlClient(entrypoint, token="", client=http_client), ), ) @@ -331,7 +334,7 @@ class E2BExecutionBindingBackend: handle=sandbox.sandbox_id, layout=self.layout, entrypoint=entrypoint, - token=self.shellctl_auth_token, + token="", client_factory=client_factory, owned_transport=http_client, ) diff --git a/dify-agent/src/dify_agent/runtime_backend/profile.py b/dify-agent/src/dify_agent/runtime_backend/profile.py index 28bcf6bd01e..9bbbf3ee3f7 100644 --- a/dify-agent/src/dify_agent/runtime_backend/profile.py +++ b/dify-agent/src/dify_agent/runtime_backend/profile.py @@ -60,7 +60,6 @@ class RuntimeBackendSettings(BaseSettings): ge=1, le=E2B_MAX_ACTIVE_TIMEOUT_SECONDS, ) - e2b_shellctl_auth_token: str = "" e2b_shellctl_port: int = Field(default=5004, ge=1, le=65535) model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( @@ -146,7 +145,6 @@ def create_runtime_backend_profile(settings: RuntimeBackendSettings) -> RuntimeB control_plane=control_plane, template=settings.e2b_template, active_timeout_seconds=settings.e2b_active_timeout_seconds, - shellctl_auth_token=settings.e2b_shellctl_auth_token, shellctl_port=settings.e2b_shellctl_port, ), ) diff --git a/dify-agent/src/dify_agent/server/settings.py b/dify-agent/src/dify_agent/server/settings.py index ed7425b84d4..d46cc29a31f 100644 --- a/dify-agent/src/dify_agent/server/settings.py +++ b/dify-agent/src/dify_agent/server/settings.py @@ -71,7 +71,6 @@ class ServerSettings(BaseSettings): ge=1, le=E2B_MAX_ACTIVE_TIMEOUT_SECONDS, ) - e2b_shellctl_auth_token: str = "" e2b_shellctl_port: int = Field(default=5004, ge=1, le=65535) agent_stub_api_base_url: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_API_BASE_URL") sandbox_files_base_url: str | None = Field( @@ -205,7 +204,6 @@ class ServerSettings(BaseSettings): e2b_api_key=self.e2b_api_key, e2b_template=self.e2b_template, e2b_active_timeout_seconds=self.e2b_active_timeout_seconds, - e2b_shellctl_auth_token=self.e2b_shellctl_auth_token, e2b_shellctl_port=self.e2b_shellctl_port, ) ) diff --git a/dify-agent/tests/local/dify_agent/runtime_backend/test_e2b.py b/dify-agent/tests/local/dify_agent/runtime_backend/test_e2b.py index 247491b0b9c..0b4e638f044 100644 --- a/dify-agent/tests/local/dify_agent/runtime_backend/test_e2b.py +++ b/dify-agent/tests/local/dify_agent/runtime_backend/test_e2b.py @@ -23,6 +23,7 @@ from dify_agent.runtime_backend.e2b import ( E2BExecutionBindingBackend, E2BHomeSnapshotBackend, E2BRuntimeLease, + E2BSDKControlPlane, ) from dify_agent.runtime_backend.shellctl import ShellctlRuntimeLease @@ -140,6 +141,36 @@ def _connected_backend(*, pause_error: Exception | None = None) -> tuple[E2BExec ) +@pytest.mark.anyio +async def test_e2b_sdk_create_disables_public_traffic(monkeypatch: pytest.MonkeyPatch) -> None: + from e2b import AsyncSandbox + + sandbox = _Sandbox(sandbox_id="sandbox-1") + create_options: dict[str, object] = {} + + async def create( + _cls: type[AsyncSandbox], + template: str, + **options: object, + ) -> _Sandbox: + assert template == "prepared-template" + create_options.update(options) + return sandbox + + monkeypatch.setattr(AsyncSandbox, "create", classmethod(create)) + control_plane = E2BSDKControlPlane(api_key="e2b-secret") + + created = await control_plane.create( + "prepared-template", + timeout=120, + metadata={"dify.resource": "runtime-sandbox"}, + on_timeout="pause", + ) + + assert created is sandbox + assert create_options["network"] == {"allow_public_traffic": False} + + @pytest.mark.anyio async def test_e2b_binding_uses_default_template_or_exact_snapshot_and_couples_refs() -> None: control = _ControlPlane() @@ -299,11 +330,14 @@ async def test_e2b_checkpoint_uses_exact_source_runtime() -> None: async def test_e2b_acquire_retries_transient_shellctl_failures_until_ready( monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setenv("SHELLCTL_AUTH_TOKEN", "ambient-shellctl-token") attempts = 0 def handler(request: httpx.Request) -> httpx.Response: nonlocal attempts attempts += 1 + assert request.headers["X-Access-Token"] == "traffic-token" + assert "Authorization" not in request.headers if attempts == 1: raise httpx.ReadTimeout("shellctl starting", request=request) if attempts == 2: @@ -328,6 +362,26 @@ async def test_e2b_acquire_retries_transient_shellctl_failures_until_ready( assert clients[0].is_closed +@pytest.mark.anyio +@pytest.mark.parametrize("traffic_access_token", [None, ""]) +async def test_e2b_acquire_fails_closed_without_traffic_token( + monkeypatch: pytest.MonkeyPatch, + traffic_access_token: str | None, +) -> None: + def handler(_request: httpx.Request) -> httpx.Response: + raise AssertionError("shellctl must not be called without an E2B traffic access token") + + clients = _mock_http(monkeypatch, handler) + backend, sandbox = _connected_backend() + sandbox.traffic_access_token = traffic_access_token + + with pytest.raises(BindingAcquireError, match="traffic access token"): + _ = await backend.acquire(sandbox.sandbox_id) + + assert clients == [] + assert sandbox.pauses == [True] + + @pytest.mark.anyio async def test_e2b_acquire_closes_transport_and_pauses_after_readiness_retries_exhausted( monkeypatch: pytest.MonkeyPatch, diff --git a/docker/.env.example b/docker/.env.example index 64513b3aea0..b614314b91a 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -283,7 +283,6 @@ DIFY_AGENT_E2B_API_KEY= DIFY_AGENT_E2B_TEMPLATE=difys-default-team/dify-agent-local-sandbox # RuntimeLease active limit; its default matches the independently configurable Agent run deadline. DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS=3600 -DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN= DIFY_AGENT_E2B_SHELLCTL_PORT=5004 # Sandbox-reachable Dify API base for dify-agent CLI /files/* transfers. # Remote Sandboxes should use the public Dify ingress; local Compose uses api via agent_ssrf_proxy. diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 1a256c7ec15..a826cb4e5e7 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -677,7 +677,6 @@ services: DIFY_AGENT_E2B_TEMPLATE: ${DIFY_AGENT_E2B_TEMPLATE:-difys-default-team/dify-agent-local-sandbox} # RuntimeLease active limit; its default matches the independently configurable Agent run deadline. DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS: ${DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS:-3600} - DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN: ${DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN:-} DIFY_AGENT_E2B_SHELLCTL_PORT: ${DIFY_AGENT_E2B_SHELLCTL_PORT:-5004} DIFY_AGENT_STUB_API_BASE_URL: ${DIFY_AGENT_STUB_API_BASE_URL:-http://agent_backend:5050/agent-stub} DIFY_AGENT_SANDBOX_FILES_BASE_URL: ${DIFY_AGENT_SANDBOX_FILES_BASE_URL:-http://api:5001} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 4d95add637f..d4f2154307e 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -683,7 +683,6 @@ services: DIFY_AGENT_E2B_TEMPLATE: ${DIFY_AGENT_E2B_TEMPLATE:-difys-default-team/dify-agent-local-sandbox} # RuntimeLease active limit; its default matches the independently configurable Agent run deadline. DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS: ${DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS:-3600} - DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN: ${DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN:-} DIFY_AGENT_E2B_SHELLCTL_PORT: ${DIFY_AGENT_E2B_SHELLCTL_PORT:-5004} DIFY_AGENT_STUB_API_BASE_URL: ${DIFY_AGENT_STUB_API_BASE_URL:-http://agent_backend:5050/agent-stub} DIFY_AGENT_SANDBOX_FILES_BASE_URL: ${DIFY_AGENT_SANDBOX_FILES_BASE_URL:-http://api:5001} diff --git a/docker/envs/core-services/dify-agent.env.example b/docker/envs/core-services/dify-agent.env.example index 3c63b32af85..a3e61fbed6f 100644 --- a/docker/envs/core-services/dify-agent.env.example +++ b/docker/envs/core-services/dify-agent.env.example @@ -32,7 +32,6 @@ DIFY_AGENT_E2B_API_KEY= DIFY_AGENT_E2B_TEMPLATE=difys-default-team/dify-agent-local-sandbox # RuntimeLease active limit; its default matches the independently configurable Agent run deadline. DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS=3600 -DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN= DIFY_AGENT_E2B_SHELLCTL_PORT=5004 # Sandbox-reachable Dify API base for signed /files/* transfers. DIFY_AGENT_SANDBOX_FILES_BASE_URL=http://api:5001