diff --git a/backend/app/api/general_skills.py b/backend/app/api/general_skills.py index aa8161791..c455de9d2 100644 --- a/backend/app/api/general_skills.py +++ b/backend/app/api/general_skills.py @@ -61,6 +61,15 @@ ) from app.general_skills.runner import GeneralSkillReader, GeneralSkillRunner from app.general_skills.schema import GeneralSkillFile +from app.general_skills.standard import ( + compose_skill_markdown, + frontmatter_for_skill, + split_frontmatter, + standard_package_files, + validate_skill_compatibility, + validate_skill_description, + validate_skill_name, +) from app.llm.model_config_resolver import resolve_model_config_for_runtime from app.security.auth import get_current_user from app.security.permissions import ( @@ -95,7 +104,33 @@ def _agent_id_or_none(agent_id: object | None) -> str | None: return agent_id if isinstance(agent_id, str) and agent_id else None +def _resolve_optional_field( + request_value: str | None, existing_value: object, is_edit: bool +) -> str: + """可选规范字段(license/compatibility/allowed-tools)的取值规则: + + - 未提交(None):保留既有 frontmatter 值(旧客户端/第三方导入不变); + - 非空:采用提交值; + - 显式空串:编辑场景视为清空,新建场景回退既有值。 + """ + if request_value is None: + return str(existing_value or "") + text = request_value.strip() + if text: + return text + return "" if is_edit else str(existing_value or "") + + +def _validate_skill_publishable(row: GeneralSkill) -> None: + """发布校验(规范 name/description 必填且合规),所有发布路径统一调用。""" + if error := validate_skill_name(row.slug): + raise HTTPException(status_code=400, detail=f"发布失败:slug(name):{error}") + if error := validate_skill_description(row.description, required=True): + raise HTTPException(status_code=400, detail=f"发布失败:{error}") + + def general_skill_read(row: GeneralSkill, status_override: str | None = None) -> GeneralSkillRead: + frontmatter = frontmatter_for_skill(row) return GeneralSkillRead( id=row.id, tenant_id=row.tenant_id, @@ -103,6 +138,9 @@ def general_skill_read(row: GeneralSkill, status_override: str | None = None) -> name=row.name, description=row.description, homepage=row.homepage, + license=frontmatter["license"] or None, + compatibility=frontmatter["compatibility"] or None, + allowed_tools=frontmatter["allowed_tools"] or None, skill_markdown=row.skill_markdown, skill_files=[ GeneralSkillFile.model_validate(item) for item in _skill_files_or_markdown(row) @@ -148,6 +186,38 @@ def import_general_skill( ) _validate_slug(slug) lookup_slug = _optional_text(request.original_slug) + # Agent Skills 规范校验:新建时 slug(即规范 name,与目录名一致)必须合规; + # 存量 slug 不可修改,编辑时宽限(只拦新格式违规的新建) + if not lookup_slug and (error := validate_skill_name(slug)): + raise HTTPException(status_code=400, detail=f"Slug(name):{error}") + if error := validate_skill_description(description, required=request.status == "published"): + raise HTTPException(status_code=400, detail=error) + if error := validate_skill_compatibility(request.compatibility or ""): + raise HTTPException(status_code=400, detail=error) + # SKILL.md 归一化:frontmatter 以表单/规范字段重组(name 恒等于 slug),正文保留; + # 非法/未闭合的 frontmatter 直接拒绝(官方 skills-ref 解析器同语义) + try: + existing_frontmatter, markdown_body = split_frontmatter(markdown, strict=True) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + is_edit = bool(lookup_slug) + markdown = compose_skill_markdown( + name=slug, + description=description or "", + body=markdown_body, + license=_resolve_optional_field(request.license, existing_frontmatter.get("license"), is_edit), + compatibility=_resolve_optional_field( + request.compatibility, existing_frontmatter.get("compatibility"), is_edit + ), + allowed_tools=_resolve_optional_field( + request.allowed_tools, existing_frontmatter.get("allowed-tools"), is_edit + ), + metadata=( + existing_frontmatter.get("metadata") + if isinstance(existing_frontmatter.get("metadata"), dict) + else {} + ), + ) agent_id = _agent_id_or_none(request.agent_id) agent = ensure_agent_scope_manager(db, request.tenant_id, agent_id, current_user) is_private_agent_scope = bool(agent and not agent.is_overall) @@ -405,6 +475,26 @@ def _create_imported_general_skill( or _clawhub_homepage_from_source(import_source) ) _validate_slug(resolved_slug) + # Agent Skills 规范:slug 必须合规范(name 形态);不合规的导入名经 _slugify 清洗 + if validate_skill_name(resolved_slug): + resolved_slug = _unique_slug(db, tenant_id, _slugify(resolved_slug)) + if error := validate_skill_description(resolved_description, required=status == "published"): + raise HTTPException(status_code=400, detail=error) + # SKILL.md 归一化:frontmatter 以解析/规范字段重组(name 恒等于 slug),正文保留 + existing_frontmatter, markdown_body = split_frontmatter(markdown) + markdown = compose_skill_markdown( + name=resolved_slug, + description=resolved_description or "", + body=markdown_body, + license=str(existing_frontmatter.get("license") or ""), + compatibility=str(existing_frontmatter.get("compatibility") or ""), + allowed_tools=str(existing_frontmatter.get("allowed-tools") or ""), + metadata=( + existing_frontmatter.get("metadata") + if isinstance(existing_frontmatter.get("metadata"), dict) + else {} + ), + ) now = utc_now() resolved_agent_id = _agent_id_or_none(agent_id) agent = ensure_agent_scope_manager(db, tenant_id, resolved_agent_id, current_user) @@ -533,6 +623,28 @@ def get_general_skill( return general_skill_read(row) +@router.get("/{slug}/export", dependencies=[Depends(require_agent_scope_viewer)]) +def export_general_skill( + slug: str, + tenant_id: str = Query(...), + db: Session = Depends(get_session), + agent_id: str | None = Query(None), +) -> StreamingResponse: + """导出标准 Agent Skills 包(zip):根目录为 slug,SKILL.md 为规范化版本。""" + row = _get_general_skill(db, tenant_id, slug) + _ensure_general_skill_visible(db, tenant_id, row, agent_id) + buffer = BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for file in standard_package_files(row): + archive.writestr(f"{row.slug}/{file['path']}", file["content"]) + buffer.seek(0) + return StreamingResponse( + buffer, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{row.slug}.zip"'}, + ) + + @router.post("/{slug}/publish", response_model=GeneralSkillRead) def publish_general_skill( slug: str, @@ -544,6 +656,8 @@ def publish_general_skill( row = _get_general_skill(db, tenant_id, slug) agent_id = _agent_id_or_none(agent_id) agent = ensure_agent_scope_manager(db, tenant_id, agent_id, current_user) + # 发布校验对所有发布路径生效(含员工私有分支) + _validate_skill_publishable(row) if agent and not agent.is_overall: binding = _ensure_general_skill_binding( db, @@ -623,6 +737,8 @@ def publish_general_skill_to_gallery( if not _private_skill_owned_by_agent(db, tenant_id, row, agent.id): raise HTTPException(status_code=403, detail="Only the skill owner can publish it") + # 发布到广场前同样必须通过规范校验(私有技能不豁免) + _validate_skill_publishable(row) row.status = "published" mark_resource_open_gallery(row, row.metadata_json or {}) row.updated_at = utc_now() @@ -1240,7 +1356,10 @@ def _metadata_text(metadata: dict[str, object], *keys: str) -> str | None: def _slugify(value: str) -> str: - slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", value.strip().lower()).strip("-_") + """转成规范 name 形态:小写、下划线转连字符、连续连字符收敛、去首尾连字符。""" + slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", value.strip().lower()) + slug = slug.replace("_", "-") + slug = re.sub(r"-{2,}", "-", slug).strip("-") return slug or "general-skill" diff --git a/backend/app/channels/schema.py b/backend/app/channels/schema.py index c1bdd67ad..be4f3479f 100644 --- a/backend/app/channels/schema.py +++ b/backend/app/channels/schema.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Optional from pydantic import BaseModel from sqlmodel import Session, select diff --git a/backend/app/general_skills/runner.py b/backend/app/general_skills/runner.py index 8aa86b36c..52e075677 100644 --- a/backend/app/general_skills/runner.py +++ b/backend/app/general_skills/runner.py @@ -17,6 +17,7 @@ from app import paths from app.db.models import GeneralSkill, ModelConfig from app.general_skills.runtime_env import ( + GeneralSkillRuntimeError, ensure_runtime_python, runtime_environment, @@ -31,6 +32,7 @@ from app.harness.artifacts import HarnessArtifactAccessError, normalize_harness_artifact_path from app.harness.command import run_sandboxed_process from app.harness.errors import HarnessExecutionError +from app.general_skills.standard import allowed_tools_list, standard_package_files from app.llm import LLMClient, LLMError from app.llm.model_config_resolver import snapshot_model_config from app.llm.stage_protocol import stage_payload, unified_system_prompt @@ -126,6 +128,24 @@ def decide( return decision.model_copy(update={"use_general_skill": False, "selected_slug": None}) +def _bash_allowed(skill: GeneralSkill) -> bool: + """allowed-tools 声明了 Bash 才放行 bash runtime;未声明 allowed-tools 不限制。""" + tools = allowed_tools_list(skill) + if not tools: + return True + return any(tool.startswith("Bash") for tool in tools) + + +def _runtime_languages(skill: GeneralSkill) -> list[str]: + return ["bash", "python"] if _bash_allowed(skill) else ["python"] + + +def _enforce_allowed_tools_runtime(skill: GeneralSkill, plan: GeneralSkillExecutionPlan) -> None: + """allowed-tools 门控:未声明 Bash 而模型仍给出 bash 计划时拒绝,促使其改用 python。""" + if plan.runtime == "bash" and not _bash_allowed(skill): + raise LLMError("该技能的 allowed-tools 未声明 Bash,请改用 python runtime 重新生成") + + class GeneralSkillReader: """Explain a skill package without generating or executing runner code.""" @@ -433,7 +453,7 @@ def _generate_plan( "package": _skill_package_payload(skill), }, "runtime": { - "languages": ["bash", "python"], + "languages": _runtime_languages(skill), "stdin_json": { "query": query, "skill_slug": skill.slug, @@ -461,6 +481,7 @@ def _generate_plan( ) plan = GeneralSkillExecutionPlan.model_validate(raw) plan.runtime = _plan_runtime(plan) + _enforce_allowed_tools_runtime(skill, plan) if not plan.code.strip(): raise LLMError("General skill runner code is empty") runtime_label = _runtime_label(plan.runtime) @@ -594,7 +615,7 @@ def _repair_plan( "package": _skill_package_payload(skill), }, "runtime": { - "languages": ["bash", "python"], + "languages": _runtime_languages(skill), "stdin_json": { "query": query, "skill_slug": skill.slug, @@ -623,6 +644,7 @@ def _repair_plan( ) plan = GeneralSkillExecutionPlan.model_validate(raw) plan.runtime = _plan_runtime(plan) + _enforce_allowed_tools_runtime(skill, plan) if not plan.code.strip(): raise LLMError("General skill repaired runner code is empty") runtime_label = _runtime_label(plan.runtime) @@ -1041,7 +1063,8 @@ def _materialize_skill_package(skill: GeneralSkill, target_dir: Path) -> None: relative_path = _safe_package_path(str(value or "")) if relative_path: (target_dir / relative_path).mkdir(parents=True, exist_ok=True) - for file in _skill_files(skill): + # SKILL.md 一律物化为规范化版本(frontmatter 齐全),存量无 frontmatter 的技能同样生效 + for file in standard_package_files(skill): relative_path = _safe_package_path(str(file["path"])) output_path = target_dir / relative_path output_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/backend/app/general_skills/schema.py b/backend/app/general_skills/schema.py index a428123ef..883ce5321 100644 --- a/backend/app/general_skills/schema.py +++ b/backend/app/general_skills/schema.py @@ -21,6 +21,10 @@ class GeneralSkillImportRequest(BaseModel): slug: Optional[str] = None description: Optional[str] = None homepage: Optional[str] = None + # Agent Skills 规范可选 frontmatter 字段(保存时写入 SKILL.md) + license: Optional[str] = None + compatibility: Optional[str] = None + allowed_tools: Optional[str] = None markdown: Optional[str] = None files: list[GeneralSkillFile] = Field(default_factory=list) directories: Optional[list[str]] = None @@ -61,6 +65,10 @@ class GeneralSkillRead(BaseModel): name: str description: Optional[str] = None homepage: Optional[str] = None + # Agent Skills 规范可选 frontmatter 字段(从 SKILL.md 解析) + license: Optional[str] = None + compatibility: Optional[str] = None + allowed_tools: Optional[str] = None skill_markdown: str skill_files: list[GeneralSkillFile] = Field(default_factory=list) skill_directories: list[str] = Field(default_factory=list) diff --git a/backend/app/general_skills/standard.py b/backend/app/general_skills/standard.py new file mode 100644 index 000000000..43fded9c0 --- /dev/null +++ b/backend/app/general_skills/standard.py @@ -0,0 +1,184 @@ +"""Agent Skills 官方规范的落地实现(agentskills.io/specification)。 + +规范要点: +- 技能 = 目录,至少含 SKILL.md(YAML frontmatter + Markdown 正文); +- frontmatter:name(必填,1-64,小写字母/数字/连字符,不可首尾/连续连字符, + 须与目录名一致)、description(必填,1-1024,做什么+何时用); + 可选 license / compatibility(≤500) / metadata(键值对) / allowed-tools(空格分隔); +- 可选目录 scripts/ references/ assets/;渐进披露。 + +本模块提供校验、frontmatter 解析与 SKILL.md 组装,后端各处保存/导出/运行统一收口。 +""" + +from __future__ import annotations + +import re +from typing import Any + +import yaml + +# name:1-64,小写字母/数字/连字符,不可首尾连字符,不可连续连字符 +SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +SKILL_NAME_MAX = 64 +SKILL_DESCRIPTION_MAX = 1024 +SKILL_COMPATIBILITY_MAX = 500 +# frontmatter 中透传的可选标量字段 +_OPTIONAL_SCALAR_KEYS = ("license", "compatibility", "allowed-tools") + + +def validate_skill_name(name: str) -> str | None: + """校验规范 name 字段,返回错误信息;合法返回 None。""" + if not name or len(name) > SKILL_NAME_MAX: + return f"name 必填且不超过 {SKILL_NAME_MAX} 字符" + if not SKILL_NAME_PATTERN.fullmatch(name): + return "name 只能包含小写字母、数字和连字符,且不可首尾或连续使用连字符" + return None + + +def validate_skill_description(description: str, *, required: bool) -> str | None: + """校验规范 description 字段;required 时必填,非必填时允许为空但限长。""" + text = (description or "").strip() + if not text: + return "description 必填(描述技能做什么、什么时候使用)" if required else None + if len(text) > SKILL_DESCRIPTION_MAX: + return f"description 不能超过 {SKILL_DESCRIPTION_MAX} 字符" + return None + + +def validate_skill_compatibility(compatibility: str) -> str | None: + """校验规范 compatibility 字段(可选,≤500 字符)。""" + text = (compatibility or "").strip() + if len(text) > SKILL_COMPATIBILITY_MAX: + return f"compatibility 不能超过 {SKILL_COMPATIBILITY_MAX} 字符" + return None + + +def split_frontmatter(markdown: str, *, strict: bool = False) -> tuple[dict[str, Any], str]: + """拆出 YAML frontmatter 与正文;无 frontmatter 时返回 ({}, 原文)。 + + 使用真正的 YAML 解析(PyYAML safe_load),重复保存不会累积转义。 + strict=True 时,frontmatter 未闭合/YAML 非法/顶层不是映射 均抛 ValueError + (保存路径使用);strict=False(读路径)遇到非法 frontmatter 回退 ({}, 原文)。 + """ + text = markdown or "" + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return {}, text + closing = None + for index, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + closing = index + break + if closing is None: + if strict: + raise ValueError("SKILL.md 的 YAML frontmatter 未闭合(缺少结束 --- 行)") + return {}, text + raw = "\n".join(lines[1:closing]) + body = "\n".join(lines[closing + 1:]).lstrip("\n") + try: + parsed = yaml.safe_load(raw) if raw.strip() else {} + except yaml.YAMLError as exc: + if strict: + raise ValueError(f"SKILL.md 的 YAML frontmatter 非法:{exc}") from exc + return {}, text + if parsed is None: + parsed = {} + if not isinstance(parsed, dict): + if strict: + raise ValueError("SKILL.md 的 YAML frontmatter 顶层必须是键值映射") + return {}, text + return parsed, body + + +def _dump_frontmatter(fields: dict[str, Any]) -> str: + """用真实 YAML 序列化输出 frontmatter(保序、Unicode 原文、自动转义)。""" + return yaml.safe_dump( + fields, + allow_unicode=True, + sort_keys=False, + default_flow_style=False, + width=10**6, + ).strip() + + +def compose_skill_markdown( + *, + name: str, + description: str, + body: str, + license: str = "", + compatibility: str = "", + allowed_tools: str = "", + metadata: dict[str, Any] | None = None, +) -> str: + """按规范组装 SKILL.md(frontmatter + 正文);metadata 中的保留键被忽略。""" + fields: dict[str, Any] = {"name": name, "description": description} + optional = { + "license": license.strip(), + "compatibility": compatibility.strip(), + "allowed-tools": allowed_tools.strip(), + } + for key in _OPTIONAL_SCALAR_KEYS: + if optional[key]: + fields[key] = optional[key] + extra_metadata = { + str(key): value + for key, value in (metadata or {}).items() + if str(key) not in {"name", "description", *_OPTIONAL_SCALAR_KEYS} and str(key).strip() + } + if extra_metadata: + fields["metadata"] = extra_metadata + return "---\n" + _dump_frontmatter(fields) + "\n---\n\n" + (body or "").strip() + "\n" + + +def frontmatter_for_skill(skill: Any) -> dict[str, Any]: + """从 GeneralSkill 行组装规范 frontmatter 字段(name 取 slug,与目录名一致)。""" + metadata, _ = split_frontmatter(getattr(skill, "skill_markdown", "") or "") + return { + "name": skill.slug, + "description": (skill.description or "").strip(), + "license": str(metadata.get("license") or ""), + "compatibility": str(metadata.get("compatibility") or ""), + "allowed_tools": str(metadata.get("allowed-tools") or ""), + "metadata": metadata.get("metadata") if isinstance(metadata.get("metadata"), dict) else {}, + } + + +def standard_skill_markdown(skill: Any) -> str: + """返回规范化后的完整 SKILL.md:frontmatter 以表单/数据库字段为准重组,正文保留。 + + 存量技能(无 frontmatter 或字段缺失)在读路径同样输出规范形态。 + """ + fields = frontmatter_for_skill(skill) + _, body = split_frontmatter(getattr(skill, "skill_markdown", "") or "") + return compose_skill_markdown(body=body, **fields) + + +def standard_package_files(skill: Any) -> list[dict[str, Any]]: + """导出/物化用的标准文件清单:SKILL.md 为规范化版本,其余文件原样(去重 SKILL.md)。""" + files = [ + { + "path": "SKILL.md", + "content": standard_skill_markdown(skill), + "mime_type": "text/markdown", + } + ] + for file in getattr(skill, "skill_files_json", None) or []: + path = str(file.get("path") or "").strip() + if not path or path.upper() == "SKILL.MD": + continue + files.append( + { + "path": path, + "content": str(file.get("content") or ""), + "mime_type": file.get("mime_type") or "text/plain", + } + ) + return files + + +def allowed_tools_list(skill: Any) -> list[str]: + """从 frontmatter 解析 allowed-tools 声明(空格分隔);未声明返回空列表。""" + fields = frontmatter_for_skill(skill) + raw = fields.get("allowed_tools") or "" + return [item for item in raw.split() if item] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 2ab556b0f..67df8750f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "pydantic-settings>=2.3.0", "pypdf>=4.2.0", "python-multipart>=0.0.9", + "PyYAML>=6.0", "python-docx>=1.1.2", "python-dotenv>=1.0.1", "requests>=2.32.0", diff --git a/backend/tests/test_agent_permissions.py b/backend/tests/test_agent_permissions.py index d2d2f4144..a474a5d27 100644 --- a/backend/tests/test_agent_permissions.py +++ b/backend/tests/test_agent_permissions.py @@ -907,6 +907,7 @@ def test_private_general_skill_edit_does_not_mutate_open_gallery_skill() -> None original_slug=updated.slug, slug="weather-renamed", name="员工天气技能", + description="员工天气技能描述", markdown="# 员工天气技能\n", ), db=db, diff --git a/backend/tests/test_capability_scope.py b/backend/tests/test_capability_scope.py index 80e7d1db2..a171f35d2 100644 --- a/backend/tests/test_capability_scope.py +++ b/backend/tests/test_capability_scope.py @@ -129,6 +129,7 @@ def test_create_update_and_read_apis_round_trip_capability_scope() -> None: tenant_id="tenant_demo", slug="sop-helper", name="SOP Helper", + description="SOP 辅助技能", markdown="# SOP Helper", capability_scope="sop_specific", ), @@ -142,6 +143,7 @@ def test_create_update_and_read_apis_round_trip_capability_scope() -> None: slug="sop-helper", original_slug="sop-helper", name="SOP Helper", + description="SOP 辅助技能", markdown="# SOP Helper v2", ), db, @@ -155,6 +157,7 @@ def test_create_update_and_read_apis_round_trip_capability_scope() -> None: slug="sop-helper", original_slug="sop-helper", name="员工 SOP Helper", + description="SOP 辅助技能", markdown="# Employee SOP Helper", ), db, diff --git a/backend/tests/test_general_skill_standard.py b/backend/tests/test_general_skill_standard.py new file mode 100644 index 000000000..d1f372300 --- /dev/null +++ b/backend/tests/test_general_skill_standard.py @@ -0,0 +1,205 @@ +"""Agent Skills 规范层单测:name/description 校验、frontmatter 解析与组装、标准化输出。""" + +from types import SimpleNamespace + +from app.general_skills.standard import ( + allowed_tools_list, + compose_skill_markdown, + frontmatter_for_skill, + split_frontmatter, + standard_package_files, + standard_skill_markdown, + validate_skill_description, + validate_skill_name, +) + + +def test_validate_skill_name_spec_rules() -> None: + assert validate_skill_name("pdf-processing") is None + assert validate_skill_name("a") is None + assert validate_skill_name("data-analysis-2") is None + assert validate_skill_name("") is not None + assert validate_skill_name("x" * 65) is not None + assert validate_skill_name("PDF-Processing") is not None # 大写不允许 + assert validate_skill_name("-pdf") is not None # 首连字符 + assert validate_skill_name("pdf-") is not None # 尾连字符 + assert validate_skill_name("pdf--processing") is not None # 连续连字符 + assert validate_skill_name("pdf_processing") is not None # 下划线 + assert validate_skill_name("pdf processing") is not None # 空格 + + +def test_validate_skill_description_rules() -> None: + assert validate_skill_description("做什么、何时用", required=True) is None + assert validate_skill_description("", required=True) is not None + assert validate_skill_description("", required=False) is None + assert validate_skill_description("x" * 1024, required=True) is None + assert validate_skill_description("x" * 1025, required=True) is not None + + +def test_split_frontmatter_full_fields() -> None: + markdown = ( + "---\n" + "name: pdf-processing\n" + "description: Extract PDF text. Use when handling PDFs.\n" + "license: Apache-2.0\n" + "compatibility: Requires git, docker\n" + "allowed-tools: Bash(git:*) Read\n" + "metadata:\n" + " author: example-org\n" + " version: \"1.0\"\n" + "---\n" + "\n" + "# 正文标题\n" + "正文内容\n" + ) + metadata, body = split_frontmatter(markdown) + assert metadata["name"] == "pdf-processing" + assert metadata["description"].startswith("Extract PDF text") + assert metadata["license"] == "Apache-2.0" + assert metadata["compatibility"] == "Requires git, docker" + assert metadata["allowed-tools"] == "Bash(git:*) Read" + assert metadata["metadata"] == {"author": "example-org", "version": "1.0"} + assert body.startswith("# 正文标题") + + +def test_split_frontmatter_absent_returns_original() -> None: + metadata, body = split_frontmatter("# 没有 frontmatter\n正文") + assert metadata == {} + assert body.startswith("# 没有 frontmatter") + + +def test_compose_and_split_roundtrip() -> None: + composed = compose_skill_markdown( + name="weather-zh", + description="查询天气。当用户问天气时使用。", + body="# 天气技能\n按步骤查询。", + license="Apache-2.0", + compatibility="Requires network", + allowed_tools="Bash(curl:*) Read", + metadata={"author": "staffdeck", "version": "1.0"}, + ) + metadata, body = split_frontmatter(composed) + assert metadata["name"] == "weather-zh" + assert metadata["description"] == "查询天气。当用户问天气时使用。" + assert metadata["license"] == "Apache-2.0" + assert metadata["compatibility"] == "Requires network" + assert metadata["allowed-tools"] == "Bash(curl:*) Read" + assert metadata["metadata"]["author"] == "staffdeck" + assert body == "# 天气技能\n按步骤查询。" + + +def test_compose_escapes_special_scalars() -> None: + composed = compose_skill_markdown(name="a-b", description='含: 冒号与 "引号"', body="x") + metadata, _ = split_frontmatter(composed) + assert metadata["name"] == "a-b" + assert "冒号" in metadata["description"] + + +def _skill(**overrides) -> SimpleNamespace: + base = { + "slug": "weather-zh", + "description": "查询天气", + "skill_markdown": "# 旧正文\n没有 frontmatter。", + "skill_files_json": [ + {"path": "SKILL.md", "content": "旧的", "mime_type": "text/markdown"}, + {"path": "scripts/run.py", "content": "print(1)", "mime_type": "text/plain"}, + ], + } + base.update(overrides) + return SimpleNamespace(**base) + + +def test_standard_skill_markdown_synthesizes_frontmatter() -> None: + skill = _skill() + markdown = standard_skill_markdown(skill) + metadata, body = split_frontmatter(markdown) + # name 恒等于 slug(规范:与目录名一致);正文保留 + assert metadata["name"] == "weather-zh" + assert metadata["description"] == "查询天气" + assert body.startswith("# 旧正文") + + +def test_standard_package_files_replaces_skill_md_and_keeps_rest() -> None: + files = standard_package_files(_skill()) + assert files[0]["path"] == "SKILL.md" + assert "name: weather-zh" in files[0]["content"] + # 旧 SKILL.md 被规范化版本替换,scripts 保留 + assert [file["path"] for file in files] == ["SKILL.md", "scripts/run.py"] + + +def test_allowed_tools_list() -> None: + skill = _skill( + skill_markdown="---\nname: a-b\ndescription: x\nallowed-tools: Bash(git:*) Read\n---\n正文" + ) + assert allowed_tools_list(skill) == ["Bash(git:*)", "Read"] + assert allowed_tools_list(_skill()) == [] + + +def test_frontmatter_for_skill_reads_existing_optional_fields() -> None: + skill = _skill( + skill_markdown=( + "---\nname: old\ndescription: old\nlicense: MIT\ncompatibility: Needs docker\n" + "allowed-tools: Bash\nmetadata:\n author: me\n---\n正文" + ) + ) + fields = frontmatter_for_skill(skill) + assert fields["name"] == "weather-zh" # 以 slug 为准,不采信旧 name + assert fields["license"] == "MIT" + assert fields["compatibility"] == "Needs docker" + assert fields["allowed_tools"] == "Bash" + assert fields["metadata"] == {"author": "me"} + + +# ---------- 复核回归:真 YAML 解析、幂等保存、严格模式 ---------- + + +def test_repeated_save_is_idempotent_with_special_chars() -> None: + """评审复现:license=MIT "Enterprise" \\ license 反复保存不再累积转义。""" + license_value = 'MIT "Enterprise" \\ license' + composed = compose_skill_markdown( + name="my-skill", description="示例", body="# 正文", license=license_value + ) + # 第一次解析回读:值精确还原 + metadata, body = split_frontmatter(composed) + assert metadata["license"] == license_value + # 再次组装:输出与第一次完全一致(幂等) + recomposed = compose_skill_markdown( + name="my-skill", + description="示例", + body=body, + license=str(metadata["license"]), + ) + assert recomposed == composed + metadata2, _ = split_frontmatter(recomposed) + assert metadata2["license"] == license_value + + +def test_strict_mode_rejects_invalid_frontmatter() -> None: + import pytest + + # 未闭合 + with pytest.raises(ValueError, match="未闭合"): + split_frontmatter("---\nname: a\n正文没有结束分隔", strict=True) + # 非法 YAML + with pytest.raises(ValueError, match="非法"): + split_frontmatter("---\nname: [unclosed\n---\n正文", strict=True) + # 顶层不是映射 + with pytest.raises(ValueError, match="键值映射"): + split_frontmatter("---\n- just\n- a list\n---\n正文", strict=True) + # 非严格模式(读路径)回退原文,不抛 + metadata, body = split_frontmatter("---\nname: [unclosed\n---\n正文") + assert metadata == {} + assert body.startswith("---") + + +def test_compose_output_is_valid_yaml_with_nested_metadata() -> None: + composed = compose_skill_markdown( + name="data-analysis", + description="数据分析:含冒号、# 井号 与\"引号\"", + body="# 正文", + metadata={"author": "某公司", "tags": "a, b"}, + ) + metadata, _ = split_frontmatter(composed, strict=True) + assert metadata["name"] == "data-analysis" + assert "冒号" in metadata["description"] + assert metadata["metadata"] == {"author": "某公司", "tags": "a, b"} diff --git a/backend/tests/test_general_skills.py b/backend/tests/test_general_skills.py index 44fc9581c..73bf86faa 100644 --- a/backend/tests/test_general_skills.py +++ b/backend/tests/test_general_skills.py @@ -292,7 +292,8 @@ def test_import_general_skill_uses_user_supplied_metadata() -> None: assert rows[0].name == "用户改名天气技能" assert rows[0].description == "用户改写描述" assert rows[0].homepage == "https://example.com/weather-cn" - assert rows[0].skill_markdown.startswith("# 天气 demo") + assert rows[0].skill_markdown.startswith("---\nname: weather-zh") + assert "# 天气 demo" in rows[0].skill_markdown try: import_general_skill( @@ -300,6 +301,7 @@ def test_import_general_skill_uses_user_supplied_metadata() -> None: tenant_id="tenant_demo", name="非法改 slug", slug="weather-cn", + description="中国城市天气查询", original_slug="weather-zh", markdown=WEATHER_SKILL_MD, ), @@ -327,6 +329,7 @@ def test_import_general_skill_without_original_slug_does_not_overwrite_existing( tenant_id="tenant_demo", name="已有天气技能", slug="weather-zh", + description="中国城市天气查询", markdown=WEATHER_SKILL_MD, ), db, @@ -339,6 +342,7 @@ def test_import_general_skill_without_original_slug_does_not_overwrite_existing( tenant_id="tenant_demo", name="新导入天气技能", slug="weather-zh", + description="中国城市天气查询", markdown="# 新内容", ), db, @@ -353,7 +357,10 @@ def test_import_general_skill_without_original_slug_does_not_overwrite_existing( assert len(rows) == 1 assert rows[0].id == first.id assert rows[0].name == "已有天气技能" - assert rows[0].skill_markdown == WEATHER_SKILL_MD.strip() + from app.general_skills.standard import split_frontmatter + + _, saved_body = split_frontmatter(rows[0].skill_markdown) + assert saved_body.strip() == WEATHER_SKILL_MD.strip() def test_deleted_open_gallery_general_skill_binding_is_not_restored_by_ensure() -> None: @@ -371,6 +378,7 @@ def test_deleted_open_gallery_general_skill_binding_is_not_restored_by_ensure() tenant_id="tenant_demo", name="天气技能", slug="weather-zh", + description="中国城市天气查询", markdown=WEATHER_SKILL_MD, ), db, @@ -422,6 +430,7 @@ def test_reimport_restores_deleted_private_skill_binding() -> None: agent_id="agent_branch", name="天气技能", slug="weather-zh", + description="中国城市天气查询", markdown=WEATHER_SKILL_MD, ), db, @@ -441,6 +450,7 @@ def test_reimport_restores_deleted_private_skill_binding() -> None: agent_id="agent_branch", name="更新后的天气技能", slug="weather-zh", + description="中国城市天气查询", markdown=WEATHER_SKILL_MD.replace("中国城市天气查询工具", "更新后的天气工具"), ), db, @@ -476,6 +486,7 @@ def test_private_skill_can_be_published_to_open_gallery() -> None: agent_id="agent_branch", name="天气技能", slug="weather-zh", + description="中国城市天气查询", markdown=WEATHER_SKILL_MD, ), db, @@ -539,7 +550,7 @@ def test_import_general_skill_folder_reads_skill_md_metadata() -> None: assert row.homepage == "https://example.com/weather" assert row.metadata["name"] == "中国城市天气" assert [file.path for file in row.skill_files] == ["SKILL.md", "data/cities.json"] - assert row.skill_markdown.startswith("---\nname: 中国城市天气") + assert row.skill_markdown.startswith("---\nname: weather-zh") def test_import_general_skill_persists_empty_directories_across_updates() -> None: @@ -551,6 +562,7 @@ def test_import_general_skill_persists_empty_directories_across_updates() -> Non tenant_id="tenant_demo", name="目录技能", slug="directory-skill", + description="目录技能描述", files=[{"path": "SKILL.md", "content": "# 目录技能\n"}], directories=["references", "references/drafts", "empty"], ), @@ -572,6 +584,7 @@ def test_import_general_skill_persists_empty_directories_across_updates() -> Non tenant_id="tenant_demo", name="目录技能", slug="directory-skill", + description="目录技能描述", original_slug="directory-skill", files=[{"path": "SKILL.md", "content": "# 更新后的目录技能\n"}], ), @@ -587,7 +600,7 @@ def test_import_clawhub_skill_reads_zip_package_without_overwriting(monkeypatch) with ZipFile(package, "w") as archive: archive.writestr( "skill-pack-main/weather/SKILL.md", - "---\nname: 天气包\nslug: weather-pack\n---\n\n# 天气包\n", + "---\nname: 天气包\nslug: weather-pack\ndescription: 天气查询技能\n---\n\n# 天气包\n", ) archive.writestr("skill-pack-main/weather/scripts/run.py", "print('ok')\n") archive.writestr("skill-pack-main/weather/data/cities.json", '{"北京": "101010100"}') @@ -622,7 +635,7 @@ def fake_download(url: str): # noqa: ANN001 "scripts/run.py", "data/cities.json", ] - assert first.skill_markdown.startswith("---\nname: 天气包") + assert first.skill_markdown.startswith("---\nname: weather-pack") def test_import_general_skill_package_upload_keeps_full_zip_folder() -> None: @@ -630,7 +643,7 @@ def test_import_general_skill_package_upload_keeps_full_zip_folder() -> None: with ZipFile(package, "w") as archive: archive.writestr( "nuwa-skill-main/skill/SKILL.md", - "---\nname: Nuwa Skill\nslug: nuwa-skill\n---\n\n# Nuwa Skill\n", + "---\nname: Nuwa Skill\nslug: nuwa-skill\ndescription: Nuwa 示例技能\n---\n\n# Nuwa Skill\n", ) archive.writestr("nuwa-skill-main/skill/scripts/run.py", "print('nuwa')\n") archive.writestr("nuwa-skill-main/skill/assets/config.json", '{"mode":"demo"}') @@ -655,11 +668,11 @@ def test_import_general_skill_package_upload_keeps_full_zip_folder() -> None: "scripts/run.py", "assets/config.json", ] - assert row.skill_markdown.startswith("---\nname: Nuwa Skill") + assert row.skill_markdown.startswith("---\nname: nuwa-skill") def test_import_general_skill_package_upload_treats_single_markdown_as_skill_md() -> None: - markdown = "---\nname: 单文件技能\nslug: single-file-skill\n---\n\n# 单文件技能\n" + markdown = "---\nname: 单文件技能\nslug: single-file-skill\ndescription: 单文件示例技能\n---\n\n# 单文件技能\n" with _test_session() as db: _seed_minimal_tenant(db) @@ -715,7 +728,7 @@ def fake_json(url: str): # noqa: ANN001 def fake_download(url: str): # noqa: ANN001 content = { - "https://raw.githubusercontent.com/example/skill-pack/main/weather/SKILL.md": "---\nname: 目录天气\nslug: weather-dir\n---\n\n# 天气\n", + "https://raw.githubusercontent.com/example/skill-pack/main/weather/SKILL.md": "---\nname: 目录天气\nslug: weather-dir\ndescription: 目录天气技能\n---\n\n# 天气\n", "https://raw.githubusercontent.com/example/skill-pack/main/weather/scripts/run.py": "print('ok')\n", "https://raw.githubusercontent.com/example/skill-pack/main/weather/data/cities.json": '{"北京":"101010100"}', }.get(url) @@ -754,7 +767,7 @@ def fake_download(url: str): # noqa: ANN001 "text/html", ) content = { - "https://raw.githubusercontent.com/example/skill-pack/main/weather/SKILL.md": "---\nname: 页面天气\nslug: weather-page\n---\n\n# 天气\n", + "https://raw.githubusercontent.com/example/skill-pack/main/weather/SKILL.md": "---\nname: 页面天气\nslug: weather-page\ndescription: 页面天气技能\n---\n\n# 天气\n", }.get(url) if content is None: raise AssertionError(f"unexpected url: {url}") @@ -793,7 +806,7 @@ def test_import_clawhub_skill_uses_clawhub_download_api_for_page_url(monkeypatch with ZipFile(package, "w") as archive: archive.writestr( "SKILL.md", - "---\nname: weather\n---\n\n# 天气\n", + "---\nname: weather\ndescription: 天气技能\n---\n\n# 天气\n", ) archive.writestr("scripts/weather.py", "print('weather')\n") archive.writestr("references/weather_details.md", "# details\n") @@ -832,7 +845,7 @@ def fake_download(url: str): # noqa: ANN001 def test_import_clawhub_skill_accepts_cli_slug(monkeypatch) -> None: package = BytesIO() with ZipFile(package, "w") as archive: - archive.writestr("SKILL.md", "---\nname: weather\n---\n\n# 天气\n") + archive.writestr("SKILL.md", "---\nname: weather\ndescription: 天气技能\n---\n\n# 天气\n") def fake_download(url: str): # noqa: ANN001 assert url == "https://wry-manatee-359.convex.site/api/v1/download?slug=maomao-weather" @@ -930,6 +943,7 @@ def fake_read(self, skill, query, model_config, **kwargs): # noqa: ANN001 tenant_id="tenant_demo", name="天气", slug="weather-zh", + description="中国城市天气查询", markdown=WEATHER_SKILL_MD, ), db, @@ -1080,6 +1094,7 @@ def fake_handle_turn(self, request): # noqa: ANN001 tenant_id="tenant_demo", name="天气", slug="weather-harness", + description="中国城市天气查询", markdown=WEATHER_SKILL_MD, status="published", ), @@ -1133,6 +1148,7 @@ def test_non_overall_agent_delete_hides_general_skill_only_in_branch() -> None: tenant_id="tenant_demo", name="天气", slug="weather-zh", + description="中国城市天气查询", markdown=WEATHER_SKILL_MD, ), db, @@ -1662,3 +1678,364 @@ def _test_session(): ) SQLModel.metadata.create_all(engine) return Session(engine) + + +# ---------- Agent Skills 规范对齐:归一化/发布校验/导出/allowed-tools 门控 ---------- + + +def _seed_standard_skill_tenant(db: Session) -> None: + _seed_minimal_tenant(db) + db.add(AgentProfile(id="agent_overall", tenant_id="tenant_demo", name="整体智能体", is_overall=True)) + db.commit() + + +def test_save_normalizes_skill_md_frontmatter_and_preserves_optional_fields() -> None: + with _test_session() as db: + _seed_standard_skill_tenant(db) + row = import_general_skill( + GeneralSkillImportRequest( + tenant_id="tenant_demo", + name="天气技能", + slug="weather-zh", + description="中国城市天气查询", + license="Apache-2.0", + compatibility="Requires network", + allowed_tools="Bash(curl:*) Read", + markdown="# 使用说明\n按城市查询天气。", + ), + db, + _admin_user(), + ) + from app.general_skills.standard import split_frontmatter + + metadata, body = split_frontmatter(row.skill_markdown) + # frontmatter 以表单字段重组:name 恒等于 slug;可选字段透传;正文保留 + assert metadata["name"] == "weather-zh" + assert metadata["description"] == "中国城市天气查询" + assert metadata["license"] == "Apache-2.0" + assert metadata["compatibility"] == "Requires network" + assert metadata["allowed-tools"] == "Bash(curl:*) Read" + assert body == "# 使用说明\n按城市查询天气。" + # DTO 透出三个可选字段 + assert row.license == "Apache-2.0" + assert row.compatibility == "Requires network" + assert row.allowed_tools == "Bash(curl:*) Read" + + +def test_save_rejects_invalid_slug_and_published_without_description() -> None: + with _test_session() as db: + _seed_standard_skill_tenant(db) + try: + import_general_skill( + GeneralSkillImportRequest( + tenant_id="tenant_demo", + name="坏技能", + slug="Weather_ZH", + description="x", + markdown="# x", + ), + db, + _admin_user(), + ) + raise AssertionError("expected invalid slug rejected") + except HTTPException as error: + assert error.status_code == 400 + assert "连字符" in error.detail or "小写" in error.detail + + try: + import_general_skill( + GeneralSkillImportRequest( + tenant_id="tenant_demo", + name="无描述技能", + slug="no-desc", + markdown="# x", + status="published", + ), + db, + _admin_user(), + ) + raise AssertionError("expected published-without-description rejected") + except HTTPException as error: + assert error.status_code == 400 + assert "description 必填" in error.detail + + # 草稿允许无描述;发布时再校验 + draft = import_general_skill( + GeneralSkillImportRequest( + tenant_id="tenant_demo", + name="草稿技能", + slug="draft-skill", + markdown="# x", + status="draft", + ), + db, + _admin_user(), + ) + assert draft.status == "draft" + try: + publish_general_skill(draft.slug, "tenant_demo", db, current_user=_admin_user()) + raise AssertionError("expected publish without description rejected") + except HTTPException as error: + assert error.status_code == 400 + assert "发布失败" in error.detail and "description 必填" in error.detail + + +def test_export_produces_standard_zip_roundtrip() -> None: + with _test_session() as db: + _seed_standard_skill_tenant(db) + row = import_general_skill( + GeneralSkillImportRequest( + tenant_id="tenant_demo", + name="天气技能", + slug="weather-zh", + description="中国城市天气查询", + markdown="# 使用说明", + files=[ + {"path": "SKILL.md", "content": "# 使用说明", "mime_type": "text/markdown"}, + {"path": "scripts/query.py", "content": "print('q')", "mime_type": "text/plain"}, + ], + ), + db, + _admin_user(), + ) + from app.api.general_skills import export_general_skill + + response = export_general_skill(row.slug, "tenant_demo", db) + assert response.media_type == "application/zip" + # StreamingResponse 直接迭代 body_iterator 收集 zip 字节 + import asyncio + + async def _collect() -> bytes: + chunks = [] + async for chunk in response.body_iterator: + chunks.append(chunk if isinstance(chunk, bytes) else chunk.encode()) + return b"".join(chunks) + + data = asyncio.run(_collect()) + with ZipFile(BytesIO(data)) as archive: + names = set(archive.namelist()) + assert names == {"weather-zh/SKILL.md", "weather-zh/scripts/query.py"} + exported_md = archive.read("weather-zh/SKILL.md").decode("utf-8") + from app.general_skills.standard import split_frontmatter + + metadata, body = split_frontmatter(exported_md) + # 根目录=slug,frontmatter name 与目录名一致(规范硬性要求) + assert metadata["name"] == "weather-zh" + assert metadata["description"] == "中国城市天气查询" + assert body == "# 使用说明" + + +def test_materialized_workspace_skill_md_is_standardized() -> None: + """存量无 frontmatter 的技能:运行时物化到工作区的 SKILL.md 也是规范形态。""" + from app.general_skills.runner import _materialize_skill_package + + legacy = GeneralSkill( + tenant_id="tenant_demo", + slug="legacy-skill", + name="存量技能", + description="存量描述", + skill_markdown="# 旧正文\n没有 frontmatter。", + skill_files_json=[{"path": "data/x.txt", "content": "v", "mime_type": "text/plain"}], + metadata_json={}, + status="published", + ) + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + _materialize_skill_package(legacy, Path(tmp)) + materialized = (Path(tmp) / "SKILL.md").read_text(encoding="utf-8") + assert (Path(tmp) / "data" / "x.txt").exists() + from app.general_skills.standard import split_frontmatter + + metadata, body = split_frontmatter(materialized) + assert metadata["name"] == "legacy-skill" + assert metadata["description"] == "存量描述" + assert body.startswith("# 旧正文") + + +def test_allowed_tools_gates_bash_runtime() -> None: + from app.general_skills.runner import _bash_allowed, _runtime_languages + + with_bash = SimpleNamespace( + slug="a", + description="d", + skill_markdown="---\nname: a\ndescription: d\nallowed-tools: Bash(git:*) Read\n---\n正文", + skill_files_json=[], + ) + without_bash = SimpleNamespace( + slug="b", + description="d", + skill_markdown="---\nname: b\ndescription: d\nallowed-tools: Read\n---\n正文", + skill_files_json=[], + ) + undeclared = SimpleNamespace(slug="c", description="d", skill_markdown="# 正文", skill_files_json=[]) + + assert _bash_allowed(with_bash) is True + assert _runtime_languages(with_bash) == ["bash", "python"] + assert _bash_allowed(without_bash) is False + assert _runtime_languages(without_bash) == ["python"] + # 未声明 allowed-tools:不限制 + assert _bash_allowed(undeclared) is True + + +# ---------- 复核回归:发布校验全路径、slugify、可选字段清空、compatibility 限长 ---------- + + +def test_publish_to_gallery_requires_valid_description() -> None: + """评审复现:无描述草稿经 publish-to-gallery 不得发布成功。""" + with _test_session() as db: + _seed_minimal_tenant(db) + db.add( + AgentProfile( + id="agent_branch", tenant_id="tenant_demo", name="研发员工", is_overall=False + ) + ) + db.commit() + draft = import_general_skill( + GeneralSkillImportRequest( + tenant_id="tenant_demo", + agent_id="agent_branch", + name="草稿", + slug="draft-skill", + markdown="# x", + status="draft", + ), + db, + _admin_user(), + ) + assert draft.status == "draft" + from app.api.general_skills import publish_general_skill_to_gallery + + try: + publish_general_skill_to_gallery( + draft.slug, "tenant_demo", "agent_branch", db, _admin_user() + ) + raise AssertionError("expected publish-to-gallery rejected") + except HTTPException as error: + assert error.status_code == 400 + assert "发布失败" in error.detail + + +def test_private_branch_publish_requires_valid_description() -> None: + """员工私有分支的 publish 也走规范校验(此前提前返回绕过)。""" + with _test_session() as db: + _seed_minimal_tenant(db) + db.add( + AgentProfile( + id="agent_branch", tenant_id="tenant_demo", name="研发员工", is_overall=False + ) + ) + db.commit() + draft = import_general_skill( + GeneralSkillImportRequest( + tenant_id="tenant_demo", + agent_id="agent_branch", + name="草稿", + slug="draft-skill", + markdown="# x", + status="draft", + ), + db, + _admin_user(), + ) + try: + publish_general_skill( + draft.slug, "tenant_demo", db, agent_id="agent_branch", current_user=_admin_user() + ) + raise AssertionError("expected private publish rejected") + except HTTPException as error: + assert error.status_code == 400 + + +def test_slugify_produces_spec_compliant_names() -> None: + from app.api.general_skills import _slugify + from app.general_skills.standard import validate_skill_name + + assert _slugify("bad_name") == "bad-name" + assert _slugify("Weather__ZH") == "weather-zh" + # 非 ASCII 字符整段清洗为连字符后为空则回退默认名(规范仅允许 a-z0-9-) + assert _slugify("--双 连--") == "general-skill" + for value in ("bad_name", "Nuwa Skill", "weather__zh", "--开头", "结尾--"): + assert validate_skill_name(_slugify(value)) is None + + +def test_compatibility_over_500_chars_rejected() -> None: + with _test_session() as db: + _seed_minimal_tenant(db) + db.add( + AgentProfile( + id="agent_overall", tenant_id="tenant_demo", name="整体智能体", is_overall=True + ) + ) + db.commit() + try: + import_general_skill( + GeneralSkillImportRequest( + tenant_id="tenant_demo", + name="技能", + slug="compat-skill", + description="描述", + compatibility="x" * 501, + markdown="# x", + ), + db, + _admin_user(), + ) + raise AssertionError("expected 501-char compatibility rejected") + except HTTPException as error: + assert error.status_code == 400 + assert "compatibility" in error.detail + + +def test_optional_fields_clearable_on_edit() -> None: + """编辑时提交空串=清空;不提交(None)=保留原值。""" + with _test_session() as db: + _seed_minimal_tenant(db) + db.add( + AgentProfile( + id="agent_overall", tenant_id="tenant_demo", name="整体智能体", is_overall=True + ) + ) + db.commit() + created = import_general_skill( + GeneralSkillImportRequest( + tenant_id="tenant_demo", + name="技能", + slug="clearable-skill", + description="描述", + license="MIT", + markdown="# x", + ), + db, + _admin_user(), + ) + assert created.license == "MIT" + # 编辑清空 + cleared = import_general_skill( + GeneralSkillImportRequest( + tenant_id="tenant_demo", + name="技能", + slug="clearable-skill", + description="描述", + license="", + original_slug="clearable-skill", + markdown="# x", + ), + db, + _admin_user(), + ) + assert cleared.license is None + # 不提交(None):保留现状(此时为空) + kept = import_general_skill( + GeneralSkillImportRequest( + tenant_id="tenant_demo", + name="技能", + slug="clearable-skill", + description="描述", + original_slug="clearable-skill", + markdown="# x", + ), + db, + _admin_user(), + ) + assert kept.license is None diff --git a/backend/tests/test_resource_creator_metadata.py b/backend/tests/test_resource_creator_metadata.py index a3ddcb524..de829e2de 100644 --- a/backend/tests/test_resource_creator_metadata.py +++ b/backend/tests/test_resource_creator_metadata.py @@ -79,6 +79,7 @@ def test_user_created_resource_metadata_is_bound_to_current_user() -> None: agent_id=agent.id, name="用户通用技能", slug="user-general-skill", + description="用户通用技能描述", markdown="# 用户通用技能\n\n用于测试 creator metadata。", ), db=db, @@ -133,6 +134,7 @@ def test_user_created_resource_metadata_is_bound_to_current_user() -> None: agent_id=agent.id, name="更新后的用户通用技能", slug="user-general-skill", + description="用户通用技能描述", original_slug="user-general-skill", markdown="# 更新后的用户通用技能\n\n用于测试 creator metadata。", ), diff --git a/frontend-enterprise/src/i18n/en.json b/frontend-enterprise/src/i18n/en.json index 41e4060be..436139eea 100644 --- a/frontend-enterprise/src/i18n/en.json +++ b/frontend-enterprise/src/i18n/en.json @@ -3108,5 +3108,26 @@ "需求澄清": "Requirements Clarification", "采购需求": "Procurement Requirements", "里程碑": "Milestones", - "指标口径": "Metric Definitions" + "指标口径": "Metric Definitions", + "描述这个技能做什么、什么时候使用(保存时由上方表单字段重新生成)": "Describe what this skill does and when to use it (regenerated from the form fields above on save)", + "在这里编写技能的使用说明(步骤、示例、边界情况)。正文会写入 SKILL.md,": "Write the skill instructions here (steps, examples, edge cases). This body goes into SKILL.md,", + "frontmatter 由上方表单生成:name 取 Slug,description 取描述。": "frontmatter is generated from the form above: name from Slug, description from Description.", + "许可证(license)": "License", + "可选,如 Apache-2.0、MIT": "Optional, e.g. Apache-2.0, MIT", + "运行环境要求(compatibility)": "Compatibility", + "可选,如 Requires network、Python 3.12+": "Optional, e.g. Requires network, Python 3.12+", + "预授权工具(allowed-tools)": "Allowed tools", + "可选,空格分隔,如 Bash(curl:*) Read;不含 Bash 则只用 Python 运行": "Optional, space-separated, e.g. Bash(curl:*) Read; without Bash it runs with Python only", + "导出技能包": "Export Skill Package", + "已导出技能包": "Skill package exported", + "导出技能包失败": "Failed to export skill package", + "展开运行结果": "Expand run result", + "收起运行结果": "Collapse run result", + "description: 描述这个技能做什么、什么时候使用(保存时由上方表单字段重新生成)": "description: Describe what this skill does and when to use it (regenerated from the form fields above on save)", + "# 技能说明": "# Skill Instructions", + "已发布到技能广场": "Published to the skill gallery", + "发布到广场失败": "Failed to publish to gallery", + "切换到渲染": "Switch to rendered view", + "渲染": "Rendered", + "切换到编辑": "Switch to editor" } diff --git a/frontend-enterprise/src/pages/GeneralSkillsPage.tsx b/frontend-enterprise/src/pages/GeneralSkillsPage.tsx index 5e4e5fc44..ec84fc2d3 100644 --- a/frontend-enterprise/src/pages/GeneralSkillsPage.tsx +++ b/frontend-enterprise/src/pages/GeneralSkillsPage.tsx @@ -16,7 +16,7 @@ import { Ban, ChevronRight, CircleCheck, Copy, Eye, EyeOff, FilePlus2, FolderPlu import { ContextMenu } from 'radix-ui'; import { api, streamPost, TENANT_ID } from '../api/client'; -import { isEnterpriseAdmin, type EnterpriseAuthUser } from '../auth'; +import { getEnterpriseAuthSession, isEnterpriseAdmin, type EnterpriseAuthUser } from '../auth'; import AppHeader from '@/components/AppHeader'; import CapabilityScopeLoading from '@/components/CapabilityScopeLoading'; import { @@ -101,9 +101,17 @@ const STATUS_BADGE: Record('general'); + const [skillLicense, setSkillLicense] = useState(''); + const [skillCompatibility, setSkillCompatibility] = useState(''); + const [skillAllowedTools, setSkillAllowedTools] = useState(''); const [skillFiles, setSkillFiles] = useState([ { path: 'SKILL.md', content: EMPTY_SKILL_MARKDOWN, size: EMPTY_SKILL_MARKDOWN.length, mime_type: 'text/markdown' }, ]); @@ -1667,6 +1678,10 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | description: skillDescription.trim() || undefined, homepage: skillHomepage.trim() || undefined, capability_scope: capabilityScope, + // 空串语义:编辑时显式清空该字段;不提交(undefined)则保留原值 + license: skillLicense.trim(), + compatibility: skillCompatibility.trim(), + allowed_tools: skillAllowedTools.trim(), markdown, files: skillFiles.length ? skillFiles : [{ path: 'SKILL.md', content: markdown }], directories: skillDirectories, @@ -1682,6 +1697,9 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | setSkillDescription(row.description || ''); setSkillHomepage(row.homepage || ''); setCapabilityScope(normalizeCapabilityScope(row.capability_scope)); + setSkillLicense(row.license || ''); + setSkillCompatibility(row.compatibility || ''); + setSkillAllowedTools(row.allowed_tools || ''); setSkillFiles(row.skill_files?.length ? row.skill_files : [{ path: 'SKILL.md', content: row.skill_markdown }]); setSkillDirectories(row.skill_directories || []); setSelectedFilePath((row.skill_files?.length ? row.skill_files : [{ path: 'SKILL.md' }])[0].path); @@ -1701,6 +1719,30 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | } } + // 导出标准 Agent Skills 包(zip):SKILL.md 为后端规范化版本,目录名即 slug + async function exportSkillPackage() { + if (!editingSlug) return; + try { + const session = getEnterpriseAuthSession(); + const apiBase = import.meta.env.VITE_API_BASE_URL || ''; + const response = await fetch( + `${apiBase}/api/enterprise/general-skills/${encodeURIComponent(editingSlug)}/export?tenant_id=${TENANT_ID}`, + { headers: session?.token ? { Authorization: `Bearer ${session.token}` } : {} }, + ); + if (!response.ok) throw new Error('导出技能包失败'); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${editingSlug}.zip`; + link.click(); + URL.revokeObjectURL(url); + notify.success('已导出技能包'); + } catch (error) { + notify.error(error instanceof Error ? error.message : '导出技能包失败'); + } + } + function newSkill() { setMarkdown(EMPTY_SKILL_MARKDOWN); setSkillName(''); @@ -1708,6 +1750,9 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | setSkillDescription(''); setSkillHomepage(''); setCapabilityScope('general'); + setSkillLicense(''); + setSkillCompatibility(''); + setSkillAllowedTools(''); setSkillFiles([{ path: 'SKILL.md', content: EMPTY_SKILL_MARKDOWN, size: EMPTY_SKILL_MARKDOWN.length, mime_type: 'text/markdown' }]); setSkillDirectories([]); setSelectedFilePath('SKILL.md'); @@ -1728,6 +1773,9 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | setSkillDescription(row.description || ''); setSkillHomepage(row.homepage || ''); setCapabilityScope(normalizeCapabilityScope(row.capability_scope)); + setSkillLicense(row.license || ''); + setSkillCompatibility(row.compatibility || ''); + setSkillAllowedTools(row.allowed_tools || ''); setSkillFiles(row.skill_files?.length ? row.skill_files : [{ path: 'SKILL.md', content: row.skill_markdown }]); setSkillDirectories(row.skill_directories || []); setSelectedFilePath((row.skill_files?.length ? row.skill_files : [{ path: 'SKILL.md' }])[0].path); @@ -2524,6 +2572,11 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | 新建技能 )} + {!isNew && editingSlug && ( + void exportSkillPackage()}> + 导出技能包 + + )} {importMenu} {canManageCurrentScope && ( void importSkill()}> @@ -2570,6 +2623,30 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | placeholder="可选,参考文档或项目主页" /> + + setSkillLicense(event.target.value)} + disabled={!canManageCurrentScope} + placeholder="可选,如 Apache-2.0、MIT" + /> + + + setSkillCompatibility(event.target.value)} + disabled={!canManageCurrentScope} + placeholder="可选,如 Requires network、Python 3.12+" + /> + + + setSkillAllowedTools(event.target.value)} + disabled={!canManageCurrentScope} + placeholder="可选,空格分隔,如 Bash(curl:*) Read;不含 Bash 则只用 Python 运行" + /> +