feat(plugins): add redacted administrator diagnostics (#7612)

* feat(plugins): add redacted administrator diagnostics

* ci: retrigger plugin diagnostics checks
This commit is contained in:
Wenchao An 2026-10-27 21:29:21 +08:00 committed by GitHub
parent fca086403d
commit 7feaf051a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 287 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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() == []

View File

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

View File

@ -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)均不可访问。
任务生命周期钩子和观察者的失败只记日志,不会进入这个列表。