"""Skill registration routes""" import logging import os import re import urllib.parse import httpx from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel from typing import Any from agentkit.skills.base import Skill, SkillConfig from agentkit.skills.pipeline import SkillPipeline logger = logging.getLogger(__name__) router = APIRouter(tags=["skills"]) # Strict skill name validation: lowercase alphanumeric, hyphens, underscores _SKILL_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") # Allowed domains for source URL downloads (SSRF mitigation) _ALLOWED_DOWNLOAD_DOMAINS = { "raw.githubusercontent.com", "github.com", "gist.githubusercontent.com", } def _validate_skill_name(name: str) -> str: """Validate and normalize a skill name. Raises HTTPException on invalid input.""" normalized = name.strip().lower() if not _SKILL_NAME_RE.match(normalized): raise HTTPException( status_code=400, detail=f"Invalid skill name '{name}': must contain only lowercase letters, digits, hyphens, and underscores (1-64 chars)", ) return normalized def _get_skills_dir(req: Request) -> str: """Get the skills directory from server_config, falling back to configs/skills/.""" server_config = getattr(req.app.state, "server_config", None) if server_config and server_config.skill_paths: # Use the first configured skill path as the install target from pathlib import Path as _P first_path = _P(server_config.skill_paths[0]) if first_path.is_dir(): return str(first_path) # Fallback: configs/skills/ relative to project root return os.path.join(os.getcwd(), "configs", "skills") def _validate_source_url(source: str) -> None: """Validate that a source URL points to an allowed domain (SSRF mitigation).""" from urllib.parse import urlparse parsed = urlparse(source) if parsed.scheme not in ("https", "http"): raise HTTPException(status_code=400, detail=f"Invalid source URL scheme: only http/https allowed") # Block private/internal IPs by checking hostname import ipaddress import socket hostname = parsed.hostname if hostname: try: # Resolve hostname to check for private IPs resolved = socket.getaddrinfo(hostname, None) for family, type_, proto, canonname, sockaddr in resolved: ip = ipaddress.ip_address(sockaddr[0]) if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: raise HTTPException( status_code=400, detail="Source URL points to a private/internal address — not allowed", ) except socket.gaierror: pass # DNS resolution failed, let httpx handle it # Check domain allowlist for source URLs if hostname and hostname not in _ALLOWED_DOWNLOAD_DOMAINS: # Allow but log a warning for non-allowlisted domains logger.warning(f"Source URL domain '{hostname}' is not in the allowlist: {_ALLOWED_DOWNLOAD_DOMAINS}") def _validate_yaml_content(content: str) -> dict: """Validate YAML content before writing to disk. Returns parsed dict.""" import yaml try: data = yaml.safe_load(content) except yaml.YAMLError as e: raise HTTPException(status_code=400, detail=f"Invalid YAML content: {e}") if not isinstance(data, dict): raise HTTPException(status_code=400, detail="Skill YAML must be a mapping/dict") # Require at least a 'name' field if "name" not in data: raise HTTPException(status_code=400, detail="Skill YAML must contain a 'name' field") return data class RegisterSkillRequest(BaseModel): config: dict[str, Any] class CreatePipelineRequest(BaseModel): name: str steps: list[dict[str, Any]] class ExecutePipelineRequest(BaseModel): input_data: dict[str, Any] class InstallSkillRequest(BaseModel): name: str source: str | None = None # Optional: URL or "github:user/repo/path" @router.post("/skills", status_code=201) async def register_skill(request: RegisterSkillRequest, req: Request): """Register a Skill""" skill_registry = req.app.state.skill_registry try: config = SkillConfig.from_dict(request.config) except Exception as e: raise HTTPException(status_code=422, detail=f"Invalid skill config: {e}") skill = Skill(config=config) skill_registry.register(skill) return { "name": skill.name, "agent_type": skill.config.agent_type, "version": skill.config.version, "description": skill.config.description, } @router.get("/skills") async def list_skills(req: Request): """List all skills with full metadata""" skill_registry = req.app.state.skill_registry skills = skill_registry.list_skills() return [ { "name": s.name, "agent_type": s.config.agent_type, "version": s.config.version, "description": s.config.description or "", "task_mode": s.config.task_mode or "", "intent_keywords": s.config.intent.keywords if s.config.intent else [], "intent_description": s.config.intent.description if s.config.intent else "", "tools": s.config.tools or [], "bound_tools": [t.name for t in (s.tools or [])], "prompt_identity": (s.config.prompt or {}).get("identity", ""), } for s in skills ] @router.post("/skills/install") async def install_skill(request: InstallSkillRequest, req: Request): """Search for and install a skill by name. Searches GitHub for agentkit-skill YAML files matching the name, downloads the first match, saves it to configs/skills/, and registers it. """ skill_name = _validate_skill_name(request.name) source = request.source skill_registry = req.app.state.skill_registry tool_registry = getattr(req.app.state, "tool_registry", None) # If source URL is provided directly, download from it if source and source.startswith("http"): _validate_source_url(source) try: async with httpx.AsyncClient(timeout=30, follow_redirects=True, max_redirects=3) as client: resp = await client.get(source) resp.raise_for_status() yaml_content = resp.text except Exception as e: raise HTTPException(status_code=400, detail=f"Failed to download from source: {e}") elif source and source.startswith("file://"): # Read from local file path local_path = source[7:] # strip "file://" if not os.path.exists(local_path): raise HTTPException(status_code=404, detail=f"Local file not found: {local_path}") # Verify the path is within the skills directory skills_dir_base = _get_skills_dir(req) if not os.path.realpath(local_path).startswith(os.path.realpath(skills_dir_base)): raise HTTPException(status_code=400, detail="Local file path must be within the skills directory") try: with open(local_path, encoding="utf-8") as f: yaml_content = f.read() except Exception as e: raise HTTPException(status_code=400, detail=f"Failed to read local file: {e}") else: # Search GitHub for skills (YAML config files) search_query = f"{skill_name} skill config filename:yaml" encoded_query = urllib.parse.quote(search_query) github_api = f"https://api.github.com/search/code?q={encoded_query}&per_page=5" try: async with httpx.AsyncClient(timeout=15) as client: gh_resp = await client.get( github_api, headers={ "Accept": "application/vnd.github.v3+json", "User-Agent": "agentkit", }, ) gh_data = gh_resp.json() except Exception as e: raise HTTPException(status_code=502, detail=f"GitHub search failed: {e}") items = gh_data.get("items", []) if not items: # Fallback: try a simpler search search_query2 = f"{skill_name} skill" encoded_query2 = urllib.parse.quote(search_query2) github_api2 = f"https://api.github.com/search/code?q={encoded_query2}+extension:yaml&per_page=5" try: async with httpx.AsyncClient(timeout=15) as client: gh_resp2 = await client.get( github_api2, headers={"Accept": "application/vnd.github.v3+json", "User-Agent": "agentkit"}, ) items = gh_resp2.json().get("items", []) except Exception: items = [] if not items: raise HTTPException(status_code=404, detail=f"No skill found matching '{skill_name}'") # Download the first matching file item = items[0] raw_url = item.get("html_url", "") if raw_url: # Validate the URL is from github.com before transforming if not raw_url.startswith("https://github.com/"): raise HTTPException(status_code=400, detail="Search result URL is not from github.com") raw_url = raw_url.replace("github.com", "raw.githubusercontent.com").replace("/blob/", "/") else: raise HTTPException(status_code=404, detail="Could not construct download URL") try: async with httpx.AsyncClient(timeout=30, follow_redirects=True, max_redirects=3) as client: resp = await client.get(raw_url) resp.raise_for_status() yaml_content = resp.text except Exception as e: raise HTTPException(status_code=400, detail=f"Failed to download skill: {e}") # Validate YAML content before writing to disk _validate_yaml_content(yaml_content) # Save to skills directory (config-driven path) skills_dir = _get_skills_dir(req) os.makedirs(skills_dir, exist_ok=True) file_path = os.path.join(skills_dir, f"{skill_name}.yaml") # Verify resolved path stays within skills_dir (path traversal protection) if not os.path.realpath(file_path).startswith(os.path.realpath(skills_dir)): raise HTTPException(status_code=400, detail="Invalid path: escapes skills directory") with open(file_path, "w", encoding="utf-8") as f: f.write(yaml_content) # Load and register the skill registration_ok = False try: from agentkit.skills.loader import SkillLoader loader = SkillLoader( skill_registry=skill_registry, tool_registry=tool_registry, ) loader.load_from_file(file_path) registration_ok = True except Exception as e: logger.warning(f"Failed to register installed skill: {e}") if not registration_ok: # Remove the invalid YAML file and report error try: os.remove(file_path) except Exception: pass raise HTTPException(status_code=500, detail=f"Skill downloaded but registration failed") return { "status": "installed", "name": skill_name, "path": file_path, } @router.delete("/skills/{name}") async def uninstall_skill(name: str, req: Request): """Unregister a skill and optionally remove its YAML file.""" # Validate name to prevent path traversal validated_name = _validate_skill_name(name) skill_registry = req.app.state.skill_registry try: skill_registry.get(validated_name) except Exception: raise HTTPException(status_code=404, detail=f"Skill '{name}' not found") # Remove from registry skill_registry.unregister(validated_name) # Remove the YAML file (config-driven path) skills_dir = _get_skills_dir(req) yaml_path = os.path.join(skills_dir, f"{validated_name}.yaml") # Verify resolved path stays within skills_dir if os.path.exists(yaml_path) and os.path.realpath(yaml_path).startswith(os.path.realpath(skills_dir)): os.remove(yaml_path) return {"status": "uninstalled", "name": validated_name} # ---- Pipeline endpoints ---- @router.post("/skills/pipelines", status_code=201) async def create_pipeline(request: CreatePipelineRequest, req: Request): """Create and register a SkillPipeline""" skill_registry = req.app.state.skill_registry # Validate step definitions for i, step in enumerate(request.steps): if "skill_name" not in step: raise HTTPException( status_code=422, detail=f"Step {i} missing required field 'skill_name'", ) pipeline = SkillPipeline( name=request.name, steps=request.steps, skill_registry=skill_registry, ) skill_registry.register_pipeline(pipeline) return { "name": pipeline.name, "steps": [ {"skill_name": s["skill_name"], "step_index": i} for i, s in enumerate(request.steps) ], } @router.get("/skills/pipelines") async def list_pipelines(req: Request): """List all registered pipelines""" skill_registry = req.app.state.skill_registry return skill_registry.list_pipelines() @router.post("/skills/pipelines/{name}/execute") async def execute_pipeline(name: str, request: ExecutePipelineRequest, req: Request): """Execute a registered pipeline""" skill_registry = req.app.state.skill_registry pipeline = skill_registry.get_pipeline(name) if pipeline is None: raise HTTPException(status_code=404, detail=f"Pipeline '{name}' not found") try: result = await pipeline.execute(input_data=request.input_data) except Exception as e: logger.error(f"Pipeline execution failed for '{name}': {e}", exc_info=True) raise HTTPException(status_code=500, detail="Pipeline execution failed") return result