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

View File

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

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,
} 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}

View File

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

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

View File

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

View File

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

View File

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

View File

@ -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,
};
}

View File

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