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:
Yi Deng 2026-07-02 11:17:23 +08:00 committed by GitHub
parent ddb097a72f
commit 331c949b95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 270 additions and 37 deletions

View File

@ -2,10 +2,10 @@ import json
import logging import logging
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field 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 app.gateway.path_utils import resolve_thread_virtual_path
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.config.app_config import AppConfig from deerflow.config.app_config import AppConfig
@ -20,6 +20,8 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["skills"]) router = APIRouter(prefix="/api", tags=["skills"])
_ADMIN_REQUIRED_DETAIL = "Admin privileges required to manage skills."
class SkillResponse(BaseModel): class SkillResponse(BaseModel):
"""Response model for skill information.""" """Response model for skill information."""
@ -106,9 +108,10 @@ async def list_skills(config: AppConfig = Depends(get_config)) -> SkillsListResp
summary="Install Skill", summary="Install Skill",
description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.", 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: 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) result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path)
await refresh_skills_system_prompt_cache_async() await refresh_skills_system_prompt_cache_async()
return SkillInstallResponse(**result) 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") @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: try:
skills = [skill for skill in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if skill.category == SkillCategory.CUSTOM] 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]) 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") @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: try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "") skill_name = skill_name.replace("\r\n", "").replace("\n", "")
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) 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") @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: try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "") skill_name = skill_name.replace("\r\n", "").replace("\n", "")
storage = get_or_new_skill_storage(app_config=config) storage = get_or_new_skill_storage(app_config=config)
storage.ensure_custom_skill_is_editable(skill_name) storage.ensure_custom_skill_is_editable(skill_name)
storage.validate_skill_markdown_content(skill_name, request.content) storage.validate_skill_markdown_content(skill_name, body.content)
scan = await scan_skill_content(request.content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config) scan = await scan_skill_content(body.content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config)
if scan.decision == "block": if scan.decision == "block":
raise HTTPException(status_code=400, detail=f"Security scan blocked the edit: {scan.reason}") raise HTTPException(status_code=400, detail=f"Security scan blocked the edit: {scan.reason}")
prev_content = storage.read_custom_skill(skill_name) 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( storage.append_history(
skill_name, skill_name,
{ {
@ -171,12 +181,12 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest
"thread_id": None, "thread_id": None,
"file_path": SKILL_MD_FILE, "file_path": SKILL_MD_FILE,
"prev_content": prev_content, "prev_content": prev_content,
"new_content": request.content, "new_content": body.content,
"scanner": {"decision": scan.decision, "reason": scan.reason}, "scanner": {"decision": scan.decision, "reason": scan.reason},
}, },
) )
await refresh_skills_system_prompt_cache_async() 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: except HTTPException:
raise raise
except FileNotFoundError as e: 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") @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: try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "") skill_name = skill_name.replace("\r\n", "").replace("\n", "")
storage = get_or_new_skill_storage(app_config=config) 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") @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: try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "") skill_name = skill_name.replace("\r\n", "").replace("\n", "")
storage = get_or_new_skill_storage(app_config=config) 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") @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: try:
storage = get_or_new_skill_storage(app_config=config) 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(): 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) history = storage.read_history(skill_name)
if not history: if not history:
raise HTTPException(status_code=400, detail=f"Custom skill '{skill_name}' has no 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") target_content = record.get("prev_content")
if target_content is None: if target_content is None:
raise HTTPException(status_code=400, detail="Selected history entry has no previous content to roll back to") 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.write_custom_skill(skill_name, SKILL_MD_FILE, target_content)
storage.append_history(skill_name, history_entry) storage.append_history(skill_name, history_entry)
await refresh_skills_system_prompt_cache_async() 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: except HTTPException:
raise raise
except IndexError: except IndexError:
@ -307,7 +320,12 @@ async def get_skill(skill_name: str, config: AppConfig = Depends(get_config)) ->
summary="Update Skill", summary="Update Skill",
description="Update a skill's enabled status by modifying the extensions_config.json file.", 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: try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "") skill_name = skill_name.replace("\r\n", "").replace("\n", "")
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) 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}") logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
extensions_config = get_extensions_config() 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 = { config_data = {
"mcpServers": {name: server.model_dump() for name, server in extensions_config.mcp_servers.items()}, "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: if updated_skill is None:
raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after update") 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) return _skill_to_response(updated_skill)
except HTTPException: except HTTPException:

View File

@ -10,6 +10,7 @@ from _router_auth_helpers import make_authed_test_app
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.gateway.auth.models import User
from app.gateway.deps import get_config from app.gateway.deps import get_config
from app.gateway.routers import skills as skills_router from app.gateway.routers import skills as skills_router
from app.gateway.routers import uploads as uploads_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 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: def _skill_content(name: str, description: str = "Demo skill") -> str:
return f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n" 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: 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.state.config = config # kept for any startup-style reads
app.dependency_overrides[get_config] = lambda: config app.dependency_overrides[get_config] = lambda: config
app.include_router(skills_router.router) 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("deerflow.skills.installer.scan_skill_content", _scan)
monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh) 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.state.config = config
app.dependency_overrides[get_config] = lambda: config app.dependency_overrides[get_config] = lambda: config
app.include_router(uploads_router.router) app.include_router(uploads_router.router)

View 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}"

