mirror of
https://github.com/langgenius/dify.git
synced 2026-09-09 05:41:00 +08:00
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:
parent
2f00e647ef
commit
c9e0118ae9
@ -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]:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"})
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user