from __future__ import annotations import copy import io import ipaddress from functools import wraps import hashlib import json import mimetypes import os from pathlib import Path, PurePosixPath import re import secrets import shutil import socket import subprocess import sys import tempfile import threading import time from typing import Any from urllib.parse import urlparse import uuid import zipfile import httpx from services.config import BASE_DIR, DATA_DIR MAX_ARCHIVE_BYTES = 25 * 1024 * 1024 MAX_FILE_BYTES = 8 * 1024 * 1024 MAX_FILES = 500 # An administrator-built export bundle carries every Skill together with its # showcase media, so it is orders of magnitude larger than a Skill downloaded # from a repository. Only the administrator-only import endpoint uses these # relaxed limits; public installs keep the tight ones above. MAX_IMPORT_ARCHIVE_BYTES = 256 * 1024 * 1024 MAX_IMPORT_FILES = 5000 MAX_RESOURCE_BYTES = 512 * 1024 MAX_SCRIPT_ARGS = 48 MAX_SCRIPT_ARG_CHARS = 2048 MAX_SCRIPT_OUTPUT_BYTES = 25 * 1024 * 1024 MEDIA_PARSER_OUTPUT_BYTES = max( MAX_SCRIPT_OUTPUT_BYTES, int(os.environ.get("CHATGPT2API_MEDIA_PARSER_MAX_BYTES", 512 * 1024 * 1024)), ) MEDIA_PARSER_ARTIFACT_TTL_SECONDS = max( 900, int(os.environ.get("CHATGPT2API_MEDIA_PARSER_TTL_SECONDS", 6 * 60 * 60)), ) MEDIA_PARSER_OWNER_STORAGE_BYTES = max( MEDIA_PARSER_OUTPUT_BYTES, int(os.environ.get("CHATGPT2API_MEDIA_PARSER_OWNER_STORAGE_BYTES", 1024 * 1024 * 1024)), ) MAX_SCRIPT_OUTPUT_FILES = 24 MAX_SCRIPT_STDIO_CHARS = 20000 DEFAULT_SCRIPT_TIMEOUT_SECONDS = 45 MAX_SCRIPT_TIMEOUT_SECONDS = 120 SKILLS_DIR = DATA_DIR / "skills" BUNDLED_SKILLS_DIR = BASE_DIR / "bundled_skills" BUNDLED_SKILL_VERSION_FILE = ".bundled-version" SKILL_RUNS_DIR = DATA_DIR / "files" / "skills" SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9._-]+") FRONTMATTER_RE = re.compile(r"^---\s*\n(?P.*?)\n---\s*(?:\n|$)", re.DOTALL) TEXT_RESOURCE_EXTENSIONS = { ".md", ".mdx", ".txt", ".json", ".jsonl", ".yaml", ".yml", ".toml", ".ini", ".py", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".css", ".html", ".svg", } SKILL_PRESENTATION_FILE = "skill-ui.json" SKILL_PRESENTATION_OVERRIDE_FILE = ".presentation-overrides.json" SKILL_PRESENTATION_ASSET_DIR = ".presentation-assets" # Updating a Skill replaces its directory wholesale. Before that happens the # previous copy is kept so an administrator can roll back a bad release. SKILL_BACKUP_DIR = ".backups" MAX_SKILL_BACKUPS = 3 SKILL_EXPORT_FORMAT = "chatsam-skills-export" SKILL_EXPORT_FORMAT_VERSION = 1 EXPORT_MANIFEST_FILE = "manifest.json" # Administrator state that survives an overwrite: script permissions, the # showcase overrides, uploaded showcase media and the user-facing visibility. PRESERVED_STATE_FILES = ( ".permissions.json", SKILL_PRESENTATION_OVERRIDE_FILE, ".visibility.json", ) PRESERVED_STATE_DIRS = (SKILL_PRESENTATION_ASSET_DIR,) SKILL_WORKFLOW_FILE = "skill-workflow.json" PRESENTATION_SOURCE_FILES = { SKILL_PRESENTATION_FILE, SKILL_PRESENTATION_OVERRIDE_FILE, SKILL_WORKFLOW_FILE, } PRESENTATION_SUMMARY_KEYS = ( "version", "title", "description", "category", "tags", "capabilities", "origin", "cover", "asset_count", "example_count", "input_count", "output_count", "workflow_count", ) PRESENTATION_ASSET_EXTENSIONS = { ".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".html", ".htm", ".pdf", ".mp4", ".webm", ".mp3", ".wav", } PRESENTATION_MEDIA_ROOTS = {"assets", "examples", "example", "demo", "demos", "preview", "previews"} SCRIPT_EXTENSIONS = {".py", ".js", ".mjs", ".cjs"} USER_GUIDE_DOC_RE = re.compile( r"^(?:readme|usage|quickstart|getting[-_ ]started|user[-_ ]guide|how[-_ ]to)(?:[._-].*)?\.(?:md|mdx|txt)$", re.IGNORECASE, ) GUIDE_CACHE_FILE = ".user-guide-cache.json" SKILL_FAVORITES_FILE = "skill-favorites.json" # Caches and build leftovers add nothing to an export bundle. EXPORT_EXCLUDED_NAMES = {GUIDE_CACHE_FILE, ".DS_Store"} EXPORT_EXCLUDED_PARTS = {"__pycache__"} # A Skill package is not always a single directory. Repositories keep runnable # code beside the Skill folder, or ship it in a monorepo package, and describe # the layout in Markdown instead of in SKILL.md. These rules decide which # documents are read while deciding what belongs to the package. PACKAGE_DOC_RE = re.compile( r"^(?:readme|usage|quickstart|getting[-_ ]started|user[-_ ]guide)(?:[._-].*)?\.(?:md|mdx|txt)$", re.IGNORECASE, ) PACKAGE_DOCUMENT_SUFFIXES = {".md", ".mdx", ".txt"} MAX_PACKAGE_DOCUMENTS = 40 # ``scripts/`` already has a fixed meaning inside a Skill — it is where the # runtime looks for controlled scripts. A repository-level ``scripts/`` holds # development tooling, so re-homing it would produce ``scripts/scripts/`` and mix # unrelated files into the Skill's own script list. RESERVED_PACKAGE_ROOT_NAMES = {"scripts"} class _KeyedRunLocks: """Serialize only duplicate idempotent runs inside this API process.""" def __init__(self) -> None: self._guard = threading.Lock() self._locks: dict[str, tuple[threading.Lock, int]] = {} def acquire(self, key: str) -> threading.Lock: with self._guard: lock, references = self._locks.get(key, (threading.Lock(), 0)) self._locks[key] = (lock, references + 1) lock.acquire() return lock def release(self, key: str, lock: threading.Lock) -> None: lock.release() with self._guard: current, references = self._locks.get(key, (lock, 1)) if current is lock and references <= 1: self._locks.pop(key, None) elif current is lock: self._locks[key] = (lock, references - 1) _SCRIPT_RUN_LOCKS = _KeyedRunLocks() def _serialize_duplicate_runs(function): @wraps(function) def wrapped(self, skill_id, identity, *args, **kwargs): client_run_id = str(kwargs.get("client_run_id") or "").strip() if not client_run_id: return function(self, skill_id, identity, *args, **kwargs) owner = _safe_name(str(identity.get("id") or "user"), "user") key = hashlib.sha256(f"{owner}:{skill_id}:{client_run_id}".encode("utf-8")).hexdigest() lock = _SCRIPT_RUN_LOCKS.acquire(key) try: return function(self, skill_id, identity, *args, **kwargs) finally: _SCRIPT_RUN_LOCKS.release(key, lock) return wrapped class SkillInstallError(ValueError): pass def _safe_name(value: str, fallback: str = "skill") -> str: normalized = SAFE_NAME_RE.sub("-", value.strip()).strip("-._").lower() return (normalized or fallback)[:80] def _frontmatter(skill_file: Path) -> dict[str, str]: try: text = skill_file.read_text(encoding="utf-8") except (OSError, UnicodeError) as exc: raise SkillInstallError(f"无法读取 {skill_file.name}: {exc}") from exc match = FRONTMATTER_RE.match(text) values: dict[str, str] = {} if match: for raw_line in match.group("body").splitlines(): key, separator, value = raw_line.partition(":") if separator and key.strip() in { "name", "description", "author", "version", "homepage", "repository", "source", "source_url", }: values[key.strip()] = value.strip().strip("\"'") values.setdefault("name", skill_file.parent.name) values.setdefault("description", "") return values def _presentation_category(skill_id: str, name: str, description: str) -> str: text = f"{skill_id} {name} {description}".lower() if re.search(r"\b(?:image|photo|poster|banner|logo|art|draw|paint|gif|wallpaper|holographic)\b|视觉|图片|海报|作图|封面|壁纸|摄影|插画|卡片", text): return "视觉创作" if re.search(r"\b(?:ui|ux|frontend|website|web)\b|界面|交互|网页", text): return "界面设计" if re.search(r"\b(?:document|pdf|ppt|slide|sheet|excel)\b|文档|表格|演示", text): return "文档效率" if re.search(r"\b(?:search|research)\b|搜索|调研", text): return "搜索研究" return "专业工作流" def _asset_kind(path: Path, mime_type: str) -> str: suffix = path.suffix.lower() if mime_type.startswith("image/"): return "image" if mime_type.startswith("video/"): return "video" if mime_type.startswith("audio/"): return "audio" if suffix in {".html", ".htm"}: return "html" if suffix == ".pdf": return "pdf" return "file" def _string_list(value: Any, *, limit: int = 16) -> list[str]: if isinstance(value, str): values = [value] elif isinstance(value, list): values = [str(item) for item in value if isinstance(item, (str, int, float))] else: return [] result: list[str] = [] for value_item in values: clean = value_item.strip() if clean and clean not in result: result.append(clean[:160]) if len(result) >= limit: break return result def _http_url(value: object) -> str: clean = str(value or "").strip() parsed = urlparse(clean) return clean if parsed.scheme in {"http", "https"} and parsed.hostname else "" def _require_public_remote_url(value: str) -> str: clean = _http_url(value) if not clean: raise SkillInstallError("安装说明地址必须是有效的 http/https URL") hostname = urlparse(clean).hostname or "" try: addresses = {item[4][0] for item in socket.getaddrinfo(hostname, None)} except OSError as exc: raise SkillInstallError("安装说明地址无法解析") from exc for address in addresses: ip = ipaddress.ip_address(address) if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified: raise SkillInstallError("安装说明地址不能指向本机或内网") return clean def _skill_origin(skill_dir: Path, metadata: dict[str, str], manifest: dict[str, Any]) -> dict[str, Any]: install_metadata: dict[str, Any] = {} try: raw_install = json.loads((skill_dir / ".install.json").read_text(encoding="utf-8")) if isinstance(raw_install, dict): install_metadata = raw_install except (OSError, UnicodeError, ValueError): pass package: dict[str, Any] = {} try: raw_package = json.loads((skill_dir / "package.json").read_text(encoding="utf-8")) if isinstance(raw_package, dict): package = raw_package except (OSError, UnicodeError, ValueError): pass manifest_source = manifest.get("source") source_config = manifest_source if isinstance(manifest_source, dict) else {} package_repository = package.get("repository") if isinstance(package_repository, dict): package_repository = package_repository.get("url") installed_source = str(install_metadata.get("source") or "").strip() url = next((candidate for candidate in ( _http_url(source_config.get("url")), _http_url(source_config.get("repository")), _http_url(metadata.get("source_url")), _http_url(metadata.get("repository")), _http_url(metadata.get("homepage")), _http_url(package_repository), _http_url(package.get("homepage")), _http_url(installed_source), ) if candidate), "") if url.startswith("git+"): url = url[4:] url = url.removesuffix(".git") package_author = package.get("author") if isinstance(package_author, dict): package_author = package_author.get("name") author = str( source_config.get("author") or manifest.get("author") or metadata.get("author") or package_author or "" ).strip()[:160] github_match = re.match(r"https?://github\.com/([^/]+)/([^/#?]+)", url, re.IGNORECASE) if not author and github_match: author = github_match.group(1) if github_match: source_type = "github" default_label = "GitHub 开源" elif installed_source.startswith("upload:"): source_type = "upload" default_label = "本地上传" elif url: source_type = "url" default_label = "外部来源" else: source_type = "local" default_label = "本地 Skill" return { "type": source_type, "label": str(source_config.get("label") or source_config.get("name") or default_label).strip()[:120], "url": url, "author": author, "version": str(source_config.get("version") or manifest.get("version") or metadata.get("version") or package.get("version") or "").strip()[:80], "open_source": bool(source_config.get("openSource", source_config.get("open_source", source_type == "github"))), } def _safe_relative_path(value: str) -> Path: clean = str(value or "").strip().replace("\\", "/") path = PurePosixPath(clean) if not clean or path.is_absolute() or ".." in path.parts or any(not part for part in path.parts): raise SkillInstallError("Skill 文件路径无效") return Path(*path.parts) def _safe_output_name(value: str) -> str: name = Path(str(value or "").replace("\\", "/")).name clean = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip(".-") if not clean: raise SkillInstallError("输出文件名无效") return clean[:160] def _is_text_resource(path: Path) -> bool: return path.suffix.lower() in TEXT_RESOURCE_EXTENSIONS def _script_kind(path: Path) -> str: suffix = path.suffix.lower() if suffix == ".py": return "python" if suffix in {".js", ".mjs", ".cjs"}: return "node" return "" class _ScannedFile: """A single file discovered by one recursive walk of a Skill directory.""" __slots__ = ("path", "relative", "name", "suffix", "size", "mtime_ns") def __init__(self, path: Path, relative: str, size: int, mtime_ns: int) -> None: self.path = path self.relative = relative self.name = path.name self.suffix = path.suffix.lower() self.size = size self.mtime_ns = mtime_ns @property def is_presentation_source(self) -> bool: return self.relative in PRESENTATION_SOURCE_FILES @property def is_user_document(self) -> bool: return bool(USER_GUIDE_DOC_RE.match(self.name.lower())) @property def is_presentation_asset(self) -> bool: return self.suffix in PRESENTATION_ASSET_EXTENSIONS def _scan_skill_files(skill_dir: Path) -> list[_ScannedFile]: """Walk a Skill directory exactly once and reuse the result everywhere. ``Path.rglob`` is a Python-level recursive scan and every call re-stats each entry, so listing one Skill used to cost four full walks plus three reads of the same SKILL.md. ``os.scandir`` reuses the stat data the OS already handed back, and every consumer below shares this single result instead. """ found: list[_ScannedFile] = [] pending: list[Path] = [skill_dir] while pending: current = pending.pop() try: with os.scandir(current) as entries: for entry in entries: try: if entry.is_dir(follow_symlinks=False): pending.append(Path(entry.path)) continue if not entry.is_file(): continue info = entry.stat() except OSError: continue path = Path(entry.path) found.append(_ScannedFile( path, path.relative_to(skill_dir).as_posix(), info.st_size, info.st_mtime_ns, )) except OSError: continue # Preserve the historical ordering: the guide fingerprint must stay stable # for unchanged directories so existing cached guides remain valid. found.sort(key=lambda item: item.path) return found def _guide_fingerprint(files: list[_ScannedFile]) -> str: """Cheap version of everything that can change a Skill's rendered surface.""" digest = hashlib.sha256() for item in files: if not ( item.relative == "SKILL.md" or item.is_user_document or item.is_presentation_source or item.is_presentation_asset ): continue digest.update(f"{item.relative}:{item.size}:{item.mtime_ns}\n".encode("utf-8")) return digest.hexdigest()[:20] def _content_fingerprint(files: list[_ScannedFile]) -> str: """Version of every file in the directory, used to key the describe cache.""" digest = hashlib.sha256() for item in files: digest.update(f"{item.relative}:{item.size}:{item.mtime_ns}\n".encode("utf-8")) return digest.hexdigest()[:20] def _presentation_summary(presentation: dict[str, Any]) -> dict[str, Any]: return { key: copy.deepcopy(presentation[key]) for key in PRESENTATION_SUMMARY_KEYS if key in presentation } def _guide_document_score(path: str) -> tuple[int, str]: normalized = path.replace("\\", "/").lower() name = normalized.rsplit("/", 1)[-1] score = 20 if "/" in normalized else 0 if re.search(r"zh[-_.]?(?:cn|hans)|chinese|中文", name): score -= 12 if name.startswith("readme"): score -= 8 if re.match(r"^(?:usage|quickstart|getting[-_ ]started|user[-_ ]guide)", name): score -= 6 if normalized.startswith("_package-docs/"): score -= 4 return score, normalized def _ancestor_directories(skill_file: Path, search_root: Path) -> list[Path]: """Directories from the Skill folder up to and including the search root.""" ancestors: list[Path] = [] search_root_resolved = search_root.resolve() current = skill_file.parent.parent while True: try: current.resolve().relative_to(search_root_resolved) except ValueError: break ancestors.append(current) if current.resolve() == search_root_resolved: break current = current.parent return ancestors def _package_documents(skill_file: Path, search_root: Path) -> list[Path]: """Return the documents that describe this Skill package. SKILL.md is the authoritative protocol file, but repositories also document how to run a Skill in ``references/*.md`` beside it and in the README above it. All of those can name directories the Skill needs at run time, so all of them are read when working out what the package contains. """ documents = [skill_file] taken = 0 for scanned in _scan_skill_files(skill_file.parent): if taken >= MAX_PACKAGE_DOCUMENTS: break if scanned.suffix not in PACKAGE_DOCUMENT_SUFFIXES: continue taken += 1 documents.append(scanned.path) for ancestor in _ancestor_directories(skill_file, search_root): try: entries = sorted(ancestor.iterdir()) except OSError: continue for candidate in entries: if candidate.is_file() and PACKAGE_DOC_RE.match(candidate.name): documents.append(candidate) return documents def _referenced_package_roots(skill_file: Path, search_root: Path) -> list[Path]: """Find repository directories that belong to this Skill package. Some repositories keep SKILL.md in a small definition directory while the executable implementation lives beside it (for example ``media-parser/`` + ``astrbot_plugin_media_parser/``), and monorepo layouts ship a companion service in a sibling package (``skills/holo-card/`` + ``packages/holo-card-api/``). Installing only the SKILL.md parent produces a Skill that can explain commands but cannot run them, so every directory named as ``dir/...`` by the package documents is included when it really exists next to the Skill. Copying an ancestor would drag the Skill folder into its own ``scripts`` directory, and copying a directory that holds another SKILL.md would install a second, competing Skill — both are skipped. """ segments: list[str] = [] for document in _package_documents(skill_file, search_root): try: content = document.read_text(encoding="utf-8")[:MAX_RESOURCE_BYTES] except (OSError, UnicodeError): continue for match in re.finditer(r"(?= 256: break if len(segments) >= 256: break skill_dir = skill_file.parent.resolve() roots: list[Path] = [] search_root_resolved = search_root.resolve() for current in _ancestor_directories(skill_file, search_root): for segment in segments: candidate = (current / segment).resolve() try: candidate.relative_to(search_root_resolved) except ValueError: continue if not candidate.exists() or candidate in roots: continue if candidate.name in RESERVED_PACKAGE_ROOT_NAMES: continue if candidate == skill_dir or candidate in skill_dir.parents or skill_dir in candidate.parents: continue if next(candidate.rglob("SKILL.md"), None) is not None: continue roots.append(candidate) return roots def _validate_zip_member(info: zipfile.ZipInfo) -> PurePosixPath: path = PurePosixPath(info.filename.replace("\\", "/")) if path.is_absolute() or ".." in path.parts: raise SkillInstallError(f"压缩包包含不安全路径:{info.filename}") mode = info.external_attr >> 16 if mode and (mode & 0o170000) == 0o120000: raise SkillInstallError(f"压缩包不允许符号链接:{info.filename}") if info.file_size > MAX_FILE_BYTES: raise SkillInstallError(f"文件过大:{info.filename}") return path def _is_export_excluded(relative: str) -> bool: parts = relative.split("/") if parts[-1] in EXPORT_EXCLUDED_NAMES or parts[-1].endswith(".pyc"): return True return any(part in EXPORT_EXCLUDED_PARTS for part in parts) def _export_bundle_sources(extracted: Path) -> dict[str, dict[str, Any]]: """Return {skill_id: manifest entry} when the archive is an export bundle. A bundle produced by ``export_skills`` is the unit of migration between deployments: it carries each Skill's recorded origin so the importing deployment can keep updating it from the same place. Importing must therefore not relabel the Skill as a local upload — which only happens for archives that actually declare the bundle format. """ path = extracted / EXPORT_MANIFEST_FILE if not path.is_file(): return {} try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, ValueError): return {} if not isinstance(payload, dict) or payload.get("format") != SKILL_EXPORT_FORMAT: return {} entries: dict[str, dict[str, Any]] = {} for item in payload.get("skills") or []: if isinstance(item, dict) and str(item.get("id") or "").strip(): entries[str(item["id"]).strip()] = item return entries def _restore_preserved_state(target: Path, backup: Path) -> None: """Copy administrator state from an overwrite snapshot into the fresh install. A reinstall brings new code, but the administrator's decisions about the Skill — which scripts may run, how it is presented, which media was uploaded and whether end users see it — belong to the installation, not to the package, so they are carried across. """ for name in PRESERVED_STATE_FILES: source = backup / name if source.is_file(): shutil.copy2(source, target / name) for name in PRESERVED_STATE_DIRS: source = backup / name if not source.is_dir(): continue destination = target / name if destination.exists(): shutil.rmtree(destination, ignore_errors=True) shutil.copytree(source, destination) def _format_size_limit(limit: int) -> str: return f"{limit // (1024 * 1024)}MB" def _extract_archive( payload: bytes, destination: Path, *, subpath: str = "", max_bytes: int = MAX_ARCHIVE_BYTES, max_files: int = MAX_FILES, ) -> None: if len(payload) > max_bytes: raise SkillInstallError(f"ZIP 不能超过 {_format_size_limit(max_bytes)}") try: archive = zipfile.ZipFile(io.BytesIO(payload)) except zipfile.BadZipFile as exc: raise SkillInstallError("上传内容不是有效 ZIP") from exc infos = archive.infolist() if subpath: wanted = PurePosixPath(subpath).parts infos = [ info for info in infos if tuple(PurePosixPath(info.filename.replace("\\", "/")).parts[1:1 + len(wanted)]) == wanted ] if not infos: raise SkillInstallError(f"仓库中找不到技能目录:{subpath}") if len(infos) > max_files: raise SkillInstallError(f"ZIP 文件数量不能超过 {max_files}") total_size = sum(info.file_size for info in infos) if total_size > max_bytes: raise SkillInstallError(f"ZIP 解压后不能超过 {_format_size_limit(max_bytes)}") for info in infos: relative = _validate_zip_member(info) if not relative.parts: continue target = destination.joinpath(*relative.parts) if info.is_dir(): target.mkdir(parents=True, exist_ok=True) continue target.parent.mkdir(parents=True, exist_ok=True) with archive.open(info) as source, target.open("wb") as output: shutil.copyfileobj(source, output) def _github_download_candidates(url: str) -> list[tuple[str, str]]: parsed = urlparse(url) if parsed.netloc.lower() not in {"github.com", "www.github.com"}: return [(url, "")] parts = [part for part in parsed.path.split("/") if part] if len(parts) < 2: raise SkillInstallError("GitHub URL 必须包含 owner/repository") owner, repository = parts[0], parts[1].removesuffix(".git") if len(parts) >= 5 and parts[2] == "tree": ref = parts[3] subpath = "/".join(parts[4:]) return [(f"https://codeload.github.com/{owner}/{repository}/zip/{ref}", subpath)] return [ (f"https://codeload.github.com/{owner}/{repository}/zip/refs/heads/main", ""), (f"https://codeload.github.com/{owner}/{repository}/zip/refs/heads/master", ""), ] class SkillService: def __init__( self, root: Path = SKILLS_DIR, runs_root: Path = SKILL_RUNS_DIR, favorites_path: Path | None = None, ) -> None: self.root = root self.runs_root = runs_root self.favorites_path = favorites_path or ( DATA_DIR / SKILL_FAVORITES_FILE if root == SKILLS_DIR else root.parent / SKILL_FAVORITES_FILE ) self._favorites_lock = threading.RLock() # Keyed by (resolved directory, guide version). The cache always holds the # full presentation; summaries are projected from it so the list view and # the detail view no longer build the same Skill twice. self._presentation_cache: dict[tuple[str, str], dict[str, Any]] = {} self._describe_cache: dict[tuple[str, str, bool], dict[str, Any]] = {} self._run_cleanup_lock = threading.RLock() self._last_run_cleanup_at = 0.0 self._media_link_lock = threading.RLock() self._media_links: dict[str, dict[str, Any]] = {} def sync_bundled_skills(self, source_root: Path = BUNDLED_SKILLS_DIR) -> list[str]: """Install the Skills shipped with the application into runtime storage. Runtime storage may be a Hugging Face persistent volume, whereas the bundled directory lives in the immutable application image. Refresh the application-owned files on every deployment while retaining the administrator-controlled visibility, permissions and presentation overrides already stored on the volume. """ if not source_root.is_dir(): return [] self.root.mkdir(parents=True, exist_ok=True) synced: list[str] = [] for source in sorted(path for path in source_root.iterdir() if path.is_dir()): skill_file = source / "SKILL.md" if not skill_file.is_file(): continue metadata = _frontmatter(skill_file) skill_id = _safe_name(metadata["name"], _safe_name(source.name)) target = self.root / skill_id digest = hashlib.sha256() for resource in sorted(path for path in source.rglob("*") if path.is_file()): relative = resource.relative_to(source).as_posix() digest.update(relative.encode("utf-8")) digest.update(b"\0") digest.update(resource.read_bytes()) digest.update(b"\0") version = digest.hexdigest() version_file = target / BUNDLED_SKILL_VERSION_FILE if version_file.is_file() and version_file.read_text(encoding="utf-8").strip() == version: continue staging = self.root / f".{skill_id}.bundled-{os.getpid()}-{uuid.uuid4().hex[:6]}" previous = self.root / f".{skill_id}.previous-{os.getpid()}-{uuid.uuid4().hex[:6]}" try: shutil.copytree(source, staging) (staging / BUNDLED_SKILL_VERSION_FILE).write_text(version + "\n", encoding="utf-8") if target.is_dir(): _restore_preserved_state(staging, target) os.replace(target, previous) os.replace(staging, target) shutil.rmtree(previous, ignore_errors=True) synced.append(skill_id) except Exception: shutil.rmtree(staging, ignore_errors=True) if previous.exists() and not target.exists(): os.replace(previous, target) raise self._presentation_cache.clear() self._describe_cache.clear() return synced def register_media_link( self, url: str, *, headers: dict[str, object] | None, name: str, media_type: str, ) -> dict[str, Any]: clean_url = _require_public_remote_url(url) allowed_headers = {"user-agent", "accept", "accept-language", "referer", "origin"} clean_headers = { str(key): str(value) for key, value in (headers or {}).items() if str(key).lower() in allowed_headers and str(value).strip() } token = secrets.token_urlsafe(32) expires_at = time.time() + MEDIA_PARSER_ARTIFACT_TTL_SECONDS with self._media_link_lock: current = time.time() self._media_links = { key: value for key, value in self._media_links.items() if float(value.get("expires_at") or 0) > current } self._media_links[token] = { "url": clean_url, "headers": clean_headers, "name": _safe_output_name(name), "media_type": str(media_type or "application/octet-stream"), "expires_at": expires_at, } return { "name": _safe_output_name(name), "type": str(media_type or "application/octet-stream"), "size": 0, "url": f"/api/skills/media/{token}", "expires_at": expires_at, "source_url": clean_url, "streamed": True, } def resolve_media_link(self, token: str) -> dict[str, Any]: clean_token = str(token or "").strip() if not re.fullmatch(r"[A-Za-z0-9_-]{32,128}", clean_token): raise SkillInstallError("媒体下载链接无效") with self._media_link_lock: record = self._media_links.get(clean_token) if not record or float(record.get("expires_at") or 0) <= time.time(): self._media_links.pop(clean_token, None) raise SkillInstallError("媒体下载链接已过期,请重新解析") return dict(record) def _media_stream_artifacts(self, stdout: str) -> list[dict[str, Any]]: try: payload = json.loads(stdout) except (TypeError, ValueError): return [] results = payload.get("results") if isinstance(payload, dict) else [] if not isinstance(results, list): return [] artifacts: list[dict[str, Any]] = [] for result_index, result in enumerate(results): if not isinstance(result, dict) or result.get("error"): continue title = _safe_name(str(result.get("title") or f"media-{result_index + 1}"), f"media-{result_index + 1}") videos = result.get("videos") if isinstance(result.get("videos"), list) else [] images = result.get("images") if isinstance(result.get("images"), list) else [] for index, media in enumerate(videos): if not isinstance(media, dict) or not media.get("direct_url"): continue artifacts.append(self.register_media_link( str(media["direct_url"]), headers=media.get("headers") if isinstance(media.get("headers"), dict) else {}, name=f"{title}-video-{index + 1}.mp4", media_type="video/mp4", )) for index, media in enumerate(images): if not isinstance(media, dict) or not media.get("direct_url"): continue artifacts.append(self.register_media_link( str(media["direct_url"]), headers=media.get("headers") if isinstance(media.get("headers"), dict) else {}, name=f"{title}-image-{index + 1}.jpg", media_type="image/jpeg", )) return artifacts[:MAX_SCRIPT_OUTPUT_FILES] def cleanup_expired_runs(self, *, force: bool = False, now: float | None = None) -> int: """Remove expired temporary Skill artifacts and idempotency records.""" current = time.time() if now is None else float(now) with self._run_cleanup_lock: monotonic_now = time.monotonic() if not force and monotonic_now - self._last_run_cleanup_at < 60: return 0 self._last_run_cleanup_at = monotonic_now removed = 0 if self.runs_root.is_dir(): for marker in self.runs_root.glob("*/*/.expires-at"): try: expires_at = float(marker.read_text(encoding="utf-8").strip()) except (OSError, UnicodeError, ValueError): continue if expires_at > current: continue run_dir = marker.parent.resolve() try: run_dir.relative_to(self.runs_root.resolve()) except ValueError: continue shutil.rmtree(run_dir, ignore_errors=True) removed += 1 cache_root = self.runs_root / ".runs" if cache_root.is_dir(): for cache_path in cache_root.glob("*.json"): try: payload = json.loads(cache_path.read_text(encoding="utf-8")) expires_at = float(payload.get("expires_at") or 0) if isinstance(payload, dict) else 0 except (OSError, UnicodeError, ValueError, TypeError): continue legacy_run_dir: Path | None = None if isinstance(payload, dict) and payload.get("skill_id") == "media-parser" and not expires_at: artifacts = payload.get("artifacts") if isinstance(payload.get("artifacts"), list) else [] first_url = next((str(item.get("url") or "") for item in artifacts if isinstance(item, dict)), "") prefix = "/files/skills/" if first_url.startswith(prefix): relative = PurePosixPath(first_url[len(prefix):]) if len(relative.parts) >= 3 and ".." not in relative.parts: candidate = (self.runs_root / Path(*relative.parts[:2])).resolve() try: candidate.relative_to(self.runs_root.resolve()) if candidate.is_dir(): legacy_run_dir = candidate except ValueError: legacy_run_dir = None if legacy_run_dir is not None: expires_at = legacy_run_dir.stat().st_mtime + MEDIA_PARSER_ARTIFACT_TTL_SECONDS (legacy_run_dir / ".expires-at").write_text(str(expires_at), encoding="utf-8") payload["expires_at"] = expires_at cache_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") if expires_at and expires_at <= current: if legacy_run_dir is not None: shutil.rmtree(legacy_run_dir, ignore_errors=True) removed += 1 cache_path.unlink(missing_ok=True) return removed def _enforce_media_owner_quota(self, owner: str) -> int: owner_root = (self.runs_root / owner).resolve() try: owner_root.relative_to(self.runs_root.resolve()) except ValueError: return 0 if not owner_root.is_dir(): return 0 runs: list[tuple[float, Path, int]] = [] total = 0 for marker in owner_root.glob("*/.expires-at"): run_dir = marker.parent try: size = sum( path.stat().st_size for path in run_dir.rglob("*") if path.is_file() and path.name != ".expires-at" ) created = run_dir.stat().st_mtime except OSError: continue total += size runs.append((created, run_dir, size)) removed = 0 for _created, run_dir, size in sorted(runs): if total <= MEDIA_PARSER_OWNER_STORAGE_BYTES: break shutil.rmtree(run_dir, ignore_errors=True) total -= size removed += 1 return removed def list(self) -> list[dict[str, Any]]: self.root.mkdir(parents=True, exist_ok=True) items: list[dict[str, Any]] = [] for skill_file in sorted(self.root.glob("*/SKILL.md")): # Installations are assembled in a hidden staging directory and # atomically renamed when complete. Never expose a partial Skill. if skill_file.parent.name.startswith("."): continue skill_dir = skill_file.parent files = _scan_skill_files(skill_dir) metadata = _frontmatter(skill_file) install_metadata = self._install_metadata(skill_dir) permissions = self._permissions(skill_dir) enabled = self._visibility(skill_dir) guide_version = _guide_fingerprint(files) presentation = self._presentation_for( skill_dir, metadata, guide_version=guide_version, files=files, summary_only=True, ) items.append({ "id": skill_dir.name, "name": metadata["name"], "description": metadata["description"], "source": str(install_metadata.get("source") or "manual"), "installed_at": str(install_metadata.get("installed_at") or ""), "file_count": len(files), "scripts_allowed": bool(permissions.get("allow_scripts")), "allowed_scripts": list(permissions.get("allowed_scripts") or []), "enabled": enabled, "guide_version": guide_version, "presentation_summary": presentation, }) return items @staticmethod def _read_presentation_manifest(skill_dir: Path) -> dict[str, Any]: """Return skill-ui.json, or an empty config when it is absent or unusable.""" path = skill_dir / SKILL_PRESENTATION_FILE if not path.is_file(): return {} try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, ValueError): return {} return value if isinstance(value, dict) else {} @staticmethod def _read_presentation_overrides(skill_dir: Path) -> dict[str, Any]: """Return the administrator overrides, ignoring unreadable or malformed files.""" path = skill_dir / SKILL_PRESENTATION_OVERRIDE_FILE if not path.is_file(): return {} try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, ValueError): return {} return value if isinstance(value, dict) else {} @staticmethod def _merge_presentation_config(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]: result = dict(base) for key, value in overrides.items(): if key == "display" and isinstance(value, dict): current = result.get("display") if isinstance(result.get("display"), dict) else {} result["display"] = {**current, **value} else: result[key] = value return result @staticmethod def _safe_presentation_asset(skill_dir: Path, value: object) -> Path | None: if not isinstance(value, str) or not value.strip(): return None try: relative = _safe_relative_path(value.strip()) except SkillInstallError: return None target = (skill_dir / relative).resolve() try: target.relative_to(skill_dir.resolve()) except ValueError: return None if not target.is_file() or target.stat().st_size > MAX_FILE_BYTES: return None return target def _build_presentation( self, skill_dir: Path, metadata: dict[str, str], *, files: list[_ScannedFile] | None = None, ) -> dict[str, Any]: scanned = files if files is not None else _scan_skill_files(skill_dir) manifest = self._read_presentation_manifest(skill_dir) overrides = self._read_presentation_overrides(skill_dir) manifest = self._merge_presentation_config(manifest, overrides) display = manifest.get("display") if isinstance(manifest.get("display"), dict) else {} origin = _skill_origin(skill_dir, metadata, manifest) skill_id = skill_dir.name name = str(display.get("title") or metadata.get("name") or skill_id).strip() description = str(display.get("description") or metadata.get("description") or "").strip() category = str(display.get("category") or _presentation_category(skill_id, name, description)).strip() tags = _string_list(display.get("tags") or manifest.get("tags"), limit=12) content = "" try: content = (skill_dir / "SKILL.md").read_text(encoding="utf-8")[:50000] except (OSError, UnicodeError): content = "" searchable = f"{name}\n{description}\n{content}".lower() capabilities = _string_list(manifest.get("capabilities"), limit=8) inferred_capabilities: list[str] = [] if re.search(r"web[_ -]?search|联网|搜索", searchable): inferred_capabilities.append("web_search") if re.search(r"image[_ -]?(?:generation|edit)|生图|图生图|图片生成|图片编辑|poster|photo|logo|art", searchable): inferred_capabilities.append("image") if any((skill_dir / folder).is_dir() for folder in ("scripts",)): inferred_capabilities.append("script") if re.search(r"html|网页|interactive|交互", searchable): inferred_capabilities.append("html") if not capabilities: capabilities = inferred_capabilities or ["chat"] asset_entries: list[dict[str, Any]] = [] seen_assets: set[str] = set() def add_asset(raw: object, *, default_role: str = "gallery") -> None: item = raw if isinstance(raw, dict) else {"path": raw} path_value = item.get("path") if isinstance(item, dict) else None target = self._safe_presentation_asset(skill_dir, path_value) if target is None or target.suffix.lower() not in PRESENTATION_ASSET_EXTENSIONS: return relative = target.relative_to(skill_dir.resolve()).as_posix() if relative in seen_assets: return seen_assets.add(relative) mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream" asset_entries.append({ "path": relative, "name": str(item.get("title") or item.get("name") or target.stem).strip()[:120], "description": str(item.get("description") or "").strip()[:500], "type": mime_type, "kind": _asset_kind(target, mime_type), "size": target.stat().st_size, "role": str(item.get("role") or default_role).strip()[:40], }) explicit_cover = display.get("cover") or manifest.get("cover") if explicit_cover: add_asset({"path": explicit_cover, "role": "cover", "title": name}, default_role="cover") raw_assets = manifest.get("assets") if isinstance(raw_assets, dict): raw_assets = raw_assets.get("gallery") or raw_assets.get("items") or [] if isinstance(raw_assets, list): for item in raw_assets[:24]: add_asset(item) readme_documents = sorted( (item for item in scanned if item.is_user_document), key=lambda item: _guide_document_score(item.relative), )[:4] readme_text = "" for readme in readme_documents: if readme.size > MAX_RESOURCE_BYTES: continue try: text_value = readme.path.read_text(encoding="utf-8") except (OSError, UnicodeError): continue readme_text += f"\n{text_value[:12000]}" for match in re.finditer(r"!\[[^\]]*\]\(([^)\s]+)(?:\s+[^)]*)?\)", text_value): source = match.group(1).strip("<>\"'") if re.match(r"^[a-z]+://", source, re.IGNORECASE): continue try: relative_to_skill = (readme.path.parent / source).resolve().relative_to(skill_dir.resolve()).as_posix() except (ValueError, OSError): continue add_asset(relative_to_skill) if len(asset_entries) < 24: for item in scanned: if not item.is_presentation_asset: continue parts = PurePosixPath(item.relative).parts if not parts or parts[0].lower() not in PRESENTATION_MEDIA_ROOTS: continue add_asset(item.relative) if len(asset_entries) >= 24: break cover = next((item for item in asset_entries if item["role"] == "cover"), None) if cover is None: cover = next((item for item in asset_entries if item["kind"] == "image"), None) if cover: cover = {**cover, "role": "cover"} raw_examples = manifest.get("examples") if isinstance(manifest.get("examples"), list) else [] examples: list[dict[str, Any]] = [] for index, raw in enumerate(raw_examples[:8]): if not isinstance(raw, dict): continue prompt = str(raw.get("prompt") or "").strip() if not prompt: continue examples.append({ "id": str(raw.get("id") or f"example-{index + 1}")[:80], "title": str(raw.get("title") or f"示例 {index + 1}")[:120], "description": str(raw.get("description") or "")[:500], "prompt": prompt[:4000], "assets": _string_list(raw.get("assets"), limit=8), "attachment_hint": str(raw.get("attachmentHint") or raw.get("attachment_hint") or "")[:300], }) if not examples: example_source = readme_text or content candidates = [ match.group(1).strip() for match in re.finditer(r"```(?:text|prompt|markdown)?\s*\n([\s\S]{12,1200}?)\n```", example_source, re.IGNORECASE) ] candidates.extend(match.group(0).strip() for match in re.finditer(rf"\${re.escape(skill_id)}\b[^\n]{{4,500}}", example_source, re.IGNORECASE)) for index, prompt in enumerate(candidates[:4]): if not prompt.startswith(f"${skill_id}"): prompt = f"${skill_id} {prompt}" examples.append({"id": f"auto-{index + 1}", "title": f"示例 {index + 1}", "description": "", "prompt": prompt, "assets": [], "attachment_hint": ""}) if not examples: examples.append({ "id": "starter", "title": "从你的需求开始", "description": description, "prompt": f"${skill_id} 请根据我接下来提供的要求完成任务", "assets": [], "attachment_hint": "如任务依赖图片或文件,请一并上传。", }) def normalize_specs(key: str, defaults: list[dict[str, Any]]) -> list[dict[str, Any]]: raw_specs = manifest.get(key) if not isinstance(raw_specs, list): return defaults result: list[dict[str, Any]] = [] for index, raw in enumerate(raw_specs[:12]): if isinstance(raw, str): raw = {"label": raw} if not isinstance(raw, dict) or not str(raw.get("label") or raw.get("name") or "").strip(): continue result.append({ "id": str(raw.get("id") or f"{key}-{index + 1}")[:80], "label": str(raw.get("label") or raw.get("name"))[:120], "type": str(raw.get("type") or "text")[:40], "required": bool(raw.get("required", False)), "description": str(raw.get("description") or "")[:500], }) return result or defaults default_inputs = [{"id": "request", "label": "任务要求", "type": "text", "required": True, "description": "说明目标、风格、尺寸或交付要求。"}] input_searchable = f"{searchable}\n{readme_text.lower()}" has_image_input = "image" in capabilities and bool(re.search( r"supplied image|uploaded image|user[- ]provided image|source image|reference image|" r"参考图|上传图片|上传照片|原图|照片", input_searchable, )) # Auto-discovered image inputs are suggestions, never hard gates. An # administrator can explicitly mark an image input as required through # skill-ui.json or the presentation overrides editor. image_input_required = False if has_image_input: default_inputs.append({ "id": "image", "label": "参考图片", "type": "image", "required": image_input_required, "description": "上传需要处理或参考的图片。" if image_input_required else "如需基于现有画面处理,可上传参考图片。", }) inputs = normalize_specs("inputs", default_inputs) default_outputs: list[dict[str, Any]] = [] if "image" in capabilities: default_outputs.append({"id": "image", "label": "图片结果", "type": "image", "required": False, "description": "可预览、下载并继续作为参考图使用。"}) if "html" in capabilities: default_outputs.append({"id": "html", "label": "互动 HTML", "type": "html", "required": False, "description": "在安全沙箱中预览,可手动启用交互。"}) if "script" in capabilities: default_outputs.append({"id": "file", "label": "文件产物", "type": "file", "required": False, "description": "由受控脚本生成的可下载文件。"}) if not default_outputs: default_outputs.append({"id": "answer", "label": "结构化结果", "type": "text", "required": False, "description": "在对话中直接交付。"}) outputs = normalize_specs("outputs", default_outputs) workflow: list[dict[str, Any]] = [] workflow_summary = "" workflow_path = skill_dir / SKILL_WORKFLOW_FILE if workflow_path.is_file(): try: raw_workflow = json.loads(workflow_path.read_text(encoding="utf-8")) workflow_summary = str(raw_workflow.get("summary") or "").strip()[:500] if isinstance(raw_workflow, dict) else "" tasks = raw_workflow.get("tasks") if isinstance(raw_workflow, dict) else [] if not isinstance(tasks, list) or not tasks: raise ValueError("tasks missing") for index, task in enumerate(tasks[:32]): if not isinstance(task, dict): continue workflow.append({ "id": str(task.get("id") or f"step-{index + 1}")[:80], "title": str(task.get("title") or task.get("id") or f"步骤 {index + 1}")[:120], "description": str(task.get("description") or task.get("prompt") or "")[:500], "capability": str(task.get("capability") or ("image" if "image" in capabilities else "task"))[:40], }) postprocess = raw_workflow.get("postprocess") if isinstance(raw_workflow, dict) else None if isinstance(postprocess, dict) and postprocess.get("script"): workflow.append({"id": "postprocess", "title": str(postprocess.get("title") or "整理最终产物")[:120], "description": "将前面步骤的结果组装为最终文件。", "capability": "script"}) except (OSError, UnicodeError, ValueError, TypeError): # A malformed workflow file simply falls back to the plain Skill flow. workflow = [] result = { "version": 1, "title": name, "description": description, "category": category, "tags": tags, "capabilities": capabilities, "origin": origin, "cover": cover, "asset_count": len(asset_entries), "example_count": len(examples), "input_count": len(inputs), "output_count": len(outputs), "workflow_count": len(workflow), } return { **result, "assets": asset_entries, "examples": examples, "inputs": inputs, "outputs": outputs, "workflow": {"summary": workflow_summary, "steps": workflow}, "manifest": { "present": bool(manifest), "path": SKILL_PRESENTATION_FILE, "overridden": bool(overrides), "override_path": SKILL_PRESENTATION_OVERRIDE_FILE, }, } def _presentation_for( self, skill_dir: Path, metadata: dict[str, str], *, guide_version: str | None = None, files: list[_ScannedFile] | None = None, summary_only: bool = False, ) -> dict[str, Any]: """Return the cached full presentation, projecting a summary on demand. The summary used to live under its own cache key, which meant the list request and the detail request could never share a build. Both views now read one entry, so opening a Skill normally reuses work already done. """ version = guide_version or (_guide_fingerprint(files) if files is not None else self._guide_version(skill_dir)) key = (str(skill_dir.resolve()), version) cached = self._presentation_cache.get(key) if cached is None: cached = self._build_presentation(skill_dir, metadata, files=files) self._presentation_cache[key] = cached if len(self._presentation_cache) > 256: self._presentation_cache = dict(list(self._presentation_cache.items())[-128:]) return _presentation_summary(cached) if summary_only else cached def presentation(self, skill_id: str) -> dict[str, Any]: skill_dir = self._skill_dir(skill_id) metadata = _frontmatter(skill_dir / "SKILL.md") return copy.deepcopy(self._presentation_for(skill_dir, metadata)) def _invalidate_caches(self, skill_dir: Path) -> None: prefix = str(skill_dir.resolve()) self._presentation_cache = { key: value for key, value in self._presentation_cache.items() if key[0] != prefix } self._describe_cache = { key: value for key, value in self._describe_cache.items() if key[0] != prefix } def reindex_presentation(self, skill_id: str) -> dict[str, Any]: skill_dir = self._skill_dir(skill_id) self._invalidate_caches(skill_dir) return self.presentation(skill_id) def update_presentation(self, skill_id: str, value: dict[str, Any]) -> dict[str, Any]: skill_dir = self._skill_dir(skill_id) allowed = {"display", "assets", "examples", "inputs", "outputs", "capabilities", "tags", "author", "version", "source"} current = self._read_presentation_overrides(skill_dir) incoming = {key: item for key, item in value.items() if key in allowed} payload = self._merge_presentation_config(current, incoming) display = payload.get("display") if display is not None and not isinstance(display, dict): raise SkillInstallError("display 必须是对象") if isinstance(display, dict): payload["display"] = { key: item for key, item in display.items() if key in {"title", "description", "category", "cover", "tags"} } for key in ("assets", "examples", "inputs", "outputs", "capabilities", "tags"): if key in payload and not isinstance(payload[key], list): raise SkillInstallError(f"{key} 必须是数组") path = skill_dir / SKILL_PRESENTATION_OVERRIDE_FILE temporary = path.with_suffix(f"{path.suffix}.{uuid.uuid4().hex}.tmp") temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") os.replace(temporary, path) return self.reindex_presentation(skill_id) def upload_presentation_assets( self, skill_id: str, uploads: list[tuple[bytes, str, str]], ) -> list[dict[str, Any]]: skill_dir = self._skill_dir(skill_id) if not uploads: raise SkillInstallError("请选择要上传的展示图片") if len(uploads) > 12: raise SkillInstallError("一次最多上传 12 张展示图片") target_dir = skill_dir / SKILL_PRESENTATION_ASSET_DIR target_dir.mkdir(parents=True, exist_ok=True) results: list[dict[str, Any]] = [] allowed_extensions = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"} for content, original_name, content_type in uploads: if not content or len(content) > MAX_FILE_BYTES: raise SkillInstallError(f"展示图片 {original_name or '未命名文件'} 为空或超过 8MB") suffix = Path(original_name or "image").suffix.lower() if suffix not in allowed_extensions or not str(content_type or "").lower().startswith("image/"): raise SkillInstallError(f"展示素材只支持 PNG、JPG、WebP、GIF 和 SVG:{original_name}") stem = _safe_name(Path(original_name).stem, "showcase")[:48] filename = f"{stem}-{uuid.uuid4().hex[:10]}{suffix}" target = target_dir / filename target.write_bytes(content) relative = target.relative_to(skill_dir).as_posix() mime_type = mimetypes.guess_type(target.name)[0] or content_type or "application/octet-stream" results.append({ "path": relative, "name": Path(original_name).stem[:120] or stem, "description": "", "type": mime_type, "kind": "image", "size": len(content), "role": "gallery", }) self.reindex_presentation(skill_id) return results def _read_favorites(self) -> dict[str, list[str]]: if not self.favorites_path.is_file(): return {} try: value = json.loads(self.favorites_path.read_text(encoding="utf-8")) except (OSError, UnicodeError, ValueError): return {} if not isinstance(value, dict): return {} result: dict[str, list[str]] = {} for owner, items in value.items(): if isinstance(owner, str) and isinstance(items, list): result[owner] = [str(item) for item in items if isinstance(item, str)] return result def favorite_ids(self, owner_id: str) -> list[str]: owner = str(owner_id or "").strip() if not owner: return [] with self._favorites_lock: known = {item["id"] for item in self.catalog()} return [item for item in self._read_favorites().get(owner, []) if item in known] def set_favorite(self, owner_id: str, skill_id: str, *, favorite: bool) -> list[str]: self.ensure_enabled(skill_id) owner = str(owner_id or "").strip() if not owner: raise SkillInstallError("用户身份无效") with self._favorites_lock: value = self._read_favorites() items = list(dict.fromkeys(value.get(owner, []))) if favorite and skill_id not in items: items.append(skill_id) if not favorite: items = [item for item in items if item != skill_id] value[owner] = items self.favorites_path.parent.mkdir(parents=True, exist_ok=True) temporary = self.favorites_path.with_suffix(f"{self.favorites_path.suffix}.{uuid.uuid4().hex}.tmp") temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8") os.replace(temporary, self.favorites_path) return items def resolve_asset(self, skill_id: str, asset_path: str) -> tuple[Path, str]: skill_dir = self._skill_dir(skill_id) target = self._safe_presentation_asset(skill_dir, asset_path) if target is None or target.suffix.lower() not in PRESENTATION_ASSET_EXTENSIONS: raise SkillInstallError("Skill 展示素材不存在、过大或类型不受支持") return target, mimetypes.guess_type(target.name)[0] or "application/octet-stream" def catalog(self, *, include_content: bool = False) -> list[dict[str, Any]]: items = [item for item in self.list() if item.get("enabled", True)] if not include_content: return items for item in items: skill_file = self.root / str(item["id"]) / "SKILL.md" try: item["content"] = skill_file.read_text(encoding="utf-8")[:50000] except (OSError, UnicodeError): item["content"] = "" return items def _skill_dir(self, skill_id: str) -> Path: safe_id = _safe_name(skill_id) if safe_id != skill_id or not safe_id or safe_id.startswith("."): raise SkillInstallError("Skill ID 无效") target = (self.root / safe_id).resolve() root = self.root.resolve() try: target.relative_to(root) except ValueError as exc: raise SkillInstallError("Skill 路径无效") from exc if not target.is_dir() or not (target / "SKILL.md").is_file(): raise SkillInstallError("Skill 不存在") return target @staticmethod def _install_metadata(skill_dir: Path) -> dict[str, Any]: """Return .install.json — where the Skill came from and when.""" path = skill_dir / ".install.json" if not path.is_file(): return {} try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): return {} return value if isinstance(value, dict) else {} def _install_record( self, *, staging: Path, target: Path, source: str, bundle_entry: dict[str, Any] | None, package_roots: list[str], ) -> dict[str, Any]: """Build the .install.json written for a freshly installed Skill. The recorded origin is what decides whether an administrator can pull updates, so provenance carried by the package must survive the install: a migrated export bundle keeps the origin of the deployment it came from (that is the whole point of migrating), and re-uploading a plain ZIP over an existing Skill keeps that Skill's known origin instead of downgrading it to an anonymous local upload. Only archives that declare the bundle format may set their own origin — a repository that happens to ship a .install.json must not redirect the update address. """ import datetime incoming = self._install_metadata(staging) if bundle_entry is not None else {} manifest_entry = bundle_entry or {} preserved_source = str(incoming.get("source") or manifest_entry.get("source") or "").strip() preserved_installed_at = str(incoming.get("installed_at") or manifest_entry.get("installed_at") or "").strip() origin = "bundle" if preserved_source else "" if not preserved_source and source.startswith("upload:") and target.is_dir(): previous = self._install_metadata(target) previous_source = str(previous.get("source") or "").strip() if previous_source: preserved_source = previous_source preserved_installed_at = str(previous.get("installed_at") or "").strip() origin = "previous" record: dict[str, Any] = dict(incoming) if origin == "bundle" else {} record["source"] = preserved_source or source record["installed_at"] = ( preserved_installed_at or datetime.datetime.now(datetime.timezone.utc).isoformat() ) record["package_roots"] = package_roots if origin: record["imported_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat() record["imported_from"] = source record["origin_preserved"] = origin return record @staticmethod def _permissions(skill_dir: Path) -> dict[str, Any]: path = skill_dir / ".permissions.json" raw: dict[str, Any] = {} if path.is_file(): try: value = json.loads(path.read_text(encoding="utf-8")) if isinstance(value, dict): raw = value except (OSError, ValueError): raw = {} allowed = [ str(item).replace("\\", "/") for item in raw.get("allowed_scripts") or [] if isinstance(item, str) and item.strip() ] try: timeout = min(MAX_SCRIPT_TIMEOUT_SECONDS, max(5, int(raw.get("timeout_seconds") or DEFAULT_SCRIPT_TIMEOUT_SECONDS))) except (TypeError, ValueError): timeout = DEFAULT_SCRIPT_TIMEOUT_SECONDS return { "allow_scripts": bool(raw.get("allow_scripts")), "allowed_scripts": allowed, "timeout_seconds": timeout, } @staticmethod def _visibility(skill_dir: Path) -> bool: path = skill_dir / ".visibility.json" if not path.is_file(): return True try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): return True return bool(value.get("enabled", True)) if isinstance(value, dict) else True @staticmethod def _guide_version(skill_dir: Path) -> str: return _guide_fingerprint(_scan_skill_files(skill_dir)) def ensure_enabled(self, skill_id: str) -> None: skill_dir = self._skill_dir(skill_id) if not self._visibility(skill_dir): raise SkillInstallError("Skill 已被管理员隐藏") def guide_context(self, skill_id: str) -> dict[str, Any]: skill_dir = self._skill_dir(skill_id) files = _scan_skill_files(skill_dir) metadata = _frontmatter(skill_dir / "SKILL.md") documents: list[dict[str, str]] = [] candidates = sorted( (item for item in files if item.is_user_document), key=lambda item: _guide_document_score(item.relative), )[:4] for item in candidates: if item.size > MAX_RESOURCE_BYTES: continue try: content = item.path.read_text(encoding="utf-8") except (OSError, UnicodeError): continue documents.append({"path": item.relative, "content": content[:10000]}) return { "id": skill_id, "name": metadata["name"], "description": metadata["description"], "version": _guide_fingerprint(files), "content": (skill_dir / "SKILL.md").read_text(encoding="utf-8")[:MAX_RESOURCE_BYTES], "documents": documents, } def read_cached_guide(self, skill_id: str) -> dict[str, Any] | None: skill_dir = self._skill_dir(skill_id) path = skill_dir / GUIDE_CACHE_FILE if not path.is_file(): return None try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): return None version = self._guide_version(skill_dir) guide = str(value.get("guide") or "").strip() if isinstance(value, dict) else "" if not guide or value.get("version") != version: return None return {"guide": guide, "version": version, "cached": True} def write_cached_guide(self, skill_id: str, *, version: str, guide: str) -> dict[str, Any]: skill_dir = self._skill_dir(skill_id) current_version = self._guide_version(skill_dir) clean_guide = guide.strip() if version != current_version or not clean_guide: raise SkillInstallError("Skill 文档已更新,请重新生成使用指南") (skill_dir / GUIDE_CACHE_FILE).write_text( json.dumps({"version": current_version, "guide": clean_guide}, ensure_ascii=False), encoding="utf-8", ) return {"guide": clean_guide, "version": current_version, "cached": False} def describe(self, skill_id: str, *, include_content: bool = True) -> dict[str, Any]: return self._describe(skill_id, include_content=include_content)[0] def describe_with_etag(self, skill_id: str, *, include_content: bool = True) -> tuple[dict[str, Any], str]: payload, version = self._describe(skill_id, include_content=include_content) return payload, f'"{version}"' def _describe(self, skill_id: str, *, include_content: bool) -> tuple[dict[str, Any], str]: skill_dir = self._skill_dir(skill_id) files = _scan_skill_files(skill_dir) content_version = _content_fingerprint(files) cache_key = (str(skill_dir.resolve()), content_version, include_content) cached = self._describe_cache.get(cache_key) if cached is not None: return copy.deepcopy(cached), content_version metadata = _frontmatter(skill_dir / "SKILL.md") permissions = self._permissions(skill_dir) resources: list[dict[str, Any]] = [] scripts: list[dict[str, Any]] = [] for item in files: if item.name in { ".install.json", ".permissions.json", ".visibility.json", GUIDE_CACHE_FILE, SKILL_PRESENTATION_OVERRIDE_FILE, }: continue kind = _script_kind(item.path) entry = { "path": item.relative, "size": item.size, "type": mimetypes.guess_type(item.name)[0] or "application/octet-stream", "readable": item.suffix in TEXT_RESOURCE_EXTENSIONS and item.size <= MAX_RESOURCE_BYTES, } resources.append(entry) if item.relative.startswith("scripts/") and kind: scripts.append({ **entry, "kind": kind, "allowed": bool(permissions["allow_scripts"]) and ( not permissions["allowed_scripts"] or item.relative in permissions["allowed_scripts"] ), }) result: dict[str, Any] = { "id": skill_id, "name": metadata["name"], "description": metadata["description"], "resources": resources, "scripts": scripts, "permissions": permissions, "enabled": self._visibility(skill_dir), "presentation": self._presentation_for( skill_dir, metadata, guide_version=_guide_fingerprint(files), files=files, ), } if include_content: result["content"] = (skill_dir / "SKILL.md").read_text(encoding="utf-8")[:MAX_RESOURCE_BYTES] self._describe_cache[cache_key] = result if len(self._describe_cache) > 128: self._describe_cache = dict(list(self._describe_cache.items())[-64:]) return copy.deepcopy(result), content_version def read_resource(self, skill_id: str, resource_path: str) -> dict[str, Any]: skill_dir = self._skill_dir(skill_id) relative = _safe_relative_path(resource_path) target = (skill_dir / relative).resolve() try: target.relative_to(skill_dir) except ValueError as exc: raise SkillInstallError("资源路径超出 Skill 目录") from exc if not target.is_file(): raise SkillInstallError("Skill 资源不存在") size = target.stat().st_size if size > MAX_RESOURCE_BYTES: raise SkillInstallError(f"Skill 文本资源不能超过 {MAX_RESOURCE_BYTES // 1024}KB") if not _is_text_resource(target): raise SkillInstallError("该资源不是可读取的文本文件") try: content = target.read_text(encoding="utf-8") except UnicodeError as exc: raise SkillInstallError("Skill 资源不是 UTF-8 文本") from exc return { "skill_id": skill_id, "path": relative.as_posix(), "content": content, "size": size, } def set_permissions( self, skill_id: str, *, allow_scripts: bool, allowed_scripts: list[str] | None = None, timeout_seconds: int = DEFAULT_SCRIPT_TIMEOUT_SECONDS, ) -> dict[str, Any]: skill_dir = self._skill_dir(skill_id) known_scripts = { path.relative_to(skill_dir).as_posix() for path in skill_dir.rglob("*") if path.is_file() and path.relative_to(skill_dir).as_posix().startswith("scripts/") and _script_kind(path) } requested: list[str] = [] for item in allowed_scripts or []: relative = _safe_relative_path(item).as_posix() if relative not in known_scripts: raise SkillInstallError(f"脚本不存在或类型不受支持:{relative}") if relative not in requested: requested.append(relative) payload = { "allow_scripts": bool(allow_scripts), "allowed_scripts": requested, "timeout_seconds": min(MAX_SCRIPT_TIMEOUT_SECONDS, max(5, int(timeout_seconds))), } (skill_dir / ".permissions.json").write_text( json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8", ) return self.describe(skill_id, include_content=False) def set_visibility(self, skill_id: str, *, enabled: bool) -> dict[str, Any]: skill_dir = self._skill_dir(skill_id) (skill_dir / ".visibility.json").write_text( json.dumps({"enabled": bool(enabled)}, ensure_ascii=False, indent=2), encoding="utf-8", ) return self.describe(skill_id, include_content=False) @_serialize_duplicate_runs def run_script( self, skill_id: str, identity: dict[str, object], *, script_path: str, args: list[str], uploads: list[tuple[bytes, str, str]], client_run_id: str = "", ) -> dict[str, Any]: self.cleanup_expired_runs() skill_dir = self._skill_dir(skill_id) permissions = self._permissions(skill_dir) if not permissions["allow_scripts"]: raise PermissionError("管理员尚未允许这个 Skill 执行脚本") relative = _safe_relative_path(script_path) relative_posix = relative.as_posix() if not relative_posix.startswith("scripts/"): raise SkillInstallError("只允许执行 Skill 的 scripts/ 目录中的脚本") if permissions["allowed_scripts"] and relative_posix not in permissions["allowed_scripts"]: raise PermissionError("该脚本不在管理员白名单中") script = (skill_dir / relative).resolve() try: script.relative_to(skill_dir) except ValueError as exc: raise SkillInstallError("脚本路径超出 Skill 目录") from exc kind = _script_kind(script) if not script.is_file() or not kind: raise SkillInstallError("脚本不存在或类型不受支持") if len(args) > MAX_SCRIPT_ARGS: raise SkillInstallError(f"脚本参数不能超过 {MAX_SCRIPT_ARGS} 个") owner = _safe_name(str(identity.get("id") or "user"), "user") clean_client_run_id = str(client_run_id or "").strip() if len(clean_client_run_id) > 200 or "\x00" in clean_client_run_id: raise SkillInstallError("client_run_id 无效") cache_path: Path | None = None if clean_client_run_id: cache_key = hashlib.sha256(f"{owner}:{skill_id}:{clean_client_run_id}".encode("utf-8")).hexdigest() cache_path = self.runs_root / ".runs" / f"{cache_key}.json" if cache_path.is_file(): try: cached = json.loads(cache_path.read_text(encoding="utf-8")) if isinstance(cached, dict): expires_at = float(cached.get("expires_at") or 0) cached_artifacts = cached.get("artifacts") if isinstance(cached.get("artifacts"), list) else [] cached_run_id = _safe_name(str(cached.get("run_id") or ""), "") def cached_artifact_exists(item: object) -> bool: if not isinstance(item, dict): return True artifact_url = str(item.get("url") or "") if item.get("streamed") and artifact_url.startswith("/api/skills/media/"): try: self.resolve_media_link(artifact_url.rsplit("/", 1)[-1]) return True except SkillInstallError: return False return ( self.runs_root / owner / cached_run_id / _safe_output_name(str(item.get("name") or "artifact")) ).is_file() artifacts_exist = all(cached_artifact_exists(item) for item in cached_artifacts) if (expires_at and expires_at <= time.time()) or not artifacts_exist: cache_path.unlink(missing_ok=True) else: return cached except (OSError, ValueError): pass run_id = f"{int(time.time())}-{uuid.uuid4().hex[:12]}" durable_dir = self.runs_root / owner / run_id with tempfile.TemporaryDirectory(prefix=f"skill-{skill_id}-") as temp_name: workspace = Path(temp_name).resolve() input_dir = workspace / "inputs" output_dir = workspace / "outputs" home_dir = workspace / "home" input_dir.mkdir() output_dir.mkdir() home_dir.mkdir() input_paths: list[Path] = [] total_upload_bytes = 0 for index, (data, name, _mime_type) in enumerate(uploads): total_upload_bytes += len(data) if total_upload_bytes > MAX_SCRIPT_OUTPUT_BYTES: raise SkillInstallError("脚本输入文件总大小不能超过 25MB") safe_name = _safe_output_name(name or f"input-{index + 1}") target = input_dir / f"{index + 1:02d}-{safe_name}" target.write_bytes(data) input_paths.append(target) output_pattern = re.compile(r"\{\{output:([^{}]+)\}\}") input_pattern = re.compile(r"\{\{input:(\d+)\}\}") def resolve_argument(raw: str) -> str: value = str(raw) # Arguments are passed as an argv list without a shell. Keep # NUL forbidden, but allow line breaks so parser Skills can # process pasted share text containing several URLs. if len(value) > MAX_SCRIPT_ARG_CHARS or "\x00" in value: raise SkillInstallError("脚本参数无效或过长") used_placeholder = False def input_replacement(match: re.Match[str]) -> str: nonlocal used_placeholder used_placeholder = True index = int(match.group(1)) if index < 0 or index >= len(input_paths): raise SkillInstallError(f"输入文件索引不存在:{index}") return str(input_paths[index]) def output_replacement(match: re.Match[str]) -> str: nonlocal used_placeholder used_placeholder = True return str(output_dir / _safe_output_name(match.group(1))) value = input_pattern.sub(input_replacement, value) value = output_pattern.sub(output_replacement, value) for token, replacement in { "{{workspace}}": workspace, "{{skill}}": skill_dir, "{{outputs}}": output_dir, }.items(): if token in value: used_placeholder = True value = value.replace(token, str(replacement)) if "{{" in value or "}}" in value: raise SkillInstallError("脚本参数包含未知占位符") if not used_placeholder: candidate = Path(value) if candidate.is_absolute() or PurePosixPath(value.replace("\\", "/")).is_absolute() or ".." in PurePosixPath(value.replace("\\", "/")).parts: raise SkillInstallError("脚本参数中的文件路径必须使用受控占位符") return value resolved_args = [resolve_argument(item) for item in args] if kind == "python": # Keep Python isolated from user/site configuration while still # allowing a Skill entry point to import sibling modules such as # scripts/core.py. The bootstrap is fixed by the host; neither # the Skill nor the model can inject code into it. bootstrap = ( "import runpy,sys; " "sys.path.insert(0,sys.argv[1]); " "sys.argv=sys.argv[2:]; " "runpy.run_path(sys.argv[0],run_name='__main__')" ) command = [ sys.executable, "-I", "-c", bootstrap, str(script.parent), str(script), *resolved_args, ] else: node = shutil.which("node") if not node: raise SkillInstallError("服务器没有可用的 Node.js 运行时") command = [node, str(script), *resolved_args] env = { "PATH": os.environ.get("PATH", ""), "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1", "HOME": str(home_dir), "USERPROFILE": str(home_dir), "TEMP": str(workspace), "TMP": str(workspace), "SKILL_DIR": str(skill_dir), "SKILL_WORKSPACE": str(workspace), "SKILL_OUTPUT_DIR": str(output_dir), } # Windows needs these variables to initialise Winsock. Omitting # them from the deliberately small child environment makes even # ``import asyncio`` fail with WinError 10106 before the Skill's # own code starts. They identify system directories only and do # not broaden the script's data or credential access. if os.name == "nt": for name in ("SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT"): value = os.environ.get(name, "") if value: env[name] = value started_at = time.monotonic() try: completed = subprocess.run( command, cwd=workspace, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=int(permissions["timeout_seconds"]), check=False, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) except subprocess.TimeoutExpired as exc: raise SkillInstallError(f"脚本执行超过 {permissions['timeout_seconds']} 秒,已终止") from exc # Empty cache markers/lock files are implementation details, not # user-facing artifacts. Do not render them as bogus downloads. output_candidates = [ path for path in output_dir.rglob("*") if path.is_file() and path.stat().st_size > 0 ] if len(output_candidates) > MAX_SCRIPT_OUTPUT_FILES: raise SkillInstallError(f"脚本输出文件不能超过 {MAX_SCRIPT_OUTPUT_FILES} 个") total_output_bytes = sum(path.stat().st_size for path in output_candidates) output_limit = MEDIA_PARSER_OUTPUT_BYTES if skill_id.lower() == "media-parser" else MAX_SCRIPT_OUTPUT_BYTES if total_output_bytes > output_limit: raise SkillInstallError(f"脚本输出文件总大小不能超过 {output_limit // 1024 // 1024}MB") artifacts: list[dict[str, Any]] = [] expires_at: float | None = None if output_candidates: durable_dir.mkdir(parents=True, exist_ok=False) for source in output_candidates: name = _safe_output_name(source.name) target = durable_dir / name suffix = 2 while target.exists(): target = durable_dir / f"{Path(name).stem}-{suffix}{Path(name).suffix}" suffix += 1 shutil.copy2(source, target) try: relative_url = target.relative_to(DATA_DIR / "files").as_posix() except ValueError: # Custom roots are used by tests and embedders. Production # keeps runs under DATA_DIR/files and uses the first path. relative_url = (Path("skills") / owner / run_id / target.name).as_posix() artifacts.append({ "name": target.name, "type": mimetypes.guess_type(target.name)[0] or "application/octet-stream", "size": target.stat().st_size, "url": f"/files/{relative_url}", }) if skill_id.lower() == "media-parser": expires_at = time.time() + MEDIA_PARSER_ARTIFACT_TTL_SECONDS (durable_dir / ".expires-at").write_text(str(expires_at), encoding="utf-8") self._enforce_media_owner_quota(owner) elif skill_id.lower() == "media-parser" and completed.returncode == 0: artifacts.extend(self._media_stream_artifacts(completed.stdout)) if artifacts: expires_at = max(float(item.get("expires_at") or 0) for item in artifacts) result = { "run_id": run_id, "skill_id": skill_id, "script": relative_posix, "status": "success" if completed.returncode == 0 else "error", "exit_code": completed.returncode, "duration_ms": int((time.monotonic() - started_at) * 1000), "stdout": completed.stdout[-MAX_SCRIPT_STDIO_CHARS:], "stderr": completed.stderr[-MAX_SCRIPT_STDIO_CHARS:], "artifacts": artifacts, } if expires_at is not None: result["expires_at"] = expires_at if cache_path is not None: cache_path.parent.mkdir(parents=True, exist_ok=True) staging = cache_path.with_suffix(f".tmp-{os.getpid()}") staging.write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8") os.replace(staging, cache_path) return result def _backup_skill(self, skill_dir: Path) -> Path: """Snapshot a Skill before it is overwritten and keep the newest few. An update is destructive: the previous files are deleted and replaced. Keeping a rolling snapshot means a bad release can be rolled back by copying the directory back, and it gives ``_restore_preserved_state`` a single source for the administrator state that has to survive. """ import datetime backups_root = self.root / SKILL_BACKUP_DIR / skill_dir.name backups_root.mkdir(parents=True, exist_ok=True) stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d-%H%M%S") destination = backups_root / f"{stamp}-{uuid.uuid4().hex[:6]}" try: shutil.copytree(skill_dir, destination) except OSError as exc: shutil.rmtree(destination, ignore_errors=True) raise SkillInstallError(f"无法备份现有 Skill,已中止覆盖:{exc}") from exc self._prune_skill_backups(backups_root) return destination @staticmethod def _prune_skill_backups(backups_root: Path) -> None: try: snapshots = sorted( (path for path in backups_root.iterdir() if path.is_dir()), key=lambda path: path.name, ) except OSError: return for stale in snapshots[:-MAX_SKILL_BACKUPS]: shutil.rmtree(stale, ignore_errors=True) def install_zip( self, payload: bytes, *, source: str, overwrite: bool = False, subpath: str = "", max_bytes: int = MAX_ARCHIVE_BYTES, max_files: int = MAX_FILES, ) -> list[dict[str, Any]]: import datetime self.root.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix="chatgpt2api-skill-") as temp_name: extracted = Path(temp_name) _extract_archive(payload, extracted, subpath=subpath, max_bytes=max_bytes, max_files=max_files) search_root = extracted if subpath: matches = [path for path in extracted.rglob(subpath.split("/")[-1]) if path.is_dir() and path.as_posix().endswith(subpath)] if not matches: raise SkillInstallError(f"仓库中找不到技能目录:{subpath}") search_root = matches[0] skill_files = sorted(search_root.rglob("SKILL.md")) if not skill_files: raise SkillInstallError("ZIP 中没有找到 SKILL.md") bundle_skills = _export_bundle_sources(extracted) installed_ids: list[str] = [] discovered_ids: set[str] = set() for skill_file in skill_files: # A parent skill owns its nested resources; do not install a # nested SKILL.md twice when a package intentionally includes one. if any(parent.name == "SKILL.md" for parent in skill_file.parents): continue metadata = _frontmatter(skill_file) skill_id = _safe_name(metadata["name"], _safe_name(skill_file.parent.name)) # Release snapshots and examples sometimes contain older copies # of the same Skill. Prefer the first (normally shallowest) # definition instead of overwriting a complete runtime with an # archived duplicate later in the same package. if skill_id in discovered_ids: continue discovered_ids.add(skill_id) target = self.root / skill_id if target.exists() and not overwrite: raise SkillInstallError(f"Skill 已存在:{skill_id}") # Overwriting deletes the previous copy outright, so snapshot it # first. The snapshot doubles as the source of the administrator # state that must survive a reinstall: script permissions, # showcase overrides, uploaded showcase media and visibility. backup_dir = self._backup_skill(target) if target.is_dir() else None staging = self.root / f".{skill_id}.installing-{os.getpid()}" if staging.exists(): shutil.rmtree(staging) try: shutil.copytree(skill_file.parent, staging) package_roots = _referenced_package_roots(skill_file, search_root) for package_root in package_roots: # External implementations become controlled Skill # scripts. Keeping them below scripts/ makes them # discoverable to the runtime and subject to the same # administrator whitelist as native Skill scripts. destination = staging / "scripts" / package_root.name if package_root.is_dir(): shutil.copytree(package_root, destination, dirs_exist_ok=True) elif package_root.is_file(): destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(package_root, destination) # A repository may keep its human-facing README/USAGE next # to (or above) the Skill directory rather than beside # SKILL.md. Preserve those package docs so the chat UI can # explain the Skill to end users after installation. copied_doc_names: set[str] = set() package_docs_dir = staging / "_package-docs" for ancestor in _ancestor_directories(skill_file, search_root): for candidate in sorted(ancestor.iterdir()): if not candidate.is_file() or not PACKAGE_DOC_RE.match(candidate.name): continue normalized_name = candidate.name.lower() if normalized_name in copied_doc_names: continue copied_doc_names.add(normalized_name) package_docs_dir.mkdir(parents=True, exist_ok=True) shutil.copy2(candidate, package_docs_dir / candidate.name) (staging / ".install.json").write_text(json.dumps( self._install_record( staging=staging, target=target, source=source, bundle_entry=bundle_skills.get(skill_id), package_roots=[path.name for path in package_roots], ), ensure_ascii=False, indent=2, ), encoding="utf-8") if target.exists(): shutil.rmtree(target) os.replace(staging, target) if backup_dir is not None: _restore_preserved_state(target, backup_dir) finally: if staging.exists(): shutil.rmtree(staging, ignore_errors=True) installed_ids.append(skill_id) if not installed_ids: raise SkillInstallError("没有可安装的 Skill") installed = {item["id"]: item for item in self.list()} return [installed[item] for item in installed_ids if item in installed] def install_url(self, url: str, *, overwrite: bool = False) -> list[dict[str, Any]]: clean_url = url.strip() parsed = urlparse(clean_url) if parsed.scheme not in {"http", "https"}: raise SkillInstallError("仅支持 http/https URL") last_error: Exception | None = None for download_url, subpath in _github_download_candidates(clean_url): try: with httpx.Client(follow_redirects=True, timeout=45) as client: response = client.get(download_url, headers={"User-Agent": "chatgpt2api-skill-installer/1.0"}) response.raise_for_status() payload = response.content return self.install_zip(payload, source=clean_url, overwrite=overwrite, subpath=subpath) except SkillInstallError as exc: # A valid branch/archive was already found. Do not mask an # installation result such as "already exists" by probing a # fallback branch that may return 404. if "Skill 已存在:" in str(exc): raise last_error = exc except httpx.HTTPError as exc: last_error = exc raise SkillInstallError(f"下载或安装失败:{last_error}") def update_skill(self, skill_id: str) -> list[dict[str, Any]]: """Reinstall a Skill from the source recorded when it was installed. Only remotely-sourced Skills can be refreshed. A Skill that arrived as an uploaded archive has no address to fetch from, so the administrator has to supply a new archive instead. """ skill_dir = self._skill_dir(skill_id) source = str(self._install_metadata(skill_dir).get("source") or "").strip() if not source or source.startswith("upload:"): raise SkillInstallError("这个 Skill 由本地上传安装,没有可用的远端地址,请重新上传新版 ZIP") if not self._is_direct_install_url(source): raise SkillInstallError(f"该 Skill 的来源不支持自动更新:{source}") installed = self.install_url(source, overwrite=True) self._invalidate_caches(skill_dir) return installed def export_skills(self, skill_ids: list[str] | None = None) -> tuple[bytes, str]: """Bundle Skills — code plus administrator state — into one archive. The result is the unit of migration between deployments: installing it elsewhere reproduces the Skills exactly, including script permissions, showcase overrides, uploaded showcase media and visibility, because the installer copies each Skill directory verbatim. """ import datetime if skill_ids: directories = [self._skill_dir(item) for item in dict.fromkeys(skill_ids)] else: directories = [self._skill_dir(str(item["id"])) for item in self.list()] if not directories: raise SkillInstallError("没有可导出的 Skill") manifest_skills: list[dict[str, Any]] = [] buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive: for skill_dir in directories: metadata = _frontmatter(skill_dir / "SKILL.md") install_metadata = self._install_metadata(skill_dir) exported = 0 for item in _scan_skill_files(skill_dir): if _is_export_excluded(item.relative): continue archive.write(item.path, f"{skill_dir.name}/{item.relative}") exported += 1 manifest_skills.append({ "id": skill_dir.name, "name": metadata["name"], "description": metadata["description"], "source": str(install_metadata.get("source") or "manual"), "installed_at": str(install_metadata.get("installed_at") or ""), "file_count": exported, }) archive.writestr(EXPORT_MANIFEST_FILE, json.dumps({ "format": SKILL_EXPORT_FORMAT, "format_version": SKILL_EXPORT_FORMAT_VERSION, "exported_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), "skill_count": len(manifest_skills), "skills": manifest_skills, }, ensure_ascii=False, indent=2)) stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") # Naming the file after a Skill only makes sense when one was picked # explicitly; a "export everything" request keeps the bundle name even # when the installation happens to hold a single Skill. filename = ( f"skill-{directories[0].name}-{stamp}.zip" if skill_ids and len(directories) == 1 else f"skills-export-{stamp}.zip" ) return buffer.getvalue(), filename @staticmethod def _instruction_urls(text: str) -> list[str]: result: list[str] = [] for match in re.finditer(r"https?://[^\s<>\]\[()\"']+", text, re.IGNORECASE): value = match.group(0).rstrip(".,;:!?,。;:!?") if value not in result: result.append(value) return result[:24] @staticmethod def _is_direct_install_url(value: str) -> bool: parsed = urlparse(value) path = parsed.path.lower() return (parsed.hostname or "").lower() == "github.com" or path.endswith(".zip") def _read_install_instruction(self, url: str) -> str: current = _require_public_remote_url(url) with httpx.Client(follow_redirects=False, timeout=25) as client: for _ in range(6): response = client.get(current, headers={"User-Agent": "chatgpt2api-skill-installer/1.0", "Accept": "text/markdown,text/plain;q=0.9,*/*;q=0.2"}) if response.is_redirect: location = response.headers.get("location") if not location: raise SkillInstallError("安装说明重定向缺少目标地址") current = _require_public_remote_url(str(httpx.URL(current).join(location))) continue response.raise_for_status() if len(response.content) > MAX_RESOURCE_BYTES: raise SkillInstallError("安装说明文件超过 512KB 限制") content_type = response.headers.get("content-type", "").lower() if not any(value in content_type for value in ("text/", "markdown", "json", "yaml", "octet-stream")): raise SkillInstallError("安装说明不是可读取的文本文件") try: return response.content.decode("utf-8") except UnicodeDecodeError as exc: raise SkillInstallError("安装说明必须使用 UTF-8 编码") from exc raise SkillInstallError("安装说明重定向次数过多") def install_instruction(self, instruction: str, *, overwrite: bool = False) -> dict[str, Any]: clean = str(instruction or "").strip() if not clean or len(clean) > 8000: raise SkillInstallError("安装请求为空或过长") supplied_urls = self._instruction_urls(clean) if not supplied_urls: raise SkillInstallError("没有找到安装说明、GitHub 或 ZIP 地址") candidates = [url for url in supplied_urls if self._is_direct_install_url(url)] instruction_urls = [url for url in supplied_urls if url not in candidates] inspected: list[str] = [] for instruction_url in instruction_urls[:3]: document = self._read_install_instruction(instruction_url) inspected.append(instruction_url) for candidate in self._instruction_urls(document): if self._is_direct_install_url(candidate) and candidate not in candidates: candidates.append(candidate) if not candidates: raise SkillInstallError("安装说明中没有找到可受控安装的 GitHub 仓库或 ZIP 地址;不会执行网页中的 shell/npx 命令") errors: list[str] = [] for candidate in candidates[:8]: try: installed = self.install_url(candidate, overwrite=overwrite) return {"installed": installed, "resolved_url": candidate, "instruction_urls": inspected} except SkillInstallError as exc: existing_match = re.search(r"Skill 已存在:([a-z0-9._-]+)", str(exc), re.IGNORECASE) if existing_match and not overwrite: existing = next((item for item in self.list() if item["id"] == existing_match.group(1)), None) if existing: return {"installed": [existing], "resolved_url": candidate, "instruction_urls": inspected, "already_installed": True} errors.append(str(exc)) raise SkillInstallError(f"安装说明已读取,但候选 Skill 均安装失败:{';'.join(errors[-3:])}") def delete(self, skill_id: str) -> None: target = self._skill_dir(skill_id) shutil.rmtree(target) skill_service = SkillService()