deer-flow/backend/tests/test_skills_custom_router.py
Hyeonsang Cho bc4a33aba7
fix(skills): stop persisting resolved secrets when toggling skills (#5357)
Toggling a skill wrote resolved secrets into extensions_config.json. The
Gateway skill toggle and DeerFlowClient.update_skill loaded the file with
ExtensionsConfig.from_file(), which replaces every "$VAR" string with the
environment value (and an unset variable with ""), then serialized that
model back through to_file_dict(). A "$GITHUB_TOKEN" reference was
persisted as the plaintext token, and an unset reference was erased for
good. DeerFlowClient.update_mcp_config had the same flaw for every key
other than mcpServers.

Every writer now does a raw read-modify-write, the way the MCP router
already did: read_raw_extensions_config reads the on-disk JSON,
set_raw_skill_enabled changes only the target entry, and
validate_raw_extensions_config checks the candidate the way the runtime
will load it before the atomic write. When the file does not exist yet,
the Gateway seeds it with the cached skill states only, never the
resolved cached model. The MCP router's raw loader and candidate
validation delegate to the same helpers, so the rule lives in one place,
and to_file_dict() is removed so the unsafe serialization has no entry
point left.

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-12 11:24:46 +08:00

1390 lines
63 KiB
Python

import errno
import json
import stat
import zipfile
from io import BytesIO
from pathlib import Path
from types import SimpleNamespace
import pytest
from _router_auth_helpers import make_authed_test_app
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.gateway.auth.models import User
from app.gateway.deps import get_config
from app.gateway.routers import skills as skills_router
from app.gateway.routers import uploads as uploads_router
from deerflow.skills.security_static_scanner import StaticScannerError
from deerflow.skills.storage.user_scoped_skill_storage import UserScopedSkillStorage
from deerflow.skills.types import Skill
def _make_admin_user() -> User:
from uuid import uuid4
return User(email="admin-test@example.com", password_hash="x", system_role="admin", id=uuid4())
def _skill_content(name: str, description: str = "Demo skill") -> str:
return f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"
async def _async_scan(decision: str, reason: str):
from deerflow.skills.security_scanner import ScanResult
return ScanResult(decision=decision, reason=reason)
def _make_skill(name: str, *, enabled: bool) -> Skill:
skill_dir = Path(f"/tmp/{name}")
return Skill(
name=name,
description=f"Description for {name}",
license="MIT",
skill_dir=skill_dir,
skill_file=skill_dir / "SKILL.md",
relative_path=Path(name),
category="public",
enabled=enabled,
)
def _make_test_app(config) -> FastAPI:
app = make_authed_test_app(user_factory=_make_admin_user)
app.state.config = config # kept for any startup-style reads
app.dependency_overrides[get_config] = lambda: config
app.include_router(skills_router.router)
return app
def _make_skill_archive(tmp_path: Path, name: str, content: str | None = None) -> Path:
archive = tmp_path / f"{name}.skill"
skill_content = content or _skill_content(name)
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr(f"{name}/SKILL.md", skill_content)
return archive
def _make_skill_archive_bytes(name: str, content: str | None = None) -> bytes:
buffer = BytesIO()
skill_content = content or _skill_content(name)
with zipfile.ZipFile(buffer, "w") as zf:
zf.writestr(f"{name}/SKILL.md", skill_content)
zf.writestr(f"{name}/references/guide.md", "# Guide\n")
return buffer.getvalue()
def _user_custom_dir(base_dir: Path, user_id: str = "default") -> Path:
"""Helper to locate the per-user custom skills dir for test assertions."""
return base_dir / "users" / user_id / "skills" / "custom"
def test_install_skill_archive_runs_security_scan(monkeypatch, tmp_path):
skills_root = tmp_path / "skills"
(skills_root / "custom").mkdir(parents=True)
archive = _make_skill_archive(tmp_path, "archive-skill")
scan_calls = []
refresh_calls = []
async def _scan(content, *, executable, location, app_config=None, static_findings=None):
from deerflow.skills.security_scanner import ScanResult
scan_calls.append({"content": content, "executable": executable, "location": location})
return ScanResult(decision="allow", reason="ok")
async def _refresh(user_id: str):
refresh_calls.append(("refresh", user_id))
from deerflow.config.paths import Paths
paths = Paths(base_dir=tmp_path)
# Monkeypatch paths BEFORE constructing UserScopedSkillStorage,
# because __init__ calls get_paths() to resolve _user_custom_root.
monkeypatch.setattr("deerflow.config.paths.get_paths", lambda: paths)
monkeypatch.setattr("deerflow.config.paths._paths", None)
# Use UserScopedSkillStorage so install goes to user-level dir
storage = UserScopedSkillStorage("default", 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 _get_user_skill_storage to return our test storage
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: storage)
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
monkeypatch.setattr(skills_router, "refresh_user_skills_system_prompt_cache_async", _refresh)
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "default")
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"})
assert response.status_code == 200
assert response.json()["skill_name"] == "archive-skill"
# UserScopedSkillStorage installs to user-level dir
user_custom = _user_custom_dir(tmp_path, "default")
assert (user_custom / "archive-skill" / "SKILL.md").exists()
assert scan_calls == [
{
"content": _skill_content("archive-skill"),
"executable": False,
"location": "archive-skill/SKILL.md",
}
]
assert refresh_calls == [("refresh", "default")]
def test_upload_skill_archive_installs_without_thread_workspace(monkeypatch, tmp_path):
installed_paths: list[Path] = []
refresh_calls: list[str] = []
class _Storage:
async def ainstall_skill_from_archive(self, archive_path: Path) -> dict:
installed_paths.append(archive_path)
assert archive_path.name.endswith(".skill")
assert archive_path.read_bytes() == b"skill archive bytes"
return {
"success": True,
"skill_name": "uploaded-skill",
"message": "Skill installed successfully",
}
async def _refresh(user_id: str) -> None:
refresh_calls.append(user_id)
config = SimpleNamespace()
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: _Storage())
monkeypatch.setattr(skills_router, "refresh_user_skills_system_prompt_cache_async", _refresh)
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "default")
app = _make_test_app(config)
with TestClient(app) as client:
response = client.post(
"/api/skills/install/upload",
files={
"archive": (
"uploaded-skill.skill",
b"skill archive bytes",
"application/octet-stream",
)
},
)
assert response.status_code == 200
assert response.json()["skill_name"] == "uploaded-skill"
assert refresh_calls == ["default"]
assert len(installed_paths) == 1
assert not installed_paths[0].exists()
def test_upload_skill_archive_rejects_non_skill_extension(monkeypatch):
install_called = False
class _Storage:
async def ainstall_skill_from_archive(self, archive_path: Path) -> dict:
nonlocal install_called
install_called = True
return {}
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: _Storage())
app = _make_test_app(SimpleNamespace())
with TestClient(app) as client:
response = client.post(
"/api/skills/install/upload",
files={"archive": ("not-a-skill.zip", b"zip bytes", "application/zip")},
)
assert response.status_code == 400
assert response.json()["detail"] == "Skill archive filename must end with .skill"
assert install_called is False
def test_upload_skill_archive_keeps_multipart_openapi_contract():
app = _make_test_app(SimpleNamespace())
operation = app.openapi()["paths"]["/api/skills/install/upload"]["post"]
request_body = operation["requestBody"]
archive_schema = request_body["content"]["multipart/form-data"]["schema"]
assert request_body["required"] is True
assert archive_schema["required"] == ["archive"]
assert archive_schema["properties"]["archive"] == {"type": "string", "format": "binary"}
def test_upload_skill_archive_rejects_oversized_payload(monkeypatch):
install_called = False
copy_called = False
class _Storage:
async def ainstall_skill_from_archive(self, archive_path: Path) -> dict:
nonlocal install_called
install_called = True
return {}
monkeypatch.setattr(skills_router, "_MAX_SKILL_ARCHIVE_UPLOAD_BYTES", 3)
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: _Storage())
def _unexpected_copy(source):
nonlocal copy_called
copy_called = True
raise AssertionError("oversized upload reached the post-parse copy")
monkeypatch.setattr(skills_router, "_copy_uploaded_skill_archive", _unexpected_copy)
app = _make_test_app(SimpleNamespace())
with TestClient(app) as client:
response = client.post(
"/api/skills/install/upload",
files={"archive": ("demo.skill", b"four", "application/octet-stream")},
)
assert response.status_code == 413
assert "upload limit" in response.json()["detail"]
assert install_called is False
assert copy_called is False
@pytest.mark.asyncio
async def test_bounded_skill_archive_stream_rejects_declared_oversize_before_read(monkeypatch):
stream_read = False
class _Request:
headers = {"content-length": "5"}
async def stream(self):
nonlocal stream_read
stream_read = True
yield b"never-read"
monkeypatch.setattr(skills_router, "_MAX_SKILL_ARCHIVE_UPLOAD_BYTES", 3)
monkeypatch.setattr(skills_router, "_MAX_SKILL_ARCHIVE_MULTIPART_OVERHEAD_BYTES", 1)
stream = skills_router._bounded_skill_archive_request_stream(_Request())
with pytest.raises(skills_router._SkillArchiveUploadTooLargeError):
await anext(stream)
assert stream_read is False
@pytest.mark.asyncio
async def test_bounded_skill_archive_stream_rejects_chunked_oversize(monkeypatch):
class _Request:
headers = {}
async def stream(self):
yield b"123"
yield b"45"
monkeypatch.setattr(skills_router, "_MAX_SKILL_ARCHIVE_UPLOAD_BYTES", 3)
monkeypatch.setattr(skills_router, "_MAX_SKILL_ARCHIVE_MULTIPART_OVERHEAD_BYTES", 1)
stream = skills_router._bounded_skill_archive_request_stream(_Request())
assert await anext(stream) == b"123"
with pytest.raises(skills_router._SkillArchiveUploadTooLargeError):
await anext(stream)
def test_uploaded_skill_archive_installs_sandbox_readable_tree(monkeypatch, tmp_path):
home = tmp_path / "home"
skills_root = tmp_path / "skills"
skills_root.mkdir()
refresh_calls = []
async def _scan(*args, **kwargs):
from deerflow.skills.security_scanner import ScanResult
return ScanResult(decision="allow", reason="ok")
async def _refresh(user_id: str):
refresh_calls.append(("refresh", user_id))
from deerflow.config.paths import Paths
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),
uploads=SimpleNamespace(auto_convert_documents=False),
)
provider = SimpleNamespace(uses_thread_data_mounts=True)
# Monkeypatch paths BEFORE constructing UserScopedSkillStorage
monkeypatch.setattr("deerflow.config.paths.get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
monkeypatch.setenv("DEER_FLOW_HOME", str(home))
monkeypatch.setattr(uploads_router, "get_sandbox_provider", lambda: provider)
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
monkeypatch.setattr(skills_router, "refresh_user_skills_system_prompt_cache_async", _refresh)
# Use UserScopedSkillStorage
storage = UserScopedSkillStorage("default", host_path=str(skills_root))
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: storage)
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "default")
app = make_authed_test_app(user_factory=_make_admin_user)
app.state.config = config
app.dependency_overrides[get_config] = lambda: config
app.include_router(uploads_router.router)
app.include_router(skills_router.router)
thread_id = "thread-uploaded-skill"
archive_bytes = _make_skill_archive_bytes("uploaded-skill")
with TestClient(app) as client:
upload_response = client.post(
f"/api/threads/{thread_id}/uploads",
files=[("files", ("uploaded-skill.skill", archive_bytes, "application/octet-stream"))],
)
assert upload_response.status_code == 200
uploaded_file = upload_response.json()["files"][0]
uploaded_path = Path(uploaded_file["path"])
assert uploaded_path.is_file()
install_response = client.post("/api/skills/install", json={"thread_id": thread_id, "path": uploaded_file["virtual_path"]})
assert install_response.status_code == 200
assert install_response.json()["skill_name"] == "uploaded-skill"
installed_dir = _user_custom_dir(tmp_path, "default") / "uploaded-skill"
nested_dir = installed_dir / "references"
assert stat.S_IMODE(installed_dir.stat().st_mode) & 0o055 == 0o055
assert stat.S_IMODE(nested_dir.stat().st_mode) & 0o055 == 0o055
assert stat.S_IMODE((installed_dir / "SKILL.md").stat().st_mode) & 0o044 == 0o044
assert stat.S_IMODE((nested_dir / "guide.md").stat().st_mode) & 0o044 == 0o044
assert refresh_calls == [("refresh", "default")]
def test_install_skill_archive_security_scan_block_returns_400(monkeypatch, tmp_path):
skills_root = tmp_path / "skills"
(skills_root / "custom").mkdir(parents=True)
archive = _make_skill_archive(tmp_path, "blocked-skill")
refresh_calls = []
async def _scan(*args, **kwargs):
from deerflow.skills.security_scanner import ScanResult
return ScanResult(decision="block", reason="prompt injection")
async def _refresh(user_id: str):
refresh_calls.append(("refresh", user_id))
from deerflow.config.paths import Paths
# Monkeypatch paths BEFORE constructing UserScopedSkillStorage
monkeypatch.setattr("deerflow.config.paths.get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
storage = UserScopedSkillStorage("default", 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(skills_router, "_get_user_skill_storage", lambda cfg: storage)
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
monkeypatch.setattr(skills_router, "refresh_user_skills_system_prompt_cache_async", _refresh)
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "default")
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"})
assert response.status_code == 400
assert "Security scan blocked skill 'blocked-skill': prompt injection" in response.json()["detail"]
assert not (_user_custom_dir(tmp_path, "default") / "blocked-skill").exists()
assert refresh_calls == []
def test_install_skill_archive_static_scan_block_returns_findings(monkeypatch, tmp_path):
skills_root = tmp_path / "skills"
(skills_root / "custom").mkdir(parents=True)
archive = _make_skill_archive(
tmp_path,
"static-blocked-skill",
"---\nname: static-blocked-skill\ndescription: Static blocked skill\n---\n\n-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAtestonlytestonlytestonly\n-----END RSA PRIVATE KEY-----\n",
)
refresh_calls = []
llm_calls = []
async def _scan(*args, **kwargs):
from deerflow.skills.security_scanner import ScanResult
llm_calls.append({"args": args, "kwargs": kwargs})
return ScanResult(decision="allow", reason="ok")
async def _refresh(user_id: str):
refresh_calls.append(("refresh", user_id))
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(skills_router, "get_or_new_user_skill_storage", lambda user_id, **kw: storage)
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "default")
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
monkeypatch.setattr(skills_router, "refresh_user_skills_system_prompt_cache_async", _refresh)
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/static-blocked-skill.skill"})
assert response.status_code == 400
detail = response.json()["detail"]
assert detail["skill_name"] == "static-blocked-skill"
assert detail["findings"][0]["rule_id"] == "secret-private-key"
assert llm_calls == []
assert refresh_calls == []
assert not (skills_root / "custom" / "static-blocked-skill").exists()
def test_custom_skills_router_lifecycle(monkeypatch, tmp_path):
skills_root = tmp_path / "skills"
from deerflow.config.paths import Paths
# Create a skill in user-level custom dir
user_custom = _user_custom_dir(tmp_path, "default")
custom_dir = user_custom / "demo-skill"
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", 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.config.paths.get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
monkeypatch.setattr("app.gateway.routers.skills.scan_skill_content", lambda *args, **kwargs: _async_scan("allow", "ok"))
refresh_calls = []
async def _refresh(user_id: str):
refresh_calls.append(("refresh", user_id))
monkeypatch.setattr("app.gateway.routers.skills.refresh_user_skills_system_prompt_cache_async", _refresh)
monkeypatch.setattr("app.gateway.routers.skills.get_effective_user_id", lambda: "default")
app = _make_test_app(config)
with TestClient(app) as client:
response = client.get("/api/skills/custom")
assert response.status_code == 200
assert response.json()["skills"][0]["name"] == "demo-skill"
get_response = client.get("/api/skills/custom/demo-skill")
assert get_response.status_code == 200
assert "# demo-skill" in get_response.json()["content"]
update_response = client.put(
"/api/skills/custom/demo-skill",
json={"content": _skill_content("demo-skill", "Edited skill")},
)
assert update_response.status_code == 200
assert update_response.json()["description"] == "Edited skill"
assert stat.S_IMODE((custom_dir / "SKILL.md").stat().st_mode) & 0o044 == 0o044
history_response = client.get("/api/skills/custom/demo-skill/history")
assert history_response.status_code == 200
assert history_response.json()["history"][-1]["action"] == "human_edit"
rollback_response = client.post("/api/skills/custom/demo-skill/rollback", json={"history_index": -1})
assert rollback_response.status_code == 200
assert rollback_response.json()["description"] == "Demo skill"
assert stat.S_IMODE((custom_dir / "SKILL.md").stat().st_mode) & 0o044 == 0o044
assert refresh_calls == [("refresh", "default"), ("refresh", "default")]
def test_custom_skill_update_static_scan_failure_blocks_edit_before_llm(monkeypatch, tmp_path):
skills_root = tmp_path / "skills"
from deerflow.config.paths import Paths
custom_dir = _user_custom_dir(tmp_path, "default") / "demo-skill"
custom_dir.mkdir(parents=True, exist_ok=True)
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", 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.config.paths.get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
refresh_calls = []
llm_calls = []
async def _refresh(user_id: str):
refresh_calls.append(("refresh", user_id))
async def _scan(*args, **kwargs):
llm_calls.append({"args": args, "kwargs": kwargs})
return await _async_scan("allow", "ok")
def _broken_static_scan(skill_dir, *, skill_name=None, app_config=None):
raise StaticScannerError("native scanner unavailable")
monkeypatch.setattr("app.gateway.routers.skills.refresh_user_skills_system_prompt_cache_async", _refresh)
monkeypatch.setattr("app.gateway.routers.skills.get_effective_user_id", lambda: "default")
monkeypatch.setattr("app.gateway.routers.skills.scan_skill_content", _scan)
monkeypatch.setattr("app.gateway.routers.skills.enforce_static_scan", _broken_static_scan)
app = _make_test_app(config)
with TestClient(app) as client:
response = client.put(
"/api/skills/custom/demo-skill",
json={"content": _skill_content("demo-skill", "Edited skill")},
)
assert response.status_code == 400
assert "Static security scan failed" in response.json()["detail"]
assert "native scanner unavailable" in response.json()["detail"]
assert llm_calls == []
assert refresh_calls == []
assert (custom_dir / "SKILL.md").read_text(encoding="utf-8") == original_content
def test_custom_skill_update_rejects_blank_description_before_write(monkeypatch, tmp_path):
"""A blank description must be rejected at the write gate, not after the file is committed.
The loader drops a skill whose description is blank, so letting the edit
through would overwrite a working SKILL.md with content that no longer
loads -- the skill would vanish from every consumer.
"""
skills_root = tmp_path / "skills"
from deerflow.config.paths import Paths
custom_dir = _user_custom_dir(tmp_path, "default") / "demo-skill"
custom_dir.mkdir(parents=True, exist_ok=True)
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", 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.config.paths.get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
refresh_calls = []
scan_calls = []
async def _refresh(user_id: str):
refresh_calls.append(("refresh", user_id))
async def _scan(*args, **kwargs):
scan_calls.append({"args": args, "kwargs": kwargs})
return await _async_scan("allow", "ok")
monkeypatch.setattr("app.gateway.routers.skills.refresh_user_skills_system_prompt_cache_async", _refresh)
monkeypatch.setattr("app.gateway.routers.skills.get_effective_user_id", lambda: "default")
monkeypatch.setattr("app.gateway.routers.skills.scan_skill_content", _scan)
app = _make_test_app(config)
with TestClient(app) as client:
response = client.put(
"/api/skills/custom/demo-skill",
json={"content": "---\nname: demo-skill\ndescription: ''\n---\n\n# demo-skill\n"},
)
assert response.status_code == 400
assert "empty" in response.json()["detail"].lower()
assert scan_calls == []
assert refresh_calls == []
assert (custom_dir / "SKILL.md").read_text(encoding="utf-8") == original_content
def test_custom_skill_rollback_blocked_by_scanner(monkeypatch, tmp_path):
skills_root = tmp_path / "skills"
from deerflow.config.paths import Paths
user_custom = _user_custom_dir(tmp_path, "default")
custom_dir = user_custom / "demo-skill"
custom_dir.mkdir(parents=True, exist_ok=True)
original_content = _skill_content("demo-skill")
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", 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.config.paths.get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
# Write history file directly for the rollback test
storage = UserScopedSkillStorage("default", host_path=str(skills_root))
history_file = storage.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",
)
async def _refresh(user_id: str):
return None
monkeypatch.setattr("app.gateway.routers.skills.refresh_user_skills_system_prompt_cache_async", _refresh)
monkeypatch.setattr("app.gateway.routers.skills.get_effective_user_id", lambda: "default")
async def _scan(*args, **kwargs):
from deerflow.skills.security_scanner import ScanResult
return ScanResult(decision="block", reason="unsafe rollback")
monkeypatch.setattr("app.gateway.routers.skills.scan_skill_content", _scan)
app = _make_test_app(config)
with TestClient(app) as client:
rollback_response = client.post("/api/skills/custom/demo-skill/rollback", json={"history_index": -1})
assert rollback_response.status_code == 400
assert "unsafe rollback" in rollback_response.json()["detail"]
history_response = client.get("/api/skills/custom/demo-skill/history")
assert history_response.status_code == 200
assert history_response.json()["history"][-1]["scanner"]["decision"] == "block"
def test_custom_skill_rollback_rejects_blank_description_before_write(monkeypatch, tmp_path):
"""Rolling back to a history entry whose stored content has a blank
description must be refused at the gate, not committed.
The loader drops a skill whose description is blank, so restoring such an
entry would overwrite a working SKILL.md with content that never loads
again. This pins the status-code contract for the rollback path: it must
return 400 (validation) rather than the old destructive 404, and the
on-disk file must be left untouched.
"""
skills_root = tmp_path / "skills"
from deerflow.config.paths import Paths
user_custom = _user_custom_dir(tmp_path, "default")
custom_dir = user_custom / "demo-skill"
custom_dir.mkdir(parents=True, exist_ok=True)
current_content = _skill_content("demo-skill", "Edited skill")
(custom_dir / "SKILL.md").write_text(current_content, encoding="utf-8")
# A previous revision whose description is an empty string.
blank_content = "---\nname: demo-skill\ndescription: ''\n---\n\n# demo-skill\n"
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("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.config.paths.get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
# Roll back target is prev_content, so store the blank revision there.
storage = UserScopedSkillStorage("default", host_path=str(skills_root))
history_file = storage.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(blank_content) + ',"new_content":' + json.dumps(current_content) + "}\n",
encoding="utf-8",
)
refresh_calls = []
scan_calls = []
async def _refresh(user_id: str):
refresh_calls.append(("refresh", user_id))
async def _scan(*args, **kwargs):
scan_calls.append({"args": args, "kwargs": kwargs})
return await _async_scan("allow", "ok")
monkeypatch.setattr("app.gateway.routers.skills.refresh_user_skills_system_prompt_cache_async", _refresh)
monkeypatch.setattr("app.gateway.routers.skills.get_effective_user_id", lambda: "default")
monkeypatch.setattr("app.gateway.routers.skills.scan_skill_content", _scan)
app = _make_test_app(config)
with TestClient(app) as client:
rollback_response = client.post("/api/skills/custom/demo-skill/rollback", json={"history_index": -1})
assert rollback_response.status_code == 400
assert "Description cannot be empty" in rollback_response.json()["detail"]
# The gate runs before the scanner and before any write, so the current
# file survives untouched and no prompt-cache refresh is triggered.
assert scan_calls == []
assert refresh_calls == []
assert (custom_dir / "SKILL.md").read_text(encoding="utf-8") == current_content
def test_custom_skill_delete_preserves_history_and_allows_restore(monkeypatch, tmp_path):
skills_root = tmp_path / "skills"
from deerflow.config.paths import Paths
user_custom = _user_custom_dir(tmp_path, "default")
custom_dir = user_custom / "demo-skill"
custom_dir.mkdir(parents=True, exist_ok=True)
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", 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.config.paths.get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
monkeypatch.setattr("app.gateway.routers.skills.scan_skill_content", lambda *args, **kwargs: _async_scan("allow", "ok"))
refresh_calls = []
async def _refresh(user_id: str):
refresh_calls.append(("refresh", user_id))
monkeypatch.setattr("app.gateway.routers.skills.refresh_user_skills_system_prompt_cache_async", _refresh)
monkeypatch.setattr("app.gateway.routers.skills.get_effective_user_id", lambda: "default")
app = _make_test_app(config)
with TestClient(app) as client:
delete_response = client.delete("/api/skills/custom/demo-skill")
assert delete_response.status_code == 200
assert not (custom_dir / "SKILL.md").exists()
history_response = client.get("/api/skills/custom/demo-skill/history")
assert history_response.status_code == 200
assert history_response.json()["history"][-1]["action"] == "human_delete"
rollback_response = client.post("/api/skills/custom/demo-skill/rollback", json={"history_index": -1})
assert rollback_response.status_code == 200
assert rollback_response.json()["description"] == "Demo skill"
assert (custom_dir / "SKILL.md").read_text(encoding="utf-8") == original_content
assert refresh_calls == [("refresh", "default"), ("refresh", "default")]
def test_custom_skill_delete_continues_when_history_write_is_readonly(monkeypatch, tmp_path):
skills_root = tmp_path / "skills"
from deerflow.config.paths import Paths
user_custom = _user_custom_dir(tmp_path, "default")
custom_dir = user_custom / "demo-skill"
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", 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.config.paths.get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
refresh_calls = []
async def _refresh(user_id: str):
refresh_calls.append(("refresh", user_id))
def _readonly_history(*args, **kwargs):
raise OSError(errno.EROFS, "Read-only file system", str(user_custom / ".history"))
monkeypatch.setattr("deerflow.skills.storage.user_scoped_skill_storage.UserScopedSkillStorage.append_history", _readonly_history)
monkeypatch.setattr("app.gateway.routers.skills.refresh_user_skills_system_prompt_cache_async", _refresh)
monkeypatch.setattr("app.gateway.routers.skills.get_effective_user_id", lambda: "default")
app = _make_test_app(config)
with TestClient(app) as client:
delete_response = client.delete("/api/skills/custom/demo-skill")
assert delete_response.status_code == 200
assert delete_response.json() == {"success": True}
assert not custom_dir.exists()
assert refresh_calls == [("refresh", "default")]
def test_custom_skill_delete_fails_when_skill_dir_removal_fails(monkeypatch, tmp_path):
skills_root = tmp_path / "skills"
from deerflow.config.paths import Paths
user_custom = _user_custom_dir(tmp_path, "default")
custom_dir = user_custom / "demo-skill"
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", 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.config.paths.get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
refresh_calls = []
async def _refresh(user_id: str):
refresh_calls.append(("refresh", user_id))
def _fail_rmtree(*args, **kwargs):
raise PermissionError(errno.EACCES, "Permission denied", str(custom_dir))
monkeypatch.setattr("deerflow.skills.storage.local_skill_storage.shutil.rmtree", _fail_rmtree)
monkeypatch.setattr("app.gateway.routers.skills.refresh_user_skills_system_prompt_cache_async", _refresh)
monkeypatch.setattr("app.gateway.routers.skills.get_effective_user_id", lambda: "default")
app = _make_test_app(config)
with TestClient(app) as client:
delete_response = client.delete("/api/skills/custom/demo-skill")
assert delete_response.status_code == 500
assert "Failed to delete custom skill" in delete_response.json()["detail"]
assert custom_dir.exists()
assert refresh_calls == []
def test_update_skill_refreshes_prompt_cache_before_return(monkeypatch, tmp_path):
config_path = tmp_path / "extensions_config.json"
enabled_state = {"value": True}
refresh_calls = []
per_user_writes: list[tuple[str, bool]] = []
def _load_skills(*, enabled_only: bool):
# Use a CUSTOM skill so the router takes the per-user cache invalidation
# branch (PUBLIC skills clear the cache for all users via
# ``clear_skills_system_prompt_cache`` — see
# ``test_public_skill_toggle_clears_all_users_cache``).
skill = Skill(
name="demo-skill",
description="Description for demo-skill",
license="MIT",
skill_dir=Path("/tmp/demo-skill"),
skill_file=Path("/tmp/demo-skill/SKILL.md"),
relative_path=Path("demo-skill"),
category="custom",
enabled=enabled_state["value"],
)
if enabled_only and not skill.enabled:
return []
return [skill]
def _set_skill_enabled_state(name: str, enabled: bool) -> None:
per_user_writes.append((name, enabled))
enabled_state["value"] = enabled
async def _refresh(user_id: str):
refresh_calls.append(("refresh", user_id))
# Mock storage must be a UserScopedSkillStorage instance so the
# router takes the per-user ``set_skill_enabled_state`` branch.
# We patch the symbol on the storage module because the router
# function imports ``UserScopedSkillStorage`` lazily from there.
from deerflow.skills.storage import user_scoped_skill_storage as uss_module
class _FakeUserScopedStorage:
def __init__(self, *args, **kwargs) -> None:
self._load = _load_skills
self._write = _set_skill_enabled_state
def load_skills(self, *, enabled_only: bool = False):
return self._load(enabled_only=enabled_only)
def set_skill_enabled_state(self, name: str, enabled: bool) -> None:
self._write(name, enabled)
monkeypatch.setattr(uss_module, "UserScopedSkillStorage", _FakeUserScopedStorage)
# The router also calls ``isinstance(storage, UserScopedSkillStorage)``
# against the symbol it imported; monkeypatch the symbol on the
# storage module so the isinstance check accepts our mock.
monkeypatch.setattr("deerflow.skills.storage.user_scoped_skill_storage.UserScopedSkillStorage", _FakeUserScopedStorage)
mock_storage = _FakeUserScopedStorage()
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: mock_storage)
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "default")
monkeypatch.setattr("app.gateway.routers.skills.reload_extensions_config", lambda: None)
monkeypatch.setattr(skills_router.ExtensionsConfig, "resolve_config_path", staticmethod(lambda config_path=None: config_path))
monkeypatch.setattr("app.gateway.routers.skills.refresh_user_skills_system_prompt_cache_async", _refresh)
app = _make_test_app(SimpleNamespace())
with TestClient(app) as client:
response = client.put("/api/skills/demo-skill", json={"enabled": False})
assert response.status_code == 200
assert response.json()["enabled"] is False
assert refresh_calls == [("refresh", "default")]
# CUSTOM skills write to per-user state (not extensions_config.json).
assert per_user_writes == [("demo-skill", False)]
assert not config_path.exists() or json.loads(config_path.read_text(encoding="utf-8")) == {"mcpServers": {}, "skills": {}}
def test_public_skill_toggle_clears_all_users_cache(monkeypatch, tmp_path):
"""P2-5: toggling a PUBLIC skill must invalidate the prompt cache for
every user, because PUBLIC state lives in the global
``extensions_config.json`` and a per-user ``refresh_*`` call would
leave the other users' cached enabled state stale.
"""
config_path = tmp_path / "extensions_config.json"
config_path.write_text(json.dumps({"mcpServers": {}, "skills": {"public-skill": {"enabled": True}}}), encoding="utf-8")
clear_calls = []
refresh_calls = []
load_calls = {"n": 0}
def _load_skills(*, enabled_only: bool):
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.skills.types import Skill
# The router re-loads after the toggle so the response reflects
# the new state. The second call therefore reads the on-disk
# JSON, which the PUBLIC branch has just rewritten.
load_calls["n"] += 1
if load_calls["n"] >= 2:
on_disk = ExtensionsConfig.from_file(config_path)
current_enabled = on_disk.skills["public-skill"].enabled
else:
current_enabled = True
skill = Skill(
name="public-skill",
description="Description for public-skill",
license="MIT",
skill_dir=Path("/tmp/public-skill"),
skill_file=Path("/tmp/public-skill/SKILL.md"),
relative_path=Path("public-skill"),
category="public",
enabled=current_enabled,
)
if enabled_only and not skill.enabled:
return []
return [skill]
def _clear():
clear_calls.append("clear")
async def _refresh(user_id: str):
refresh_calls.append(("refresh", user_id))
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: SimpleNamespace(load_skills=_load_skills))
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "default")
# ``resolve_config_path()`` is called with no args inside the router
# to discover where to write; point it at the temp file.
monkeypatch.setattr(skills_router.ExtensionsConfig, "resolve_config_path", staticmethod(lambda config_path=None: config_path if config_path is not None else config_path))
# Re-bind the staticmethod to actually default to config_path when
# called with no args. ``staticmethod`` is a descriptor, so we wrap
# with a callable that always returns the test path.
def _resolve(_config_path=None):
return config_path
monkeypatch.setattr(skills_router.ExtensionsConfig, "resolve_config_path", staticmethod(_resolve))
monkeypatch.setattr("app.gateway.routers.skills.reload_extensions_config", lambda: None)
monkeypatch.setattr("app.gateway.routers.skills.clear_skills_system_prompt_cache", _clear)
monkeypatch.setattr("app.gateway.routers.skills.refresh_user_skills_system_prompt_cache_async", _refresh)
app = _make_test_app(SimpleNamespace())
with TestClient(app) as client:
response = client.put("/api/skills/public-skill", json={"enabled": False})
assert response.status_code == 200, response.text
assert response.json()["enabled"] is False
# PUBLIC skills must hit the global cache-clear branch, not per-user refresh.
assert clear_calls == ["clear"]
assert refresh_calls == []
# The global state file must reflect the toggle.
persisted = json.loads(config_path.read_text(encoding="utf-8"))
assert persisted["skills"]["public-skill"]["enabled"] is False
def test_public_skill_toggle_creates_missing_extensions_config(monkeypatch, tmp_path):
from deerflow.config.extensions_config import ExtensionsConfig
backend_dir = tmp_path / "backend"
backend_dir.mkdir()
monkeypatch.chdir(backend_dir)
config_path = tmp_path / "extensions_config.json"
def _load_skills(*, enabled_only: bool):
enabled = True
if config_path.exists():
enabled = json.loads(config_path.read_text(encoding="utf-8"))["skills"]["public-skill"]["enabled"]
skill = _make_skill("public-skill", enabled=enabled)
return [] if enabled_only and not enabled else [skill]
storage = SimpleNamespace(load_skills=_load_skills)
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda _config: storage)
monkeypatch.setattr(skills_router, "get_extensions_config", lambda: ExtensionsConfig())
def _resolve_config_path(explicit_path=None):
if explicit_path is None:
return None
path = Path(explicit_path)
if not path.exists():
raise FileNotFoundError(path)
return path
monkeypatch.setattr(skills_router.ExtensionsConfig, "resolve_config_path", staticmethod(_resolve_config_path))
monkeypatch.setattr(skills_router, "reload_extensions_config", lambda: None)
monkeypatch.setattr(skills_router, "clear_skills_system_prompt_cache", lambda: None)
with TestClient(_make_test_app(SimpleNamespace())) as client:
response = client.put("/api/skills/public-skill", json={"enabled": False})
assert response.status_code == 200, response.text
assert response.json()["enabled"] is False
# Only skill states are seeded; the cached model is never serialized because
# its $VAR values are already resolved.
assert json.loads(config_path.read_text(encoding="utf-8")) == {
"skills": {"public-skill": {"enabled": False}},
}
def test_public_skill_toggle_rebuilds_projection_before_response(monkeypatch, tmp_path):
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, reset_extensions_config, set_extensions_config
from deerflow.config.paths import Paths
from deerflow.skills.projection import rebuild_skill_projections
skills_root = tmp_path / "skills"
skill_file = skills_root / "public" / "public-skill" / "SKILL.md"
skill_file.parent.mkdir(parents=True)
skill_file.write_text(_skill_content("public-skill"), encoding="utf-8")
(skills_root / "custom").mkdir()
config_path = tmp_path / "extensions_config.json"
config_path.write_text(
json.dumps(
{
"mcpServers": {},
"skills": {
"public-skill": {"enabled": True},
"untouched-skill": {"enabled": False},
},
}
),
encoding="utf-8",
)
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(config_path))
paths = Paths(base_dir=tmp_path)
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("deerflow.config.paths.get_paths", lambda: paths)
monkeypatch.setattr("deerflow.config.paths._paths", None)
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "default")
monkeypatch.setattr(skills_router, "clear_skills_system_prompt_cache", lambda: None)
# Simulate another worker having updated the file after this worker cached
# an older snapshot. The public toggle must reload from disk under the
# cross-process projection lock before its read-modify-write.
set_extensions_config(ExtensionsConfig(skills={"public-skill": SkillStateConfig(enabled=True)}))
storage = UserScopedSkillStorage("default", host_path=str(skills_root), app_config=config)
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda _config: storage)
projected = rebuild_skill_projections(storage)
assert (projected.public / "public-skill" / "SKILL.md").is_file()
try:
with TestClient(_make_test_app(config)) as client:
response = client.put("/api/skills/public-skill", json={"enabled": False})
assert response.status_code == 200, response.text
assert response.json()["enabled"] is False
assert not (projected.public / "public-skill").exists()
persisted = json.loads(config_path.read_text(encoding="utf-8"))
assert persisted["skills"]["untouched-skill"] == {"enabled": False}
finally:
reset_extensions_config()
class TestMultiUserSkillIsolation:
"""End-to-end integration tests verifying per-user skill isolation
through the HTTP router → _get_user_skill_storage → filesystem chain.
These tests simulate two distinct users (alice and bob) calling the
same API endpoints and verify that each user's skills are completely
isolated: alice cannot see/edit/delete bob's skills and vice versa.
"""
@staticmethod
def _setup_two_user_env(monkeypatch, tmp_path, skills_root):
"""Shared setup: patch paths and create two UserScopedSkillStorage instances."""
from deerflow.config.paths import Paths
monkeypatch.setattr("deerflow.config.paths.get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
alice_storage = UserScopedSkillStorage("alice", host_path=str(skills_root))
bob_storage = UserScopedSkillStorage("bob", 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),
)
return alice_storage, bob_storage, config
def test_alice_skill_not_visible_to_bob_via_list_api(self, monkeypatch, tmp_path):
"""Alice installs a skill; Bob's /api/skills listing does not include it."""
skills_root = tmp_path / "skills"
skills_root.mkdir()
alice_storage, bob_storage, config = self._setup_two_user_env(monkeypatch, tmp_path, skills_root)
# Alice creates a skill directly in her per-user directory
alice_custom = _user_custom_dir(tmp_path, "alice")
skill_dir = alice_custom / "alice-secret-skill"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(_skill_content("alice-secret-skill"), encoding="utf-8")
# Alice's listing includes her skill
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: alice_storage)
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "alice")
alice_app = _make_test_app(config)
with TestClient(alice_app) as client:
alice_response = client.get("/api/skills")
alice_skills = alice_response.json()["skills"]
alice_custom_skills = [s for s in alice_skills if s["category"] == "custom"]
assert len(alice_custom_skills) == 1
assert alice_custom_skills[0]["name"] == "alice-secret-skill"
# Bob's listing does NOT include Alice's skill
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: bob_storage)
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "bob")
bob_app = _make_test_app(config)
with TestClient(bob_app) as client:
bob_response = client.get("/api/skills")
bob_skills = bob_response.json()["skills"]
bob_custom_skills = [s for s in bob_skills if s["category"] == "custom"]
assert len(bob_custom_skills) == 0
def test_bob_cannot_read_alice_skill_via_get_api(self, monkeypatch, tmp_path):
"""Bob cannot GET /api/skills/custom/alice-secret-skill — 404."""
skills_root = tmp_path / "skills"
skills_root.mkdir()
alice_storage, _, config = self._setup_two_user_env(monkeypatch, tmp_path, skills_root)
alice_custom = _user_custom_dir(tmp_path, "alice")
skill_dir = alice_custom / "alice-secret-skill"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(_skill_content("alice-secret-skill"), encoding="utf-8")
# Simulate Bob's request context
bob_storage = UserScopedSkillStorage("bob", host_path=str(skills_root))
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: bob_storage)
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "bob")
bob_app = _make_test_app(config)
with TestClient(bob_app) as client:
bob_get_response = client.get("/api/skills/custom/alice-secret-skill")
assert bob_get_response.status_code == 404
# Alice can still read it
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: alice_storage)
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "alice")
alice_app = _make_test_app(config)
with TestClient(alice_app) as client:
alice_get_response = client.get("/api/skills/custom/alice-secret-skill")
assert alice_get_response.status_code == 200
assert "# alice-secret-skill" in alice_get_response.json()["content"]
def test_bob_cannot_delete_alice_skill(self, monkeypatch, tmp_path):
"""Bob cannot DELETE /api/skills/custom/alice-secret-skill — 404."""
skills_root = tmp_path / "skills"
skills_root.mkdir()
alice_storage, _, config = self._setup_two_user_env(monkeypatch, tmp_path, skills_root)
alice_custom = _user_custom_dir(tmp_path, "alice")
skill_dir = alice_custom / "alice-secret-skill"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(_skill_content("alice-secret-skill"), encoding="utf-8")
bob_storage = UserScopedSkillStorage("bob", host_path=str(skills_root))
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: bob_storage)
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "bob")
monkeypatch.setattr("app.gateway.routers.skills.refresh_user_skills_system_prompt_cache_async", lambda uid: None)
bob_app = _make_test_app(config)
with TestClient(bob_app) as client:
bob_delete_response = client.delete("/api/skills/custom/alice-secret-skill")
assert bob_delete_response.status_code == 404
# Alice's skill file still exists
assert (skill_dir / "SKILL.md").exists()
def test_alice_cannot_edit_bob_skill_via_update_api(self, monkeypatch, tmp_path):
"""Alice cannot PUT /api/skills/custom/bob-skill — 404."""
skills_root = tmp_path / "skills"
skills_root.mkdir()
_, bob_storage, config = self._setup_two_user_env(monkeypatch, tmp_path, skills_root)
bob_custom = _user_custom_dir(tmp_path, "bob")
skill_dir = bob_custom / "bob-priv-skill"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(_skill_content("bob-priv-skill"), encoding="utf-8")
alice_storage = UserScopedSkillStorage("alice", host_path=str(skills_root))
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: alice_storage)
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "alice")
monkeypatch.setattr("app.gateway.routers.skills.scan_skill_content", lambda *a, **kw: _async_scan("allow", "ok"))
monkeypatch.setattr("app.gateway.routers.skills.refresh_user_skills_system_prompt_cache_async", lambda uid: None)
alice_app = _make_test_app(config)
with TestClient(alice_app) as client:
alice_update_response = client.put(
"/api/skills/custom/bob-priv-skill",
json={"content": _skill_content("bob-priv-skill", "Hacked by alice")},
)
assert alice_update_response.status_code == 404
# Bob's skill content is unchanged
assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == _skill_content("bob-priv-skill")
def test_two_users_install_same_skill_name_independently(self, monkeypatch, tmp_path):
"""Both users install a skill named 'my-workflow' — they are stored separately."""
skills_root = tmp_path / "skills"
skills_root.mkdir()
alice_storage, bob_storage, config = self._setup_two_user_env(monkeypatch, tmp_path, skills_root)
# Both users create a skill with the same name but different content
alice_custom = _user_custom_dir(tmp_path, "alice")
alice_dir = alice_custom / "my-workflow"
alice_dir.mkdir(parents=True, exist_ok=True)
(alice_dir / "SKILL.md").write_text(_skill_content("my-workflow", "Alice's version"), encoding="utf-8")
bob_custom = _user_custom_dir(tmp_path, "bob")
bob_dir = bob_custom / "my-workflow"
bob_dir.mkdir(parents=True, exist_ok=True)
(bob_dir / "SKILL.md").write_text(_skill_content("my-workflow", "Bob's version"), encoding="utf-8")
# Alice sees her version
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: alice_storage)
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "alice")
alice_app = _make_test_app(config)
with TestClient(alice_app) as client:
alice_skills = client.get("/api/skills").json()["skills"]
alice_custom_skills = [s for s in alice_skills if s["category"] == "custom"]
assert len(alice_custom_skills) == 1
assert alice_custom_skills[0]["name"] == "my-workflow"
alice_content = client.get("/api/skills/custom/my-workflow").json()["content"]
assert "Alice's version" in alice_content
# Bob sees his version
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: bob_storage)
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "bob")
bob_app = _make_test_app(config)
with TestClient(bob_app) as client:
bob_skills = client.get("/api/skills").json()["skills"]
bob_custom_skills = [s for s in bob_skills if s["category"] == "custom"]
assert len(bob_custom_skills) == 1
assert bob_custom_skills[0]["name"] == "my-workflow"
bob_content = client.get("/api/skills/custom/my-workflow").json()["content"]
assert "Bob's version" in bob_content
def test_skill_response_includes_editable_field(self, monkeypatch, tmp_path):
"""SkillResponse.editable is true for CUSTOM, false for PUBLIC and LEGACY."""
skills_root = tmp_path / "skills"
skills_root.mkdir()
# Create a public skill
public_dir = skills_root / "public" / "deep-research"
public_dir.mkdir(parents=True, exist_ok=True)
(public_dir / "SKILL.md").write_text(_skill_content("deep-research", "Built-in skill"), encoding="utf-8")
# Create a global custom skill (LEGACY fallback for users without per-user dir)
global_custom_dir = skills_root / "custom" / "legacy-shared-skill"
global_custom_dir.mkdir(parents=True, exist_ok=True)
(global_custom_dir / "SKILL.md").write_text(_skill_content("legacy-shared-skill", "Legacy shared skill"), encoding="utf-8")
# Create a per-user custom skill
from deerflow.config.paths import Paths
monkeypatch.setattr("deerflow.config.paths.get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
alice_storage = UserScopedSkillStorage("alice", host_path=str(skills_root))
alice_custom = _user_custom_dir(tmp_path, "alice")
alice_dir = alice_custom / "alice-custom-skill"
alice_dir.mkdir(parents=True, exist_ok=True)
(alice_dir / "SKILL.md").write_text(_skill_content("alice-custom-skill"), encoding="utf-8")
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, "_get_user_skill_storage", lambda cfg: alice_storage)
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "alice")
monkeypatch.setattr("app.gateway.routers.skills.reload_extensions_config", lambda: None)
app = _make_test_app(config)
with TestClient(app) as client:
response = client.get("/api/skills")
skills = response.json()["skills"]
# PUBLIC skill: editable=false
public_skill = next(s for s in skills if s["name"] == "deep-research")
assert public_skill["category"] == "public"
assert public_skill["editable"] is False
# CUSTOM skill: editable=true
custom_skill = next(s for s in skills if s["name"] == "alice-custom-skill")
assert custom_skill["category"] == "custom"
assert custom_skill["editable"] is True
def test_toggle_enabled_accepted_for_custom_skill(self, monkeypatch, tmp_path):
"""PUT /api/skills/<custom-skill> with {enabled: false} returns 200.
All skill categories (public, custom, legacy) can be toggled via
extensions_config. CUSTOM skills default to enabled, but users may
disable them temporarily without deleting.
"""
skills_root = tmp_path / "skills"
skills_root.mkdir()
from deerflow.config.paths import Paths
monkeypatch.setattr("deerflow.config.paths.get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
alice_storage = UserScopedSkillStorage("alice", host_path=str(skills_root))
alice_custom = _user_custom_dir(tmp_path, "alice")
alice_dir = alice_custom / "alice-custom-skill"
alice_dir.mkdir(parents=True, exist_ok=True)
(alice_dir / "SKILL.md").write_text(_skill_content("alice-custom-skill"), encoding="utf-8")
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),
)
async def _noop_async(user_id: str) -> None:
pass
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: alice_storage)
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "alice")
monkeypatch.setattr("app.gateway.routers.skills.reload_extensions_config", lambda: None)
monkeypatch.setattr("app.gateway.routers.skills.refresh_user_skills_system_prompt_cache_async", _noop_async)
app = _make_test_app(config)
with TestClient(app) as client:
response = client.put("/api/skills/alice-custom-skill", json={"enabled": False})
assert response.status_code == 200
assert response.json()["enabled"] is False