mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-22 14:08:28 +00:00
fix(gateway): require admin for global skills management endpoints (#3855)
* fix(gateway): require admin for global skills management endpoints The skills router exposed its management endpoints with no authorization check (only Depends(get_config)), while the MCP router guards the equivalent global extensions_config mutations with require_admin_user (added in the #3425 security hardening). Skills storage is global/shared across users (no per-user path), lives in the same extensions_config.json as MCP servers, and custom skill SKILL.md content is injected into every user's agent system prompt. Any authenticated non-admin user could mutate or read global skills that affect all tenants: - POST /api/skills/install: install from an arbitrary thread_id, writing into the global custom skills tree - PUT /api/skills/custom/{skill_name}: rewrite a global skill, injecting instructions into all users' agent prompts (cross-tenant prompt injection) - DELETE /api/skills/custom/{skill_name} and rollback: tamper with global skills - GET /api/skills/custom, GET .../{skill_name}, GET .../history: read raw global custom skill bodies/history - PUT /api/skills/{skill_name} (enable toggle): writes the shared extensions_config.json and refreshes the system prompt for every tenant, so a non-admin could enable/disable any skill globally. There is no per-user skill state, so this is a global mutation, not a preference. Add require_admin_user to every endpoint above, mirroring the MCP router. The shared read path used internally by update/rollback was extracted into a non-auth helper (_read_custom_skill_response) so internal reuse does not double-check auth. Only the read-only GET /api/skills and GET /api/skills/{skill_name} stay open to normal users: they return just name/description/enabled and back the user-facing settings UI. Tests: - New tests/test_skills_router_authz.py: a non-admin user gets 403 on every guarded endpoint (including the enable toggle); basic listing stays open; admins can still toggle. - Update tests/test_skills_custom_router.py to authenticate as admin. - pytest tests/ -k skill -> 350 passed, 1 skipped; ruff clean. Signed-off-by: DengY11 <151997860+DengY11@users.noreply.github.com> * fix(frontend): gate skill enable/install UI behind admin + handle 403 Follow-up to the backend change that made the global skills mutations admin-only. Without a matching UI change, a non-admin user would hit a silent 403 when toggling a skill in Settings -> Skills or clicking "Install skill" on a .skill artifact. - core/skills/api.ts: add SkillRequestError (status + isAdminRequired), throw it from loadSkills/enableSkill on non-ok responses and from installSkill on 403 (other install errors keep the soft-failure contract). - core/skills/hooks.ts: useSkills no longer retries on SkillRequestError. - skill-settings-page.tsx: show an "admin required" message on 403, and disable the enable toggle for non-admins (mirrors the MCP tools page). - artifact-file-detail.tsx / artifact-file-list.tsx: only render the "Install skill" action for admins, and surface an admin-required toast if a 403 still occurs. - i18n: add settings.skills.adminRequired / installAdminRequired (en + zh). Auth/no-auth and static-website modes synthesize an admin user, so these gates do not affect single-user/local deployments. Verified locally: pnpm check (eslint + tsc) passes with no new errors, pnpm build succeeds, and the dev server renders / and /login (200) with no compile/runtime errors. Signed-off-by: DengY11 <151997860+DengY11@users.noreply.github.com> * style(frontend): format skills hook for prettier Keep the admin-guard skills hook aligned with Prettier output so the frontend format check passes in CI. --------- Signed-off-by: DengY11 <151997860+DengY11@users.noreply.github.com>
This commit is contained in:
parent
ddb097a72f
commit
331c949b95
@ -2,10 +2,10 @@ import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_config
|
||||
from app.gateway.deps import get_config, require_admin_user
|
||||
from app.gateway.path_utils import resolve_thread_virtual_path
|
||||
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||
from deerflow.config.app_config import AppConfig
|
||||
@ -20,6 +20,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["skills"])
|
||||
|
||||
_ADMIN_REQUIRED_DETAIL = "Admin privileges required to manage skills."
|
||||
|
||||
|
||||
class SkillResponse(BaseModel):
|
||||
"""Response model for skill information."""
|
||||
@ -106,9 +108,10 @@ async def list_skills(config: AppConfig = Depends(get_config)) -> SkillsListResp
|
||||
summary="Install Skill",
|
||||
description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.",
|
||||
)
|
||||
async def install_skill(request: SkillInstallRequest, config: AppConfig = Depends(get_config)) -> SkillInstallResponse:
|
||||
async def install_skill(request: Request, body: SkillInstallRequest, config: AppConfig = Depends(get_config)) -> SkillInstallResponse:
|
||||
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
||||
try:
|
||||
skill_file_path = resolve_thread_virtual_path(request.thread_id, request.path)
|
||||
skill_file_path = resolve_thread_virtual_path(body.thread_id, body.path)
|
||||
result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path)
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
return SkillInstallResponse(**result)
|
||||
@ -126,7 +129,8 @@ async def install_skill(request: SkillInstallRequest, config: AppConfig = Depend
|
||||
|
||||
|
||||
@router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills")
|
||||
async def list_custom_skills(config: AppConfig = Depends(get_config)) -> SkillsListResponse:
|
||||
async def list_custom_skills(request: Request, config: AppConfig = Depends(get_config)) -> SkillsListResponse:
|
||||
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
||||
try:
|
||||
skills = [skill for skill in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if skill.category == SkillCategory.CUSTOM]
|
||||
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
||||
@ -136,7 +140,12 @@ async def list_custom_skills(config: AppConfig = Depends(get_config)) -> SkillsL
|
||||
|
||||
|
||||
@router.get("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Get Custom Skill Content")
|
||||
async def get_custom_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
|
||||
async def get_custom_skill(skill_name: str, request: Request, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
|
||||
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
||||
return await _read_custom_skill_response(skill_name, config)
|
||||
|
||||
|
||||
async def _read_custom_skill_response(skill_name: str, config: AppConfig) -> CustomSkillContentResponse:
|
||||
try:
|
||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||
@ -152,17 +161,18 @@ async def get_custom_skill(skill_name: str, config: AppConfig = Depends(get_conf
|
||||
|
||||
|
||||
@router.put("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Edit Custom Skill")
|
||||
async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
|
||||
async def update_custom_skill(skill_name: str, body: CustomSkillUpdateRequest, request: Request, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
|
||||
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
||||
try:
|
||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||
storage = get_or_new_skill_storage(app_config=config)
|
||||
storage.ensure_custom_skill_is_editable(skill_name)
|
||||
storage.validate_skill_markdown_content(skill_name, request.content)
|
||||
scan = await scan_skill_content(request.content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config)
|
||||
storage.validate_skill_markdown_content(skill_name, body.content)
|
||||
scan = await scan_skill_content(body.content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config)
|
||||
if scan.decision == "block":
|
||||
raise HTTPException(status_code=400, detail=f"Security scan blocked the edit: {scan.reason}")
|
||||
prev_content = storage.read_custom_skill(skill_name)
|
||||
storage.write_custom_skill(skill_name, SKILL_MD_FILE, request.content)
|
||||
storage.write_custom_skill(skill_name, SKILL_MD_FILE, body.content)
|
||||
storage.append_history(
|
||||
skill_name,
|
||||
{
|
||||
@ -171,12 +181,12 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest
|
||||
"thread_id": None,
|
||||
"file_path": SKILL_MD_FILE,
|
||||
"prev_content": prev_content,
|
||||
"new_content": request.content,
|
||||
"new_content": body.content,
|
||||
"scanner": {"decision": scan.decision, "reason": scan.reason},
|
||||
},
|
||||
)
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
return await get_custom_skill(skill_name, config)
|
||||
return await _read_custom_skill_response(skill_name, config)
|
||||
except HTTPException:
|
||||
raise
|
||||
except FileNotFoundError as e:
|
||||
@ -189,7 +199,8 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest
|
||||
|
||||
|
||||
@router.delete("/skills/custom/{skill_name}", summary="Delete Custom Skill")
|
||||
async def delete_custom_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> dict[str, bool]:
|
||||
async def delete_custom_skill(skill_name: str, request: Request, config: AppConfig = Depends(get_config)) -> dict[str, bool]:
|
||||
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
||||
try:
|
||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||
storage = get_or_new_skill_storage(app_config=config)
|
||||
@ -217,7 +228,8 @@ async def delete_custom_skill(skill_name: str, config: AppConfig = Depends(get_c
|
||||
|
||||
|
||||
@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History")
|
||||
async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
|
||||
async def get_custom_skill_history(skill_name: str, request: Request, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
|
||||
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
||||
try:
|
||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||
storage = get_or_new_skill_storage(app_config=config)
|
||||
@ -232,7 +244,8 @@ async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(
|
||||
|
||||
|
||||
@router.post("/skills/custom/{skill_name}/rollback", response_model=CustomSkillContentResponse, summary="Rollback Custom Skill")
|
||||
async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
|
||||
async def rollback_custom_skill(skill_name: str, body: SkillRollbackRequest, request: Request, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
|
||||
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
||||
try:
|
||||
storage = get_or_new_skill_storage(app_config=config)
|
||||
if not storage.custom_skill_exists(skill_name) and not storage.get_skill_history_file(skill_name).exists():
|
||||
@ -240,7 +253,7 @@ async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest,
|
||||
history = storage.read_history(skill_name)
|
||||
if not history:
|
||||
raise HTTPException(status_code=400, detail=f"Custom skill '{skill_name}' has no history")
|
||||
record = history[request.history_index]
|
||||
record = history[body.history_index]
|
||||
target_content = record.get("prev_content")
|
||||
if target_content is None:
|
||||
raise HTTPException(status_code=400, detail="Selected history entry has no previous content to roll back to")
|
||||
@ -264,7 +277,7 @@ async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest,
|
||||
storage.write_custom_skill(skill_name, SKILL_MD_FILE, target_content)
|
||||
storage.append_history(skill_name, history_entry)
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
return await get_custom_skill(skill_name, config)
|
||||
return await _read_custom_skill_response(skill_name, config)
|
||||
except HTTPException:
|
||||
raise
|
||||
except IndexError:
|
||||
@ -307,7 +320,12 @@ async def get_skill(skill_name: str, config: AppConfig = Depends(get_config)) ->
|
||||
summary="Update Skill",
|
||||
description="Update a skill's enabled status by modifying the extensions_config.json file.",
|
||||
)
|
||||
async def update_skill(skill_name: str, request: SkillUpdateRequest, config: AppConfig = Depends(get_config)) -> SkillResponse:
|
||||
async def update_skill(skill_name: str, body: SkillUpdateRequest, request: Request, config: AppConfig = Depends(get_config)) -> SkillResponse:
|
||||
# Enabling/disabling a skill writes the shared extensions_config.json and
|
||||
# refreshes the system prompt for every tenant, so it is a global mutation
|
||||
# (there is no per-user skill state). Guard it as admin-only like the other
|
||||
# global config writes, matching the MCP router.
|
||||
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
||||
try:
|
||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||
@ -322,7 +340,7 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
|
||||
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
|
||||
|
||||
extensions_config = get_extensions_config()
|
||||
extensions_config.skills[skill_name] = SkillStateConfig(enabled=request.enabled)
|
||||
extensions_config.skills[skill_name] = SkillStateConfig(enabled=body.enabled)
|
||||
|
||||
config_data = {
|
||||
"mcpServers": {name: server.model_dump() for name, server in extensions_config.mcp_servers.items()},
|
||||
@ -342,7 +360,7 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
|
||||
if updated_skill is None:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after update")
|
||||
|
||||
logger.info(f"Skill '{skill_name}' enabled status updated to {request.enabled}")
|
||||
logger.info(f"Skill '{skill_name}' enabled status updated to {body.enabled}")
|
||||
return _skill_to_response(updated_skill)
|
||||
|
||||
except HTTPException:
|
||||
|
||||
@ -10,6 +10,7 @@ from _router_auth_helpers import make_authed_test_app
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.gateway.auth.models import User
|
||||
from app.gateway.deps import get_config
|
||||
from app.gateway.routers import skills as skills_router
|
||||
from app.gateway.routers import uploads as uploads_router
|
||||
@ -17,6 +18,12 @@ from deerflow.skills.storage import get_or_new_skill_storage
|
||||
from deerflow.skills.types import Skill
|
||||
|
||||
|
||||
def _make_admin_user() -> User:
|
||||
from uuid import uuid4
|
||||
|
||||
return User(email="admin-test@example.com", password_hash="x", system_role="admin", id=uuid4())
|
||||
|
||||
|
||||
def _skill_content(name: str, description: str = "Demo skill") -> str:
|
||||
return f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"
|
||||
|
||||
@ -42,7 +49,7 @@ def _make_skill(name: str, *, enabled: bool) -> Skill:
|
||||
|
||||
|
||||
def _make_test_app(config) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app = make_authed_test_app(user_factory=_make_admin_user)
|
||||
app.state.config = config # kept for any startup-style reads
|
||||
app.dependency_overrides[get_config] = lambda: config
|
||||
app.include_router(skills_router.router)
|
||||
@ -141,7 +148,7 @@ def test_uploaded_skill_archive_installs_sandbox_readable_tree(monkeypatch, tmp_
|
||||
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
|
||||
monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh)
|
||||
|
||||
app = make_authed_test_app()
|
||||
app = make_authed_test_app(user_factory=_make_admin_user)
|
||||
app.state.config = config
|
||||
app.dependency_overrides[get_config] = lambda: config
|
||||
app.include_router(uploads_router.router)
|
||||
|
||||
141
backend/tests/test_skills_router_authz.py
Normal file
141
backend/tests/test_skills_router_authz.py
Normal file
@ -0,0 +1,141 @@
|
||||
"""Authorization regression tests for the skills router.
|
||||
|
||||
Skills storage is global/shared across all users, and custom skill SKILL.md
|
||||
content is injected into every user's agent system prompt. The mutating skills
|
||||
endpoints (and the endpoints that expose raw custom-skill content/history) must
|
||||
therefore be admin-only, matching the MCP router which guards the equivalent
|
||||
global extensions_config mutations with ``require_admin_user``.
|
||||
|
||||
These tests pin the access-control boundary: a normal authenticated
|
||||
(non-admin) user must receive 403 on every guarded endpoint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
from _router_auth_helpers import make_authed_test_app
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.gateway.auth.models import User
|
||||
from app.gateway.deps import get_config
|
||||
from app.gateway.routers import skills as skills_router
|
||||
|
||||
|
||||
def _make_user(system_role: str) -> User:
|
||||
return User(email=f"{system_role}-test@example.com", password_hash="x", system_role=system_role, id=uuid4())
|
||||
|
||||
|
||||
def _make_app(*, system_role: str) -> FastAPI:
|
||||
config = SimpleNamespace(
|
||||
skills=SimpleNamespace(get_skills_path=lambda: "/tmp/skills", container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
|
||||
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
|
||||
)
|
||||
app = make_authed_test_app(user_factory=lambda: _make_user(system_role))
|
||||
app.state.config = config
|
||||
app.dependency_overrides[get_config] = lambda: config
|
||||
app.include_router(skills_router.router)
|
||||
return app
|
||||
|
||||
|
||||
# (method, path, json_body) for every endpoint that must require admin.
|
||||
# Every entry here writes/reads global shared state (the custom skills tree,
|
||||
# the shared extensions_config.json, or raw global skill content), so all are
|
||||
# admin-only. PUT /api/skills/{name} is included: toggling enabled writes the
|
||||
# shared extensions_config.json and changes every tenant's injected skill set.
|
||||
_GUARDED_ENDPOINTS = [
|
||||
("post", "/api/skills/install", {"thread_id": "t1", "path": "mnt/user-data/outputs/x.skill"}),
|
||||
("get", "/api/skills/custom", None),
|
||||
("get", "/api/skills/custom/demo", None),
|
||||
("put", "/api/skills/custom/demo", {"content": "---\nname: demo\ndescription: hijacked\n---\n"}),
|
||||
("delete", "/api/skills/custom/demo", None),
|
||||
("get", "/api/skills/custom/demo/history", None),
|
||||
("post", "/api/skills/custom/demo/rollback", {"history_index": -1}),
|
||||
("put", "/api/skills/demo", {"enabled": False}),
|
||||
]
|
||||
|
||||
|
||||
def test_non_admin_is_forbidden_on_all_mutating_skills_endpoints():
|
||||
"""A normal (non-admin) authenticated user must get 403, never 200/500.
|
||||
|
||||
403 proves the admin guard fired before any business logic ran. If the
|
||||
guard were missing the request would instead reach the handler and return
|
||||
200 or a 4xx/5xx from the storage layer.
|
||||
"""
|
||||
app = _make_app(system_role="user")
|
||||
with TestClient(app) as client:
|
||||
for method, path, body in _GUARDED_ENDPOINTS:
|
||||
resp = getattr(client, method)(path, json=body) if body is not None else getattr(client, method)(path)
|
||||
assert resp.status_code == 403, f"{method.upper()} {path} expected 403 for non-admin, got {resp.status_code}"
|
||||
|
||||
|
||||
def test_basic_skill_listing_stays_open_to_normal_users(monkeypatch):
|
||||
"""The basic list/detail endpoints expose only name/description and are
|
||||
needed by the normal-user UI, so they must NOT be admin-gated.
|
||||
"""
|
||||
|
||||
def _load_skills(*, enabled_only: bool):
|
||||
from pathlib import Path
|
||||
|
||||
from deerflow.skills.types import Skill
|
||||
|
||||
return [
|
||||
Skill(
|
||||
name="demo",
|
||||
description="d",
|
||||
license="MIT",
|
||||
skill_dir=Path("/tmp/demo"),
|
||||
skill_file=Path("/tmp/demo/SKILL.md"),
|
||||
relative_path=Path("demo"),
|
||||
category="public",
|
||||
enabled=True,
|
||||
)
|
||||
]
|
||||
|
||||
app = _make_app(system_role="user")
|
||||
app.dependency_overrides[get_config] = lambda: SimpleNamespace()
|
||||
monkeypatch.setattr(skills_router, "get_or_new_skill_storage", lambda **kw: SimpleNamespace(load_skills=_load_skills))
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/api/skills").status_code == 200
|
||||
assert client.get("/api/skills/demo").status_code == 200
|
||||
|
||||
|
||||
def test_enable_toggle_allowed_for_admin(monkeypatch, tmp_path):
|
||||
"""`PUT /api/skills/{name}` writes the shared extensions_config.json, so it
|
||||
is admin-only. This confirms the guard does not block a legitimate admin.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from deerflow.skills.types import Skill
|
||||
|
||||
config_path = tmp_path / "extensions_config.json"
|
||||
|
||||
def _load_skills(*, enabled_only: bool):
|
||||
return [
|
||||
Skill(
|
||||
name="demo",
|
||||
description="d",
|
||||
license="MIT",
|
||||
skill_dir=Path("/tmp/demo"),
|
||||
skill_file=Path("/tmp/demo/SKILL.md"),
|
||||
relative_path=Path("demo"),
|
||||
category="public",
|
||||
enabled=True,
|
||||
)
|
||||
]
|
||||
|
||||
app = _make_app(system_role="admin")
|
||||
monkeypatch.setattr(skills_router, "get_or_new_skill_storage", lambda **kw: SimpleNamespace(load_skills=_load_skills))
|
||||
monkeypatch.setattr(skills_router, "get_extensions_config", lambda: SimpleNamespace(mcp_servers={}, skills={}))
|
||||
monkeypatch.setattr(skills_router, "reload_extensions_config", lambda: None)
|
||||
monkeypatch.setattr(skills_router.ExtensionsConfig, "resolve_config_path", staticmethod(lambda: config_path))
|
||||
|
||||
async def _refresh():
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh)
|
||||
with TestClient(app) as client:
|
||||
resp = client.put("/api/skills/demo", json={"enabled": False})
|
||||
assert resp.status_code == 200, f"admin toggle should succeed, got {resp.status_code}"
|
||||
@ -38,10 +38,11 @@ import {
|
||||
HTML_PREVIEW_SCROLL_MESSAGE_SOURCE,
|
||||
} from "@/core/artifacts/preview";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { writeTextToClipboard } from "@/core/clipboard";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { findToolCallResult } from "@/core/messages/utils";
|
||||
import { installSkill } from "@/core/skills/api";
|
||||
import { installSkill, SkillRequestError } from "@/core/skills/api";
|
||||
import { SafeStreamdown } from "@/core/streamdown/components";
|
||||
import {
|
||||
canBrowserPreviewFile,
|
||||
@ -72,6 +73,8 @@ export function ArtifactFileDetail({
|
||||
threadId: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.system_role === "admin";
|
||||
const { artifacts, setOpen, select } = useArtifacts();
|
||||
const { thread, isMock } = useThread();
|
||||
const isWriteFile = useMemo(() => {
|
||||
@ -194,11 +197,15 @@ export function ArtifactFileDetail({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to install skill:", error);
|
||||
toast.error("Failed to install skill");
|
||||
if (error instanceof SkillRequestError && error.isAdminRequired) {
|
||||
toast.error(t.settings.skills.installAdminRequired);
|
||||
} else {
|
||||
toast.error("Failed to install skill");
|
||||
}
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
}, [threadId, filepath, isInstalling]);
|
||||
}, [threadId, filepath, isInstalling, t]);
|
||||
return (
|
||||
<Artifact className={cn(className)}>
|
||||
<ArtifactHeader className="px-2">
|
||||
@ -249,7 +256,7 @@ export function ArtifactFileDetail({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ArtifactActions>
|
||||
{!isWriteFile && filepath.endsWith(".skill") && (
|
||||
{!isWriteFile && filepath.endsWith(".skill") && isAdmin && (
|
||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||
<ArtifactAction
|
||||
icon={isInstalling ? LoaderIcon : PackageIcon}
|
||||
|
||||
@ -11,8 +11,9 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { installSkill } from "@/core/skills/api";
|
||||
import { installSkill, SkillRequestError } from "@/core/skills/api";
|
||||
import {
|
||||
getFileExtensionDisplayName,
|
||||
getFileIcon,
|
||||
@ -32,6 +33,8 @@ export function ArtifactFileList({
|
||||
threadId: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.system_role === "admin";
|
||||
const { select: selectArtifact, setOpen } = useArtifacts();
|
||||
const [installingFile, setInstallingFile] = useState<string | null>(null);
|
||||
|
||||
@ -63,12 +66,16 @@ export function ArtifactFileList({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to install skill:", error);
|
||||
toast.error("Failed to install skill");
|
||||
if (error instanceof SkillRequestError && error.isAdminRequired) {
|
||||
toast.error(t.settings.skills.installAdminRequired);
|
||||
} else {
|
||||
toast.error("Failed to install skill");
|
||||
}
|
||||
} finally {
|
||||
setInstallingFile(null);
|
||||
}
|
||||
},
|
||||
[threadId, installingFile],
|
||||
[threadId, installingFile, t],
|
||||
);
|
||||
|
||||
return (
|
||||
@ -90,7 +97,7 @@ export function ArtifactFileList({
|
||||
{getFileExtensionDisplayName(file)} file
|
||||
</CardDescription>
|
||||
<CardAction className="row-span-1 self-center">
|
||||
{file.endsWith(".skill") && (
|
||||
{file.endsWith(".skill") && isAdmin && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={installingFile === file}
|
||||
|
||||
@ -22,7 +22,9 @@ import {
|
||||
} from "@/components/ui/item";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { SkillRequestError } from "@/core/skills/api";
|
||||
import { useEnableSkill, useSkills } from "@/core/skills/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import { env } from "@/env";
|
||||
@ -32,6 +34,8 @@ import { SettingsSection } from "./settings-section";
|
||||
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
const { skills, isLoading, error } = useSkills();
|
||||
const adminRequired =
|
||||
error instanceof SkillRequestError && error.isAdminRequired;
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t.settings.skills.title}
|
||||
@ -39,6 +43,10 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
||||
) : adminRequired ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t.settings.skills.adminRequired}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div>Error: {error.message}</div>
|
||||
) : (
|
||||
@ -57,6 +65,8 @@ function SkillSettingsList({
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.system_role === "admin";
|
||||
const [filter, setFilter] = useState<string>("public");
|
||||
const { mutate: enableSkill } = useEnableSkill();
|
||||
const filteredSkills = useMemo(
|
||||
@ -102,7 +112,9 @@ function SkillSettingsList({
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || !isAdmin
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
enableSkill({ skillName: skill.name, enabled: checked })
|
||||
}
|
||||
|
||||
@ -525,6 +525,9 @@ export const enUS: Translations = {
|
||||
emptyDescription:
|
||||
"Put your agent skill folders under the `/skills/custom` folder under the root folder of DeerFlow.",
|
||||
emptyButton: "Create Your First Skill",
|
||||
adminRequired: "Admin privileges are required to manage agent skills.",
|
||||
installAdminRequired:
|
||||
"Admin privileges are required to install agent skills.",
|
||||
},
|
||||
notification: {
|
||||
title: "Notification",
|
||||
|
||||
@ -431,6 +431,8 @@ export interface Translations {
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
emptyButton: string;
|
||||
adminRequired: string;
|
||||
installAdminRequired: string;
|
||||
};
|
||||
notification: {
|
||||
title: string;
|
||||
|
||||
@ -503,6 +503,8 @@ export const zhCN: Translations = {
|
||||
emptyDescription:
|
||||
"将你的 Agent Skill 文件夹放在 DeerFlow 根目录下的 `/skills/custom` 文件夹中。",
|
||||
emptyButton: "创建你的第一个技能",
|
||||
adminRequired: "需要管理员权限才能管理 Agent Skill。",
|
||||
installAdminRequired: "需要管理员权限才能安装 Agent Skill。",
|
||||
},
|
||||
notification: {
|
||||
title: "通知",
|
||||
|
||||
@ -3,8 +3,32 @@ import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { Skill } from "./type";
|
||||
|
||||
export class SkillRequestError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "SkillRequestError";
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
get isAdminRequired(): boolean {
|
||||
return this.status === 403;
|
||||
}
|
||||
}
|
||||
|
||||
async function readErrorDetail(response: Response): Promise<string> {
|
||||
const data = (await response.json().catch(() => ({}))) as {
|
||||
detail?: string;
|
||||
};
|
||||
return data.detail ?? `HTTP ${response.status}: ${response.statusText}`;
|
||||
}
|
||||
|
||||
export async function loadSkills() {
|
||||
const skills = await fetch(`${getBackendBaseURL()}/api/skills`);
|
||||
if (!skills.ok) {
|
||||
throw new SkillRequestError(skills.status, await readErrorDetail(skills));
|
||||
}
|
||||
const json = await skills.json();
|
||||
return json.skills as Skill[];
|
||||
}
|
||||
@ -22,6 +46,12 @@ export async function enableSkill(skillName: string, enabled: boolean) {
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new SkillRequestError(
|
||||
response.status,
|
||||
await readErrorDetail(response),
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@ -48,14 +78,17 @@ export async function installSkill(
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle HTTP error responses (4xx, 5xx)
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const errorMessage =
|
||||
errorData.detail ?? `HTTP ${response.status}: ${response.statusText}`;
|
||||
const message = await readErrorDetail(response);
|
||||
// Surface authorization failures so callers can show an admin-only hint
|
||||
// instead of a generic failure.
|
||||
if (response.status === 403) {
|
||||
throw new SkillRequestError(response.status, message);
|
||||
}
|
||||
// Other HTTP errors keep the existing soft-failure contract.
|
||||
return {
|
||||
success: false,
|
||||
skill_name: "",
|
||||
message: errorMessage,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { enableSkill } from "./api";
|
||||
import { enableSkill, SkillRequestError } from "./api";
|
||||
|
||||
import { loadSkills } from ".";
|
||||
|
||||
@ -8,6 +8,7 @@ export function useSkills() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["skills"],
|
||||
queryFn: () => loadSkills(),
|
||||
retry: (count, err) => !(err instanceof SkillRequestError) && count < 3,
|
||||
});
|
||||
return { skills: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user