"""Runtime user-uploaded skills: install / uninstall / startup reload. Two kinds of skills are supported: - **Code skill** (zip with a .py handler): the handler is importlib-loaded, registers its own ToolSpec, and calls a real API. - **Prompt skill** (zip with ONLY a SKILL.md): the SKILL.md body is injected into the LLM's system prompt as domain knowledge. No code is loaded. A synthetic echo handler is registered so the skill appears in /api/skills and can be "tested" (returns the body). Zip layouts accepted (auto-detected by content, not by layout): (a) / SKILL.md (required, frontmatter: name, description) .py (optional, only for code skills) [other files] (optional, ignored) (b) SKILL.md (flat — root of the zip) [other files] (optional, ignored) The on-disk storage is always a directory: /data/user_skills// containing SKILL.md (and, for code skills, .py). This keeps uninstall and reload paths uniform. Lifecycle: 1. install_skill_from_zip(): extract → validate → move to user_skills_dir → either exec the handler module (code) or register an echo handler with the body (prompt). Roll back on any failure. 2. uninstall_skill(name): REGISTRY.unregister + rmtree the directory. 3. load_uploaded_skills_at_startup(): walk user_skills_dir at app startup, re-import every previously uploaded skill so they survive container restarts. Security notes: - Upload endpoint is gated by the global X-API-Key (admin-only). - Zip path-traversal blocked (every entry must resolve under temp). - Zip size capped via max_skill_upload_bytes. - Code handler is py_compile-validated before import. - Built-in skills are immutable (cannot be deleted). """ from __future__ import annotations import importlib.util import io import logging import py_compile import re import shutil import sys import tempfile import zipfile from dataclasses import dataclass from pathlib import Path from typing import Any from app.config import get_settings from app.skills.base import ToolResult, ToolSpec from app.skills.registry import REGISTRY logger = logging.getLogger(__name__) # Built-in skills cannot be overwritten or deleted via the upload API. BUILTIN_SKILLS: frozenset[str] = frozenset( { "financial-query", "news-search", "announcement-search", "report-search", } ) # ----------------- frontmatter ----------------- _FRONTMATTER_RE = re.compile(r"\s*---\s*\n(.*?)\n---\s*\n", re.DOTALL) def _parse_frontmatter(text: str) -> tuple[str | None, str | None, str | None]: """Extract (name, displayName, description) from YAML frontmatter. Tolerant of bad input — we don't want to crash the uploader over a typo. Each field is optional. If `name:` is missing or there's no frontmatter block, returns (None, None, None). """ m = _FRONTMATTER_RE.match(text) if not m: return None, None, None name = display_name = description = None for line in m.group(1).splitlines(): if not name: kv = re.match(r"\s*name\s*:\s*(.*?)\s*$", line) if kv: name = kv.group(1).strip() continue if not display_name: kv = re.match(r"\s*displayName\s*:\s*(.*?)\s*$", line) if kv: display_name = kv.group(1).strip() continue if not description: kv = re.match(r"\s*description\s*:\s*(.*?)\s*$", line) if kv: description = kv.group(1).strip() continue return name, display_name, description def _parse_frontmatter_name(md_text: str) -> str | None: """Backwards-compat shim — returns just the name. New code should use _parse_frontmatter() directly.""" name, _, _ = _parse_frontmatter(md_text) return name # ----------------- zip layout detection ----------------- @dataclass class _LocatedSkill: """Result of finding the skill in the extracted zip.""" skill_md: Path # absolute path to SKILL.md name: str # the skill's identity (from dir name or frontmatter) has_py: bool # True if a code handler exists def _locate_skill(tmp_path: Path) -> _LocatedSkill: """Find the SKILL.md in the extracted zip and decide which layout it uses. Raises ValueError on illegal layouts. Layouts accepted: (a) tmp_path//SKILL.md (and optionally /.py) (b) tmp_path/SKILL.md (flat — no .py allowed) Layouts REJECTED: - No SKILL.md at all - More than one SKILL.md - Two top-level directories when SKILL.md isn't at root - Flat layout that ALSO has stray subdirs """ md_candidates = list(tmp_path.rglob("SKILL.md")) if not md_candidates: raise ValueError("No SKILL.md found in zip") if len(md_candidates) > 1: raise ValueError("Multiple SKILL.md files in zip — exactly one is allowed") skill_md = md_candidates[0] # Layout (b): SKILL.md directly in the zip root if skill_md.parent == tmp_path: # Reject if there are also subdirectories (would be ambiguous) stray_dirs = [p for p in tmp_path.iterdir() if p.is_dir()] if stray_dirs: raise ValueError( "Zip mixes a top-level SKILL.md with subdirectories — " "pick one layout (flat with SKILL.md only, OR one top-level dir)" ) # Name must come from frontmatter (no directory to derive it from) name, _, _ = _parse_frontmatter(skill_md.read_text(encoding="utf-8")) if not name: raise ValueError( "Flat zip layout (no top-level dir) requires SKILL.md " "frontmatter to include `name:`" ) return _LocatedSkill(skill_md=skill_md, name=name, has_py=False) # Layout (a): SKILL.md is inside a top-level dir top_level = [p for p in tmp_path.iterdir() if p.is_dir()] if len(top_level) != 1: raise ValueError( "Zip must contain exactly one top-level directory " "(or a single SKILL.md at the root)" ) skill_dir = top_level[0] if not skill_dir.name or skill_dir.name.startswith("."): raise ValueError(f"Invalid skill directory name: '{skill_dir.name}'") matching_py = (skill_dir / f"{skill_dir.name}.py").exists() other_py = [p for p in skill_dir.glob("*.py") if p.name != f"{skill_dir.name}.py"] if other_py: # A .py exists but its name doesn't match the directory — this # is almost certainly a user mistake (typo in filename). Refuse # rather than silently treat as prompt-only. names = ", ".join(p.name for p in other_py) raise ValueError( f"Handler file in {skill_dir.name}/ must be named " f"'{skill_dir.name}.py' (found: {names})" ) return _LocatedSkill(skill_md=skill_md, name=skill_dir.name, has_py=matching_py) # ----------------- validation ----------------- def _validate_code_handler(skill_dir: Path, name: str) -> None: """For code skills: ensure the handler file exists and is syntactically valid Python. Raises ValueError on any problem.""" handler = skill_dir / f"{name}.py" if not handler.exists(): raise ValueError(f"Handler file '{handler.name}' missing") try: py_compile.compile(str(handler), doraise=True) except py_compile.PyCompileError as e: raise ValueError(f"Handler has Python syntax errors: {e}") def _build_prompt_handler(name: str, body: str): """Closure factory: returns an async handler that yields the SKILL.md body as its result. The default args capture `body` and `name` to avoid late-binding issues in the loop.""" async def _prompt_handler(**_) -> ToolResult: return ToolResult( tool=name, ok=True, data={"skill_body": body}, meta={"prompt_only": True}, ) return _prompt_handler def _build_prompt_spec(name: str, display_name: str | None, description: str | None) -> ToolSpec: return ToolSpec( name=name, display_name=display_name or name, description=description or "(prompt-only skill — no description provided)", category="user", parameters=[], # explicitly empty for prompt skills requires=[], enabled_by_default=True, version="0.1.0", ) # ----------------- install / uninstall / reload ----------------- def install_skill_from_zip(zip_bytes: bytes) -> dict[str, Any]: """Extract, validate, hot-load one skill (code OR prompt). Returns the SkillItem dict on success. Raises ValueError on any failure (with full rollback — no files left behind, no REGISTRY state mutated).""" settings = get_settings() target_root = Path(settings.user_skills_dir) max_bytes = settings.max_skill_upload_bytes with tempfile.TemporaryDirectory(prefix="skill_upload_") as tmp: tmp_path = Path(tmp) # Phase 1: pre-flight + extract. Reject path traversal and zip # bombs BEFORE any file lands on disk. with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: total_uncompressed = 0 for info in zf.infolist(): member_target = (tmp_path / info.filename).resolve() if not str(member_target).startswith(str(tmp_path.resolve()) + "/") and \ member_target != tmp_path.resolve(): raise ValueError(f"Unsafe path in zip: {info.filename}") total_uncompressed += info.file_size if total_uncompressed > max_bytes: raise ValueError( f"Zip contents exceed {max_bytes // (1024*1024)} MB limit" ) zf.extractall(tmp_path) # Phase 2: locate SKILL.md and decide kind. located = _locate_skill(tmp_path) # Phase 3: name conflicts if located.name in BUILTIN_SKILLS: raise ValueError(f"'{located.name}' conflicts with a built-in skill") if REGISTRY.get_spec(located.name) is not None: raise ValueError(f"Skill '{located.name}' is already registered") dest = target_root / located.name if dest.exists(): raise ValueError( f"Skill '{located.name}' was already uploaded previously" ) # Phase 4: parse frontmatter from the located SKILL.md and verify # the name matches what the directory told us. md_text = located.skill_md.read_text(encoding="utf-8") fm_name, fm_display, fm_description = _parse_frontmatter(md_text) if not fm_name: raise ValueError("SKILL.md frontmatter missing 'name' field") if fm_name != located.name: raise ValueError( f"SKILL.md name '{fm_name}' does not match directory/file '{located.name}'" ) # Phase 5: layout-specific validation + move into the persistent root. # Final on-disk layout is ALWAYS: target_root// containing # SKILL.md (and .py for code skills). if located.has_py: # Layout (a) with .py: the whole skill_dir is the dir we want. # Validate BEFORE moving (file is at its temp location now). _validate_code_handler(located.skill_md.parent, located.name) # Move the whole top-level dir into target_root// shutil.move(str(located.skill_md.parent), str(dest)) skill_dir = dest # The handler path is now under dest (recompute — the temp # location no longer exists after the move). handler_path = dest / f"{located.name}.py" else: # Layout (a) without .py OR layout (b) — both end up as # a single-file directory: target_root//SKILL.md dest.mkdir(parents=True, exist_ok=False) shutil.move(str(located.skill_md), str(dest / "SKILL.md")) skill_dir = dest handler_path = None # explicit: there is no handler # Phase 6: register in REGISTRY. body = (skill_dir / "SKILL.md").read_text(encoding="utf-8") if handler_path is not None: # Code skill: py_compile already ran in Phase 5; just exec. if str(target_root) not in sys.path: sys.path.insert(0, str(target_root)) pre_count = len(REGISTRY.list_specs()) try: _exec_skill_module(located.name, handler_path) except Exception as e: shutil.rmtree(dest, ignore_errors=True) logger.exception("Uploaded code skill '%s' raised on import", located.name) raise ValueError( f"Handler raised on import: {type(e).__name__}: {e}" ) from e if len(REGISTRY.list_specs()) == pre_count: shutil.rmtree(dest, ignore_errors=True) raise ValueError( "Handler module loaded but did not call REGISTRY.register(...). " "Make sure the file ends with REGISTRY.register(SPEC, handler)." ) else: # Prompt skill: register a synthetic echo handler. spec = _build_prompt_spec(located.name, fm_display, fm_description) handler = _build_prompt_handler(located.name, body) REGISTRY.register(spec, handler) # Stash the body so to_prompt_text() can inject it into the system prompt. REGISTRY.set_prompt_body(located.name, body) spec = REGISTRY.get_spec(located.name) if spec is None: # Belt-and-braces; should be impossible after the steps above. shutil.rmtree(dest, ignore_errors=True) raise RuntimeError("Internal: registry state inconsistent after install") return { "name": located.name, "spec": spec.model_dump(), "enabled": REGISTRY.is_enabled(located.name), "uploaded": True, "kind": "prompt" if handler_path is None else "code", } def uninstall_skill(name: str) -> None: """Remove an uploaded skill from REGISTRY and delete its directory. Built-in skills are refused.""" if name in BUILTIN_SKILLS: raise ValueError(f"Cannot delete built-in skill '{name}'") if REGISTRY.get_spec(name) is None: raise ValueError(f"Skill '{name}' is not registered") REGISTRY.unregister(name) REGISTRY.set_prompt_body(name, None) target = Path(get_settings().user_skills_dir) / name if target.exists(): shutil.rmtree(target, ignore_errors=True) def load_uploaded_skills_at_startup() -> int: """Re-register every previously uploaded skill so they survive container restarts. Returns the count successfully loaded.""" settings = get_settings() root = Path(settings.user_skills_dir) if not root.exists(): return 0 loaded = 0 for entry in sorted(root.iterdir()): if not entry.is_dir(): continue if entry.name in BUILTIN_SKILLS: logger.warning( "Uploaded skill dir named '%s' shadows a built-in; skipping", entry.name, ) continue if REGISTRY.get_spec(entry.name) is not None: continue skill_md = entry / "SKILL.md" handler_py = entry / f"{entry.name}.py" if not skill_md.exists(): logger.warning( "Uploaded skill '%s' missing SKILL.md; skipping", entry.name, ) continue try: if handler_py.exists(): # Code skill: importlib exec if str(root) not in sys.path: sys.path.insert(0, str(root)) _exec_skill_module(entry.name, handler_py) else: # Prompt skill: rehydrate spec + echo handler from the # saved SKILL.md body. body = skill_md.read_text(encoding="utf-8") _name, _display, _desc = _parse_frontmatter(body) spec = _build_prompt_spec(entry.name, _display, _desc) handler = _build_prompt_handler(entry.name, body) REGISTRY.register(spec, handler) REGISTRY.set_prompt_body(entry.name, body) loaded += 1 logger.info("Reloaded uploaded skill '%s'", entry.name) except Exception: logger.exception( "Failed to reload uploaded skill '%s'; leaving on disk for manual inspection", entry.name, ) return loaded def _exec_skill_module(name: str, file_path: Path) -> None: """Load a single skill's handler file as a top-level module and execute it, expecting it to call REGISTRY.register(...) as a side-effect. Why not importlib.import_module(name) directly? Because the handler lives inside a subdirectory (e.g. /data/user_skills/my_skill/my_skill.py) and Python 3 treats directories-without-__init__.py as namespace packages, so `import my_skill` would resolve to an EMPTY namespace package and the handler file would never be executed. Loading by file path via spec_from_file_location sidesteps that entirely. """ spec = importlib.util.spec_from_file_location(name, str(file_path)) if spec is None or spec.loader is None: raise ImportError(f"Could not build import spec for {file_path}") module = importlib.util.module_from_spec(spec) sys.modules[name] = module try: spec.loader.exec_module(module) except Exception: sys.modules.pop(name, None) raise