diff --git a/backend/app/gateway/routers/agents.py b/backend/app/gateway/routers/agents.py index ec5e2faac..92002d75b 100644 --- a/backend/app/gateway/routers/agents.py +++ b/backend/app/gateway/routers/agents.py @@ -8,6 +8,7 @@ import yaml from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field +from deerflow.config.agents_api_config import get_agents_api_config from deerflow.config.agents_config import AgentConfig, list_custom_agents, load_agent_config, load_agent_soul from deerflow.config.paths import get_paths @@ -73,6 +74,15 @@ def _normalize_agent_name(name: str) -> str: return name.lower() +def _require_agents_api_enabled() -> None: + """Reject access unless the custom-agent management API is explicitly enabled.""" + if not get_agents_api_config().enabled: + raise HTTPException( + status_code=403, + detail=("Custom-agent management API is disabled. Set agents_api.enabled=true to expose agent and user-profile routes over HTTP."), + ) + + def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False) -> AgentResponse: """Convert AgentConfig to AgentResponse.""" soul: str | None = None @@ -100,6 +110,8 @@ async def list_agents() -> AgentsListResponse: Returns: List of all custom agents with their metadata and soul content. """ + _require_agents_api_enabled() + try: agents = list_custom_agents() return AgentsListResponse(agents=[_agent_config_to_response(a, include_soul=True) for a in agents]) @@ -125,6 +137,7 @@ async def check_agent_name(name: str) -> dict: Raises: HTTPException: 422 if the name is invalid. """ + _require_agents_api_enabled() _validate_agent_name(name) normalized = _normalize_agent_name(name) available = not get_paths().agent_dir(normalized).exists() @@ -149,6 +162,7 @@ async def get_agent(name: str) -> AgentResponse: Raises: HTTPException: 404 if agent not found. """ + _require_agents_api_enabled() _validate_agent_name(name) name = _normalize_agent_name(name) @@ -181,6 +195,7 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse: Raises: HTTPException: 409 if agent already exists, 422 if name is invalid. """ + _require_agents_api_enabled() _validate_agent_name(request.name) normalized_name = _normalize_agent_name(request.name) @@ -243,6 +258,7 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse: Raises: HTTPException: 404 if agent not found. """ + _require_agents_api_enabled() _validate_agent_name(name) name = _normalize_agent_name(name) @@ -315,6 +331,8 @@ async def get_user_profile() -> UserProfileResponse: Returns: UserProfileResponse with content=None if USER.md does not exist yet. """ + _require_agents_api_enabled() + try: user_md_path = get_paths().user_md_file if not user_md_path.exists(): @@ -341,6 +359,8 @@ async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileR Returns: UserProfileResponse with the saved content. """ + _require_agents_api_enabled() + try: paths = get_paths() paths.base_dir.mkdir(parents=True, exist_ok=True) @@ -367,6 +387,7 @@ async def delete_agent(name: str) -> None: Raises: HTTPException: 404 if agent not found. """ + _require_agents_api_enabled() _validate_agent_name(name) name = _normalize_agent_name(name) diff --git a/backend/packages/harness/deerflow/config/agents_api_config.py b/backend/packages/harness/deerflow/config/agents_api_config.py new file mode 100644 index 000000000..84205259e --- /dev/null +++ b/backend/packages/harness/deerflow/config/agents_api_config.py @@ -0,0 +1,32 @@ +"""Configuration for the custom agents management API.""" + +from pydantic import BaseModel, Field + + +class AgentsApiConfig(BaseModel): + """Configuration for custom-agent and user-profile management routes.""" + + enabled: bool = Field( + default=False, + description=("Whether to expose the custom-agent management API over HTTP. When disabled, the gateway rejects read/write access to custom agent SOUL.md, config, and USER.md prompt-management routes."), + ) + + +_agents_api_config: AgentsApiConfig = AgentsApiConfig() + + +def get_agents_api_config() -> AgentsApiConfig: + """Get the current agents API configuration.""" + return _agents_api_config + + +def set_agents_api_config(config: AgentsApiConfig) -> None: + """Set the agents API configuration.""" + global _agents_api_config + _agents_api_config = config + + +def load_agents_api_config_from_dict(config_dict: dict) -> None: + """Load agents API configuration from a dictionary.""" + global _agents_api_config + _agents_api_config = AgentsApiConfig(**config_dict) diff --git a/backend/packages/harness/deerflow/config/app_config.py b/backend/packages/harness/deerflow/config/app_config.py index df526029c..2aa81c9f0 100644 --- a/backend/packages/harness/deerflow/config/app_config.py +++ b/backend/packages/harness/deerflow/config/app_config.py @@ -9,6 +9,7 @@ from dotenv import load_dotenv from pydantic import BaseModel, ConfigDict, Field from deerflow.config.acp_config import load_acp_config_from_dict +from deerflow.config.agents_api_config import AgentsApiConfig, load_agents_api_config_from_dict from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict from deerflow.config.extensions_config import ExtensionsConfig from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict @@ -60,6 +61,7 @@ class AppConfig(BaseModel): title: TitleConfig = Field(default_factory=TitleConfig, description="Automatic title generation configuration") summarization: SummarizationConfig = Field(default_factory=SummarizationConfig, description="Conversation summarization configuration") memory: MemoryConfig = Field(default_factory=MemoryConfig, description="Memory subsystem configuration") + agents_api: AgentsApiConfig = Field(default_factory=AgentsApiConfig, description="Custom-agent management API configuration") subagents: SubagentsAppConfig = Field(default_factory=SubagentsAppConfig, description="Subagent runtime configuration") guardrails: GuardrailsConfig = Field(default_factory=GuardrailsConfig, description="Guardrail middleware configuration") circuit_breaker: CircuitBreakerConfig = Field(default_factory=CircuitBreakerConfig, description="LLM circuit breaker configuration") @@ -125,6 +127,10 @@ class AppConfig(BaseModel): if "memory" in config_data: load_memory_config_from_dict(config_data["memory"]) + # Always refresh agents API config so removed config sections reset + # singleton-backed state to its default/disabled values on reload. + load_agents_api_config_from_dict(config_data.get("agents_api") or {}) + # Load subagents config if present if "subagents" in config_data: load_subagents_config_from_dict(config_data["subagents"]) diff --git a/backend/tests/test_app_config_reload.py b/backend/tests/test_app_config_reload.py index 716d74495..9e865f142 100644 --- a/backend/tests/test_app_config_reload.py +++ b/backend/tests/test_app_config_reload.py @@ -6,6 +6,7 @@ from pathlib import Path import yaml +from deerflow.config.agents_api_config import get_agents_api_config from deerflow.config.app_config import get_app_config, reset_app_config @@ -28,6 +29,30 @@ def _write_config(path: Path, *, model_name: str, supports_thinking: bool) -> No ) +def _write_config_with_agents_api( + path: Path, + *, + model_name: str, + supports_thinking: bool, + agents_api: dict | None = None, +) -> None: + config = { + "sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}, + "models": [ + { + "name": model_name, + "use": "langchain_openai:ChatOpenAI", + "model": "gpt-test", + "supports_thinking": supports_thinking, + } + ], + } + if agents_api is not None: + config["agents_api"] = agents_api + + path.write_text(yaml.safe_dump(config), encoding="utf-8") + + def _write_extensions_config(path: Path) -> None: path.write_text(json.dumps({"mcpServers": {}, "skills": {}}), encoding="utf-8") @@ -79,3 +104,38 @@ def test_get_app_config_reloads_when_config_path_changes(tmp_path, monkeypatch): assert second is not first finally: reset_app_config() + + +def test_get_app_config_resets_agents_api_config_when_section_removed(tmp_path, monkeypatch): + config_path = tmp_path / "config.yaml" + extensions_path = tmp_path / "extensions_config.json" + _write_extensions_config(extensions_path) + _write_config_with_agents_api( + config_path, + model_name="first-model", + supports_thinking=False, + agents_api={"enabled": True}, + ) + + monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path)) + monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path)) + reset_app_config() + + try: + initial = get_app_config() + assert initial.models[0].name == "first-model" + assert get_agents_api_config().enabled is True + + _write_config_with_agents_api( + config_path, + model_name="first-model", + supports_thinking=False, + ) + next_mtime = config_path.stat().st_mtime + 5 + os.utime(config_path, (next_mtime, next_mtime)) + + reloaded = get_app_config() + assert reloaded is not initial + assert get_agents_api_config().enabled is False + finally: + reset_app_config() diff --git a/backend/tests/test_custom_agent.py b/backend/tests/test_custom_agent.py index 9b5e7bb28..2117e05d2 100644 --- a/backend/tests/test_custom_agent.py +++ b/backend/tests/test_custom_agent.py @@ -9,6 +9,8 @@ import pytest import yaml from fastapi.testclient import TestClient +from deerflow.config.agents_api_config import AgentsApiConfig, get_agents_api_config, set_agents_api_config + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -387,13 +389,38 @@ def _make_test_app(tmp_path: Path): @pytest.fixture() def agent_client(tmp_path): """TestClient with agents router, using tmp_path as base_dir.""" - paths_instance = _make_paths(tmp_path) + import app.gateway.routers.agents as agents_router - with patch("deerflow.config.agents_config.get_paths", return_value=paths_instance), patch("app.gateway.routers.agents.get_paths", return_value=paths_instance): - app = _make_test_app(tmp_path) - with TestClient(app) as client: - client._tmp_path = tmp_path # type: ignore[attr-defined] - yield client + paths_instance = _make_paths(tmp_path) + previous_config = AgentsApiConfig(**get_agents_api_config().model_dump()) + + with patch("deerflow.config.agents_config.get_paths", return_value=paths_instance), patch.object(agents_router, "get_paths", return_value=paths_instance): + set_agents_api_config(AgentsApiConfig(enabled=True)) + try: + app = _make_test_app(tmp_path) + with TestClient(app) as client: + client._tmp_path = tmp_path # type: ignore[attr-defined] + yield client + finally: + set_agents_api_config(previous_config) + + +@pytest.fixture() +def disabled_agent_client(tmp_path): + """TestClient with agents router while the management API is disabled.""" + import app.gateway.routers.agents as agents_router + + paths_instance = _make_paths(tmp_path) + previous_config = AgentsApiConfig(**get_agents_api_config().model_dump()) + + with patch("deerflow.config.agents_config.get_paths", return_value=paths_instance), patch.object(agents_router, "get_paths", return_value=paths_instance): + set_agents_api_config(AgentsApiConfig(enabled=False)) + try: + app = _make_test_app(tmp_path) + with TestClient(app) as client: + yield client + finally: + set_agents_api_config(previous_config) class TestAgentsAPI: @@ -559,3 +586,37 @@ class TestUserProfileAPI: response = agent_client.put("/api/user-profile", json={"content": ""}) assert response.status_code == 200 assert response.json()["content"] is None + + +class TestAgentsApiDisabled: + def test_agents_list_returns_403(self, disabled_agent_client): + response = disabled_agent_client.get("/api/agents") + assert response.status_code == 403 + assert "agents_api.enabled=true" in response.json()["detail"] + + def test_agent_get_returns_403(self, disabled_agent_client): + response = disabled_agent_client.get("/api/agents/example-agent") + assert response.status_code == 403 + + def test_agent_name_check_returns_403(self, disabled_agent_client): + response = disabled_agent_client.get("/api/agents/check", params={"name": "example-agent"}) + assert response.status_code == 403 + + def test_agent_create_returns_403(self, disabled_agent_client): + response = disabled_agent_client.post("/api/agents", json={"name": "example-agent", "soul": "blocked"}) + assert response.status_code == 403 + + def test_agent_update_returns_403(self, disabled_agent_client): + response = disabled_agent_client.put("/api/agents/example-agent", json={"description": "blocked"}) + assert response.status_code == 403 + + def test_agent_delete_returns_403(self, disabled_agent_client): + response = disabled_agent_client.delete("/api/agents/example-agent") + assert response.status_code == 403 + + def test_user_profile_routes_return_403(self, disabled_agent_client): + get_response = disabled_agent_client.get("/api/user-profile") + put_response = disabled_agent_client.put("/api/user-profile", json={"content": "blocked"}) + + assert get_response.status_code == 403 + assert put_response.status_code == 403 diff --git a/config.example.yaml b/config.example.yaml index 89d8e8a85..9d2328530 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -12,7 +12,7 @@ # ============================================================================ # Bump this number when the config schema changes. # Run `make config-upgrade` to merge new fields into your local config.yaml. -config_version: 6 +config_version: 7 # ============================================================================ # Logging @@ -708,6 +708,14 @@ memory: injection_enabled: true # Whether to inject memory into system prompt max_injection_tokens: 2000 # Maximum tokens for memory injection +# ============================================================================ +# Custom Agent Management API +# ============================================================================ +# Controls whether the HTTP gateway exposes custom-agent SOUL/USER.md management. +# Keep this disabled unless the gateway is behind a trusted authenticated admin boundary. +agents_api: + enabled: false + # ============================================================================ # Skill Self-Evolution Configuration # ============================================================================