View File

@ -38,10 +38,11 @@ import {
HTML_PREVIEW_SCROLL_MESSAGE_SOURCE, HTML_PREVIEW_SCROLL_MESSAGE_SOURCE,
} from "@/core/artifacts/preview"; } from "@/core/artifacts/preview";
import { urlOfArtifact } from "@/core/artifacts/utils"; import { urlOfArtifact } from "@/core/artifacts/utils";
import { useAuth } from "@/core/auth/AuthProvider";
import { writeTextToClipboard } from "@/core/clipboard"; import { writeTextToClipboard } from "@/core/clipboard";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { findToolCallResult } from "@/core/messages/utils"; 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 { SafeStreamdown } from "@/core/streamdown/components";
import { import {
canBrowserPreviewFile, canBrowserPreviewFile,
@ -72,6 +73,8 @@ export function ArtifactFileDetail({
threadId: string; threadId: string;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { user } = useAuth();
const isAdmin = user?.system_role === "admin";
const { artifacts, setOpen, select } = useArtifacts(); const { artifacts, setOpen, select } = useArtifacts();
const { thread, isMock } = useThread(); const { thread, isMock } = useThread();
const isWriteFile = useMemo(() => { const isWriteFile = useMemo(() => {
@ -194,11 +197,15 @@ export function ArtifactFileDetail({
} }
} catch (error) { } catch (error) {
console.error("Failed to install skill:", 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 { } finally {
setIsInstalling(false); setIsInstalling(false);
} }
}, [threadId, filepath, isInstalling]); }, [threadId, filepath, isInstalling, t]);
return ( return (
<Artifact className={cn(className)}> <Artifact className={cn(className)}>
<ArtifactHeader className="px-2"> <ArtifactHeader className="px-2">
@ -249,7 +256,7 @@ export function ArtifactFileDetail({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ArtifactActions> <ArtifactActions>
{!isWriteFile && filepath.endsWith(".skill") && ( {!isWriteFile && filepath.endsWith(".skill") && isAdmin && (
<Tooltip content={t.toolCalls.skillInstallTooltip}> <Tooltip content={t.toolCalls.skillInstallTooltip}>
<ArtifactAction <ArtifactAction
icon={isInstalling ? LoaderIcon : PackageIcon} icon={isInstalling ? LoaderIcon : PackageIcon}

View File

@ -11,8 +11,9 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { urlOfArtifact } from "@/core/artifacts/utils"; import { urlOfArtifact } from "@/core/artifacts/utils";
import { useAuth } from "@/core/auth/AuthProvider";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { installSkill } from "@/core/skills/api"; import { installSkill, SkillRequestError } from "@/core/skills/api";
import { import {
getFileExtensionDisplayName, getFileExtensionDisplayName,
getFileIcon, getFileIcon,
@ -32,6 +33,8 @@ export function ArtifactFileList({
threadId: string; threadId: string;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { user } = useAuth();
const isAdmin = user?.system_role === "admin";
const { select: selectArtifact, setOpen } = useArtifacts(); const { select: selectArtifact, setOpen } = useArtifacts();
const [installingFile, setInstallingFile] = useState<string | null>(null); const [installingFile, setInstallingFile] = useState<string | null>(null);
@ -63,12 +66,16 @@ export function ArtifactFileList({
} }
} catch (error) { } catch (error) {
console.error("Failed to install skill:", 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 { } finally {
setInstallingFile(null); setInstallingFile(null);
} }
}, },
[threadId, installingFile], [threadId, installingFile, t],
); );
return ( return (
@ -90,7 +97,7 @@ export function ArtifactFileList({
{getFileExtensionDisplayName(file)} file {getFileExtensionDisplayName(file)} file
</CardDescription> </CardDescription>
<CardAction className="row-span-1 self-center"> <CardAction className="row-span-1 self-center">
{file.endsWith(".skill") && ( {file.endsWith(".skill") && isAdmin && (
<Button <Button
variant="ghost" variant="ghost"
disabled={installingFile === file} disabled={installingFile === file}

View File

@ -22,7 +22,9 @@ import {
} from "@/components/ui/item"; } from "@/components/ui/item";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useAuth } from "@/core/auth/AuthProvider";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { SkillRequestError } from "@/core/skills/api";
import { useEnableSkill, useSkills } from "@/core/skills/hooks"; import { useEnableSkill, useSkills } from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type"; import type { Skill } from "@/core/skills/type";
import { env } from "@/env"; import { env } from "@/env";
@ -32,6 +34,8 @@ import { SettingsSection } from "./settings-section";
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) { export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
const { t } = useI18n(); const { t } = useI18n();
const { skills, isLoading, error } = useSkills(); const { skills, isLoading, error } = useSkills();
const adminRequired =
error instanceof SkillRequestError && error.isAdminRequired;
return ( return (
<SettingsSection <SettingsSection
title={t.settings.skills.title} title={t.settings.skills.title}
@ -39,6 +43,10 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
> >
{isLoading ? ( {isLoading ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div> <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 ? ( ) : error ? (
<div>Error: {error.message}</div> <div>Error: {error.message}</div>
) : ( ) : (
@ -57,6 +65,8 @@ function SkillSettingsList({
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const { user } = useAuth();
const isAdmin = user?.system_role === "admin";
const [filter, setFilter] = useState<string>("public"); const [filter, setFilter] = useState<string>("public");
const { mutate: enableSkill } = useEnableSkill(); const { mutate: enableSkill } = useEnableSkill();
const filteredSkills = useMemo( const filteredSkills = useMemo(
@ -102,7 +112,9 @@ function SkillSettingsList({
<ItemActions> <ItemActions>
<Switch <Switch
checked={skill.enabled} checked={skill.enabled}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"} disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || !isAdmin
}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
enableSkill({ skillName: skill.name, enabled: checked }) enableSkill({ skillName: skill.name, enabled: checked })
} }

View File

@ -525,6 +525,9 @@ export const enUS: Translations = {
emptyDescription: emptyDescription:
"Put your agent skill folders under the `/skills/custom` folder under the root folder of DeerFlow.", "Put your agent skill folders under the `/skills/custom` folder under the root folder of DeerFlow.",
emptyButton: "Create Your First Skill", emptyButton: "Create Your First Skill",
adminRequired: "Admin privileges are required to manage agent skills.",
installAdminRequired:
"Admin privileges are required to install agent skills.",
}, },
notification: { notification: {
title: "Notification", title: "Notification",

View File

@ -431,6 +431,8 @@ export interface Translations {
emptyTitle: string; emptyTitle: string;
emptyDescription: string; emptyDescription: string;
emptyButton: string; emptyButton: string;
adminRequired: string;
installAdminRequired: string;
}; };
notification: { notification: {
title: string; title: string;

View File

@ -503,6 +503,8 @@ export const zhCN: Translations = {
emptyDescription: emptyDescription:
"将你的 Agent Skill 文件夹放在 DeerFlow 根目录下的 `/skills/custom` 文件夹中。", "将你的 Agent Skill 文件夹放在 DeerFlow 根目录下的 `/skills/custom` 文件夹中。",
emptyButton: "创建你的第一个技能", emptyButton: "创建你的第一个技能",
adminRequired: "需要管理员权限才能管理 Agent Skill。",
installAdminRequired: "需要管理员权限才能安装 Agent Skill。",
}, },
notification: { notification: {
title: "通知", title: "通知",

View File

@ -3,8 +3,32 @@ import { getBackendBaseURL } from "@/core/config";
import type { Skill } from "./type"; 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() { export async function loadSkills() {
const skills = await fetch(`${getBackendBaseURL()}/api/skills`); const skills = await fetch(`${getBackendBaseURL()}/api/skills`);
if (!skills.ok) {
throw new SkillRequestError(skills.status, await readErrorDetail(skills));
}
const json = await skills.json(); const json = await skills.json();
return json.skills as Skill[]; 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(); return response.json();
} }
@ -48,14 +78,17 @@ export async function installSkill(
}); });
if (!response.ok) { if (!response.ok) {
// Handle HTTP error responses (4xx, 5xx) const message = await readErrorDetail(response);
const errorData = await response.json().catch(() => ({})); // Surface authorization failures so callers can show an admin-only hint
const errorMessage = // instead of a generic failure.
errorData.detail ?? `HTTP ${response.status}: ${response.statusText}`; if (response.status === 403) {
throw new SkillRequestError(response.status, message);
}
// Other HTTP errors keep the existing soft-failure contract.
return { return {
success: false, success: false,
skill_name: "", skill_name: "",
message: errorMessage, message,
}; };
} }

View File

@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { enableSkill } from "./api"; import { enableSkill, SkillRequestError } from "./api";
import { loadSkills } from "."; import { loadSkills } from ".";
@ -8,6 +8,7 @@ export function useSkills() {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["skills"], queryKey: ["skills"],
queryFn: () => loadSkills(), queryFn: () => loadSkills(),
retry: (count, err) => !(err instanceof SkillRequestError) && count < 3,
}); });
return { skills: data ?? [], isLoading, error }; return { skills: data ?? [], isLoading, error };
} }