diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b995bccaff..6e94304b06 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -39,10 +39,10 @@ Yuxi 只交付完整知识能力路径。API 始终注册 `external_kb`、`knowl ### `backend/package/yuxi` -- `agents` 定义 LangGraph 智能体体系。`BaseAgent` 是智能体基类,`BaseContext` 是运行上下文;`buildin/chatbot` 和 `buildin/subagent` 放由 `buildin.BUILTIN_BACKENDS` 显式注册、按需创建的无共享运行状态后端;`presets` 按模块发现预置角色定义,由 service 统一初始化、repository 保留既有配置;`middlewares` 组合文件系统、Skills、SubAgent、摘要、审批、模型兼容和用量统计;`toolkits` 管理本地工具;`backends` 对接沙盒、知识库和 Skills 文件系统;`skills` 与 `mcp` 管理扩展能力及其运行时加载。 +- `agents` 定义 LangGraph 智能体体系。`BaseAgent` 是智能体基类,`BaseContext` 是运行上下文;`buildin/chatbot` 和 `buildin/subagent` 放由 `buildin.BUILTIN_BACKENDS` 显式注册、按需创建的无共享运行状态后端;`presets` 按模块发现预置角色定义,由 service 统一初始化、repository 保留既有配置;`middlewares` 组合文件系统、Skills、SubAgent、摘要、审批、模型兼容和用量统计;`toolkits` 管理本地工具;`backends` 对接沙盒、知识库和 Skills 文件系统;`skills` 保留运行时解析与内置包,`mcp` 管理 MCP 扩展能力。 - `workspace` 是持久化 UserWorkspace Owner。`paths.py` 拥有 uid、宿主根和数据库 `projects/` 映射,`filesystem.py` 拥有 no-follow 文件原语,`workdir.py` 提供以一个 Project 为根的持久化视图,`preview.py` 拥有 UserWorkspace 文件预览和 runtime 本地 Office 缓存。Agent Backend 单独拥有 `/home/gem/...` runtime 路径。 -- `services` 是用例层。智能体主链路重点分为请求接入与排队、Run 生命周期、运行时配置、worker 执行和 SubAgent 调用;聊天历史、附件、工作区、文件预览、评估、认证和观测等跨模块流程也从这里找入口。 -- `repositories` 是 PostgreSQL 访问边界,封装业务对象、知识库元数据、AgentRun、请求队列、Task 和扩展配置查询。路由不应绕过 repository 直接拼装持久化逻辑。 +- `services` 是用例层。智能体主链路重点分为请求接入与排队、Run 生命周期、运行时配置、worker 执行和 SubAgent 调用;聊天历史、附件、工作区、文件预览、评估、认证和观测等跨模块流程也从这里找入口。`services/skills` 拥有 Skill 草稿生命周期、远程获取、个人与共享安装、编辑和投影;`catalog.py` 组合个人与共享查询,`package.py` 集中包解析、快照复制与包内 slug 改写,`resolved.py` 保存最终来源描述;Agent 工具通过个人 service 执行安装。 +- `repositories` 是 PostgreSQL 访问边界,封装业务对象、知识库元数据、AgentRun、请求队列、Task 和扩展配置查询;`skill_repository.py` 持有共享 Skill 索引查询。路由不应绕过 repository 直接拼装持久化逻辑。 - `storage/postgres` 管理 SQLAlchemy 模型、业务连接池和 LangGraph checkpoint 连接池。 - `storage/redis` 管理同步/异步 Redis 客户端和 ARQ 连接参数;业务 key、事件格式和缓存语义留在各自服务中。 - `storage/minio` 管理对象上传、下载和临时文件访问。 @@ -110,7 +110,7 @@ Yuxi 只交付完整知识能力路径。API 始终注册 `external_kb`、`knowl - 智能体能力通过 context、middleware、toolkits、Skills、MCP 和 backends 组合;不要把知识库、沙盒或扩展逻辑硬编码进单个页面或路由。 - Skill 的依赖工具只有在对应 Skill 被显式预加载或动态激活后才对模型开放;基础工具与受 Skill 门控的工具保持边界。 - Shipping 进程始终装配知识库、图谱和评估能力;解析器等只服务实际动作的重运行时继续保持惰性加载。 -- 文件边界只使用三种跨层路径:数据库中的 Project `workdir_path`、Viewer 当前 scope 相对 `/foo`、Agent/artifact runtime 绝对 `/home/gem/user-data/...`;宿主 `Path` 由 `yuxi.workspace` 或显式 v0.7.1 storage migration 内部持有,普通 Service/Repository 不得取得。 +- 文件边界只使用三种跨层路径:数据库中的 Project `workdir_path`、Viewer 当前 scope 相对 `/foo`、Agent/artifact runtime 绝对 `/home/gem/user-data/...`;普通 Service/Repository 不得取得 UserWorkspace 宿主 `Path`。个人 Skill 的文件 Owner `services/skills/personal.py` 可在已认证 uid 下定位 UserWorkspace 的固定 `agents/skills` 子目录,其他路径解析由 `yuxi.workspace` 或显式 v0.7.1 storage migration 拥有。 - 沙盒虚拟路径由当前 Project Workdir、User Data 与共享 Skills 根共同约束;个人 Skill 保存在 UserWorkspace 的 `agents/skills`,共享与内置 Skill 才投影到只读 `/home/gem/skills`。Sandbox 的惰性创建不得绕过 runtime scope、uid、Workdir 或 generation 校验,Run 终态仍清理 runtime 进程并保留 Workdir。用户可见路径、对象存储 URL 与宿主机真实路径不能混用。 - 面向用户和外部系统的输入在边界校验;内部服务优先依赖已有类型、事务和仓储约束,避免用静默回退掩盖设计错误。 diff --git a/backend/package/yuxi/agents/backends/composite.py b/backend/package/yuxi/agents/backends/composite.py index 1e2f65433a..f3fdeefcb4 100644 --- a/backend/package/yuxi/agents/backends/composite.py +++ b/backend/package/yuxi/agents/backends/composite.py @@ -10,7 +10,7 @@ ) from yuxi.agents.backends.paths import runtime_workdir_path -from yuxi.agents.skills.service import refresh_user_skill_projection_async +from yuxi.services.skills.projection import refresh_user_skill_projection_async from .sandbox import ProvisionerSandboxBackend diff --git a/backend/package/yuxi/agents/context.py b/backend/package/yuxi/agents/context.py index 918e3b7c74..e2044ee075 100644 --- a/backend/package/yuxi/agents/context.py +++ b/backend/package/yuxi/agents/context.py @@ -460,9 +460,9 @@ async def resolve_agent_resource_options( if server.slug in enabled_slugs ] if "skills" in fields_to_load: - from yuxi.agents.skills.service import list_accessible_shared_skills + from yuxi.repositories.skill_repository import SkillRepository - skills = await list_accessible_shared_skills(db, user) + skills = await SkillRepository(db).list_enabled_readable(user) options["skills"] = [ _resource_option(skill.slug, skill.name, skill.description) for skill in skills if skill.slug ] diff --git a/backend/package/yuxi/agents/middlewares/skills.py b/backend/package/yuxi/agents/middlewares/skills.py index f998da3fac..03604117dc 100644 --- a/backend/package/yuxi/agents/middlewares/skills.py +++ b/backend/package/yuxi/agents/middlewares/skills.py @@ -16,8 +16,8 @@ from yuxi.agents.backends.paths import VIRTUAL_PERSONAL_SKILLS_PATH, VIRTUAL_SKILLS_PATH from yuxi.agents.mcp.service import get_enabled_mcp_tools from yuxi.agents.skills.runtime import RuntimeSkill, build_dependency_bundle -from yuxi.agents.skills.service import is_valid_skill_slug, normalize_string_list from yuxi.agents.toolkits import get_all_tool_instances +from yuxi.services.skills.package import is_valid_skill_slug, normalize_string_list from yuxi.utils.logging_config import logger diff --git a/backend/package/yuxi/agents/skills/runtime.py b/backend/package/yuxi/agents/skills/runtime.py index 5b0219dc64..f0ae3bafd2 100644 --- a/backend/package/yuxi/agents/skills/runtime.py +++ b/backend/package/yuxi/agents/skills/runtime.py @@ -10,8 +10,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from yuxi.agents.backends.paths import VIRTUAL_PERSONAL_SKILLS_PATH, VIRTUAL_SKILLS_PATH -from yuxi.agents.skills.service import list_accessible_skills, normalize_string_list from yuxi.agents.toolkits import get_all_tool_instances +from yuxi.services.skills.package import normalize_string_list +from yuxi.services.skills.personal import list_personal_skills +from yuxi.services.skills.shared import lock_accessible_shared_skills_for_runtime, resolved_shared_skill from yuxi.storage.postgres.models_business import User from yuxi.utils.logging_config import logger from yuxi.utils.paths import open_regular_file_fd @@ -28,6 +30,54 @@ class RuntimeSkill(TypedDict): skills: list[str] +async def resolve_runtime_skills_for_context( + context, + *, + db: AsyncSession, + user: User, +) -> dict: + """合并已选共享与全部个人 Skill,派生运行范围和预加载快照。""" + selected = normalize_string_list(getattr(context, "skills", None)) + personal_items = await list_personal_skills(str(user.uid)) + personal_slugs = {item.slug for item in personal_items} + shared_rows = await lock_accessible_shared_skills_for_runtime( + db, + user, + selected, + shadowed_slugs=personal_slugs, + ) + skill_items_by_slug = {item.slug: resolved_shared_skill(item) for item in shared_rows if item.slug} + skill_items_by_slug.update({item.slug: item for item in personal_items if item.slug}) + runtime_skills = build_runtime_skills(list(skill_items_by_slug.values())) + selected_skills = [slug for slug in selected if slug in skill_items_by_slug] + context_skills = normalize_string_list([*selected_skills, *(item.slug for item in personal_items)]) + effective_skills = expand_skill_closure(context_skills, runtime_skills) + configured_preloads = normalize_string_list(getattr(context, "preload_skills", None)) + context_preload_skills = [slug for slug in configured_preloads if slug in selected_skills] + preloaded_skills = expand_skill_closure(context_preload_skills, runtime_skills) + preloaded_contents = ( + await asyncio.to_thread(_read_preloaded_skill_contents, preloaded_skills, skill_items_by_slug) + if preloaded_skills + else {} + ) + return { + "context_skills": context_skills, + "context_preload_skills": context_preload_skills, + "effective_skills": effective_skills, + "runtime_skills": runtime_skills, + "skill_metadata": { + slug: { + "source_scope": skill_items_by_slug[slug].source_scope, + "version": skill_items_by_slug[slug].version, + "content_hash": skill_items_by_slug[slug].content_hash, + } + for slug in effective_skills + }, + "preloaded_skills": preloaded_skills, + "preloaded_skill_contents": preloaded_contents, + } + + def build_runtime_skills(skills: list) -> dict[str, RuntimeSkill]: """从已授权 Skill 构建运行时信息。""" result: dict[str, RuntimeSkill] = {} @@ -84,69 +134,6 @@ def dfs(slug: str, stack: set[str]) -> None: return result -async def resolve_runtime_skills_for_context( - context, - *, - db: AsyncSession, - user: User, -) -> dict: - """合并已选共享与全部个人 Skill,派生运行范围和预加载快照。""" - skill_items = [item for item in await list_accessible_skills(db, user) if item.slug] - runtime_skills = build_runtime_skills(skill_items) - available = set(runtime_skills) - selected = normalize_string_list(getattr(context, "skills", None)) - shared_skills = [slug for slug in selected if slug in available] - context_skills = normalize_string_list( - [*shared_skills, *(item.slug for item in skill_items if item.source_scope == "personal")] - ) - effective_skills = expand_skill_closure(context_skills, runtime_skills) - configured_preloads = normalize_string_list(getattr(context, "preload_skills", None)) - context_preload_skills = [slug for slug in configured_preloads if slug in shared_skills] - preloaded_skills = expand_skill_closure(context_preload_skills, runtime_skills) - items_by_slug = {item.slug: item for item in skill_items} - preloaded_contents = ( - await asyncio.to_thread(_read_preloaded_skill_contents, preloaded_skills, items_by_slug) - if preloaded_skills - else {} - ) - return { - "context_skills": context_skills, - "context_preload_skills": context_preload_skills, - "effective_skills": effective_skills, - "runtime_skills": runtime_skills, - "skill_metadata": { - slug: { - "source_scope": items_by_slug[slug].source_scope, - "version": items_by_slug[slug].version, - "content_hash": items_by_slug[slug].content_hash, - } - for slug in effective_skills - }, - "preloaded_skills": preloaded_skills, - "preloaded_skill_contents": preloaded_contents, - } - - -def _read_preloaded_skill_contents(slugs: list[str], skill_items: dict[str, Any]) -> dict[str, str]: - """从授权解析得到的真实来源读取根级 SKILL.md。""" - - contents: dict[str, str] = {} - for slug in slugs: - try: - source_dir = Path(skill_items[slug].source_dir) - if not source_dir.is_absolute() or ".." in source_dir.parts: - raise OSError("Skill 来源目录必须是规范化绝对路径") - with open_regular_file_fd( - Path(source_dir.anchor), - (*source_dir.parts[1:], "SKILL.md"), - ) as (file_fd, _file_stat): - with os.fdopen(os.dup(file_fd), encoding="utf-8") as skill_file: - contents[slug] = skill_file.read() - except (OSError, UnicodeError) as exc: - raise RuntimeError(f"预加载 Skill '{slug}' 失败:根级 SKILL.md 不可读") from exc - return contents - - def resolve_skill_gated_tools(context) -> list: """解析所有可见 Skill 依赖且需注册到 ToolNode 的本地工具。""" runtime_skills = getattr(context, "_skill_runtime_snapshot", {}).get("runtime_skills", {}) or {} @@ -184,3 +171,23 @@ def build_dependency_bundle( mcps.append(mcp_name) return {"tools": tools, "mcps": mcps} + + +def _read_preloaded_skill_contents(slugs: list[str], skill_items: dict[str, Any]) -> dict[str, str]: + """从授权解析得到的真实来源读取根级 SKILL.md。""" + + contents: dict[str, str] = {} + for slug in slugs: + try: + source_dir = Path(skill_items[slug].source_dir) + if not source_dir.is_absolute() or ".." in source_dir.parts: + raise OSError("Skill 来源目录必须是规范化绝对路径") + with open_regular_file_fd( + Path(source_dir.anchor), + (*source_dir.parts[1:], "SKILL.md"), + ) as (file_fd, _file_stat): + with os.fdopen(os.dup(file_fd), encoding="utf-8") as skill_file: + contents[slug] = skill_file.read() + except (OSError, UnicodeError) as exc: + raise RuntimeError(f"预加载 Skill '{slug}' 失败:根级 SKILL.md 不可读") from exc + return contents diff --git a/backend/package/yuxi/agents/skills/service.py b/backend/package/yuxi/agents/skills/service.py deleted file mode 100644 index ca730ccec0..0000000000 --- a/backend/package/yuxi/agents/skills/service.py +++ /dev/null @@ -1,1775 +0,0 @@ -from __future__ import annotations - -import asyncio -import fcntl -import hashlib -import json -import os -import re -import shutil -import stat -import tempfile -import threading -import time -import uuid -import zipfile -from collections.abc import Callable -from contextlib import contextmanager -from dataclasses import dataclass, replace -from pathlib import Path, PurePosixPath -from typing import Any - -import yaml -from sqlalchemy import select, text -from sqlalchemy.ext.asyncio import AsyncSession - -from yuxi.agents.mcp.service import get_enabled_mcp_server_slugs -from yuxi.agents.skills.buildin import BUILTIN_SKILLS_DIR -from yuxi.agents.skills.repository import SkillRepository -from yuxi.config import ( - get_runtime_dir, - get_skill_data_dir, - get_skill_projection_dir, -) -from yuxi.permissions import ResourcePermission, normalize_permission_config, resolve_skill_permission -from yuxi.storage.postgres.models_business import Skill, User -from yuxi.utils.logging_config import logger -from yuxi.utils.paths import ensure_within_root, open_directory_fd, open_regular_file_fd - -SKILL_SLUG_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") -SKILL_NAME_PATTERN = SKILL_SLUG_PATTERN - -TEXT_FILE_EXTENSIONS = { - ".md", - ".txt", - ".py", - ".js", - ".ts", - ".json", - ".yaml", - ".yml", - ".toml", - ".ini", - ".cfg", - ".conf", - ".xml", - ".html", - ".css", - ".sql", - ".sh", - ".bat", - ".ps1", - ".env", - ".csv", - ".tsv", - ".rst", - ".ipynb", - ".vue", - ".jsx", - ".tsx", -} - -BUILTIN_SKILL_OPERATOR = "builtin-system" -ADMIN_ROLES = {"admin", "superadmin"} -DEFAULT_SKILL_SHARE_CONFIG = {"access_level": "user", "department_ids": [], "user_uids": []} -BUILTIN_SKILL_SHARE_CONFIG = {"access_level": "global", "department_ids": [], "user_uids": []} -SKILL_DRAFT_TTL_SECONDS = 60 * 60 -PERSONAL_SKILL_SOURCE_TYPE = "personal" -_USER_SKILLS_LOCK = threading.Lock() -_USER_SKILLS_LOCKS: dict[str, threading.Lock] = {} -_USER_SKILL_PROJECTION_LOCK_SCOPE = "yuxi:skills:user-projection:v1:" -SKILL_STORAGE_LOCK = 0x5958534B - - -@dataclass(frozen=True, slots=True) -class ResolvedSkill: - """描述当前用户最终可用的 Skill 及其真实来源。""" - - id: Any - slug: str - name: str - description: str - source_type: str - source_scope: str - source_dir: Path - enabled: bool - created_by: str | None - share_config: dict[str, Any] | None - tool_dependencies: list[str] - mcp_dependencies: list[str] - skill_dependencies: list[str] - version: str | None = None - content_hash: str | None = None - overrides_shared: bool = False - shadowed_by_personal: bool = False - - def to_dict(self) -> dict[str, Any]: - """返回可安全提供给前端的 Skill 元数据。""" - data = { - "id": self.id, - "slug": self.slug, - "name": self.name, - "description": self.description, - "source_type": self.source_type, - "source_scope": self.source_scope, - "enabled": self.enabled, - "created_by": self.created_by, - "tool_dependencies": self.tool_dependencies, - "mcp_dependencies": self.mcp_dependencies, - "skill_dependencies": self.skill_dependencies, - "overrides_shared": self.overrides_shared, - "shadowed_by_personal": self.shadowed_by_personal, - } - if self.share_config is not None: - data["share_config"] = self.share_config - return data - - -def _get_user_skills_lock(uid: str) -> threading.Lock: - with _USER_SKILLS_LOCK: - lock = _USER_SKILLS_LOCKS.get(uid) - if lock is None: - lock = threading.Lock() - _USER_SKILLS_LOCKS[uid] = lock - return lock - - -@contextmanager -def _user_skills_file_lock(uid: str): - """在共享投影卷上串行化同一用户的目录替换。""" - from yuxi.workspace.paths import workspace_uid_dirname - - lock_dir = get_skill_projection_dir() / ".locks" - lock_dir.mkdir(parents=True, exist_ok=True) - lock_path = lock_dir / f"{workspace_uid_dirname(uid)}.lock" - with lock_path.open("a+b") as lock_file: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) - try: - yield - finally: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) - - -def normalize_string_list(values: list[str] | None) -> list[str]: - if not values: - return [] - normalized: list[str] = [] - seen: set[str] = set() - for value in values: - if not isinstance(value, str): - continue - item = value.strip() - if not item or item in seen: - continue - seen.add(item) - normalized.append(item) - return normalized - - -def is_valid_skill_slug(slug: str) -> bool: - if not isinstance(slug, str): - return False - return bool(SKILL_SLUG_PATTERN.match(slug.strip())) - - -def is_builtin_skill(item: Skill | dict) -> bool: - source_type = item.get("source_type") if isinstance(item, dict) else item.source_type - return source_type == "builtin" - - -def get_allowed_skill_access_levels(user: User) -> list[str]: - if user.role in ADMIN_ROLES: - return ["global", "department", "user"] - return ["user"] - - -def normalize_skill_share_config( - share_config: dict | None, - *, - operator_uid: str, - source_type: str = "upload", - allowed_access_levels: set[str] | None = None, -) -> dict: - if source_type == "builtin": - return {"version": 2, "read_scope": BUILTIN_SKILL_SHARE_CONFIG.copy(), "manage_scope": None} - - default_scope = { - "access_level": "user", - "department_ids": [], - "user_uids": [operator_uid], - } - return normalize_permission_config( - share_config or {"version": 2, "read_scope": default_scope, "manage_scope": None}, - allowed_access_levels=allowed_access_levels, - unauthorized_access_level_message="当前用户无权使用该 Skill 共享范围", - strict=True, - ) - - -def user_can_access_skill(user: User, skill: Skill, *, require_enabled: bool = True) -> bool: - if require_enabled and not skill.enabled: - return False - return resolve_skill_permission(user, skill) != ResourcePermission.NONE - - -def user_can_manage_skill(user: User, skill: Skill) -> bool: - if is_builtin_skill(skill): - return user.role in ADMIN_ROLES - return resolve_skill_permission(user, skill) == ResourcePermission.MANAGE - - -def can_skill_depend_on(parent: Skill, dependency: Skill) -> bool: - if not dependency.enabled: - return False - if is_builtin_skill(dependency): - return True - - dep_config = normalize_permission_config(dependency.share_config) - parent_config = normalize_permission_config(parent.share_config) - dependency_scopes = [scope for scope in (dep_config["read_scope"], dep_config["manage_scope"]) if scope] - parent_scopes = [scope for scope in (parent_config["read_scope"], parent_config["manage_scope"]) if scope] - owner_scope = {"access_level": "user", "department_ids": [], "user_uids": []} - if not dependency_scopes: - dependency_scopes = [{**owner_scope, "user_uids": [str(dependency.created_by or "")]}] - if not parent_scopes: - parent_scopes = [{**owner_scope, "user_uids": [str(parent.created_by or "")]}] - return all( - any(_scope_contains(dependency_scope, parent_scope) for dependency_scope in dependency_scopes) - for parent_scope in parent_scopes - ) - - -def _scope_contains(container: dict, target: dict) -> bool: - """判断一个共享范围是否完整覆盖另一个范围。""" - - container_level = container.get("access_level") - target_level = target.get("access_level") - if container_level == "global": - return True - if target_level == "global" or container_level != target_level: - return False - if target_level == "department": - container_ids = {int(value) for value in container.get("department_ids") or []} - target_ids = {int(value) for value in target.get("department_ids") or []} - return target_ids.issubset(container_ids) - if target_level == "user": - container_uids = {str(value) for value in container.get("user_uids") or []} - target_uids = {str(value) for value in target.get("user_uids") or []} - return target_uids.issubset(container_uids) - return False - - -def _ensure_non_builtin(item: Skill) -> None: - if is_builtin_skill(item): - raise ValueError("内置 skill 不允许执行该操作") - - -def get_skills_root_dir() -> Path: - """返回共享与内置 Skill 的持久源目录。""" - root = get_skill_data_dir() / "shared" - root.mkdir(parents=True, exist_ok=True) - return root - - -def get_skill_drafts_root_dir() -> Path: - """返回可丢弃的 Skill 安装草稿目录。""" - root = get_runtime_dir() / "skill_import_drafts" - root.mkdir(parents=True, exist_ok=True) - return root - - -def _load_skill_draft(draft_id: str) -> tuple[Path, dict]: - if not re.fullmatch(r"[0-9a-fA-F-]{32,36}", str(draft_id or "")): - raise ValueError("无效的安装草稿") - draft_dir = (get_skill_drafts_root_dir() / draft_id).resolve() - try: - draft_dir.relative_to(get_skill_drafts_root_dir().resolve()) - except ValueError: - raise ValueError("无效的安装草稿") from None - metadata_path = draft_dir / "metadata.json" - if not metadata_path.exists(): - raise ValueError("安装草稿不存在或已过期") - data = json.loads(metadata_path.read_text(encoding="utf-8")) - if data.get("expires_at", 0) < time.time(): - shutil.rmtree(draft_dir, ignore_errors=True) - raise ValueError("安装草稿已过期") - return draft_dir, data - - -def _load_and_select_draft_items( - draft_id: str, slugs: list[str] | None, operator: User -) -> tuple[Path, dict, list[dict]]: - """加载安装草稿,校验权限与来源类型,并按需筛选选中的条目。""" - draft_dir, data = _load_skill_draft(draft_id) - if data.get("created_by") != operator.uid and operator.role not in ADMIN_ROLES: - raise ValueError("无权确认该安装草稿") - if data.get("source_type") not in {"upload", "remote"}: - raise ValueError("无效的安装草稿来源") - - draft_items = data.get("items") or [] - if slugs is not None: - selected_slugs = set(slugs) - if not selected_slugs: - raise ValueError("至少选择一个 Skill") - available_slugs = {str(item.get("slug") or "").strip() for item in draft_items} - if selected_slugs - available_slugs: - raise ValueError("确认安装包含草稿外的 Skill") - draft_items = [item for item in draft_items if str(item.get("slug") or "").strip() in selected_slugs] - - return draft_dir, data, draft_items - - -def get_user_skills_root_dir(uid: str) -> Path: - """返回当前用户获授权的共享 Skill 只读投影根目录。""" - from yuxi.workspace.paths import workspace_uid_dirname - - safe_uid = workspace_uid_dirname(uid) - root = get_skill_projection_dir() / safe_uid - root.mkdir(parents=True, exist_ok=True) - return root - - -async def sync_user_accessible_skills_async( - uid: str, - source_dirs: dict[str, str | Path], -) -> Path: - """在线程池同步用户获授权的共享 Skill 投影,避免阻塞 Agent 事件循环。""" - return await asyncio.to_thread( - sync_user_accessible_skills, - uid, - source_dirs, - ) - - -async def refresh_user_skill_projection_async(uid: str) -> dict[str, str]: - """按数据库中的最新授权快照重建用户共享 Skill 投影。""" - from yuxi.repositories.user_repository import UserRepository - from yuxi.storage.postgres.manager import pg_manager - - normalized_uid = str(uid or "").strip() - if not normalized_uid: - raise ValueError("uid is required to refresh the user Skill projection") - - async with pg_manager.get_async_session_context() as db: - await db.execute( - text("SELECT pg_advisory_xact_lock(hashtext(:lock_scope))"), - {"lock_scope": f"{_USER_SKILL_PROJECTION_LOCK_SCOPE}{normalized_uid}"}, - ) - user = await UserRepository().get_by_uid_with_db(db, normalized_uid) - if user is None or bool(user.is_deleted): - source_dirs: dict[str, str] = {} - else: - source_dirs = { - item.slug: str(_resolve_skill_dir(item)) - for item in await list_accessible_shared_skills(db, user) - if item.slug - } - await sync_user_accessible_skills_async(normalized_uid, source_dirs) - return source_dirs - - -def _remove_skill_from_user_projection(uid: str, slug: str) -> None: - """从一个已物化 uid 投影移除 Skill,授权变更时保持 fail-closed。""" - if not is_valid_skill_slug(slug): - raise ValueError("无效 skill slug") - with _get_user_skills_lock(uid), _user_skills_file_lock(uid): - _remove_skill_projection_entry(get_user_skills_root_dir(uid) / slug) - - -async def apply_skill_projection_policy_change(db: AsyncSession, slug: str) -> None: - """提交 Skill 授权变更,并同步所有已存在的 uid 投影。""" - from yuxi.workspace.paths import workspace_uid_dirname - - result = await db.execute(select(User.uid).where(User.is_deleted == 0).order_by(User.id)) - projection_root = get_skill_projection_dir() - uids = [str(uid) for uid in result.scalars().all() if (projection_root / workspace_uid_dirname(str(uid))).is_dir()] - for uid in uids: - await db.execute( - text("SELECT pg_advisory_xact_lock(hashtext(:lock_scope))"), - {"lock_scope": f"{_USER_SKILL_PROJECTION_LOCK_SCOPE}{uid}"}, - ) - for uid in uids: - await asyncio.to_thread(_remove_skill_from_user_projection, uid, slug) - await db.commit() - for uid in uids: - await refresh_user_skill_projection_async(uid) - - -def sync_user_accessible_skills( - uid: str, - source_dirs: dict[str, str | Path], -) -> Path: - """将用户有权访问的共享 Skill 来源同步到统一只读目录。""" - user_skills_root = get_user_skills_root_dir(uid) - normalized_sources = { - slug: Path(os.path.abspath(os.fspath(path))) - for slug, path in source_dirs.items() - if is_valid_skill_slug(slug) and isinstance(path, (str, Path)) - } - accessible_slugs = set(normalized_sources) - with _get_user_skills_lock(uid), _user_skills_file_lock(uid): - for entry in user_skills_root.iterdir(): - if entry.name in accessible_slugs: - continue - _remove_skill_projection_entry(entry) - - for slug, source_dir in normalized_sources.items(): - target_dir = user_skills_root / slug - temp_target = user_skills_root / f".{slug}.tmp-{uuid.uuid4().hex[:8]}" - try: - if skill_dirs_equal(source_dir, target_dir): - continue - copy_skill_tree_no_symlinks(source_dir, temp_target) - _remove_skill_projection_entry(target_dir) - temp_target.rename(target_dir) - except FileNotFoundError: - logger.warning(f"跳过不存在的 Skill 来源: slug={slug}") - _remove_skill_projection_entry(target_dir) - except (OSError, ValueError): - _remove_skill_projection_entry(target_dir) - raise - finally: - if temp_target.exists(): - shutil.rmtree(temp_target, ignore_errors=True) - - return user_skills_root - - -def _remove_skill_projection_entry(path: Path) -> None: - """删除一个投影条目,不跟随可能存在的符号链接。""" - if not path.exists() and not path.is_symlink(): - return - if path.is_dir() and not path.is_symlink(): - shutil.rmtree(path) - else: - path.unlink() - - -def _build_builtin_skill_dir_path(slug: str) -> str: - return (Path("shared") / slug).as_posix() - - -def _dir_contains_symlink(path: Path) -> bool: - """检查目录内是否包含任意符号链接子路径。""" - return any(child.is_symlink() for child in path.rglob("*")) - - -def copy_skill_tree_no_symlinks(source_dir: Path, target_dir: Path) -> None: - """复制不含符号链接的 Skill 目录到 staging。""" - source_dir = source_dir.resolve() - if not source_dir.is_dir(): - raise FileNotFoundError(source_dir) - if _dir_contains_symlink(source_dir): - raise ValueError(f"Skill 来源只允许普通文件和目录: {source_dir}") - try: - shutil.copytree(source_dir, target_dir, symlinks=False) - except BaseException: - shutil.rmtree(target_dir, ignore_errors=True) - raise - - -def _copy_skill_snapshot( - source_dir: Path, - target_dir: Path, - *, - expected_slug: str | None = None, - final_slug: str | None = None, -) -> dict[str, Any]: - """复制并解析 Skill staging,可校验来源或重写最终 slug。""" - copy_skill_tree_no_symlinks(source_dir, target_dir) - parsed = parse_skill_dir_metadata(target_dir) - if expected_slug and parsed["slug"] != expected_slug: - raise ValueError("Skill slug 在复制过程中发生变化") - if final_slug and parsed["slug"] != final_slug: - skill_md = target_dir / "SKILL.md" - skill_md.write_text( - _rewrite_frontmatter_slug(skill_md.read_text(encoding="utf-8"), final_slug), - encoding="utf-8", - ) - return parsed - - -def skill_dirs_equal(dir1: Path, dir2: Path) -> bool: - """按 no-follow 字节与执行位比较来源和投影,非法来源显式失败。""" - source_hash = _compute_projection_hash(dir1) - try: - return source_hash == _compute_projection_hash(dir2) - except OSError: - # 缺失或被替换为链接的投影必须重建,不能沿用相同字节的链接。 - return False - - -def _compute_projection_hash(path: Path) -> bytes: - """通过目录 fd 读取投影比较摘要,拒绝链接和特殊文件。""" - hasher = hashlib.sha256() - - def visit(directory_fd: int) -> None: - """在已打开的目录内递归比较所需的类型、执行位和字节。""" - for name in sorted(os.listdir(directory_fd)): - hasher.update(os.fsencode(name) + b"\0") - mode = os.stat(name, dir_fd=directory_fd, follow_symlinks=False).st_mode - if stat.S_ISDIR(mode): - child_fd = open_directory_fd(directory_fd, (name,)) - try: - hasher.update(b"directory\0") - visit(child_fd) - hasher.update(b"end-directory\0") - finally: - os.close(child_fd) - else: - with open_regular_file_fd(directory_fd, (name,)) as (file_fd, file_stat): - hasher.update(b"file\0" + bytes([stat.S_IMODE(file_stat.st_mode) & 0o111])) - content_hash = hashlib.sha256() - while chunk := os.read(file_fd, 1024 * 1024): - content_hash.update(chunk) - hasher.update(content_hash.digest()) - - absolute = Path(os.path.abspath(path)) - directory_fd = open_directory_fd(Path(absolute.anchor), absolute.parts[1:]) - try: - visit(directory_fd) - finally: - os.close(directory_fd) - return hasher.digest() - - -def _compute_dir_hash(source_dir: Path) -> str: - hasher = hashlib.sha256() - entries = sorted(source_dir.rglob("*"), key=lambda path: path.relative_to(source_dir).as_posix()) - for entry in entries: - relative_path = entry.relative_to(source_dir).as_posix() - hasher.update(relative_path.encode("utf-8")) - hasher.update(b"\0") - if entry.is_dir(): - hasher.update(b"directory\0") - continue - if not entry.is_file(): - hasher.update(b"other\0") - continue - hasher.update(b"file\0") - hasher.update(bytes([stat.S_IMODE(entry.stat().st_mode) & 0o111])) - with entry.open("rb") as f: - while chunk := f.read(1024 * 1024): - hasher.update(chunk) - hasher.update(b"\0") - return hasher.hexdigest() - - -def _replace_skill_target( - target_dir: Path, - source_dir: Path, - *, - validate: Callable[[Path], None] | None = None, -) -> None: - """将 source_dir 原子地复制为 target_dir:先复制到临时目录,可选校验后再替换。""" - temp_target = target_dir.with_name(f".{target_dir.name}.tmp-{uuid.uuid4().hex[:8]}") - trash_dir: Path | None = None - if temp_target.exists(): - shutil.rmtree(temp_target, ignore_errors=True) - - copy_skill_tree_no_symlinks(source_dir, temp_target) - try: - if validate is not None: - validate(temp_target) - if target_dir.exists(): - trash_dir = target_dir.with_name(f".{target_dir.name}.bak-{uuid.uuid4().hex[:8]}") - target_dir.rename(trash_dir) - temp_target.rename(target_dir) - except Exception: - shutil.rmtree(temp_target, ignore_errors=True) - if trash_dir and trash_dir.exists() and not target_dir.exists(): - trash_dir.rename(target_dir) - raise - - if trash_dir and trash_dir.exists(): - shutil.rmtree(trash_dir, ignore_errors=True) - - -async def list_accessible_skills( - db: AsyncSession, - user: User, - *, - require_enabled: bool = True, -) -> list[ResolvedSkill]: - """返回当前用户最终生效的共享与个人 Skill。""" - shared_items, personal_items = await asyncio.gather( - list_accessible_shared_skills(db, user, require_enabled=require_enabled), - list_personal_skills(str(user.uid)), - ) - personal_by_slug = {item.slug: item for item in personal_items} - - effective: dict[str, ResolvedSkill] = {} - for item in shared_items: - effective[item.slug] = _resolved_shared_skill( - item, - shadowed_by_personal=item.slug in personal_by_slug, - ) - for slug, item in personal_by_slug.items(): - effective[slug] = replace(item, overrides_shared=slug in effective) - return list(effective.values()) - - -async def list_skill_cards_for_user( - db: AsyncSession, - user: User, -) -> list[ResolvedSkill]: - """返回管理页所需的共享与个人 Skill 卡片。""" - shared_items, personal_items = await asyncio.gather( - list_visible_skills_for_management(db, user), - list_personal_skills(str(user.uid)), - ) - personal_slugs = {item.slug for item in personal_items} - shared_slugs = {item.slug for item in shared_items} - - personal_cards = [replace(item, overrides_shared=item.slug in shared_slugs) for item in personal_items] - shared_cards = [ - _resolved_shared_skill(item, shadowed_by_personal=item.slug in personal_slugs) for item in shared_items - ] - return [*personal_cards, *shared_cards] - - -async def list_visible_skills_for_management(db: AsyncSession, user: User) -> list[Skill]: - repo = SkillRepository(db) - visible: list[Skill] = [] - seen: set[str] = set() - for item in await repo.list_all(): - if item.slug in seen: - continue - if user_can_manage_skill(user, item) or (item.enabled and user_can_access_skill(user, item)): - visible.append(item) - seen.add(item.slug) - return visible - - -async def list_skills(db: AsyncSession) -> list[Skill]: - repo = SkillRepository(db) - return await repo.list_all() - - -async def list_skill_slugs(db: AsyncSession, *, user: User | None = None) -> list[str]: - if user is not None: - return await _list_shared_skill_slugs(db, user) - result = await db.execute( - select(Skill.slug).where(Skill.enabled.is_(True)).order_by(Skill.updated_at.desc(), Skill.id.desc()) - ) - return [slug for slug in result.scalars().all() if isinstance(slug, str)] - - -async def get_skill_dependency_options( - db: AsyncSession, user: User, slug: str | None = None -) -> dict[str, list[str] | list[dict]]: - from yuxi.agents.toolkits.service import get_tool_metadata - - def get_tools(): - all_tools = get_tool_metadata() - return [{"slug": tool["slug"], "name": tool.get("name", tool["slug"])} for tool in all_tools] - - skill_slugs, tool_list, mcp_names = await asyncio.gather( - list_skill_slugs(db, user=user), - asyncio.to_thread(get_tools), - get_enabled_mcp_server_slugs(db=db), - ) - if slug: - skill_slugs = [item for item in skill_slugs if item != slug] - - return { - "tools": tool_list, - "mcps": mcp_names, - "skills": skill_slugs, - } - - -async def list_accessible_shared_skills( - db: AsyncSession, - user: User, - *, - require_enabled: bool = True, -) -> list[Skill]: - """按现有共享范围返回用户可访问的数据库 Skill。""" - repo = SkillRepository(db) - items = await repo.list_enabled() if require_enabled else await repo.list_all() - return [item for item in items if user_can_access_skill(user, item, require_enabled=require_enabled)] - - -async def _list_shared_skill_slugs(db: AsyncSession, user: User) -> list[str]: - """返回依赖配置可引用的共享 Skill slug。""" - return [item.slug for item in await list_accessible_shared_skills(db, user) if isinstance(item.slug, str)] - - -def _get_all_tool_names() -> list[str]: - """获取所有工具名称(包括 buildin 和其他来源)""" - from yuxi.agents.toolkits.service import get_tool_metadata - - all_tools = get_tool_metadata() - return [tool["slug"] for tool in all_tools] - - -async def _validate_dependencies( - *, - parent: Skill, - tool_dependencies: list[str], - mcp_dependencies: list[str], - skill_dependencies: list[str], - available_skills: dict[str, Skill], -) -> tuple[list[str], list[str], list[str]]: - tools = normalize_string_list(tool_dependencies) - mcps = normalize_string_list(mcp_dependencies) - skills = normalize_string_list(skill_dependencies) - - # 验证所有工具(不仅仅是 buildin) - available_tools = set(_get_all_tool_names()) - invalid_tools = [name for name in tools if name not in available_tools] - if invalid_tools: - raise ValueError(f"存在无效工具依赖: {', '.join(invalid_tools)}") - - available_mcps = set(await get_enabled_mcp_server_slugs(db=None)) - invalid_mcps = [name for name in mcps if name not in available_mcps] - if invalid_mcps: - raise ValueError(f"存在无效 MCP 依赖: {', '.join(invalid_mcps)}") - - invalid_skills = [name for name in skills if name not in available_skills] - if invalid_skills: - raise ValueError(f"存在无效 skill 依赖: {', '.join(invalid_skills)}") - - if parent.slug in skills: - raise ValueError("skill_dependencies 不允许包含自身") - - forbidden_skills = [name for name in skills if not can_skill_depend_on(parent, available_skills[name])] - if forbidden_skills: - raise ValueError(f"存在权限范围不匹配的 skill 依赖: {', '.join(forbidden_skills)}") - - return tools, mcps, skills - - -async def update_skill_dependencies( - db: AsyncSession, - *, - slug: str, - tool_dependencies: list[str], - mcp_dependencies: list[str], - skill_dependencies: list[str], - operator: User, -) -> Skill: - item = await get_manageable_skill_or_raise(db, operator, slug) - _ensure_non_builtin(item) - repo = SkillRepository(db) - skill_items = await list_accessible_shared_skills(db, operator) - available_skills = {skill.slug: skill for skill in skill_items} - tools, mcps, skills = await _validate_dependencies( - parent=item, - tool_dependencies=tool_dependencies, - mcp_dependencies=mcp_dependencies, - skill_dependencies=skill_dependencies, - available_skills=available_skills, - ) - - updated = await repo.update_dependencies( - item, - tool_dependencies=tools, - mcp_dependencies=mcps, - skill_dependencies=skills, - updated_by=operator.uid, - ) - await db.commit() - return updated - - -def _validate_skill_slug_value(slug: str, *, field_name: str) -> str: - slug = slug.strip() - if not slug: - raise ValueError(f"SKILL.md frontmatter 缺少 {field_name}") - if len(slug) > 128: - raise ValueError(f"SKILL.md frontmatter.{field_name} 长度不能超过 128") - if not SKILL_NAME_PATTERN.match(slug): - raise ValueError(f"SKILL.md frontmatter.{field_name} 必须是小写字母/数字/短横线,且不能连续短横线") - return slug - - -def _validate_skill_display_name(name: str) -> str: - name = name.strip() - if not name: - raise ValueError("SKILL.md frontmatter 缺少 name") - if len(name) > 128: - raise ValueError("SKILL.md frontmatter.name 长度不能超过 128") - return name - - -def _split_frontmatter(content: str) -> tuple[str, str]: - if not content.startswith("---"): - raise ValueError("SKILL.md 缺少有效 frontmatter(--- ... ---)") - - lines = content.splitlines(keepends=True) - if not lines or lines[0].strip() != "---": - raise ValueError("SKILL.md 缺少有效 frontmatter(--- ... ---)") - - frontmatter_lines: list[str] = [] - body_start = 0 - for index, line in enumerate(lines[1:], start=1): - if line.strip() == "---": - body_start = index + 1 - break - frontmatter_lines.append(line) - else: - raise ValueError("SKILL.md 缺少有效 frontmatter(--- ... ---)") - - frontmatter_raw = "".join(frontmatter_lines) - body = "".join(lines[body_start:]) - return frontmatter_raw, body - - -def _parse_skill_markdown(content: str) -> tuple[str, str, str, dict[str, Any]]: - frontmatter_raw, _body = _split_frontmatter(content) - try: - data = yaml.safe_load(frontmatter_raw) - except yaml.YAMLError as e: - raise ValueError(f"SKILL.md frontmatter YAML 解析失败: {e}") from e - - if not isinstance(data, dict): - raise ValueError("SKILL.md frontmatter 必须是对象") - - name = _validate_skill_display_name(str(data.get("name", ""))) - raw_slug = str(data.get("slug", "")).strip() - slug = ( - _validate_skill_slug_value(raw_slug, field_name="slug") - if raw_slug - else _validate_skill_slug_value(name, field_name="name") - ) - description = str(data.get("description", "")).strip() - if not description: - raise ValueError("SKILL.md frontmatter 缺少 description") - - return slug, name, description, data - - -def _rewrite_frontmatter_slug(content: str, new_slug: str) -> str: - frontmatter_raw, body = _split_frontmatter(content) - data = yaml.safe_load(frontmatter_raw) - if not isinstance(data, dict): - raise ValueError("SKILL.md frontmatter 必须是对象") - if data.get("slug"): - data["slug"] = new_slug - else: - data["name"] = new_slug - dumped = yaml.safe_dump(data, sort_keys=False, allow_unicode=True).strip() - return f"---\n{dumped}\n---\n{body}" - - -def _validate_zip_paths(zip_file: zipfile.ZipFile) -> None: - for name in zip_file.namelist(): - pure = PurePosixPath(name) - if pure.is_absolute(): - raise ValueError(f"ZIP 包含不安全绝对路径: {name}") - if ".." in pure.parts: - raise ValueError(f"ZIP 包含路径穿越片段: {name}") - - -async def _generate_available_slug(repo: SkillRepository, base_slug: str) -> str: - root = get_skills_root_dir() - if not await repo.exists_slug(base_slug) and not (root / base_slug).exists(): - return base_slug - - idx = 2 - while True: - candidate = f"{base_slug}-v{idx}" - if not await repo.exists_slug(candidate) and not (root / candidate).exists(): - return candidate - idx += 1 - - -def parse_skill_dir_metadata(source_skill_dir: Path) -> dict[str, Any]: - skill_md_path = source_skill_dir / "SKILL.md" - if not skill_md_path.exists() or not skill_md_path.is_file(): - raise ValueError("技能目录缺少根级 SKILL.md") - - content = skill_md_path.read_text(encoding="utf-8") - parsed_slug, parsed_name, parsed_desc, meta = _parse_skill_markdown(content) - return { - "slug": parsed_slug, - "name": parsed_name, - "description": parsed_desc, - "tool_dependencies": normalize_string_list(meta.get("tool_dependencies")), - "mcp_dependencies": normalize_string_list(meta.get("mcp_dependencies")), - "skill_dependencies": normalize_string_list(meta.get("skill_dependencies")), - } - - -def get_personal_skills_root_dir(uid: str) -> Path: - """返回 UserWorkspace 内认证用户唯一的个人 Skill 目录。""" - from yuxi.workspace.paths import user_workspace_dir - - return user_workspace_dir(uid) / "agents" / "skills" - - -def _personal_skills_root(uid: str) -> Path: - """返回已创建且位于当前用户工作区内的个人 Skill 根。""" - from yuxi.workspace.paths import ensure_user_workspace, user_workspace_dir - - ensure_user_workspace(uid) - workspace_root = user_workspace_dir(uid).resolve() - root = get_personal_skills_root_dir(uid) - root.mkdir(parents=True, exist_ok=True) - return ensure_within_root(root.resolve(), workspace_root, error_message="个人 Skill 路径越界") - - -def _resolve_personal_skill_dir(root: Path, slug: str) -> Path: - """安全解析固定根下的个人 Skill 目录。""" - if not is_valid_skill_slug(slug): - raise ValueError("无效 skill slug") - target = root / slug - if target.is_symlink(): - raise ValueError("个人 Skill 路径非法") - return target - - -async def list_personal_skills(uid: str) -> list[ResolvedSkill]: - """直接扫描个人 Skill 持久目录。""" - return await asyncio.to_thread(_scan_personal_skills, uid) - - -async def install_personal_skill_dir( - uid: str, - source_dir: Path | str, - *, - expected_slug: str | None = None, -) -> ResolvedSkill: - """将一个 Skill 原子安装到当前用户个人持久源。""" - return await asyncio.to_thread( - _install_personal_skill_dir_sync, - uid, - Path(source_dir), - expected_slug=expected_slug, - ) - - -async def read_personal_skill_file(uid: str, slug: str, relative_path: str) -> dict[str, Any]: - """读取个人 Skill 中的文本文件。""" - skill_dir = _resolve_personal_skill_dir(_personal_skills_root(uid), slug) - target, normalized_path = _resolve_relative_path(skill_dir, relative_path) - if not target.is_file(): - raise ValueError("文件不存在") - if not _is_text_path(target): - raise ValueError("仅支持读取文本文件") - try: - content = target.read_text(encoding="utf-8") - except UnicodeDecodeError as exc: - raise ValueError("文件编码不支持(仅支持 UTF-8)") from exc - return {"path": normalized_path, "content": content} - - -async def delete_personal_skill(uid: str, slug: str) -> None: - """删除当前用户个人 Skill。""" - skill_dir = _resolve_personal_skill_dir(_personal_skills_root(uid), slug) - if not skill_dir.is_dir(): - raise ValueError("个人 Skill 不存在") - await asyncio.to_thread(shutil.rmtree, skill_dir) - - -def _resolved_shared_skill(item: Skill, *, shadowed_by_personal: bool = False) -> ResolvedSkill: - """将数据库 Skill 适配为统一的有效 Skill 描述。""" - source_scope = "builtin" if is_builtin_skill(item) else "shared" - return ResolvedSkill( - id=item.id, - slug=item.slug, - name=item.name, - description=item.description, - source_type=item.source_type, - source_scope=source_scope, - source_dir=_resolve_skill_dir(item), - enabled=bool(item.enabled), - created_by=item.created_by, - share_config=normalize_permission_config( - item.share_config, - ), - tool_dependencies=normalize_string_list(item.tool_dependencies), - mcp_dependencies=normalize_string_list(item.mcp_dependencies), - skill_dependencies=normalize_string_list(item.skill_dependencies), - version=item.version, - content_hash=item.content_hash, - shadowed_by_personal=shadowed_by_personal, - ) - - -def _resolved_personal_skill(uid: str, root: Path, metadata: dict[str, Any]) -> ResolvedSkill: - """将个人目录元数据适配为不含共享语义的有效 Skill 描述。""" - slug = str(metadata["slug"]) - if not is_valid_skill_slug(slug): - raise ValueError("个人 Skill 包含非法 slug") - source_dir = root / slug - return ResolvedSkill( - id=f"personal:{slug}", - slug=slug, - name=str(metadata["name"]), - description=str(metadata["description"]), - source_type=PERSONAL_SKILL_SOURCE_TYPE, - source_scope=PERSONAL_SKILL_SOURCE_TYPE, - source_dir=source_dir, - enabled=True, - created_by=uid, - share_config=None, - tool_dependencies=[], - mcp_dependencies=[], - skill_dependencies=[], - ) - - -def _scan_personal_skills(uid: str) -> list[ResolvedSkill]: - """扫描并校验当前用户个人 Skill 的直接子目录。""" - items: list[ResolvedSkill] = [] - root = _personal_skills_root(uid) - for entry in sorted(root.iterdir(), key=lambda path: path.name): - if entry.is_symlink() or not entry.is_dir() or not is_valid_skill_slug(entry.name): - logger.warning(f"跳过非法个人 Skill 目录: uid={uid}, name={entry.name}") - continue - if _dir_contains_symlink(entry): - logger.warning(f"跳过包含符号链接的个人 Skill: uid={uid}, slug={entry.name}") - continue - try: - metadata = parse_skill_dir_metadata(entry) - if metadata["slug"] != entry.name: - raise ValueError("目录名必须与 SKILL.md slug 一致") - items.append(_resolved_personal_skill(uid, root, metadata)) - except Exception as exc: - logger.warning(f"跳过无法解析的个人 Skill: uid={uid}, slug={entry.name}, error={exc}") - return items - - -def _install_personal_skill_dir_sync( - uid: str, - source_dir: Path, - *, - expected_slug: str | None = None, -) -> ResolvedSkill: - """将一个 Skill 原子复制到个人目录。""" - source_dir = source_dir.resolve() - root = _personal_skills_root(uid) - temp_target = root / f".install.tmp-{uuid.uuid4().hex[:8]}" - target_dir: Path | None = None - try: - metadata = _copy_skill_snapshot(source_dir, temp_target, expected_slug=expected_slug) - slug = metadata["slug"] - target_dir = root / slug - if target_dir.exists() or target_dir.is_symlink(): - raise ValueError(f"个人 Skill 源已存在同名 Skill: {slug}") - temp_target.rename(target_dir) - except (FileExistsError, OSError) as exc: - if target_dir is None or not target_dir.exists(): - raise - raise ValueError(f"个人 Skill 源已存在同名 Skill: {slug}") from exc - finally: - if temp_target.exists(): - shutil.rmtree(temp_target, ignore_errors=True) - return _resolved_personal_skill(uid, root, metadata) - - -async def _stage_skill_draft_item( - repo: SkillRepository, - *, - source_skill_dir: Path, - draft_items_dir: Path, -) -> dict[str, Any]: - item_id = uuid.uuid4().hex - item_dir = draft_items_dir / item_id - parsed = _copy_skill_snapshot(source_skill_dir, item_dir) - final_slug = await _generate_available_slug(repo, parsed["slug"]) - return { - "draft_item_id": item_id, - "source_dir": f"items/{item_id}", - "slug": final_slug, - "name": parsed["name"], - "original_name": parsed["slug"], - "description": parsed["description"], - "tool_dependencies": parsed["tool_dependencies"], - "mcp_dependencies": parsed["mcp_dependencies"], - "skill_dependencies": parsed["skill_dependencies"], - "warnings": [f"原始 slug {parsed['slug']} 已存在,将安装为 {final_slug}"] - if final_slug != parsed["slug"] - else [], - "success": True, - } - - -def _build_default_share_payload(operator: User) -> dict[str, Any]: - default_share_config = normalize_skill_share_config( - None, - operator_uid=operator.uid, - allowed_access_levels=set(get_allowed_skill_access_levels(operator)), - ) - return { - "default_share_config": default_share_config, - "allowed_access_levels": get_allowed_skill_access_levels(operator), - } - - -def _resolve_skill_dir(item: Skill) -> Path: - dir_path = Path(item.dir_path) - if dir_path.is_absolute(): - return dir_path - return (get_skill_data_dir() / dir_path).resolve() - - -def _resolve_relative_path(skill_dir: Path, relative_path: str, *, allow_root: bool = False) -> tuple[Path, str]: - rel = (relative_path or "").strip().replace("\\", "/") - rel = rel.lstrip("/") - if not rel and not allow_root: - raise ValueError("path 不能为空") - pure = PurePosixPath(rel) if rel else PurePosixPath(".") - if ".." in pure.parts: - raise ValueError("非法路径:不允许上级路径引用") - - target = ensure_within_root((skill_dir / pure).resolve(), skill_dir, error_message="非法路径:越界访问被拒绝") - - return target, rel - - -def _is_text_path(path: Path) -> bool: - if path.name == "SKILL.md": - return True - suffix = path.suffix.lower() - return suffix in TEXT_FILE_EXTENSIONS - - -def _build_tree(path: Path, base_dir: Path) -> list[dict[str, Any]]: - children: list[dict[str, Any]] = [] - for child in sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())): - rel = child.relative_to(base_dir).as_posix() - if child.is_dir(): - children.append( - { - "name": child.name, - "path": rel, - "is_dir": True, - "children": _build_tree(child, base_dir), - } - ) - else: - children.append( - { - "name": child.name, - "path": rel, - "is_dir": False, - } - ) - return children - - -async def prepare_skill_upload( - db: AsyncSession, - *, - filename: str, - file_bytes: bytes, - operator: User, -) -> dict[str, Any]: - normalized_filename = filename.lower() - is_zip_upload = normalized_filename.endswith(".zip") - is_skill_md_upload = normalized_filename.endswith("skill.md") - if not is_zip_upload and not is_skill_md_upload: - raise ValueError("仅支持上传 .zip 或 SKILL.md 文件") - - repo = SkillRepository(db) - draft_dir = get_skill_drafts_root_dir() / str(uuid.uuid4()) - items_dir = draft_dir / "items" - draft_dir.mkdir(parents=True, exist_ok=False) - items_dir.mkdir(parents=True, exist_ok=True) - - try: - with tempfile.TemporaryDirectory(prefix=".skill-prepare-", dir=str(get_skills_root_dir().parent)) as temp_root: - extract_dir = Path(temp_root) / "extract" - extract_dir.mkdir(parents=True, exist_ok=True) - if is_zip_upload: - zip_path = Path(temp_root) / "upload.zip" - zip_path.write_bytes(file_bytes) - with zipfile.ZipFile(zip_path, "r") as zf: - _validate_zip_paths(zf) - zf.extractall(extract_dir) - skill_md_files = list(extract_dir.rglob("SKILL.md")) - if len(skill_md_files) != 1: - raise ValueError("ZIP 必须且只能包含一个技能(检测到一个 SKILL.md)") - source_skill_dir = skill_md_files[0].parent - else: - source_skill_dir = extract_dir - (source_skill_dir / "SKILL.md").write_bytes(file_bytes) - - item = await _stage_skill_draft_item(repo, source_skill_dir=source_skill_dir, draft_items_dir=items_dir) - - data = { - "draft_id": draft_dir.name, - "created_by": operator.uid, - "source_type": "upload", - "source": filename, - "created_at": time.time(), - "expires_at": time.time() + SKILL_DRAFT_TTL_SECONDS, - "items": [item], - **_build_default_share_payload(operator), - } - (draft_dir / "metadata.json").write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") - return data - except Exception: - shutil.rmtree(draft_dir, ignore_errors=True) - raise - - -async def prepare_remote_skill_install( - db: AsyncSession, - *, - source: str, - skills: list[str], - operator: User, -) -> dict[str, Any]: - from yuxi.agents.skills.remote_install import prepare_remote_skills_batch - - repo = SkillRepository(db) - draft_dir = get_skill_drafts_root_dir() / str(uuid.uuid4()) - items_dir = draft_dir / "items" - draft_dir.mkdir(parents=True, exist_ok=False) - items_dir.mkdir(parents=True, exist_ok=True) - - preparation = None - try: - preparation = await prepare_remote_skills_batch(source=source, skills=skills) - items: list[dict[str, Any]] = [] - for result in preparation.results: - slug = result.get("slug", "") - if not result.get("success"): - item = {"slug": slug, "success": False, "error": result.get("error", "安装失败")} - items.append(item) - continue - - try: - item = await _stage_skill_draft_item( - repo, - source_skill_dir=Path(result["source_dir"]), - draft_items_dir=items_dir, - ) - except Exception as e: - item = {"slug": slug, "success": False, "error": str(e)} - items.append(item) - continue - - items.append(item) - - data = { - "draft_id": draft_dir.name, - "created_by": operator.uid, - "source_type": "remote", - "source": source, - "created_at": time.time(), - "expires_at": time.time() + SKILL_DRAFT_TTL_SECONDS, - "items": items, - **_build_default_share_payload(operator), - } - (draft_dir / "metadata.json").write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") - return data - except Exception: - shutil.rmtree(draft_dir, ignore_errors=True) - raise - finally: - if preparation is not None: - await preparation.cleanup() - - -async def confirm_skill_install_draft( - db: AsyncSession, - *, - draft_id: str, - share_config: dict | None, - slugs: list[str] | None = None, - operator: User, -) -> list[dict[str, Any]]: - draft_dir, data, draft_items = _load_and_select_draft_items(draft_id, slugs, operator) - source_type = data.get("source_type") - - normalized_share_config = normalize_skill_share_config( - share_config, - operator_uid=operator.uid, - source_type=source_type, - allowed_access_levels=set(get_allowed_skill_access_levels(operator)), - ) - - repo = SkillRepository(db) - skills_root = get_skills_root_dir() - results: list[dict[str, Any]] = [] - - for draft_item in draft_items: - slug = str(draft_item.get("slug") or "").strip() - if not draft_item.get("success", True): - result = {"slug": slug, "success": False, "error": draft_item.get("error", "安装失败")} - results.append(result) - continue - - if not is_valid_skill_slug(slug): - result = {"slug": slug, "success": False, "error": "无效 skill slug"} - results.append(result) - continue - if await repo.exists_slug(slug) or (skills_root / slug).exists(): - result = {"slug": slug, "success": False, "error": "Skill slug 已被占用,请重新解析安装"} - results.append(result) - continue - - source_dir = (draft_dir / str(draft_item.get("source_dir", ""))).resolve() - try: - source_dir.relative_to(draft_dir.resolve()) - except ValueError: - result = {"slug": slug, "success": False, "error": "安装草稿路径非法"} - results.append(result) - continue - - temp_target = skills_root / f".{slug}.tmp-{uuid.uuid4().hex[:8]}" - final_dir = skills_root / slug - published = False - try: - parsed = _copy_skill_snapshot(source_dir, temp_target, final_slug=slug) - if final_dir.exists(): - raise ValueError("Skill slug 已被占用,请重新解析安装") - temp_target.rename(final_dir) - published = True - item = await repo.create( - slug=slug, - name=parsed["name"], - description=parsed["description"], - source_type=source_type, - tool_dependencies=parsed["tool_dependencies"], - mcp_dependencies=parsed["mcp_dependencies"], - skill_dependencies=parsed["skill_dependencies"], - dir_path=(Path("shared") / slug).as_posix(), - share_config=normalized_share_config, - enabled=True, - created_by=operator.uid, - ) - await db.commit() - results.append({"slug": item.slug, "success": True, "skill": item.to_dict()}) - except Exception as e: - await db.rollback() - if published: - shutil.rmtree(final_dir, ignore_errors=True) - result = {"slug": slug, "success": False, "error": str(e)} - results.append(result) - finally: - shutil.rmtree(temp_target, ignore_errors=True) - - if any(item.get("success") for item in results): - shutil.rmtree(draft_dir, ignore_errors=True) - return results - - -async def confirm_personal_skill_install_draft( - *, - draft_id: str, - slugs: list[str] | None, - operator: User, -) -> list[dict[str, Any]]: - """确认草稿并将选中 Skill 安装到当前用户个人持久源。""" - draft_dir, _data, draft_items = _load_and_select_draft_items(draft_id, slugs, operator) - - results: list[dict[str, Any]] = [] - for draft_item in draft_items: - requested_slug = str(draft_item.get("slug") or "").strip() - personal_slug = str(draft_item.get("original_name") or requested_slug).strip() - if not draft_item.get("success", True): - results.append( - { - "slug": personal_slug, - "requested_slug": requested_slug, - "success": False, - "error": draft_item.get("error", "安装失败"), - } - ) - continue - if not is_valid_skill_slug(personal_slug): - results.append( - { - "slug": personal_slug, - "requested_slug": requested_slug, - "success": False, - "error": "无效 skill slug", - } - ) - continue - - source_dir = (draft_dir / str(draft_item.get("source_dir", ""))).resolve() - try: - source_dir.relative_to(draft_dir.resolve()) - item = await install_personal_skill_dir( - str(operator.uid), - source_dir, - expected_slug=personal_slug, - ) - results.append( - { - "slug": item.slug, - "requested_slug": requested_slug, - "success": True, - "skill": item.to_dict(), - } - ) - except Exception as exc: - results.append( - { - "slug": personal_slug, - "requested_slug": requested_slug, - "success": False, - "error": str(exc), - } - ) - - if any(item.get("success") for item in results): - shutil.rmtree(draft_dir, ignore_errors=True) - return results - - -async def discard_skill_install_draft(*, draft_id: str, operator: User) -> None: - draft_dir, data = _load_skill_draft(draft_id) - if data.get("created_by") != operator.uid and operator.role not in ADMIN_ROLES: - raise ValueError("无权删除该安装草稿") - shutil.rmtree(draft_dir, ignore_errors=True) - - -async def get_skill_or_raise(db: AsyncSession, slug: str) -> Skill: - slug = slug.strip() if isinstance(slug, str) else "" - if not is_valid_skill_slug(slug): - raise ValueError("无效 skill slug") - - repo = SkillRepository(db) - item = await repo.get_by_slug(slug) - if not item: - raise ValueError(f"技能 '{slug}' 不存在") - return item - - -async def get_management_readable_skill_or_raise(db: AsyncSession, user: User, slug: str) -> Skill: - item = await get_skill_or_raise(db, slug) - if not user_can_manage_skill(user, item) and not user_can_access_skill(user, item): - raise ValueError(f"技能 '{slug}' 不存在或无权访问") - return item - - -async def get_manageable_skill_or_raise(db: AsyncSession, user: User, slug: str) -> Skill: - item = await get_skill_or_raise(db, slug) - if not user_can_manage_skill(user, item): - raise ValueError(f"技能 '{slug}' 不存在或无权管理") - return item - - -async def get_skill_tree(db: AsyncSession, *, slug: str, operator: User) -> list[dict[str, Any]]: - item = await get_management_readable_skill_or_raise(db, operator, slug) - skill_dir = _resolve_skill_dir(item) - if not skill_dir.exists() or not skill_dir.is_dir(): - raise ValueError(f"技能目录不存在: {item.dir_path}") - return _build_tree(skill_dir, skill_dir) - - -async def read_skill_file( - db: AsyncSession, - *, - slug: str, - relative_path: str, - operator: User, -) -> dict[str, Any]: - item = await get_management_readable_skill_or_raise(db, operator, slug) - skill_dir = _resolve_skill_dir(item) - target, rel = _resolve_relative_path(skill_dir, relative_path) - if not target.exists() or not target.is_file(): - raise ValueError(f"文件不存在: {relative_path}") - if not _is_text_path(target): - raise ValueError("仅支持读取文本文件") - try: - content = target.read_text(encoding="utf-8") - except UnicodeDecodeError as e: - raise ValueError(f"文件编码不支持(仅支持 UTF-8): {e}") from e - - return {"path": rel, "content": content} - - -async def create_skill_node( - db: AsyncSession, - *, - slug: str, - relative_path: str, - is_dir: bool, - content: str | None, - updated_by: str | None, - operator: User, -) -> None: - item = await get_manageable_skill_or_raise(db, operator, slug) - if is_builtin_skill(item): - raise ValueError("内置 skill 不允许直接修改文件") - skill_dir = _resolve_skill_dir(item) - target, _ = _resolve_relative_path(skill_dir, relative_path) - if target.exists(): - raise ValueError("目标已存在") - - if is_dir: - target.mkdir(parents=True, exist_ok=False) - return - - if not _is_text_path(target): - raise ValueError("仅支持创建文本文件") - - target.parent.mkdir(parents=True, exist_ok=True) - - # 先写入文件,再更新元数据 - target.write_text(content or "", encoding="utf-8") - - await _update_skill_metadata_if_skills_md(db, item, content or "", skill_dir, target, updated_by) - await db.commit() - - -async def update_skill_file( - db: AsyncSession, - *, - slug: str, - relative_path: str, - content: str, - updated_by: str | None, - operator: User, -) -> None: - item = await get_manageable_skill_or_raise(db, operator, slug) - if is_builtin_skill(item): - raise ValueError("内置 skill 不允许直接修改文件") - skill_dir = _resolve_skill_dir(item) - target, _ = _resolve_relative_path(skill_dir, relative_path) - if not target.exists() or not target.is_file(): - raise ValueError("文件不存在") - if not _is_text_path(target): - raise ValueError("仅支持编辑文本文件") - - await _update_skill_metadata_if_skills_md(db, item, content, skill_dir, target, updated_by) - - target.write_text(content, encoding="utf-8") - await db.commit() - - -async def _update_skill_metadata_if_skills_md( - db: AsyncSession, - item: Skill, - content: str, - skill_dir: Path, - target: Path, - updated_by: str | None, -) -> None: - """如果目标文件是 SKILL.md,则解析并更新元数据""" - if target.name == "SKILL.md" and target.parent == skill_dir: - parsed_slug, parsed_name, parsed_desc, _ = _parse_skill_markdown(content) - if parsed_slug != item.slug: - raise ValueError("SKILL.md frontmatter.slug 必须与 skill slug 一致") - repo = SkillRepository(db) - await repo.update_metadata(item, name=parsed_name, description=parsed_desc, updated_by=updated_by) - - -async def delete_skill_node( - db: AsyncSession, - *, - slug: str, - relative_path: str, - operator: User, -) -> None: - item = await get_manageable_skill_or_raise(db, operator, slug) - if is_builtin_skill(item): - raise ValueError("内置 skill 不允许直接修改文件") - skill_dir = _resolve_skill_dir(item) - target, rel = _resolve_relative_path(skill_dir, relative_path, allow_root=False) - if not target.exists(): - raise ValueError("目标不存在") - - if rel == "SKILL.md": - raise ValueError("不允许删除根目录 SKILL.md") - - if target.is_dir(): - await asyncio.to_thread(shutil.rmtree, target) - else: - target.unlink() - - -async def export_skill_zip(db: AsyncSession, *, slug: str, operator: User) -> tuple[str, str]: - item = await get_manageable_skill_or_raise(db, operator, slug) - skill_dir = _resolve_skill_dir(item) - if not skill_dir.exists() or not skill_dir.is_dir(): - raise ValueError("技能目录不存在") - - fd, export_path = tempfile.mkstemp(prefix=f"skill-{slug}-", suffix=".zip") - Path(export_path).unlink(missing_ok=True) - export_file = Path(export_path) - try: - with zipfile.ZipFile(export_file, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for p in skill_dir.rglob("*"): - arcname = Path(slug) / p.relative_to(skill_dir) - zf.write(p, arcname.as_posix()) - except Exception: - export_file.unlink(missing_ok=True) - raise - return export_path, f"{slug}.zip" - - -async def delete_skill(db: AsyncSession, *, slug: str, operator: User) -> None: - repo = SkillRepository(db) - item = await repo.get_by_slug(slug, for_update=True) - if not item: - raise ValueError(f"技能 '{slug}' 不存在") - if not user_can_manage_skill(operator, item): - raise ValueError(f"技能 '{slug}' 不存在或无权管理") - _ensure_non_builtin(item) - - skill_dir = _resolve_skill_dir(item) - trash_dir: Path | None = None - - if skill_dir.exists(): - trash_dir = skill_dir.with_name(f".deleted-{slug}-{uuid.uuid4().hex[:8]}") - skill_dir.rename(trash_dir) - - try: - await repo.delete(item) - await db.commit() - except Exception: - if trash_dir and trash_dir.exists(): - trash_dir.rename(skill_dir) - raise - - if trash_dir and trash_dir.exists(): - await asyncio.to_thread(shutil.rmtree, trash_dir, ignore_errors=True) - - -async def delete_skills_batch(db: AsyncSession, *, slugs: list[str], operator: User) -> list[dict]: - """批量删除多个 skills(单技能独立的子事务与回滚)。""" - if len(slugs) > 50: - raise ValueError("批量删除的技能数量不能超过 50 个") - results = [] - for slug in slugs: - try: - await delete_skill(db, slug=slug, operator=operator) - results.append({"slug": slug, "success": True}) - except Exception as e: - if hasattr(db, "rollback"): - await db.rollback() - results.append({"slug": slug, "success": False, "error": str(e)}) - return results - - -async def update_skill_share_config( - db: AsyncSession, - *, - slug: str, - share_config: dict | None, - operator: User, -) -> Skill: - item = await get_manageable_skill_or_raise(db, operator, slug) - _ensure_non_builtin(item) - normalized = normalize_skill_share_config( - share_config, - operator_uid=operator.uid, - source_type=item.source_type, - allowed_access_levels=set(get_allowed_skill_access_levels(operator)), - ) - repo = SkillRepository(db) - updated = await repo.update_share_config(item, share_config=normalized, updated_by=operator.uid) - await apply_skill_projection_policy_change(db, slug) - return updated - - -async def update_skill_enabled(db: AsyncSession, *, slug: str, enabled: bool, operator: User) -> Skill: - item = await get_manageable_skill_or_raise(db, operator, slug) - repo = SkillRepository(db) - updated = await repo.update_enabled(item, enabled=enabled, updated_by=operator.uid) - await apply_skill_projection_policy_change(db, slug) - return updated - - -def list_builtin_skill_specs() -> list[dict[str, Any]]: - """发现源码目录中的 Skill,并以 frontmatter 作为唯一元数据。""" - specs: list[dict[str, Any]] = [] - for source_dir in sorted(BUILTIN_SKILLS_DIR.iterdir()): - if not source_dir.is_dir() or source_dir.name.startswith(("_", ".")): - continue - slug = source_dir.name - skill_md = source_dir / "SKILL.md" - if not skill_md.exists(): - raise ValueError(f"内置 skill 缺少 SKILL.md: {source_dir}") - - content = skill_md.read_text(encoding="utf-8") - parsed_slug, parsed_name, parsed_desc, meta = _parse_skill_markdown(content) - if parsed_slug != slug: - raise ValueError(f"内置 skill frontmatter.slug 必须等于 slug: {slug}") - - specs.append( - { - "slug": slug, - "name": parsed_name, - "description": parsed_desc, - "version": str(meta.get("version", "1.0.0")), - "tool_dependencies": normalize_string_list(meta.get("tool_dependencies")), - "mcp_dependencies": normalize_string_list(meta.get("mcp_dependencies")), - "skill_dependencies": normalize_string_list(meta.get("skill_dependencies")), - "content_hash": _compute_dir_hash(source_dir), - "source_dir": source_dir, - } - ) - - return specs - - -async def init_builtin_skills(db: AsyncSession, *, created_by: str = "system") -> list[Skill]: - if db is not None and db.get_bind().dialect.name == "postgresql": - await db.execute(text("SELECT pg_advisory_xact_lock(:lock_key)"), {"lock_key": SKILL_STORAGE_LOCK}) - - repo = SkillRepository(db) - synced_items: list[Skill] = [] - - for spec in list_builtin_skill_specs(): - slug = spec["slug"] - existing = await repo.get_by_slug(slug) - if existing and not is_builtin_skill(existing): - raise ValueError(f"内置 skill '{slug}' 与已存在的非内置 skill 冲突") - - target_dir = get_skills_root_dir() / slug - _replace_skill_target(target_dir, Path(spec["source_dir"])) - - if existing: - existing.dir_path = _build_builtin_skill_dir_path(slug) - if existing.name != spec["name"] or existing.description != spec["description"]: - await repo.update_metadata( - existing, - name=spec["name"], - description=spec["description"], - updated_by=created_by, - ) - if ( - normalize_string_list(existing.tool_dependencies or []) != spec["tool_dependencies"] - or normalize_string_list(existing.mcp_dependencies or []) != spec["mcp_dependencies"] - or normalize_string_list(existing.skill_dependencies or []) != spec["skill_dependencies"] - ): - await repo.update_dependencies( - existing, - tool_dependencies=spec["tool_dependencies"], - mcp_dependencies=spec["mcp_dependencies"], - skill_dependencies=spec["skill_dependencies"], - updated_by=created_by, - ) - synced_items.append( - await repo.update_builtin_install( - existing, - version=spec["version"], - content_hash=spec["content_hash"], - updated_by=created_by, - ) - ) - continue - - synced_items.append( - await repo.create( - slug=slug, - name=spec["name"], - description=spec["description"], - source_type="builtin", - tool_dependencies=spec["tool_dependencies"], - mcp_dependencies=spec["mcp_dependencies"], - skill_dependencies=spec["skill_dependencies"], - dir_path=_build_builtin_skill_dir_path(slug), - share_config=BUILTIN_SKILL_SHARE_CONFIG.copy(), - enabled=True, - version=spec["version"], - content_hash=spec["content_hash"], - created_by=created_by or BUILTIN_SKILL_OPERATOR, - ) - ) - - if db is not None: - await db.commit() - return synced_items diff --git a/backend/package/yuxi/agents/toolkits/buildin/install_skill.py b/backend/package/yuxi/agents/toolkits/buildin/install_skill.py index 1e384888f8..f49f449c1a 100644 --- a/backend/package/yuxi/agents/toolkits/buildin/install_skill.py +++ b/backend/package/yuxi/agents/toolkits/buildin/install_skill.py @@ -1,7 +1,3 @@ -import asyncio -import shutil -import tempfile -from pathlib import Path, PurePosixPath from typing import Annotated from langchain.tools import InjectedToolCallId @@ -10,13 +6,10 @@ from langgraph.types import Command from pydantic import BaseModel, Field -from yuxi.agents.backends.paths import VIRTUAL_PATH_PREFIX, VIRTUAL_PERSONAL_SKILLS_PATH -from yuxi.agents.backends.sandbox.download import download_sandbox_directory +from yuxi.agents.backends.paths import VIRTUAL_PERSONAL_SKILLS_PATH from yuxi.agents.toolkits.registry import tool from yuxi.utils.logging_config import logger -SANDBOX_PATH_HINT = "请使用当前 Project Workdir 下的目录,或 /home/gem/user-data/..." - class InstallSkillInput(BaseModel): source: str = Field( @@ -29,54 +22,19 @@ class InstallSkillInput(BaseModel): ) -def _prepare_skill_from_sandbox( - sandbox_path: str, - thread_id: str, - uid: str, - staging_root: Path, - workdir_relative_path: str | None = None, - workdir_path: str | None = None, -) -> Path: - """从 Sandbox 路径准备 skill 目录,返回本地暂存目录。""" - from yuxi.agents.backends.sandbox import ProvisionerSandboxBackend - from yuxi.agents.skills.service import is_valid_skill_slug - - slug = PurePosixPath(sandbox_path.rstrip("/")).name - if not is_valid_skill_slug(slug): - raise ValueError(f"slug '{slug}' 不合法(仅允许小写字母、数字和连字符)") - - allowed = sandbox_path.startswith(f"{VIRTUAL_PATH_PREFIX.rstrip('/')}/") - allowed = allowed or bool(workdir_path and sandbox_path.startswith(f"{workdir_path.rstrip('/')}/")) - if not allowed: - raise ValueError(f"不支持的沙盒路径: {sandbox_path}。{SANDBOX_PATH_HINT}") - - staging = staging_root / slug - backend = ProvisionerSandboxBackend( - thread_id=thread_id, - uid=uid, - workdir_path=workdir_relative_path, - create_if_missing=True, - ) - download_sandbox_directory( - backend, - sandbox_path, - staging, - empty_message=f"沙盒路径 {sandbox_path} 中未发现可下载文件", - ) - if not (staging / "SKILL.md").exists(): - shutil.rmtree(staging, ignore_errors=True) - raise ValueError(f"沙盒路径 {sandbox_path} 中未找到 SKILL.md") - - return staging - - -async def _run_install_task( +@tool( + category="buildin", + tags=["skill", "安装"], + display_name="安装技能", + args_schema=InstallSkillInput, +) +async def install_skill( source: str, - runtime: ToolRuntime, - tool_call_id: str, skill_names: list[str] | None = None, + runtime: ToolRuntime = None, + tool_call_id: Annotated[str, InjectedToolCallId] = "", ) -> Command: - """执行异步安装任务的核心逻辑。""" + """安装新的 Skill 到当前用户私有空间,并返回可直接读取的 Skill 路径。""" runtime_context = getattr(runtime, "context", None) if getattr(runtime_context, "is_subagent_runtime", False): return Command( @@ -106,53 +64,16 @@ async def _run_install_task( ) try: - from yuxi.agents.skills.service import install_personal_skill_dir - - installed_slugs: list[str] = [] - failed_items: list[dict] = [] - - if source.startswith("/"): - with tempfile.TemporaryDirectory(prefix=".skill-install-") as tmp: - source_dir = await asyncio.to_thread( - _prepare_skill_from_sandbox, - source, - thread_id, - uid, - Path(tmp), - getattr(runtime_context, "workdir_relative_path", None), - getattr(runtime_context, "workdir_path", None), - ) - item = await install_personal_skill_dir(uid, source_dir) - installed_slugs = [item.slug] - else: - if not skill_names: - return Command( - update={ - "messages": [ - ToolMessage( - content="错误:从 Git 安装时必须通过 skill_names 指定技能名称", - tool_call_id=tool_call_id, - ) - ] - } - ) - - from yuxi.agents.skills.remote_install import prepare_remote_skills_batch - - preparation = await prepare_remote_skills_batch(source=source, skills=skill_names) - try: - for result in preparation.results: - if not result.get("success"): - failed_items.append(result) - continue - try: - item = await install_personal_skill_dir(uid, result["source_dir"]) - installed_slugs.append(item.slug) - except Exception as e: - failed_items.append({"slug": result["slug"], "success": False, "error": str(e)}) - - finally: - await preparation.cleanup() + from yuxi.services.skills.personal import install_personal_skills_from_source + + installed_slugs, failed_items = await install_personal_skills_from_source( + uid=uid, + thread_id=thread_id, + source=source, + skill_names=skill_names, + workdir_relative_path=getattr(runtime_context, "workdir_relative_path", None), + workdir_path=getattr(runtime_context, "workdir_path", None), + ) lines = [] if installed_slugs: @@ -183,19 +104,3 @@ async def _run_install_task( ] } ) - - -@tool( - category="buildin", - tags=["skill", "安装"], - display_name="安装技能", - args_schema=InstallSkillInput, -) -async def install_skill( - source: str, - skill_names: list[str] | None = None, - runtime: ToolRuntime = None, - tool_call_id: Annotated[str, InjectedToolCallId] = "", -) -> Command: - """安装新的 Skill 到当前用户私有空间,并返回可直接读取的 Skill 路径。""" - return await _run_install_task(source, runtime, tool_call_id, skill_names) diff --git a/backend/package/yuxi/agents/skills/repository.py b/backend/package/yuxi/repositories/skill_repository.py similarity index 59% rename from backend/package/yuxi/agents/skills/repository.py rename to backend/package/yuxi/repositories/skill_repository.py index a6cc95feac..8d7ec467db 100644 --- a/backend/package/yuxi/agents/skills/repository.py +++ b/backend/package/yuxi/repositories/skill_repository.py @@ -3,7 +3,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from yuxi.storage.postgres.models_business import Skill +from yuxi.permissions import ResourcePermission, resolve_skill_permission +from yuxi.storage.postgres.models_business import Skill, User from yuxi.utils.datetime_utils import utc_now_naive @@ -11,20 +12,76 @@ class SkillRepository: def __init__(self, db_session: AsyncSession): self.db = db_session + async def list_enabled_readable(self, user: User) -> list[Skill]: + """只返回当前用户可使用的已启用共享索引。""" + return [ + item + for item in await self.list_enabled() + if resolve_skill_permission(user, item) != ResourcePermission.NONE + ] + + async def list_authorized_for_projection(self, user: User) -> list[Skill]: + """为投影锁定阶段包含当前用户可读的停用项。""" + return [ + item for item in await self.list_all() if resolve_skill_permission(user, item) != ResourcePermission.NONE + ] + + async def list_visible_for_management(self, user: User) -> list[Skill]: + """返回可管理项和可读取的已启用项。""" + visible = [] + for item in await self.list_all(): + permission = resolve_skill_permission(user, item) + can_manage_builtin = item.source_type == "builtin" and user.role in {"admin", "superadmin"} + if ( + permission == ResourcePermission.MANAGE + or can_manage_builtin + or (item.enabled and permission != ResourcePermission.NONE) + ): + visible.append(item) + return visible + + async def lock_rows_for_read(self, ids: list[int]) -> list[Skill]: + """只锁定调用方已筛出的共享 Skill,并刷新会话内旧值。""" + if not ids: + return [] + stmt = ( + select(Skill) + .where(Skill.id.in_(ids)) + .order_by(Skill.id) + .with_for_update(read=True) + .execution_options(populate_existing=True) + ) + result = await self.db.execute(stmt) + return list(result.scalars().all()) + + async def get_by_slug_for_read(self, slug: str) -> Skill | None: + """读取文件期间取得单个共享 Skill 的共享行锁。""" + stmt = ( + select(Skill).where(Skill.slug == slug).with_for_update(read=True).execution_options(populate_existing=True) + ) + result = await self.db.execute(stmt) + return result.scalar_one_or_none() + + async def list_builtin(self) -> list[Skill]: + """在数据库中过滤内置来源,保持索引的更新时间排序。""" + stmt = select(Skill).where(Skill.source_type == "builtin").order_by(Skill.updated_at.desc(), Skill.id.desc()) + result = await self.db.execute(stmt) + return list(result.scalars().all()) + async def list_all(self) -> list[Skill]: - result = await self.db.execute(select(Skill).order_by(Skill.updated_at.desc(), Skill.id.desc())) + stmt = select(Skill).order_by(Skill.updated_at.desc(), Skill.id.desc()) + result = await self.db.execute(stmt) return list(result.scalars().all()) async def list_enabled(self) -> list[Skill]: - result = await self.db.execute( - select(Skill).where(Skill.enabled.is_(True)).order_by(Skill.updated_at.desc(), Skill.id.desc()) - ) + stmt = select(Skill).where(Skill.enabled.is_(True)).order_by(Skill.updated_at.desc(), Skill.id.desc()) + result = await self.db.execute(stmt) return list(result.scalars().all()) async def get_by_slug(self, slug: str, *, for_update: bool = False) -> Skill | None: stmt = select(Skill).where(Skill.slug == slug) if for_update: - stmt = stmt.with_for_update() + stmt = stmt.with_for_update().execution_options(populate_existing=True) result = await self.db.execute(stmt) return result.scalar_one_or_none() diff --git a/backend/package/yuxi/services/agent_run_manifest_service.py b/backend/package/yuxi/services/agent_run_manifest_service.py index 9d2dd90235..cea25f45ee 100644 --- a/backend/package/yuxi/services/agent_run_manifest_service.py +++ b/backend/package/yuxi/services/agent_run_manifest_service.py @@ -18,8 +18,8 @@ from yuxi.agents.backends.paths import runtime_workdir_path from yuxi.agents.buildin import get_agent_backend from yuxi.agents.context import BaseContext, prepare_agent_runtime_context -from yuxi.agents.skills.service import PERSONAL_SKILL_SOURCE_TYPE from yuxi.repositories.agent_repository import AgentRepository +from yuxi.services.skills.personal import PERSONAL_SKILL_SOURCE_TYPE from yuxi.services.workdir_service import AuthorizedWorkdir from yuxi.storage.postgres.models_business import AgentRun, User diff --git a/backend/package/yuxi/services/artifact_service.py b/backend/package/yuxi/services/artifact_service.py index 5c80ac3cbb..0e2e44aaad 100644 --- a/backend/package/yuxi/services/artifact_service.py +++ b/backend/package/yuxi/services/artifact_service.py @@ -18,10 +18,12 @@ runtime_user_data_path, workspace_scope_from_runtime_path, ) -from yuxi.agents.skills.service import ResolvedSkill, list_accessible_skills from yuxi.repositories.user_repository import UserRepository from yuxi.services.file_preview import render_file_preview +from yuxi.services.skills.edit import open_shared_skill_dir +from yuxi.services.skills.shared import lock_accessible_shared_skill_for_file from yuxi.services.workdir_service import resolve_authorized_workdir +from yuxi.storage.postgres.models_business import Skill from yuxi.utils.filepreview import ( MAX_BINARY_PREVIEW_SIZE_BYTES, OfficePreviewConversionError, @@ -48,9 +50,7 @@ def _normalize_artifact_path(workdir_path: str, path: str) -> str: return normalized -async def _require_skill_artifact_access( - *, normalized_path: str, current_uid: str, db -) -> tuple[ResolvedSkill, str] | None: +async def _require_skill_artifact_access(*, normalized_path: str, current_uid: str, db) -> tuple[Skill, str] | None: skills_prefix = f"{VIRTUAL_SKILLS_PATH}/" if not normalized_path.startswith(skills_prefix): return None @@ -58,23 +58,31 @@ async def _require_skill_artifact_access( user = await UserRepository(db).get_by_uid(str(current_uid)) if user is None or bool(user.is_deleted): raise HTTPException(status_code=403, detail="artifact access denied") - accessible = {skill.slug: skill for skill in await list_accessible_skills(db, user)} - skill = accessible.get(slug) - if skill is None: + shared = await lock_accessible_shared_skill_for_file(db, user, slug) + if shared is None: raise HTTPException(status_code=403, detail="artifact access denied") relative_path = normalized_path[len(skills_prefix) + len(slug) :].lstrip("/") if not relative_path: raise HTTPException(status_code=400, detail="artifact path is not a regular file") - return skill, relative_path + return shared, relative_path -def _copy_skill_file_to_path(skill: ResolvedSkill, relative_path: str, target_path: str, max_bytes: int) -> int: +def _copy_skill_file_to_path(skill: Skill, relative_path: str, target_path: str, max_bytes: int) -> int: """从已授权 Skill 真实来源有界复制普通文件。""" parts = tuple(PurePosixPath(relative_path).parts) if not parts or ".." in parts: raise ValueError("invalid skill artifact path") + skill_fd = open_shared_skill_dir(skill) + try: + return _copy_from_shared_skill_fd(skill_fd, parts, target_path, max_bytes) + finally: + os.close(skill_fd) + + +def _copy_from_shared_skill_fd(skill_fd: int, parts: tuple[str, ...], target_path: str, max_bytes: int) -> int: + """从已打开的共享目录复制普通文件。""" target_fd = None - with open_regular_file_fd(skill.source_dir, parts) as (source_fd, source_stat): + with open_regular_file_fd(skill_fd, parts) as (source_fd, source_stat): if source_stat.st_size > max_bytes: raise FileTransferLimitError("file exceeds transfer limit") try: diff --git a/backend/package/yuxi/services/context_compression_service.py b/backend/package/yuxi/services/context_compression_service.py index 077b38bc25..cef8e8cfba 100644 --- a/backend/package/yuxi/services/context_compression_service.py +++ b/backend/package/yuxi/services/context_compression_service.py @@ -18,13 +18,13 @@ ) from yuxi.agents.middlewares import create_summary_middleware_from_context from yuxi.agents.middlewares.token_usage import TOKEN_USAGE_CONTEXT_FIELDS -from yuxi.agents.skills.service import get_user_skills_root_dir from yuxi.repositories.agent_repository import AgentRepository from yuxi.repositories.agent_run_repository import AgentRunRepository from yuxi.repositories.agent_run_request_repository import AgentRunRequestRepository from yuxi.repositories.agent_state_repository import AgentStateRepository from yuxi.repositories.conversation_repository import ConversationRepository from yuxi.services.agent_run_service import resolve_agent_run_model_spec +from yuxi.services.skills.projection import get_user_skills_root_dir from yuxi.services.workdir_service import ensure_conversation_workdir_available from yuxi.storage.postgres.models_business import User from yuxi.utils.logging_config import logger diff --git a/backend/package/yuxi/services/run_worker.py b/backend/package/yuxi/services/run_worker.py index 629dd3998c..ef779a0a2c 100644 --- a/backend/package/yuxi/services/run_worker.py +++ b/backend/package/yuxi/services/run_worker.py @@ -17,7 +17,6 @@ from yuxi.agents.backends.sandbox.provider import get_sandbox_provider from yuxi.agents.callbacks.model_request_timing import FirstModelRequestRecorder from yuxi.agents.mcp.service import ensure_builtin_mcp_servers_in_db -from yuxi.agents.skills.service import init_builtin_skills from yuxi.config import get_int_env from yuxi.repositories.agent_run_repository import TERMINAL_RUN_STATUSES, AgentRunRepository from yuxi.services.agent_request_queue_service import ( @@ -45,6 +44,7 @@ claim_and_dispatch_due_jobs, recover_scheduled_dispatches, ) +from yuxi.services.skills.shared import init_builtin_skills from yuxi.services.task_queue_service import ( TASK_RECONCILIATION_HEALTH_KEY, TASK_RECONCILIATION_HEALTH_TTL_SECONDS, diff --git a/backend/package/yuxi/services/skills/__init__.py b/backend/package/yuxi/services/skills/__init__.py new file mode 100644 index 0000000000..639c0185f5 --- /dev/null +++ b/backend/package/yuxi/services/skills/__init__.py @@ -0,0 +1 @@ +"""Skill 安装、管理与共享投影用例。""" diff --git a/backend/package/yuxi/services/skills/catalog.py b/backend/package/yuxi/services/skills/catalog.py new file mode 100644 index 0000000000..f56478e02c --- /dev/null +++ b/backend/package/yuxi/services/skills/catalog.py @@ -0,0 +1,47 @@ +"""面向用户的个人与共享 Skill 组合查询。""" + +from __future__ import annotations + +import asyncio +from dataclasses import replace + +from sqlalchemy.ext.asyncio import AsyncSession +from yuxi.repositories.skill_repository import SkillRepository +from yuxi.services.skills.personal import list_personal_skills +from yuxi.services.skills.resolved import ResolvedSkill +from yuxi.services.skills.shared import resolved_shared_skill +from yuxi.storage.postgres.models_business import User + + +async def list_accessible_skills( + db: AsyncSession, + user: User, +) -> list[ResolvedSkill]: + """返回当前用户最终生效的共享与个人 Skill。""" + shared_items, personal_items = await asyncio.gather( + SkillRepository(db).list_enabled_readable(user), + list_personal_skills(str(user.uid)), + ) + effective = {item.slug: resolved_shared_skill(item) for item in shared_items} + for item in personal_items: + effective[item.slug] = replace(item, overrides_shared=item.slug in effective) + return list(effective.values()) + + +async def list_skill_cards_for_user( + db: AsyncSession, + user: User, +) -> list[ResolvedSkill]: + """返回管理页所需的共享与个人 Skill 卡片。""" + shared_items, personal_items = await asyncio.gather( + SkillRepository(db).list_visible_for_management(user), + list_personal_skills(str(user.uid)), + ) + personal_slugs = {item.slug for item in personal_items} + shared_slugs = {item.slug for item in shared_items} + + personal_cards = [replace(item, overrides_shared=item.slug in shared_slugs) for item in personal_items] + shared_cards = [ + replace(resolved_shared_skill(item), shadowed_by_personal=item.slug in personal_slugs) for item in shared_items + ] + return [*personal_cards, *shared_cards] diff --git a/backend/package/yuxi/services/skills/draft.py b/backend/package/yuxi/services/skills/draft.py new file mode 100644 index 0000000000..b072d2ec11 --- /dev/null +++ b/backend/package/yuxi/services/skills/draft.py @@ -0,0 +1,291 @@ +"""Skill 安装草稿的创建、读取、消费与删除。""" + +from __future__ import annotations + +import json +import re +import shutil +import tempfile +import time +import uuid +import zipfile +from dataclasses import dataclass +from io import BytesIO +from pathlib import Path, PurePosixPath +from typing import Any + +from yuxi.config import get_runtime_dir +from yuxi.services.skills.package import copy_skill_snapshot, is_valid_skill_slug +from yuxi.storage.postgres.models_business import User + +SKILL_DRAFT_TTL_SECONDS = 60 * 60 +ADMIN_ROLES = {"admin", "superadmin"} + + +@dataclass(frozen=True, slots=True) +class PreparedSkillDraftItem: + """已经验证且可交给目标来源安装的临时包。""" + + slug: str + source_dir: Path + + +async def create_uploaded_skill_draft( + *, + filename: str, + file_bytes: bytes, + operator: User, +) -> dict[str, Any]: + """解析上传文件并暂存安装草稿。""" + normalized_filename = filename.lower() + is_zip_upload = normalized_filename.endswith(".zip") + is_skill_md_upload = normalized_filename.endswith("skill.md") + if not is_zip_upload and not is_skill_md_upload: + raise ValueError("仅支持上传 .zip 或 SKILL.md 文件") + + draft_dir = get_skill_drafts_root_dir() / str(uuid.uuid4()) + items_dir = draft_dir / "items" + draft_dir.mkdir(parents=True, exist_ok=False) + items_dir.mkdir(parents=True, exist_ok=True) + + try: + with tempfile.TemporaryDirectory(prefix=".skill-prepare-", dir=str(draft_dir)) as temp_root: + extract_dir = Path(temp_root) / "extract" + extract_dir.mkdir(parents=True, exist_ok=True) + if is_zip_upload: + with zipfile.ZipFile(BytesIO(file_bytes), "r") as zf: + _validate_zip_paths(zf) + zf.extractall(extract_dir) + skill_md_files = list(extract_dir.rglob("SKILL.md")) + if len(skill_md_files) != 1: + raise ValueError("ZIP 必须且只能包含一个技能(检测到一个 SKILL.md)") + source_skill_dir = skill_md_files[0].parent + else: + source_skill_dir = extract_dir + (source_skill_dir / "SKILL.md").write_bytes(file_bytes) + + item = _stage_skill_draft_item(source_skill_dir=source_skill_dir, draft_items_dir=items_dir) + + return _write_skill_draft( + draft_dir, operator=operator, source_type="upload", source=filename, items=[item], failures=[] + ) + except Exception: + shutil.rmtree(draft_dir, ignore_errors=True) + raise + + +async def create_remote_skill_draft( + *, + source: str, + skills: list[str], + operator: User, +) -> dict[str, Any]: + """拉取远程 Skill 并暂存安装草稿。""" + from yuxi.services.skills.remote import SkillDownloadFailure, download_remote_skills + + draft_dir = get_skill_drafts_root_dir() / str(uuid.uuid4()) + items_dir = draft_dir / "items" + draft_dir.mkdir(parents=True, exist_ok=False) + items_dir.mkdir(parents=True, exist_ok=True) + + downloads = None + try: + downloads = await download_remote_skills(source=source, skills=skills) + items: list[dict[str, Any]] = [] + failures: list[dict[str, str]] = [] + seen_slugs: set[str] = set() + for result in downloads.results: + slug = result.slug + if isinstance(result, SkillDownloadFailure): + failures.append({"slug": slug, "error": result.error}) + continue + + try: + item = _stage_skill_draft_item( + source_skill_dir=result.source_dir, + draft_items_dir=items_dir, + ) + except Exception as e: + failures.append({"slug": slug, "error": str(e)}) + continue + + if item["slug"] in seen_slugs: + shutil.rmtree(draft_dir / item["source_dir"], ignore_errors=True) + failures.append({"slug": slug, "error": f"Skill slug 重复: {item['slug']}"}) + continue + seen_slugs.add(item["slug"]) + items.append(item) + + return _write_skill_draft( + draft_dir, operator=operator, source_type="remote", source=source, items=items, failures=failures + ) + except Exception: + shutil.rmtree(draft_dir, ignore_errors=True) + raise + finally: + if downloads is not None: + await downloads.cleanup() + + +async def discard_skill_install_draft(*, draft_id: str, operator: User) -> None: + """删除当前操作人可管理的安装草稿。""" + draft_dir, data = load_skill_draft(draft_id) + if data.get("created_by") != operator.uid and operator.role not in ADMIN_ROLES: + raise ValueError("无权删除该安装草稿") + shutil.rmtree(draft_dir, ignore_errors=True) + + +def load_and_select_draft_items( + draft_id: str, slugs: list[str] | None, operator: User +) -> tuple[Path, dict, list[PreparedSkillDraftItem]]: + """加载草稿并只返回结构、身份和路径均有效的可安装条目。""" + draft_dir, data = load_skill_draft(draft_id) + if not isinstance(data.get("created_by"), str) or not data["created_by"]: + raise ValueError("安装草稿元数据非法") + if data.get("created_by") != operator.uid and operator.role not in ADMIN_ROLES: + raise ValueError("无权确认该安装草稿") + if data.get("source_type") not in {"upload", "remote"}: + raise ValueError("无效的安装草稿来源") + failures = data.get("failures") + if not isinstance(failures, list) or any( + not isinstance(item, dict) or not isinstance(item.get("slug"), str) or not isinstance(item.get("error"), str) + for item in failures + ): + raise ValueError("安装草稿失败记录非法") + + raw_items = data.get("items") + if not isinstance(raw_items, list): + raise ValueError("安装草稿条目非法") + items: list[PreparedSkillDraftItem] = [] + seen_slugs: set[str] = set() + for raw_item in raw_items: + if not isinstance(raw_item, dict) or "success" in raw_item: + raise ValueError("安装草稿条目非法") + slug = raw_item.get("slug") + relative_source = raw_item.get("source_dir") + if not is_valid_skill_slug(slug) or not isinstance(relative_source, str): + raise ValueError("安装草稿条目非法") + if slug in seen_slugs: + raise ValueError("安装草稿包含重复 Skill slug") + if not re.fullmatch(r"items/[0-9a-f]{32}", relative_source): + raise ValueError("安装草稿路径非法") + source_dir = draft_dir / relative_source + if source_dir.is_symlink() or source_dir.parent.is_symlink() or not source_dir.is_dir(): + raise ValueError("安装草稿路径非法") + if not source_dir.resolve().is_relative_to(draft_dir): + raise ValueError("安装草稿路径非法") + items.append(PreparedSkillDraftItem(slug=slug, source_dir=source_dir)) + seen_slugs.add(slug) + + if slugs is not None: + selected_slugs = set(slugs) + if not selected_slugs: + raise ValueError("至少选择一个 Skill") + if selected_slugs - seen_slugs: + raise ValueError("确认安装包含草稿外的 Skill 或不可安装的 Skill") + items = [item for item in items if item.slug in selected_slugs] + + if not items: + raise ValueError("安装草稿没有可安装的 Skill") + + return draft_dir, data, items + + +def consume_installed_draft_items(draft_dir: Path, data: dict, installed_slugs: set[str]) -> None: + """移除已安装条目,保留失败和未选中的快照供后续确认。""" + if not installed_slugs: + return + remaining = [item for item in data["items"] if item["slug"] not in installed_slugs] + if not remaining: + shutil.rmtree(draft_dir, ignore_errors=True) + return + updated = {**data, "items": remaining} + metadata = draft_dir / "metadata.json" + temporary = draft_dir / f".metadata-{uuid.uuid4().hex}.tmp" + try: + temporary.write_text(json.dumps(updated, ensure_ascii=False, indent=2), encoding="utf-8") + temporary.replace(metadata) + finally: + temporary.unlink(missing_ok=True) + for item in data["items"]: + if item["slug"] in installed_slugs: + shutil.rmtree(draft_dir / item["source_dir"], ignore_errors=True) + + +def load_skill_draft(draft_id: str) -> tuple[Path, dict]: + """加载尚未过期的安装草稿及其元数据。""" + if not re.fullmatch(r"[0-9a-fA-F-]{32,36}", str(draft_id or "")): + raise ValueError("无效的安装草稿") + draft_dir = (get_skill_drafts_root_dir() / draft_id).resolve() + try: + draft_dir.relative_to(get_skill_drafts_root_dir().resolve()) + except ValueError: + raise ValueError("无效的安装草稿") from None + metadata_path = draft_dir / "metadata.json" + if metadata_path.is_symlink() or not metadata_path.is_file(): + raise ValueError("安装草稿不存在或已过期") + data = json.loads(metadata_path.read_text(encoding="utf-8")) + if not isinstance(data, dict) or not isinstance(data.get("expires_at"), (int, float)): + raise ValueError("安装草稿元数据非法") + if data["expires_at"] < time.time(): + shutil.rmtree(draft_dir, ignore_errors=True) + raise ValueError("安装草稿已过期") + return draft_dir, data + + +def get_skill_drafts_root_dir() -> Path: + """返回可丢弃的 Skill 安装草稿目录。""" + root = get_runtime_dir() / "skill_import_drafts" + root.mkdir(parents=True, exist_ok=True) + return root + + +def _write_skill_draft( + draft_dir: Path, + *, + operator: User, + source_type: str, + source: str, + items: list[dict[str, Any]], + failures: list[dict[str, str]], +) -> dict[str, Any]: + """为已暂存的条目写入统一草稿元数据并返回预览。""" + created_at = time.time() + data = { + "draft_id": draft_dir.name, + "created_by": operator.uid, + "source_type": source_type, + "source": source, + "created_at": created_at, + "expires_at": created_at + SKILL_DRAFT_TTL_SECONDS, + "items": items, + "failures": failures, + } + (draft_dir / "metadata.json").write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + return data + + +def _validate_zip_paths(zip_file: zipfile.ZipFile) -> None: + """拒绝压缩包中的越界路径。""" + for name in zip_file.namelist(): + pure = PurePosixPath(name) + if pure.is_absolute(): + raise ValueError(f"ZIP 包含不安全绝对路径: {name}") + if ".." in pure.parts: + raise ValueError(f"ZIP 包含路径穿越片段: {name}") + + +def _stage_skill_draft_item( + *, + source_skill_dir: Path, + draft_items_dir: Path, +) -> dict[str, Any]: + """暂存包内容,保留其原始 slug 供目标来源确认安装。""" + item_id = uuid.uuid4().hex + item_dir = draft_items_dir / item_id + try: + parsed = copy_skill_snapshot(source_skill_dir, item_dir) + except Exception: + shutil.rmtree(item_dir, ignore_errors=True) + raise + return {"source_dir": f"items/{item_id}", **parsed} diff --git a/backend/package/yuxi/services/skills/edit.py b/backend/package/yuxi/services/skills/edit.py new file mode 100644 index 0000000000..ba05d9ba75 --- /dev/null +++ b/backend/package/yuxi/services/skills/edit.py @@ -0,0 +1,446 @@ +"""共享 Skill 在线编辑用例。""" + +from __future__ import annotations + +import errno +import hashlib +import os +import shutil +import stat +import tempfile +import uuid +import zipfile +from collections.abc import Callable +from contextlib import ExitStack +from pathlib import Path + +import yaml +from sqlalchemy.ext.asyncio import AsyncSession +from yuxi.config import get_skill_data_dir +from yuxi.repositories.skill_repository import SkillRepository +from yuxi.services.skills.package import ( + TEXT_FILE_EXTENSIONS, + is_valid_skill_slug, + parse_skill_markdown, + split_skill_frontmatter, + validated_shared_skill_parts, + validated_skill_file_parts, +) +from yuxi.services.skills.shared import ( + get_manageable_skill_or_raise, + get_management_readable_skill_or_raise, + is_builtin_skill, + user_can_access_skill, + user_can_manage_skill, + validate_skill_dependencies, +) +from yuxi.storage.postgres.models_business import Skill, User +from yuxi.utils.paths import open_directory_fd, open_regular_file_fd + + +class SkillEditConflict(ValueError): + """文件自上次读取后已被修改。""" + + +async def edit_shared_skill_file( + db: AsyncSession, + *, + slug: str, + relative_path: str, + content: str, + expected_revision: str, + operator: User, +) -> tuple[Skill, str]: + """按预期修订值保存共享 Skill 文本文件。""" + return await _edit_shared_skill( + db, + slug=slug, + relative_path=relative_path, + update_content=lambda _previous: content, + expected_revision=expected_revision, + operator=operator, + ) + + +async def edit_shared_skill_dependencies( + db: AsyncSession, + *, + slug: str, + tool_dependencies: list[str], + mcp_dependencies: list[str], + skill_dependencies: list[str], + expected_revision: str, + operator: User, +) -> tuple[Skill, str]: + """将依赖表单写入根文件并同步数据库索引。""" + + def update_dependencies(previous: str) -> str: + """保留根文件正文,将依赖表单写入前置元数据。""" + _, _, _, frontmatter = parse_skill_markdown(previous) + frontmatter.update( + tool_dependencies=tool_dependencies, + mcp_dependencies=mcp_dependencies, + skill_dependencies=skill_dependencies, + ) + _, body = split_skill_frontmatter(previous) + return "---\n" + yaml.safe_dump(frontmatter, allow_unicode=True, sort_keys=False) + "---\n" + body + + return await _edit_shared_skill( + db, + slug=slug, + relative_path="SKILL.md", + update_content=update_dependencies, + expected_revision=expected_revision, + operator=operator, + ) + + +async def get_skill_tree(db: AsyncSession, *, slug: str, operator: User) -> list[dict[str, object]]: + """在共享行锁下读取目录树,并拒绝链接及特殊文件。""" + item = await _lock_readable_skill(db, operator, slug) + skill_fd = open_shared_skill_dir(item) + try: + return _build_tree(skill_fd) + finally: + os.close(skill_fd) + + +async def read_skill_file( + db: AsyncSession, + *, + slug: str, + relative_path: str, + operator: User, +) -> dict[str, object]: + """锁定共享来源,读取原始字节及同一版本的索引。""" + parts = _skill_path_parts(relative_path, text_only=True) + item = await _lock_readable_skill(db, operator, slug) + with ExitStack() as resources: + skill_fd = open_shared_skill_dir(item) + resources.callback(os.close, skill_fd) + try: + parent_fd = _open_parent_dir(skill_fd, parts) + resources.callback(os.close, parent_fd) + raw, _mode = _read_current_file(parent_fd, parts[-1]) + except FileNotFoundError as exc: + raise ValueError(f"文件不存在: {relative_path}") from exc + except PermissionError as exc: + raise ValueError("非法路径:不允许符号链接或特殊文件") from exc + try: + content = raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError("文件编码不支持(仅支持 UTF-8)") from exc + return { + "path": "/".join(parts), + "content": content, + "revision": hashlib.sha256(raw).hexdigest(), + "skill": ( + { + "name": item.name, + "description": item.description, + "tool_dependencies": item.tool_dependencies or [], + "mcp_dependencies": item.mcp_dependencies or [], + "skill_dependencies": item.skill_dependencies or [], + } + if parts == ("SKILL.md",) + else None + ), + } + + +async def create_skill_node( + db: AsyncSession, + *, + slug: str, + relative_path: str, + is_dir: bool, + content: str | None, + operator: User, +) -> None: + """在共享行锁下创建节点,提交失败时撤回新节点。""" + parts = _skill_path_parts(relative_path, text_only=not is_dir) + item = await get_manageable_skill_or_raise(db, operator, slug, for_update=True) + if is_builtin_skill(item): + raise ValueError("内置 skill 不允许直接修改文件") + if parts == ("SKILL.md",) and is_dir: + raise ValueError("根级 SKILL.md 必须是文本文件") + if parts == ("SKILL.md",): + await _sync_root_index(db, SkillRepository(db), item, content or "", operator) + + with ExitStack() as resources: + skill_fd = open_shared_skill_dir(item) + resources.callback(os.close, skill_fd) + parent_fd = _open_parent_dir(skill_fd, parts, create=True) + resources.callback(os.close, parent_fd) + created = False + try: + if is_dir: + os.mkdir(parts[-1], 0o755, dir_fd=parent_fd) + created = True + else: + file_fd = os.open( + parts[-1], os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644, dir_fd=parent_fd + ) + created = True + resources.callback(os.close, file_fd) + with os.fdopen(file_fd, "wb", closefd=False) as stream: + stream.write((content or "").encode("utf-8")) + stream.flush() + os.fsync(file_fd) + os.fsync(parent_fd) + await db.commit() + except Exception: + if created: + if is_dir: + os.rmdir(parts[-1], dir_fd=parent_fd) + else: + os.unlink(parts[-1], dir_fd=parent_fd) + raise + + +async def delete_skill_node( + db: AsyncSession, + *, + slug: str, + relative_path: str, + operator: User, +) -> None: + """把待删除节点移出 Skill 目录,提交失败时恢复。""" + parts = _skill_path_parts(relative_path) + if parts == ("SKILL.md",): + raise ValueError("不允许删除根目录 SKILL.md") + item = await get_manageable_skill_or_raise(db, operator, slug, for_update=True) + if is_builtin_skill(item): + raise ValueError("内置 skill 不允许直接修改文件") + with ExitStack() as resources: + skill_fd = open_shared_skill_dir(item) + resources.callback(os.close, skill_fd) + parent_fd = _open_parent_dir(skill_fd, parts) + resources.callback(os.close, parent_fd) + entry = os.stat(parts[-1], dir_fd=parent_fd, follow_symlinks=False) + if not (stat.S_ISDIR(entry.st_mode) or stat.S_ISREG(entry.st_mode)): + raise ValueError("Skill 文件路径非法") + staging_fd = _open_staging_dir() + trash = f".deleted-{slug}-{uuid.uuid4().hex}" + resources.callback(os.close, staging_fd) + os.rename(parts[-1], trash, src_dir_fd=parent_fd, dst_dir_fd=staging_fd) + try: + os.fsync(parent_fd) + await db.commit() + except Exception: + os.rename(trash, parts[-1], src_dir_fd=staging_fd, dst_dir_fd=parent_fd) + raise + if stat.S_ISDIR(entry.st_mode): + shutil.rmtree(trash, dir_fd=staging_fd) + else: + os.unlink(trash, dir_fd=staging_fd) + + +async def export_skill_zip(db: AsyncSession, *, slug: str, operator: User) -> tuple[str, str]: + """在共享行锁下逐个 no-follow 读取并打包文件。""" + item = await _lock_readable_skill(db, operator, slug) + if not user_can_manage_skill(operator, item): + raise ValueError(f"技能 '{slug}' 不存在或无权管理") + with ExitStack() as resources: + skill_fd = open_shared_skill_dir(item) + resources.callback(os.close, skill_fd) + export_fd, export_path = tempfile.mkstemp(prefix=f"skill-{slug}-", suffix=".zip") + os.close(export_fd) + try: + with zipfile.ZipFile(export_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + _write_archive_tree(archive, skill_fd, slug) + except Exception: + Path(export_path).unlink(missing_ok=True) + raise + return export_path, f"{slug}.zip" + + +def open_shared_skill_dir(item: Skill) -> int: + """从可信共享根逐层 no-follow 打开数据库指向的目录。""" + return open_directory_fd(Path(get_skill_data_dir()), validated_shared_skill_parts(item.slug, item.dir_path)) + + +async def _edit_shared_skill( + db: AsyncSession, + *, + slug: str, + relative_path: str, + update_content: Callable[[str], str], + expected_revision: str, + operator: User, +) -> tuple[Skill, str]: + """在同一行锁下校验、发布文件并提交数据库索引。""" + if not is_valid_skill_slug(slug): + raise ValueError("无效 skill slug") + if not expected_revision: + raise ValueError("缺少文件修订值,请重新加载后保存") + parts = _skill_path_parts(relative_path, text_only=True) + repo = SkillRepository(db) + item = await repo.get_by_slug(slug, for_update=True) + if item is None or not user_can_manage_skill(operator, item): + raise ValueError(f"技能 '{slug}' 不存在或无权管理") + if is_builtin_skill(item): + raise ValueError("内置 skill 不允许直接修改文件") + + with ExitStack() as resources: + skill_fd = open_shared_skill_dir(item) + resources.callback(os.close, skill_fd) + parent_fd = _open_parent_dir(skill_fd, parts) + resources.callback(os.close, parent_fd) + staging_fd = _open_staging_dir() + resources.callback(os.close, staging_fd) + try: + previous, mode = _read_current_file(parent_fd, parts[-1]) + except FileNotFoundError as exc: + raise ValueError("文件不存在") from exc + except PermissionError as exc: + raise ValueError("非法路径:不允许符号链接或特殊文件") from exc + if hashlib.sha256(previous).hexdigest() != expected_revision: + raise SkillEditConflict("文件已被其他编辑更新,请复制当前草稿后重新加载") + content = update_content(previous.decode("utf-8")) + new_bytes = content.encode("utf-8") + if parts == ("SKILL.md",): + await _sync_root_index(db, repo, item, content, operator) + + try: + _replace_file(staging_fd, parent_fd, parts[-1], new_bytes, mode) + await db.commit() + except Exception: + _replace_file(staging_fd, parent_fd, parts[-1], previous, mode) + raise + return item, hashlib.sha256(new_bytes).hexdigest() + + +async def _lock_readable_skill(db: AsyncSession, operator: User, slug: str) -> Skill: + """锁前筛候选,锁后重新检查读取或管理权限。""" + candidate = await get_management_readable_skill_or_raise(db, operator, slug) + item = await SkillRepository(db).get_by_slug_for_read(candidate.slug) + if item is None or (not user_can_manage_skill(operator, item) and not user_can_access_skill(operator, item)): + raise ValueError(f"技能 '{slug}' 不存在或无权访问") + return item + + +def _skill_path_parts(relative_path: str, *, text_only: bool = False) -> tuple[str, ...]: + """校验共享 Skill 根下的相对路径组件。""" + parts = validated_skill_file_parts(relative_path) + if text_only and Path(parts[-1]).suffix.lower() not in TEXT_FILE_EXTENSIONS: + raise ValueError("仅支持编辑文本文件") + return parts + + +def _open_staging_dir() -> int: + """打开共享 Skill 根,用于目录外的发布与补偿。""" + return open_directory_fd(Path(get_skill_data_dir()), ("shared",)) + + +def _open_parent_dir(skill_fd: int, parts: tuple[str, ...], *, create: bool = False) -> int: + """打开相对父目录,并将链接路径统一映射为输入错误。""" + try: + return open_directory_fd(skill_fd, parts[:-1], create=create) + except OSError as exc: + if exc.errno in {errno.ELOOP, errno.ENOTDIR}: + raise ValueError("非法路径:不允许符号链接") from exc + raise + + +def _read_current_file(parent_fd: int, filename: str) -> tuple[bytes, int]: + """从可信目录读取普通文件与其权限位。""" + with open_regular_file_fd(parent_fd, (filename,)) as (file_fd, file_stat): + chunks = [] + while chunk := os.read(file_fd, 1024 * 1024): + chunks.append(chunk) + return b"".join(chunks), stat.S_IMODE(file_stat.st_mode) + + +def _replace_file(staging_fd: int, parent_fd: int, filename: str, content: bytes, mode: int) -> None: + """在 Skill 目录外暂存后原子替换文件。""" + temporary = f".{filename}.{uuid.uuid4().hex}.tmp" + file_fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, mode, dir_fd=staging_fd) + try: + os.fchmod(file_fd, mode) + with os.fdopen(file_fd, "wb", closefd=False) as stream: + stream.write(content) + stream.flush() + os.fsync(file_fd) + os.replace(temporary, filename, src_dir_fd=staging_fd, dst_dir_fd=parent_fd) + os.fsync(parent_fd) + finally: + os.close(file_fd) + try: + os.unlink(temporary, dir_fd=staging_fd) + except FileNotFoundError: + pass + + +def _build_tree(directory_fd: int, prefix: str = "") -> list[dict[str, object]]: + """从已打开目录生成拒绝符号链接的文件树。""" + children = [] + for name in os.listdir(directory_fd): + mode = os.stat(name, dir_fd=directory_fd, follow_symlinks=False).st_mode + if not (stat.S_ISDIR(mode) or stat.S_ISREG(mode)): + raise ValueError("Skill 来源包含链接或特殊文件") + path = f"{prefix}/{name}" if prefix else name + if stat.S_ISDIR(mode): + child_fd = open_directory_fd(directory_fd, (name,)) + try: + children.append({"name": name, "path": path, "is_dir": True, "children": _build_tree(child_fd, path)}) + finally: + os.close(child_fd) + else: + children.append({"name": name, "path": path, "is_dir": False}) + return sorted(children, key=lambda child: (not child["is_dir"], str(child["name"]).lower())) + + +def _write_archive_tree(archive: zipfile.ZipFile, directory_fd: int, prefix: str) -> None: + """从目录 fd 向 ZIP 写入普通文件及空目录。""" + for name in sorted(os.listdir(directory_fd)): + mode = os.stat(name, dir_fd=directory_fd, follow_symlinks=False).st_mode + path = f"{prefix}/{name}" + if stat.S_ISDIR(mode): + child_fd = open_directory_fd(directory_fd, (name,)) + try: + archive.writestr(f"{path}/", b"") + _write_archive_tree(archive, child_fd, path) + finally: + os.close(child_fd) + elif stat.S_ISREG(mode): + with open_regular_file_fd(directory_fd, (name,)) as (file_fd, _file_stat): + with archive.open(path, "w") as output: + while chunk := os.read(file_fd, 1024 * 1024): + output.write(chunk) + else: + raise ValueError("Skill 来源包含链接或特殊文件") + + +async def _sync_root_index(db: AsyncSession, repo: SkillRepository, item: Skill, content: str, operator: User) -> None: + """根文件发布前校验元数据和平台依赖,并更新同一行索引。""" + parsed_slug, name, description, meta = parse_skill_markdown(content) + if parsed_slug != item.slug: + raise ValueError("SKILL.md frontmatter.slug 必须与 skill slug 一致") + for key in ("tool_dependencies", "mcp_dependencies", "skill_dependencies"): + value = meta.get(key, []) + if not isinstance(value, list) or any(not isinstance(entry, str) for entry in value): + raise ValueError(f"{key} 必须是字符串列表") + available = {skill.slug: skill for skill in await SkillRepository(db).list_enabled_readable(operator)} + tools, mcps, skills = await validate_skill_dependencies( + parent=item, + tool_dependencies=meta.get("tool_dependencies") or [], + mcp_dependencies=meta.get("mcp_dependencies") or [], + skill_dependencies=meta.get("skill_dependencies") or [], + available_skills=available, + ) + for key, normalized in ( + ("tool_dependencies", tools), + ("mcp_dependencies", mcps), + ("skill_dependencies", skills), + ): + if key in meta and meta[key] != normalized: + raise ValueError(f"{key} 含重复或空值") + await repo.update_metadata(item, name=name, description=description, updated_by=operator.uid) + await repo.update_dependencies( + item, + tool_dependencies=tools, + mcp_dependencies=mcps, + skill_dependencies=skills, + updated_by=operator.uid, + ) diff --git a/backend/package/yuxi/services/skills/package.py b/backend/package/yuxi/services/skills/package.py new file mode 100644 index 0000000000..3e5f8459ac --- /dev/null +++ b/backend/package/yuxi/services/skills/package.py @@ -0,0 +1,229 @@ +"""Skill 包格式与安全复制原语。""" + +from __future__ import annotations + +import re +import shutil +from pathlib import Path, PurePosixPath +from typing import Any + +import yaml + +SKILL_SLUG_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") + +TEXT_FILE_EXTENSIONS = { + ".md", + ".txt", + ".py", + ".js", + ".ts", + ".json", + ".yaml", + ".yml", + ".toml", + ".ini", + ".cfg", + ".conf", + ".xml", + ".html", + ".css", + ".sql", + ".sh", + ".bat", + ".ps1", + ".env", + ".csv", + ".tsv", + ".rst", + ".ipynb", + ".vue", + ".jsx", + ".tsx", +} + + +def parse_skill_dir_metadata(source_skill_dir: Path) -> dict[str, Any]: + """读取目录中的根文件并提取 Skill 元数据。""" + skill_md_path = source_skill_dir / "SKILL.md" + if not skill_md_path.is_file(): + raise ValueError("技能目录缺少根级 SKILL.md") + + content = skill_md_path.read_text(encoding="utf-8") + parsed_slug, parsed_name, parsed_desc, meta = parse_skill_markdown(content) + return { + "slug": parsed_slug, + "name": parsed_name, + "description": parsed_desc, + "tool_dependencies": normalize_string_list(meta.get("tool_dependencies")), + "mcp_dependencies": normalize_string_list(meta.get("mcp_dependencies")), + "skill_dependencies": normalize_string_list(meta.get("skill_dependencies")), + } + + +def parse_skill_markdown(content: str) -> tuple[str, str, str, dict[str, Any]]: + """解析并校验 Skill 根文件的前置元数据。""" + frontmatter_raw, _body = split_skill_frontmatter(content) + data = _load_skill_frontmatter(frontmatter_raw) + + name = _validate_skill_display_name(str(data.get("name", ""))) + raw_slug = str(data.get("slug", "")).strip() + slug = ( + _validate_skill_slug_value(raw_slug, field_name="slug") + if raw_slug + else _validate_skill_slug_value(name, field_name="name") + ) + description = str(data.get("description", "")).strip() + if not description: + raise ValueError("SKILL.md frontmatter 缺少 description") + + return slug, name, description, data + + +def copy_skill_snapshot( + source_dir: Path, + target_dir: Path, + *, + expected_slug: str | None = None, + final_slug: str | None = None, +) -> dict[str, Any]: + """复制并解析 Skill staging,可校验来源或重写最终 slug。""" + copy_skill_tree_no_symlinks(source_dir, target_dir) + parsed = parse_skill_dir_metadata(target_dir) + if expected_slug and parsed["slug"] != expected_slug: + raise ValueError("Skill slug 在复制过程中发生变化") + if final_slug and parsed["slug"] != final_slug: + skill_md = target_dir / "SKILL.md" + skill_md.write_text( + rewrite_frontmatter_slug(skill_md.read_text(encoding="utf-8"), final_slug), + encoding="utf-8", + ) + return parsed + + +def copy_skill_tree_no_symlinks(source_dir: Path, target_dir: Path) -> None: + """复制不含符号链接的 Skill 目录到 staging。""" + if source_dir.is_symlink(): + raise ValueError(f"Skill 来源只允许普通文件和目录: {source_dir}") + source_dir = source_dir.resolve() + if not source_dir.is_dir(): + raise FileNotFoundError(source_dir) + if skill_tree_contains_symlink(source_dir): + raise ValueError(f"Skill 来源只允许普通文件和目录: {source_dir}") + try: + shutil.copytree(source_dir, target_dir, symlinks=False) + except BaseException: + shutil.rmtree(target_dir, ignore_errors=True) + raise + + +def rewrite_frontmatter_slug(content: str, new_slug: str) -> str: + """在安装快照中写入最终 Skill slug。""" + frontmatter_raw, body = split_skill_frontmatter(content) + data = _load_skill_frontmatter(frontmatter_raw) + if data.get("slug"): + data["slug"] = new_slug + else: + data["name"] = new_slug + dumped = yaml.safe_dump(data, sort_keys=False, allow_unicode=True).strip() + return f"---\n{dumped}\n---\n{body}" + + +def validated_skill_file_parts(relative_path: str, *, error_message: str = "非法 Skill 文件路径") -> tuple[str, ...]: + """校验 Skill 根下的相对文件路径并返回组件。""" + if not relative_path or relative_path.startswith("/") or "\\" in relative_path: + raise ValueError(error_message) + parts = PurePosixPath(relative_path).parts + if not parts or any(part in {".", ".."} for part in parts): + raise ValueError(error_message) + return parts + + +def validated_shared_skill_parts(slug: str, dir_path: str) -> tuple[str, str]: + """确认数据库共享来源只指向自身 slug 对应的持久目录。""" + if not is_valid_skill_slug(slug) or dir_path != f"shared/{slug}": + raise ValueError("Skill 来源目录非法") + return "shared", slug + + +def split_skill_frontmatter(content: str) -> tuple[str, str]: + """拆分 Skill 根文件的前置元数据与正文。""" + lines = content.splitlines(keepends=True) + if not content.startswith("---") or not lines or lines[0].strip() != "---": + raise ValueError("SKILL.md 缺少有效 frontmatter(--- ... ---)") + for index, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + return "".join(lines[1:index]), "".join(lines[index + 1 :]) + raise ValueError("SKILL.md 缺少有效 frontmatter(--- ... ---)") + + +def normalize_string_list(values: list[str] | None) -> list[str]: + """去除字符串列表中的空值和重复项。""" + if not values: + return [] + normalized: list[str] = [] + seen: set[str] = set() + for value in values: + if not isinstance(value, str): + continue + item = value.strip() + if not item or item in seen: + continue + seen.add(item) + normalized.append(item) + return normalized + + +def is_valid_skill_slug(slug: str) -> bool: + """判断名称是否符合 Skill slug 格式。""" + if not isinstance(slug, str): + return False + return SKILL_SLUG_PATTERN.fullmatch(slug) is not None + + +def skill_tree_contains_symlink(path: Path) -> bool: + """检查目录内是否包含任意符号链接子路径。""" + return any(child.is_symlink() for child in path.rglob("*")) + + +def _load_skill_frontmatter(raw: str) -> dict[str, Any]: + """兼容未引用且含冒号的多行 description。""" + try: + data = yaml.safe_load(raw) + except yaml.YAMLError as error: + folded = re.sub( + r"(?m)^description:[ \t]*(\r?\n)(?=[ \t]+\S)", + lambda match: f"description: >-{match.group(1)}", + raw, + count=1, + ) + if folded == raw: + raise ValueError(f"SKILL.md frontmatter YAML 解析失败: {error}") from error + try: + data = yaml.safe_load(folded) + except yaml.YAMLError as folded_error: + raise ValueError(f"SKILL.md frontmatter YAML 解析失败: {folded_error}") from folded_error + if not isinstance(data, dict): + raise ValueError("SKILL.md frontmatter 必须是对象") + return data + + +def _validate_skill_slug_value(slug: str, *, field_name: str) -> str: + """校验根文件中用于生成 slug 的值。""" + slug = slug.strip() + if not slug: + raise ValueError(f"SKILL.md frontmatter 缺少 {field_name}") + if len(slug) > 128: + raise ValueError(f"SKILL.md frontmatter.{field_name} 长度不能超过 128") + if not SKILL_SLUG_PATTERN.match(slug): + raise ValueError(f"SKILL.md frontmatter.{field_name} 必须是小写字母/数字/短横线,且不能连续短横线") + return slug + + +def _validate_skill_display_name(name: str) -> str: + """校验根文件中的显示名称。""" + name = name.strip() + if not name: + raise ValueError("SKILL.md frontmatter 缺少 name") + if len(name) > 128: + raise ValueError("SKILL.md frontmatter.name 长度不能超过 128") + return name diff --git a/backend/package/yuxi/services/skills/personal.py b/backend/package/yuxi/services/skills/personal.py new file mode 100644 index 0000000000..8da2b2c57c --- /dev/null +++ b/backend/package/yuxi/services/skills/personal.py @@ -0,0 +1,290 @@ +"""个人 Skill 的草稿确认、文件操作与持久来源边界。""" + +from __future__ import annotations + +import asyncio +import os +import shutil +import tempfile +import uuid +from pathlib import Path +from typing import Any + +from yuxi.agents.backends.paths import VIRTUAL_PATH_PREFIX +from yuxi.agents.backends.sandbox.download import download_sandbox_directory +from yuxi.services.skills.draft import consume_installed_draft_items, load_and_select_draft_items +from yuxi.services.skills.package import ( + TEXT_FILE_EXTENSIONS, + copy_skill_snapshot, + is_valid_skill_slug, + parse_skill_dir_metadata, + skill_tree_contains_symlink, + validated_skill_file_parts, +) +from yuxi.services.skills.resolved import ResolvedSkill +from yuxi.storage.postgres.models_business import User +from yuxi.utils.logging_config import logger +from yuxi.utils.paths import ensure_within_root, open_regular_file_fd +from yuxi.workspace.paths import ensure_user_workspace, user_workspace_dir + +PERSONAL_SKILL_SOURCE_TYPE = "personal" + + +async def confirm_personal_skill_install_draft( + *, + draft_id: str, + slugs: list[str] | None, + operator: User, +) -> list[dict[str, Any]]: + """确认草稿并将选中 Skill 安装到当前用户个人持久源。""" + draft_dir, data, draft_items = load_and_select_draft_items(draft_id, slugs, operator) + + results: list[dict[str, Any]] = [] + for draft_item in draft_items: + slug = draft_item.slug + try: + item = await install_personal_skill_dir( + str(operator.uid), + draft_item.source_dir, + expected_slug=slug, + ) + results.append( + { + "slug": item.slug, + "requested_slug": slug, + "success": True, + "skill": item.to_dict(), + } + ) + except Exception as exc: + results.append( + { + "slug": slug, + "requested_slug": slug, + "success": False, + "error": str(exc), + } + ) + + consume_installed_draft_items(draft_dir, data, {item["requested_slug"] for item in results if item["success"]}) + return results + + +async def install_personal_skills_from_source( + *, + uid: str, + thread_id: str, + source: str, + skill_names: list[str] | None = None, + workdir_relative_path: str | None = None, + workdir_path: str | None = None, +) -> tuple[list[str], list[dict[str, str]]]: + """获取沙盒或远程包并安装到用户个人来源,汇总逐项结果。""" + source = source.strip() + if not source: + raise ValueError("Skill 来源不能为空") + if source.startswith("/"): + with tempfile.TemporaryDirectory(prefix=".skill-install-") as tmp: + source_dir = await asyncio.to_thread( + _download_sandbox_skill, source, thread_id, uid, Path(tmp), workdir_relative_path, workdir_path + ) + item = await install_personal_skill_dir(uid, source_dir) + return [item.slug], [] + if not skill_names: + raise ValueError("从 Git 安装时必须通过 skill_names 指定技能名称") + + from yuxi.services.skills.remote import SkillDownloadFailure, download_remote_skills + + installed: list[str] = [] + failures: list[dict[str, str]] = [] + downloads = await download_remote_skills(source=source, skills=skill_names) + try: + for result in downloads.results: + if isinstance(result, SkillDownloadFailure): + failures.append({"slug": result.slug, "error": result.error}) + continue + try: + item = await install_personal_skill_dir(uid, result.source_dir) + installed.append(item.slug) + except Exception as exc: + failures.append({"slug": result.slug, "error": str(exc)}) + finally: + await downloads.cleanup() + return installed, failures + + +async def list_personal_skills(uid: str) -> list[ResolvedSkill]: + """直接扫描个人 Skill 持久目录。""" + return await asyncio.to_thread(_scan_personal_skills, uid) + + +async def install_personal_skill_dir( + uid: str, + source_dir: Path | str, + *, + expected_slug: str | None = None, +) -> ResolvedSkill: + """将一个 Skill 原子安装到当前用户个人持久源。""" + return await asyncio.to_thread( + _install_personal_skill_dir_sync, + uid, + Path(source_dir), + expected_slug=expected_slug, + ) + + +async def read_personal_skill_file(uid: str, slug: str, relative_path: str) -> dict[str, Any]: + """读取个人 Skill 中的文本文件。""" + skill_dir = _resolve_personal_skill_dir(_personal_skills_root(uid), slug) + parts = validated_skill_file_parts(relative_path, error_message="个人 Skill 文件路径非法") + if Path(parts[-1]).suffix.lower() not in TEXT_FILE_EXTENSIONS: + raise ValueError("仅支持读取文本文件") + try: + with open_regular_file_fd(skill_dir, parts) as (file_fd, _file_stat): + with os.fdopen(os.dup(file_fd), encoding="utf-8") as stream: + content = stream.read() + except FileNotFoundError as exc: + raise ValueError("文件不存在") from exc + except PermissionError as exc: + raise ValueError("个人 Skill 文件路径非法") from exc + except UnicodeDecodeError as exc: + raise ValueError("文件编码不支持(仅支持 UTF-8)") from exc + return {"path": "/".join(parts), "content": content} + + +async def delete_personal_skill(uid: str, slug: str) -> None: + """删除当前用户个人 Skill。""" + skill_dir = _resolve_personal_skill_dir(_personal_skills_root(uid), slug) + if not skill_dir.is_dir(): + raise ValueError("个人 Skill 不存在") + await asyncio.to_thread(shutil.rmtree, skill_dir) + + +def _scan_personal_skills(uid: str) -> list[ResolvedSkill]: + """扫描并校验当前用户个人 Skill 的直接子目录。""" + items: list[ResolvedSkill] = [] + root = _personal_skills_root(uid) + for entry in sorted(root.iterdir(), key=lambda path: path.name): + if entry.is_symlink() or not entry.is_dir() or not is_valid_skill_slug(entry.name): + logger.warning(f"跳过非法个人 Skill 目录: uid={uid}, name={entry.name}") + continue + if skill_tree_contains_symlink(entry): + logger.warning(f"跳过包含符号链接的个人 Skill: uid={uid}, slug={entry.name}") + continue + try: + metadata = parse_skill_dir_metadata(entry) + if metadata["slug"] != entry.name: + raise ValueError("目录名必须与 SKILL.md slug 一致") + items.append(_resolved_personal_skill(uid, root, metadata)) + except Exception as exc: + logger.warning(f"跳过无法解析的个人 Skill: uid={uid}, slug={entry.name}, error={exc}") + return items + + +def _install_personal_skill_dir_sync( + uid: str, + source_dir: Path, + *, + expected_slug: str | None = None, +) -> ResolvedSkill: + """将一个 Skill 原子复制到个人目录。""" + source_dir = source_dir.resolve() + root = _personal_skills_root(uid) + temp_target = root / f".install.tmp-{uuid.uuid4().hex[:8]}" + try: + metadata = copy_skill_snapshot(source_dir, temp_target, expected_slug=expected_slug) + slug = metadata["slug"] + target_dir = root / slug + if target_dir.exists() or target_dir.is_symlink(): + raise ValueError(f"个人 Skill 源已存在同名 Skill: {slug}") + try: + temp_target.rename(target_dir) + except OSError as exc: + if target_dir.exists(): + raise ValueError(f"个人 Skill 源已存在同名 Skill: {slug}") from exc + raise + finally: + if temp_target.exists(): + shutil.rmtree(temp_target, ignore_errors=True) + return _resolved_personal_skill(uid, root, metadata) + + +def _resolved_personal_skill(uid: str, root: Path, metadata: dict[str, Any]) -> ResolvedSkill: + """将个人目录元数据适配为不含共享语义的有效 Skill 描述。""" + slug = metadata["slug"] + source_dir = root / slug + return ResolvedSkill( + id=f"personal:{slug}", + slug=slug, + name=metadata["name"], + description=metadata["description"], + source_type=PERSONAL_SKILL_SOURCE_TYPE, + source_scope=PERSONAL_SKILL_SOURCE_TYPE, + source_dir=source_dir, + enabled=True, + created_by=uid, + share_config=None, + tool_dependencies=[], + mcp_dependencies=[], + skill_dependencies=[], + ) + + +def _personal_skills_root(uid: str) -> Path: + """返回已创建且位于当前用户工作区内的个人 Skill 根。""" + ensure_user_workspace(uid) + workspace = user_workspace_dir(uid) + workspace_root = workspace.resolve() + root = ensure_within_root( + (workspace / "agents" / "skills").resolve(), workspace_root, error_message="个人 Skill 路径越界" + ) + root.mkdir(parents=True, exist_ok=True) + return root + + +def _resolve_personal_skill_dir(root: Path, slug: str) -> Path: + """安全解析固定根下的个人 Skill 目录。""" + if not is_valid_skill_slug(slug): + raise ValueError("无效 skill slug") + target = root / slug + if target.is_symlink(): + raise ValueError("个人 Skill 路径非法") + return target + + +def _download_sandbox_skill( + sandbox_path: str, + thread_id: str, + uid: str, + staging_root: Path, + workdir_relative_path: str | None = None, + workdir_path: str | None = None, +) -> Path: + """从当前用户沙盒下载 Skill 目录到本地暂存区。""" + from yuxi.agents.backends.sandbox import ProvisionerSandboxBackend + + allowed = sandbox_path.startswith(f"{VIRTUAL_PATH_PREFIX.rstrip('/')}/") + allowed = allowed or bool(workdir_path and sandbox_path.startswith(f"{workdir_path.rstrip('/')}/")) + if not allowed: + raise ValueError( + f"不支持的沙盒路径: {sandbox_path}。请使用当前 Project Workdir 下的目录,或 /home/gem/user-data/..." + ) + + staging = staging_root / "package" + backend = ProvisionerSandboxBackend( + thread_id=thread_id, + uid=uid, + workdir_path=workdir_relative_path, + create_if_missing=True, + ) + download_sandbox_directory( + backend, + sandbox_path, + staging, + empty_message=f"沙盒路径 {sandbox_path} 中未发现可下载文件", + ) + if not (staging / "SKILL.md").exists(): + shutil.rmtree(staging, ignore_errors=True) + raise ValueError(f"沙盒路径 {sandbox_path} 中未找到 SKILL.md") + + return staging diff --git a/backend/package/yuxi/services/skills/projection.py b/backend/package/yuxi/services/skills/projection.py new file mode 100644 index 0000000000..ca2863b509 --- /dev/null +++ b/backend/package/yuxi/services/skills/projection.py @@ -0,0 +1,233 @@ +"""用户共享 Skill 投影的授权快照与目录发布用例。""" + +from __future__ import annotations + +import asyncio +import fcntl +import hashlib +import os +import shutil +import stat +import threading +import uuid +from contextlib import contextmanager +from pathlib import Path + +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession +from yuxi.config import get_skill_data_dir, get_skill_projection_dir +from yuxi.permissions import ResourcePermission, resolve_skill_permission +from yuxi.repositories.skill_repository import SkillRepository +from yuxi.services.skills.package import ( + copy_skill_tree_no_symlinks, + is_valid_skill_slug, + validated_shared_skill_parts, +) +from yuxi.storage.postgres.models_business import Skill, User +from yuxi.utils.logging_config import logger +from yuxi.utils.paths import open_directory_fd, open_regular_file_fd + +_USER_SKILLS_LOCK = threading.Lock() +_USER_SKILLS_LOCKS: dict[str, threading.Lock] = {} +_USER_SKILL_PROJECTION_LOCK_SCOPE = "yuxi:skills:user-projection:v1:" + + +async def refresh_user_skill_projection_async(uid: str) -> dict[str, str]: + """按数据库中的最新授权快照重建用户共享 Skill 投影。""" + from yuxi.repositories.user_repository import UserRepository + from yuxi.storage.postgres.manager import pg_manager + + normalized_uid = str(uid or "").strip() + if not normalized_uid: + raise ValueError("uid is required to refresh the user Skill projection") + + async with pg_manager.get_async_session_context() as db: + user = await UserRepository().get_by_uid_with_db(db, normalized_uid) + if user is None or bool(user.is_deleted): + source_dirs: dict[str, str] = {} + else: + source_dirs = { + item.slug: str(_resolve_shared_skill_dir(item)) + for item in await lock_accessible_shared_skills_for_projection(db, user) + if item.enabled and item.slug + } + await db.execute( + text("SELECT pg_advisory_xact_lock(hashtext(:lock_scope))"), + {"lock_scope": f"{_USER_SKILL_PROJECTION_LOCK_SCOPE}{normalized_uid}"}, + ) + await asyncio.to_thread(sync_user_accessible_skills, normalized_uid, source_dirs) + return source_dirs + + +async def commit_skill_policy_and_refresh_projections(db: AsyncSession, slug: str) -> None: + """提交 Skill 授权变更,并同步所有已存在的 uid 投影。""" + from yuxi.workspace.paths import workspace_uid_dirname + + result = await db.execute(select(User.uid).where(User.is_deleted == 0).order_by(User.id)) + projection_root = get_skill_projection_dir() + uids = [str(uid) for uid in result.scalars().all() if (projection_root / workspace_uid_dirname(str(uid))).is_dir()] + for uid in uids: + await db.execute( + text("SELECT pg_advisory_xact_lock(hashtext(:lock_scope))"), + {"lock_scope": f"{_USER_SKILL_PROJECTION_LOCK_SCOPE}{uid}"}, + ) + for uid in uids: + await asyncio.to_thread(_remove_skill_from_user_projection, uid, slug) + await db.commit() + for uid in uids: + await refresh_user_skill_projection_async(uid) + + +def sync_user_accessible_skills( + uid: str, + source_dirs: dict[str, str | Path], +) -> Path: + """将用户有权访问的共享 Skill 来源同步到统一只读目录。""" + user_skills_root = get_user_skills_root_dir(uid) + normalized_sources = { + slug: Path(os.path.abspath(os.fspath(path))) + for slug, path in source_dirs.items() + if is_valid_skill_slug(slug) and isinstance(path, (str, Path)) + } + accessible_slugs = set(normalized_sources) + with _get_user_skills_lock(uid), _user_skills_file_lock(uid): + for entry in user_skills_root.iterdir(): + if entry.name in accessible_slugs: + continue + _remove_skill_projection_entry(entry) + + for slug, source_dir in normalized_sources.items(): + target_dir = user_skills_root / slug + temp_target = user_skills_root / f".{slug}.tmp-{uuid.uuid4().hex[:8]}" + try: + if skill_dirs_equal(source_dir, target_dir): + continue + copy_skill_tree_no_symlinks(source_dir, temp_target) + _remove_skill_projection_entry(target_dir) + temp_target.rename(target_dir) + except FileNotFoundError: + logger.warning(f"跳过不存在的 Skill 来源: slug={slug}") + _remove_skill_projection_entry(target_dir) + except (OSError, ValueError): + _remove_skill_projection_entry(target_dir) + raise + finally: + if temp_target.exists(): + shutil.rmtree(temp_target, ignore_errors=True) + + return user_skills_root + + +async def lock_accessible_shared_skills_for_projection(db: AsyncSession, user: User) -> list[Skill]: + """先锁定授权候选,再供投影按最新启停状态复制文件。""" + repo = SkillRepository(db) + visible = await repo.list_authorized_for_projection(user) + locked = {item.id: item for item in await repo.lock_rows_for_read([item.id for item in visible])} + return [ + locked[item.id] + for item in visible + if item.id in locked and resolve_skill_permission(user, locked[item.id]) != ResourcePermission.NONE + ] + + +def get_user_skills_root_dir(uid: str) -> Path: + """返回当前用户获授权的共享 Skill 只读投影根目录。""" + from yuxi.workspace.paths import workspace_uid_dirname + + safe_uid = workspace_uid_dirname(uid) + root = get_skill_projection_dir() / safe_uid + root.mkdir(parents=True, exist_ok=True) + return root + + +def skill_dirs_equal(dir1: Path, dir2: Path) -> bool: + """按 no-follow 字节与执行位比较来源和投影,非法来源显式失败。""" + source_hash = _compute_projection_hash(dir1) + try: + return source_hash == _compute_projection_hash(dir2) + except OSError: + # 缺失或被替换为链接的投影必须重建,不能沿用相同字节的链接。 + return False + + +def _get_user_skills_lock(uid: str) -> threading.Lock: + """取得当前进程内按用户划分的投影锁。""" + with _USER_SKILLS_LOCK: + lock = _USER_SKILLS_LOCKS.get(uid) + if lock is None: + lock = threading.Lock() + _USER_SKILLS_LOCKS[uid] = lock + return lock + + +@contextmanager +def _user_skills_file_lock(uid: str): + """在共享投影卷上串行化同一用户的目录替换。""" + from yuxi.workspace.paths import workspace_uid_dirname + + lock_dir = get_skill_projection_dir() / ".locks" + lock_dir.mkdir(parents=True, exist_ok=True) + lock_path = lock_dir / f"{workspace_uid_dirname(uid)}.lock" + with lock_path.open("a+b") as lock_file: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + + +def _remove_skill_from_user_projection(uid: str, slug: str) -> None: + """从一个已物化 uid 投影移除 Skill,授权变更时保持 fail-closed。""" + if not is_valid_skill_slug(slug): + raise ValueError("无效 skill slug") + with _get_user_skills_lock(uid), _user_skills_file_lock(uid): + _remove_skill_projection_entry(get_user_skills_root_dir(uid) / slug) + + +def _remove_skill_projection_entry(path: Path) -> None: + """删除一个投影条目,不跟随可能存在的符号链接。""" + if not path.exists() and not path.is_symlink(): + return + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + else: + path.unlink() + + +def _compute_projection_hash(path: Path) -> bytes: + """通过目录 fd 读取投影比较摘要,拒绝链接和特殊文件。""" + hasher = hashlib.sha256() + + def visit(directory_fd: int) -> None: + """在已打开的目录内递归比较所需的类型、执行位和字节。""" + for name in sorted(os.listdir(directory_fd)): + hasher.update(os.fsencode(name) + b"\0") + mode = os.stat(name, dir_fd=directory_fd, follow_symlinks=False).st_mode + if stat.S_ISDIR(mode): + child_fd = open_directory_fd(directory_fd, (name,)) + try: + hasher.update(b"directory\0") + visit(child_fd) + hasher.update(b"end-directory\0") + finally: + os.close(child_fd) + else: + with open_regular_file_fd(directory_fd, (name,)) as (file_fd, file_stat): + hasher.update(b"file\0" + bytes([stat.S_IMODE(file_stat.st_mode) & 0o111])) + content_hash = hashlib.sha256() + while chunk := os.read(file_fd, 1024 * 1024): + content_hash.update(chunk) + hasher.update(content_hash.digest()) + + absolute = Path(os.path.abspath(path)) + directory_fd = open_directory_fd(Path(absolute.anchor), absolute.parts[1:]) + try: + visit(directory_fd) + finally: + os.close(directory_fd) + return hasher.digest() + + +def _resolve_shared_skill_dir(item: Skill) -> Path: + """把已锁定的共享行映射到唯一持久来源。""" + return get_skill_data_dir().joinpath(*validated_shared_skill_parts(item.slug, item.dir_path)) diff --git a/backend/package/yuxi/agents/skills/remote_install.py b/backend/package/yuxi/services/skills/remote.py similarity index 87% rename from backend/package/yuxi/agents/skills/remote_install.py rename to backend/package/yuxi/services/skills/remote.py index dce06214b5..591579d527 100644 --- a/backend/package/yuxi/agents/skills/remote_install.py +++ b/backend/package/yuxi/services/skills/remote.py @@ -14,8 +14,8 @@ from yuxi.agents.backends.sandbox import ProvisionerSandboxBackend from yuxi.agents.backends.sandbox.download import download_sandbox_directory from yuxi.agents.backends.sandbox.provider import get_sandbox_provider -from yuxi.agents.skills.service import is_valid_skill_slug from yuxi.config.options import remote_skill_source_policy +from yuxi.services.skills.package import is_valid_skill_slug from yuxi.utils.logging_config import logger ANSI_ESCAPE_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") @@ -27,16 +27,145 @@ REMOTE_SKILL_SANDBOX_ROOT = f"{VIRTUAL_PATH_PREFIX.rstrip('/')}/outputs" +@dataclass(frozen=True, slots=True) +class DownloadedSkill: + """已下载的目录;包格式由草稿或安装边界校验。""" + + slug: str + source_dir: Path + + +@dataclass(frozen=True, slots=True) +class SkillDownloadFailure: + """一个请求条目的获取失败原因。""" + + slug: str + error: str + + @dataclass(slots=True) -class RemoteSkillsBatchPreparation: +class RemoteSkillDownloads: + """一次远程批量获取的临时目录与逐项结果。""" + temp_home: str | None - results: list[dict] + results: list[DownloadedSkill | SkillDownloadFailure] async def cleanup(self) -> None: + """释放本批获取持有的宿主临时目录。""" if self.temp_home: await asyncio.to_thread(shutil.rmtree, self.temp_home, ignore_errors=True) +async def list_remote_skills(source: str) -> list[dict[str, str]]: + """列出允许来源中的可安装 Skill。""" + policy = await remote_skill_source_policy.get() + normalized_source = _normalize_source(source, policy["allowed_hosts"]) + + sandbox = _RemoteSkillSandbox.create() + try: + output = await sandbox.run(["npx", "-y", "skills", "add", normalized_source, "--list"]) + finally: + await sandbox.cleanup() + + skills = _parse_available_skills(output) + if not skills: + raise ValueError("未发现可安装的 skills") + return skills + + +async def download_remote_skills( + *, + source: str, + skills: list[str], +) -> RemoteSkillDownloads: + """批量从远程仓库拉取 skill 目录,但不写数据库。""" + policy = await remote_skill_source_policy.get() + normalized_source = _normalize_source(source, policy["allowed_hosts"]) + if not skills: + raise ValueError("skills 列表不能为空") + + results: dict[int, DownloadedSkill | SkillDownloadFailure] = {} + requests: list[tuple[int, str]] = [] + for index, skill in enumerate(skills): + try: + requests.append((index, _normalize_skill_name(skill))) + except ValueError as exc: + results[index] = SkillDownloadFailure(slug=skill, error=str(exc)) + + if not requests: + return RemoteSkillDownloads(temp_home=None, results=list(results.values())) + + sandbox = _RemoteSkillSandbox.create() + temp_home: str | None = None + try: + try: + temp_home = tempfile.mkdtemp(prefix=".remote-skills-") + skill_args: list[str] = [] + for _index, name in requests: + skill_args.extend(["--skill", name]) + + cli_failed = False + try: + await sandbox.run( + [ + "npx", + "-y", + "skills", + "add", + normalized_source, + *skill_args, + "-g", + "-y", + "--copy", + ] + ) + except ValueError: + # CLI 对不匹配的 skill 会退出码非零,但已安装的目录仍在 + cli_failed = True + + downloaded_dirs: dict[str, Path | None] = {} + for original_index, name in requests: + installed_dir = Path(temp_home) / name + if name not in downloaded_dirs: + try: + await sandbox.download_skill(name, installed_dir) + downloaded_dirs[name] = installed_dir + except ValueError: + downloaded_dirs[name] = None + installed_dir = downloaded_dirs[name] + if installed_dir is None: + error_msg = "CLI 安装失败" if cli_failed else "skills CLI 未生成预期的技能目录" + results[original_index] = SkillDownloadFailure(slug=name, error=error_msg) + continue + results[original_index] = DownloadedSkill(slug=name, source_dir=installed_dir) + + finally: + await sandbox.cleanup() + except BaseException: + if temp_home: + await asyncio.to_thread(shutil.rmtree, temp_home, ignore_errors=True) + raise + + return RemoteSkillDownloads(temp_home=temp_home, results=[results[index] for index in range(len(skills))]) + + +async def search_remote_skills(query: str) -> list[dict[str, str]]: + """使用 npx skills find 搜索远程 skills。""" + query_val = str(query or "").strip() + if not query_val: + return [] + if any(ch in query_val for ch in ("\n", "\r", "\x00")): + raise ValueError("搜索关键字包含非法字符") + + sandbox = _RemoteSkillSandbox.create() + try: + output = await sandbox.run(["npx", "-y", "skills", "find", query_val]) + finally: + await sandbox.cleanup() + + return _parse_search_skills(output) + + @dataclass(slots=True) class _RemoteSkillSandbox: """在一次性 Sandbox 中执行不可信的远程 Skill CLI。""" @@ -206,103 +335,6 @@ def _parse_available_skills(output: str) -> list[dict[str, str]]: return items -async def list_remote_skills(source: str) -> list[dict[str, str]]: - policy = await remote_skill_source_policy.get() - normalized_source = _normalize_source(source, policy["allowed_hosts"]) - - sandbox = _RemoteSkillSandbox.create() - try: - output = await sandbox.run(["npx", "-y", "skills", "add", normalized_source, "--list"]) - finally: - await sandbox.cleanup() - - skills = _parse_available_skills(output) - if not skills: - raise ValueError("未发现可安装的 skills") - return skills - - -async def prepare_remote_skills_batch( - *, - source: str, - skills: list[str], -) -> RemoteSkillsBatchPreparation: - """批量从远程仓库拉取 skill 目录,但不写数据库。""" - policy = await remote_skill_source_policy.get() - normalized_source = _normalize_source(source, policy["allowed_hosts"]) - if not skills: - raise ValueError("skills 列表不能为空") - - # 预分配结果数组(按请求顺序),校验非法名并记录失败 - results: list[dict] = [{"slug": "", "success": False, "error": "unset"} for _ in range(len(skills))] - normalized_skills: list[str] = [] - valid_indices: list[int] = [] - for i, skill in enumerate(skills): - try: - normalized_skills.append(_normalize_skill_name(skill)) - valid_indices.append(i) - except ValueError as e: - results[i] = {"slug": skill, "success": False, "error": str(e)} - - if not normalized_skills: - return RemoteSkillsBatchPreparation(temp_home=None, results=results) - - sandbox = _RemoteSkillSandbox.create() - temp_home: str | None = None - keep_temp_home = False - try: - try: - temp_home = tempfile.mkdtemp(prefix=".remote-skills-") - skill_args: list[str] = [] - for name in normalized_skills: - skill_args.extend(["--skill", name]) - - cli_failed = False - try: - await sandbox.run( - [ - "npx", - "-y", - "skills", - "add", - normalized_source, - *skill_args, - "-g", - "-y", - "--copy", - ] - ) - except ValueError: - # CLI 对不匹配的 skill 会退出码非零,但已安装的目录仍在 - cli_failed = True - - downloaded_dirs: dict[str, Path | None] = {} - for original_index, name in zip(valid_indices, normalized_skills): - installed_dir = Path(temp_home) / name - if name not in downloaded_dirs: - try: - await sandbox.download_skill(name, installed_dir) - downloaded_dirs[name] = installed_dir - except ValueError: - downloaded_dirs[name] = None - installed_dir = downloaded_dirs[name] - if installed_dir is None: - error_msg = "CLI 安装失败" if cli_failed else "skills CLI 未生成预期的技能目录" - results[original_index] = {"slug": name, "success": False, "error": error_msg} - continue - results[original_index] = {"slug": name, "success": True, "source_dir": installed_dir} - - preparation = RemoteSkillsBatchPreparation(temp_home=temp_home, results=results) - finally: - await sandbox.cleanup() - - keep_temp_home = True - return preparation - finally: - if temp_home and not keep_temp_home: - await asyncio.to_thread(shutil.rmtree, temp_home, ignore_errors=True) - - def _parse_search_skills(output: str) -> list[dict[str, str]]: """解析 npx skills find 命令的输出。""" lines = _clean_cli_output(output) @@ -326,20 +358,3 @@ def _parse_search_skills(output: str) -> list[dict[str, str]]: } ) return results - - -async def search_remote_skills(query: str) -> list[dict[str, str]]: - """使用 npx skills find 搜索远程 skills。""" - query_val = str(query or "").strip() - if not query_val: - return [] - if any(ch in query_val for ch in ("\n", "\r", "\x00")): - raise ValueError("搜索关键字包含非法字符") - - sandbox = _RemoteSkillSandbox.create() - try: - output = await sandbox.run(["npx", "-y", "skills", "find", query_val]) - finally: - await sandbox.cleanup() - - return _parse_search_skills(output) diff --git a/backend/package/yuxi/services/skills/resolved.py b/backend/package/yuxi/services/skills/resolved.py new file mode 100644 index 0000000000..927da1f1a1 --- /dev/null +++ b/backend/package/yuxi/services/skills/resolved.py @@ -0,0 +1,51 @@ +"""不同来源 Skill 的统一只读描述。""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +@dataclass(frozen=True, slots=True) +class ResolvedSkill: + """描述当前用户最终可用的 Skill 及其真实来源。""" + + id: Any + slug: str + name: str + description: str + source_type: str + source_scope: str + source_dir: Path + enabled: bool + created_by: str | None + share_config: dict[str, Any] | None + tool_dependencies: list[str] + mcp_dependencies: list[str] + skill_dependencies: list[str] + version: str | None = None + content_hash: str | None = None + overrides_shared: bool = False + shadowed_by_personal: bool = False + + def to_dict(self) -> dict[str, Any]: + """返回可安全提供给前端的 Skill 元数据。""" + data = { + "id": self.id, + "slug": self.slug, + "name": self.name, + "description": self.description, + "source_type": self.source_type, + "source_scope": self.source_scope, + "enabled": self.enabled, + "created_by": self.created_by, + "tool_dependencies": self.tool_dependencies, + "mcp_dependencies": self.mcp_dependencies, + "skill_dependencies": self.skill_dependencies, + "overrides_shared": self.overrides_shared, + "shadowed_by_personal": self.shadowed_by_personal, + } + if self.share_config is not None: + data["share_config"] = self.share_config + return data diff --git a/backend/package/yuxi/services/skills/shared.py b/backend/package/yuxi/services/skills/shared.py new file mode 100644 index 0000000000..2229b8c277 --- /dev/null +++ b/backend/package/yuxi/services/skills/shared.py @@ -0,0 +1,599 @@ +from __future__ import annotations + +import asyncio +import hashlib +import shutil +import stat +import uuid +from pathlib import Path +from typing import Any + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession +from yuxi.agents.mcp.service import get_enabled_mcp_server_slugs +from yuxi.agents.skills.buildin import BUILTIN_SKILLS_DIR +from yuxi.config import get_skill_data_dir +from yuxi.permissions import ResourcePermission, normalize_permission_config, resolve_skill_permission +from yuxi.repositories.skill_repository import SkillRepository +from yuxi.services.skills.draft import consume_installed_draft_items, load_and_select_draft_items +from yuxi.services.skills.package import ( + copy_skill_snapshot, + copy_skill_tree_no_symlinks, + is_valid_skill_slug, + normalize_string_list, + parse_skill_markdown, + validated_shared_skill_parts, +) +from yuxi.services.skills.projection import commit_skill_policy_and_refresh_projections +from yuxi.services.skills.resolved import ResolvedSkill +from yuxi.storage.postgres.models_business import Skill, User + +BUILTIN_SKILL_OPERATOR = "builtin-system" +ADMIN_ROLES = {"admin", "superadmin"} +BUILTIN_SKILL_SHARE_CONFIG = {"access_level": "global", "department_ids": [], "user_uids": []} +SKILL_STORAGE_LOCK = 0x5958534B + + +async def get_skill_dependency_options( + db: AsyncSession, user: User, slug: str | None = None +) -> dict[str, list[str] | list[dict]]: + """返回当前 Skill 可选择的依赖项。""" + from yuxi.agents.toolkits.service import get_tool_metadata + + skill_items, tool_list, mcp_names = await asyncio.gather( + SkillRepository(db).list_enabled_readable(user), + asyncio.to_thread(get_tool_metadata), + get_enabled_mcp_server_slugs(db=db), + ) + skill_slugs = [item.slug for item in skill_items if isinstance(item.slug, str) and item.slug != slug] + + return { + "tools": [{"slug": tool["slug"], "name": tool.get("name", tool["slug"])} for tool in tool_list], + "mcps": mcp_names, + "skills": skill_slugs, + } + + +async def confirm_skill_install_draft( + db: AsyncSession, + *, + draft_id: str, + share_config: dict | None, + slugs: list[str] | None = None, + operator: User, +) -> list[dict[str, Any]]: + """按共享授权确认并发布 Skill 草稿。""" + draft_dir, data, draft_items = load_and_select_draft_items(draft_id, slugs, operator) + source_type = data["source_type"] + + normalized_share_config = normalize_skill_share_config( + share_config, + operator_uid=operator.uid, + source_type=source_type, + allowed_access_levels=set(get_allowed_skill_access_levels(operator)), + ) + + repo = SkillRepository(db) + skills_root = get_skills_root_dir() + results: list[dict[str, Any]] = [] + + for draft_item in draft_items: + slug = draft_item.slug + final_slug = await _generate_available_slug(repo, slug) + + temp_target = skills_root / f".{final_slug}.tmp-{uuid.uuid4().hex[:8]}" + final_dir = skills_root / final_slug + published = False + try: + parsed = copy_skill_snapshot(draft_item.source_dir, temp_target, expected_slug=slug, final_slug=final_slug) + if final_dir.exists(): + raise ValueError("Skill slug 已被占用,请重新解析安装") + temp_target.rename(final_dir) + published = True + item = await repo.create( + slug=final_slug, + name=parsed["name"], + description=parsed["description"], + source_type=source_type, + tool_dependencies=parsed["tool_dependencies"], + mcp_dependencies=parsed["mcp_dependencies"], + skill_dependencies=parsed["skill_dependencies"], + dir_path=(Path("shared") / final_slug).as_posix(), + share_config=normalized_share_config, + enabled=True, + created_by=operator.uid, + ) + await db.commit() + results.append({"slug": item.slug, "requested_slug": slug, "success": True, "skill": item.to_dict()}) + except Exception as e: + await db.rollback() + if published: + shutil.rmtree(final_dir, ignore_errors=True) + result = {"slug": slug, "success": False, "error": str(e)} + results.append(result) + finally: + shutil.rmtree(temp_target, ignore_errors=True) + + consume_installed_draft_items(draft_dir, data, {item["requested_slug"] for item in results if item["success"]}) + return results + + +async def delete_skill(db: AsyncSession, *, slug: str, operator: User) -> None: + """删除一个可管理的共享 Skill。""" + repo = SkillRepository(db) + item = await repo.get_by_slug(slug, for_update=True) + if not item: + raise ValueError(f"技能 '{slug}' 不存在") + if not user_can_manage_skill(operator, item): + raise ValueError(f"技能 '{slug}' 不存在或无权管理") + _ensure_non_builtin(item) + + skill_dir = _resolve_skill_dir(item) + trash_dir: Path | None = None + + if skill_dir.exists(): + trash_dir = skill_dir.with_name(f".deleted-{slug}-{uuid.uuid4().hex[:8]}") + skill_dir.rename(trash_dir) + + try: + await repo.delete(item) + await db.commit() + except Exception: + if trash_dir and trash_dir.exists(): + trash_dir.rename(skill_dir) + raise + + if trash_dir and trash_dir.exists(): + await asyncio.to_thread(shutil.rmtree, trash_dir, ignore_errors=True) + + +async def delete_skills_batch(db: AsyncSession, *, slugs: list[str], operator: User) -> list[dict]: + """批量删除多个 skills(单技能独立的子事务与回滚)。""" + if len(slugs) > 50: + raise ValueError("批量删除的技能数量不能超过 50 个") + results = [] + for slug in slugs: + try: + await delete_skill(db, slug=slug, operator=operator) + results.append({"slug": slug, "success": True}) + except Exception as e: + if hasattr(db, "rollback"): + await db.rollback() + results.append({"slug": slug, "success": False, "error": str(e)}) + return results + + +async def update_skill_share_config( + db: AsyncSession, + *, + slug: str, + share_config: dict | None, + operator: User, +) -> Skill: + """更新共享 Skill 授权并刷新已有投影。""" + item = await get_manageable_skill_or_raise(db, operator, slug) + _ensure_non_builtin(item) + normalized = normalize_skill_share_config( + share_config, + operator_uid=operator.uid, + source_type=item.source_type, + allowed_access_levels=set(get_allowed_skill_access_levels(operator)), + ) + repo = SkillRepository(db) + updated = await repo.update_share_config(item, share_config=normalized, updated_by=operator.uid) + await commit_skill_policy_and_refresh_projections(db, slug) + return updated + + +async def update_skill_enabled(db: AsyncSession, *, slug: str, enabled: bool, operator: User) -> Skill: + """更新共享 Skill 启用状态并刷新已有投影。""" + item = await get_manageable_skill_or_raise(db, operator, slug) + repo = SkillRepository(db) + updated = await repo.update_enabled(item, enabled=enabled, updated_by=operator.uid) + await commit_skill_policy_and_refresh_projections(db, slug) + return updated + + +async def init_builtin_skills(db: AsyncSession, *, created_by: str = "system") -> list[Skill]: + """将内置 Skill 定义同步到共享索引。""" + if db is not None and db.get_bind().dialect.name == "postgresql": + await db.execute(text("SELECT pg_advisory_xact_lock(:lock_key)"), {"lock_key": SKILL_STORAGE_LOCK}) + + repo = SkillRepository(db) + synced_items: list[Skill] = [] + + for spec in list_builtin_skill_specs(): + slug = spec["slug"] + existing = await repo.get_by_slug(slug) + if existing and not is_builtin_skill(existing): + raise ValueError(f"内置 skill '{slug}' 与已存在的非内置 skill 冲突") + + target_dir = get_skills_root_dir() / slug + _replace_skill_target(target_dir, Path(spec["source_dir"])) + + if existing: + existing.dir_path = (Path("shared") / slug).as_posix() + if existing.name != spec["name"] or existing.description != spec["description"]: + await repo.update_metadata( + existing, + name=spec["name"], + description=spec["description"], + updated_by=created_by, + ) + if ( + normalize_string_list(existing.tool_dependencies or []) != spec["tool_dependencies"] + or normalize_string_list(existing.mcp_dependencies or []) != spec["mcp_dependencies"] + or normalize_string_list(existing.skill_dependencies or []) != spec["skill_dependencies"] + ): + await repo.update_dependencies( + existing, + tool_dependencies=spec["tool_dependencies"], + mcp_dependencies=spec["mcp_dependencies"], + skill_dependencies=spec["skill_dependencies"], + updated_by=created_by, + ) + synced_items.append( + await repo.update_builtin_install( + existing, + version=spec["version"], + content_hash=spec["content_hash"], + updated_by=created_by, + ) + ) + continue + + synced_items.append( + await repo.create( + slug=slug, + name=spec["name"], + description=spec["description"], + source_type="builtin", + tool_dependencies=spec["tool_dependencies"], + mcp_dependencies=spec["mcp_dependencies"], + skill_dependencies=spec["skill_dependencies"], + dir_path=(Path("shared") / slug).as_posix(), + share_config=BUILTIN_SKILL_SHARE_CONFIG.copy(), + enabled=True, + version=spec["version"], + content_hash=spec["content_hash"], + created_by=created_by or BUILTIN_SKILL_OPERATOR, + ) + ) + + if db is not None: + await db.commit() + return synced_items + + +async def lock_accessible_shared_skill_for_file(db: AsyncSession, user: User, slug: str) -> Skill | None: + """在读取共享源文件期间锁定一行并重新检查权限。""" + repo = SkillRepository(db) + visible = {item.slug for item in await repo.list_enabled_readable(user)} + if slug not in visible: + return None + item = await repo.get_by_slug_for_read(slug) + return item if item is not None and user_can_access_skill(user, item) else None + + +async def lock_accessible_shared_skills_for_runtime( + db: AsyncSession, + user: User, + selected: list[str], + *, + shadowed_slugs: set[str] | None = None, +) -> list[Skill]: + """只锁定已选共享 Skill 及其依赖,锁后重新检查权限。""" + repo = SkillRepository(db) + visible = {item.slug for item in await repo.list_enabled_readable(user)} + shadowed = shadowed_slugs or set() + pending = list(selected) + locked: dict[str, Skill] = {} + while pending: + slug = pending.pop(0) + if slug in shadowed or slug not in visible or slug in locked: + continue + item = await repo.get_by_slug_for_read(slug) + if item is None or not user_can_access_skill(user, item): + continue + locked[slug] = item + pending.extend(item.skill_dependencies or []) + return list(locked.values()) + + +async def validate_skill_dependencies( + *, + parent: Skill, + tool_dependencies: list[str], + mcp_dependencies: list[str], + skill_dependencies: list[str], + available_skills: dict[str, Skill], +) -> tuple[list[str], list[str], list[str]]: + """校验工具、MCP 和共享 Skill 依赖。""" + tools = normalize_string_list(tool_dependencies) + mcps = normalize_string_list(mcp_dependencies) + skills = normalize_string_list(skill_dependencies) + + # 验证所有工具(不仅仅是 buildin) + from yuxi.agents.toolkits.service import get_tool_metadata + + available_tools = {tool["slug"] for tool in get_tool_metadata()} + invalid_tools = [name for name in tools if name not in available_tools] + if invalid_tools: + raise ValueError(f"存在无效工具依赖: {', '.join(invalid_tools)}") + + available_mcps = set(await get_enabled_mcp_server_slugs(db=None)) + invalid_mcps = [name for name in mcps if name not in available_mcps] + if invalid_mcps: + raise ValueError(f"存在无效 MCP 依赖: {', '.join(invalid_mcps)}") + + invalid_skills = [name for name in skills if name not in available_skills] + if invalid_skills: + raise ValueError(f"存在无效 skill 依赖: {', '.join(invalid_skills)}") + + if parent.slug in skills: + raise ValueError("skill_dependencies 不允许包含自身") + + forbidden_skills = [name for name in skills if not can_skill_depend_on(parent, available_skills[name])] + if forbidden_skills: + raise ValueError(f"存在权限范围不匹配的 skill 依赖: {', '.join(forbidden_skills)}") + + return tools, mcps, skills + + +async def get_skill_or_raise(db: AsyncSession, slug: str, *, for_update: bool = False) -> Skill: + """读取指定共享 Skill,必要时加独占行锁。""" + slug = slug.strip() if isinstance(slug, str) else "" + if not is_valid_skill_slug(slug): + raise ValueError("无效 skill slug") + + repo = SkillRepository(db) + item = await repo.get_by_slug(slug, for_update=True) if for_update else await repo.get_by_slug(slug) + if not item: + raise ValueError(f"技能 '{slug}' 不存在") + return item + + +async def get_management_readable_skill_or_raise(db: AsyncSession, user: User, slug: str) -> Skill: + """读取当前用户可查看的共享 Skill。""" + item = await get_skill_or_raise(db, slug) + if not user_can_manage_skill(user, item) and not user_can_access_skill(user, item): + raise ValueError(f"技能 '{slug}' 不存在或无权访问") + return item + + +async def get_manageable_skill_or_raise(db: AsyncSession, user: User, slug: str, *, for_update: bool = False) -> Skill: + """读取当前用户可管理的共享 Skill。""" + item = await get_skill_or_raise(db, slug, for_update=for_update) + if not user_can_manage_skill(user, item): + raise ValueError(f"技能 '{slug}' 不存在或无权管理") + return item + + +def resolved_shared_skill(item: Skill) -> ResolvedSkill: + """将数据库 Skill 适配为统一的有效 Skill 描述。""" + source_scope = "builtin" if is_builtin_skill(item) else "shared" + return ResolvedSkill( + id=item.id, + slug=item.slug, + name=item.name, + description=item.description, + source_type=item.source_type, + source_scope=source_scope, + source_dir=_resolve_skill_dir(item), + enabled=bool(item.enabled), + created_by=item.created_by, + share_config=normalize_permission_config( + item.share_config, + ), + tool_dependencies=normalize_string_list(item.tool_dependencies), + mcp_dependencies=normalize_string_list(item.mcp_dependencies), + skill_dependencies=normalize_string_list(item.skill_dependencies), + version=item.version, + content_hash=item.content_hash, + ) + + +def can_skill_depend_on(parent: Skill, dependency: Skill) -> bool: + """检查两个共享 Skill 的依赖授权是否兼容。""" + if not dependency.enabled: + return False + if is_builtin_skill(dependency): + return True + + dep_config = normalize_permission_config(dependency.share_config) + parent_config = normalize_permission_config(parent.share_config) + dependency_scopes = [scope for scope in (dep_config["read_scope"], dep_config["manage_scope"]) if scope] + parent_scopes = [scope for scope in (parent_config["read_scope"], parent_config["manage_scope"]) if scope] + owner_scope = {"access_level": "user", "department_ids": [], "user_uids": []} + if not dependency_scopes: + dependency_scopes = [{**owner_scope, "user_uids": [str(dependency.created_by or "")]}] + if not parent_scopes: + parent_scopes = [{**owner_scope, "user_uids": [str(parent.created_by or "")]}] + return all( + any(_scope_contains(dependency_scope, parent_scope) for dependency_scope in dependency_scopes) + for parent_scope in parent_scopes + ) + + +def normalize_skill_share_config( + share_config: dict | None, + *, + operator_uid: str, + source_type: str = "upload", + allowed_access_levels: set[str] | None = None, +) -> dict: + """校验并标准化共享 Skill 的授权配置。""" + if source_type == "builtin": + return {"version": 2, "read_scope": BUILTIN_SKILL_SHARE_CONFIG.copy(), "manage_scope": None} + + default_scope = { + "access_level": "user", + "department_ids": [], + "user_uids": [operator_uid], + } + return normalize_permission_config( + share_config or {"version": 2, "read_scope": default_scope, "manage_scope": None}, + allowed_access_levels=allowed_access_levels, + unauthorized_access_level_message="当前用户无权使用该 Skill 共享范围", + strict=True, + ) + + +def get_allowed_skill_access_levels(user: User) -> list[str]: + """返回操作人可设定的共享范围。""" + if user.role in ADMIN_ROLES: + return ["global", "department", "user"] + return ["user"] + + +def user_can_access_skill(user: User, skill: Skill) -> bool: + """检查用户是否可使用已启用的共享 Skill。""" + if not skill.enabled: + return False + return resolve_skill_permission(user, skill) != ResourcePermission.NONE + + +def user_can_manage_skill(user: User, skill: Skill) -> bool: + """检查用户是否可管理共享 Skill。""" + if is_builtin_skill(skill): + return user.role in ADMIN_ROLES + return resolve_skill_permission(user, skill) == ResourcePermission.MANAGE + + +def is_builtin_skill(item: Skill | ResolvedSkill) -> bool: + """判断共享 Skill 是否为内置来源。""" + return item.source_type == "builtin" + + +def get_skills_root_dir() -> Path: + """返回共享与内置 Skill 的持久源目录。""" + root = get_skill_data_dir() / "shared" + root.mkdir(parents=True, exist_ok=True) + return root + + +def list_builtin_skill_specs() -> list[dict[str, Any]]: + """发现源码目录中的 Skill,并以 frontmatter 作为唯一元数据。""" + specs: list[dict[str, Any]] = [] + for source_dir in sorted(BUILTIN_SKILLS_DIR.iterdir()): + if not source_dir.is_dir() or source_dir.name.startswith(("_", ".")): + continue + slug = source_dir.name + skill_md = source_dir / "SKILL.md" + if not skill_md.exists(): + raise ValueError(f"内置 skill 缺少 SKILL.md: {source_dir}") + + content = skill_md.read_text(encoding="utf-8") + parsed_slug, parsed_name, parsed_desc, meta = parse_skill_markdown(content) + if parsed_slug != slug: + raise ValueError(f"内置 skill frontmatter.slug 必须等于 slug: {slug}") + + specs.append( + { + "slug": slug, + "name": parsed_name, + "description": parsed_desc, + "version": str(meta.get("version", "1.0.0")), + "tool_dependencies": normalize_string_list(meta.get("tool_dependencies")), + "mcp_dependencies": normalize_string_list(meta.get("mcp_dependencies")), + "skill_dependencies": normalize_string_list(meta.get("skill_dependencies")), + "content_hash": _compute_dir_hash(source_dir), + "source_dir": source_dir, + } + ) + + return specs + + +def _scope_contains(container: dict, target: dict) -> bool: + """判断一个共享范围是否完整覆盖另一个范围。""" + + container_level = container.get("access_level") + target_level = target.get("access_level") + if container_level == "global": + return True + if target_level == "global" or container_level != target_level: + return False + if target_level == "department": + container_ids = {int(value) for value in container.get("department_ids") or []} + target_ids = {int(value) for value in target.get("department_ids") or []} + return target_ids.issubset(container_ids) + if target_level == "user": + container_uids = {str(value) for value in container.get("user_uids") or []} + target_uids = {str(value) for value in target.get("user_uids") or []} + return target_uids.issubset(container_uids) + return False + + +def _ensure_non_builtin(item: Skill) -> None: + """拒绝对内置 Skill 执行来源文件修改。""" + if is_builtin_skill(item): + raise ValueError("内置 skill 不允许执行该操作") + + +def _compute_dir_hash(source_dir: Path) -> str: + """计算目录树内容及执行位的摘要。""" + hasher = hashlib.sha256() + entries = sorted(source_dir.rglob("*"), key=lambda path: path.relative_to(source_dir).as_posix()) + for entry in entries: + relative_path = entry.relative_to(source_dir).as_posix() + hasher.update(relative_path.encode("utf-8")) + hasher.update(b"\0") + if entry.is_dir(): + hasher.update(b"directory\0") + continue + if not entry.is_file(): + hasher.update(b"other\0") + continue + hasher.update(b"file\0") + hasher.update(bytes([stat.S_IMODE(entry.stat().st_mode) & 0o111])) + with entry.open("rb") as f: + while chunk := f.read(1024 * 1024): + hasher.update(chunk) + hasher.update(b"\0") + return hasher.hexdigest() + + +def _replace_skill_target( + target_dir: Path, + source_dir: Path, +) -> None: + """将 source_dir 复制到临时目录,再替换 target_dir。""" + temp_target = target_dir.with_name(f".{target_dir.name}.tmp-{uuid.uuid4().hex[:8]}") + trash_dir: Path | None = None + if temp_target.exists(): + shutil.rmtree(temp_target, ignore_errors=True) + + copy_skill_tree_no_symlinks(source_dir, temp_target) + try: + if target_dir.exists(): + trash_dir = target_dir.with_name(f".{target_dir.name}.bak-{uuid.uuid4().hex[:8]}") + target_dir.rename(trash_dir) + temp_target.rename(target_dir) + except Exception: + shutil.rmtree(temp_target, ignore_errors=True) + if trash_dir and trash_dir.exists() and not target_dir.exists(): + trash_dir.rename(target_dir) + raise + + if trash_dir and trash_dir.exists(): + shutil.rmtree(trash_dir, ignore_errors=True) + + +async def _generate_available_slug(repo: SkillRepository, base_slug: str) -> str: + """为共享来源分配未占用的 slug。""" + root = get_skills_root_dir() + if not await repo.exists_slug(base_slug) and not (root / base_slug).exists(): + return base_slug + + idx = 2 + while True: + candidate = f"{base_slug}-v{idx}" + if not await repo.exists_slug(candidate) and not (root / candidate).exists(): + return candidate + idx += 1 + + +def _resolve_skill_dir(item: Skill) -> Path: + """将共享 Skill 数据库路径解析到持久根下。""" + return get_skill_data_dir().joinpath(*validated_shared_skill_parts(item.slug, item.dir_path)) diff --git a/backend/package/yuxi/storage_migrations/v071_skills.py b/backend/package/yuxi/storage_migrations/v071_skills.py index bc2942d890..4270a1356e 100644 --- a/backend/package/yuxi/storage_migrations/v071_skills.py +++ b/backend/package/yuxi/storage_migrations/v071_skills.py @@ -9,15 +9,11 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession -from yuxi.agents.skills.repository import SkillRepository -from yuxi.agents.skills.service import ( - SKILL_STORAGE_LOCK, - copy_skill_tree_no_symlinks, - get_skills_root_dir, - parse_skill_dir_metadata, - skill_dirs_equal, -) from yuxi.config import get_legacy_storage_dir, get_skill_data_dir +from yuxi.repositories.skill_repository import SkillRepository +from yuxi.services.skills.package import copy_skill_tree_no_symlinks, parse_skill_dir_metadata +from yuxi.services.skills.projection import skill_dirs_equal +from yuxi.services.skills.shared import SKILL_STORAGE_LOCK, get_skills_root_dir _MIGRATION_MARKER = ".legacy-migration-complete" diff --git a/backend/server/routers/skill_router.py b/backend/server/routers/skill_router.py index bb1d3e1507..51506a5ff6 100644 --- a/backend/server/routers/skill_router.py +++ b/backend/server/routers/skill_router.py @@ -8,43 +8,49 @@ from fastapi.responses import FileResponse from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession - -from server.utils.auth_middleware import get_admin_user, get_db, get_required_user -from yuxi.agents.skills.service import ( +from yuxi.permissions import resolve_skill_permission +from yuxi.repositories.skill_repository import SkillRepository +from yuxi.services.skills.catalog import list_accessible_skills, list_skill_cards_for_user +from yuxi.services.skills.draft import ( + create_remote_skill_draft, + create_uploaded_skill_draft, + discard_skill_install_draft, +) +from yuxi.services.skills.edit import ( + SkillEditConflict, + create_skill_node, + delete_skill_node, + edit_shared_skill_dependencies, + edit_shared_skill_file, + export_skill_zip, + get_skill_tree, + read_skill_file, +) +from yuxi.services.skills.personal import ( confirm_personal_skill_install_draft, + delete_personal_skill, + read_personal_skill_file, +) +from yuxi.services.skills.remote import list_remote_skills, search_remote_skills +from yuxi.services.skills.shared import ( confirm_skill_install_draft, - create_skill_node, delete_skill, - delete_skill_node, delete_skills_batch, - delete_personal_skill, - discard_skill_install_draft, - export_skill_zip, get_allowed_skill_access_levels, get_manageable_skill_or_raise, get_skill_dependency_options, - get_skill_tree, init_builtin_skills, is_builtin_skill, - list_accessible_skills, - list_skill_cards_for_user, - list_skills, - list_visible_skills_for_management, - prepare_remote_skill_install, - prepare_skill_upload, - read_personal_skill_file, - read_skill_file, - update_skill_dependencies, + normalize_skill_share_config, update_skill_enabled, - update_skill_file, update_skill_share_config, user_can_manage_skill, ) -from yuxi.permissions import resolve_skill_permission -from yuxi.agents.skills.remote_install import list_remote_skills, search_remote_skills from yuxi.storage.postgres.models_business import User from yuxi.utils.logging_config import logger +from server.utils.auth_middleware import get_admin_user, get_db, get_required_user + skills = APIRouter(prefix="/system/skills", tags=["skills"]) user_skills = APIRouter(prefix="/skills", tags=["skills"]) @@ -66,12 +72,14 @@ class SkillNodeCreateRequest(BaseModel): class SkillFileUpdateRequest(BaseModel): path: str = Field(..., description="相对 skill 根目录的路径") content: str = Field(..., description="文件内容") + expected_revision: str = Field(..., description="读取文件时取得的 SHA-256 修订值") class SkillDependenciesUpdateRequest(BaseModel): tool_dependencies: list[str] = Field(default_factory=list, description="依赖的内置工具列表") mcp_dependencies: list[str] = Field(default_factory=list, description="依赖的 MCP 服务列表") skill_dependencies: list[str] = Field(default_factory=list, description="依赖的其他 skill slug 列表") + expected_revision: str = Field(..., description="读取根级 SKILL.md 时取得的修订值") class RemoteSkillSourceRequest(BaseModel): @@ -102,35 +110,6 @@ class PersonalSkillDraftConfirmRequest(_DraftConfirmRequestBase): pass -def _raise_from_value_error(e: ValueError) -> None: - message = str(e) - status_code = 404 if "不存在" in message or "无权" in message else 400 - raise HTTPException(status_code=status_code, detail=message) - - -def _cleanup_export_file(path: str) -> None: - try: - Path(path).unlink(missing_ok=True) - except Exception as e: - logger.warning(f"Failed to cleanup exported skill archive '{path}': {e}") - - -def _summarize_results(results: list[dict]) -> dict[str, int]: - return { - "total": len(results), - "success": sum(1 for item in results if item.get("success")), - "failed": sum(1 for item in results if not item.get("success")), - } - - -def _serialize_skill_for_user(item, user: User) -> dict: - data = item.to_dict() - data["can_manage"] = user_can_manage_skill(user, item) - data["effective_permission"] = resolve_skill_permission(user, item).value - data["is_builtin"] = is_builtin_skill(item) - return data - - @user_skills.get("") async def list_skill_cards_route( current_user: User = Depends(get_required_user), @@ -166,18 +145,21 @@ async def list_accessible_skills_route( @user_skills.post("/import/prepare") -async def prepare_skill_upload_route( +async def create_uploaded_skill_draft_route( file: UploadFile = File(...), current_user: User = Depends(get_required_user), - db: AsyncSession = Depends(get_db), ): try: - data = await prepare_skill_upload( - db, + data = await create_uploaded_skill_draft( filename=file.filename or "", file_bytes=await file.read(), operator=current_user, ) + allowed = get_allowed_skill_access_levels(current_user) + data["allowed_access_levels"] = allowed + data["default_share_config"] = normalize_skill_share_config( + None, operator_uid=current_user.uid, allowed_access_levels=set(allowed) + ) return {"success": True, "data": data} except ValueError as e: _raise_from_value_error(e) @@ -223,15 +205,18 @@ async def search_remote_skills_route( async def prepare_remote_skills_route( payload: RemoteSkillPrepareRequest, current_user: User = Depends(get_required_user), - db: AsyncSession = Depends(get_db), ): try: - data = await prepare_remote_skill_install( - db, + data = await create_remote_skill_draft( source=payload.source, skills=payload.skills, operator=current_user, ) + allowed = get_allowed_skill_access_levels(current_user) + data["allowed_access_levels"] = allowed + data["default_share_config"] = normalize_skill_share_config( + None, operator_uid=current_user.uid, allowed_access_levels=set(allowed) + ) return {"success": True, "data": data} except ValueError as e: _raise_from_value_error(e) @@ -346,7 +331,7 @@ async def list_skills_route( db: AsyncSession = Depends(get_db), ): try: - items = await list_visible_skills_for_management(db, current_user) + items = await SkillRepository(db).list_visible_for_management(current_user) return { "success": True, "data": [_serialize_skill_for_user(item, current_user) for item in items], @@ -384,7 +369,7 @@ async def list_builtin_skills_route( db: AsyncSession = Depends(get_db), ): try: - items = [item for item in await list_skills(db) if item.source_type == "builtin"] + items = await SkillRepository(db).list_builtin() return {"success": True, "data": [item.to_dict() for item in items]} except ValueError as e: _raise_from_value_error(e) @@ -502,7 +487,6 @@ async def create_skill_file_route( relative_path=payload.path, is_dir=payload.is_dir, content=payload.content, - updated_by=current_user.uid, operator=current_user, ) return {"success": True} @@ -523,15 +507,17 @@ async def update_skill_file_route( db: AsyncSession = Depends(get_db), ): try: - await update_skill_file( + item, revision = await edit_shared_skill_file( db, slug=slug, relative_path=payload.path, content=payload.content, - updated_by=current_user.uid, + expected_revision=payload.expected_revision, operator=current_user, ) - return {"success": True} + return {"success": True, "data": {"skill": _serialize_skill_for_user(item, current_user), "revision": revision}} + except SkillEditConflict as e: + raise HTTPException(status_code=409, detail=str(e)) from e except ValueError as e: _raise_from_value_error(e) except HTTPException: @@ -549,15 +535,18 @@ async def update_skill_dependencies_route( db: AsyncSession = Depends(get_db), ): try: - item = await update_skill_dependencies( + item, revision = await edit_shared_skill_dependencies( db, slug=slug, tool_dependencies=payload.tool_dependencies, mcp_dependencies=payload.mcp_dependencies, skill_dependencies=payload.skill_dependencies, + expected_revision=payload.expected_revision, operator=current_user, ) - return {"success": True, "data": _serialize_skill_for_user(item, current_user)} + return {"success": True, "data": {"skill": _serialize_skill_for_user(item, current_user), "revision": revision}} + except SkillEditConflict as e: + raise HTTPException(status_code=409, detail=str(e)) from e except ValueError as e: _raise_from_value_error(e) except HTTPException: @@ -640,3 +629,33 @@ async def delete_skills_batch_route( except Exception as e: logger.error(f"Failed to delete skills batch: {e}") raise HTTPException(status_code=500, detail="批量删除技能失败") + + +def _raise_from_value_error(e: ValueError) -> None: + """按现有错误契约映射 HTTP 状态。""" + message = str(e) + status_code = 404 if "不存在" in message or "无权" in message else 400 + raise HTTPException(status_code=status_code, detail=message) + + +def _cleanup_export_file(path: str) -> None: + """响应完成后清理临时导出文件,记录清理失败。""" + try: + Path(path).unlink(missing_ok=True) + except Exception as e: + logger.warning(f"Failed to cleanup exported skill archive '{path}': {e}") + + +def _summarize_results(results: list[dict]) -> dict[str, int]: + """汇总逐项安装或删除结果。""" + succeeded = sum(1 for item in results if item.get("success")) + return {"total": len(results), "success": succeeded, "failed": len(results) - succeeded} + + +def _serialize_skill_for_user(item, user: User) -> dict: + """为 Skill 描述附加当前用户的管理权限。""" + data = item.to_dict() + data["can_manage"] = user_can_manage_skill(user, item) + data["effective_permission"] = resolve_skill_permission(user, item).value + data["is_builtin"] = is_builtin_skill(item) + return data diff --git a/backend/server/utils/lifespan.py b/backend/server/utils/lifespan.py index ea4c5866a4..095d9e35cb 100644 --- a/backend/server/utils/lifespan.py +++ b/backend/server/utils/lifespan.py @@ -87,7 +87,7 @@ async def _startup(app: FastAPI) -> None: async def initialize_builtin_skills() -> None: """在独立事务中安装内置 Skills。""" - from yuxi.agents.skills.service import init_builtin_skills + from yuxi.services.skills.shared import init_builtin_skills async with pg_manager.get_async_session_context() as session: await init_builtin_skills(session) diff --git a/backend/test/e2e/test_personal_skill_agent_e2e.py b/backend/test/e2e/test_personal_skill_agent_e2e.py index 0a8c941751..72b3a80d54 100644 --- a/backend/test/e2e/test_personal_skill_agent_e2e.py +++ b/backend/test/e2e/test_personal_skill_agent_e2e.py @@ -14,7 +14,8 @@ make_test_conversation_title, remove_e2e_thread_storage, ) -from yuxi.agents.skills.service import get_personal_skills_root_dir, get_user_skills_root_dir +from yuxi.services.skills.projection import get_user_skills_root_dir +from yuxi.workspace.paths import user_workspace_dir pytestmark = [pytest.mark.asyncio, pytest.mark.e2e, pytest.mark.slow] @@ -133,7 +134,7 @@ async def test_main_agent_reads_personal_skill_directly_from_user_workspace( finally: await conn.close() - personal_skill = get_personal_skills_root_dir(uid) / slug / "SKILL.md" + personal_skill = user_workspace_dir(uid) / "agents" / "skills" / slug / "SKILL.md" assert personal_skill.read_text(encoding="utf-8") == skill_md projected_skill = get_user_skills_root_dir(uid) / slug / "SKILL.md" assert not projected_skill.exists() diff --git a/backend/test/e2e/test_shared_skill_edit_e2e.py b/backend/test/e2e/test_shared_skill_edit_e2e.py new file mode 100644 index 0000000000..d7c9d12bb0 --- /dev/null +++ b/backend/test/e2e/test_shared_skill_edit_e2e.py @@ -0,0 +1,140 @@ +"""共享 Skill 编辑后由真实 worker 加载新内容。""" + +from __future__ import annotations + +import hashlib +import json +import uuid + +import asyncpg +import httpx +import pytest +from e2e_helpers import cancel_run, delete_agent, postgres_dsn, wait_for_run +from yuxi.services.skills.projection import get_user_skills_root_dir + +from test_deterministic_agent_path_e2e import MODEL_SPEC, _create_provider, _delete_provider + +pytestmark = [pytest.mark.asyncio, pytest.mark.e2e, pytest.mark.slow, pytest.mark.timeout(360)] + + +async def test_edited_shared_skill_is_loaded_by_next_run(e2e_client: httpx.AsyncClient, e2e_headers: dict[str, str]): + """从 HTTP 编辑到 worker Run,回读实际投影与持久运行清单。""" + slug = f"pytest-run-skill-{uuid.uuid4().hex[:8]}" + agent_slug = f"pytest-run-agent-{uuid.uuid4().hex[:8]}" + original = f"---\nname: {slug}\nslug: {slug}\ndescription: before\n---\n# Before\n" + marker = f"UPDATED_SHARED_SKILL_{uuid.uuid4().hex}" + updated = original.replace( + "description: before", "description: after\ntool_dependencies:\n- present_artifacts" + ).replace("# Before", f"# 图片生成技能\n{marker}") + me = await e2e_client.get("/api/auth/me", headers=e2e_headers) + assert me.status_code == 200, me.text + uid = str(me.json()["uid"]) + provider_created = False + skill_created = False + agent_created = False + thread_id = None + run_id = None + try: + prepared = await e2e_client.post( + "/api/skills/import/prepare", + headers=e2e_headers, + files={"file": ("SKILL.md", original.encode(), "text/markdown")}, + ) + assert prepared.status_code == 200, prepared.text + draft_id = prepared.json()["data"]["draft_id"] + confirmed = await e2e_client.post( + f"/api/skills/install-drafts/{draft_id}/confirm", + headers=e2e_headers, + json={"slugs": [slug], "share_config": None}, + ) + assert confirmed.status_code == 200, confirmed.text + assert confirmed.json()["data"][0]["success"] is True + skill_created = True + + saved = await e2e_client.put( + f"/api/system/skills/{slug}/file", + headers=e2e_headers, + json={ + "path": "SKILL.md", + "content": updated, + "expected_revision": hashlib.sha256(original.encode()).hexdigest(), + }, + ) + assert saved.status_code == 200, saved.text + + await _create_provider(e2e_client, e2e_headers) + provider_created = True + agent = await e2e_client.post( + "/api/agent", + headers=e2e_headers, + json={ + "name": agent_slug, + "slug": agent_slug, + "backend_id": "ChatbotAgent", + "description": "共享 Skill 编辑后加载测试", + "config_json": { + "context": { + "model": MODEL_SPEC, + "system_prompt": "不要调用工具,只输出 DETERMINISTIC_AGENT_E2E_OK。", + "tools": [], + "knowledges": [], + "mcps": [], + "skills": [slug], + "preload_skills": [slug], + "subagents": [], + } + }, + "share_config": { + "version": 2, + "read_scope": {"access_level": "user", "department_ids": [], "user_uids": [uid]}, + "manage_scope": None, + }, + }, + ) + assert agent.status_code == 200, agent.text + agent_created = True + thread = await e2e_client.post( + "/api/chat/thread", + headers=e2e_headers, + json={"agent_id": agent_slug, "title": f"pytest-shared-edit-{uuid.uuid4().hex[:8]}"}, + ) + assert thread.status_code == 200, thread.text + thread_id = str(thread.json().get("thread_id") or thread.json()["id"]) + run = await e2e_client.post( + "/api/agent/runs", + headers=e2e_headers, + json={ + "agent_slug": agent_slug, + "thread_id": thread_id, + "query": "只输出 DETERMINISTIC_AGENT_E2E_OK", + "meta": {"request_id": str(uuid.uuid4())}, + }, + ) + assert run.status_code == 200, run.text + run_id = str(run.json()["run_id"]) + final = await wait_for_run(e2e_client, e2e_headers, run_id) + assert final["status"] == "completed", final + assert get_user_skills_root_dir(uid).joinpath(slug, "SKILL.md").read_text(encoding="utf-8") == updated + + conn = await asyncpg.connect(postgres_dsn()) + try: + raw_manifest = await conn.fetchval("SELECT manifest FROM agent_runs WHERE id = $1", run_id) + finally: + await conn.close() + manifest = json.loads(raw_manifest) if isinstance(raw_manifest, str) else raw_manifest + assert [item["slug"] for item in manifest["resources"]["skills"]] == [slug] + assert ( + manifest["resources"]["skills"][0]["preload_content_hash"] == hashlib.sha256(updated.encode()).hexdigest() + ) + finally: + await cancel_run(e2e_client, e2e_headers, run_id) + if thread_id: + response = await e2e_client.delete(f"/api/chat/thread/{thread_id}", headers=e2e_headers) + assert response.status_code in {200, 404}, response.text + if agent_created: + await delete_agent(e2e_client, e2e_headers, agent_slug) + if provider_created: + await _delete_provider(e2e_client, e2e_headers) + if skill_created: + response = await e2e_client.delete(f"/api/system/skills/{slug}", headers=e2e_headers) + assert response.status_code in {200, 404}, response.text diff --git a/backend/test/integration/api/test_shared_skill_edit_router.py b/backend/test/integration/api/test_shared_skill_edit_router.py new file mode 100644 index 0000000000..c16e77e495 --- /dev/null +++ b/backend/test/integration/api/test_shared_skill_edit_router.py @@ -0,0 +1,247 @@ +"""共享 Skill 编辑经真实 HTTP、PostgreSQL 和文件来源的结果。""" + +from __future__ import annotations + +import asyncio +import hashlib +import os +import uuid + +import pytest +import yaml +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from yuxi.repositories.skill_repository import SkillRepository +from yuxi.services.skills.projection import get_user_skills_root_dir, sync_user_accessible_skills +from yuxi.services.skills.shared import ( + get_skills_root_dir, + lock_accessible_shared_skills_for_runtime, +) +from yuxi.storage.postgres.models_business import Skill, User + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +async def test_shared_skill_edit_updates_file_and_index_and_rejects_stale_or_unauthorized( + test_client, admin_headers, standard_user +): + """保存后回读两个事实来源,旧修订与无权限用户不能覆盖。""" + slug = f"pytest-edit-{uuid.uuid4().hex[:10]}" + original = f"---\r\nname: {slug}\r\nslug: {slug}\r\ndescription: before\r\n---\r\n# Before\r\n" + prepared = await test_client.post( + "/api/skills/import/prepare", + headers=admin_headers, + files={"file": ("SKILL.md", original.encode(), "text/markdown")}, + ) + assert prepared.status_code == 200, prepared.text + draft_id = prepared.json()["data"]["draft_id"] + confirmed = await test_client.post( + f"/api/skills/install-drafts/{draft_id}/confirm", + headers=admin_headers, + json={"slugs": [slug], "share_config": None}, + ) + assert confirmed.status_code == 200, confirmed.text + + try: + read = await test_client.get( + f"/api/system/skills/{slug}/file", params={"path": "SKILL.md"}, headers=admin_headers + ) + assert read.status_code == 200, read.text + revision = read.json()["data"]["revision"] + assert revision == hashlib.sha256(original.encode()).hexdigest() + assert read.json()["data"]["content"] == original + + missing_revision = await test_client.put( + f"/api/system/skills/{slug}/file", + headers=admin_headers, + json={"path": "SKILL.md", "content": original}, + ) + assert missing_revision.status_code == 422, missing_revision.text + + updated_content = original.replace("before", "after").replace("# Before", "# After") + saved = await test_client.put( + f"/api/system/skills/{slug}/file", + headers=admin_headers, + json={"path": "SKILL.md", "content": updated_content, "expected_revision": revision}, + ) + assert saved.status_code == 200, saved.text + saved_revision = saved.json()["data"]["revision"] + assert saved_revision == hashlib.sha256(updated_content.encode()).hexdigest() + + stale = await test_client.put( + f"/api/system/skills/{slug}/file", + headers=admin_headers, + json={"path": "SKILL.md", "content": original, "expected_revision": revision}, + ) + assert stale.status_code == 409, stale.text + + denied = await test_client.put( + f"/api/system/skills/{slug}/file", + headers=standard_user["headers"], + json={"path": "SKILL.md", "content": original, "expected_revision": revision}, + ) + assert denied.status_code in {403, 404}, denied.text + + options = await test_client.get( + "/api/system/skills/dependency-options", params={"slug": slug}, headers=admin_headers + ) + assert options.status_code == 200, options.text + tool_slug = options.json()["data"]["tools"][0]["slug"] + dependencies = await test_client.put( + f"/api/system/skills/{slug}/dependencies", + headers=admin_headers, + json={ + "tool_dependencies": [tool_slug], + "mcp_dependencies": [], + "skill_dependencies": [], + "expected_revision": saved_revision, + }, + ) + assert dependencies.status_code == 200, dependencies.text + + root_snapshot = await test_client.get( + f"/api/system/skills/{slug}/file", params={"path": "SKILL.md"}, headers=admin_headers + ) + assert root_snapshot.status_code == 200, root_snapshot.text + assert root_snapshot.json()["data"]["skill"]["tool_dependencies"] == [tool_slug] + assert root_snapshot.json()["data"]["revision"] == dependencies.json()["data"]["revision"] + + source = get_skills_root_dir() / slug / "SKILL.md" + persisted_content = source.read_text(encoding="utf-8") + frontmatter = yaml.safe_load(persisted_content.split("---", 2)[1]) + assert frontmatter["description"] == "after" + assert frontmatter["tool_dependencies"] == [tool_slug] + assert "# After" in persisted_content + engine = create_async_engine(os.environ["POSTGRES_URL"]) + try: + session_factory = async_sessionmaker(engine, expire_on_commit=False) + async with session_factory() as db: + row = (await db.execute(select(Skill).where(Skill.slug == slug))).scalar_one() + assert row.description == "after" + assert row.tool_dependencies == [tool_slug] + + async with session_factory() as editor, session_factory() as runtime: + await editor.execute(select(Skill).where(Skill.slug == slug).with_for_update()) + runtime_read = asyncio.create_task(SkillRepository(runtime).lock_rows_for_read([row.id])) + await asyncio.sleep(0.2) + assert not runtime_read.done(), "运行时读取必须等待编辑行锁释放" + await editor.rollback() + assert any(item.slug == slug for item in await asyncio.wait_for(runtime_read, 5)) + finally: + await engine.dispose() + + profile = await test_client.get("/api/auth/me", headers=admin_headers) + assert profile.status_code == 200, profile.text + uid = profile.json()["uid"] + sync_user_accessible_skills(uid, {slug: source.parent}) + assert (get_user_skills_root_dir(uid) / slug / "SKILL.md").read_text(encoding="utf-8") == persisted_content + finally: + deleted = await test_client.delete(f"/api/system/skills/{slug}", headers=admin_headers) + assert deleted.status_code == 200, deleted.text + + +@pytest.mark.parametrize("personal_override", [False, True]) +async def test_runtime_read_does_not_lock_unrelated_private_skill(personal_override): + """其他用户的私有 Skill 正在编辑时,当前用户仍可读取自己的共享快照。""" + suffix = uuid.uuid4().hex[:10] + reader_uid = f"reader-{suffix}" + engine = create_async_engine(os.environ["POSTGRES_URL"]) + session_factory = async_sessionmaker(engine, expire_on_commit=False) + visible = Skill( + slug=f"visible-{suffix}", + name="visible", + description="visible", + source_type="upload", + dir_path=f"shared/visible-{suffix}", + enabled=True, + created_by=reader_uid, + share_config={"version": 2, "read_scope": {"access_level": "user", "user_uids": [reader_uid]}}, + ) + hidden = Skill( + slug=f"hidden-{suffix}", + name="hidden", + description="hidden", + source_type="upload", + dir_path=f"shared/hidden-{suffix}", + enabled=True, + created_by=f"owner-{suffix}", + share_config={"version": 2, "read_scope": {"access_level": "user", "user_uids": [f"owner-{suffix}"]}}, + ) + if personal_override: + hidden.share_config = {"version": 2, "read_scope": {"access_level": "global"}} + try: + async with session_factory() as setup: + setup.add_all([visible, hidden]) + await setup.commit() + + async with session_factory() as editor, session_factory() as runtime: + await editor.execute(select(Skill).where(Skill.id == hidden.id).with_for_update()) + reader = User(uid=reader_uid, role="user") + items = await asyncio.wait_for( + lock_accessible_shared_skills_for_runtime( + runtime, + reader, + [visible.slug, hidden.slug], + shadowed_slugs={hidden.slug} if personal_override else set(), + ), + timeout=2, + ) + slugs = {item.slug for item in items} + assert visible.slug in slugs + assert hidden.slug not in slugs + await editor.rollback() + finally: + async with session_factory() as cleanup: + for item_id in (visible.id, hidden.id): + if item_id is not None: + found = await cleanup.get(Skill, item_id) + if found is not None: + await cleanup.delete(found) + await cleanup.commit() + await engine.dispose() + + +async def test_unquoted_multiline_description_can_be_saved_through_http(test_client, admin_headers): + """预览支持的多行描述可经真实安装和依赖编辑路径保存。""" + slug = f"pytest-multiline-{uuid.uuid4().hex[:10]}" + content = ( + f"---\nname: {slug}\ndescription:\n" + ' Use this skill for PDFs.\n CREATE (from scratch): "make a PDF".\n' + "license: MIT\n---\n# Body\n" + ) + prepared = await test_client.post( + "/api/skills/import/prepare", + headers=admin_headers, + files={"file": ("SKILL.md", content.encode(), "text/markdown")}, + ) + assert prepared.status_code == 200, prepared.text + draft_id = prepared.json()["data"]["draft_id"] + confirmed = await test_client.post( + f"/api/skills/install-drafts/{draft_id}/confirm", + headers=admin_headers, + json={"slugs": [slug], "share_config": None}, + ) + assert confirmed.status_code == 200, confirmed.text + + try: + read = await test_client.get( + f"/api/system/skills/{slug}/file", params={"path": "SKILL.md"}, headers=admin_headers + ) + assert read.status_code == 200, read.text + saved = await test_client.put( + f"/api/system/skills/{slug}/dependencies", + headers=admin_headers, + json={ + "tool_dependencies": [], + "mcp_dependencies": [], + "skill_dependencies": [], + "expected_revision": read.json()["data"]["revision"], + }, + ) + assert saved.status_code == 200, saved.text + assert saved.json()["data"]["skill"]["description"] == ( + 'Use this skill for PDFs. CREATE (from scratch): "make a PDF".' + ) + finally: + deleted = await test_client.delete(f"/api/system/skills/{slug}", headers=admin_headers) + assert deleted.status_code == 200, deleted.text diff --git a/backend/test/integration/api/test_skill_artifact_authorization.py b/backend/test/integration/api/test_skill_artifact_authorization.py index a8220059cd..cc038a5467 100644 --- a/backend/test/integration/api/test_skill_artifact_authorization.py +++ b/backend/test/integration/api/test_skill_artifact_authorization.py @@ -11,7 +11,8 @@ from sqlalchemy import delete from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from yuxi.agents.skills import service as skill_service +from yuxi.services.skills import shared as skill_service +from yuxi.services.skills import projection as projection_service from yuxi.storage.postgres.models_business import Skill from test.live_api_cleanup import make_test_conversation_metadata, make_test_conversation_title @@ -58,7 +59,7 @@ async def test_skill_artifact_rechecks_authorization_after_share_revoke( engine = create_async_engine(os.environ["POSTGRES_URL"], pool_pre_ping=True) session_factory = async_sessionmaker(engine, expire_on_commit=False) skill_id: int | None = None - projection_paths = [skill_service.get_user_skills_root_dir(uid) / slug for uid in (admin_uid, user_uid)] + projection_paths = [projection_service.get_user_skills_root_dir(uid) / slug for uid in (admin_uid, user_uid)] try: async with session_factory() as db: skill = Skill( @@ -87,7 +88,7 @@ async def test_skill_artifact_rechecks_authorization_after_share_revoke( skill_id = skill.id for uid in (admin_uid, user_uid): - skill_service.sync_user_accessible_skills(uid, {slug: source_dir}) + projection_service.sync_user_accessible_skills(uid, {slug: source_dir}) admin_thread = await _create_thread(test_client, admin_headers, f"skill-artifact-admin-{suffix[:8]}") user_thread = await _create_thread(test_client, user_headers, f"skill-artifact-user-{suffix[:8]}") diff --git a/backend/test/integration/services/test_builtin_discovery.py b/backend/test/integration/services/test_builtin_discovery.py index 217fd3ecb6..149ef46286 100644 --- a/backend/test/integration/services/test_builtin_discovery.py +++ b/backend/test/integration/services/test_builtin_discovery.py @@ -11,7 +11,7 @@ from yuxi.agents import presets from yuxi.agents.buildin import AgentBackendNotFoundError -from yuxi.agents.skills import service as skill_service +from yuxi.services.skills import shared as skill_service from yuxi.services.agent_config_service import initialize_agent_presets from yuxi.storage.postgres.models_business import Agent, Skill diff --git a/backend/test/integration/services/test_project_workdir_provisioner.py b/backend/test/integration/services/test_project_workdir_provisioner.py index 69b9031a00..a3139ab9b0 100644 --- a/backend/test/integration/services/test_project_workdir_provisioner.py +++ b/backend/test/integration/services/test_project_workdir_provisioner.py @@ -15,7 +15,7 @@ user_workspace_dir, workspace_uid_dirname, ) -from yuxi.agents.skills.service import get_user_skills_root_dir, sync_user_accessible_skills_async +from yuxi.services.skills.projection import get_user_skills_root_dir, sync_user_accessible_skills from yuxi.config import get_skill_projection_dir, get_user_data_dir pytestmark = [pytest.mark.asyncio, pytest.mark.integration] @@ -241,14 +241,15 @@ async def test_user_skill_projection_is_shared_across_sandboxes_but_isolated_by_ (selected_source / "SKILL.md").write_text("selected-skill", encoding="utf-8") (unselected_source / "SKILL.md").write_text("authorized-unselected-skill", encoding="utf-8") - await sync_user_accessible_skills_async( + await asyncio.to_thread( + sync_user_accessible_skills, uid, { "selected": selected_source, "authorized-unselected": unselected_source, }, ) - await sync_user_accessible_skills_async(other_uid, {}) + await asyncio.to_thread(sync_user_accessible_skills, other_uid, {}) ensure_user_workspace(uid) ensure_user_workspace(other_uid) @@ -283,8 +284,8 @@ async def test_user_skill_projection_is_shared_across_sandboxes_but_isolated_by_ canonical_not_found = f"file '{selected_skill_path}' not found" assert any(marker in other_error for marker in ("does not exist", canonical_not_found, "filenotfounderror")) finally: - await sync_user_accessible_skills_async(uid, {}) - await sync_user_accessible_skills_async(other_uid, {}) + await asyncio.to_thread(sync_user_accessible_skills, uid, {}) + await asyncio.to_thread(sync_user_accessible_skills, other_uid, {}) for scope, scope_uid in ( (first_scope, uid), (second_scope, uid), diff --git a/backend/test/integration/services/test_user_skill_projection.py b/backend/test/integration/services/test_user_skill_projection.py index c9816a4c41..2c9d64be59 100644 --- a/backend/test/integration/services/test_user_skill_projection.py +++ b/backend/test/integration/services/test_user_skill_projection.py @@ -10,12 +10,13 @@ import pytest from sqlalchemy import delete, select, text, update +from sqlalchemy.exc import DBAPIError from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine - -from yuxi.agents.skills import service as skill_service -from yuxi.storage_migrations import v071_skills +from yuxi.services.skills import projection as projection_service from yuxi.storage.postgres.manager import pg_manager from yuxi.storage.postgres.models_business import Skill, User +from yuxi.storage_migrations import v071_skills +from yuxi.workspace.paths import user_workspace_dir pytestmark = [pytest.mark.asyncio, pytest.mark.integration] @@ -65,13 +66,8 @@ async def local_session_context(): raise monkeypatch.setattr(pg_manager, "get_async_session_context", local_session_context) - monkeypatch.setattr(skill_service, "get_skill_data_dir", lambda: tmp_path / "skill-sources") - monkeypatch.setattr(skill_service, "get_skill_projection_dir", lambda: tmp_path / "skill-projections") - - async def no_personal_skills(_uid: str): - return [] - - monkeypatch.setattr(skill_service, "list_personal_skills", no_personal_skills) + monkeypatch.setattr(projection_service, "get_skill_data_dir", lambda: tmp_path / "skill-sources") + monkeypatch.setattr(projection_service, "get_skill_projection_dir", lambda: tmp_path / "skill-projections") suffix = uuid.uuid4().hex uid = f"pytest-skill-user-{suffix}" @@ -114,10 +110,32 @@ async def no_personal_skills(_uid: str): user_id = user.id skill_id = skill.id - skill_service.sync_user_accessible_skills(uid, {slug: source_dir}) - projection = skill_service.get_user_skills_root_dir(uid) + projection_service.sync_user_accessible_skills(uid, {slug: source_dir}) + projection = projection_service.get_user_skills_root_dir(uid) assert (projection / slug / "SKILL.md").is_file() + source_file = source_dir / "SKILL.md" + async with session_factory() as edit_db: + await edit_db.execute(select(Skill).where(Skill.id == skill_id).with_for_update()) + source_file.write_text("# uncommitted\n", encoding="utf-8") + try: + refresh_task = asyncio.create_task(projection_service.refresh_user_skill_projection_async(uid)) + await asyncio.sleep(0.2) + assert not refresh_task.done(), "投影不得读取尚未提交的 Skill 文件" + assert (projection / slug / "SKILL.md").read_text(encoding="utf-8") == "# authorized\n" + finally: + source_file.write_text("# authorized\n", encoding="utf-8") + await edit_db.rollback() + + await asyncio.wait_for(refresh_task, timeout=5) + assert (projection / slug / "SKILL.md").read_text(encoding="utf-8") == "# authorized\n" + + async with session_factory() as db: + await db.execute(update(Skill).where(Skill.id == skill_id).values(enabled=False)) + await db.commit() + await projection_service.refresh_user_skill_projection_async(uid) + assert not (projection / slug).exists() + async with session_factory() as lock_db: await lock_db.execute( text("SELECT pg_advisory_xact_lock(hashtext(:lock_scope))"), @@ -132,32 +150,47 @@ async def no_personal_skills(_uid: str): ) ) ).one() - refresh_task = asyncio.create_task(skill_service.refresh_user_skill_projection_async(uid)) + try: + refresh_task = asyncio.create_task(projection_service.refresh_user_skill_projection_async(uid)) + assert await _wait_for_advisory_waiter(session_factory, lock_identity) + async with session_factory() as checker: + await checker.execute(text("SET LOCAL lock_timeout = '200ms'")) + with pytest.raises(DBAPIError, match="lock timeout"): + await checker.execute(select(Skill).where(Skill.id == skill_id).with_for_update()) + await checker.rollback() + finally: + await lock_db.commit() - assert await _wait_for_advisory_waiter(session_factory, lock_identity), ( - "refresh did not wait on the expected PostgreSQL advisory lock" - ) - assert not refresh_task.done() + await asyncio.wait_for(refresh_task, timeout=5) + async with session_factory() as db: + await db.execute(update(Skill).where(Skill.id == skill_id).values(enabled=True)) + await db.commit() + await projection_service.refresh_user_skill_projection_async(uid) + assert (projection / slug / "SKILL.md").is_file() - async with session_factory() as revoke_db: - await revoke_db.execute( - update(Skill) - .where(Skill.id == skill_id) - .values( - share_config={ - "version": 2, - "read_scope": { - "access_level": "user", - "department_ids": [], - "user_uids": ["different-user"], - }, - "manage_scope": None, - } - ) + async with session_factory() as policy_db: + await policy_db.execute( + update(Skill) + .where(Skill.id == skill_id) + .values( + share_config={ + "version": 2, + "read_scope": { + "access_level": "user", + "department_ids": [], + "user_uids": ["different-user"], + }, + "manage_scope": None, + } ) - await revoke_db.commit() - - await lock_db.commit() + ) + refresh_task = asyncio.create_task(projection_service.refresh_user_skill_projection_async(uid)) + await asyncio.sleep(0.2) + assert not refresh_task.done() + policy_task = asyncio.create_task( + projection_service.commit_skill_policy_and_refresh_projections(policy_db, slug) + ) + await asyncio.wait_for(policy_task, timeout=5) refreshed_sources = await asyncio.wait_for(refresh_task, timeout=5) assert slug not in refreshed_sources @@ -180,7 +213,7 @@ async def no_personal_skills(_uid: str): ) ) await db.commit() - await skill_service.refresh_user_skill_projection_async(uid) + await projection_service.refresh_user_skill_projection_async(uid) assert (projection / slug / "SKILL.md").is_file() async with session_factory() as lock_db: @@ -213,7 +246,9 @@ async def no_personal_skills(_uid: str): } ) ) - policy_task = asyncio.create_task(skill_service.apply_skill_projection_policy_change(policy_db, slug)) + policy_task = asyncio.create_task( + projection_service.commit_skill_policy_and_refresh_projections(policy_db, slug) + ) assert await _wait_for_advisory_waiter(session_factory, lock_identity), ( "policy mutation did not wait on the uid projection lock" ) @@ -298,7 +333,7 @@ async def test_legacy_shared_skill_migrates_without_touching_personal_workspace( user_id = user.id skill_id = skill.id - original_rmtree = skill_service.shutil.rmtree + original_rmtree = projection_service.shutil.rmtree cleanup_failed = False def fail_shared_cleanup_once(path, *args, **kwargs): @@ -328,7 +363,7 @@ def fail_shared_cleanup_once(path, *args, **kwargs): assert "shared-marker" in (tmp_path / "skill-sources/shared" / shared_slug / "SKILL.md").read_text( encoding="utf-8" ) - assert skill_service.get_personal_skills_root_dir(uid) / personal_slug == legacy_personal + assert user_workspace_dir(uid) / "agents" / "skills" / personal_slug == legacy_personal assert "personal-marker" in (legacy_personal / "SKILL.md").read_text(encoding="utf-8") assert not legacy_shared.exists() assert legacy_personal.is_dir() diff --git a/backend/test/performance/probe.py b/backend/test/performance/probe.py index 497f3ef86b..120983b214 100644 --- a/backend/test/performance/probe.py +++ b/backend/test/performance/probe.py @@ -114,7 +114,7 @@ def run(): from yuxi.services.arq_worker import run_worker from yuxi.agents import BaseAgent from yuxi.agents.buildin.chatbot import graph - from yuxi.agents.skills import service + from yuxi.services.skills import projection as skill_projection_service from yuxi.services import agent_run_manifest_service, chat_service, run_worker as worker from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver @@ -149,7 +149,7 @@ def run(): wrap(BaseAgent, "_get_checkpointer") wrap(graph.ChatbotAgent, "get_graph") wrap(AsyncPostgresSaver, "aget_tuple") - wrap(service, "sync_user_accessible_skills") + wrap(skill_projection_service, "sync_user_accessible_skills") original_process = worker.process_agent_run @functools.wraps(original_process) diff --git a/backend/test/performance/stage_probe.py b/backend/test/performance/stage_probe.py index c315e29eed..05cc97203e 100644 --- a/backend/test/performance/stage_probe.py +++ b/backend/test/performance/stage_probe.py @@ -149,7 +149,7 @@ def install(probe, app=None): "yuxi.services.run_queue_service", "yuxi.agents.context", "yuxi.agents.skills.runtime", - "yuxi.agents.skills.service", + "yuxi.services.skills.shared", "yuxi.agents.backends.composite", "yuxi.agents.buildin.chatbot.graph", "server.utils.auth_middleware", diff --git a/backend/test/unit/agents/skills/test_mysql_reporter_scripts.py b/backend/test/unit/agents/skills/test_mysql_reporter_scripts.py index 2be99b22c8..454c95171c 100644 --- a/backend/test/unit/agents/skills/test_mysql_reporter_scripts.py +++ b/backend/test/unit/agents/skills/test_mysql_reporter_scripts.py @@ -10,9 +10,8 @@ from types import ModuleType import pytest - from yuxi.agents.skills.buildin import BUILTIN_SKILLS_DIR -from yuxi.agents.skills.service import copy_skill_tree_no_symlinks +from yuxi.services.skills.package import copy_skill_tree_no_symlinks def _mysql_reporter_dir() -> Path: diff --git a/backend/test/unit/agents/skills/test_skill_runtime.py b/backend/test/unit/agents/skills/test_skill_runtime.py index 042f5f9581..d3e85a2559 100644 --- a/backend/test/unit/agents/skills/test_skill_runtime.py +++ b/backend/test/unit/agents/skills/test_skill_runtime.py @@ -1,9 +1,32 @@ from types import SimpleNamespace import pytest - import yuxi.agents.skills.runtime as skill_runtime +from sqlalchemy.dialects import postgresql from yuxi.agents.skills.runtime import build_dependency_bundle, expand_skill_closure, resolve_runtime_skills_for_context +from yuxi.repositories.skill_repository import SkillRepository as RealSkillRepository +from yuxi.workspace.paths import user_workspace_dir + + +def _mock_runtime_sources(monkeypatch, accessible): + """让运行时组合测试使用给定共享和个人候选。""" + + async def locked(db, user, _selected, *, shadowed_slugs=None): + items = await accessible(db, user) + shadowed_slugs = shadowed_slugs or set() + return [ + item + for item in items + if item.source_scope != "personal" and item.slug not in shadowed_slugs + ] + + async def personal(uid): + items = await accessible(object(), SimpleNamespace(uid=uid)) + return [item for item in items if item.source_scope == "personal"] + + monkeypatch.setattr(skill_runtime, "lock_accessible_shared_skills_for_runtime", locked) + monkeypatch.setattr(skill_runtime, "list_personal_skills", personal) + monkeypatch.setattr(skill_runtime, "resolved_shared_skill", lambda item: item) @pytest.mark.asyncio @@ -14,12 +37,12 @@ async def test_personal_skills_are_available_independently_of_shared_selection( ): """真实个人目录始终参与运行,选项仅共享且其他用户目录不可见。""" from yuxi.agents.context import normalize_agent_context_config, resolve_agent_resource_options - from yuxi.agents.skills import service + from yuxi.services.skills import shared as service from yuxi.storage.postgres.models_business import Skill from yuxi.workspace import paths monkeypatch.setattr(paths, "get_user_data_dir", lambda: tmp_path / "user-data") - monkeypatch.setattr(service, "get_skill_data_dir", lambda: tmp_path / "shared") + monkeypatch.setattr(service, "get_skill_data_dir", lambda: tmp_path) shared_dir = tmp_path / "shared" / "extra" shared_dir.mkdir(parents=True) (shared_dir / "SKILL.md").write_text("# Extra shared body", encoding="utf-8") @@ -29,7 +52,7 @@ async def test_personal_skills_are_available_independently_of_shared_selection( name="Shared title", description="shared description", source_type="upload", - dir_path="shared", + dir_path="shared/shared", enabled=True, created_by="user-a", share_config={"version": 2, "read_scope": {"access_level": "global"}, "manage_scope": None}, @@ -43,7 +66,7 @@ async def test_personal_skills_are_available_independently_of_shared_selection( name="Extra", description="extra shared", source_type="upload", - dir_path="extra", + dir_path="shared/extra", enabled=True, created_by="user-a", share_config=shared.share_config, @@ -52,7 +75,7 @@ async def test_personal_skills_are_available_independently_of_shared_selection( skill_dependencies=[], ) - class SkillRepository: + class FakeSkillRepository(RealSkillRepository): """提供共享记录,个人来源由真实目录扫描。""" def __init__(self, db): @@ -62,9 +85,18 @@ async def list_enabled(self): """返回测试共享记录。""" return [shared, extra] - monkeypatch.setattr(service, "SkillRepository", SkillRepository) + async def lock_rows_for_read(self, ids): + """模拟按可见 ID 锁定共享来源。""" + return [item for item in (shared, extra) if item.id in ids] + + async def get_by_slug_for_read(self, slug): + """模拟单行持锁读取。""" + return next((item for item in (shared, extra) if item.slug == slug), None) + + monkeypatch.setattr(service, "SkillRepository", FakeSkillRepository) + monkeypatch.setattr("yuxi.repositories.skill_repository.SkillRepository", FakeSkillRepository) for uid, slug in [("user-a", "personal"), ("user-a", "shared"), ("user-b", "other-user")]: - directory = service.get_personal_skills_root_dir(uid) / slug + directory = user_workspace_dir(uid) / "agents" / "skills" / slug directory.mkdir(parents=True) (directory / "SKILL.md").write_text( f"---\nname: {slug}\ndescription: personal {slug}\n---\nPersonal body", encoding="utf-8" @@ -121,9 +153,9 @@ async def test_personal_skill_is_not_a_direct_preload_candidate(tmp_path, monkey async def accessible(_db, _user): return [personal] - monkeypatch.setattr(skill_runtime, "list_accessible_skills", accessible) + _mock_runtime_sources(monkeypatch, accessible) scope = await resolve_runtime_skills_for_context( - SimpleNamespace(skills=[], preload_skills=["personal"]), db=None, user=None + SimpleNamespace(skills=[], preload_skills=["personal"]), db=None, user=SimpleNamespace(uid="test") ) assert scope["context_skills"] == ["personal"] @@ -165,12 +197,12 @@ async def fake_list_accessible_skills(db, user): ), ] - monkeypatch.setattr(skill_runtime, "list_accessible_skills", fake_list_accessible_skills) + _mock_runtime_sources(monkeypatch, fake_list_accessible_skills) scope = await resolve_runtime_skills_for_context( SimpleNamespace(skills=["alpha", "missing"]), db=object(), - user=object(), + user=SimpleNamespace(uid="test"), ) assert scope["context_skills"] == ["alpha", "beta"] @@ -214,11 +246,11 @@ async def test_preload_reads_authorized_dependency_closure(tmp_path, monkeypatch async def fake_list_accessible_skills(_db, _user): return skills - monkeypatch.setattr(skill_runtime, "list_accessible_skills", fake_list_accessible_skills) + _mock_runtime_sources(monkeypatch, fake_list_accessible_skills) scope = await resolve_runtime_skills_for_context( SimpleNamespace(skills=["alpha"], preload_skills=["alpha", "beta", "missing"]), db=object(), - user=object(), + user=SimpleNamespace(uid="test"), ) assert scope["context_preload_skills"] == ["alpha"] @@ -241,13 +273,13 @@ async def test_preload_rejects_symlinked_source_ancestor(tmp_path, monkeypatch): async def fake_list_accessible_skills(_db, _user): return [item] - monkeypatch.setattr(skill_runtime, "list_accessible_skills", fake_list_accessible_skills) + _mock_runtime_sources(monkeypatch, fake_list_accessible_skills) with pytest.raises(RuntimeError, match="根级 SKILL.md 不可读"): await resolve_runtime_skills_for_context( SimpleNamespace(skills=["alpha"], preload_skills=["alpha"]), db=object(), - user=object(), + user=SimpleNamespace(uid="test"), ) @@ -261,11 +293,11 @@ async def test_manifest_retains_metadata_from_authorized_resolution(tmp_path, mo async def accessible(db, user): return [item] - monkeypatch.setattr(skill_runtime, "list_accessible_skills", accessible) + _mock_runtime_sources(monkeypatch, accessible) scope = await resolve_runtime_skills_for_context( SimpleNamespace(skills=["alpha"], preload_skills=["alpha"]), db=object(), - user=object(), + user=SimpleNamespace(uid="test"), ) item.version, item.content_hash = "v2", "hash-v2" (item.source_dir / "SKILL.md").write_text("changed body", encoding="utf-8") @@ -283,7 +315,7 @@ async def accessible(db, user): async def test_preload_all_reads_only_enabled_authorized_skill_closure(tmp_path, monkeypatch, selection, expected): """全部预加载沿真实解析链读取已启用 Skill 及其授权依赖的文件。""" from yuxi.agents.context import normalize_agent_context_config - from yuxi.agents.skills import service + from yuxi.services.skills import shared as service skills = [ _skill(tmp_path, "alpha", dependencies=["beta"]), @@ -294,14 +326,48 @@ async def test_preload_all_reads_only_enabled_authorized_skill_closure(tmp_path, async def accessible(db, user): return skills - monkeypatch.setattr(service, "list_accessible_shared_skills", accessible) - monkeypatch.setattr(skill_runtime, "list_accessible_skills", accessible) + monkeypatch.setattr(service.SkillRepository, "list_enabled_readable", accessible) + _mock_runtime_sources(monkeypatch, accessible) normalized = await normalize_agent_context_config( {"tools": [], "knowledges": [], "skills": selection, "preload_skills": "all"}, db=None, user=None, ) - scope = await resolve_runtime_skills_for_context(SimpleNamespace(**normalized), db=None, user=None) + scope = await resolve_runtime_skills_for_context( + SimpleNamespace(**normalized), db=None, user=SimpleNamespace(uid="test") + ) assert scope["preloaded_skills"] == expected assert scope["preloaded_skill_contents"] == {slug: f"# {slug}" for slug in expected} assert scope["context_preload_skills"] == normalized["skills"] + + +@pytest.mark.asyncio +async def test_runtime_skill_query_holds_shared_row_locks(): + """运行时读元数据时等待共享 Skill 编辑事务完成。""" + statements = [] + + class Session: + async def execute(self, stmt): + statements.append(stmt) + return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: [])) + + await RealSkillRepository(Session()).lock_rows_for_read([1]) + + assert "FOR SHARE" in str(statements[0].compile(dialect=postgresql.dialect())) + assert statements[0].get_execution_options()["populate_existing"] is True + + +@pytest.mark.asyncio +async def test_skill_file_read_uses_shared_row_lock(): + """普通读取允许其他读取并发,仍阻止编辑发布。""" + statements = [] + + class Session: + async def execute(self, stmt): + statements.append(stmt) + return SimpleNamespace(scalar_one_or_none=lambda: None) + + await RealSkillRepository(Session()).get_by_slug_for_read("demo") + + assert "FOR SHARE" in str(statements[0].compile(dialect=postgresql.dialect())) + assert "FOR UPDATE" not in str(statements[0].compile(dialect=postgresql.dialect())) diff --git a/backend/test/unit/agents/test_builtin_discovery.py b/backend/test/unit/agents/test_builtin_discovery.py index d90b5d70d3..44b03738c3 100644 --- a/backend/test/unit/agents/test_builtin_discovery.py +++ b/backend/test/unit/agents/test_builtin_discovery.py @@ -8,7 +8,7 @@ from yuxi.agents import buildin, presets from yuxi.agents.buildin.chatbot.graph import ChatbotAgent from yuxi.agents.buildin.subagent.graph import SubAgentBackend -from yuxi.agents.skills import service as skill_service +from yuxi.services.skills import shared as skill_service @pytest.fixture(autouse=True) diff --git a/backend/test/unit/agents/test_context_auth.py b/backend/test/unit/agents/test_context_auth.py index 3f5cb2fadd..b776b2e739 100644 --- a/backend/test/unit/agents/test_context_auth.py +++ b/backend/test/unit/agents/test_context_auth.py @@ -1,11 +1,10 @@ from __future__ import annotations -from unittest.mock import AsyncMock - import importlib import sys import types from dataclasses import dataclass, field +from unittest.mock import AsyncMock import pytest from yuxi.knowledge.read_models import KnowledgeBaseSummary @@ -225,8 +224,12 @@ async def list_visible_subagents(self, *, user): ) monkeypatch.setitem( sys.modules, - "yuxi.agents.skills.service", - types.SimpleNamespace(list_accessible_shared_skills=fake_list_skills), + "yuxi.repositories.skill_repository", + types.SimpleNamespace( + SkillRepository=lambda db: types.SimpleNamespace( + list_enabled_readable=lambda user: fake_list_skills(db, user) + ) + ), ) monkeypatch.setitem( sys.modules, @@ -439,8 +442,12 @@ async def list_visible_subagents(self, *, user): ) monkeypatch.setitem( sys.modules, - "yuxi.agents.skills.service", - types.SimpleNamespace(list_accessible_shared_skills=fake_list_skills), + "yuxi.repositories.skill_repository", + types.SimpleNamespace( + SkillRepository=lambda db: types.SimpleNamespace( + list_enabled_readable=lambda user: fake_list_skills(db, user) + ) + ), ) monkeypatch.setitem( sys.modules, @@ -571,8 +578,8 @@ def test_persistent_config_cannot_replace_runtime_identity(): @pytest.mark.asyncio async def test_normalized_persistent_config_drops_subagent_runtime_flags(): """状态查询与主动压缩的配置归一化不接受运行标记。""" - from yuxi.agents.context import normalize_agent_context_config from yuxi.agents.buildin.subagent.context import SubAgentContext + from yuxi.agents.context import normalize_agent_context_config normalized = await normalize_agent_context_config( { @@ -657,6 +664,7 @@ class ResourceFieldContext(BaseContext): async def test_resource_field_declarations_drive_write_runtime_and_schema(monkeypatch): """新增声明自动参与写入校验和展开,普通列表保持原样。""" from types import SimpleNamespace + from yuxi.repositories.agent_repository import merge_agent_config_json from yuxi.services.agent_config_service import prepare_agent_config_write diff --git a/backend/test/unit/backends/test_sandbox_backends.py b/backend/test/unit/backends/test_sandbox_backends.py index a5d70e3f78..9afab44b99 100644 --- a/backend/test/unit/backends/test_sandbox_backends.py +++ b/backend/test/unit/backends/test_sandbox_backends.py @@ -278,7 +278,7 @@ def test_create_agent_filesystem_middleware_uses_context_scope(monkeypatch): def test_context_backend_construction_does_not_sync_skill_projection(monkeypatch, tmp_path) -> None: """每轮模型调用重建 backend 时不得扫描或复制 Skill。""" - from yuxi.agents.skills import service as skill_service + from yuxi.services.skills import projection as projection_service monkeypatch.setattr("yuxi.agents.backends.sandbox.backend.get_sandbox_provider", lambda: object()) monkeypatch.setenv("YUXI_USER_DATA_DIR", str(tmp_path / "threads")) @@ -296,7 +296,7 @@ def test_context_backend_construction_does_not_sync_skill_projection(monkeypatch create_agent_composite_backend(context) create_agent_composite_backend(context) - user_skill = skill_service.get_user_skills_root_dir("user-1") / "shared-skill" + user_skill = projection_service.get_user_skills_root_dir("user-1") / "shared-skill" assert not user_skill.exists() diff --git a/backend/test/unit/repositories/test_skill_repository.py b/backend/test/unit/repositories/test_skill_repository.py index 57a3029c56..27009460b2 100644 --- a/backend/test/unit/repositories/test_skill_repository.py +++ b/backend/test/unit/repositories/test_skill_repository.py @@ -2,7 +2,7 @@ import pytest -from yuxi.agents.skills.repository import SkillRepository +from yuxi.repositories.skill_repository import SkillRepository @pytest.mark.asyncio diff --git a/backend/test/unit/routers/test_skill_router.py b/backend/test/unit/routers/test_skill_router.py index 3d7b8dd009..4449a04998 100644 --- a/backend/test/unit/routers/test_skill_router.py +++ b/backend/test/unit/routers/test_skill_router.py @@ -67,7 +67,7 @@ async def fake_list_visible_skills_for_management(_db, user): return [_skill()] monkeypatch.setattr( - "server.routers.skill_router.list_visible_skills_for_management", + "yuxi.repositories.skill_repository.SkillRepository.list_visible_for_management", fake_list_visible_skills_for_management, ) @@ -91,7 +91,7 @@ async def fake_list_visible_skills_for_management(_db, user): ] monkeypatch.setattr( - "server.routers.skill_router.list_visible_skills_for_management", + "yuxi.repositories.skill_repository.SkillRepository.list_visible_for_management", fake_list_visible_skills_for_management, ) @@ -168,16 +168,16 @@ async def fake_delete(uid, slug): assert delete_resp.status_code == 200, delete_resp.text -def test_prepare_skill_upload_route(monkeypatch): +def test_create_uploaded_skill_draft_route(monkeypatch): captured: dict[str, object] = {} - async def fake_prepare_skill_upload(_db, *, filename, file_bytes, operator): + async def fake_create_uploaded_skill_draft(*, filename, file_bytes, operator): captured["filename"] = filename captured["file_bytes"] = file_bytes.decode("utf-8") captured["operator_uid"] = operator.uid - return {"draft_id": "draft-1", "items": [{"slug": "demo", "success": True}]} + return {"draft_id": "draft-1", "items": [{"slug": "demo"}], "failures": []} - monkeypatch.setattr("server.routers.skill_router.prepare_skill_upload", fake_prepare_skill_upload) + monkeypatch.setattr("server.routers.skill_router.create_uploaded_skill_draft", fake_create_uploaded_skill_draft) client = TestClient(_build_app(role="user")) resp = client.post( @@ -197,9 +197,9 @@ async def fake_prepare_skill_upload(_db, *, filename, file_bytes, operator): def test_remote_skill_prepare_and_admin_confirm_routes(monkeypatch): captured: dict[str, object] = {} - async def fake_prepare_remote_skill_install(_db, *, source, skills, operator): + async def fake_create_remote_skill_draft(*, source, skills, operator): captured["prepare"] = {"source": source, "skills": skills, "operator_uid": operator.uid} - return {"draft_id": "draft-remote", "items": [{"slug": "frontend-design", "success": True}]} + return {"draft_id": "draft-remote", "items": [{"slug": "frontend-design"}], "failures": []} async def fake_confirm_skill_install_draft(_db, *, draft_id, share_config, slugs, operator): captured["confirm"] = { @@ -213,7 +213,7 @@ async def fake_confirm_skill_install_draft(_db, *, draft_id, share_config, slugs {"slug": "broken", "success": False, "error": "解析失败"}, ] - monkeypatch.setattr("server.routers.skill_router.prepare_remote_skill_install", fake_prepare_remote_skill_install) + monkeypatch.setattr("server.routers.skill_router.create_remote_skill_draft", fake_create_remote_skill_draft) monkeypatch.setattr("server.routers.skill_router.confirm_skill_install_draft", fake_confirm_skill_install_draft) client = TestClient(_build_app(role="admin")) @@ -333,23 +333,27 @@ async def fake_export_skill_zip(_db, *, slug, operator): def test_update_skill_dependencies_route_passes_operator(monkeypatch): captured: dict[str, object] = {} - async def fake_update_skill_dependencies( + async def fake_edit_shared_skill_dependencies( _db, *, slug, tool_dependencies, mcp_dependencies, skill_dependencies, + expected_revision, operator, ): captured["slug"] = slug captured["tool_dependencies"] = tool_dependencies captured["mcp_dependencies"] = mcp_dependencies captured["skill_dependencies"] = skill_dependencies + captured["expected_revision"] = expected_revision captured["operator_uid"] = operator.uid - return _skill(slug=slug) + return _skill(slug=slug), "next-revision" - monkeypatch.setattr("server.routers.skill_router.update_skill_dependencies", fake_update_skill_dependencies) + monkeypatch.setattr( + "server.routers.skill_router.edit_shared_skill_dependencies", fake_edit_shared_skill_dependencies + ) client = TestClient(_build_app()) resp = client.put( @@ -358,6 +362,7 @@ async def fake_update_skill_dependencies( "tool_dependencies": ["calculator"], "mcp_dependencies": ["mcp-a"], "skill_dependencies": ["other-skill"], + "expected_revision": "old-revision", }, ) @@ -367,8 +372,10 @@ async def fake_update_skill_dependencies( "tool_dependencies": ["calculator"], "mcp_dependencies": ["mcp-a"], "skill_dependencies": ["other-skill"], + "expected_revision": "old-revision", "operator_uid": "admin", } + assert resp.json()["data"]["revision"] == "next-revision" def test_builtin_routes_require_admin(): diff --git a/backend/test/unit/agents/skills/test_remote_install.py b/backend/test/unit/services/skills/test_remote_install.py similarity index 91% rename from backend/test/unit/agents/skills/test_remote_install.py rename to backend/test/unit/services/skills/test_remote_install.py index 570a4a13a2..4bf537e750 100644 --- a/backend/test/unit/agents/skills/test_remote_install.py +++ b/backend/test/unit/services/skills/test_remote_install.py @@ -4,7 +4,7 @@ from types import SimpleNamespace import pytest -from yuxi.agents.skills import remote_install as svc +from yuxi.services.skills import remote as svc @pytest.fixture(autouse=True) @@ -206,18 +206,18 @@ def __init__(self, *, thread_id, uid, inherit_env): @pytest.mark.asyncio -async def test_prepare_remote_skills_batch_downloads_duplicate_skill_once(monkeypatch: pytest.MonkeyPatch): +async def test_download_remote_skills_downloads_duplicate_skill_once(monkeypatch: pytest.MonkeyPatch): sandbox = _FakeRemoteSkillSandbox(available={"frontend-design"}) _use_fake_sandbox(monkeypatch, sandbox) - preparation = await svc.prepare_remote_skills_batch( + preparation = await svc.download_remote_skills( source="anthropics/skills", skills=["frontend-design", "frontend-design"], ) try: - assert [item["success"] for item in preparation.results] == [True, True] + assert [isinstance(item, svc.DownloadedSkill) for item in preparation.results] == [True, True] assert sandbox.download_calls == ["frontend-design"] - assert preparation.results[0]["source_dir"] == preparation.results[1]["source_dir"] + assert preparation.results[0].source_dir == preparation.results[1].source_dir finally: await preparation.cleanup() @@ -248,7 +248,7 @@ async def test_prepare_remote_skills_batch_downloads_duplicate_skill_once(monkey ), ], ) -async def test_prepare_remote_skills_batch_preserves_partial_results( +async def test_download_remote_skills_preserves_partial_results( monkeypatch: pytest.MonkeyPatch, skills: list[str], available: set[str], @@ -258,10 +258,15 @@ async def test_prepare_remote_skills_batch_preserves_partial_results( sandbox = _FakeRemoteSkillSandbox(available=available) _use_fake_sandbox(monkeypatch, sandbox) - preparation = await svc.prepare_remote_skills_batch(source="test/repo", skills=skills) + preparation = await svc.download_remote_skills(source="test/repo", skills=skills) try: results = [ - {key: value for key, value in result.items() if key != "source_dir"} for result in preparation.results + ( + {"slug": result.slug, "success": False, "error": result.error} + if isinstance(result, svc.SkillDownloadFailure) + else {"slug": result.slug, "success": True} + ) + for result in preparation.results ] assert results == expected_results assert len(sandbox.calls) == 1 @@ -274,7 +279,7 @@ async def test_prepare_remote_skills_batch_preserves_partial_results( @pytest.mark.asyncio -async def test_prepare_remote_skills_batch_removes_temp_home_when_sandbox_cleanup_fails( +async def test_download_remote_skills_removes_temp_home_when_sandbox_cleanup_fails( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ): @@ -293,7 +298,7 @@ def make_temp_home(*_args, **_kwargs) -> str: monkeypatch.setattr(svc.tempfile, "mkdtemp", make_temp_home) with pytest.raises(RuntimeError, match="cleanup failed"): - await svc.prepare_remote_skills_batch( + await svc.download_remote_skills( source="anthropics/skills", skills=["frontend-design"], ) @@ -302,7 +307,7 @@ def make_temp_home(*_args, **_kwargs) -> str: @pytest.mark.asyncio -async def test_prepare_remote_skills_batch_creates_sandbox_before_temp_home(monkeypatch: pytest.MonkeyPatch): +async def test_download_remote_skills_creates_sandbox_before_temp_home(monkeypatch: pytest.MonkeyPatch): def fail_create(): raise RuntimeError("provider init failed") @@ -314,7 +319,7 @@ def fail_create(): ) with pytest.raises(RuntimeError, match="provider init failed"): - await svc.prepare_remote_skills_batch(source="anthropics/skills", skills=["frontend-design"]) + await svc.download_remote_skills(source="anthropics/skills", skills=["frontend-design"]) @pytest.mark.asyncio diff --git a/backend/test/unit/services/skills/test_skill_draft.py b/backend/test/unit/services/skills/test_skill_draft.py new file mode 100644 index 0000000000..c02665cede --- /dev/null +++ b/backend/test/unit/services/skills/test_skill_draft.py @@ -0,0 +1,127 @@ +"""安装草稿只向确认入口提供经过校验的可安装快照。""" + +from __future__ import annotations + +import json +import time +from pathlib import Path + +import pytest +from yuxi.services.skills import draft as skill_draft +from yuxi.services.skills.package import copy_skill_tree_no_symlinks +from yuxi.storage.postgres.models_business import User + + +def _write_draft(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, items: list[dict]) -> str: + """在临时运行目录写入草稿元数据和规范的条目目录。""" + draft_id = "11111111-1111-1111-1111-111111111111" + root = tmp_path / "skill_import_drafts" / draft_id + root.mkdir(parents=True) + for item in items: + relative = item.get("source_dir") + if isinstance(relative, str) and relative.startswith("items/") and ".." not in relative: + (root / relative).mkdir(parents=True, exist_ok=True) + (root / "metadata.json").write_text( + json.dumps( + { + "created_by": "owner", + "source_type": "remote", + "expires_at": time.time() + 300, + "items": items, + "failures": [], + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(skill_draft, "get_runtime_dir", lambda: tmp_path) + return draft_id + + +@pytest.mark.parametrize( + ("items", "message"), + [ + ([{"slug": "demo", "source_dir": f"items/{'a' * 32}", "success": False}], "条目非法"), + ( + [{"slug": "demo", "source_dir": f"items/{'a' * 32}"}, {"slug": "demo", "source_dir": f"items/{'b' * 32}"}], + "重复", + ), + ([{"slug": "demo", "source_dir": "items/../../outside"}], "路径非法"), + ([{"slug": " demo ", "source_dir": f"items/{'a' * 32}"}], "条目非法"), + ], +) +def test_invalid_draft_item_never_reaches_install( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, items: list[dict], message: str +): + """失败状态、重复身份和不可信路径在草稿边界拒绝。""" + draft_id = _write_draft(tmp_path, monkeypatch, items) + + with pytest.raises(ValueError, match=message): + skill_draft.load_and_select_draft_items(draft_id, None, User(uid="owner", role="user")) + + +def test_failed_preparation_cannot_be_selected(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """失败报告不进入可确认条目集合。""" + draft_id = _write_draft(tmp_path, monkeypatch, [{"slug": "demo", "source_dir": f"items/{'a' * 32}"}]) + metadata = tmp_path / "skill_import_drafts" / draft_id / "metadata.json" + data = json.loads(metadata.read_text(encoding="utf-8")) + data["failures"] = [{"slug": "broken", "error": "加载失败"}] + metadata.write_text(json.dumps(data), encoding="utf-8") + + with pytest.raises(ValueError, match="不可安装"): + skill_draft.load_and_select_draft_items(draft_id, ["broken"], User(uid="owner", role="user")) + + +def test_failed_only_draft_cannot_be_confirmed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """全量准备失败时确认入口不能返回空的成功结果。""" + draft_id = _write_draft(tmp_path, monkeypatch, []) + + with pytest.raises(ValueError, match="没有可安装"): + skill_draft.load_and_select_draft_items(draft_id, None, User(uid="owner", role="user")) + + +def test_symlinked_source_root_cannot_be_staged(tmp_path: Path): + """远程来源目录本身是符号链接时不能越界复制。""" + source = tmp_path / "source" + source.mkdir() + alias = tmp_path / "alias" + alias.symlink_to(source, target_is_directory=True) + + with pytest.raises(ValueError, match="只允许普通文件和目录"): + copy_skill_tree_no_symlinks(alias, tmp_path / "snapshot") + + +@pytest.mark.asyncio +async def test_partial_install_preserves_failed_and_unselected_snapshots(tmp_path, monkeypatch): + """部分成功后重读磁盘,失败和未选条目仍可确认且成功项不再出现。""" + from yuxi.services.skills import personal + + items = [ + {"slug": slug, "source_dir": f"items/{letter * 32}"} + for slug, letter in [("alpha", "a"), ("beta", "b"), ("gamma", "c")] + ] + draft_id = _write_draft(tmp_path, monkeypatch, items) + for item in items: + directory = tmp_path / "skill_import_drafts" / draft_id / item["source_dir"] + (directory / "SKILL.md").write_text( + f"---\nname: {item['slug']}\ndescription: example\n---\nBody", encoding="utf-8" + ) + from yuxi.workspace import paths + + monkeypatch.setattr(paths, "get_user_data_dir", lambda: tmp_path / "users") + operator = User(uid="owner", role="user") + existing = paths.user_workspace_dir("owner") / "agents/skills/beta" + existing.mkdir(parents=True) + (existing / "sentinel.txt").write_text("existing", encoding="utf-8") + + results = await personal.confirm_personal_skill_install_draft( + draft_id=draft_id, slugs=["alpha", "beta"], operator=operator + ) + + assert [item["success"] for item in results] == [True, False] + root, metadata, remaining = skill_draft.load_and_select_draft_items(draft_id, None, operator) + assert [item.slug for item in remaining] == ["beta", "gamma"] + assert [item["slug"] for item in metadata["items"]] == ["beta", "gamma"] + assert not (root / items[0]["source_dir"]).exists() + assert all((item.source_dir / "SKILL.md").is_file() for item in remaining) + assert (existing / "sentinel.txt").read_text(encoding="utf-8") == "existing" + assert (existing.parent / "alpha/SKILL.md").is_file() diff --git a/backend/test/unit/services/skills/test_skill_edit_service.py b/backend/test/unit/services/skills/test_skill_edit_service.py new file mode 100644 index 0000000000..d512607e20 --- /dev/null +++ b/backend/test/unit/services/skills/test_skill_edit_service.py @@ -0,0 +1,430 @@ +"""共享 Skill 在线编辑的文件与索引结果。""" + +from __future__ import annotations + +import hashlib +import os +from pathlib import Path + +import pytest +from yuxi.agents.toolkits import service as tool_service +from yuxi.services import artifact_service +from yuxi.services.skills import edit as edit_service +from yuxi.services.skills import projection as projection_service +from yuxi.services.skills import shared as skill_service +from yuxi.storage.postgres.models_business import Skill, User + + +class _Session: + def __init__(self, *, fail_commit: bool = False): + self.fail_commit = fail_commit + + async def commit(self): + if self.fail_commit: + raise RuntimeError("database commit failed") + + +def _setup_shared_skill(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + skill_dir = tmp_path / "shared" / "demo" + skill_dir.mkdir(parents=True) + content = "---\nname: demo\nslug: demo\ndescription: old\n---\n# Old\n" + (skill_dir / "SKILL.md").write_text(content, encoding="utf-8") + item = Skill( + slug="demo", + name="demo", + description="old", + source_type="upload", + dir_path="shared/demo", + enabled=True, + created_by="owner", + share_config={ + "version": 2, + "read_scope": {"access_level": "user", "user_uids": ["owner"], "department_ids": []}, + "manage_scope": {"access_level": "user", "user_uids": ["owner"], "department_ids": []}, + }, + tool_dependencies=[], + mcp_dependencies=[], + skill_dependencies=[], + ) + + class FakeRepo: + def __init__(self, _db): + pass + + async def get_by_slug(self, slug, *, for_update=False): + assert slug == "demo" and for_update + return item + + async def update_metadata(self, target, *, name, description, updated_by): + target.name = name + target.description = description + target.updated_by = updated_by + return target + + async def update_dependencies( + self, target, *, tool_dependencies, mcp_dependencies, skill_dependencies, updated_by + ): + target.tool_dependencies = tool_dependencies + target.mcp_dependencies = mcp_dependencies + target.skill_dependencies = skill_dependencies + target.updated_by = updated_by + return target + + async def accessible(_db, _user): + return [item] + + async def mcps(db=None): + return [] + + monkeypatch.setattr(edit_service, "get_skill_data_dir", lambda: tmp_path) + monkeypatch.setattr(edit_service, "SkillRepository", FakeRepo) + monkeypatch.setattr(FakeRepo, "list_enabled_readable", accessible, raising=False) + monkeypatch.setattr(tool_service, "get_tool_metadata", lambda: []) + monkeypatch.setattr(skill_service, "get_enabled_mcp_server_slugs", mcps) + return skill_dir, item, content + + +def _user(uid: str) -> User: + return User(uid=uid, role="user") + + +def test_shared_artifact_source_ignores_personal_override_and_rejects_links(tmp_path, monkeypatch): + """同名个人源不改变共享 artifact 来源,链接不能逃离共享目录。""" + skill_dir, item, _old = _setup_shared_skill(tmp_path, monkeypatch) + personal_dir = tmp_path / "personal" / "demo" + personal_dir.mkdir(parents=True) + (skill_dir / "notes.txt").write_text("shared", encoding="utf-8") + (personal_dir / "notes.txt").write_text("personal", encoding="utf-8") + target = tmp_path / "download.txt" + target.write_bytes(b"") + + artifact_service._copy_skill_file_to_path(item, "notes.txt", str(target), 1024) + assert target.read_text(encoding="utf-8") == "shared" + (skill_dir / "link.txt").symlink_to(personal_dir / "notes.txt") + with pytest.raises(PermissionError): + artifact_service._copy_skill_file_to_path(item, "link.txt", str(target), 1024) + assert target.read_text(encoding="utf-8") == "shared" + + +@pytest.mark.asyncio +async def test_shared_node_create_commit_failure_removes_new_file(tmp_path, monkeypatch): + """数据库提交失败时,新文件不能留在正式共享来源。""" + skill_dir, item, _old = _setup_shared_skill(tmp_path, monkeypatch) + + async def manageable(_db, _user, _slug, *, for_update=False): + assert for_update + return item + + monkeypatch.setattr(edit_service, "get_manageable_skill_or_raise", manageable) + with pytest.raises(RuntimeError, match="database commit failed"): + await edit_service.create_skill_node( + _Session(fail_commit=True), + slug="demo", + relative_path="new.txt", + is_dir=False, + content="new", + operator=_user("owner"), + ) + assert not (skill_dir / "new.txt").exists() + + +@pytest.mark.asyncio +async def test_shared_node_delete_commit_failure_restores_file(tmp_path, monkeypatch): + """删除提交失败时,原文件仍可从共享目录读取。""" + skill_dir, item, _old = _setup_shared_skill(tmp_path, monkeypatch) + (skill_dir / "notes.txt").write_text("before", encoding="utf-8") + + async def manageable(_db, _user, _slug, *, for_update=False): + assert for_update + return item + + monkeypatch.setattr(edit_service, "get_manageable_skill_or_raise", manageable) + with pytest.raises(RuntimeError, match="database commit failed"): + await edit_service.delete_skill_node( + _Session(fail_commit=True), slug="demo", relative_path="notes.txt", operator=_user("owner") + ) + assert (skill_dir / "notes.txt").read_text(encoding="utf-8") == "before" + + +@pytest.mark.asyncio +async def test_shared_tree_rejects_symlinked_entry(tmp_path, monkeypatch): + """文件树不能把目录外的个人文件伪装成共享节点。""" + skill_dir, item, _old = _setup_shared_skill(tmp_path, monkeypatch) + outside = tmp_path / "outside.txt" + outside.write_text("private", encoding="utf-8") + (skill_dir / "linked.txt").symlink_to(outside) + + async def readable(_db, _user, _slug): + return item + + monkeypatch.setattr(edit_service, "_lock_readable_skill", readable) + with pytest.raises(ValueError, match="链接"): + await edit_service.get_skill_tree(_Session(), slug="demo", operator=_user("owner")) + + +@pytest.mark.asyncio +async def test_edit_root_file_updates_bytes_and_database_dependencies(tmp_path, monkeypatch): + skill_dir, item, old = _setup_shared_skill(tmp_path, monkeypatch) + new = "---\nname: demo\nslug: demo\ndescription: updated\n---\n# New\n" + + result, revision = await edit_service.edit_shared_skill_file( + _Session(), + slug="demo", + relative_path="SKILL.md", + content=new, + expected_revision=hashlib.sha256(old.encode()).hexdigest(), + operator=_user("owner"), + ) + + assert result is item + assert item.description == "updated" + assert item.tool_dependencies == [] + assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == new + assert revision == hashlib.sha256(new.encode()).hexdigest() + + +@pytest.mark.asyncio +async def test_edit_rejects_stale_revision_without_overwriting(tmp_path, monkeypatch): + skill_dir, item, old = _setup_shared_skill(tmp_path, monkeypatch) + + with pytest.raises(edit_service.SkillEditConflict, match="其他编辑"): + await edit_service.edit_shared_skill_file( + _Session(), + slug="demo", + relative_path="SKILL.md", + content=old.replace("old", "new"), + expected_revision="0" * 64, + operator=_user("owner"), + ) + + assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == old + assert item.description == "old" + + +@pytest.mark.asyncio +async def test_edit_rejects_unmanaged_and_builtin_skills(tmp_path, monkeypatch): + skill_dir, item, old = _setup_shared_skill(tmp_path, monkeypatch) + revision = hashlib.sha256(old.encode()).hexdigest() + + with pytest.raises(ValueError, match="无权管理"): + await edit_service.edit_shared_skill_file( + _Session(), + slug="demo", + relative_path="SKILL.md", + content="changed", + expected_revision=revision, + operator=_user("other"), + ) + item.source_type = "builtin" + with pytest.raises(ValueError, match="内置 skill"): + await edit_service.edit_shared_skill_file( + _Session(), + slug="demo", + relative_path="SKILL.md", + content="changed", + expected_revision=revision, + operator=User(uid="owner", role="admin"), + ) + + assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == old + + +@pytest.mark.asyncio +async def test_edit_rejects_symlink_and_path_traversal(tmp_path, monkeypatch): + skill_dir, _item, old = _setup_shared_skill(tmp_path, monkeypatch) + (skill_dir / "link.md").symlink_to(skill_dir / "SKILL.md") + revision = hashlib.sha256(old.encode()).hexdigest() + + for path in ("../outside.md", "link.md"): + with pytest.raises(ValueError, match="路径"): + await edit_service.edit_shared_skill_file( + _Session(), + slug="demo", + relative_path=path, + content="changed", + expected_revision=revision, + operator=_user("owner"), + ) + + assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == old + + +@pytest.mark.asyncio +async def test_edit_restores_file_when_database_commit_fails(tmp_path, monkeypatch): + skill_dir, _item, old = _setup_shared_skill(tmp_path, monkeypatch) + + with pytest.raises(RuntimeError, match="database commit failed"): + await edit_service.edit_shared_skill_file( + _Session(fail_commit=True), + slug="demo", + relative_path="SKILL.md", + content=old.replace("old", "updated"), + expected_revision=hashlib.sha256(old.encode()).hexdigest(), + operator=_user("owner"), + ) + + assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == old + + +@pytest.mark.asyncio +async def test_edit_restores_file_when_directory_sync_fails(tmp_path, monkeypatch): + skill_dir, _item, _old = _setup_shared_skill(tmp_path, monkeypatch) + file = skill_dir / "notes.md" + file.write_text("before", encoding="utf-8") + original_fsync = os.fsync + calls = 0 + + def fail_first_directory_sync(fd): + nonlocal calls + calls += 1 + if calls == 2: + raise OSError("directory sync failed") + original_fsync(fd) + + monkeypatch.setattr(edit_service.os, "fsync", fail_first_directory_sync) + with pytest.raises(OSError, match="directory sync failed"): + await edit_service.edit_shared_skill_file( + _Session(), + slug="demo", + relative_path="notes.md", + content="after", + expected_revision=hashlib.sha256(b"before").hexdigest(), + operator=_user("owner"), + ) + + assert file.read_text(encoding="utf-8") == "before" + + +@pytest.mark.asyncio +async def test_projection_cannot_copy_edit_staging_file(tmp_path, monkeypatch): + skill_dir, _item, old = _setup_shared_skill(tmp_path, monkeypatch) + monkeypatch.setattr(projection_service, "get_skill_projection_dir", lambda: tmp_path / "projections") + original_replace = os.replace + observed = False + + def observe_publication(source, target, *, src_dir_fd, dst_dir_fd): + nonlocal observed + observed = True + projection = projection_service.sync_user_accessible_skills("owner", {"demo": skill_dir}) / "demo" + assert (projection / "SKILL.md").read_text(encoding="utf-8") == old + assert not any(path.name.endswith(".tmp") for path in projection.rglob("*")) + return original_replace(source, target, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd) + + monkeypatch.setattr(edit_service.os, "replace", observe_publication) + updated = old.replace("old", "new") + await edit_service.edit_shared_skill_file( + _Session(), + slug="demo", + relative_path="SKILL.md", + content=updated, + expected_revision=hashlib.sha256(old.encode()).hexdigest(), + operator=_user("owner"), + ) + + assert observed + assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == updated + + +@pytest.mark.asyncio +async def test_dependency_form_updates_root_file_and_index(tmp_path, monkeypatch): + skill_dir, item, old = _setup_shared_skill(tmp_path, monkeypatch) + monkeypatch.setattr(tool_service, "get_tool_metadata", lambda: [{"slug": "calculator"}]) + + result, revision = await edit_service.edit_shared_skill_dependencies( + _Session(), + slug="demo", + tool_dependencies=["calculator"], + mcp_dependencies=[], + skill_dependencies=[], + expected_revision=hashlib.sha256(old.encode()).hexdigest(), + operator=_user("owner"), + ) + + saved = (skill_dir / "SKILL.md").read_text(encoding="utf-8") + assert result is item + assert item.tool_dependencies == ["calculator"] + assert "tool_dependencies:\n- calculator" in saved + assert revision == hashlib.sha256(saved.encode()).hexdigest() + + +@pytest.mark.asyncio +async def test_dependency_edit_accepts_unquoted_multiline_description(tmp_path, monkeypatch): + """预览可识别的多行描述也必须能保存依赖。""" + skill_dir, item, _old = _setup_shared_skill(tmp_path, monkeypatch) + original = ( + "---\nname: demo\nslug: demo\ndescription:\n" + ' Use this skill for PDFs.\n CREATE (from scratch): "make a PDF".\n' + "license: MIT\n---\n# Body\n" + ) + (skill_dir / "SKILL.md").write_text(original, encoding="utf-8") + + result, revision = await edit_service.edit_shared_skill_dependencies( + _Session(), + slug="demo", + tool_dependencies=[], + mcp_dependencies=[], + skill_dependencies=[], + expected_revision=hashlib.sha256(original.encode()).hexdigest(), + operator=_user("owner"), + ) + + saved = (skill_dir / "SKILL.md").read_text(encoding="utf-8") + assert result is item + assert item.description == 'Use this skill for PDFs. CREATE (from scratch): "make a PDF".' + assert skill_service.parse_skill_markdown(saved)[2] == item.description + assert revision == hashlib.sha256(saved.encode()).hexdigest() + + +@pytest.mark.asyncio +async def test_edit_rejects_invalid_dependency_without_publishing_file(tmp_path, monkeypatch): + skill_dir, item, old = _setup_shared_skill(tmp_path, monkeypatch) + invalid = old.replace("description: old", "description: changed\ntool_dependencies:\n- missing-tool") + + with pytest.raises(ValueError, match="无效工具依赖"): + await edit_service.edit_shared_skill_file( + _Session(), + slug="demo", + relative_path="SKILL.md", + content=invalid, + expected_revision=hashlib.sha256(old.encode()).hexdigest(), + operator=_user("owner"), + ) + + assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == old + assert item.description == "old" + assert item.tool_dependencies == [] + + +@pytest.mark.asyncio +async def test_export_closes_source_directory_when_tempfile_creation_fails(tmp_path, monkeypatch): + """导出临时文件创建失败时,已打开的来源目录描述符仍被释放。""" + _directory, item, _content = _setup_shared_skill(tmp_path, monkeypatch) + opened = [] + original_open = edit_service.open_shared_skill_dir + + async def readable(*_args): + """使用已经授权的共享条目。""" + return item + + def capture_open(skill): + """记录真实目录描述符供失败后核对。""" + fd = original_open(skill) + opened.append(fd) + return fd + + def fail_tempfile(**_kwargs): + """模拟目标磁盘无法创建导出文件。""" + raise OSError("cannot create export") + + monkeypatch.setattr(edit_service, "_lock_readable_skill", readable) + monkeypatch.setattr(edit_service, "open_shared_skill_dir", capture_open) + monkeypatch.setattr(edit_service.tempfile, "mkstemp", fail_tempfile) + with pytest.raises(OSError, match="cannot create export"): + await edit_service.export_skill_zip(_Session(), slug="demo", operator=_user("owner")) + + assert len(opened) == 1 + with pytest.raises(OSError) as error: + os.fstat(opened[0]) + import errno + + assert error.value.errno == errno.EBADF diff --git a/backend/test/unit/services/test_skill_service.py b/backend/test/unit/services/skills/test_skill_service.py similarity index 80% rename from backend/test/unit/services/test_skill_service.py rename to backend/test/unit/services/skills/test_skill_service.py index d21690eb61..19ecb45d51 100644 --- a/backend/test/unit/services/test_skill_service.py +++ b/backend/test/unit/services/skills/test_skill_service.py @@ -6,24 +6,34 @@ import socket import subprocess import sys +import time import zipfile from pathlib import Path from types import SimpleNamespace import pytest -from yuxi.agents.skills import service as svc from yuxi.agents.toolkits import service as tool_service +from yuxi.repositories.skill_repository import SkillRepository as RealSkillRepository +from yuxi.services.skills import catalog +from yuxi.services.skills import draft as draft_service +from yuxi.services.skills import draft as skill_draft +from yuxi.services.skills import edit as edit_service +from yuxi.services.skills import personal as personal_service +from yuxi.services.skills import projection as projection_service +from yuxi.services.skills import shared as svc +from yuxi.services.skills.remote import DownloadedSkill from yuxi.storage.postgres.models_business import Skill, User - +from yuxi.workspace.paths import user_workspace_dir _MULTIPROCESS_SKILL_SYNC_SCRIPT = """ import json import os import select import sys +import time import traceback from pathlib import Path -from yuxi.agents.skills import service +from yuxi.services.skills import projection as service save_dir, uid, encoded_sources = sys.argv[1:] sources = json.loads(encoded_sources) @@ -117,7 +127,7 @@ def test_allowed_skill_access_levels_by_role(): @pytest.mark.asyncio -async def test_prepare_remote_skill_install_stages_success_and_failure( +async def test_create_remote_skill_draft_stages_success_and_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ): @@ -130,17 +140,11 @@ async def test_prepare_remote_skill_install_stages_success_and_failure( encoding="utf-8", ) - class FakeRepo: - def __init__(self, _db): - pass - - async def exists_slug(self, _slug: str) -> bool: - return False - class FakePreparation: results = [ - {"slug": "pdf", "success": True, "source_dir": valid_dir}, - {"slug": "broken", "success": True, "source_dir": invalid_dir}, + DownloadedSkill(slug="pdf", source_dir=valid_dir), + DownloadedSkill(slug="broken", source_dir=invalid_dir), + DownloadedSkill(slug="duplicate", source_dir=valid_dir), ] cleaned = False @@ -149,24 +153,28 @@ async def cleanup(self): preparation = FakePreparation() - async def fake_prepare_remote_skills_batch(*, source, skills): + async def fake_download_remote_skills(*, source, skills): assert source == "anthropics/skills" - assert skills == ["pdf", "broken"] + assert skills == ["pdf", "broken", "duplicate"] return preparation - monkeypatch.setattr(svc, "SkillRepository", FakeRepo) monkeypatch.setattr( - "yuxi.agents.skills.remote_install.prepare_remote_skills_batch", - fake_prepare_remote_skills_batch, + "yuxi.services.skills.remote.download_remote_skills", + fake_download_remote_skills, ) - draft = await svc.prepare_remote_skill_install( - None, + draft = await draft_service.create_remote_skill_draft( source="anthropics/skills", - skills=["pdf", "broken"], + skills=["pdf", "broken", "duplicate"], operator=_user(), ) - assert [item["success"] for item in draft["items"]] == [True, False] + assert [item["slug"] for item in draft["items"]] == ["pdf"] + assert draft["failures"] == [ + {"slug": "broken", "error": "技能目录缺少根级 SKILL.md"}, + {"slug": "duplicate", "error": "Skill slug 重复: pdf"}, + ] + staged_items = skill_draft.get_skill_drafts_root_dir() / draft["draft_id"] / "items" + assert [item.name for item in staged_items.iterdir()] == [Path(draft["items"][0]["source_dir"]).name] assert preparation.cleaned is True @@ -217,7 +225,7 @@ async def test_list_visible_skills_for_management_includes_owned_disabled_and_en ), ] - class FakeRepo: + class FakeRepo(RealSkillRepository): def __init__(self, _db): pass @@ -226,11 +234,53 @@ async def list_all(self): monkeypatch.setattr(svc, "SkillRepository", FakeRepo) - visible = await svc.list_visible_skills_for_management(None, _user("root", role="user")) + visible = await svc.SkillRepository(None).list_visible_for_management(_user("root", role="user")) assert [item.slug for item in visible] == ["owned-disabled", "shared-enabled", "shared-disabled"] +@pytest.mark.asyncio +async def test_locked_shared_list_only_locks_visible_rows_and_rechecks_permission(monkeypatch): + """无权行不参与运行时锁;等待期间撤销的权限也不能进入结果。""" + visible = Skill( + id=1, + slug="visible", + enabled=True, + created_by="other", + share_config={"version": 2, "read_scope": {"access_level": "user", "user_uids": ["root"]}}, + ) + hidden = Skill( + id=2, + slug="hidden", + enabled=True, + created_by="other", + share_config={"version": 2, "read_scope": {"access_level": "user", "user_uids": ["other"]}}, + ) + locked_ids = [] + + class FakeRepo(RealSkillRepository): + def __init__(self, _db): + pass + + async def list_all(self): + return [visible, hidden] + + async def lock_rows_for_read(self, ids): + locked_ids.extend(ids) + visible.share_config = { + "version": 2, + "read_scope": {"access_level": "user", "user_uids": ["other"]}, + } + return [visible] + + monkeypatch.setattr(projection_service, "SkillRepository", FakeRepo) + + result = await projection_service.lock_accessible_shared_skills_for_projection(None, _user("root", role="user")) + + assert locked_ids == [1] + assert result == [] + + @pytest.mark.asyncio @pytest.mark.parametrize( "skill,operator", @@ -283,7 +333,7 @@ async def test_management_readable_skill_allows_manageable_disabled_and_enabled_ skill: Skill, operator: User, ): - class FakeRepo: + class FakeRepo(RealSkillRepository): def __init__(self, _db): pass @@ -313,7 +363,7 @@ async def test_management_readable_skill_allows_disabled_user_shared_manager(mon }, ) - class FakeRepo: + class FakeRepo(RealSkillRepository): def __init__(self, _db): pass @@ -343,51 +393,35 @@ async def test_runtime_access_still_excludes_disabled_shared_skill(monkeypatch: }, ) - class FakeRepo: + class FakeRepo(RealSkillRepository): def __init__(self, _db): pass async def list_enabled(self): return [] - monkeypatch.setattr(svc, "SkillRepository", FakeRepo) + monkeypatch.setattr(catalog, "SkillRepository", FakeRepo) async def no_personal_skills(_uid): return [] - monkeypatch.setattr(svc, "list_personal_skills", no_personal_skills) + monkeypatch.setattr(catalog, "list_personal_skills", no_personal_skills) assert svc.user_can_access_skill(_user("root", role="user"), skill) is False - assert await svc.list_accessible_skills(None, _user("root", role="user")) == [] + assert await catalog.list_accessible_skills(None, _user("root", role="user")) == [] @pytest.mark.asyncio -async def test_normal_user_skill_upload_draft_defaults_to_personal_read_scope( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - - class FakeRepo: - def __init__(self, _db): - pass - - async def exists_slug(self, _slug: str) -> bool: - return False - - monkeypatch.setattr(svc, "SkillRepository", FakeRepo) - - draft = await svc.prepare_skill_upload( - None, +async def test_upload_draft_does_not_include_shared_policy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + draft = await draft_service.create_uploaded_skill_draft( filename="SKILL.md", file_bytes=b"---\nname: demo\ndescription: demo skill\n---\n# Demo\n", operator=_user("normal-user", role="user"), ) - assert draft["default_share_config"] == { - "version": 2, - "read_scope": {"access_level": "user", "department_ids": [], "user_uids": ["normal-user"]}, - "manage_scope": None, - } - assert draft["allowed_access_levels"] == ["user"] + assert "default_share_config" not in draft + assert "allowed_access_levels" not in draft + assert draft["items"][0]["slug"] == "demo" @pytest.mark.parametrize( @@ -412,7 +446,7 @@ async def test_normal_user_confirm_skill_draft_rejects_wider_share_scope( share_config: dict, ): - class FakeRepo: + class FakeRepo(RealSkillRepository): def __init__(self, _db): pass @@ -424,8 +458,7 @@ async def create(self, **_kwargs) -> Skill: monkeypatch.setattr(svc, "SkillRepository", FakeRepo) operator = _user("normal-user", role="user") - draft = await svc.prepare_skill_upload( - None, + draft = await draft_service.create_uploaded_skill_draft( filename="SKILL.md", file_bytes=b"---\nname: demo\ndescription: demo skill\n---\n# Demo\n", operator=operator, @@ -441,40 +474,30 @@ async def create(self, **_kwargs) -> Skill: @pytest.mark.asyncio -async def test_confirm_skill_install_draft_only_processes_selected_slugs( +async def test_install_draft_loader_only_selects_ready_slugs( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ): draft_dir = tmp_path / "draft" - draft_dir.mkdir() + items_dir = draft_dir / "items" + items_dir.mkdir(parents=True) + for item_id in ("a" * 32, "b" * 32): + (items_dir / item_id).mkdir() data = { "created_by": "root", "source_type": "remote", "items": [ - {"slug": "alpha", "success": False, "error": "alpha failed"}, - {"slug": "beta", "success": False, "error": "beta failed"}, + {"slug": "alpha", "source_dir": f"items/{'a' * 32}"}, + {"slug": "beta", "source_dir": f"items/{'b' * 32}"}, ], + "failures": [{"slug": "broken", "error": "加载失败"}], } + monkeypatch.setattr(skill_draft, "load_skill_draft", lambda _draft_id: (draft_dir, data)) + _draft_dir, _data, selected = skill_draft.load_and_select_draft_items("draft-1", ["beta"], _user()) - class FakeRepo: - def __init__(self, _db): - pass - - monkeypatch.setattr(svc, "_load_skill_draft", lambda _draft_id: (draft_dir, data)) - monkeypatch.setattr(svc, "SkillRepository", FakeRepo) - results = await svc.confirm_skill_install_draft( - _UnitOfWork(), - draft_id="draft-1", - share_config={ - "version": 2, - "read_scope": {"access_level": "user", "user_uids": ["root"]}, - "manage_scope": {"access_level": "user", "user_uids": ["root"]}, - }, - slugs=["beta"], - operator=_user(), - ) - - assert results == [{"slug": "beta", "success": False, "error": "beta failed"}] + assert [item.slug for item in selected] == ["beta"] + with pytest.raises(ValueError, match="不可安装"): + skill_draft.load_and_select_draft_items("draft-1", ["broken"], _user()) @pytest.mark.parametrize(("slugs", "message"), [([], "至少选择一个 Skill"), (["missing"], "草稿外的 Skill")]) @@ -486,13 +509,15 @@ async def test_confirm_skill_install_draft_rejects_invalid_selection( message: str, ): draft_dir = tmp_path / "draft" - draft_dir.mkdir() + item_dir = draft_dir / "items" / ("a" * 32) + item_dir.mkdir(parents=True) data = { "created_by": "root", "source_type": "remote", - "items": [{"slug": "alpha", "success": True}], + "items": [{"slug": "alpha", "source_dir": f"items/{'a' * 32}"}], + "failures": [], } - monkeypatch.setattr(svc, "_load_skill_draft", lambda _draft_id: (draft_dir, data)) + monkeypatch.setattr(skill_draft, "load_skill_draft", lambda _draft_id: (draft_dir, data)) with pytest.raises(ValueError, match=message): await svc.confirm_skill_install_draft( @@ -510,7 +535,7 @@ async def test_confirm_skill_install_draft_rejects_invalid_selection( def test_parse_skill_markdown_ok(): content = "---\nname: demo-skill\ndescription: demo description\n---\n# Demo\n" - slug, name, desc, meta = svc._parse_skill_markdown(content) + slug, name, desc, meta = svc.parse_skill_markdown(content) assert slug == "demo-skill" assert name == "demo-skill" assert desc == "demo description" @@ -529,7 +554,7 @@ def test_parse_skill_markdown_supports_display_name_with_slug(): "---\n" "# Word / DOCX\n" ) - slug, name, desc, meta = svc._parse_skill_markdown(content) + slug, name, desc, meta = svc.parse_skill_markdown(content) assert slug == "word-docx" assert name == "Word / DOCX" assert desc == "Create, inspect, and edit Microsoft Word documents." @@ -538,7 +563,7 @@ def test_parse_skill_markdown_supports_display_name_with_slug(): def test_parse_skill_markdown_requires_frontmatter(): with pytest.raises(ValueError, match="frontmatter"): - svc._parse_skill_markdown("# missing") + svc.parse_skill_markdown("# missing") @pytest.fixture @@ -610,6 +635,8 @@ def test_is_valid_skill_slug(): # Test invalid slugs assert svc.is_valid_skill_slug("../bad") is False assert svc.is_valid_skill_slug("Invalid") is False # uppercase not allowed + assert svc.is_valid_skill_slug(" demo ") is False + assert svc.is_valid_skill_slug("demo\n") is False assert svc.is_valid_skill_slug("") is False @@ -657,7 +684,7 @@ def test_sync_user_accessible_skills( (tmp_path / rel_path / "SKILL.md").write_text(new_content, encoding="utf-8") resolved_sources = {slug: tmp_path / rel for slug, rel in source_dirs.items()} - user_root = svc.sync_user_accessible_skills("user_1", resolved_sources) + user_root = projection_service.sync_user_accessible_skills("user_1", resolved_sources) assert user_root == tmp_path / "skill-projections" / "user_1" assert sorted(path.name for path in user_root.iterdir()) == sorted(expected_entries) @@ -673,15 +700,15 @@ def test_unchanged_skill_projection_does_not_create_staging(tmp_path: Path, monk source = tmp_path / "sources/demo" source.mkdir(parents=True) (source / "SKILL.md").write_text("# unchanged\n", encoding="utf-8") - projection = svc.sync_user_accessible_skills("user-1", {"demo": source}) + projection = projection_service.sync_user_accessible_skills("user-1", {"demo": source}) projected_file = projection / "demo/SKILL.md" original_inode = projected_file.stat().st_ino def refuse_staging(*args, **kwargs): raise AssertionError("未变化的投影不应创建 staging") - monkeypatch.setattr(svc, "copy_skill_tree_no_symlinks", refuse_staging) - svc.sync_user_accessible_skills("user-1", {"demo": source}) + monkeypatch.setattr(projection_service, "copy_skill_tree_no_symlinks", refuse_staging) + projection_service.sync_user_accessible_skills("user-1", {"demo": source}) assert projected_file.read_text(encoding="utf-8") == "# unchanged\n" assert projected_file.stat().st_ino == original_inode @@ -696,7 +723,7 @@ def test_projection_comparison_does_not_accept_equal_symlink( source = tmp_path / "sources/demo" source.mkdir(parents=True) (source / "SKILL.md").write_text("# identical\n", encoding="utf-8") - projection = svc.sync_user_accessible_skills("user-1", {"demo": source}) + projection = projection_service.sync_user_accessible_skills("user-1", {"demo": source}) outside = tmp_path / "outside.md" outside.write_text("# identical\n", encoding="utf-8") linked_file = (source if linked_side == "source" else projection / "demo") / "SKILL.md" @@ -705,41 +732,15 @@ def test_projection_comparison_does_not_accept_equal_symlink( if linked_side == "source": with pytest.raises(PermissionError, match="symlink"): - svc.sync_user_accessible_skills("user-1", {"demo": source}) + projection_service.sync_user_accessible_skills("user-1", {"demo": source}) assert not (projection / "demo").exists() else: - svc.sync_user_accessible_skills("user-1", {"demo": source}) + projection_service.sync_user_accessible_skills("user-1", {"demo": source}) assert not linked_file.is_symlink() assert linked_file.read_text(encoding="utf-8") == "# identical\n" assert outside.read_text(encoding="utf-8") == "# identical\n" -@pytest.mark.asyncio -async def test_sync_user_accessible_skills_async_runs_in_thread(monkeypatch: pytest.MonkeyPatch): - """异步同步入口必须把目录扫描和复制下沉到工作线程。""" - calls = [] - expected_root = Path("/tmp/thread-skills") - - async def to_thread(func, *args): - calls.append((func, args)) - return expected_root - - monkeypatch.setattr(svc.asyncio, "to_thread", to_thread) - - result = await svc.sync_user_accessible_skills_async( - "user-1", - {"alpha": "/tmp/alpha"}, - ) - - assert result == expected_root - assert calls == [ - ( - svc.sync_user_accessible_skills, - ("user-1", {"alpha": "/tmp/alpha"}), - ) - ] - - @pytest.mark.parametrize("component", ["root", "ancestor"]) def test_projection_comparison_rejects_symlinked_source_directory( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, component: str @@ -748,11 +749,13 @@ def test_projection_comparison_rejects_symlinked_source_directory( source = tmp_path / "sources/demo" source.mkdir(parents=True) (source / "SKILL.md").write_text("# same\n", encoding="utf-8") - projection = svc.sync_user_accessible_skills("user-1", {"demo": source}) + projection = projection_service.sync_user_accessible_skills("user-1", {"demo": source}) linked = tmp_path / "linked" linked.symlink_to(source if component == "root" else source.parent, target_is_directory=True) with pytest.raises(OSError): - svc.sync_user_accessible_skills("user-1", {"demo": linked if component == "root" else linked / "demo"}) + projection_service.sync_user_accessible_skills( + "user-1", {"demo": linked if component == "root" else linked / "demo"} + ) assert not (projection / "demo").exists() @@ -762,19 +765,19 @@ def test_projection_comparison_rejects_file_replaced_after_stat(tmp_path: Path, source.mkdir(parents=True) source_file = source / "SKILL.md" source_file.write_text("# same\n", encoding="utf-8") - projection = svc.sync_user_accessible_skills("user-1", {"demo": source}) + projection = projection_service.sync_user_accessible_skills("user-1", {"demo": source}) outside = tmp_path / "outside.md" outside.write_text("# same\n", encoding="utf-8") - original_open = svc.open_regular_file_fd + original_open = projection_service.open_regular_file_fd def swap_before_open(*args, **kwargs): source_file.unlink() source_file.symlink_to(outside) return original_open(*args, **kwargs) - monkeypatch.setattr(svc, "open_regular_file_fd", swap_before_open) + monkeypatch.setattr(projection_service, "open_regular_file_fd", swap_before_open) with pytest.raises(PermissionError, match="symlink"): - svc.sync_user_accessible_skills("user-1", {"demo": source}) + projection_service.sync_user_accessible_skills("user-1", {"demo": source}) assert not (projection / "demo").exists() assert outside.read_text(encoding="utf-8") == "# same\n" @@ -810,9 +813,9 @@ async def refresh(uid: str): lifecycle.append(f"refresh:{uid}") return {} - monkeypatch.setattr(svc, "refresh_user_skill_projection_async", refresh) + monkeypatch.setattr(projection_service, "refresh_user_skill_projection_async", refresh) - await svc.apply_skill_projection_policy_change(Db(), "reporter") + await projection_service.commit_skill_policy_and_refresh_projections(Db(), "reporter") assert lifecycle == ["commit", "refresh:user-1", "refresh:user-2"] @@ -853,7 +856,7 @@ def test_sync_user_accessible_skills_rejects_special_files( source_dir = tmp_path / "s" source_dir.mkdir(parents=True) (source_dir / "SKILL.md").write_text("# personal\n", encoding="utf-8") - projection = svc.sync_user_accessible_skills("user-1", {slug: source_dir}) + projection = projection_service.sync_user_accessible_skills("user-1", {slug: source_dir}) assert (projection / slug / "SKILL.md").is_file() unix_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) @@ -861,7 +864,7 @@ def test_sync_user_accessible_skills_rejects_special_files( unix_socket.bind("stream.sock") try: with pytest.raises((OSError, ValueError)): - svc.sync_user_accessible_skills("user-1", {slug: source_dir}) + projection_service.sync_user_accessible_skills("user-1", {slug: source_dir}) finally: unix_socket.close() @@ -874,7 +877,7 @@ def test_personal_skill_root_is_inside_user_workspace(tmp_path: Path, monkeypatc monkeypatch.setattr(sandbox_paths, "get_user_data_dir", lambda: tmp_path / "user-data") - root = svc.get_personal_skills_root_dir("user-1") + root = user_workspace_dir("user-1") / "agents" / "skills" assert root == tmp_path / "user-data/shared/user-1/workspace/agents/skills" @@ -900,7 +903,9 @@ def test_personal_skill_root_rejects_symlinked_components( monkeypatch.setattr(sandbox_paths, "get_user_data_dir", lambda: user_data) with pytest.raises(ValueError, match="路径"): - svc._scan_personal_skills("user-1") + personal_service._scan_personal_skills("user-1") + if component == "agents": + assert not (outside / "skills").exists() @pytest.mark.asyncio @@ -912,8 +917,8 @@ async def test_read_personal_skill_file_rejects_symlink(tmp_path: Path, monkeypa outside.write_text("secret", encoding="utf-8") (skill_dir / "leak.txt").symlink_to(outside) - with pytest.raises(ValueError, match="越界访问"): - await svc.read_personal_skill_file("user-1", "demo", "leak.txt") + with pytest.raises(ValueError, match="路径非法"): + await personal_service.read_personal_skill_file("user-1", "demo", "leak.txt") def test_install_personal_skill_preserves_concurrent_target(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): @@ -932,7 +937,7 @@ def copytree_with_concurrent_target(source_dir, temp_target, **kwargs): monkeypatch.setattr(svc.shutil, "copytree", copytree_with_concurrent_target) with pytest.raises(ValueError, match="已存在同名 Skill"): - svc._install_personal_skill_dir_sync("user-1", source) + personal_service._install_personal_skill_dir_sync("user-1", source) assert (root / "demo/user-file.txt").read_text(encoding="utf-8") == "keep" @@ -948,12 +953,12 @@ def test_sync_user_accessible_skills_updates_executable_mode( script.write_text("#!/bin/sh\n", encoding="utf-8") script.chmod(0o644) - projection = svc.sync_user_accessible_skills("user-1", {slug: source_dir}) + projection = projection_service.sync_user_accessible_skills("user-1", {slug: source_dir}) projected_script = projection / slug / "run.sh" assert projected_script.stat().st_mode & 0o111 == 0 script.chmod(0o755) - svc.sync_user_accessible_skills("user-1", {slug: source_dir}) + projection_service.sync_user_accessible_skills("user-1", {slug: source_dir}) assert projected_script.stat().st_mode & 0o111 == 0o111 @@ -967,7 +972,7 @@ async def test_refresh_user_skill_projection_serializes_authorization_snapshots( advisory_lock = asyncio.Lock() first_sync_started = asyncio.Event() allow_first_sync = asyncio.Event() - current_items = [SimpleNamespace(slug="legacy", source_dir=Path("/tmp/legacy"))] + current_items = [SimpleNamespace(slug="legacy", source_dir=Path("/tmp/legacy"), enabled=True)] synchronized_sources: list[dict[str, str]] = [] class FakeDb: @@ -986,8 +991,7 @@ async def get_user(_self, _db, uid): assert uid == "user-1" return SimpleNamespace(is_deleted=0) - async def list_shared(_db, _user, *, require_enabled=True): - del require_enabled + async def list_shared(_db, _user): return list(current_items) async def to_thread(_func, _uid, sources): @@ -1002,14 +1006,14 @@ async def to_thread(_func, _uid, sources): lambda: FakeSessionContext(), ) monkeypatch.setattr(user_repository.UserRepository, "get_by_uid_with_db", get_user) - monkeypatch.setattr(svc, "list_accessible_shared_skills", list_shared) - monkeypatch.setattr(svc, "_resolve_skill_dir", lambda item: item.source_dir) + monkeypatch.setattr(projection_service, "lock_accessible_shared_skills_for_projection", list_shared) + monkeypatch.setattr(projection_service, "_resolve_shared_skill_dir", lambda item: item.source_dir) monkeypatch.setattr(svc.asyncio, "to_thread", to_thread) - old_run = asyncio.create_task(svc.refresh_user_skill_projection_async("user-1")) + old_run = asyncio.create_task(projection_service.refresh_user_skill_projection_async("user-1")) await first_sync_started.wait() current_items.clear() - new_run = asyncio.create_task(svc.refresh_user_skill_projection_async("user-1")) + new_run = asyncio.create_task(projection_service.refresh_user_skill_projection_async("user-1")) allow_first_sync.set() await asyncio.gather(old_run, new_run) @@ -1024,7 +1028,7 @@ async def test_refresh_user_skill_projection_excludes_personal_skills(monkeypatc from yuxi.storage.postgres import manager as postgres_manager synchronized_sources: list[dict[str, str]] = [] - shared = SimpleNamespace(slug="shared") + shared = SimpleNamespace(slug="shared", enabled=True) class FakeDb: async def execute(self, _statement, _parameters): @@ -1040,24 +1044,25 @@ async def __aexit__(self, *_args): async def get_user(_self, _db, _uid): return SimpleNamespace(is_deleted=0) - async def list_shared(_db, _user, *, require_enabled=True): - del require_enabled + async def list_shared(_db, _user): return [shared] async def fail_combined_list(*_args, **_kwargs): raise AssertionError("共享投影不得扫描或合并个人 Skill") - async def sync_projection(_uid, sources): + async def to_thread(func, uid, sources): + assert func is projection_service.sync_user_accessible_skills + assert uid == "user-1" synchronized_sources.append(dict(sources)) monkeypatch.setattr(postgres_manager.pg_manager, "get_async_session_context", lambda: FakeSessionContext()) monkeypatch.setattr(user_repository.UserRepository, "get_by_uid_with_db", get_user) - monkeypatch.setattr(svc, "list_accessible_shared_skills", list_shared) - monkeypatch.setattr(svc, "list_accessible_skills", fail_combined_list) - monkeypatch.setattr(svc, "_resolve_skill_dir", lambda item: Path(f"/tmp/{item.slug}")) - monkeypatch.setattr(svc, "sync_user_accessible_skills_async", sync_projection) + monkeypatch.setattr(projection_service, "lock_accessible_shared_skills_for_projection", list_shared) + monkeypatch.setattr(catalog, "list_accessible_skills", fail_combined_list) + monkeypatch.setattr(projection_service, "_resolve_shared_skill_dir", lambda item: Path(f"/tmp/{item.slug}")) + monkeypatch.setattr(projection_service.asyncio, "to_thread", to_thread) - sources = await svc.refresh_user_skill_projection_async("user-1") + sources = await projection_service.refresh_user_skill_projection_async("user-1") assert sources == {"shared": "/tmp/shared"} assert synchronized_sources == [{"shared": "/tmp/shared"}] @@ -1082,11 +1087,11 @@ async def fake_get_enabled_mcp_server_slugs(db=None): user = SimpleNamespace(uid="user") - async def fake_list_skill_slugs(_db, *, user): + async def fake_list_accessible_shared_skills(_db, user): assert user.uid == "user" - return ["alpha", "beta"] + return [SimpleNamespace(slug="alpha"), SimpleNamespace(slug="beta")] - monkeypatch.setattr(svc, "list_skill_slugs", fake_list_skill_slugs) + monkeypatch.setattr(svc.SkillRepository, "list_enabled_readable", fake_list_accessible_shared_skills) result = await svc.get_skill_dependency_options(None, user) assert result["tools"] == [{"slug": "calculator", "name": "Calculator"}, {"slug": "search", "name": "Search"}] @@ -1094,18 +1099,15 @@ async def fake_list_skill_slugs(_db, *, user): assert result["skills"] == ["alpha", "beta"] -def test_resolve_relative_path_blocks_traversal(tmp_path: Path): - skill_dir = tmp_path / "skill" - skill_dir.mkdir(parents=True, exist_ok=True) - - with pytest.raises(ValueError, match="上级路径"): - svc._resolve_relative_path(skill_dir, "../outside.txt") +def test_shared_skill_path_blocks_traversal(): + with pytest.raises(ValueError, match="非法 Skill 文件路径"): + edit_service._skill_path_parts("../outside.txt") @pytest.mark.asyncio async def test_skill_upload_prepare_confirm_rewrites_conflicting_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - class FakeRepo: + class FakeRepo(RealSkillRepository): existing_slugs = {"demo"} created_item: Skill | None = None @@ -1131,8 +1133,7 @@ async def create(self, **kwargs) -> Skill: ) operator = _user("root") - draft = await svc.prepare_skill_upload( - None, + draft = await draft_service.create_uploaded_skill_draft( filename="demo.zip", file_bytes=zip_bytes, operator=operator, @@ -1140,7 +1141,7 @@ async def create(self, **kwargs) -> Skill: results = await svc.confirm_skill_install_draft( _UnitOfWork(), draft_id=draft["draft_id"], - share_config=draft["default_share_config"], + share_config=None, operator=operator, ) @@ -1154,7 +1155,7 @@ async def create(self, **kwargs) -> Skill: @pytest.mark.asyncio async def test_skill_zip_import_uses_skill_md_name_not_zip_or_root_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - class FakeRepo: + class FakeRepo(RealSkillRepository): created_item: Skill | None = None def __init__(self, _db): @@ -1178,8 +1179,7 @@ async def create(self, **kwargs) -> Skill: ) operator = _user("root") - draft = await svc.prepare_skill_upload( - None, + draft = await draft_service.create_uploaded_skill_draft( filename="Bad--Archive-Name.zip", file_bytes=zip_bytes, operator=operator, @@ -1187,11 +1187,10 @@ async def create(self, **kwargs) -> Skill: results = await svc.confirm_skill_install_draft( _UnitOfWork(), draft_id=draft["draft_id"], - share_config=draft["default_share_config"], + share_config=None, operator=operator, ) - assert draft["items"][0]["original_name"] == "valid-skill" assert draft["items"][0]["slug"] == "valid-skill" assert results[0]["success"] is True assert results[0]["slug"] == "valid-skill" @@ -1204,7 +1203,7 @@ async def test_skill_zip_import_validates_skill_md_name_not_zip_filename( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): - class FakeRepo: + class FakeRepo(RealSkillRepository): def __init__(self, _db): pass @@ -1220,8 +1219,7 @@ async def exists_slug(self, _slug: str) -> bool: ) with pytest.raises(ValueError, match="SKILL.md frontmatter.name 必须是小写字母/数字/短横线"): - await svc.prepare_skill_upload( - None, + await draft_service.create_uploaded_skill_draft( filename="valid-archive.zip", file_bytes=zip_bytes, operator=_user("root"), @@ -1233,7 +1231,7 @@ async def test_skill_zip_import_uses_frontmatter_slug_and_keeps_display_name( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): - class FakeRepo: + class FakeRepo(RealSkillRepository): created_item: Skill | None = None def __init__(self, _db): @@ -1267,8 +1265,7 @@ async def create(self, **kwargs) -> Skill: ) operator = _user("root") - draft = await svc.prepare_skill_upload( - None, + draft = await draft_service.create_uploaded_skill_draft( filename="Word Skill.zip", file_bytes=zip_bytes, operator=operator, @@ -1276,7 +1273,7 @@ async def create(self, **kwargs) -> Skill: results = await svc.confirm_skill_install_draft( _UnitOfWork(), draft_id=draft["draft_id"], - share_config=draft["default_share_config"], + share_config=None, operator=operator, ) @@ -1293,7 +1290,7 @@ async def test_skill_zip_import_rewrites_conflicting_slug_not_display_name( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): - class FakeRepo: + class FakeRepo(RealSkillRepository): existing_slugs = {"word-docx"} created_item: Skill | None = None @@ -1325,8 +1322,7 @@ async def create(self, **kwargs) -> Skill: ) operator = _user("root") - draft = await svc.prepare_skill_upload( - None, + draft = await draft_service.create_uploaded_skill_draft( filename="Word Skill.zip", file_bytes=zip_bytes, operator=operator, @@ -1334,7 +1330,7 @@ async def create(self, **kwargs) -> Skill: results = await svc.confirm_skill_install_draft( _UnitOfWork(), draft_id=draft["draft_id"], - share_config=draft["default_share_config"], + share_config=None, operator=operator, ) @@ -1349,7 +1345,7 @@ async def create(self, **kwargs) -> Skill: @pytest.mark.asyncio async def test_skill_md_prepare_confirm_creates_single_file_skill(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - class FakeRepo: + class FakeRepo(RealSkillRepository): created_item: Skill | None = None def __init__(self, _db): @@ -1367,8 +1363,7 @@ async def create(self, **kwargs) -> Skill: skill_md = "---\nname: demo\ndescription: this is demo\n---\n# Demo\n" operator = _user("root") - draft = await svc.prepare_skill_upload( - None, + draft = await draft_service.create_uploaded_skill_draft( filename="SKILL.md", file_bytes=skill_md.encode("utf-8"), operator=operator, @@ -1376,7 +1371,7 @@ async def create(self, **kwargs) -> Skill: results = await svc.confirm_skill_install_draft( _UnitOfWork(), draft_id=draft["draft_id"], - share_config=draft["default_share_config"], + share_config=None, operator=operator, ) @@ -1386,168 +1381,6 @@ async def create(self, **kwargs) -> Skill: assert (tmp_path / "skill-sources/shared" / "demo" / "SKILL.md").read_text(encoding="utf-8") == skill_md -@pytest.mark.asyncio -async def test_update_skill_md_syncs_metadata(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - skill_dir = tmp_path / "skill-sources/shared" / "demo" - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text( - "---\nname: demo\ndescription: old\n---\n# old\n", - encoding="utf-8", - ) - - item = Skill( - slug="demo", - name="demo", - description="old", - dir_path="shared/demo", - created_by="root", - updated_by="root", - ) - - async def fake_get_manageable_skill_or_raise(_db, _operator, _slug: str): - return item - - updates: dict[str, str | None] = {} - - class FakeRepo: - def __init__(self, _db): - pass - - async def update_metadata( - self, - _item: Skill, - *, - name: str, - description: str, - updated_by: str | None, - ) -> Skill: - updates["name"] = name - updates["description"] = description - updates["updated_by"] = updated_by - return item - - monkeypatch.setattr(svc, "get_manageable_skill_or_raise", fake_get_manageable_skill_or_raise) - monkeypatch.setattr(svc, "SkillRepository", FakeRepo) - - new_content = "---\nname: demo\ndescription: updated desc\n---\n# updated\n" - await svc.update_skill_file( - _UnitOfWork(), - slug="demo", - relative_path="SKILL.md", - content=new_content, - updated_by="admin", - operator=_user("root"), - ) - - assert updates["name"] == "demo" - assert updates["description"] == "updated desc" - assert updates["updated_by"] == "admin" - saved_content = (skill_dir / "SKILL.md").read_text(encoding="utf-8") - assert "description: updated desc" in saved_content - - -@pytest.mark.asyncio -async def test_update_skill_dependencies(monkeypatch: pytest.MonkeyPatch): - item = Skill( - slug="alpha", - name="alpha", - description="alpha", - source_type="upload", - dir_path="shared/alpha", - created_by="root", - share_config={ - "version": 2, - "read_scope": {"access_level": "user", "user_uids": ["root"]}, - "manage_scope": {"access_level": "user", "user_uids": ["root"]}, - }, - enabled=True, - tool_dependencies=[], - mcp_dependencies=[], - skill_dependencies=[], - ) - dependency = Skill( - slug="beta", - name="beta", - description="beta", - source_type="upload", - dir_path="shared/beta", - created_by="root", - share_config={ - "version": 2, - "read_scope": {"access_level": "user", "user_uids": ["root"]}, - "manage_scope": {"access_level": "user", "user_uids": ["root"]}, - }, - enabled=True, - tool_dependencies=[], - mcp_dependencies=[], - skill_dependencies=[], - ) - - # Mock get_tool_metadata to return tool list - def fake_get_tool_metadata(category=None): - return [{"slug": "calculator", "name": "Calculator"}] - - monkeypatch.setattr(tool_service, "get_tool_metadata", fake_get_tool_metadata) - - async def fake_get_enabled_mcp_server_slugs(db=None): - del db - return ["mcp-a"] - - monkeypatch.setattr(svc, "get_enabled_mcp_server_slugs", fake_get_enabled_mcp_server_slugs) - - async def fake_get_skill_or_raise(_db, _operator, slug: str): - assert slug == "alpha" - return item - - captured: dict[str, list[str] | str | None] = {} - - class FakeRepo: - def __init__(self, _db): - pass - - async def list_all(self): - return [item, dependency] - - async def update_dependencies( - self, - _item: Skill, - *, - tool_dependencies: list[str], - mcp_dependencies: list[str], - skill_dependencies: list[str], - updated_by: str | None, - ): - captured["tool_dependencies"] = tool_dependencies - captured["mcp_dependencies"] = mcp_dependencies - captured["skill_dependencies"] = skill_dependencies - captured["updated_by"] = updated_by - _item.tool_dependencies = tool_dependencies - _item.mcp_dependencies = mcp_dependencies - _item.skill_dependencies = skill_dependencies - return _item - - async def fake_list_accessible_shared_skills(_db, _operator): - return [item, dependency] - - monkeypatch.setattr(svc, "get_manageable_skill_or_raise", fake_get_skill_or_raise) - monkeypatch.setattr(svc, "list_accessible_shared_skills", fake_list_accessible_shared_skills) - monkeypatch.setattr(svc, "SkillRepository", FakeRepo) - - updated = await svc.update_skill_dependencies( - _UnitOfWork(), - slug="alpha", - tool_dependencies=["calculator", "calculator"], - mcp_dependencies=["mcp-a", "mcp-a"], - skill_dependencies=["beta", "beta"], - operator=_user("root"), - ) - assert captured["tool_dependencies"] == ["calculator"] - assert captured["mcp_dependencies"] == ["mcp-a"] - assert captured["skill_dependencies"] == ["beta"] - assert captured["updated_by"] == "root" - assert updated.skill_dependencies == ["beta"] - - def test_skill_dependency_scope_covers_read_and_manage_audiences(): parent = Skill( slug="parent", @@ -1638,7 +1471,7 @@ async def test_init_builtin_skills_create_missing(tmp_path: Path, monkeypatch: p monkeypatch.setattr(svc, "BUILTIN_SKILLS_DIR", source_dir.parent) - class FakeRepo: + class FakeRepo(RealSkillRepository): created_payload: dict | None = None def __init__(self, _db): @@ -1714,7 +1547,7 @@ async def test_init_builtin_skills_updates_existing_record_and_preserves_disable captured: dict[str, object] = {} - class FakeRepo: + class FakeRepo(RealSkillRepository): def __init__(self, _db): pass @@ -1814,7 +1647,7 @@ async def test_init_builtin_skills_rejects_non_builtin_conflict(tmp_path: Path, ], ) - class FakeRepo: + class FakeRepo(RealSkillRepository): def __init__(self, _db): pass @@ -1843,7 +1676,7 @@ async def fake_get_manageable_skill_or_raise(_db, user, slug: str): assert slug == "reporter" return builtin_item - class FakeRepo: + class FakeRepo(RealSkillRepository): def __init__(self, _db): pass @@ -1854,7 +1687,7 @@ async def update_enabled(self, item: Skill, *, enabled: bool, updated_by: str | monkeypatch.setattr(svc, "get_manageable_skill_or_raise", fake_get_manageable_skill_or_raise) monkeypatch.setattr(svc, "SkillRepository", FakeRepo) - monkeypatch.setattr(svc, "apply_skill_projection_policy_change", lambda *_args: asyncio.sleep(0)) + monkeypatch.setattr(svc, "commit_skill_policy_and_refresh_projections", lambda *_args: asyncio.sleep(0)) updated = await svc.update_skill_enabled(_UnitOfWork(), slug="reporter", enabled=False, operator=_user("root")) @@ -1863,37 +1696,48 @@ async def update_enabled(self, item: Skill, *, enabled: bool, updated_by: str | @pytest.mark.asyncio -async def test_builtin_skill_file_edit_blocked(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - - target_dir = tmp_path / "skill-sources/shared" / "reporter" - target_dir.mkdir(parents=True, exist_ok=True) - (target_dir / "SKILL.md").write_text( - "---\nname: reporter\ndescription: builtin\n---\n# Reporter\n", - encoding="utf-8", +async def test_skill_node_mutations_lock_skill_row(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + item = Skill( + slug="demo", + name="demo", + description="demo", + source_type="upload", + dir_path="shared/demo", + share_config={ + "version": 2, + "read_scope": {"access_level": "global", "department_ids": [], "user_uids": []}, + "manage_scope": {"access_level": "global", "department_ids": [], "user_uids": []}, + }, ) + locked_reads = [] - builtin_item = Skill( - slug="reporter", - name="reporter", - description="builtin", - dir_path="shared/reporter", - source_type="builtin", - ) + class FakeRepo(RealSkillRepository): + def __init__(self, _db): + pass - async def fake_get_skill_or_raise(_db, _operator, _slug: str): - return builtin_item + async def get_by_slug(self, slug, *, for_update=False): + locked_reads.append((slug, for_update)) + return item - monkeypatch.setattr(svc, "get_manageable_skill_or_raise", fake_get_skill_or_raise) + monkeypatch.setattr(svc, "SkillRepository", FakeRepo) + source = tmp_path / "skill-sources/shared/demo" + source.mkdir(parents=True) + await edit_service.create_skill_node( + _UnitOfWork(), + slug="demo", + relative_path="notes", + is_dir=True, + content=None, + operator=_user("root", role="admin"), + ) + note = source / "notes" / "note.md" + note.write_text("before", encoding="utf-8") + await edit_service.delete_skill_node( + _UnitOfWork(), slug="demo", relative_path="notes/note.md", operator=_user("root", role="admin") + ) - with pytest.raises(ValueError, match="内置 skill 不允许直接修改文件"): - await svc.update_skill_file( - _UnitOfWork(), - slug="reporter", - relative_path="SKILL.md", - content="new content", - updated_by="root", - operator=_user("root"), - ) + assert locked_reads == [("demo", True), ("demo", True)] + assert not note.exists() @pytest.mark.asyncio @@ -1928,7 +1772,7 @@ async def test_delete_skills_batch_ok(tmp_path: Path, monkeypatch: pytest.Monkey db_items = {"skill-a": item_a, "skill-b": item_b} deleted_slugs = [] - class FakeRepo: + class FakeRepo(RealSkillRepository): def __init__(self, _db): pass @@ -1986,7 +1830,7 @@ class UnitOfWork(_UnitOfWork): async def commit(self) -> None: events.append("commit") - class FakeRepo: + class FakeRepo(RealSkillRepository): def __init__(self, _db): pass @@ -2033,9 +1877,9 @@ async def test_personal_skill_list_reads_current_workspace_state( root = _personal_skill_root(tmp_path, monkeypatch) _write_personal_skill(root, "demo", "first") - first = await svc.list_personal_skills("user-1") + first = await personal_service.list_personal_skills("user-1") _write_personal_skill(root, "demo", "changed") - current = await svc.list_personal_skills("user-1") + current = await personal_service.list_personal_skills("user-1") assert first[0].description == "first" assert current[0].description == "changed" @@ -2067,16 +1911,16 @@ async def test_personal_skill_overrides_shared_skill_and_drops_dependencies( skill_dependencies=["base"], ) - class FakeRepo: + class FakeRepo(RealSkillRepository): def __init__(self, _db): pass async def list_enabled(self): return [shared] - monkeypatch.setattr(svc, "SkillRepository", FakeRepo) + monkeypatch.setattr(catalog, "SkillRepository", FakeRepo) - items = await svc.list_accessible_skills(None, _user("user-1", role="user")) + items = await catalog.list_accessible_skills(None, _user("user-1", role="user")) assert len(items) == 1 assert items[0].source_scope == "personal" @@ -2098,8 +1942,8 @@ async def test_personal_skills_are_isolated_by_uid( _write_personal_skill(roots["user-a"], "demo", "from a") _write_personal_skill(roots["user-b"], "demo", "from b") - user_a = await svc.list_personal_skills("user-a") - user_b = await svc.list_personal_skills("user-b") + user_a = await personal_service.list_personal_skills("user-a") + user_b = await personal_service.list_personal_skills("user-b") assert user_a[0].description == "from a" assert user_b[0].description == "from b" @@ -2125,16 +1969,16 @@ async def test_skill_cards_keep_shadowed_shared_item_for_management( share_config={"version": 2, "read_scope": None, "manage_scope": None}, ) - class FakeRepo: + class FakeRepo(RealSkillRepository): def __init__(self, _db): pass async def list_all(self): return [shared] - monkeypatch.setattr(svc, "SkillRepository", FakeRepo) + monkeypatch.setattr(catalog, "SkillRepository", FakeRepo) - cards = await svc.list_skill_cards_for_user(None, _user("user-1", role="user")) + cards = await catalog.list_skill_cards_for_user(None, _user("user-1", role="user")) assert [(item.slug, item.source_scope) for item in cards] == [ ("demo", "personal"), @@ -2145,31 +1989,30 @@ async def list_all(self): @pytest.mark.asyncio -async def test_confirm_personal_skill_draft_uses_original_slug_without_database( +async def test_confirm_personal_skill_draft_uses_package_slug_without_database( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ): personal_root = _personal_skill_root(tmp_path, monkeypatch) draft_id = "11111111-1111-1111-1111-111111111111" draft_dir = tmp_path / "runtime/skill_import_drafts" / draft_id - item_dir = draft_dir / "items" / "item-1" + item_dir = draft_dir / "items" / ("a" * 32) item_dir.mkdir(parents=True) (item_dir / "SKILL.md").write_text( "---\nname: demo\ndescription: personal demo\n---\n# Demo\n", encoding="utf-8", ) (draft_dir / "metadata.json").write_text( - svc.json.dumps( + json.dumps( { "created_by": "user-1", "source_type": "remote", - "expires_at": svc.time.time() + 300, + "expires_at": time.time() + 300, + "failures": [], "items": [ { - "slug": "demo-v2", - "original_name": "demo", - "source_dir": "items/item-1", - "success": True, + "slug": "demo", + "source_dir": f"items/{'a' * 32}", } ], } @@ -2179,15 +2022,15 @@ async def test_confirm_personal_skill_draft_uses_original_slug_without_database( monkeypatch.setenv("YUXI_RUNTIME_DIR", str(tmp_path / "runtime")) - results = await svc.confirm_personal_skill_install_draft( + results = await personal_service.confirm_personal_skill_install_draft( draft_id=draft_id, - slugs=["demo-v2"], + slugs=["demo"], operator=_user("user-1", role="user"), ) assert results[0]["success"] is True assert results[0]["slug"] == "demo" - assert results[0]["requested_slug"] == "demo-v2" + assert results[0]["requested_slug"] == "demo" assert (personal_root / "demo" / "SKILL.md").exists() assert not draft_dir.exists() @@ -2207,7 +2050,7 @@ def test_resolved_shared_skill_captures_original_version_and_hash(monkeypatch, t content_hash="hash-v1", ) monkeypatch.setattr(svc, "_resolve_skill_dir", lambda item: tmp_path) - resolved = svc._resolved_shared_skill(row) + resolved = svc.resolved_shared_skill(row) row.version, row.content_hash = "v2", "hash-v2" assert resolved.version == "v1" assert resolved.content_hash == "hash-v1" diff --git a/backend/test/unit/services/test_artifact_service.py b/backend/test/unit/services/test_artifact_service.py index 43d2fea165..2d2868dcfc 100644 --- a/backend/test/unit/services/test_artifact_service.py +++ b/backend/test/unit/services/test_artifact_service.py @@ -5,12 +5,12 @@ from pathlib import Path import pytest -from fastapi import HTTPException - import yuxi.services.artifact_service as svc +from fastapi import HTTPException from yuxi.agents.backends.paths import workspace_scope_from_runtime_path -from yuxi.workspace.errors import FileTransferLimitError +from yuxi.services.skills import edit as skill_edit from yuxi.services.workdir_service import AuthorizedWorkdir +from yuxi.workspace.errors import FileTransferLimitError from yuxi.workspace.workdir import Workdir @@ -67,7 +67,9 @@ def add_runtime_file(self, runtime_path: str, content: bytes) -> None: @pytest.fixture def live_files(monkeypatch, tmp_path): - backend = _Workspace(tmp_path / "reporter") + (tmp_path / "shared").mkdir() + backend = _Workspace(tmp_path / "shared/reporter") + monkeypatch.setattr(skill_edit, "get_skill_data_dir", lambda: tmp_path) binding = AuthorizedWorkdir( conversation_id=1, thread_id="thread-1", @@ -93,8 +95,12 @@ async def resolve(**kwargs): ) monkeypatch.setattr( svc, - "list_accessible_skills", - lambda _db, _user: _async_value([type("Skill", (), {"slug": "reporter", "source_dir": backend.skill_root})()]), + "lock_accessible_shared_skill_for_file", + lambda _db, _user, slug: _async_value( + type("Skill", (), {"slug": "reporter", "dir_path": "shared/reporter", "source_type": "upload"})() + if slug == "reporter" + else None + ), ) return backend @@ -223,7 +229,7 @@ async def test_artifact_rejects_workdir_viewer_scope(live_files): @pytest.mark.asyncio async def test_artifact_rechecks_current_skill_authorization(live_files, monkeypatch): - monkeypatch.setattr(svc, "list_accessible_skills", lambda _db, _user: _async_value([])) + monkeypatch.setattr(svc, "lock_accessible_shared_skill_for_file", lambda _db, _user, _slug: _async_value(None)) with pytest.raises(HTTPException) as exc: await svc.resolve_thread_artifact_view( diff --git a/backend/test/unit/toolkits/test_install_skill.py b/backend/test/unit/toolkits/test_install_skill.py index f361daa567..1a68885bdf 100644 --- a/backend/test/unit/toolkits/test_install_skill.py +++ b/backend/test/unit/toolkits/test_install_skill.py @@ -6,8 +6,9 @@ from types import SimpleNamespace import pytest -from yuxi.agents.skills import service as skill_service from yuxi.agents.toolkits.buildin import install_skill as exported_install_skill +from yuxi.services.skills import personal as personal_service +from yuxi.workspace.paths import user_workspace_dir install_skill_module = importlib.import_module("yuxi.agents.toolkits.buildin.install_skill") sandbox_backend_module = importlib.import_module("yuxi.agents.backends.sandbox") @@ -33,12 +34,14 @@ def fail_get_session(): source_dir.mkdir() content = "---\nname: demo-skill\ndescription: Personal skill\n---\n# Demo\n" (source_dir / "SKILL.md").write_text(content, encoding="utf-8") - monkeypatch.setattr(install_skill_module, "_prepare_skill_from_sandbox", lambda *args: source_dir) + monkeypatch.setattr(personal_service, "_download_sandbox_skill", lambda *args: source_dir) runtime = _runtime(uid="user-1", thread_id="shared-agent-thread", skills=[]) - result = await install_skill_module._run_install_task("/home/gem/user-data/demo-skill", runtime, "tool-1") + result = await install_skill_module.install_skill.coroutine( + "/home/gem/user-data/demo-skill", runtime=runtime, tool_call_id="tool-1" + ) - installed = skill_service.get_personal_skills_root_dir("user-1") / "demo-skill" / "SKILL.md" + installed = user_workspace_dir("user-1") / "agents" / "skills" / "demo-skill" / "SKILL.md" assert installed.read_text(encoding="utf-8") == content assert result.update["messages"][0].content.splitlines() == [ "已安装 Skill: demo-skill", @@ -88,11 +91,11 @@ async def install_personal_skill_dir(uid, source_dir_arg, **kwargs): ) monkeypatch.setattr( - install_skill_module, - "_prepare_skill_from_sandbox", + personal_service, + "_download_sandbox_skill", prepare_skill_from_sandbox, ) - monkeypatch.setattr(skill_service, "install_personal_skill_dir", install_personal_skill_dir) + monkeypatch.setattr(personal_service, "install_personal_skill_dir", install_personal_skill_dir) runtime = _runtime( uid="normal-user", thread_id="thread-1", @@ -100,10 +103,10 @@ async def install_personal_skill_dir(uid, source_dir_arg, **kwargs): workdir_path="/home/gem/user-data/projects/11111111-1111-4111-8111-111111111111", skills=["existing-skill"], ) - result = await install_skill_module._run_install_task( + result = await install_skill_module.install_skill.coroutine( " /home/gem/user-data/demo-skill ", - runtime, - "tool-1", + runtime=runtime, + tool_call_id="tool-1", ) assert "activated_skills" not in result.update @@ -124,15 +127,15 @@ def fail_install(*args, **kwargs): raise AssertionError("子智能体运行态不应执行安装") monkeypatch.setattr( - skill_service, + personal_service, "install_personal_skill_dir", fail_install, ) - result = await install_skill_module._run_install_task( + result = await install_skill_module.install_skill.coroutine( "/home/gem/user-data/demo-skill", - _runtime(uid="user-1", thread_id="child-thread", is_subagent_runtime=True), - "tool-1", + runtime=_runtime(uid="user-1", thread_id="child-thread", is_subagent_runtime=True), + tool_call_id="tool-1", ) assert "只能在主智能体中使用" in result.update["messages"][0].content @@ -141,10 +144,10 @@ def fail_install(*args, **kwargs): @pytest.mark.asyncio async def test_install_skill_git_source_requires_skill_names(): - result = await install_skill_module._run_install_task( + result = await install_skill_module.install_skill.coroutine( "owner/repo", - _runtime(uid="user-1", thread_id="thread-1"), - "tool-1", + runtime=_runtime(uid="user-1", thread_id="thread-1"), + tool_call_id="tool-1", ) assert "必须通过 skill_names 指定技能名称" in result.update["messages"][0].content @@ -152,16 +155,16 @@ async def test_install_skill_git_source_requires_skill_names(): @pytest.mark.asyncio async def test_install_skill_rejects_empty_source(): - result = await install_skill_module._run_install_task( + result = await install_skill_module.install_skill.coroutine( " ", - _runtime(uid="user-1", thread_id="thread-1"), - "tool-1", + runtime=_runtime(uid="user-1", thread_id="thread-1"), + tool_call_id="tool-1", ) assert "Skill 来源不能为空" in result.update["messages"][0].content -def test_prepare_skill_from_sandbox_uses_sandbox_api_without_host_path_resolution(monkeypatch, tmp_path: Path): +def test_download_sandbox_skill_uses_sandbox_api_without_host_path_resolution(monkeypatch, tmp_path: Path): remote_dir = "/home/gem/user-data/demo-skill" class FakeProvisionerSandboxBackend: @@ -184,7 +187,7 @@ def download_files(self, paths): monkeypatch.setattr(sandbox_backend_module, "ProvisionerSandboxBackend", FakeProvisionerSandboxBackend) - staging = install_skill_module._prepare_skill_from_sandbox( + staging = personal_service._download_sandbox_skill( remote_dir, "thread-1", "user-1", @@ -194,7 +197,7 @@ def download_files(self, paths): assert (staging / "SKILL.md").read_text(encoding="utf-8") == "# demo" -def test_prepare_skill_from_sandbox_preserves_download_error_message(monkeypatch, tmp_path: Path): +def test_download_sandbox_skill_preserves_download_error_message(monkeypatch, tmp_path: Path): remote_dir = "/home/gem/user-data/demo-skill" class FakeProvisionerSandboxBackend: @@ -216,7 +219,7 @@ def download_files(self, _paths): monkeypatch.setattr(sandbox_backend_module, "ProvisionerSandboxBackend", FakeProvisionerSandboxBackend) with pytest.raises(ValueError, match="下载沙盒文件失败"): - install_skill_module._prepare_skill_from_sandbox( + personal_service._download_sandbox_skill( remote_dir, "thread-1", "user-1", diff --git a/docs/agents/skills-management.md b/docs/agents/skills-management.md index e95003a0bf..99b4d1aee1 100644 --- a/docs/agents/skills-management.md +++ b/docs/agents/skills-management.md @@ -55,7 +55,7 @@ skill_dependencies: [] 写出 Agent 应遵循的步骤、限制和验收方式。 ``` -必填字段是 `name` 和 `description`,两者最长 128 个字符。`slug` 可省略,省略时直接使用 `name`,因此省略 slug 时 `name` 本身也必须是小写字母、数字和单个短横线组成的值,中文或带空格的展示名称会校验失败。 +必填字段是 `name` 和 `description`。名称和 `slug` 最多 128 个字符。`slug` 可省略,省略时直接使用 `name`,因此省略 slug 时 `name` 本身也必须是小写字母、数字和单个短横线组成的值,中文或带空格的展示名称会校验失败。 建议显式填写 slug,把自然语言名称和稳定标识分开。 @@ -79,7 +79,9 @@ skill_dependencies: [] 4. **在线编辑**:编辑已有且有管理权限的共享 Skill 文件和依赖。 5. **Agent 内安装**:主智能体使用 `install_skill` 把 Skill 安装到当前用户的个人来源;子智能体不能使用该工具。 -上传和远程安装都先解析为草稿,再选择个人或共享位置并确认。确认前可以检查名称、说明、文件和依赖;取消草稿不会写入正式 Skill。 +上传和远程安装都先解析为草稿,再选择个人或共享位置并确认。草稿中的可安装条目只包含已解析的临时包;远程拉取或解析失败会单独显示,不能被确认安装。确认前可以检查名称、说明、文件和依赖;取消草稿不会写入正式 Skill。 + +有管理权限的用户可在共享 Skill 详情页编辑文本文件。保存 `SKILL.md` 时,根文件的名称、描述及依赖声明会同步到数据库索引;“配置”中的依赖选择也会回写根文件。每次保存都使用打开文件时取得的修订值;如果其他人已修改该文件,页面会拒绝覆盖并保留当前草稿,用户需核对最新内容后重试。切换文件或离开页面时,未保存的修改会触发确认。共享范围和启停状态仍在配置页分别保存。新的共享内容在后续 Agent Run 准备时生效;已准备 Run 的预加载内容保留原有快照,按需读取的用户投影可随之后的编辑刷新。 ### 远程来源限制 @@ -161,4 +163,4 @@ Skill 的选择影响 Prompt 和工具激活;共享投影按用户授权集合 - 脚本按不可信输入处理,不读取或输出运行环境中的秘密。 - 修改共享 Skill 的依赖、范围或文件后,用一个真实 Agent Run 验证模型可见工具和最终产物。 -实现入口见 [Skill 服务](https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/agents/skills/service.py)、[运行时解析](https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/agents/skills/runtime.py) 和 [Skills middleware](https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/agents/middlewares/skills.py)。 +实现入口见 [包格式解析](https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/services/skills/package.py)、[安装草稿生命周期](https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/services/skills/draft.py)、[共享 Skill 索引与安装](https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/services/skills/shared.py)、[共享文件编辑](https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/services/skills/edit.py)、[个人 Skill 安装与文件操作](https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/services/skills/personal.py)、[用户投影](https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/services/skills/projection.py)、[运行时解析](https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/agents/skills/runtime.py) 和 [Skills middleware](https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/agents/middlewares/skills.py)。 diff --git a/docs/develop-guides/decisions/archived/0.7.3/09-skill-cli/2026-08-18-skill-source-convergence.md b/docs/develop-guides/decisions/archived/0.7.3/09-skill-cli/2026-08-18-skill-source-convergence.md index 4e659aa8b1..cfef7b90ec 100644 --- a/docs/develop-guides/decisions/archived/0.7.3/09-skill-cli/2026-08-18-skill-source-convergence.md +++ b/docs/develop-guides/decisions/archived/0.7.3/09-skill-cli/2026-08-18-skill-source-convergence.md @@ -2,7 +2,7 @@ 状态:archived 类型:simplification -Owner:backend/package/yuxi/agents/skills/service.py +Owner:backend/package/yuxi/services/skills/shared.py Skill 持久目录配置由 `yuxi.config` 拥有;Prompt 与激活路径由 `agents/middlewares/skills.py` 拥有;Sandbox 只读挂载仍由 provisioner 拥有。 diff --git a/docs/develop-guides/decisions/archived/0.7.3/09-skill-cli/2026-08-20-skill-runtime-module-boundary.md b/docs/develop-guides/decisions/archived/0.7.3/09-skill-cli/2026-08-20-skill-runtime-module-boundary.md index 9e97a87ca4..77e33899cf 100644 --- a/docs/develop-guides/decisions/archived/0.7.3/09-skill-cli/2026-08-20-skill-runtime-module-boundary.md +++ b/docs/develop-guides/decisions/archived/0.7.3/09-skill-cli/2026-08-20-skill-runtime-module-boundary.md @@ -25,14 +25,14 @@ Skill 的存储和授权边界已经由共享 Skill 投影与个人 UserWorkspac `get_dependency_map()`,以及依赖包中没有消费者的 `skills` 字段;仓库没有旧 Python API 的 公开兼容承诺,因此不保留 re-export 或第二份实现。 -Skill 文件、安装、个人缓存、共享投影和路径权限仍由 `agents/skills/service.py` 拥有;数据库 -索引仍由 repository 拥有;Middleware 的激活时机、MCP best-effort 语义、共享/个人路径与 +Skill 文件与安装由 `services/skills` 按来源拥有,共享投影由 +`services/skills/projection.py` 拥有,数据库索引由 `repositories/skill_repository.py` 拥有;Middleware 的激活时机、MCP best-effort 语义、共享/个人路径与 Sandbox 来源校验不变。 ## 替代方案 - 保持所有解析函数位于 Middleware:改动风险最低,但不能解决反向依赖和职责混杂。 -- 移入 `agents/skills/service.py`:会把存储、安装、缓存、授权和运行时编排重新堆入同一服务, +- 移入 Agent 业务 service:会把存储、安装、缓存、授权和运行时编排重新堆入同一服务, 因此不采用。 - 保留 Middleware 兼容门面:仓库没有公开承诺或真实消费者证据,保留它只会制造长期维护面。 - 删除整个 Middleware:工具可见性门控、动态激活和 MCP 生命周期仍需要请求级 Owner,不能删除。 diff --git a/docs/develop-guides/decisions/archived/0.7.3/12-concurrency/2026-09-07-agent-concurrency-optimization.md b/docs/develop-guides/decisions/archived/0.7.3/12-concurrency/2026-09-07-agent-concurrency-optimization.md index 03bcd5cdbd..69338676fb 100644 --- a/docs/develop-guides/decisions/archived/0.7.3/12-concurrency/2026-09-07-agent-concurrency-optimization.md +++ b/docs/develop-guides/decisions/archived/0.7.3/12-concurrency/2026-09-07-agent-concurrency-optimization.md @@ -30,7 +30,7 @@ Callback 归位不改变实现、触发点或持久化规则,对应单测位 | 执行边界与 Owner | 保留的最小优化 | 不变的约束 | | --- | --- | --- | -| [Skill 投影](https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/agents/skills/service.py) | 锁内比较路径、类型、执行位与内容,未变则跳过 staging 复制及删除 | fd-relative、no-follow、跨进程锁及锁内授权重读不变;非法来源清理旧投影并失败,非法目标重建 | +| [Skill 投影](https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/services/skills/projection.py) | 锁内比较路径、类型、执行位与内容,未变则跳过 staging 复制及删除 | fd-relative、no-follow、跨进程锁及锁内授权重读不变;非法来源清理旧投影并失败,非法目标重建 | | [SubAgent 工具](https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/agents/middlewares/subagent_task.py) | 进程内只复用四个不可变输入 Schema | 工具实例、动态描述、闭包与父 Run 上下文逐次创建 | | [聊天服务](https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/services/chat_service.py) | 复用同 Run manifest 的规范化配置,直接调用拥有查询的 repository | 线程归属、Agent 可见性和 executor 权限不缓存、不跳过 | | [短期事件发布](https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/services/run_queue_service.py) | XADD 与 EXPIRE 使用一次非事务 pipeline | 发布顺序、事件 ID 与 TTL 保留;PostgreSQL 终态不依赖 Redis 成功 | diff --git a/docs/develop-guides/decisions/implemented/2026-09-28-shared-skill-edit.md b/docs/develop-guides/decisions/implemented/2026-09-28-shared-skill-edit.md new file mode 100644 index 0000000000..0d0f02eeed --- /dev/null +++ b/docs/develop-guides/decisions/implemented/2026-09-28-shared-skill-edit.md @@ -0,0 +1,42 @@ +# 共享 Skill 在线编辑 + +状态:implemented +类型:feature +Owner:backend/package/yuxi/services/skills/edit.py + +## 问题 + +共享 Skill 的文件、元数据与依赖分开写入。编辑根级 `SKILL.md` 时,名称和描述会更新,依赖声明不会同步;文件写入先于数据库提交,失败时可能留下不同版本。详情页切换文件会丢弃草稿,保存根文件会重载并覆盖同页未保存的配置。 + +## 决策 + +### 实现方案 + +共享 Skill 的文件保存与依赖表单保存沿用原有 HTTP 路径,由 `services/skills/edit.py` 统一处理。路由只转换请求和响应;service 校验管理权限、来源、相对路径、文件类型和依赖。普通文件保存更新共享目录;根级 `SKILL.md` 保存同时更新 `SkillRepository` 中的名称、描述和依赖索引。依赖表单保存反向修改根文件的 frontmatter,并更新同一数据库索引。文件读取、创建、删除和导出也由该 service 处理;共享范围与启停状态由共享索引 service 处理。个人 Skill 的工作区来源独立于共享数据库。 + +读取文件以原始字节计算 SHA-256 修订值,并在同一 Skill 共享行锁下返回根文件及其数据库索引;两种保存请求都必须提交对应文件的预期修订值。service 对写入取得独占行锁,文件创建和删除也使用同一行锁;编辑在校验修订值后,将临时文件写在 Skill 来源目录之外,再原子替换目标并提交数据库。运行时只锁定已选共享 Skill 及其依赖,用户投影单独锁定当前授权候选;两者都在锁后重新校验权限,直到对应文件读取或复制完成。投影刷新随后取得用户投影锁,与共享范围更新保持同一顺序。新授权若与本次筛选并发,当前读取可暂不包含它,由授权变更后的投影刷新补齐。修订值过期返回 HTTP 409;文件替换后的同步失败或数据库提交失败时恢复旧文件。权限和 no-follow 路径校验在共享文件 service 中执行,前端按钮不构成授权边界。 + +共享文件用例使用标准 `ExitStack` 统一释放已打开的目录描述符;文件替换与数据库提交共用一个异常补偿出口,恢复旧文件仍由编辑用例显式执行。投影策略用例命名为 `commit_skill_policy_and_refresh_projections`,明确其先撤回投影、提交授权变更再重建投影的事务责任。 + +根文件解析兼容 `description:` 后未引用的多行文本:仅在标准 YAML 解析失败时,将该字段按折叠文本重试,使预览、安装和保存接受同一格式。其他 YAML 错误继续拒绝。 + +详情页提供明确的文件编辑入口。编辑器在切换文件、切换页签、离开页面和刷新时保护未保存的草稿;确认放弃时清除草稿,保存失败时保留草稿。删除成功后直接离开已失效的详情页。读取文件及保存期间限制切换文件,并行保存期间锁定依赖选项,避免旧响应覆盖当前页面状态。根文件读取同时更新文件修订值和依赖表单,避免组合不同版本;根文件保存只更新相关元数据及当前文件,不重载并覆盖未保存的其他表单。依赖保存后刷新当前根文件及其修订值。依赖保存遇到修订值冲突时,用户须明确选择加载最新根文件;当前依赖草稿保留,再次保存会覆盖最新依赖选择。 + +## 替代方案 + +- 只调整前端、保留独立文件和依赖写入:改动更少,但两个持久来源仍可能分叉。 +- 整个 Skill 目录作为单次草稿发布:可以统一多文件版本,但需要目录级发布生命周期,不符合当前的单文件编辑需求。 + +## 后果 + +文件系统与 PostgreSQL 没有跨资源原子事务。进程在文件替换与数据库提交之间崩溃仍可能留下不一致;正常提交失败由 service 恢复文件。运行时预加载读取被行锁约束;已准备 Run 的按需文件读取依赖用户投影刷新,可能看到之后发布的新内容。依赖表单经 YAML 序列化回写 frontmatter,可能重排字段或丢失其中的注释,正文保持原样。旧客户端需要先读取文件修订值才能保存。 + +## 验证 + +`docker compose exec -T api env SANDBOX_RUNTIME_PROFILE=core uv run --no-sync --group test pytest test/unit -m 'not slow' -q -p no:cacheprovider --timeout=60` 通过:2454 passed、58 skipped。原样 `uv run --group test` 在 editable 包同步时因挂载目录不可写而失败;`--no-sync` 使用容器现有依赖测试当前挂载源码。 + +`docker compose exec -T api uv run --no-sync --group test pytest test/integration/api/test_shared_skill_edit_router.py test/integration/api/test_skill_artifact_authorization.py test/integration/services/test_user_skill_projection.py -q -p no:cacheprovider --timeout=120` 通过:7 passed,覆盖修订值冲突、文件/索引回读、共享锁、个人覆盖与 artifact 授权。`docker compose exec -T api uv run --no-sync --group test pytest test/e2e/test_shared_skill_edit_e2e.py -q -p no:cacheprovider --timeout=360` 通过:1 passed,使用确定性 replay provider,回读真实 worker 的投影内容与 PostgreSQL Run 清单。 + +前端 `docker compose exec -T web pnpm run lint:check`、`pnpm run test:unit` 和 `pnpm run build` 通过,unit 为 394 passed。真实浏览器验证文件保存并从 HTTP 回读、取消切页保留草稿、409 后保留编辑内容。工程契约检查及 63 项测试、锁定版本 Ruff 的 lint/format/import 检查、`pnpm --dir docs run build` 与 `git diff --check` 通过。 + +完整 integration/E2E 套件、真实外部模型 provider 与进程崩溃恢复未验证;单测跳过项不计入通过结果。 diff --git a/docs/develop-guides/decisions/implemented/2026-09-29-personal-skill-service-ownership.md b/docs/develop-guides/decisions/implemented/2026-09-29-personal-skill-service-ownership.md new file mode 100644 index 0000000000..ef8b9373d3 --- /dev/null +++ b/docs/develop-guides/decisions/implemented/2026-09-29-personal-skill-service-ownership.md @@ -0,0 +1,31 @@ +# 个人 Skill 文件用例由专用服务拥有 + +状态:implemented +类型:architecture +Owner:backend/package/yuxi/services/skills/personal.py + +## 问题 + +个人 Skill 的草稿确认位于 service,列举、安装、读取和删除却位于 `workspace/personal_skills.py`。同一来源的业务流程分散在两个模块;目录拼接又经一次性函数转发。新 Skill 模块的部分私有实现位于主要用例之前,增加了阅读负担。 + +## 决策 + +### 实现方案 + +个人 Skill 的确认、列举、安装、读取和删除由 `services/skills/personal.py` 统一拥有;共用包解析和复制由 `services/skills/package.py` 提供,底层文件原语复用现有 filesystem 工具。`workspace/personal_skills.py` 和只拼接目录的 `get_personal_skills_root_dir` 不再存在。个人 Skill 字节仍保存在现有 UserWorkspace 的 `agents/skills`,服务在安装、读取和删除边界校验固定用户目录及目标路径。 + +`personal.py` 是唯一允许直接定位 UserWorkspace 宿主根的 service。工程门禁按精确文件路径放行它对 `user_workspace_dir` 的导入,继续拒绝其他 service、repository 对宿主根的访问。新增 Skill 模块按主要公开用例、领域查询、私有实现的顺序组织;单次异步投影转发和仅提取工具 slug 的薄层函数内联到消费用例。 + +## 替代方案 + +- 个人文件操作继续放在 workspace,再由 service 转发:保持原宿主路径门禁,但业务 Owner 分裂,且需要无业务语义的转发层。 +- 将个人 Skill 字节迁出 UserWorkspace:需要数据迁移与运行时挂载改动,会改变既有持久来源和用户可见路径。 +- 放宽所有 service 的宿主路径访问:实现简单,却失去 UserWorkspace 的现有跨层边界。 + +## 后果 + +个人 Skill 的业务入口集中在专用 service;数据仍由当前用户的 UserWorkspace 持有。精确例外要求该服务自行维护 uid 与路径的文件边界;若未来再拆分个人文件操作,必须同步审查门禁和架构规则。 + +## 验证 + +`python3 scripts/verify_engineering_contracts.py` 与 `python3 -m unittest scripts.test_verify_engineering_contracts` 通过(63 项),包括允许个人 Skill Owner 定位用户根、拒绝其他 service 获取宿主路径的负向案例。完整后端 non-slow unit 通过(2454 passed、58 skipped),共享文件与投影相关真实 HTTP/PostgreSQL integration 通过(7 passed)。命令与环境限制见[共享 Skill 编辑验证](./2026-09-28-shared-skill-edit.md#验证);未运行完整个人 Skill E2E。 diff --git a/docs/develop-guides/decisions/implemented/2026-09-29-skill-draft-contract.md b/docs/develop-guides/decisions/implemented/2026-09-29-skill-draft-contract.md new file mode 100644 index 0000000000..7a653eeb6b --- /dev/null +++ b/docs/develop-guides/decisions/implemented/2026-09-29-skill-draft-contract.md @@ -0,0 +1,37 @@ +# Skill 安装草稿的可确认条目 + +状态:implemented +类型:architecture +Owner:backend/package/yuxi/services/skills/draft.py + +## 问题 + +安装草稿的 `items` 曾同时包含有效包快照和远程拉取失败结果。个人与共享确认各自解释松散字典中的 `success`、slug 和目录路径;按 slug 选择时,重复条目也没有明确身份。 + +## 决策 + +### 实现方案 + +`services/skills/draft.py` 拥有上传和远程安装草稿的创建、加载、授权、条目选择、消费及删除。元数据 `items` 只包含已解析的包快照,获取或解析失败保存在 `failures`。共享授权选项由 HTTP 响应适配层装配,不进入草稿。草稿加载校验期限、操作者、来源、唯一 slug 与路径,向目标来源返回具名条目。 + +`remote.py` 拥有远程发现与目录下载;获取结果明确区分 `DownloadedSkill` 与 `SkillDownloadFailure`,下载完成不代表包格式有效。个人 service 同时拥有草稿确认和 Agent 直接安装;工具仅提取运行上下文并装配 ToolMessage,直接安装无需创建持久草稿。共享 service 在确认时分配 slug 并提交文件和索引。两个安装执行边界均重新解析复制后的包并核对草稿 slug。 + +页面保留失败条目对应草稿用于重试,用户结束流程时清理剩余草稿。重试按实际可复用失败条目选择路径;草稿只剩用户移除的条目时,清理旧草稿并重新获取远程失败项,不把已移除项重新加入安装。确认后只消费成功条目,保留失败和未选条目的快照及原有效期;元数据以临时文件替换发布,最后一个可安装条目消费后删除草稿。此契约支持正常请求完成后的部分失败重试,不承诺并发确认或进程崩溃后的恰好一次安装。个人安装已成功但草稿消费失败时,重试仍遵循个人同名拒绝规则。 + +上传与远程草稿共用元数据写入函数,来源入口只负责获取和暂存条目。ZIP 从上传字节直接解压;远程下载以请求位置保存逐项结果,显式在获取失败或沙盒清理失败时释放宿主临时目录,成功后由调用方持有。组合查询直接以 slug 合并共享和个人结果。 + +## 替代方案 + +- 保持成功与失败混合并在两个确认函数中重复判断:改动少,但草稿始终没有可执行契约。 +- 引入跨个人与共享来源的通用安装状态机:能统一调用形状,却会混淆文件系统和数据库的不同发布规则。 +- 改为公开 item ID 选择协议:身份最明确,但当前草稿可直接保证 slug 唯一,立即修改 HTTP 契约没有必要。 + +## 后果 + +草稿、包获取和目标安装各自拥有清晰边界;个人和共享保留各自发布规则。`prepare.py` 与旧准备函数移除,不提供转发兼容层。个人与共享组合查询归属 `catalog.py`。运行时先扫描个人来源,再锁定未被个人覆盖的已选共享包和依赖。显式选择的个人覆盖版本仍可按配置预加载,自动加入的个人包不扩大预加载范围。 + +## 验证 + +草稿持久文件回读、部分成功、远程获取结果及工具调用用例随完整后端 non-slow unit 通过(2454 passed、58 skipped);个人覆盖场景随相关 PostgreSQL integration 通过(7 passed)。前端 394 项 unit 通过,重试案例覆盖用户移除的条目不重新选中、混合解析失败保留,以及草稿仅剩未选条目时重新获取远程失败项。最后一个案例在恢复旧分支条件时因未发起重新获取而失败,修复后通过。 + +工程契约检查及其 63 项测试、前端 lint/build、锁定版本 Ruff、文档构建和 `git diff --check` 通过。完整命令与环境限制见[共享 Skill 编辑验证](./2026-09-28-shared-skill-edit.md#验证)。真实远程下载服务不可用时的恢复未做端到端验证。 diff --git a/docs/develop-guides/decisions/implemented/2026-09-29-skill-module-ownership.md b/docs/develop-guides/decisions/implemented/2026-09-29-skill-module-ownership.md new file mode 100644 index 0000000000..8c5724655a --- /dev/null +++ b/docs/develop-guides/decisions/implemented/2026-09-29-skill-module-ownership.md @@ -0,0 +1,33 @@ +# Skill 模块按运行时、用例与仓储分层 + +状态:implemented +类型:architecture +Owner:backend/package/yuxi/services/skills/shared.py + +## 问题 + +Skill 安装和管理用例曾散落在 `agents/skills` 与顶层 `services`;数据库查询也位于 Agent 目录。目录职责与架构约定不一致,调用方需要跨多个位置寻找同一业务链路。 + +## 决策 + +### 实现方案 + +共享、个人、草稿、远程获取、编辑和投影用例位于 `yuxi.services.skills`;Skill 数据库访问位于 `yuxi.repositories.skill_repository`。包解析、快照复制及包内 slug 改写集中在 `services/skills/package.py`,来源描述位于同目录 `resolved.py`;用例、Agent 运行时和存储迁移直接导入这些实际模块。`yuxi.agents.skills` 保留运行时 Skill 解析和内置 Skill 资源。HTTP 路由、worker、存储迁移与测试都直接导入新的语义 Owner;旧模块路径不保留兼容转发。 + +这次模块迁移不改变 HTTP 契约、持久数据、安装结果或运行时选择语义。远程获取的公开入口位于私有执行与解析细节之前;个人 Skill 的确认与文件操作由 `services/skills/personal.py` 集中持有,`workspace.paths` 只提供用户工作区根与校验原语。 + +简单共享查询由调用方直接使用 `SkillRepository`,service 不提供逐方法转发。内置来源筛选由 repository 的 SQL 查询执行,保留原有排序。service 保留组合查询、安装、授权与一致性用例。 + +## 替代方案 + +- 继续保留散落文件,仅重命名模块:改动小,但架构 Owner 仍不清楚。 +- 把所有文件放进 `agents/skills`:Agent 运行时入口集中,却继续让安装、权限和 PostgreSQL 查询受 Agent 目录拥有。 +- 为包格式和类型单设顶层目录:增加一个查找位置,且包快照复制本就拥有文件副作用;当前没有需要独立维护的领域包边界。 + +## 后果 + +调用方按运行时、用例、格式和数据库边界定位 Skill 代码。包格式与文件复制归属 Skill service 内部模块,`workspace` 不依赖这些模块;`services/skills/shared.py` 仍通过 Agent 内置资源目录发现随代码发布的 Skill。旧 Python 模块路径的仓库外消费者需要同步迁移。 + +## 验证 + +旧路径搜索、工程契约检查及其 63 项测试通过;完整后端 non-slow unit 为 2454 passed、58 skipped,相关真实 HTTP/PostgreSQL integration 为 7 passed,共享编辑到真实 worker 的确定性 E2E 为 1 passed。锁定版本 Ruff 的 lint、format 和 import 检查,以及前端 lint、394 项 unit、生产构建和文档构建通过。命令与未验证范围见[共享 Skill 编辑验证](./2026-09-28-shared-skill-edit.md#验证)。 diff --git a/docs/develop-guides/decisions/implemented/2026-09-29-skill-owner-boundaries.md b/docs/develop-guides/decisions/implemented/2026-09-29-skill-owner-boundaries.md new file mode 100644 index 0000000000..7678dbc2ac --- /dev/null +++ b/docs/develop-guides/decisions/implemented/2026-09-29-skill-owner-boundaries.md @@ -0,0 +1,32 @@ +# Skill 来源与文件一致性边界 + +状态:implemented +类型:architecture +Owner:backend/package/yuxi/services/skills/projection.py + +## 问题 + +共享 Skill、个人 Skill、安装草稿和用户投影曾由单个 service 混合处理。共享文件编辑引入行锁和修订值后,读取、创建、删除、导出与 artifact 仍可能走不同的来源和锁协议;运行时通过通用列表函数的布尔参数选择锁语义。调用者无法从入口判断授权、文件可见性和事务的责任边界。 + +## 决策 + +### 实现方案 + +个人 Skill 的草稿确认、列举、安装、读取与删除由 `backend/package/yuxi/services/skills/personal.py` 负责,用户工作区根由 `workspace.paths` 提供。个人安装沿用包内 slug,不查询或分配共享数据库 slug。共享 Skill 的业务索引和安装保留在 `backend/package/yuxi/services/skills/shared.py`;包格式解析位于 `backend/package/yuxi/services/skills/package.py`,上传与远程暂存位于 `backend/package/yuxi/services/skills/draft.py`,草稿读取与筛选位于 `backend/package/yuxi/services/skills/draft.py`。 + +共享文件入口由 `backend/package/yuxi/services/skills/edit.py` 持有:先锁定共享数据库行并重查权限,再从可信根逐层 no-follow 打开目录和普通文件。编辑使用修订值,发布文件后提交索引;提交失败恢复旧文件。创建失败撤回新节点,删除在提交前把节点移到暂存区以便恢复。Artifact 下载只使用已授权的共享行定位文件,不经个人同名覆盖。 + +投影授权快照、跨进程锁和目录发布由本记录的 Owner 负责;运行时单独锁定已选择的共享 Skill 及其依赖。展示列表不接受影响授权或锁行为的布尔参数。HTTP 路由仍只编排对应 service,repository 持有可见性查询和行锁查询。 + +## 替代方案 + +- 只调整单个 service 的函数排序:改动少,但文件来源和事务责任仍混合。 +- 建立统一 Skill provider 框架:可统一调用形态,却把个人工作区与共享数据库伪装为相同的持久化模型。 + +## 后果 + +个人与共享 Skill 保持同名覆盖的运行时语义,但安装命名互不影响。共享文件读取、运行时快照和投影刷新使用各自明确的锁入口。PostgreSQL 与文件系统无法组成原子事务;进程在文件替换和数据库提交之间崩溃的窗口仍是已知限制,需要后续恢复机制才能消除。 + +## 验证 + +完整后端 non-slow unit 通过(2454 passed、58 skipped),相关真实 HTTP/PostgreSQL integration 通过(7 passed),共享编辑到真实 worker 的确定性 E2E 通过(1 passed),覆盖修订值、共享行锁、投影回读、artifact 来源及 Run 清单。工程契约检查及其 63 项测试、锁定版本 Ruff 与补丁检查通过。命令与未验证范围见[共享 Skill 编辑验证](./2026-09-28-shared-skill-edit.md#验证);这些证据不覆盖文件与数据库发布之间的进程崩溃窗口。 diff --git a/scripts/test_verify_engineering_contracts.py b/scripts/test_verify_engineering_contracts.py index 23d1090acd..371ece4f54 100644 --- a/scripts/test_verify_engineering_contracts.py +++ b/scripts/test_verify_engineering_contracts.py @@ -599,6 +599,15 @@ def test_service_workspace_host_path_bypasses_are_rejected(self) -> None: any(expected_error in error for error in self._errors()) ) + def test_personal_skill_service_is_the_only_service_with_workspace_root_access(self) -> None: + """个人 Skill 文件 Owner 可定位用户根,其余 Service 仍被拒绝。""" + source = "from yuxi.workspace.paths import user_workspace_dir\n" + self._write("backend/package/yuxi/services/skills/personal.py", source) + self.assertFalse(any("UserWorkspace 宿主 Path" in error for error in self._errors())) + + self._write("backend/package/yuxi/services/skills/other.py", source) + self.assertTrue(any("UserWorkspace 宿主 Path" in error for error in self._errors())) + def test_agents_instruction_file_missing_is_rejected(self) -> None: (self.root / "backend/AGENTS.md").unlink() diff --git a/scripts/verify_engineering_contracts.py b/scripts/verify_engineering_contracts.py index ad8f243461..474f0e10c9 100644 --- a/scripts/verify_engineering_contracts.py +++ b/scripts/verify_engineering_contracts.py @@ -66,6 +66,7 @@ "user_workdir_host_dir", } ) +PERSONAL_SKILL_WORKSPACE_OWNER = Path("backend/package/yuxi/services/skills/personal.py") DIRECT_WEB_API_LITERAL = re.compile(r"(?P['\"`])/api(?:[/ ?]|(?P=quote))") AGENTS_FILE_BUDGETS = { "AGENTS.md": 5000, @@ -963,6 +964,8 @@ def _validate_workspace_host_path_boundary(root: Path, errors: list[str]) -> int alias.name for alias in node.names ) ) + if relative == PERSONAL_SKILL_WORKSPACE_OWNER: + forbidden.discard("user_workspace_dir") elif isinstance(node, ast.ImportFrom) and node.module == "yuxi.config": if any(alias.name == "get_user_data_dir" for alias in node.names): forbidden.add("get_user_data_dir") diff --git a/web/src/apis/skill_api.js b/web/src/apis/skill_api.js index b6e3f2793e..75eac454b0 100644 --- a/web/src/apis/skill_api.js +++ b/web/src/apis/skill_api.js @@ -7,10 +7,7 @@ export const listSkills = async () => { return apiGet(BASE_URL) } -export const listSkillCards = async ({ refreshPersonal = false } = {}) => { - const query = refreshPersonal ? '?refresh_personal=true' : '' - return apiGet(`${USER_BASE_URL}${query}`) -} +export const listSkillCards = async () => apiGet(USER_BASE_URL) export const listAccessibleSkills = async () => { return apiGet(`${USER_BASE_URL}/accessible`) diff --git a/web/src/components/AgentFilePreview.vue b/web/src/components/AgentFilePreview.vue index c4ea381c0b..514f7c95bd 100644 --- a/web/src/components/AgentFilePreview.vue +++ b/web/src/components/AgentFilePreview.vue @@ -146,6 +146,16 @@
未保存 +
@@ -1023,10 +1023,10 @@ const handleBatchDelete = () => { }) } -const fetchSkills = async ({ refreshPersonal = false } = {}) => { +const fetchSkills = async () => { loading.value = true try { - const skillResult = await skillApi.listSkillCards({ refreshPersonal }) + const skillResult = await skillApi.listSkillCards() skills.value = skillResult?.data || [] } catch { message.error('加载失败') diff --git a/web/src/components/extensions/SkillDetailView.vue b/web/src/components/extensions/SkillDetailView.vue index fcfafaf862..bddbc7ea17 100644 --- a/web/src/components/extensions/SkillDetailView.vue +++ b/web/src/components/extensions/SkillDetailView.vue @@ -1,6 +1,7 @@