diff --git a/api/services/agent/skill_package_service.py b/api/services/agent/skill_package_service.py index 9dc725bc48f..42c48d2f26d 100644 --- a/api/services/agent/skill_package_service.py +++ b/api/services/agent/skill_package_service.py @@ -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]: diff --git a/api/services/skill_management_service.py b/api/services/skill_management_service.py index 8616b388e9c..b3bd3546add 100644 --- a/api/services/skill_management_service.py +++ b/api/services/skill_management_service.py @@ -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: diff --git a/api/tests/unit_tests/services/agent/test_skill_package_service.py b/api/tests/unit_tests/services/agent/test_skill_package_service.py index fa7cb7a48eb..94aef19e514 100644 --- a/api/tests/unit_tests/services/agent/test_skill_package_service.py +++ b/api/tests/unit_tests/services/agent/test_skill_package_service.py @@ -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"}) diff --git a/api/tests/unit_tests/services/test_skill_management_service.py b/api/tests/unit_tests/services/test_skill_management_service.py index 62807577ed0..8bdb7503dcc 100644 --- a/api/tests/unit_tests/services/test_skill_management_service.py +++ b/api/tests/unit_tests/services/test_skill_management_service.py @@ -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: