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/<user_id>/, so a caller saw it in the
response. Return the fixed "Failed to <action>" 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
This commit is contained in:
Hyeonsang Cho 2026-10-22 09:41:07 +09:00 committed by GitHub
parent a125277984
commit c5306142db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 151 additions and 8 deletions

View File

@ -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/<user_id>/`. They now return a fixed
`Failed to <action>` 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

View File

@ -5256,6 +5256,13 @@
### 安全
- **agents:** 自定义智能体与用户画像路由的 `500` detail 不再包含原始异常文本。
此前无法解析的智能体 `config.yaml` 或文件系统错误(如 `[Errno 13]`)会让响应
带出 `users/<user_id>/` 下的宿主机绝对路径;现在返回与智能体导入、导出一致的
固定 `Failed to <action>` 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

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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/<user_id>/``; 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")

View File

@ -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()