mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 13:40:37 +00:00
feat(plugins): add redacted administrator diagnostics (#7612)
* feat(plugins): add redacted administrator diagnostics * ci: retrigger plugin diagnostics checks
This commit is contained in:
parent
fca086403d
commit
7feaf051a2
@ -148,6 +148,7 @@ to a boundary (SQLite) or causing the search to fail (PostgreSQL).
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| `GET /api/models` | List available LLM models |
|
||||
| `GET /api/plugins/diagnostics` | Read redacted diagnostics for this Gateway process (admin only) |
|
||||
| `GET/PUT /api/mcp/config` | Manage MCP server configurations |
|
||||
| `POST /api/mcp/cache/reset` | Reset cached MCP tools so they reload on next use |
|
||||
| `GET /api/skills` | List skills visible to the caller |
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
# Router contracts
|
||||
|
||||
`GET /api/plugins/diagnostics` uses `require_admin_user` before reading the
|
||||
canonical runtime diagnostic snapshot. Return bounded entry-point identifiers,
|
||||
known levels and fixed summaries; raw messages stay in logs. Never clear the
|
||||
sink or expand PAT permissions.
|
||||
|
||||
Branch metadata inherits valid `assistant_id` values verbatim. Validate with
|
||||
`custom_agent_name` and clear invalid legacy values to null in the new row;
|
||||
leave the source record unchanged.
|
||||
|
||||
@ -4,17 +4,59 @@ import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from types import MappingProxyType
|
||||
|
||||
from deerflow_extension_api.auth import resolve_principal
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
|
||||
from app.gateway.deps import require_admin_user
|
||||
from deerflow.extensions import get_runtime_diagnostics
|
||||
from deerflow.extensions.browser_assets import LoadedBrowserAssets, valid_asset_path
|
||||
from deerflow.extensions.plugin_tools import plugin_settings
|
||||
|
||||
router = APIRouter(prefix="/api/plugins", tags=["plugins"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DIAGNOSTIC_SOURCE = re.compile(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*:[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*", re.ASCII)
|
||||
|
||||
|
||||
def _diagnostic_summary(message: str) -> str:
|
||||
# Never scrub and return arbitrary exception text: secrets have no fixed format.
|
||||
summaries = (
|
||||
(
|
||||
(
|
||||
"could not resolve extension entry point:",
|
||||
"extension entry point is not callable:",
|
||||
"could not inspect extension-api version marker:",
|
||||
"extension declares invalid extension-api version marker",
|
||||
"extension requires extension-api ",
|
||||
),
|
||||
"Extension loading failed.",
|
||||
),
|
||||
(("install() failed:",), "Extension registration failed."),
|
||||
(("router ",), "Extension router could not be mounted."),
|
||||
(("service start()",), "Extension service could not start."),
|
||||
(("service stop()",), "Extension service could not stop."),
|
||||
)
|
||||
summary = next((summary for prefixes, summary in summaries if isinstance(message, str) and message.startswith(prefixes)), "Extension reported a diagnostic.")
|
||||
return f"{summary} See Gateway logs for details."
|
||||
|
||||
|
||||
@router.get("/diagnostics")
|
||||
async def plugin_diagnostics(request: Request, response: Response) -> list[dict[str, str]]:
|
||||
"""Read a safe snapshot of this Gateway process's retained diagnostics."""
|
||||
await require_admin_user(request, detail="Only administrators can read plugin diagnostics.")
|
||||
response.headers["Cache-Control"] = "private, no-store"
|
||||
return [
|
||||
{
|
||||
"source": item.source if isinstance(item.source, str) and len(item.source) <= 256 and _DIAGNOSTIC_SOURCE.fullmatch(item.source) else "[redacted]",
|
||||
"level": item.level if isinstance(item.level, str) and item.level in {"debug", "info", "warning", "error"} else "unknown",
|
||||
"summary": _diagnostic_summary(item.message),
|
||||
}
|
||||
for item in get_runtime_diagnostics()
|
||||
]
|
||||
|
||||
|
||||
def _principal(request):
|
||||
principal = resolve_principal(request)
|
||||
|
||||
@ -730,6 +730,38 @@ the existing Agent. Import is create-only and never restores runtime state.
|
||||
- `/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
|
||||
|
||||
### Plugin Diagnostics
|
||||
|
||||
`GET /api/plugins/diagnostics`
|
||||
|
||||
Returns a snapshot of the current Gateway process's retained extension diagnostics.
|
||||
Requires an authenticated administrator; ordinary users and PAT credentials,
|
||||
including an administrator's PAT, cannot access it. The existing auth-disabled
|
||||
mode retains its synthetic administrator behavior.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"source": "example_extension:install",
|
||||
"level": "error",
|
||||
"summary": "Extension registration failed. See Gateway logs for details."
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- `source` is an ASCII Python entry-point identifier of at most 256 characters;
|
||||
other source strings are replaced with `[redacted]`.
|
||||
- `level` is `debug`, `info`, `warning`, or `error`; unexpected levels become `unknown`.
|
||||
- `summary` is fixed host text describing a recognized loading, registration,
|
||||
router, or service failure, with a generic fallback for other diagnostics.
|
||||
Raw exception messages, configuration, paths, and tracebacks are not returned.
|
||||
|
||||
This read does not clear or modify diagnostics. It reuses the existing 1000-entry
|
||||
retention limit, returns `[]` when empty, and sends `Cache-Control: private, no-store`.
|
||||
It is a process-local view, not an aggregate across Gateway workers. Task lifecycle
|
||||
and observer failures that are only logged do not appear in this list. Consult
|
||||
Gateway logs for full details.
|
||||
|
||||
### Models
|
||||
|
||||
#### List Models
|
||||
|
||||
@ -83,8 +83,8 @@ class ExtensionSpec(BaseModel):
|
||||
class Diagnostic:
|
||||
"""A load- or run-time problem attributed to a specific extension.
|
||||
|
||||
The repository has no structured diagnostics channel today; this is a
|
||||
deliberately minimal one whose only job is keeping failures attributable.
|
||||
Raw details stay in the Gateway log. HTTP diagnostics use a redacted
|
||||
projection while this record keeps the failure attributable.
|
||||
"""
|
||||
|
||||
level: DiagnosticLevel
|
||||
|
||||
191
backend/tests/test_plugin_diagnostics.py
Normal file
191
backend/tests/test_plugin_diagnostics.py
Normal file
@ -0,0 +1,191 @@
|
||||
"""Administrator-only, read-only projections of the live extension diagnostics."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.gateway.auth_middleware import AuthMiddleware
|
||||
from app.gateway.internal_auth import create_internal_auth_headers
|
||||
from app.gateway.routers.plugins import router
|
||||
from deerflow.config.authorization_config import AuthorizationConfig
|
||||
from deerflow.extensions import initialize_runtime_diagnostics, record_runtime_diagnostics
|
||||
from deerflow.extensions.loader import Diagnostic, ExtensionSpec, load_extensions
|
||||
|
||||
URL = "/api/plugins/diagnostics"
|
||||
SECRET = "fixture-private-key"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def diagnostics_client(monkeypatch):
|
||||
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "")
|
||||
monkeypatch.setattr("app.gateway.authz._get_route_authorization_config", lambda: AuthorizationConfig())
|
||||
monkeypatch.setattr("deerflow.extensions._runtime_diagnostics", [])
|
||||
users = {role: SimpleNamespace(id=role, system_role=role, needs_setup=False, token_version=0) for role in ("admin", "user")}
|
||||
|
||||
async def resolve_session(request):
|
||||
user = users.get(request.cookies.get("access_token"))
|
||||
if user is None:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
return user
|
||||
|
||||
monkeypatch.setattr("app.gateway.deps.get_current_user_from_request", resolve_session)
|
||||
app = FastAPI()
|
||||
app.state.extension_diagnostics = initialize_runtime_diagnostics([])
|
||||
app.include_router(router)
|
||||
app.add_middleware(AuthMiddleware)
|
||||
with TestClient(app) as client:
|
||||
yield client, users
|
||||
|
||||
|
||||
def admin(client):
|
||||
client.cookies.set("access_token", "admin")
|
||||
|
||||
|
||||
def test_admin_reads_live_diagnostics_without_exposing_or_changing_details(diagnostics_client):
|
||||
client, _ = diagnostics_client
|
||||
admin(client)
|
||||
diagnostics = client.app.state.extension_diagnostics
|
||||
first = Diagnostic.error("example:install", f"install() failed: api_key={SECRET}; /private/config.yaml")
|
||||
record_runtime_diagnostics([first])
|
||||
response = client.get(URL)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "private, no-store"
|
||||
assert response.json() == [{"source": "example:install", "level": "error", "summary": "Extension registration failed. See Gateway logs for details."}]
|
||||
assert SECRET not in response.text and "/private/config.yaml" not in response.text
|
||||
assert diagnostics == [first]
|
||||
record_runtime_diagnostics([Diagnostic.warning("other.plugin:install", f"service start() failed; continuing without it: {SECRET}")])
|
||||
updated = client.get(URL)
|
||||
assert updated.status_code == 200 and len(updated.json()) == 2
|
||||
assert updated.json()[1]["summary"] == "Extension service could not start. See Gateway logs for details."
|
||||
assert SECRET not in updated.text
|
||||
assert client.app.state.extension_diagnostics is diagnostics
|
||||
assert diagnostics[0] is first and SECRET in diagnostics[0].message
|
||||
|
||||
|
||||
def test_empty_diagnostics_are_an_empty_array(diagnostics_client):
|
||||
client, _ = diagnostics_client
|
||||
admin(client)
|
||||
assert client.get(URL).json() == []
|
||||
|
||||
|
||||
def test_failed_extension_is_visible_even_without_a_registered_plugin(diagnostics_client):
|
||||
client, _ = diagnostics_client
|
||||
admin(client)
|
||||
loaded, diagnostics = load_extensions([ExtensionSpec(use="nonexistent_diagnostics_fixture:install")])
|
||||
assert not loaded.plugins
|
||||
record_runtime_diagnostics(diagnostics)
|
||||
response = client.get(URL)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [{"source": "nonexistent_diagnostics_fixture:install", "level": "error", "summary": "Extension loading failed. See Gateway logs for details."}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", [None, "user", "internal"])
|
||||
def test_non_admin_cannot_read_even_the_diagnostics_list(diagnostics_client, monkeypatch, role):
|
||||
client, _ = diagnostics_client
|
||||
|
||||
def unreadable():
|
||||
pytest.fail("Diagnostics were read before administrator authorization")
|
||||
|
||||
monkeypatch.setattr("app.gateway.routers.plugins.get_runtime_diagnostics", unreadable, raising=False)
|
||||
headers = {}
|
||||
if role == "internal":
|
||||
headers = create_internal_auth_headers()
|
||||
elif role:
|
||||
client.cookies.set("access_token", role)
|
||||
response = client.get(URL, headers=headers)
|
||||
assert response.status_code == (401 if role is None else 403)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("allow_route", [False, True])
|
||||
def test_admin_pat_has_no_diagnostic_access(diagnostics_client, monkeypatch, allow_route):
|
||||
client, users = diagnostics_client
|
||||
monkeypatch.setattr("app.gateway.auth.pat.authenticate_pat", AsyncMock(return_value=(users["admin"], ["threads:read"])))
|
||||
if allow_route:
|
||||
# Pin the route's own admin guard even if an alternative host admits PATs.
|
||||
monkeypatch.setattr("app.gateway.auth.pat.is_pat_allowed_route", lambda method, path: True)
|
||||
response = client.get(URL, headers={"Authorization": "Bearer fixture-token"})
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH", "DELETE"])
|
||||
def test_diagnostics_have_no_write_operation(diagnostics_client, method):
|
||||
client, _ = diagnostics_client
|
||||
admin(client)
|
||||
diagnostic = Diagnostic.error("example:install", SECRET)
|
||||
record_runtime_diagnostics([diagnostic])
|
||||
assert client.request(method, URL, json={"clear": True}).status_code == 405
|
||||
assert client.app.state.extension_diagnostics == [diagnostic]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source",
|
||||
[f"/private/{SECRET}.py", f"https://user:{SECRET}@example.com/plugin", f"example:install\n{SECRET}", f"{SECRET}" * 100, ""],
|
||||
ids=["path", "credentials", "newline", "oversized", "empty"],
|
||||
)
|
||||
def test_invalid_or_oversized_sources_are_redacted(diagnostics_client, source):
|
||||
client, _ = diagnostics_client
|
||||
admin(client)
|
||||
record_runtime_diagnostics([Diagnostic.error(source, f"arbitrary failure: {SECRET}")])
|
||||
response = client.get(URL)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [{"source": "[redacted]", "level": "error", "summary": "Extension reported a diagnostic. See Gateway logs for details."}]
|
||||
assert SECRET not in response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("message", "summary"),
|
||||
[
|
||||
("extension requires extension-api private-version", "Extension loading failed."),
|
||||
("install() failed: credential", "Extension registration failed."),
|
||||
("router path /private is already served", "Extension router could not be mounted."),
|
||||
("service start() raised CancelledError; continuing without it", "Extension service could not start."),
|
||||
("service stop() timed out after 5s; continuing shutdown", "Extension service could not stop."),
|
||||
("PrivateMiddleware.before_model failed and was skipped", "Extension reported a diagnostic."),
|
||||
],
|
||||
)
|
||||
def test_only_fixed_summaries_are_returned(diagnostics_client, message, summary):
|
||||
client, _ = diagnostics_client
|
||||
admin(client)
|
||||
record_runtime_diagnostics([Diagnostic.warning("example:install", f"{message}: {SECRET}")])
|
||||
response = client.get(URL)
|
||||
assert response.status_code == 200
|
||||
assert response.json()[0] == {"source": "example:install", "level": "warning", "summary": f"{summary} See Gateway logs for details."}
|
||||
assert SECRET not in response.text
|
||||
|
||||
|
||||
def test_existing_retention_bound_is_preserved_without_draining_the_list(diagnostics_client):
|
||||
client, _ = diagnostics_client
|
||||
admin(client)
|
||||
records = [Diagnostic.error(f"example_{index}:install", SECRET) for index in range(1005)]
|
||||
record_runtime_diagnostics(records)
|
||||
response = client.get(URL)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 1000
|
||||
assert response.json()[0]["source"] == "example_5:install"
|
||||
assert response.json()[-1]["source"] == "example_1004:install"
|
||||
assert client.app.state.extension_diagnostics == records[-1000:]
|
||||
assert client.get(URL).json() == response.json()
|
||||
assert SECRET not in response.text
|
||||
|
||||
|
||||
def test_unknown_level_is_not_returned_as_free_form_text(diagnostics_client):
|
||||
client, _ = diagnostics_client
|
||||
admin(client)
|
||||
record_runtime_diagnostics([Diagnostic(SECRET, "example:install", SECRET)])
|
||||
response = client.get(URL)
|
||||
assert response.status_code == 200
|
||||
assert response.json()[0]["level"] == "unknown"
|
||||
assert SECRET not in response.text
|
||||
|
||||
|
||||
def test_auth_disabled_mode_keeps_the_existing_synthetic_admin_policy(diagnostics_client, monkeypatch):
|
||||
client, _ = diagnostics_client
|
||||
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
|
||||
monkeypatch.setenv("DEER_FLOW_ENV", "test")
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
response = client.get(URL)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
@ -191,7 +191,14 @@ At shutdown the Gateway stops accepting new fire-and-forget observations, such a
|
||||
|
||||
## Diagnostics
|
||||
|
||||
A diagnostic is a problem attributed to one extension. Every diagnostic is written to the Gateway log with the entry point as its prefix. Load failures, middleware construction and isolation failures, router mount rejections, and service start and stop failures are also collected in a live list on `app.state.extension_diagnostics`, which keeps the most recent **1000** entries. There is no HTTP endpoint for it, so operators read the Gateway log.
|
||||
A diagnostic is a problem attributed to one extension. Every diagnostic is written to the Gateway log with the entry point as its prefix. Load failures, middleware construction and isolation failures, router mount rejections, and service start and stop failures are also collected in a live list on `app.state.extension_diagnostics`, which keeps the most recent **1000** entries.
|
||||
|
||||
Administrators can read that list through `GET /api/plugins/diagnostics`. It returns
|
||||
entry-point identifiers, levels, and fixed safe summaries; unrecognized source
|
||||
strings are redacted. Raw messages, configuration, paths, and tracebacks stay in
|
||||
the Gateway log. The response is not cached and reading it does not clear the list.
|
||||
It covers the current Gateway process only. Ordinary users and PAT credentials,
|
||||
including administrator PATs, cannot use this endpoint.
|
||||
|
||||
Failures of task-lifecycle hooks and observers are logged but not added to that list.
|
||||
|
||||
|
||||
@ -191,7 +191,12 @@ token 预算未启用时,五个 token 字段都是 `None`。子 Agent 的构
|
||||
|
||||
## 诊断
|
||||
|
||||
诊断是归属到某个扩展的问题。每条诊断都会以入口点为前缀写入 Gateway 日志。加载失败、中间件构建与隔离失败、路由挂载被拒,以及服务启动与停止失败,还会被收集到 `app.state.extension_diagnostics` 上的一个实时列表里,只保留最近的 **1000** 条。它没有 HTTP 端点,运维人员请查看 Gateway 日志。
|
||||
诊断是归属到某个扩展的问题。每条诊断都会以入口点为前缀写入 Gateway 日志。加载失败、中间件构建与隔离失败、路由挂载被拒,以及服务启动与停止失败,还会被收集到 `app.state.extension_diagnostics` 上的一个实时列表里,只保留最近的 **1000** 条。
|
||||
|
||||
管理员可通过 `GET /api/plugins/diagnostics` 只读查询这个列表。接口返回入口标识、
|
||||
级别和固定的安全摘要;不符合入口格式的来源字符串会被脱敏。异常原文、配置、
|
||||
路径和堆栈仍只留在 Gateway 日志里。响应不缓存,查询也不会清空列表。
|
||||
它只反映当前 Gateway 进程;普通用户和 PAT 凭据(包括管理员的 PAT)均不可访问。
|
||||
|
||||
任务生命周期钩子和观察者的失败只记日志,不会进入这个列表。
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user