fix(dify-agent): require E2B traffic authentication (#40871)

This commit is contained in:
盐粒 Yanli
2026-08-17 08:35:53 +00:00
committed by GitHub
parent 64587eae43
commit 4249a0d052
11 changed files with 67 additions and 17 deletions
-1
View File
@@ -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=
@@ -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. |
@@ -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
@@ -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,
)
@@ -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,
),
)
@@ -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,
)
)
@@ -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,
-1
View File
@@ -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.
-1
View File
@@ -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}
-1
View File
@@ -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}
@@ -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