fix(api): accept CRLF SKILL.md when importing skill zips (#41657)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
This commit is contained in:
leilei3167 2026-09-08 09:34:43 +00:00 committed by GitHub
parent 2f00e647ef
commit c9e0118ae9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 73 additions and 13 deletions

View File

@ -105,12 +105,18 @@ class SkillPackageService:
)
skill_md_member = normalized_members[_SKILL_MD_NAME]
self._validate_skill_md_size(skill_md_member)
skill_md_bytes = self._read_member_bytes_from_archive(archive, member_info=skill_md_member)
skill_md = self._decode_skill_md(skill_md_bytes)
raw_skill_md_bytes = self._read_member_bytes_from_archive(archive, member_info=skill_md_member)
skill_md = self._decode_skill_md(raw_skill_md_bytes)
skill_md_bytes = skill_md.encode("utf-8")
normalized_archive_bytes = self._build_normalized_archive(
archive=archive, normalized_members=normalized_members
archive=archive,
normalized_members=normalized_members,
skill_md_bytes=skill_md_bytes,
)
normalized_size = sum(
len(skill_md_bytes) if path == _SKILL_MD_NAME else max(info.file_size, 0)
for path, info in normalized_members.items()
)
normalized_size = sum(max(info.file_size, 0) for info in normalized_members.values())
name, description = self._parse_skill_md(skill_md)
try:
@ -243,14 +249,17 @@ class SkillPackageService:
*,
archive: zipfile.ZipFile,
normalized_members: dict[str, zipfile.ZipInfo],
skill_md_bytes: bytes,
) -> bytes:
output = io.BytesIO()
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as normalized_archive:
for normalized_path in sorted(normalized_members):
normalized_archive.writestr(
normalized_path,
self._read_member_bytes_from_archive(archive, member_info=normalized_members[normalized_path]),
payload = (
skill_md_bytes
if normalized_path == _SKILL_MD_NAME
else self._read_member_bytes_from_archive(archive, member_info=normalized_members[normalized_path])
)
normalized_archive.writestr(normalized_path, payload)
return output.getvalue()
@staticmethod
@ -303,9 +312,11 @@ class SkillPackageService:
@staticmethod
def _decode_skill_md(raw: bytes) -> str:
try:
return raw.decode("utf-8")
decoded = raw.decode("utf-8")
except UnicodeDecodeError as exc:
raise SkillPackageError("skill_md_not_utf8", "SKILL.md must be UTF-8 encoded", status_code=400) from exc
# Windows editors often save SKILL.md with CRLF; normalize before frontmatter parse.
return decoded.replace("\r\n", "\n").replace("\r", "\n")
@classmethod
def _parse_skill_md(cls, content: str) -> tuple[str, str]:

View File

@ -114,7 +114,7 @@ _UNTITLED_SKILL_MD_BODY = """# Untitled skill
Describe what this Skill does, when an Agent should use it, and any step-by-step instructions it must follow.
"""
_FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n?", re.DOTALL)
_FRONTMATTER_RE = re.compile(r"\A---\r?\n(.*?)\r?\n---(?:\r?\n)?", re.DOTALL)
_FILE_EXTENSION_RE = re.compile(r"\.[A-Za-z0-9][A-Za-z0-9._+-]*\Z")
_SKILL_ASSISTANT_SYSTEM_PROMPT = """You are Dify's Skill Authoring assistant.
@ -3614,7 +3614,13 @@ class SkillManagementService:
)
@staticmethod
def _parse_frontmatter(content: str) -> dict[str, Any]:
def _normalize_newlines(content: str) -> str:
"""Normalize Windows/Mac newlines so SKILL.md frontmatter parsing is stable."""
return content.replace("\r\n", "\n").replace("\r", "\n")
@classmethod
def _parse_frontmatter(cls, content: str) -> dict[str, Any]:
content = cls._normalize_newlines(content)
match = _FRONTMATTER_RE.match(content)
if match is None:
return {}
@ -3639,9 +3645,9 @@ class SkillManagementService:
)
return payload
@staticmethod
def _frontmatter_field_line(content: str, field: str) -> int:
match = _FRONTMATTER_RE.match(content)
@classmethod
def _frontmatter_field_line(cls, content: str, field: str) -> int:
match = _FRONTMATTER_RE.match(cls._normalize_newlines(content))
if match is None:
return 2
frontmatter_start_line = 2
@ -3794,6 +3800,7 @@ class SkillManagementService:
if path == _SKILL_MD:
if text is None:
raise SkillManagementServiceError("invalid_skill_md", "SKILL.md must be UTF-8 text")
text = self._normalize_newlines(text)
metadata = self._parse_frontmatter(text)
skill_md_content = text
if text is not None:

View File

@ -51,6 +51,16 @@ def test_valid_skill_normalizes_manifest():
assert len(manifest.hash) == 64
def test_validate_and_normalize_accepts_crlf_skill_md():
crlf_skill_md = _SKILL_MD.replace("\n", "\r\n")
package = _normalize({"SKILL.md": crlf_skill_md.encode()})
assert package.manifest.name == "pdf-toolkit"
assert package.manifest.description == "Tools for working with PDF files."
assert b"\r" not in package.skill_md_bytes
assert package.skill_md_bytes.decode() == _SKILL_MD
def test_name_and_description_are_required_in_frontmatter():
with pytest.raises(SkillPackageError) as exc_info:
_normalize({"SKILL.md": b"# heading-name\n\nbody"})

View File

@ -2664,6 +2664,38 @@ def test_import_skill_package_creates_draft_and_rejects_name_conflicts() -> None
assert exc_info.value.code == "skill_name_conflict"
def test_import_skill_package_accepts_crlf_skill_md() -> None:
"""Windows CRLF SKILL.md must not blank out the frontmatter name on import."""
skill_md = (
"---\r\n"
"name: expense-sop\r\n"
"description: Expenses\r\n"
"metadata:\r\n"
" display-name: Expense SOP\r\n"
"---\r\n"
"\r\n"
"# Expenses\r\n"
)
package = io.BytesIO()
with zipfile.ZipFile(package, "w") as archive:
archive.writestr("expense-sop/SKILL.md", skill_md.encode("utf-8"))
archive.writestr("expense-sop/references/policy.md", "Policy")
service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
imported = service.import_skill(
tenant_id=TENANT,
user_id=USER,
payload=SkillImportPayload(content=package.getvalue(), filename="expense-sop.zip"),
)
assert imported["name"] == "expense-sop"
assert imported["display_name"] == "Expense SOP"
assert imported["description"] == "Expenses"
skill_md_file = next(item for item in imported["files"] if item["path"] == "SKILL.md")
assert "\r" not in skill_md_file["content"]
assert skill_md_file["content"].startswith("---\nname: expense-sop\n")
def test_import_skill_package_strips_root_alongside_macos_metadata_folder() -> None:
package = io.BytesIO()
with zipfile.ZipFile(package, "w") as archive: