mirror of
https://github.com/langgenius/dify.git
synced 2026-09-19 02:07:44 +08:00
fix(agent): authenticate agent control plane (#40737)
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__ = [
|
||||
|
||||
@@ -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__ = [
|
||||
|
||||
@@ -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")
|
||||
@@ -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))'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user