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
82 changes: 82 additions & 0 deletions backend/app/api/general_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@
)
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_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 @@ -82,13 +90,17 @@ def _agent_id_or_none(agent_id: object | None) -> str | None:


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 @@ -134,6 +146,29 @@ 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)
# SKILL.md 归一化:frontmatter 以表单/规范字段重组(name 恒等于 slug),正文保留
existing_frontmatter, markdown_body = split_frontmatter(markdown)
markdown = compose_skill_markdown(
name=slug,
description=description or "",
body=markdown_body,
license=_optional_text(request.license) or str(existing_frontmatter.get("license") or ""),
compatibility=_optional_text(request.compatibility)
or str(existing_frontmatter.get("compatibility") or ""),
allowed_tools=_optional_text(request.allowed_tools)
or str(existing_frontmatter.get("allowed-tools") or ""),
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 @@ -391,6 +426,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 @@ -519,6 +574,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 +621,11 @@ def publish_general_skill(
db.commit()
return general_skill_read(row, status_override="published")
ensure_open_gallery_admin(tenant_id, current_user)
# 发布前必须通过 Agent Skills 规范校验(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}")
row.status = "published"
mark_resource_open_gallery(row, row.metadata_json or {})
row.updated_at = utc_now()
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 @@ -1037,7 +1059,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