from __future__ import annotations import asyncio import base64 import time from typing import ClassVar import httpx import pytest from fastapi.testclient import TestClient from shell_session_manager.shellctl.client import ShellctlClient import dify_agent.server.app as app_module from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer from dify_agent.layers.shell import DifyShellLayerConfig from dify_agent.layers.shell.layer import DifyShellLayer from dify_agent.runtime.compositor_factory import DifyAgentLayerProvider from dify_agent.server.app import create_app, create_plugin_daemon_http_client from dify_agent.server.settings import ServerSettings from dify_agent.storage.redis_run_store import RedisRunStore def _base64url_secret(value: bytes) -> str: return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") def _execution_context() -> DifyExecutionContextLayerConfig: return DifyExecutionContextLayerConfig( tenant_id="tenant-1", user_id="user-1", user_from="account", agent_mode="workflow_run", invoke_from="service-api", ) def _patch_app_lifecycle(monkeypatch: pytest.MonkeyPatch) -> tuple[FakeRedis, FakePluginDaemonHttpClient]: fake_redis = FakeRedis() fake_http_client = FakePluginDaemonHttpClient() FakeRunScheduler.created.clear() FakeRedisModule.fake_redis = fake_redis monkeypatch.setattr(app_module, "Redis", FakeRedisModule) monkeypatch.setattr(app_module, "RunScheduler", FakeRunScheduler) def fake_create_plugin_daemon_http_client(_settings: ServerSettings) -> FakePluginDaemonHttpClient: return fake_http_client monkeypatch.setattr(app_module, "create_plugin_daemon_http_client", fake_create_plugin_daemon_http_client) return fake_redis, fake_http_client class FakeRedis: closed: bool def __init__(self) -> None: self.closed = False async def aclose(self) -> None: self.closed = True class FakeRunScheduler: created: list["FakeRunScheduler"] = [] store: object shutdown_grace_seconds: float layer_providers: tuple[DifyAgentLayerProvider, ...] plugin_daemon_http_client: FakePluginDaemonHttpClient shutdown_called: bool def __init__( self, *, store: object, plugin_daemon_http_client: FakePluginDaemonHttpClient, shutdown_grace_seconds: float, layer_providers: tuple[DifyAgentLayerProvider, ...], ) -> None: self.store = store self.shutdown_grace_seconds = shutdown_grace_seconds self.layer_providers = layer_providers self.plugin_daemon_http_client = plugin_daemon_http_client self.shutdown_called = False self.created.append(self) async def shutdown(self) -> None: self.shutdown_called = True class FakePluginDaemonHttpClient: timeout: object | None limits: object | None trust_env: bool | None is_closed: bool def __init__( self, *, timeout: object | None = None, limits: object | None = None, trust_env: bool | None = None, ) -> None: self.timeout = timeout self.limits = limits self.trust_env = trust_env self.is_closed = False async def aclose(self) -> None: self.is_closed = True class FakeAgentStubGRPCServer: closed: bool def __init__(self) -> None: self.closed = False async def aclose(self) -> None: self.closed = True class FakeTimeout: connect: float read: float write: float pool: float def __init__(self, *, connect: float, read: float, write: float, pool: float) -> None: self.connect = connect self.read = read self.write = write self.pool = pool class FakeLimits: max_connections: int max_keepalive_connections: int keepalive_expiry: float def __init__(self, *, max_connections: int, max_keepalive_connections: int, keepalive_expiry: float) -> None: self.max_connections = max_connections self.max_keepalive_connections = max_keepalive_connections self.keepalive_expiry = keepalive_expiry class FakeRedisModule: fake_redis: ClassVar[FakeRedis | None] = None @staticmethod def from_url(_url: str) -> FakeRedis: assert FakeRedisModule.fake_redis is not None return FakeRedisModule.fake_redis class FakeHttpxModule: Timeout: ClassVar[type[FakeTimeout]] = FakeTimeout Limits: ClassVar[type[FakeLimits]] = FakeLimits AsyncClient: ClassVar[type[FakePluginDaemonHttpClient]] = FakePluginDaemonHttpClient def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pytest.MonkeyPatch) -> None: fake_redis, fake_http_client = _patch_app_lifecycle(monkeypatch) settings = ServerSettings( redis_url="redis://example.invalid/0", redis_prefix="test", shutdown_grace_seconds=5, run_retention_seconds=7, plugin_daemon_url="http://plugin-daemon", plugin_daemon_api_key="daemon-secret", shellctl_entrypoint="http://shellctl", shellctl_auth_token="shell-secret", agent_stub_url="https://agent.example.com/agent-stub", server_secret_key=_base64url_secret(b"1" * 32), dify_api_base_url="https://api.example.com", dify_api_inner_api_key="inner-secret", plugin_daemon_connect_timeout=1, plugin_daemon_read_timeout=2, plugin_daemon_write_timeout=3, plugin_daemon_pool_timeout=4, plugin_daemon_max_connections=5, plugin_daemon_max_keepalive_connections=3, plugin_daemon_keepalive_expiry=6, ) with TestClient(create_app(settings)): assert len(FakeRunScheduler.created) == 1 scheduler = FakeRunScheduler.created[0] assert scheduler.shutdown_grace_seconds == 5 layer_providers = scheduler.layer_providers assert isinstance(layer_providers, tuple) execution_context_provider = next( provider for provider in layer_providers if provider.type_id == "dify.execution_context" ) execution_context_layer = execution_context_provider.create_layer( DifyExecutionContextLayerConfig( tenant_id="tenant-1", user_from="account", agent_mode="workflow_run", invoke_from="service-api", ) ) shell_provider = next(provider for provider in layer_providers if provider.type_id == "dify.shell") shell_layer = shell_provider.create_layer(DifyShellLayerConfig()) assert isinstance(execution_context_layer, DifyExecutionContextLayer) assert isinstance(shell_layer, DifyShellLayer) assert execution_context_layer.daemon_url == "http://plugin-daemon" assert execution_context_layer.daemon_api_key == "daemon-secret" assert shell_layer.shellctl_entrypoint == "http://shellctl" assert shell_layer.agent_stub_url == "https://agent.example.com/agent-stub" shellctl_client = shell_layer.shellctl_client_factory("http://shellctl") assert isinstance(shellctl_client, ShellctlClient) assert shellctl_client.token == "shell-secret" asyncio.run(shellctl_client.close()) http_client = scheduler.plugin_daemon_http_client assert http_client is fake_http_client assert http_client.is_closed is False store = scheduler.store assert isinstance(store, RedisRunStore) assert store.run_retention_seconds == 7 assert any(getattr(route, "path", None) == "/agent-stub/connections" for route in create_app(settings).routes) assert any( getattr(route, "path", None) == "/agent-stub/files/upload-request" for route in create_app(settings).routes ) assert any( getattr(route, "path", None) == "/agent-stub/files/download-request" for route in create_app(settings).routes ) assert FakeRunScheduler.created[0].shutdown_called is True assert FakeRunScheduler.created[0].plugin_daemon_http_client.is_closed is True assert fake_redis.closed is True def test_create_app_wires_authenticated_agent_stub_connection_route(monkeypatch: pytest.MonkeyPatch) -> None: fake_redis, fake_http_client = _patch_app_lifecycle(monkeypatch) settings = ServerSettings( redis_url="redis://example.invalid/0", agent_stub_url="https://agent.example.com/agent-stub", server_secret_key=_base64url_secret(b"1" * 32), ) token_codec = settings.create_agent_stub_token_codec() assert token_codec is not None token = token_codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1) with TestClient(create_app(settings)) as client: response = client.post( "/agent-stub/connections", headers={"Authorization": f"Bearer {token}"}, json={"protocol_version": 1, "argv": ["connect"]}, ) assert response.status_code == 200 assert response.json()["status"] == "connected" assert isinstance(response.json()["connection_id"], str) assert FakeRunScheduler.created[0].shutdown_called is True assert fake_http_client.is_closed is True assert fake_redis.closed is True def test_create_app_wires_authenticated_agent_stub_file_upload_route(monkeypatch: pytest.MonkeyPatch) -> None: fake_redis, fake_http_client = _patch_app_lifecycle(monkeypatch) settings = ServerSettings( redis_url="redis://example.invalid/0", agent_stub_url="https://agent.example.com/agent-stub", server_secret_key=_base64url_secret(b"1" * 32), dify_api_base_url="https://api.example.com", dify_api_inner_api_key="inner-secret", ) token_codec = settings.create_agent_stub_token_codec() assert token_codec is not None token = token_codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1) original_async_client = httpx.AsyncClient def handler(request: httpx.Request) -> httpx.Response: assert str(request.url) == "https://api.example.com/inner/api/upload/file/request" assert request.headers["X-Inner-Api-Key"] == "inner-secret" return httpx.Response(200, json={"data": {"url": "https://files.example.com/upload"}}) monkeypatch.setattr( "dify_agent.agent_stub.server.agent_stub_files.httpx.AsyncClient", lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs), ) with TestClient(create_app(settings)) as client: response = client.post( "/agent-stub/files/upload-request", headers={"Authorization": f"Bearer {token}"}, json={"filename": "report.pdf", "mimetype": "application/pdf"}, ) assert response.status_code == 200 assert response.json() == {"upload_url": "https://files.example.com/upload"} assert FakeRunScheduler.created[0].shutdown_called is True assert fake_http_client.is_closed is True assert fake_redis.closed is True def test_create_app_starts_and_stops_agent_stub_grpc_server_for_grpc_url(monkeypatch: pytest.MonkeyPatch) -> None: fake_redis, fake_http_client = _patch_app_lifecycle(monkeypatch) started: dict[str, object] = {} fake_grpc_server = FakeAgentStubGRPCServer() async def fake_start_agent_stub_grpc_server(**kwargs): started.update(kwargs) return fake_grpc_server monkeypatch.setattr(app_module, "start_agent_stub_grpc_server", fake_start_agent_stub_grpc_server) settings = ServerSettings( redis_url="redis://example.invalid/0", agent_stub_url="grpc://agent.example.com:9091", agent_stub_grpc_bind_address="0.0.0.0:9191", server_secret_key=_base64url_secret(b"1" * 32), ) with TestClient(create_app(settings)): assert started["public_url"] == "grpc://agent.example.com:9091" assert started["bind_address"] == "0.0.0.0:9191" assert fake_grpc_server.closed is True assert FakeRunScheduler.created[0].shutdown_called is True assert fake_http_client.is_closed is True assert fake_redis.closed is True def test_create_plugin_daemon_http_client_uses_configured_httpx_construction_args( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(app_module, "httpx", FakeHttpxModule) client = create_plugin_daemon_http_client(ServerSettings()) assert isinstance(client, FakePluginDaemonHttpClient) assert isinstance(client.timeout, FakeTimeout) assert client.timeout.connect == 10 assert client.timeout.read == 600 assert client.timeout.write == 30 assert client.timeout.pool == 10 assert isinstance(client.limits, FakeLimits) assert client.limits.max_connections == 100 assert client.limits.max_keepalive_connections == 20 assert client.limits.keepalive_expiry == 30 assert client.trust_env is False