diff --git a/api/.env.example b/api/.env.example index 9d0f17fe664..2c11616de4e 100644 --- a/api/.env.example +++ b/api/.env.example @@ -703,7 +703,7 @@ INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y # Dify Agent backend AGENT_BACKEND_BASE_URL=http://localhost:5050 -# Bearer token sent to the Agent backend /runs API. Must match DIFY_AGENT_API_TOKEN on the server side. +# Bearer token sent to the Agent backend control-plane API. Must match DIFY_AGENT_API_TOKEN on the server side. AGENT_BACKEND_API_TOKEN=dify-agent-run-token-for-dev-only AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS=30 AGENT_BACKEND_STREAM_MAX_RECONNECTS=3 diff --git a/api/clients/agent_backend/factory.py b/api/clients/agent_backend/factory.py index 2fd9c6faf4a..81f3c9fd1f2 100644 --- a/api/clients/agent_backend/factory.py +++ b/api/clients/agent_backend/factory.py @@ -8,6 +8,12 @@ from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackend from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario +def create_agent_backend_client(*, base_url: str, api_token: str | None = None, stream_timeout: float = 30) -> Client: + api_token = api_token.strip() if api_token else None + headers = {"Authorization": f"Bearer {api_token}"} if api_token else None + return Client(base_url=base_url, stream_timeout=stream_timeout, headers=headers) + + def create_agent_backend_run_client( *, base_url: str | None = None, @@ -23,11 +29,12 @@ def create_agent_backend_run_client( return FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario(fake_scenario)) if base_url is None: raise ValueError("base_url is required when creating a real Agent backend client") - headers: dict[str, str] = {} - if api_token: - headers["Authorization"] = f"Bearer {api_token}" return DifyAgentBackendRunClient( - Client(base_url=base_url, stream_timeout=stream_read_timeout_seconds, headers=headers), + create_agent_backend_client( + base_url=base_url, + api_token=api_token, + stream_timeout=stream_read_timeout_seconds, + ), stream_max_reconnects=stream_max_reconnects, stream_timeout_seconds=stream_run_timeout_seconds, ) diff --git a/api/configs/extra/agent_backend_config.py b/api/configs/extra/agent_backend_config.py index fd412936390..b31803774bb 100644 --- a/api/configs/extra/agent_backend_config.py +++ b/api/configs/extra/agent_backend_config.py @@ -13,7 +13,7 @@ class AgentBackendConfig(BaseSettings): ) AGENT_BACKEND_API_TOKEN: str | None = Field( - description="Bearer token for authenticating with the Agent backend /runs API.", + description="Bearer token for authenticating with the Agent backend control-plane API.", default=None, ) diff --git a/api/services/agent/home_snapshot_service.py b/api/services/agent/home_snapshot_service.py index 722c0f53a9a..54b0eb2591e 100644 --- a/api/services/agent/home_snapshot_service.py +++ b/api/services/agent/home_snapshot_service.py @@ -7,6 +7,7 @@ from dify_agent.protocol import CreateHomeSnapshotFromBindingRequest from sqlalchemy import select from sqlalchemy.orm import Session +from clients.agent_backend.factory import create_agent_backend_client from configs import dify_config from core.db.session_factory import session_factory from libs.datetime_utils import naive_utc_now @@ -159,7 +160,10 @@ class AgentHomeSnapshotService: base_url = dify_config.AGENT_BACKEND_BASE_URL if not base_url: raise AgentHomeSnapshotUnavailableError("Dify Agent backend is required for Home Snapshot operations") - return Client(base_url=base_url) + return create_agent_backend_client( + base_url=base_url, + api_token=dify_config.AGENT_BACKEND_API_TOKEN, + ) def validate_home_snapshot_binding(*, session: Session, agent: Agent, home_snapshot_id: str | None) -> None: diff --git a/api/services/agent/workspace_service.py b/api/services/agent/workspace_service.py index 5053d9294bf..2cc6c34e881 100644 --- a/api/services/agent/workspace_service.py +++ b/api/services/agent/workspace_service.py @@ -16,6 +16,7 @@ from dify_agent.protocol import CreateExecutionBindingRequest, DestroyExecutionB from sqlalchemy import select from sqlalchemy.orm import Session +from clients.agent_backend.factory import create_agent_backend_client from configs import dify_config from core.db.session_factory import session_factory from libs.datetime_utils import naive_utc_now @@ -426,7 +427,10 @@ class AgentWorkspaceService: base_url = dify_config.AGENT_BACKEND_BASE_URL if not base_url: raise AgentWorkspaceError("Dify Agent backend is required for Workspace operations") - return Client(base_url=base_url) + return create_agent_backend_client( + base_url=base_url, + api_token=dify_config.AGENT_BACKEND_API_TOKEN, + ) __all__ = [ diff --git a/api/services/agent_app_sandbox_service.py b/api/services/agent_app_sandbox_service.py index 91f16c2771c..ad7b7b2fd85 100644 --- a/api/services/agent_app_sandbox_service.py +++ b/api/services/agent_app_sandbox_service.py @@ -21,6 +21,7 @@ from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session +from clients.agent_backend.factory import create_agent_backend_client from configs import dify_config from core.db.session_factory import session_factory from core.tools.signature import bind_file_uri @@ -472,7 +473,10 @@ def _default_client_factory() -> Client: "the Binding file inspector is not available (Agent backend not configured)", status_code=503, ) - return Client(base_url=base_url) + return create_agent_backend_client( + base_url=base_url, + api_token=dify_config.AGENT_BACKEND_API_TOKEN, + ) __all__ = [ diff --git a/api/tests/unit_tests/clients/agent_backend/test_factory.py b/api/tests/unit_tests/clients/agent_backend/test_factory.py new file mode 100644 index 00000000000..1fc55bc2b22 --- /dev/null +++ b/api/tests/unit_tests/clients/agent_backend/test_factory.py @@ -0,0 +1,58 @@ +from collections.abc import Callable +from types import ModuleType +from unittest.mock import MagicMock, patch + +import pytest +from dify_agent.client import Client + +from clients.agent_backend.factory import create_agent_backend_client +from configs import dify_config +from services import agent_app_sandbox_service +from services.agent import home_snapshot_service, workspace_service + + +@pytest.mark.parametrize( + ("api_token", "headers"), + [ + ("secret-token", {"Authorization": "Bearer secret-token"}), + (" secret-token ", {"Authorization": "Bearer secret-token"}), + (" ", None), + (None, None), + ], +) +@patch("clients.agent_backend.factory.Client") +def test_create_agent_backend_client_forwards_authentication( + client_cls: MagicMock, + api_token: str | None, + headers: dict[str, str] | None, +) -> None: + create_agent_backend_client(base_url="http://agent-backend", api_token=api_token) + + client_cls.assert_called_once_with( + base_url="http://agent-backend", + stream_timeout=30, + headers=headers, + ) + + +@pytest.mark.parametrize( + ("factory", "module"), + [ + (home_snapshot_service.AgentHomeSnapshotService._client, home_snapshot_service), + (workspace_service.AgentWorkspaceService._client, workspace_service), + (agent_app_sandbox_service._default_client_factory, agent_app_sandbox_service), + ], +) +def test_default_agent_backend_clients_forward_authentication( + monkeypatch: pytest.MonkeyPatch, + factory: Callable[[], Client], + module: ModuleType, +) -> None: + monkeypatch.setattr(dify_config, "AGENT_BACKEND_BASE_URL", "http://agent-backend") + monkeypatch.setattr(dify_config, "AGENT_BACKEND_API_TOKEN", "secret-token") + create_client = MagicMock() + monkeypatch.setattr(module, "create_agent_backend_client", create_client) + + factory() + + create_client.assert_called_once_with(base_url="http://agent-backend", api_token="secret-token") diff --git a/dify-agent/.example.env b/dify-agent/.example.env index 31220ebb52c..bc7a12262df 100644 --- a/dify-agent/.example.env +++ b/dify-agent/.example.env @@ -65,7 +65,7 @@ DIFY_AGENT_SANDBOX_FILES_BASE_URL=http://localhost:5001 # Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))' DIFY_AGENT_SERVER_SECRET_KEY=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY -# Inbound Bearer token for /runs API authentication. +# Inbound Bearer token for control-plane API authentication. # Must match AGENT_BACKEND_API_TOKEN on the Dify API side. # Replace this development default in production. # Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))' diff --git a/dify-agent/docs/dify-agent/concepts/runtime-resources/index.md b/dify-agent/docs/dify-agent/concepts/runtime-resources/index.md index 7b3e02e9483..18d7adc4e42 100644 --- a/dify-agent/docs/dify-agent/concepts/runtime-resources/index.md +++ b/dify-agent/docs/dify-agent/concepts/runtime-resources/index.md @@ -65,6 +65,8 @@ resource registry. Its private control-plane endpoints create or destroy backend resources from requests made by Dify API. Redis run records and event streams are observability state, not the Home/Workspace/Binding ledger. +When `DIFY_AGENT_API_TOKEN` is configured, every private control-plane request must carry the matching Dify API `AGENT_BACKEND_API_TOKEN` as a Bearer token. + ## Creation and execution flow Agent creation does not create a Home Snapshot. A config with no logical Home diff --git a/dify-agent/docs/dify-agent/guide/index.md b/dify-agent/docs/dify-agent/guide/index.md index 56996a5de5c..37e4d5e33e7 100644 --- a/dify-agent/docs/dify-agent/guide/index.md +++ b/dify-agent/docs/dify-agent/guide/index.md @@ -35,6 +35,7 @@ also reads `.env` and `dify-agent/.env` when present. | `DIFY_AGENT_REDIS_PREFIX` | `dify-agent` | Prefix for Redis record and event keys. | | `DIFY_AGENT_SHUTDOWN_GRACE_SECONDS` | `30` | Seconds to wait for active local runs during graceful shutdown before cancellation. | | `DIFY_AGENT_RUN_RETENTION_SECONDS` | `259200` | Seconds to retain Redis run records and per-run event streams; defaults to 3 days. | +| `DIFY_AGENT_API_TOKEN` | empty | Optional Bearer token required by private run, Execution Binding, Home Snapshot, and Binding file control-plane routes. Must match Dify API `AGENT_BACKEND_API_TOKEN`. | | `DIFY_AGENT_PLUGIN_DAEMON_URL` | `http://localhost:5002` | Base URL for the Dify plugin daemon. | | `DIFY_AGENT_PLUGIN_DAEMON_API_KEY` | empty | API key sent to the Dify plugin daemon. | | `DIFY_AGENT_INNER_API_URL` | `http://localhost:5001` | Dify API service root used when dify-agent calls `/inner/api/...` endpoints. | @@ -73,6 +74,7 @@ DIFY_AGENT_REDIS_URL=redis://localhost:6379/0 DIFY_AGENT_REDIS_PREFIX=dify-agent-dev DIFY_AGENT_SHUTDOWN_GRACE_SECONDS=30 DIFY_AGENT_RUN_RETENTION_SECONDS=259200 +DIFY_AGENT_API_TOKEN=replace-with-agent-backend-token DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002 DIFY_AGENT_PLUGIN_DAEMON_API_KEY=replace-with-daemon-key DIFY_AGENT_INNER_API_URL=http://localhost:5001 diff --git a/dify-agent/src/dify_agent/server/app.py b/dify-agent/src/dify_agent/server/app.py index 567397f0cdc..64a04326c1b 100644 --- a/dify-agent/src/dify_agent/server/app.py +++ b/dify-agent/src/dify_agent/server/app.py @@ -18,7 +18,7 @@ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager import httpx -from fastapi import FastAPI +from fastapi import APIRouter, FastAPI from redis.asyncio import Redis from dify_agent.agent_stub.shell_env import ShellAgentStubTokenFactory @@ -132,16 +132,14 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: def get_scheduler() -> RunScheduler: return state["scheduler"] # pyright: ignore[reportReturnType] - app.include_router( - create_runs_router( - get_store, - get_scheduler, - auth_dependency=create_bearer_token_dependency(resolved_settings.api_token), - ) + control_plane_router = APIRouter( + dependencies=[create_bearer_token_dependency(resolved_settings.api_token)], ) - app.include_router(create_execution_bindings_router(lambda: execution_binding_service)) - app.include_router(create_home_snapshots_router(lambda: home_snapshot_service)) - app.include_router(create_binding_files_router(lambda: binding_file_service)) + control_plane_router.include_router(create_runs_router(get_store, get_scheduler)) + control_plane_router.include_router(create_execution_bindings_router(lambda: execution_binding_service)) + control_plane_router.include_router(create_home_snapshots_router(lambda: home_snapshot_service)) + control_plane_router.include_router(create_binding_files_router(lambda: binding_file_service)) + app.include_router(control_plane_router) app.include_router( create_agent_stub_router( token_codec=agent_stub_token_codec, diff --git a/dify-agent/src/dify_agent/server/routes/runs.py b/dify-agent/src/dify_agent/server/routes/runs.py index 4f08e76aa9f..2daecbfd752 100644 --- a/dify-agent/src/dify_agent/server/routes/runs.py +++ b/dify-agent/src/dify_agent/server/routes/runs.py @@ -14,7 +14,6 @@ from collections.abc import Callable from typing import Annotated from fastapi import APIRouter, Depends, Header, HTTPException, Query -from fastapi.params import Depends as DependsInstance from fastapi.responses import StreamingResponse from dify_agent.protocol.schemas import ( @@ -33,11 +32,8 @@ from dify_agent.storage.redis_run_store import RedisRunStore, RunNotFoundError def create_runs_router( get_store: Callable[[], RedisRunStore], get_scheduler: Callable[[], RunScheduler], - *, - auth_dependency: DependsInstance | None = None, ) -> APIRouter: - dependencies: list[DependsInstance] = [auth_dependency] if auth_dependency is not None else [] - router = APIRouter(prefix="/runs", tags=["runs"], dependencies=dependencies) + router = APIRouter(prefix="/runs", tags=["runs"]) async def store_dep() -> RedisRunStore: return get_store() diff --git a/dify-agent/src/dify_agent/server/settings.py b/dify-agent/src/dify_agent/server/settings.py index 177456dc47a..fbf203d35cb 100644 --- a/dify-agent/src/dify_agent/server/settings.py +++ b/dify-agent/src/dify_agent/server/settings.py @@ -149,14 +149,13 @@ class ServerSettings(BaseSettings): raise ValueError("DIFY_AGENT_INNER_API_URL must not include a query string or fragment") return parsed - @field_validator("inner_api_key") + @field_validator("inner_api_key", "api_token") @classmethod - def normalize_inner_api_key(cls, value: str | None) -> str | None: - """Normalize the optional trusted Dify inner API key.""" + def normalize_optional_api_token(cls, value: str | None) -> str | None: + """Normalize optional API authentication tokens.""" if value is None: return None - stripped = value.strip() - return stripped or None + return value.strip() or None def get_shell_redact_patterns(self) -> list[str]: """Parse the JSON array from shell_redact_patterns; empty/blank → empty list.""" diff --git a/dify-agent/tests/local/dify_agent/server/test_app.py b/dify-agent/tests/local/dify_agent/server/test_app.py index c68252d6c2b..cede01c446a 100644 --- a/dify-agent/tests/local/dify_agent/server/test_app.py +++ b/dify-agent/tests/local/dify_agent/server/test_app.py @@ -155,6 +155,27 @@ class FakeHttpxModule: AsyncClient: ClassVar[type[FakePluginDaemonHttpClient]] = FakePluginDaemonHttpClient +@pytest.mark.parametrize( + "path", + [ + "/runs", + "/execution-bindings", + "/home-snapshots/from-binding", + "/execution-bindings/files/list", + ], +) +def test_create_app_authenticates_control_plane_routes( + monkeypatch: pytest.MonkeyPatch, + path: str, +) -> None: + _patch_app_lifecycle(monkeypatch) + settings = ServerSettings(redis_url="redis://example.invalid/0", api_token="secret-token") + + with TestClient(create_app(settings)) as client: + assert client.post(path, json={}).status_code == 401 + assert client.post(path, headers={"Authorization": "Bearer secret-token"}, json={}).status_code != 401 + + def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pytest.MonkeyPatch) -> None: fake_redis = FakeRedis() fake_http_client = FakePluginDaemonHttpClient() diff --git a/dify-agent/tests/local/dify_agent/server/test_settings.py b/dify-agent/tests/local/dify_agent/server/test_settings.py index 282542b4cca..d4d72fe6265 100644 --- a/dify-agent/tests/local/dify_agent/server/test_settings.py +++ b/dify-agent/tests/local/dify_agent/server/test_settings.py @@ -169,6 +169,11 @@ def test_server_settings_normalizes_inner_api_url_from_env(monkeypatch: pytest.M assert settings.inner_api_key == "inner-secret" +@pytest.mark.parametrize(("value", "expected"), [("", None), (" ", None), (" secret-token ", "secret-token")]) +def test_server_settings_normalizes_api_token(value: str, expected: str | None) -> None: + assert ServerSettings(api_token=value).api_token == expected + + def test_server_settings_allows_inner_api_url_without_key_until_a_bridge_is_used() -> None: settings = ServerSettings(inner_api_key="inner-secret") assert settings.inner_api_key == "inner-secret" diff --git a/docker/.env.example b/docker/.env.example index d44703af976..8e4dec76054 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -254,7 +254,7 @@ MARKETPLACE_URL= # Dify Agent backend AGENT_BACKEND_BASE_URL=http://agent_backend:5050 -# Bearer token for the Agent backend /runs API. +# Bearer token for the Agent backend control-plane API. # Replace this development default in production. # Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))' DIFY_AGENT_API_TOKEN=dify-agent-run-token-for-dev-only