fix(gateway): allow standard skill frontmatter metadata (#1103)

* fix(gateway): allow standard skill frontmatter metadata

Accept standard optional frontmatter fields during .skill installs so external skills with version, author, or compatibility metadata do not fail validation.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* docs: sync skill installer metadata behavior

Document the skill install allowlist so user-facing and backend contributor docs match the gateway validation contract.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Ryanba 2026-03-13 21:23:35 +08:00 committed by GitHub
parent 03cafea715
commit cda9fb7bca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 83 additions and 4 deletions

View File

@ -330,6 +330,8 @@ A standard Agent Skill is a structured capability module — a Markdown file tha
Skills are loaded progressively — only when the task needs them, not all at once. This keeps the context window lean and makes DeerFlow work well even with token-sensitive models. Skills are loaded progressively — only when the task needs them, not all at once. This keeps the context window lean and makes DeerFlow work well even with token-sensitive models.
When you install `.skill` archives through the Gateway, DeerFlow accepts standard optional frontmatter metadata such as `version`, `author`, and `compatibility` instead of rejecting otherwise valid external skills.
Tools follow the same philosophy. DeerFlow comes with a core toolset — web search, web fetch, file operations, bash execution — and supports custom tools via MCP servers and Python functions. Swap anything. Add anything. Tools follow the same philosophy. DeerFlow comes with a core toolset — web search, web fetch, file operations, bash execution — and supports custom tools via MCP servers and Python functions. Swap anything. Add anything.
Gateway-generated follow-up suggestions now normalize both plain-string model output and block/list-style rich content before parsing the JSON array response, so provider-specific content wrappers do not silently drop suggestions. Gateway-generated follow-up suggestions now normalize both plain-string model output and block/list-style rich content before parsing the JSON array response, so provider-specific content wrappers do not silently drop suggestions.

View File

@ -164,7 +164,7 @@ FastAPI application on port 8001 with health check at `GET /health`.
|--------|-----------| |--------|-----------|
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details | | **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - update config (saves to extensions_config.json) | | **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - update config (saves to extensions_config.json) |
| **Skills** (`/api/skills`) | `GET /` - list skills; `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive | | **Skills** (`/api/skills`) | `GET /` - list skills; `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) |
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data | | **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete | | **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; `?download=true` for file download | | **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; `?download=true` for file download |

View File

@ -4,7 +4,9 @@ import re
import shutil import shutil
import tempfile import tempfile
import zipfile import zipfile
from collections.abc import Mapping
from pathlib import Path from pathlib import Path
from typing import cast
import yaml import yaml
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
@ -57,7 +59,20 @@ class SkillInstallResponse(BaseModel):
# Allowed properties in SKILL.md frontmatter # Allowed properties in SKILL.md frontmatter
ALLOWED_FRONTMATTER_PROPERTIES = {"name", "description", "license", "allowed-tools", "metadata"} ALLOWED_FRONTMATTER_PROPERTIES = {
"name",
"description",
"license",
"allowed-tools",
"metadata",
"compatibility",
"version",
"author",
}
def _safe_load_frontmatter(frontmatter_text: str) -> object:
return cast(object, yaml.safe_load(frontmatter_text))
def _validate_skill_frontmatter(skill_dir: Path) -> tuple[bool, str, str | None]: def _validate_skill_frontmatter(skill_dir: Path) -> tuple[bool, str, str | None]:
@ -86,9 +101,11 @@ def _validate_skill_frontmatter(skill_dir: Path) -> tuple[bool, str, str | None]
# Parse YAML frontmatter # Parse YAML frontmatter
try: try:
frontmatter = yaml.safe_load(frontmatter_text) parsed_frontmatter = _safe_load_frontmatter(frontmatter_text)
if not isinstance(frontmatter, dict): if not isinstance(parsed_frontmatter, Mapping):
return False, "Frontmatter must be a YAML dictionary", None return False, "Frontmatter must be a YAML dictionary", None
parsed_frontmatter = cast(Mapping[object, object], parsed_frontmatter)
frontmatter: dict[str, object] = {str(key): value for key, value in parsed_frontmatter.items()}
except yaml.YAMLError as e: except yaml.YAMLError as e:
return False, f"Invalid YAML in frontmatter: {e}", None return False, f"Invalid YAML in frontmatter: {e}", None

View File

@ -0,0 +1,60 @@
from collections.abc import Callable
from pathlib import Path
from typing import cast
import src.gateway.routers.skills as skills_router
VALIDATE_SKILL_FRONTMATTER = cast(
Callable[[Path], tuple[bool, str, str | None]],
getattr(skills_router, "_validate_skill_frontmatter"),
)
def _write_skill(skill_dir: Path, frontmatter: str) -> None:
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(frontmatter, encoding="utf-8")
def test_validate_skill_frontmatter_allows_standard_optional_metadata(tmp_path: Path) -> None:
skill_dir = tmp_path / "demo-skill"
_write_skill(
skill_dir,
"""---
name: demo-skill
description: Demo skill
version: 1.0.0
author: example.com/demo
compatibility: OpenClaw >= 1.0
license: MIT
---
# Demo Skill
""",
)
valid, message, skill_name = VALIDATE_SKILL_FRONTMATTER(skill_dir)
assert valid is True
assert message == "Skill is valid!"
assert skill_name == "demo-skill"
def test_validate_skill_frontmatter_still_rejects_unknown_keys(tmp_path: Path) -> None:
skill_dir = tmp_path / "demo-skill"
_write_skill(
skill_dir,
"""---
name: demo-skill
description: Demo skill
unsupported: true
---
# Demo Skill
""",
)
valid, message, skill_name = VALIDATE_SKILL_FRONTMATTER(skill_dir)
assert valid is False
assert "unsupported" in message
assert skill_name is None