refactor(skills): Unified skill storage capability (#2613)

This commit is contained in:
Xun
2026-05-01 13:23:26 +08:00
committed by GitHub
parent eba3b9e18d
commit 1ad1420e31
29 changed files with 1031 additions and 968 deletions
+15
View File
@@ -68,6 +68,21 @@ def provisioner_module():
# context should mark themselves ``@pytest.mark.no_auto_user``.
@pytest.fixture(autouse=True)
def _reset_skill_storage_singleton():
"""Reset the SkillStorage singleton between tests to prevent cross-test contamination."""
try:
from deerflow.skills.storage import reset_skill_storage
except ImportError:
yield
return
reset_skill_storage()
try:
yield
finally:
reset_skill_storage()
@pytest.fixture(autouse=True)
def _auto_user_context(request):
"""Inject a default ``test-user-autouse`` into the contextvar.
+57 -31
View File
@@ -43,8 +43,12 @@ def mock_app_config():
@pytest.fixture
def client(mock_app_config):
def client(mock_app_config, tmp_path):
"""Create a DeerFlowClient with mocked config loading."""
import deerflow.skills.storage as _storage_mod
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
_storage_mod._default_skill_storage = LocalSkillStorage(host_path=str(tmp_path))
with patch("deerflow.client.get_app_config", return_value=mock_app_config):
return DeerFlowClient()
@@ -135,7 +139,7 @@ class TestConfigQueries:
skill.category = "public"
skill.enabled = True
with patch("deerflow.skills.loader.load_skills", return_value=[skill]) as mock_load:
with patch("deerflow.skills.storage.local_skill_storage.LocalSkillStorage.load_skills", return_value=[skill]) as mock_load:
result = client.list_skills()
mock_load.assert_called_once_with(enabled_only=False)
@@ -150,7 +154,7 @@ class TestConfigQueries:
}
def test_list_skills_enabled_only(self, client):
with patch("deerflow.skills.loader.load_skills", return_value=[]) as mock_load:
with patch("deerflow.skills.storage.local_skill_storage.LocalSkillStorage.load_skills", return_value=[]) as mock_load:
client.list_skills(enabled_only=True)
mock_load.assert_called_once_with(enabled_only=True)
@@ -1163,13 +1167,13 @@ class TestSkillsManagement:
def test_get_skill_found(self, client):
skill = self._make_skill()
with patch("deerflow.skills.loader.load_skills", return_value=[skill]):
with patch("deerflow.skills.storage.local_skill_storage.LocalSkillStorage.load_skills", return_value=[skill]):
result = client.get_skill("test-skill")
assert result is not None
assert result["name"] == "test-skill"
def test_get_skill_not_found(self, client):
with patch("deerflow.skills.loader.load_skills", return_value=[]):
with patch("deerflow.skills.storage.local_skill_storage.LocalSkillStorage.load_skills", return_value=[]):
result = client.get_skill("nonexistent")
assert result is None
@@ -1190,7 +1194,7 @@ class TestSkillsManagement:
client._agent = MagicMock()
with (
patch("deerflow.skills.loader.load_skills", side_effect=[[skill], [updated_skill]]),
patch("deerflow.skills.storage.local_skill_storage.LocalSkillStorage.load_skills", side_effect=[[skill], [updated_skill]]),
patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=tmp_path),
patch("deerflow.client.get_extensions_config", return_value=ext_config),
patch("deerflow.client.reload_extensions_config"),
@@ -1202,7 +1206,7 @@ class TestSkillsManagement:
tmp_path.unlink()
def test_update_skill_not_found(self, client):
with patch("deerflow.skills.loader.load_skills", return_value=[]):
with patch("deerflow.skills.storage.local_skill_storage.LocalSkillStorage.load_skills", return_value=[]):
with pytest.raises(ValueError, match="not found"):
client.update_skill("nonexistent", enabled=True)
@@ -1222,7 +1226,9 @@ class TestSkillsManagement:
skills_root = tmp_path / "skills"
(skills_root / "custom").mkdir(parents=True)
with patch("deerflow.skills.installer.get_skills_root_path", return_value=skills_root):
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
with patch("deerflow.skills.storage._default_skill_storage", LocalSkillStorage(host_path=str(skills_root))):
result = client.install_skill(archive_path)
assert result["success"] is True
@@ -1785,12 +1791,12 @@ class TestScenarioConfigManagement:
skill.category = "public"
skill.enabled = True
with patch("deerflow.skills.loader.load_skills", return_value=[skill]):
with patch("deerflow.skills.storage.local_skill_storage.LocalSkillStorage.load_skills", return_value=[skill]):
skills_result = client.list_skills()
assert len(skills_result["skills"]) == 1
# Get specific skill
with patch("deerflow.skills.loader.load_skills", return_value=[skill]):
with patch("deerflow.skills.storage.local_skill_storage.LocalSkillStorage.load_skills", return_value=[skill]):
detail = client.get_skill("web-search")
assert detail is not None
assert detail["enabled"] is True
@@ -1841,7 +1847,7 @@ class TestScenarioConfigManagement:
client._agent = MagicMock() # Simulate re-created agent
with (
patch("deerflow.skills.loader.load_skills", side_effect=[[skill], [toggled]]),
patch("deerflow.skills.storage.local_skill_storage.LocalSkillStorage.load_skills", side_effect=[[skill], [toggled]]),
patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=config_file),
patch("deerflow.client.get_extensions_config", return_value=ext_config),
patch("deerflow.client.reload_extensions_config"),
@@ -2061,7 +2067,9 @@ class TestScenarioSkillInstallAndUse:
(skills_root / "custom").mkdir(parents=True)
# Step 1: Install
with patch("deerflow.skills.installer.get_skills_root_path", return_value=skills_root):
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
with patch("deerflow.skills.storage._default_skill_storage", LocalSkillStorage(host_path=str(skills_root))):
result = client.install_skill(archive)
assert result["success"] is True
assert (skills_root / "custom" / "my-analyzer" / "SKILL.md").exists()
@@ -2074,7 +2082,7 @@ class TestScenarioSkillInstallAndUse:
installed_skill.category = "custom"
installed_skill.enabled = True
with patch("deerflow.skills.loader.load_skills", return_value=[installed_skill]):
with patch("deerflow.skills.storage.local_skill_storage.LocalSkillStorage.load_skills", return_value=[installed_skill]):
skills_result = client.list_skills()
assert any(s["name"] == "my-analyzer" for s in skills_result["skills"])
@@ -2094,7 +2102,7 @@ class TestScenarioSkillInstallAndUse:
config_file.write_text("{}")
with (
patch("deerflow.skills.loader.load_skills", side_effect=[[installed_skill], [disabled_skill]]),
patch("deerflow.skills.storage.local_skill_storage.LocalSkillStorage.load_skills", side_effect=[[installed_skill], [disabled_skill]]),
patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=config_file),
patch("deerflow.client.get_extensions_config", return_value=ext_config),
patch("deerflow.client.reload_extensions_config"),
@@ -2268,7 +2276,7 @@ class TestGatewayConformance:
skill.category = "public"
skill.enabled = True
with patch("deerflow.skills.loader.load_skills", return_value=[skill]):
with patch("deerflow.skills.storage.local_skill_storage.LocalSkillStorage.load_skills", return_value=[skill]):
result = client.list_skills()
parsed = SkillsListResponse(**result)
@@ -2283,7 +2291,7 @@ class TestGatewayConformance:
skill.category = "public"
skill.enabled = True
with patch("deerflow.skills.loader.load_skills", return_value=[skill]):
with patch("deerflow.skills.storage.local_skill_storage.LocalSkillStorage.load_skills", return_value=[skill]):
result = client.get_skill("web-search")
assert result is not None
@@ -2299,7 +2307,9 @@ class TestGatewayConformance:
with zipfile.ZipFile(archive, "w") as zf:
zf.write(skill_dir / "SKILL.md", "my-skill/SKILL.md")
with patch("deerflow.skills.installer.get_skills_root_path", return_value=tmp_path):
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
with patch("deerflow.skills.storage._default_skill_storage", LocalSkillStorage(host_path=str(tmp_path))):
result = client.install_skill(archive)
parsed = SkillInstallResponse(**result)
@@ -2453,8 +2463,10 @@ class TestInstallSkillSecurity:
def patched_extract(zf, dest, max_total_size=100):
return orig(zf, dest, max_total_size=100)
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
with (
patch("deerflow.skills.installer.get_skills_root_path", return_value=skills_root),
patch("deerflow.skills.storage._default_skill_storage", LocalSkillStorage(host_path=str(skills_root))),
patch("deerflow.skills.installer.safe_extract_skill_archive", side_effect=patched_extract),
):
with pytest.raises(ValueError, match="too large"):
@@ -2470,7 +2482,9 @@ class TestInstallSkillSecurity:
skills_root = Path(tmp) / "skills"
(skills_root / "custom").mkdir(parents=True)
with patch("deerflow.skills.installer.get_skills_root_path", return_value=skills_root):
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
with patch("deerflow.skills.storage._default_skill_storage", LocalSkillStorage(host_path=str(skills_root))):
with pytest.raises(ValueError, match="unsafe"):
client.install_skill(archive)
@@ -2484,7 +2498,9 @@ class TestInstallSkillSecurity:
skills_root = Path(tmp) / "skills"
(skills_root / "custom").mkdir(parents=True)
with patch("deerflow.skills.installer.get_skills_root_path", return_value=skills_root):
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
with patch("deerflow.skills.storage._default_skill_storage", LocalSkillStorage(host_path=str(skills_root))):
with pytest.raises(ValueError, match="unsafe"):
client.install_skill(archive)
@@ -2506,7 +2522,9 @@ class TestInstallSkillSecurity:
skills_root = tmp_path / "skills"
(skills_root / "custom").mkdir(parents=True)
with patch("deerflow.skills.installer.get_skills_root_path", return_value=skills_root):
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
with patch("deerflow.skills.storage._default_skill_storage", LocalSkillStorage(host_path=str(skills_root))):
result = client.install_skill(archive)
assert result["success"] is True
@@ -2530,9 +2548,11 @@ class TestInstallSkillSecurity:
skills_root = tmp_path / "skills"
(skills_root / "custom").mkdir(parents=True)
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
with (
patch("deerflow.skills.installer.get_skills_root_path", return_value=skills_root),
patch("deerflow.skills.installer._validate_skill_frontmatter", return_value=(True, "OK", "../evil")),
patch("deerflow.skills.storage._default_skill_storage", LocalSkillStorage(host_path=str(skills_root))),
patch("deerflow.skills.validation._validate_skill_frontmatter", return_value=(True, "OK", "../evil")),
):
with pytest.raises(ValueError, match="Invalid skill name"):
client.install_skill(archive)
@@ -2553,9 +2573,11 @@ class TestInstallSkillSecurity:
skills_root = tmp_path / "skills"
(skills_root / "custom" / "dupe-skill").mkdir(parents=True)
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
with (
patch("deerflow.skills.installer.get_skills_root_path", return_value=skills_root),
patch("deerflow.skills.installer._validate_skill_frontmatter", return_value=(True, "OK", "dupe-skill")),
patch("deerflow.skills.storage._default_skill_storage", LocalSkillStorage(host_path=str(skills_root))),
patch("deerflow.skills.validation._validate_skill_frontmatter", return_value=(True, "OK", "dupe-skill")),
):
with pytest.raises(ValueError, match="already exists"):
client.install_skill(archive)
@@ -2570,7 +2592,9 @@ class TestInstallSkillSecurity:
skills_root = Path(tmp) / "skills"
(skills_root / "custom").mkdir(parents=True)
with patch("deerflow.skills.installer.get_skills_root_path", return_value=skills_root):
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
with patch("deerflow.skills.storage._default_skill_storage", LocalSkillStorage(host_path=str(skills_root))):
with pytest.raises(ValueError, match="empty"):
client.install_skill(archive)
@@ -2589,9 +2613,11 @@ class TestInstallSkillSecurity:
skills_root = tmp_path / "skills"
(skills_root / "custom").mkdir(parents=True)
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
with (
patch("deerflow.skills.installer.get_skills_root_path", return_value=skills_root),
patch("deerflow.skills.installer._validate_skill_frontmatter", return_value=(False, "Missing name field", "")),
patch("deerflow.skills.storage._default_skill_storage", LocalSkillStorage(host_path=str(skills_root))),
patch("deerflow.skills.validation._validate_skill_frontmatter", return_value=(False, "Missing name field", "")),
):
with pytest.raises(ValueError, match="Invalid skill"):
client.install_skill(archive)
@@ -2683,7 +2709,7 @@ class TestConfigUpdateErrors:
skill.name = "some-skill"
with (
patch("deerflow.skills.loader.load_skills", return_value=[skill]),
patch("deerflow.skills.storage.local_skill_storage.LocalSkillStorage.load_skills", return_value=[skill]),
patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=None),
):
with pytest.raises(FileNotFoundError, match="Cannot locate"):
@@ -2703,7 +2729,7 @@ class TestConfigUpdateErrors:
config_file.write_text("{}")
with (
patch("deerflow.skills.loader.load_skills", side_effect=[[skill], []]),
patch("deerflow.skills.storage.local_skill_storage.LocalSkillStorage.load_skills", side_effect=[[skill], []]),
patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=config_file),
patch("deerflow.client.get_extensions_config", return_value=ext_config),
patch("deerflow.client.reload_extensions_config"),
@@ -3118,7 +3144,7 @@ class TestBugAgentInvalidationInconsistency:
config_file.write_text("{}")
with (
patch("deerflow.skills.loader.load_skills", side_effect=[[skill], [updated]]),
patch("deerflow.skills.storage.local_skill_storage.LocalSkillStorage.load_skills", side_effect=[[skill], [updated]]),
patch("deerflow.client.ExtensionsConfig.resolve_config_path", return_value=config_file),
patch("deerflow.client.get_extensions_config", return_value=ext_config),
patch("deerflow.client.reload_extensions_config"),
+43 -25
View File
@@ -23,8 +23,6 @@ from dotenv import load_dotenv
from deerflow.client import DeerFlowClient, StreamEvent
from deerflow.config.app_config import AppConfig
from deerflow.config.model_config import ModelConfig
from deerflow.config.sandbox_config import SandboxConfig
# Load .env from project root (for OPENAI_API_KEY etc.)
load_dotenv(os.path.join(os.path.dirname(__file__), "../../.env"))
@@ -55,24 +53,34 @@ def _make_e2e_config() -> AppConfig:
- ``E2E_MODEL_ID`` (default: ``ep-20251211175242-llcmh``)
- ``E2E_BASE_URL`` (default: ``https://ark-cn-beijing.bytedance.net/api/v3``)
- ``OPENAI_API_KEY`` (required for LLM tests)
Note: We use model_validate with a raw dict (not AppConfig(models=[ModelConfig(...)]))
because passing already-validated Pydantic instances triggers a pydantic-core
shortcut that returns stale cached data when another AppConfig was previously
loaded from disk in the same process. Dict-based validation is always correct.
"""
return AppConfig(
models=[
ModelConfig(
name=os.getenv("E2E_MODEL_NAME", "volcengine-ark"),
display_name="E2E Test Model",
use=os.getenv("E2E_MODEL_USE", "langchain_openai:ChatOpenAI"),
model=os.getenv("E2E_MODEL_ID", "ep-20251211175242-llcmh"),
base_url=os.getenv("E2E_BASE_URL", "https://ark-cn-beijing.bytedance.net/api/v3"),
api_key=os.getenv("OPENAI_API_KEY", ""),
max_tokens=512,
temperature=0.7,
supports_thinking=False,
supports_reasoning_effort=False,
supports_vision=False,
)
],
sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider", allow_host_bash=True),
return AppConfig.model_validate(
{
"models": [
{
"name": os.getenv("E2E_MODEL_NAME", "volcengine-ark"),
"display_name": "E2E Test Model",
"use": os.getenv("E2E_MODEL_USE", "langchain_openai:ChatOpenAI"),
"model": os.getenv("E2E_MODEL_ID", "ep-20251211175242-llcmh"),
"base_url": os.getenv("E2E_BASE_URL", "https://ark-cn-beijing.bytedance.net/api/v3"),
"api_key": os.getenv("OPENAI_API_KEY", ""),
"max_tokens": 512,
"temperature": 0.7,
"supports_thinking": False,
"supports_reasoning_effort": False,
"supports_vision": False,
}
],
"sandbox": {
"use": "deerflow.sandbox.local:LocalSandboxProvider",
"allow_host_bash": True,
},
}
)
@@ -95,10 +103,16 @@ def e2e_env(tmp_path, monkeypatch):
monkeypatch.setattr("deerflow.config.paths._paths", None)
monkeypatch.setattr("deerflow.sandbox.sandbox_provider._default_sandbox_provider", None)
# 2. Inject a clean AppConfig via the global singleton.
# 2. Inject a clean AppConfig. We must reset _app_config to None BEFORE
# calling _make_e2e_config() because AppConfig() constructor misbehaves when
# a disk config is already cached: it returns the cached model list instead
# of the provided one. Clearing first ensures the test config is correct.
monkeypatch.setattr("deerflow.config.app_config._app_config", None)
monkeypatch.setattr("deerflow.config.app_config._app_config_is_custom", False)
config = _make_e2e_config()
monkeypatch.setattr("deerflow.config.app_config._app_config", config)
monkeypatch.setattr("deerflow.config.app_config._app_config_is_custom", True)
monkeypatch.setattr("deerflow.client.get_app_config", lambda: config)
# 3. Disable title generation (extra LLM call, non-deterministic)
from deerflow.config.title_config import TitleConfig
@@ -540,9 +554,11 @@ class TestSkillInstallation:
skills_root = tmp_path / "skills"
(skills_root / "public").mkdir(parents=True)
(skills_root / "custom").mkdir(parents=True)
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
monkeypatch.setattr(
"deerflow.skills.installer.get_skills_root_path",
lambda: skills_root,
"deerflow.skills.storage._default_skill_storage",
LocalSkillStorage(host_path=str(skills_root)),
)
self._skills_root = skills_root
@@ -617,19 +633,21 @@ class TestConfigManagement:
def test_list_models_returns_injected_config(self, e2e_env):
"""list_models() returns the model from the injected AppConfig."""
expected_model_name = os.getenv("E2E_MODEL_NAME", "volcengine-ark")
c = DeerFlowClient(checkpointer=None, thinking_enabled=False)
result = c.list_models()
assert "models" in result
assert len(result["models"]) == 1
assert result["models"][0]["name"] == "volcengine-ark"
assert result["models"][0]["name"] == expected_model_name
assert result["models"][0]["display_name"] == "E2E Test Model"
def test_get_model_found(self, e2e_env):
"""get_model() returns the model when it exists."""
expected_model_name = os.getenv("E2E_MODEL_NAME", "volcengine-ark")
c = DeerFlowClient(checkpointer=None, thinking_enabled=False)
model = c.get_model("volcengine-ark")
model = c.get_model(expected_model_name)
assert model is not None
assert model["name"] == "volcengine-ark"
assert model["name"] == expected_model_name
assert model["supports_thinking"] is False
def test_get_model_not_found(self, e2e_env):
+2 -2
View File
@@ -92,7 +92,7 @@ def test_refresh_skills_system_prompt_cache_async_reloads_immediately(monkeypatc
)
state = {"skills": [make_skill("first-skill")]}
monkeypatch.setattr(prompt_module, "load_skills", lambda enabled_only=True: list(state["skills"]))
monkeypatch.setattr(prompt_module, "get_or_new_skill_storage", lambda **kwargs: __import__("types").SimpleNamespace(load_skills=lambda *, enabled_only: list(state["skills"])))
_set_skills_cache_state()
try:
@@ -145,7 +145,7 @@ def test_clear_cache_does_not_spawn_parallel_refresh_workers(monkeypatch, tmp_pa
return [make_skill(f"skill-{current_call}")]
monkeypatch.setattr(prompt_module, "load_skills", fake_load_skills)
monkeypatch.setattr(prompt_module, "get_or_new_skill_storage", lambda **kwargs: __import__("types").SimpleNamespace(load_skills=lambda *, enabled_only: fake_load_skills(enabled_only=enabled_only)))
_set_skills_cache_state()
try:
+2 -2
View File
@@ -108,8 +108,8 @@ def test_get_skills_prompt_section_uses_explicit_config_for_enabled_skills(monke
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: [_make_skill("global-skill")])
monkeypatch.setattr(
"deerflow.agents.lead_agent.prompt.load_skills",
lambda enabled_only=True, app_config=None: [_make_skill("explicit-skill")] if app_config is explicit_config else [],
"deerflow.agents.lead_agent.prompt.get_or_new_skill_storage",
lambda app_config=None, **kwargs: __import__("types").SimpleNamespace(load_skills=lambda *, enabled_only: [_make_skill("explicit-skill")] if app_config is explicit_config else []),
)
result = get_skills_prompt_section(app_config=explicit_config)
@@ -469,7 +469,7 @@ class TestLocalSandboxProviderMounts:
],
)
config = SimpleNamespace(
skills=SimpleNamespace(container_path="/custom-skills", get_skills_path=lambda: skills_dir),
skills=SimpleNamespace(container_path="/custom-skills", get_skills_path=lambda: skills_dir, use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
sandbox=sandbox_config,
)
@@ -491,7 +491,7 @@ class TestLocalSandboxProviderMounts:
],
)
config = SimpleNamespace(
skills=SimpleNamespace(container_path="/mnt/skills", get_skills_path=lambda: skills_dir),
skills=SimpleNamespace(container_path="/mnt/skills", get_skills_path=lambda: skills_dir, use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
sandbox=sandbox_config,
)
@@ -515,7 +515,7 @@ class TestLocalSandboxProviderMounts:
],
)
config = SimpleNamespace(
skills=SimpleNamespace(container_path="/mnt/skills", get_skills_path=lambda: skills_dir),
skills=SimpleNamespace(container_path="/mnt/skills", get_skills_path=lambda: skills_dir, use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
sandbox=sandbox_config,
)
@@ -631,7 +631,7 @@ class TestLocalSandboxProviderMounts:
],
)
config = SimpleNamespace(
skills=SimpleNamespace(container_path="/mnt/skills", get_skills_path=lambda: skills_dir),
skills=SimpleNamespace(container_path="/mnt/skills", get_skills_path=lambda: skills_dir, use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
sandbox=sandbox_config,
)
@@ -0,0 +1,162 @@
"""Tests for LocalSkillStorage.write_custom_skill path-traversal guards."""
from __future__ import annotations
import os
import pytest
from deerflow.skills.storage import get_or_new_skill_storage
@pytest.fixture()
def storage(tmp_path):
return get_or_new_skill_storage(skills_path=str(tmp_path))
@pytest.fixture()
def skill_dir(tmp_path, storage):
"""Pre-create the skill directory so symlink tests can plant files inside."""
d = tmp_path / "custom" / "demo-skill"
d.mkdir(parents=True, exist_ok=True)
return d
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
def test_write_creates_file(tmp_path, storage):
storage.write_custom_skill("demo-skill", "SKILL.md", "# hello")
assert (tmp_path / "custom" / "demo-skill" / "SKILL.md").read_text() == "# hello"
def test_write_creates_subdirectory(tmp_path, storage):
storage.write_custom_skill("demo-skill", "references/ref.md", "# ref")
assert (tmp_path / "custom" / "demo-skill" / "references" / "ref.md").exists()
def test_write_is_atomic_overwrite(tmp_path, storage):
storage.write_custom_skill("demo-skill", "SKILL.md", "first")
storage.write_custom_skill("demo-skill", "SKILL.md", "second")
assert (tmp_path / "custom" / "demo-skill" / "SKILL.md").read_text() == "second"
# ---------------------------------------------------------------------------
# Empty / blank path
# ---------------------------------------------------------------------------
def test_rejects_empty_string(storage):
with pytest.raises(ValueError, match="empty"):
storage.write_custom_skill("demo-skill", "", "x")
# ---------------------------------------------------------------------------
# Absolute paths
# ---------------------------------------------------------------------------
def test_rejects_absolute_unix_path(storage):
with pytest.raises(ValueError, match="skill directory"):
storage.write_custom_skill("demo-skill", "/etc/passwd", "x")
def test_rejects_absolute_path_with_skill_prefix(tmp_path, storage):
"""Absolute path within skill dir: containment check passes (not a security issue).
Python's Path(base) / "/abs/path" ignores base and returns /abs/path directly.
If that absolute path resolves within skill_dir, the write succeeds.
This is not an escape — the file lands in the correct location.
"""
absolute = str(tmp_path / "custom" / "demo-skill" / "SKILL.md")
# Does not raise; the write goes to the expected place
storage.write_custom_skill("demo-skill", absolute, "# ok")
assert (tmp_path / "custom" / "demo-skill" / "SKILL.md").read_text() == "# ok"
# ---------------------------------------------------------------------------
# Parent-directory traversal
# ---------------------------------------------------------------------------
def test_rejects_dotdot_escape(storage):
with pytest.raises(ValueError, match="skill directory"):
storage.write_custom_skill("demo-skill", "../../escaped.txt", "x")
def test_rejects_dotdot_sibling(storage):
with pytest.raises(ValueError, match="skill directory"):
storage.write_custom_skill("demo-skill", "../sibling/x.txt", "x")
def test_rejects_dotdot_in_subpath(storage):
with pytest.raises(ValueError, match="skill directory"):
storage.write_custom_skill("demo-skill", "sub/../../escape.txt", "x")
def test_rejects_dotdot_only(storage):
with pytest.raises(ValueError, match="skill directory"):
storage.write_custom_skill("demo-skill", "..", "x")
# ---------------------------------------------------------------------------
# Symlink escape
# ---------------------------------------------------------------------------
def test_rejects_symlink_pointing_outside(tmp_path, storage, skill_dir):
outside = tmp_path / "outside.txt"
link = skill_dir / "escape_link.txt"
os.symlink(outside, link)
with pytest.raises(ValueError, match="skill directory"):
storage.write_custom_skill("demo-skill", "escape_link.txt", "x")
def test_rejects_symlink_dir_pointing_outside(tmp_path, storage, skill_dir):
outside_dir = tmp_path / "outside_dir"
outside_dir.mkdir()
link_dir = skill_dir / "linked_dir"
os.symlink(outside_dir, link_dir)
with pytest.raises(ValueError, match="skill directory"):
storage.write_custom_skill("demo-skill", "linked_dir/file.txt", "x")
def test_allows_symlink_within_skill_dir(tmp_path, storage, skill_dir):
"""A symlink that resolves inside the skill directory is allowed.
Because target is resolved before writing, the write goes to the real file
the symlink points to (both the link and the real file end up with the new
content).
"""
real_file = skill_dir / "real.md"
real_file.write_text("real")
link = skill_dir / "alias.md"
os.symlink(real_file, link)
# Should not raise
storage.write_custom_skill("demo-skill", "alias.md", "updated")
# resolve() writes through to the real target file
assert real_file.read_text() == "updated"
assert (skill_dir / "alias.md").read_text() == "updated"
# ---------------------------------------------------------------------------
# Invalid skill-name traversal
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"name,method_name",
[
("../../escaped", "get_custom_skill_dir"),
("../../escaped", "get_custom_skill_file"),
("../../escaped", "get_skill_history_file"),
("../../escaped", "custom_skill_exists"),
("../../escaped", "public_skill_exists"),
],
)
def test_rejects_invalid_skill_name_in_path_helpers(storage, name, method_name):
method = getattr(storage, method_name)
with pytest.raises(ValueError, match="hyphen-case"):
method(name)
+5 -10
View File
@@ -20,11 +20,10 @@ async def _async_result(decision: str, reason: str):
def test_skill_manage_create_and_patch(monkeypatch, tmp_path):
skills_root = tmp_path / "skills"
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"),
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.skills.security_scanner.get_app_config", lambda: config)
refresh_calls = []
@@ -64,11 +63,10 @@ def test_skill_manage_create_and_patch(monkeypatch, tmp_path):
def test_skill_manage_patch_replaces_single_occurrence_by_default(monkeypatch, tmp_path):
skills_root = tmp_path / "skills"
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"),
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.skills.security_scanner.get_app_config", lambda: config)
async def _refresh():
@@ -104,11 +102,10 @@ def test_skill_manage_rejects_public_skill_patch(monkeypatch, tmp_path):
public_dir.mkdir(parents=True, exist_ok=True)
(public_dir / "SKILL.md").write_text(_skill_content("deep-research"), encoding="utf-8")
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"),
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config)
runtime = SimpleNamespace(context={}, config={"configurable": {}})
@@ -128,11 +125,10 @@ def test_skill_manage_rejects_public_skill_patch(monkeypatch, tmp_path):
def test_skill_manage_sync_wrapper_supported(monkeypatch, tmp_path):
skills_root = tmp_path / "skills"
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"),
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config)
refresh_calls = []
async def _refresh():
@@ -156,11 +152,10 @@ def test_skill_manage_sync_wrapper_supported(monkeypatch, tmp_path):
def test_skill_manage_rejects_support_path_traversal(monkeypatch, tmp_path):
skills_root = tmp_path / "skills"
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"),
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.skills.security_scanner.get_app_config", lambda: config)
async def _refresh():
+37 -23
View File
@@ -8,7 +8,7 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.gateway.routers import skills as skills_router
from deerflow.skills.manager import get_skill_history_file
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.types import Skill
@@ -58,7 +58,7 @@ def test_install_skill_archive_runs_security_scan(monkeypatch, tmp_path):
scan_calls = []
refresh_calls = []
async def _scan(content, *, executable, location):
async def _scan(content, *, executable, location, app_config=None):
from deerflow.skills.security_scanner import ScanResult
scan_calls.append({"content": content, "executable": executable, "location": location})
@@ -67,13 +67,21 @@ def test_install_skill_archive_runs_security_scan(monkeypatch, tmp_path):
async def _refresh():
refresh_calls.append("refresh")
from types import SimpleNamespace
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
storage = LocalSkillStorage(host_path=str(skills_root))
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr(skills_router, "resolve_thread_virtual_path", lambda thread_id, path: archive)
monkeypatch.setattr("deerflow.skills.installer.get_skills_root_path", lambda: skills_root)
monkeypatch.setattr(skills_router, "get_or_new_skill_storage", lambda **kw: storage)
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh)
app = FastAPI()
app.include_router(skills_router.router)
app = _make_test_app(config)
with TestClient(app) as client:
response = client.post("/api/skills/install", json={"thread_id": "thread-1", "path": "mnt/user-data/outputs/archive-skill.skill"})
@@ -105,13 +113,21 @@ def test_install_skill_archive_security_scan_block_returns_400(monkeypatch, tmp_
async def _refresh():
refresh_calls.append("refresh")
from types import SimpleNamespace
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
storage = LocalSkillStorage(host_path=str(skills_root))
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr(skills_router, "resolve_thread_virtual_path", lambda thread_id, path: archive)
monkeypatch.setattr("deerflow.skills.installer.get_skills_root_path", lambda: skills_root)
monkeypatch.setattr(skills_router, "get_or_new_skill_storage", lambda **kw: storage)
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh)
app = FastAPI()
app.include_router(skills_router.router)
app = _make_test_app(config)
with TestClient(app) as client:
response = client.post("/api/skills/install", json={"thread_id": "thread-1", "path": "mnt/user-data/outputs/blocked-skill.skill"})
@@ -128,11 +144,10 @@ def test_custom_skills_router_lifecycle(monkeypatch, tmp_path):
custom_dir.mkdir(parents=True, exist_ok=True)
(custom_dir / "SKILL.md").write_text(_skill_content("demo-skill"), encoding="utf-8")
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"),
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config)
monkeypatch.setattr("app.gateway.routers.skills.scan_skill_content", lambda *args, **kwargs: _async_scan("allow", "ok"))
refresh_calls = []
@@ -177,12 +192,13 @@ def test_custom_skill_rollback_blocked_by_scanner(monkeypatch, tmp_path):
edited_content = _skill_content("demo-skill", "Edited skill")
(custom_dir / "SKILL.md").write_text(edited_content, encoding="utf-8")
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"),
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config)
get_skill_history_file("demo-skill", app_config=config).write_text(
history_file = get_or_new_skill_storage(app_config=config).get_skill_history_file("demo-skill")
history_file.parent.mkdir(parents=True, exist_ok=True)
history_file.write_text(
'{"action":"human_edit","prev_content":' + json.dumps(original_content) + ',"new_content":' + json.dumps(edited_content) + "}\n",
encoding="utf-8",
)
@@ -218,11 +234,10 @@ def test_custom_skill_delete_preserves_history_and_allows_restore(monkeypatch, t
original_content = _skill_content("demo-skill")
(custom_dir / "SKILL.md").write_text(original_content, encoding="utf-8")
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"),
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config)
monkeypatch.setattr("app.gateway.routers.skills.scan_skill_content", lambda *args, **kwargs: _async_scan("allow", "ok"))
refresh_calls = []
@@ -255,11 +270,10 @@ def test_custom_skill_delete_continues_when_history_write_is_readonly(monkeypatc
custom_dir.mkdir(parents=True, exist_ok=True)
(custom_dir / "SKILL.md").write_text(_skill_content("demo-skill"), encoding="utf-8")
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"),
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config)
refresh_calls = []
async def _refresh():
@@ -268,7 +282,7 @@ def test_custom_skill_delete_continues_when_history_write_is_readonly(monkeypatc
def _readonly_history(*args, **kwargs):
raise OSError(errno.EROFS, "Read-only file system", str(skills_root / "custom" / ".history"))
monkeypatch.setattr("app.gateway.routers.skills.append_history", _readonly_history)
monkeypatch.setattr("deerflow.skills.storage.local_skill_storage.LocalSkillStorage.append_history", _readonly_history)
monkeypatch.setattr("app.gateway.routers.skills.refresh_skills_system_prompt_cache_async", _refresh)
app = _make_test_app(config)
@@ -288,11 +302,10 @@ def test_custom_skill_delete_fails_when_skill_dir_removal_fails(monkeypatch, tmp
custom_dir.mkdir(parents=True, exist_ok=True)
(custom_dir / "SKILL.md").write_text(_skill_content("demo-skill"), encoding="utf-8")
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"),
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config)
refresh_calls = []
async def _refresh():
@@ -301,7 +314,7 @@ def test_custom_skill_delete_fails_when_skill_dir_removal_fails(monkeypatch, tmp
def _fail_rmtree(*args, **kwargs):
raise PermissionError(errno.EACCES, "Permission denied", str(custom_dir))
monkeypatch.setattr("app.gateway.routers.skills.shutil.rmtree", _fail_rmtree)
monkeypatch.setattr("deerflow.skills.storage.local_skill_storage.shutil.rmtree", _fail_rmtree)
monkeypatch.setattr("app.gateway.routers.skills.refresh_skills_system_prompt_cache_async", _refresh)
app = _make_test_app(config)
@@ -320,7 +333,7 @@ def test_update_skill_refreshes_prompt_cache_before_return(monkeypatch, tmp_path
enabled_state = {"value": True}
refresh_calls = []
def _load_skills(*, enabled_only: bool, app_config=None):
def _load_skills(*, enabled_only: bool):
skill = _make_skill("demo-skill", enabled=enabled_state["value"])
if enabled_only and not skill.enabled:
return []
@@ -330,7 +343,8 @@ def test_update_skill_refreshes_prompt_cache_before_return(monkeypatch, tmp_path
refresh_calls.append("refresh")
enabled_state["value"] = False
monkeypatch.setattr("app.gateway.routers.skills.load_skills", _load_skills)
mock_storage = SimpleNamespace(load_skills=_load_skills)
monkeypatch.setattr("app.gateway.routers.skills.get_or_new_skill_storage", lambda **kwargs: mock_storage)
monkeypatch.setattr("app.gateway.routers.skills.get_extensions_config", lambda: SimpleNamespace(mcp_servers={}, skills={}))
monkeypatch.setattr("app.gateway.routers.skills.reload_extensions_config", lambda: None)
monkeypatch.setattr(skills_router.ExtensionsConfig, "resolve_config_path", staticmethod(lambda: config_path))
+16 -16
View File
@@ -9,7 +9,6 @@ import pytest
from deerflow.skills.installer import (
SkillSecurityScanError,
install_skill_from_archive,
is_symlink_member,
is_unsafe_zip_member,
resolve_skill_dir_from_archive,
@@ -17,6 +16,7 @@ from deerflow.skills.installer import (
should_ignore_archive_entry,
)
from deerflow.skills.security_scanner import ScanResult
from deerflow.skills.storage import get_or_new_skill_storage
# ---------------------------------------------------------------------------
# is_unsafe_zip_member
@@ -193,7 +193,7 @@ class TestInstallSkillFromArchive:
zip_path = self._make_skill_zip(tmp_path)
skills_root = tmp_path / "skills"
skills_root.mkdir()
result = install_skill_from_archive(zip_path, skills_root=skills_root)
result = get_or_new_skill_storage(skills_path=skills_root).install_skill_from_archive(zip_path)
assert result["success"] is True
assert result["skill_name"] == "test-skill"
assert (skills_root / "custom" / "test-skill" / "SKILL.md").exists()
@@ -210,7 +210,7 @@ class TestInstallSkillFromArchive:
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
install_skill_from_archive(zip_path, skills_root=skills_root)
get_or_new_skill_storage(skills_path=skills_root).install_skill_from_archive(zip_path)
assert calls == [
{
@@ -240,7 +240,7 @@ class TestInstallSkillFromArchive:
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
install_skill_from_archive(zip_path, skills_root=skills_root)
get_or_new_skill_storage(skills_path=skills_root).install_skill_from_archive(zip_path)
assert calls == [
{
@@ -275,7 +275,7 @@ class TestInstallSkillFromArchive:
skills_root.mkdir()
with pytest.raises(SkillSecurityScanError, match="nested SKILL.md"):
install_skill_from_archive(zip_path, skills_root=skills_root)
get_or_new_skill_storage(skills_path=skills_root).install_skill_from_archive(zip_path)
assert not (skills_root / "custom" / "test-skill").exists()
@@ -295,7 +295,7 @@ class TestInstallSkillFromArchive:
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
with pytest.raises(SkillSecurityScanError, match="rejected executable.*script needs review"):
install_skill_from_archive(zip_path, skills_root=skills_root)
get_or_new_skill_storage(skills_path=skills_root).install_skill_from_archive(zip_path)
assert not (skills_root / "custom" / "test-skill").exists()
@@ -310,7 +310,7 @@ class TestInstallSkillFromArchive:
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
with pytest.raises(SkillSecurityScanError, match="Security scan blocked.*prompt injection"):
install_skill_from_archive(zip_path, skills_root=skills_root)
get_or_new_skill_storage(skills_path=skills_root).install_skill_from_archive(zip_path)
assert not (skills_root / "custom" / "blocked-skill").exists()
@@ -328,7 +328,7 @@ class TestInstallSkillFromArchive:
monkeypatch.setattr("deerflow.skills.installer.shutil.copytree", _copytree)
with pytest.raises(OSError, match="copy failed"):
install_skill_from_archive(zip_path, skills_root=skills_root)
get_or_new_skill_storage(skills_path=skills_root).install_skill_from_archive(zip_path)
custom_dir = skills_root / "custom"
assert not (custom_dir / "test-skill").exists()
@@ -349,7 +349,7 @@ class TestInstallSkillFromArchive:
monkeypatch.setattr("deerflow.skills.installer.shutil.copytree", _copytree)
with pytest.raises(ValueError, match="already exists"):
install_skill_from_archive(zip_path, skills_root=skills_root)
get_or_new_skill_storage(skills_path=skills_root).install_skill_from_archive(zip_path)
assert (target / "marker.txt").read_text(encoding="utf-8") == "external"
assert not (target / "SKILL.md").exists()
@@ -366,7 +366,7 @@ class TestInstallSkillFromArchive:
monkeypatch.setattr("deerflow.skills.installer.shutil.move", _move)
with pytest.raises(OSError, match="move failed"):
install_skill_from_archive(zip_path, skills_root=skills_root)
get_or_new_skill_storage(skills_path=skills_root).install_skill_from_archive(zip_path)
assert not (skills_root / "custom" / "test-skill").exists()
@@ -375,13 +375,13 @@ class TestInstallSkillFromArchive:
skills_root = tmp_path / "skills"
(skills_root / "custom" / "test-skill").mkdir(parents=True)
with pytest.raises(ValueError, match="already exists"):
install_skill_from_archive(zip_path, skills_root=skills_root)
get_or_new_skill_storage(skills_path=skills_root).install_skill_from_archive(zip_path)
def test_invalid_extension(self, tmp_path):
bad_path = tmp_path / "bad.zip"
bad_path.write_text("not a skill")
with pytest.raises(ValueError, match=".skill"):
install_skill_from_archive(bad_path)
get_or_new_skill_storage(skills_path=tmp_path).install_skill_from_archive(bad_path)
def test_bad_frontmatter(self, tmp_path):
zip_path = tmp_path / "bad.skill"
@@ -390,11 +390,11 @@ class TestInstallSkillFromArchive:
skills_root = tmp_path / "skills"
skills_root.mkdir()
with pytest.raises(ValueError, match="Invalid skill"):
install_skill_from_archive(zip_path, skills_root=skills_root)
get_or_new_skill_storage(skills_path=skills_root).install_skill_from_archive(zip_path)
def test_nonexistent_file(self):
def test_nonexistent_file(self, tmp_path):
with pytest.raises(FileNotFoundError):
install_skill_from_archive(Path("/nonexistent/path.skill"))
get_or_new_skill_storage(skills_path=tmp_path).install_skill_from_archive(Path("/nonexistent/path.skill"))
def test_macosx_filtered_during_resolve(self, tmp_path):
"""Archive with __MACOSX dir still installs correctly."""
@@ -404,6 +404,6 @@ class TestInstallSkillFromArchive:
zf.writestr("__MACOSX/._my-skill", "meta")
skills_root = tmp_path / "skills"
skills_root.mkdir()
result = install_skill_from_archive(zip_path, skills_root=skills_root)
result = get_or_new_skill_storage(skills_path=skills_root).install_skill_from_archive(zip_path)
assert result["success"] is True
assert result["skill_name"] == "my-skill"
+8 -5
View File
@@ -1,8 +1,10 @@
"""Tests for recursive skills loading."""
from pathlib import Path
from types import SimpleNamespace
from deerflow.skills.loader import get_skills_root_path, load_skills
from deerflow.config.skills_config import SkillsConfig
from deerflow.skills.storage import get_or_new_skill_storage
def _write_skill(skill_dir: Path, name: str, description: str) -> None:
@@ -14,7 +16,8 @@ def _write_skill(skill_dir: Path, name: str, description: str) -> None:
def test_get_skills_root_path_points_to_project_root_skills():
"""get_skills_root_path() should point to deer-flow/skills (sibling of backend/), not backend/packages/skills."""
path = get_skills_root_path()
app_config = SimpleNamespace(skills=SkillsConfig())
path = get_or_new_skill_storage(app_config=app_config).get_skills_root_path()
assert path.name == "skills", f"Expected 'skills', got '{path.name}'"
assert (path.parent / "backend").is_dir(), f"Expected skills path's parent to be project root containing 'backend/', but got {path}"
@@ -27,7 +30,7 @@ def test_load_skills_discovers_nested_skills_and_sets_container_paths(tmp_path:
_write_skill(skills_root / "public" / "parent" / "child-skill", "child-skill", "Child skill")
_write_skill(skills_root / "custom" / "team" / "helper", "team-helper", "Team helper")
skills = load_skills(skills_path=skills_root, use_config=False, enabled_only=False)
skills = get_or_new_skill_storage(skills_path=skills_root).load_skills(enabled_only=False)
by_name = {skill.name: skill for skill in skills}
assert {"root-skill", "child-skill", "team-helper"} <= set(by_name)
@@ -57,7 +60,7 @@ def test_load_skills_skips_hidden_directories(tmp_path: Path):
"Hidden skill",
)
skills = load_skills(skills_path=skills_root, use_config=False, enabled_only=False)
skills = get_or_new_skill_storage(skills_path=skills_root).load_skills(enabled_only=False)
names = {skill.name for skill in skills}
assert "ok-skill" in names
@@ -69,7 +72,7 @@ def test_load_skills_prefers_custom_over_public_with_same_name(tmp_path: Path):
_write_skill(skills_root / "public" / "shared-skill", "shared-skill", "Public version")
_write_skill(skills_root / "custom" / "shared-skill", "shared-skill", "Custom version")
skills = load_skills(skills_path=skills_root, use_config=False, enabled_only=False)
skills = get_or_new_skill_storage(skills_path=skills_root).load_skills(enabled_only=False)
shared = next(skill for skill in skills if skill.name == "shared-skill")
assert shared.category == "custom"