deer-flow/backend/tests/test_skill_export.py
Ryker_Feng f52818fe5e
feat(skills): export custom skill packages with revision-bound preview (#5332)
* feat(skills): export custom skill packages with revision preview

* docs(gateway): keep export guidance within size budget

* ci: retry checks after transient uv setup download failure

* docs: focus skill export agent guidance on maintenance invariants

* fix(skills): handle export disconnects and bound archive transfers

* docs(gateway): remove redundant export guidance to fit merged budget

* fix(skills): reset export idle deadline after transfer progress
2026-09-11 16:21:23 +08:00

510 lines
20 KiB
Python

import os
import zipfile
import pytest
from deerflow.skills import export
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
@pytest.fixture
def package(tmp_path):
storage = LocalSkillStorage(host_path=str(tmp_path))
root = tmp_path / "custom" / "sample"
root.mkdir(parents=True)
(root / "SKILL.md").write_bytes(b"---\nname: sample\ndescription: Example\n---\nHello\r\n")
return storage, root
def test_snapshot_revision_and_zip_roundtrip(package, tmp_path):
storage, root = package
(root / "empty").mkdir()
(root / "run.sh").write_bytes(b"#!/bin/sh\necho hello\n")
(root / "run.sh").chmod(0o751)
manifest = export.export_manifest(storage, "sample")
assert manifest["can_export"] and manifest["directory_count"] == 2
archive = export.build_skill_export(storage, "sample", manifest["revision"])
try:
with zipfile.ZipFile(archive.file) as z:
assert z.read("sample/SKILL.md") == (root / "SKILL.md").read_bytes()
from deerflow.skills.installer import safe_extract_skill_archive
safe_extract_skill_archive(z, tmp_path / "imported")
assert (tmp_path / "imported/sample/empty").is_dir()
if os.name == "posix":
assert (tmp_path / "imported/sample/run.sh").stat().st_mode & 0o7777 == 0o755
finally:
archive.close()
(root / "run.sh").chmod(0o644)
with pytest.raises(export.SkillExportError, match="changed") as error:
export.build_skill_export(storage, "sample", manifest["revision"])
assert error.value.status == 409
@pytest.mark.parametrize("kind", ["symlink", "hardlink", "nested", "reserved", "binary", "collision"])
def test_structural_blockers(package, kind):
storage, root = package
if kind == "symlink":
(root / "link").symlink_to(root / "SKILL.md")
elif kind == "hardlink":
os.link(root / "SKILL.md", root / "hard")
elif kind == "nested":
(root / "nested").mkdir()
(root / "nested/SKILL.md").write_text("fixture")
elif kind == "reserved":
(root / "CON.txt").touch()
elif kind == "binary":
(root / "elf").write_bytes(b"\x7fELFhello")
else:
(root / "A").touch()
(root / "a").touch()
if len(list(root.iterdir())) < 3:
pytest.skip("case insensitive filesystem")
manifest = export.export_manifest(storage, "sample")
assert not manifest["can_export"] and manifest["revision"] is None
assert manifest["blockers"]
def test_limits_are_not_truncated(package, monkeypatch):
storage, root = package
monkeypatch.setattr(export, "MAX_ENTRIES", 1)
with pytest.raises(export.SkillExportError) as error:
export.export_manifest(storage, "sample")
assert error.value.status == 413
def test_root_link_and_missing_ownership(package):
storage, root = package
root.rename(root.with_name("other"))
root.symlink_to(root.with_name("other"), target_is_directory=True)
assert export.export_manifest(storage, "sample")["blockers"][0]["code"] == "skill_export_link"
with pytest.raises(export.SkillExportError) as error:
export.export_manifest(storage, "missing")
assert error.value.status == 404
def test_local_writer_waits_for_export_lock(package):
import threading
from deerflow.skills.projection import skill_projection_read_lock
storage, root = package
started = threading.Event()
finished = threading.Event()
def write():
started.set()
storage.write_custom_skill("sample", "new/resource.txt", "new")
finished.set()
with skill_projection_read_lock(storage):
thread = threading.Thread(target=write)
thread.start()
assert started.wait(1)
assert not finished.wait(0.1)
assert not (root / "new").exists()
thread.join(2)
assert finished.is_set()
def test_snapshot_source_race(package, monkeypatch):
storage, root = package
original = export._walk
def racing(*args):
result = original(*args)
(root / "SKILL.md").write_bytes((root / "SKILL.md").read_bytes() + b"changed")
return result
monkeypatch.setattr(export, "_walk", racing)
with pytest.raises(export.SkillExportError) as error:
export.export_manifest(storage, "sample")
assert error.value.status == 409
def test_zip_uses_only_snapshot(package, monkeypatch):
storage, root = package
manifest = export.export_manifest(storage, "sample")
original_bytes = (root / "SKILL.md").read_bytes()
original = export._capture
def replace_after_capture(*args):
result = original(*args)
(root / "SKILL.md").write_bytes(b"new content")
return result
monkeypatch.setattr(export, "_capture", replace_after_capture)
archive = export.build_skill_export(storage, "sample", manifest["revision"])
try:
with zipfile.ZipFile(archive.file) as z:
assert z.read("sample/SKILL.md") == original_bytes
finally:
archive.close()
@pytest.mark.parametrize("limit", ["MAX_FILE_BYTES", "MAX_TOTAL_BYTES", "MAX_PATH_BYTES", "MAX_DEPTH"])
def test_resource_limits(package, monkeypatch, limit):
storage, root = package
(root / "deep").mkdir()
(root / "deep/file").write_bytes(b"abc")
monkeypatch.setattr(export, limit, 1)
with pytest.raises(export.SkillExportError) as error:
export.export_manifest(storage, "sample")
assert error.value.status == 413
def test_zip_limit_and_cancellation_close_files(package, monkeypatch):
import threading
storage, root = package
manifest = export.export_manifest(storage, "sample")
files = []
original = export.tempfile.TemporaryFile
def track(*args, **kwargs):
file = original(*args, **kwargs)
files.append(file)
return file
monkeypatch.setattr(export.tempfile, "TemporaryFile", track)
monkeypatch.setattr(export, "MAX_ZIP_BYTES", 1)
with pytest.raises(export.SkillExportError) as error:
export.build_skill_export(storage, "sample", manifest["revision"])
assert error.value.status == 413
assert all(file.closed for file in files)
event = threading.Event()
event.set()
with pytest.raises(export.SkillExportError) as error:
export.export_manifest(storage, "sample", event)
assert error.value.code == "skill_export_cancelled"
assert all(file.closed for file in files)
def test_diagnostics_requirements_do_not_expose_contents(package):
import json
storage, root = package
(root / ".env").write_text("SECRET_VALUE=private-contents", encoding="utf-8")
(root / "SKILL.md").write_text("---\nname: sample\ndescription: Example\ncompatibility: Requires Python\nrequired-secrets:\n - name: API_KEY\n optional: true\n value: private-contents\n---\n", encoding="utf-8")
manifest = export.export_manifest(storage, "sample")
assert manifest["requirements"]["required_secrets"] == [{"name": "API_KEY", "optional": True}]
assert manifest["warnings"]
assert "private-contents" not in json.dumps(manifest)
@pytest.mark.parametrize("skill_name", ["code-documentation", "skill-creator"])
def test_public_skill_real_install_roundtrip(tmp_path, monkeypatch, skill_name):
import shutil
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
from deerflow.skills import installer
public = Path(__file__).resolve().parents[2] / "skills/public" / skill_name
storage = LocalSkillStorage(host_path=str(tmp_path / "source"))
root = storage.get_custom_skill_dir(skill_name)
shutil.copytree(public, root, ignore=shutil.ignore_patterns("__pycache__"))
manifest = export.export_manifest(storage, skill_name)
assert manifest["can_export"], manifest["blockers"]
archive = export.build_skill_export(storage, skill_name, manifest["revision"])
archive_path = tmp_path / "code-documentation.skill"
try:
archive_path.write_bytes(archive.file.read())
finally:
archive.close()
# Run the real installer and deterministic scanner; substitute only the remote LLM decision.
scan = AsyncMock(return_value=SimpleNamespace(decision="allow", reason="test approval"))
monkeypatch.setattr(installer, "scan_skill_content", scan)
target = LocalSkillStorage(host_path=str(tmp_path / "target"), app_config=SimpleNamespace(skill_scan=SimpleNamespace(enabled=True)))
result = target.install_skill_from_archive(archive_path)
assert result["success"]
assert scan.called
assert export.export_manifest(target, skill_name)["revision"] == manifest["revision"]
if skill_name == "skill-creator":
import subprocess
import sys
installed = target.get_custom_skill_dir(skill_name)
result = subprocess.run([sys.executable, str(installed / "scripts/quick_validate.py"), str(installed)], capture_output=True, text=True, timeout=10)
assert result.returncode == 0, result.stdout + result.stderr
assert "Skill is valid!" in result.stdout
@pytest.mark.parametrize("mode, expected", [(0o100777, 0o755), (0o107777, 0o755), (0o100640, 0o644), (0, 0o644)])
def test_import_permissions_from_independent_zip(tmp_path, mode, expected):
import io
from deerflow.skills.installer import safe_extract_skill_archive
raw = io.BytesIO()
with zipfile.ZipFile(raw, "w") as archive:
info = zipfile.ZipInfo("script")
info.create_system = 3
info.external_attr = mode << 16
archive.writestr(info, b"#!/bin/sh\n")
raw.seek(0)
with zipfile.ZipFile(raw) as archive:
safe_extract_skill_archive(archive, tmp_path)
if os.name == "posix":
assert (tmp_path / "script").stat().st_mode & 0o7777 == expected
def test_cross_process_lock_timeout(package, monkeypatch):
import subprocess
import sys
storage, root = package
lock = root.parent.parent / ".custom.projection.lock"
child = subprocess.Popen([sys.executable, "-c", 'import fcntl,sys,time; f=open(sys.argv[1],"a"); fcntl.flock(f,fcntl.LOCK_EX); print("ready",flush=True); time.sleep(10)', str(lock)], stdout=subprocess.PIPE, text=True)
try:
assert child.stdout.readline().strip() == "ready"
monkeypatch.setattr(export, "LOCK_TIMEOUT_SECONDS", 0.05)
with pytest.raises(export.SkillExportError) as error:
export.export_manifest(storage, "sample")
assert error.value.code == "skill_export_timeout"
finally:
child.terminate()
child.wait(timeout=2)
child.stdout.close()
def test_user_read_lock_no_projection_mutation_and_owned_only(tmp_path, monkeypatch):
from deerflow.config.paths import Paths
from deerflow.skills.projection import get_skill_projection_paths
from deerflow.skills.storage.user_scoped_skill_storage import UserScopedSkillStorage
monkeypatch.setattr("deerflow.config.paths.get_paths", lambda: Paths(base_dir=tmp_path))
storage = UserScopedSkillStorage("one", host_path=str(tmp_path / "global"))
legacy = tmp_path / "global/custom/sample"
legacy.mkdir(parents=True)
(legacy / "SKILL.md").write_text("---\nname: sample\ndescription: Example\n---\n", encoding="utf-8")
with pytest.raises(export.SkillExportError) as error:
export.export_manifest(storage, "sample")
assert error.value.status == 404
import shutil
shutil.copytree(legacy, storage.get_custom_skill_dir("sample"))
scope = get_skill_projection_paths(storage).custom.parent
scope.mkdir(parents=True, exist_ok=True)
marker = scope / ".projection-manifest.json"
marker.write_text("unchanged", encoding="utf-8")
assert export.export_manifest(storage, "sample")["can_export"]
assert marker.read_text(encoding="utf-8") == "unchanged"
@pytest.mark.parametrize("user_scoped", [False, True])
def test_temp_creation_and_cleanup_within_mutation(tmp_path, monkeypatch, user_scoped):
from contextlib import contextmanager
from deerflow.config.paths import Paths
from deerflow.skills.storage.user_scoped_skill_storage import UserScopedSkillStorage
monkeypatch.setattr("deerflow.config.paths.get_paths", lambda: Paths(base_dir=tmp_path))
storage = UserScopedSkillStorage("one", host_path=str(tmp_path)) if user_scoped else LocalSkillStorage(host_path=str(tmp_path))
root = storage.get_custom_skill_dir("sample")
active = False
@contextmanager
def locked():
nonlocal active
assert not root.exists()
active = True
try:
yield
finally:
assert list(root.iterdir()) == []
active = False
monkeypatch.setattr(storage, "_skill_projection_mutation", locked)
from deerflow.skills.storage import local_skill_storage
original = local_skill_storage.tempfile.NamedTemporaryFile
@contextmanager
def failing(*args, **kwargs):
assert active
with original(*args, **kwargs) as file:
class Broken:
name = file.name
def write(self, content):
file.write(content)
raise OSError("simulated write failure")
yield Broken()
monkeypatch.setattr(local_skill_storage.tempfile, "NamedTemporaryFile", failing)
with pytest.raises(OSError):
storage.write_custom_skill("sample", "SKILL.md", "abc")
assert not active
def test_ancestor_symlink_is_not_followed(package, tmp_path):
storage, root = package
# A user scope ancestor is also an ownership boundary, even if custom itself is real.
custom = root.parent
custom.rename(tmp_path / "outside")
(tmp_path / "scope").symlink_to(tmp_path, target_is_directory=True)
storage.get_custom_skill_dir = lambda name: tmp_path / "scope/outside" / name
manifest = export.export_manifest(storage, "sample")
assert not manifest["can_export"]
assert manifest["blockers"][0]["code"] == "skill_export_link"
def test_nofollow_file_replacement_is_changed(package, monkeypatch):
storage, root = package
original = export.os.open
swapped = False
def racing(path, flags, *args, **kwargs):
nonlocal swapped
if path == "SKILL.md" and not swapped:
swapped = True
(root / "SKILL.md").unlink()
(root / "SKILL.md").symlink_to("/etc/hosts")
return original(path, flags, *args, **kwargs)
monkeypatch.setattr(export.os, "open", racing)
monkeypatch.setattr(export.os, "supports_dir_fd", os.supports_dir_fd | {racing})
with pytest.raises(export.SkillExportError) as error:
export.export_manifest(storage, "sample")
assert error.value.status == 409
def test_large_body_and_bounded_frontmatter(package):
storage, root = package
header = (root / "SKILL.md").read_bytes()
(root / "SKILL.md").write_bytes(header + b"body\n" * 250000)
assert export.export_manifest(storage, "sample")["can_export"]
(root / "SKILL.md").write_bytes(b"---\nname: sample\ndescription: Example\ncompatibility: " + b"x" * (1024 * 1024) + b"\n---\n")
with pytest.raises(export.SkillExportError) as error:
export.export_manifest(storage, "sample")
assert error.value.status == 413
def test_invalid_utf8_in_large_body_is_blocked(package):
storage, root = package
(root / "SKILL.md").write_bytes((root / "SKILL.md").read_bytes() + b"x" * (1024 * 1024 + 1) + b"\xff")
assert not export.export_manifest(storage, "sample")["can_export"]
@pytest.mark.parametrize("name", ["con", "aux", "com1"])
def test_windows_reserved_root_is_blocked(tmp_path, name):
storage = LocalSkillStorage(host_path=str(tmp_path))
root = storage.get_custom_skill_dir(name)
root.mkdir(parents=True)
(root / "SKILL.md").write_text(f"---\nname: {name}\ndescription: Example\n---\n", encoding="utf-8")
assert not export.export_manifest(storage, name)["can_export"]
def test_required_secrets_normalization_and_invalid_warning(package):
storage, root = package
(root / "SKILL.md").write_text('---\nname: sample\ndescription: Example\nrequired-secrets:\n - " API_KEY "\n - name: API_KEY\n - name: " OPTIONAL_KEY "\n optional: true\n - name: []\n - 123\n---\n', encoding="utf-8")
manifest = export.export_manifest(storage, "sample")
assert manifest["requirements"]["required_secrets"] == [{"name": "API_KEY", "optional": False}, {"name": "OPTIONAL_KEY", "optional": True}]
assert any(warning["code"] == "skill_export_invalid_declaration" for warning in manifest["warnings"])
def test_mtime_does_not_change_revision(package):
storage, root = package
before = export.export_manifest(storage, "sample")["revision"]
os.utime(root / "SKILL.md", (1, 1))
assert export.export_manifest(storage, "sample")["revision"] == before
def test_deep_yaml_is_bounded_without_source_diagnostics(package):
storage, root = package
(root / "SKILL.md").write_text("---\nname: sample\ndescription: " + "[" * 5000 + "secret" + "]" * 5000 + "\n---\n", encoding="utf-8")
manifest = export.export_manifest(storage, "sample")
assert not manifest["can_export"]
assert "secret" not in str(manifest["blockers"])
def test_invalid_unicode_nodes_still_consume_entry_budget(package, monkeypatch):
from contextlib import contextmanager
from types import SimpleNamespace
storage, root = package
(root / "one").mkdir()
(root / "two").mkdir()
directory_inodes = {(root / "one").stat().st_ino, (root / "two").stat().st_ino}
original = export.os.scandir
@contextmanager
def names(fd):
if os.fstat(fd).st_ino in directory_inodes:
yield iter([SimpleNamespace(name="\udcff")])
else:
with original(fd) as iterator:
yield iterator
monkeypatch.setattr(export.os, "scandir", names)
monkeypatch.setattr(export.os, "supports_fd", os.supports_fd | {names})
monkeypatch.setattr(export, "MAX_ENTRIES", 4)
with pytest.raises(export.SkillExportError) as error:
export.export_manifest(storage, "sample")
assert error.value.status == 413
@pytest.mark.parametrize("metadata", ["metadata: {base: &base {x: 1}, copy: *base}", "description: &text Example\nmetadata: {copy: *text}"])
def test_yaml_alias_is_rejected_before_constructor(package, monkeypatch, metadata):
storage, root = package
(root / "SKILL.md").write_text("---\nname: sample\ndescription: Example\n" + metadata + "\n---\n", encoding="utf-8")
def forbidden(*args, **kwargs):
raise AssertionError("YAML constructor must not run for aliases")
monkeypatch.setattr(export, "validate_skill_frontmatter_text", forbidden)
manifest = export.export_manifest(storage, "sample")
assert not manifest["can_export"]
assert manifest["blockers"][0]["code"] == "skill_export_yaml_alias"
assert "aliases" in manifest["blockers"][0]["message"]
def test_yaml_merge_alias_bomb_is_blocked_without_expansion(package):
storage, root = package
levels = [" a0: &a0 {x: 1}"]
levels.extend(f" a{i}: &a{i} {{<<: [*a{i - 1}, *a{i - 1}]}}" for i in range(1, 31))
content = "---\nname: sample\ndescription: Example\nmetadata:\n" + "\n".join(levels) + "\n---\n"
(root / "SKILL.md").write_text(content, encoding="utf-8")
manifest = export.export_manifest(storage, "sample")
assert not manifest["can_export"]
assert manifest["blockers"][0]["code"] == "skill_export_yaml_alias"
@pytest.mark.parametrize("kind", ["depth", "events"])
def test_yaml_structural_budget_precedes_constructor(package, monkeypatch, kind):
storage, root = package
extra = "metadata: " + "[" * 40 + "x" + "]" * 40 if kind == "depth" else "metadata: [" + ",".join("x" for _ in range(20)) + "]"
if kind == "events":
monkeypatch.setattr(export, "MAX_YAML_EVENTS", 16)
(root / "SKILL.md").write_text("---\nname: sample\ndescription: Example\n" + extra + "\n---\n", encoding="utf-8")
def forbidden(*args, **kwargs):
raise AssertionError("YAML constructor must not run over structural budget")
monkeypatch.setattr(export, "validate_skill_frontmatter_text", forbidden)
manifest = export.export_manifest(storage, "sample")
assert not manifest["can_export"]
assert manifest["blockers"][0]["code"] == "skill_export_yaml_complexity"
def test_yaml_event_preflight_observes_cancellation(package, monkeypatch):
import threading
storage, root = package
event = threading.Event()
original = export.yaml.parse
def cancel_during_parse(*args, **kwargs):
for parsed in original(*args, **kwargs):
event.set()
yield parsed
monkeypatch.setattr(export.yaml, "parse", cancel_during_parse)
with pytest.raises(export.SkillExportError) as error:
export.export_manifest(storage, "sample", event)
assert error.value.code == "skill_export_cancelled"