From c5306142dbca6eb782ccaa45593b4c48d571f212 Mon Sep 17 00:00:00 2001 From: Hyeonsang Cho Date: Thu, 22 Oct 2026 09:41:07 +0900 Subject: [PATCH] fix(agents): keep host paths out of agent and user-profile error details (#7255) * fix(agents): keep host paths out of agent and user-profile error details The custom-agent and user-profile routes put str(e) in their 500 detail. Agent config parse errors and filesystem OSErrors name the absolute host path under users//, so a caller saw it in the response. Return the fixed "Failed to " detail the import and export routes already use and keep the full error in the existing log lines. PUT also handles a parse error from its initial config load. The scheduled-task assistant resolver echoed the same parse error as its invalid_assistant 422 message, which reached the UI and the scheduler tool result. It now logs the error and returns a fixed message under the same code. * docs(changelog): reference #7255 for the agents error detail fix --- CHANGELOG.md | 10 +++ CHANGELOG_zh.md | 8 ++ backend/app/gateway/routers/AGENTS.md | 4 + backend/app/gateway/routers/agents.py | 17 ++-- .../app/gateway/routers/scheduled_tasks.py | 7 +- backend/docs/API.md | 6 ++ backend/tests/test_custom_agent.py | 90 +++++++++++++++++++ .../test_scheduled_task_router_behavior.py | 17 ++++ 8 files changed, 151 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf4c407469..8fe26d23af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6916,6 +6916,15 @@ As of 2026-10-21, the milestone includes **829 merged pull requests**. ### Security +- **agents:** The custom-agent and user-profile routes no longer put the raw + exception text in their `500` detail. An unparsable agent `config.yaml` or a + filesystem error (`[Errno 13]` and the like) made them answer with the + absolute host path under `users//`. They now return a fixed + `Failed to ` detail, matching agent import and export, and the full + error stays in the Gateway log. Scheduled-task create and update keep the + `invalid_assistant` 422 for an unloadable agent config but no longer echo + the parse error, which also reached the scheduler tool result. ([#7255]) + - **infoquest:** Screen organic, news and image result URLs through the shared public-URL policy, dropping non-public and unsafe references before they reach the model. ([#7031]) @@ -12985,3 +12994,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#7170]: https://github.com/bytedance/deer-flow/pull/7170 [#7205]: https://github.com/bytedance/deer-flow/pull/7205 [#7207]: https://github.com/bytedance/deer-flow/pull/7207 +[#7255]: https://github.com/bytedance/deer-flow/pull/7255 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 1063c66cd0..faf7a2b15c 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -5256,6 +5256,13 @@ ### 安全 +- **agents:** 自定义智能体与用户画像路由的 `500` detail 不再包含原始异常文本。 + 此前无法解析的智能体 `config.yaml` 或文件系统错误(如 `[Errno 13]`)会让响应 + 带出 `users//` 下的宿主机绝对路径;现在返回与智能体导入、导出一致的 + 固定 `Failed to ` detail,完整错误仅保留在 Gateway 日志中。定时任务创建与 + 更新在智能体配置无法加载时仍返回 `invalid_assistant` 422,但不再回显解析错误 + (该错误此前也会进入定时任务工具结果)。([#7255]) + - **infoquest:** 通过共享公网 URL 策略筛查自然搜索、新闻与图片结果的 URL,在其 到达模型前丢弃非公网及不安全引用。([#7031]) @@ -10364,3 +10371,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#7170]: https://github.com/bytedance/deer-flow/pull/7170 [#7205]: https://github.com/bytedance/deer-flow/pull/7205 [#7207]: https://github.com/bytedance/deer-flow/pull/7207 +[#7255]: https://github.com/bytedance/deer-flow/pull/7255 diff --git a/backend/app/gateway/routers/AGENTS.md b/backend/app/gateway/routers/AGENTS.md index 6b1f3449c7..7ecf3692be 100644 --- a/backend/app/gateway/routers/AGENTS.md +++ b/backend/app/gateway/routers/AGENTS.md @@ -35,6 +35,10 @@ Agent PUT uses `preserve_non_managed_fields(..., new_tool_groups=...)` to clear an inherited knowledge default when changed groups remove knowledge access. Explicit scope/null updates still win; unchanged groups are not a migration. +Custom-agent and user-profile 500s and the scheduled-task `invalid_assistant` +422 use fixed text: agent config parse errors and `OSError`s carry host paths +and stay in the server log. + Committed MCP reconciliation failures are logged once by `fail_mcp_reconciliation`, with only the underlying exception type. `_run_drained_mcp_apply` passes `_McpCommittedReconciliationError` as an expected diff --git a/backend/app/gateway/routers/agents.py b/backend/app/gateway/routers/agents.py index 0d1490c7d4..e9fc00eb27 100644 --- a/backend/app/gateway/routers/agents.py +++ b/backend/app/gateway/routers/agents.py @@ -321,7 +321,7 @@ async def list_agents(request: Request) -> AgentsListResponse: return await asyncio.to_thread(_list) except Exception as e: logger.error(f"Failed to list agents: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to list agents") @router.get( @@ -490,7 +490,7 @@ async def get_agent(name: str, request: Request) -> AgentResponse: raise HTTPException(status_code=404, detail=f"Agent '{name}' not found") except Exception as e: logger.error(f"Failed to get agent '{name}': {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to get agent: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to get agent") @router.post( @@ -526,7 +526,7 @@ async def create_agent_endpoint(body: AgentCreateRequest, request: Request) -> A raise HTTPException(status_code=409, detail=f"Agent '{normalized_name}' already exists") except Exception as e: logger.error(f"Failed to create agent '{body.name}': {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to create agent: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to create agent") @router.put( @@ -559,6 +559,9 @@ async def update_agent(name: str, body: AgentUpdateRequest, request: Request) -> agent_cfg = await asyncio.to_thread(load_agent_config, name, user_id=user_id) except FileNotFoundError: raise HTTPException(status_code=404, detail=f"Agent '{name}' not found") + except Exception as e: + logger.error(f"Failed to update agent '{name}': {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Failed to update agent") def _is_legacy_only_layout() -> bool: # The DB importer retains legacy source files for rollback. They say @@ -664,7 +667,7 @@ async def update_agent(name: str, body: AgentUpdateRequest, request: Request) -> raise except Exception as e: logger.error(f"Failed to update agent '{name}': {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to update agent") class UserProfileResponse(BaseModel): @@ -713,7 +716,7 @@ async def get_user_profile(request: Request) -> UserProfileResponse: return UserProfileResponse(content=raw or None) except Exception as e: logger.error(f"Failed to read user profile: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to read user profile: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to read user profile") @router.put( @@ -753,7 +756,7 @@ async def update_user_profile(body: UserProfileUpdateRequest, request: Request) return UserProfileResponse(content=body.content or None) except Exception as e: logger.error(f"Failed to update user profile: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to update user profile: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to update user profile") @router.delete( @@ -784,7 +787,7 @@ async def delete_agent(name: str, request: Request) -> None: outcome = await run_drained_write("Delete agent", _delete_agent_with_memory_cancel, (), name, user_id) except Exception as e: logger.error(f"Failed to delete agent '{name}': {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to delete agent") if outcome == "legacy": raise HTTPException( diff --git a/backend/app/gateway/routers/scheduled_tasks.py b/backend/app/gateway/routers/scheduled_tasks.py index 7b980a5198..3cf3ba694a 100644 --- a/backend/app/gateway/routers/scheduled_tasks.py +++ b/backend/app/gateway/routers/scheduled_tasks.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import logging import uuid from datetime import UTC, datetime from typing import Any @@ -38,6 +39,8 @@ from deerflow.scheduler.schedules import ( ) from deerflow.utils.thread_id import ThreadId +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/api", tags=["scheduled-tasks"]) _DEFAULT_ASSISTANT_ID = "lead_agent" @@ -79,7 +82,9 @@ async def resolve_scheduled_task_assistant_id(raw: str | None, *, user_id: str) except FileNotFoundError as exc: raise scheduler_error(422, "unknown_assistant", f"Unknown assistant_id {raw!r}") from exc except ValueError as exc: - raise scheduler_error(422, "invalid_assistant", str(exc)) from exc + # The store's parse error names the config file's host path. + logger.warning("Failed to load agent config for assistant_id %r: %s", raw, exc) + raise scheduler_error(422, "invalid_assistant", f"Agent config for assistant_id {raw!r} could not be loaded") from exc if config is None: raise scheduler_error(422, "unknown_assistant", f"Unknown assistant_id {raw!r}") return normalized diff --git a/backend/docs/API.md b/backend/docs/API.md index 5b832ccfe4..042a88a797 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -685,6 +685,12 @@ the existing Agent. Import is create-only and never restores runtime state. } ``` +### Custom Agent errors + +**Error behavior:** +- `/api/agents` and `/api/user-profile` routes answer unexpected failures with a fixed `500` detail such as `{"detail": "Failed to get agent"}`; an unparsable agent `config.yaml` or a filesystem error names host paths, so the full exception stays in server logs +- Scheduled-task create and update return `invalid_assistant` (422) with a fixed message when the selected agent's config cannot be loaded + ### Models #### List Models diff --git a/backend/tests/test_custom_agent.py b/backend/tests/test_custom_agent.py index fd63a0b5f6..eeba2e83bf 100644 --- a/backend/tests/test_custom_agent.py +++ b/backend/tests/test_custom_agent.py @@ -2,6 +2,8 @@ from __future__ import annotations +import errno +import os from pathlib import Path from types import SimpleNamespace from unittest.mock import patch @@ -1131,6 +1133,94 @@ class TestUserProfileAPI: assert response.json()["content"] is None +class TestAgentsAPIErrorDetail: + """Unexpected-failure responses must not echo the exception text. + + Agent config parse errors and filesystem ``OSError``s name absolute host + paths under ``users//``; they belong in the server log only. + """ + + def _user_dir(self, tmp_path: Path) -> Path: + return tmp_path / "users" / "test-user-autouse" + + def _break_agent_config(self, agent_client, tmp_path: Path) -> Path: + assert agent_client.post("/api/agents", json={"name": "broken", "soul": "x"}).status_code == 201 + config_file = self._user_dir(tmp_path) / "agents" / "broken" / "config.yaml" + config_file.write_text("name: broken\n bad: [\n", encoding="utf-8") + return config_file + + def test_get_agent_with_unparsable_config_returns_fixed_500(self, agent_client, tmp_path, caplog): + config_file = self._break_agent_config(agent_client, tmp_path) + + response = agent_client.get("/api/agents/broken") + + assert response.status_code == 500 + assert response.json() == {"detail": "Failed to get agent"} + assert str(config_file) in caplog.text + + def test_update_agent_with_unparsable_config_returns_fixed_500(self, agent_client, tmp_path, caplog): + config_file = self._break_agent_config(agent_client, tmp_path) + + response = agent_client.put("/api/agents/broken", json={"description": "new"}) + + assert response.status_code == 500 + assert response.json() == {"detail": "Failed to update agent"} + assert str(config_file) in caplog.text + + def test_list_agents_os_error_returns_fixed_500(self, agent_client, tmp_path, caplog): + agents_root = self._user_dir(tmp_path) / "agents" + agents_root.parent.mkdir(parents=True) + agents_root.write_text("not a directory", encoding="utf-8") + + response = agent_client.get("/api/agents") + + assert response.status_code == 500 + assert response.json() == {"detail": "Failed to list agents"} + assert str(agents_root) in caplog.text + + def test_create_agent_os_error_returns_fixed_500(self, agent_client, tmp_path, caplog): + agents_root = self._user_dir(tmp_path) / "agents" + agents_root.parent.mkdir(parents=True) + agents_root.write_text("not a directory", encoding="utf-8") + + response = agent_client.post("/api/agents", json={"name": "fresh", "soul": "x"}) + + assert response.status_code == 500 + assert response.json() == {"detail": "Failed to create agent"} + assert str(agents_root) in caplog.text + + def test_delete_agent_os_error_returns_fixed_500(self, agent_client, tmp_path, caplog): + from deerflow.persistence.agents.file import FileAgentStore + + assert agent_client.post("/api/agents", json={"name": "locked", "soul": "x"}).status_code == 201 + agent_dir = self._user_dir(tmp_path) / "agents" / "locked" + denied = PermissionError(errno.EACCES, os.strerror(errno.EACCES), str(agent_dir)) + + with patch.object(FileAgentStore, "delete", side_effect=denied): + response = agent_client.delete("/api/agents/locked") + + assert response.status_code == 500 + assert response.json() == {"detail": "Failed to delete agent"} + assert str(agent_dir) in caplog.text + + @pytest.mark.parametrize( + ("method", "kwargs", "detail"), + [ + ("get", {}, "Failed to read user profile"), + ("put", {"json": {"content": "hi"}}, "Failed to update user profile"), + ], + ) + def test_user_profile_os_error_returns_fixed_500(self, agent_client, tmp_path, caplog, method, kwargs, detail): + user_md = self._user_dir(tmp_path) / "USER.md" + user_md.mkdir(parents=True) + + response = getattr(agent_client, method)("/api/user-profile", **kwargs) + + assert response.status_code == 500 + assert response.json() == {"detail": detail} + assert str(user_md) in caplog.text + + class TestAgentsApiDisabled: def test_agents_list_returns_403(self, disabled_agent_client): response = disabled_agent_client.get("/api/agents") diff --git a/backend/tests/test_scheduled_task_router_behavior.py b/backend/tests/test_scheduled_task_router_behavior.py index 6826f2a3ad..717e486441 100644 --- a/backend/tests/test_scheduled_task_router_behavior.py +++ b/backend/tests/test_scheduled_task_router_behavior.py @@ -1304,6 +1304,23 @@ async def test_create_invalid_assistant_id_is_rejected(): assert "Invalid assistant_id" in exc_info.value.detail["message"] +@pytest.mark.asyncio +async def test_create_unparsable_assistant_config_keeps_host_path_out_of_422(caplog): + config_file = "/srv/deer-flow/users/user-1/agents/broken-bot/config.yaml" + with patch( + "app.gateway.routers.scheduled_tasks.load_agent_config", + side_effect=ValueError(f"Failed to parse agent config {config_file}: mapping values are not allowed here"), + ): + with pytest.raises(HTTPException) as exc_info: + await _call_create(_create_request(assistant_id="broken-bot")) + assert exc_info.value.status_code == 422 + assert exc_info.value.detail == { + "code": "invalid_assistant", + "message": "Agent config for assistant_id 'broken-bot' could not be loaded", + } + assert config_file in caplog.text + + @pytest.mark.asyncio async def test_update_custom_assistant_id_is_persisted(): repo = _Repo()