Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 120 additions & 1 deletion backend/app/api/general_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -95,14 +104,43 @@ 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,
slug=row.slug,
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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"


Expand Down
2 changes: 1 addition & 1 deletion backend/app/channels/schema.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
29 changes: 26 additions & 3 deletions backend/app/general_skills/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions backend/app/general_skills/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading