deer-flow/backend/tests/test_skill_storage_lifecycle.py
Zhipeng Zheng 53a80d3ad1
feat(skills): per-user custom skill isolation with sandbox mounting (#3889)
* feat(skills): per-user skill isolation (#2905)

Implement user-scoped skill storage that isolates custom skills between
users while sharing public skills globally.

Key changes:
- Add UserScopedSkillStorage class for per-user custom skill directories
- Introduce get_or_new_user_skill_storage() factory with user_id context
- Auth middleware sets effective_user_id for request-scoped storage
- Agent/prompt/middleware now use user-scoped storage and prompt cache
- Sandbox mounts user-scoped skill directories for search/read tools
- Add validate_skill_file_path() to SkillStorage for path security
- Migration script supports --all-users bulk migration
- Frontend: add editable field to Skill type, error check in enableSkill
- All skill categories can be toggled (custom skills default to enabled)
- Update skill-creator SKILL.md with isolation-aware instructions

Tests:
- Add test_user_scoped_skill_storage.py (new)
- Update all existing skill tests for user-scoped storage
- Update sandbox, client, and router tests

* fix(skills): address second-round PR review feedback (#3889)

- P1-1: restrict legacy skill mount to users without custom skills
- P1-2: fail-closed for _is_disabled_skill_path (OSError → return True)
- P2-1: AND-merge global extensions_config skill disabled state
- P2-2: atomic write for _skill_states.json (mkstemp + replace)
- P2-3: normalize X-DeerFlow-Owner-User-Id in trusted boundary
- P2-4: LRU-bounded _enabled_skills_by_config_cache (OrderedDict, maxsize=256)
- P2-5: clear global prompt cache on PUBLIC skill toggle
- P2-6: invalidate skill caches on client.update_skill

* fix(tests): correct tool policy test after merge

* fix(skills): use DEFAULT_SKILLS_CONTAINER_PATH in UserScopedSkillStorage

The "/mnt/skills" literal in UserScopedSkillStorage.__init__ triggers
test_skill_container_path_defaults::test_mnt_skills_literal_is_owned_by_skill_constants_module
on CI. Migrate the default to the existing deerflow.constants constant,
matching the pattern already used by LocalSkillStorage, SkillStorage, and
the durable/tool_error middlewares.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-04 13:54:04 +08:00

334 lines
12 KiB
Python

"""Concurrency regression tests for the skill storage singleton lifecycle.
These guard the unsynchronized check-then-create in ``get_or_new_skill_storage``
and ``get_or_new_user_skill_storage``, and the unlocked ``reset_skill_storage``:
before the lock was added, concurrent cold-start callers could each construct a
separate ``SkillStorage`` and overwrite the global, and a ``reset_skill_storage``
racing a get could hand a caller ``None``.
This mirrors ``test_sandbox_provider_lifecycle.py`` — the sibling singleton that
``skills/storage/__init__.py`` documents itself as patterned after — adapted to
the fact that ``SkillStorage`` has no teardown hook, so the fix constructs the
singleton *inside* the lock (like ``get_memory_storage``) and never builds an
orphan to clean up.
Each test resets the process-global singleton on entry and in a ``finally`` so
tests never leak storage into one another.
"""
import threading
import time
from pathlib import Path
import deerflow.skills.storage as skill_storage
from deerflow.config.paths import Paths
from deerflow.skills.storage import SkillStorage
class SlowSkillStorage(SkillStorage):
"""Storage whose constructor is slow, to widen the check-then-create gap."""
instances_created = 0
instances_lock = threading.Lock()
def __init__(self, **kwargs) -> None:
super().__init__(container_path=kwargs.get("container_path", "/mnt/skills"))
time.sleep(0.05)
with self.instances_lock:
type(self).instances_created += 1
def get_skills_root_path(self) -> Path:
return Path("/tmp/skills")
def _iter_skill_files(self):
return []
def read_custom_skill(self, name: str) -> str:
return ""
def write_custom_skill(self, name: str, relative_path: str, content: str) -> None:
pass
async def ainstall_skill_from_archive(self, archive_path) -> dict:
return {}
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None:
pass
def custom_skill_exists(self, name: str) -> bool:
return False
def public_skill_exists(self, name: str) -> bool:
return False
def append_history(self, name: str, record: dict) -> None:
pass
def read_history(self, name: str) -> list[dict]:
return []
class _SkillsConfig:
use = "SlowSkillStorage"
container_path = "/mnt/skills"
def get_skills_path(self) -> Path:
return Path("/tmp/skills")
class _AppConfig:
skills = _SkillsConfig()
# A single, stable AppConfig identity: the singleton keys its cache on the
# identity of the object returned by get_app_config(), so all threads must see
# the same instance for the singleton path to engage.
_APP_CONFIG = _AppConfig()
def _patch_storage_resolution(monkeypatch, cls=SlowSkillStorage) -> None:
monkeypatch.setattr("deerflow.config.get_app_config", lambda: _APP_CONFIG)
monkeypatch.setattr("deerflow.reflection.resolve_class", lambda *args, **kwargs: cls)
def test_get_or_new_skill_storage_constructs_one_singleton_under_concurrent_access(monkeypatch):
"""Eight threads racing on a cold start must construct exactly one instance.
The fix builds the singleton inside the lock, so unlike the sandbox provider
(which builds outside the lock and tears orphans down) no second instance is
ever constructed — every caller observes the one that was built.
"""
skill_storage.reset_skill_storage()
SlowSkillStorage.instances_created = 0
_patch_storage_resolution(monkeypatch)
n_threads = 8
storages: list[SkillStorage] = []
storages_lock = threading.Lock()
# Barrier makes all threads enter get_or_new_skill_storage() at the same
# moment, so the race is triggered deterministically rather than by chance.
barrier = threading.Barrier(n_threads)
def get_storage() -> None:
barrier.wait()
storage = skill_storage.get_or_new_skill_storage()
with storages_lock:
storages.append(storage)
threads = [threading.Thread(target=get_storage) for _ in range(n_threads)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
try:
assert len({id(storage) for storage in storages}) == 1
assert SlowSkillStorage.instances_created == 1
installed = skill_storage.get_or_new_skill_storage()
assert all(storage is installed for storage in storages)
finally:
skill_storage.reset_skill_storage()
def test_reset_racing_get_of_live_singleton_never_returns_none(monkeypatch):
"""A reset racing concurrent gets of a live singleton must never hand back
``None``: every returned value is a real storage instance.
The singleton is populated before the barrier so the resetter nulls a live
instance while the getters read it — the interleaving that the unlocked
check-then-return path could turn into a ``None`` return.
"""
skill_storage.reset_skill_storage()
SlowSkillStorage.instances_created = 0
_patch_storage_resolution(monkeypatch)
# Populate the singleton up front so the reset races a live instance.
skill_storage.get_or_new_skill_storage()
results: list[object] = []
results_lock = threading.Lock()
barrier = threading.Barrier(5)
def getter() -> None:
barrier.wait()
storage = skill_storage.get_or_new_skill_storage()
with results_lock:
results.append(storage)
def resetter() -> None:
barrier.wait()
skill_storage.reset_skill_storage()
threads = [threading.Thread(target=getter) for _ in range(4)]
threads.append(threading.Thread(target=resetter))
for thread in threads:
thread.start()
for thread in threads:
thread.join()
try:
assert results, "every getter recorded a result"
assert all(isinstance(storage, SlowSkillStorage) for storage in results)
finally:
skill_storage.reset_skill_storage()
# ---------------------------------------------------------------------------
# Per-user skill storage lifecycle
# ---------------------------------------------------------------------------
class SlowUserSkillStorage(SkillStorage):
"""Storage whose constructor is slow, to widen the check-then-create gap.
Signature mirrors ``UserScopedSkillStorage.__init__(user_id, **kwargs)``
so the module-level factory can call it as ``cls(user_id, **kwargs)``.
"""
instances_created = 0
instances_lock = threading.Lock()
def __init__(self, user_id: str = "default", **kwargs) -> None:
super().__init__(container_path=kwargs.get("container_path", "/mnt/skills"))
self._user_id = user_id
time.sleep(0.05)
with self.instances_lock:
type(self).instances_created += 1
def get_skills_root_path(self) -> Path:
return Path("/tmp/skills")
def _iter_skill_files(self):
return []
def read_custom_skill(self, name: str) -> str:
return ""
def write_custom_skill(self, name: str, relative_path: str, content: str) -> None:
pass
async def ainstall_skill_from_archive(self, archive_path) -> dict:
return {}
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None:
pass
def custom_skill_exists(self, name: str) -> bool:
return False
def public_skill_exists(self, name: str) -> bool:
return False
def append_history(self, name: str, record: dict) -> None:
pass
def read_history(self, name: str) -> list[dict]:
return []
def _patch_user_storage_resolution(monkeypatch, cls=SlowUserSkillStorage) -> None:
monkeypatch.setattr("deerflow.config.get_app_config", lambda: _APP_CONFIG)
monkeypatch.setattr("deerflow.config.paths.get_paths", lambda: Paths(base_dir=Path("/tmp")))
monkeypatch.setattr("deerflow.config.paths._paths", None)
# get_or_new_user_skill_storage calls UserScopedSkillStorage(user_id, **kwargs)
# directly — not via resolve_class. Patch the class reference in the module.
monkeypatch.setattr("deerflow.skills.storage.UserScopedSkillStorage", cls)
def test_get_or_new_user_skill_storage_constructs_one_per_user_under_concurrent_access(monkeypatch):
"""Eight threads racing on a cold start for the same user_id must construct
exactly one instance per user. Different user_ids get different instances.
"""
skill_storage.reset_skill_storage()
SlowUserSkillStorage.instances_created = 0
_patch_user_storage_resolution(monkeypatch)
n_threads = 8
storages: list[SkillStorage] = []
storages_lock = threading.Lock()
barrier = threading.Barrier(n_threads)
def get_storage() -> None:
barrier.wait()
storage = skill_storage.get_or_new_user_skill_storage("alice")
with storages_lock:
storages.append(storage)
threads = [threading.Thread(target=get_storage) for _ in range(n_threads)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
try:
assert len({id(storage) for storage in storages}) == 1
assert SlowUserSkillStorage.instances_created == 1
finally:
skill_storage.reset_skill_storage()
def test_different_users_get_different_storages(monkeypatch):
"""Concurrent calls for different user_ids must produce distinct instances."""
skill_storage.reset_skill_storage()
SlowUserSkillStorage.instances_created = 0
_patch_user_storage_resolution(monkeypatch)
s_alice = skill_storage.get_or_new_user_skill_storage("alice")
s_bob = skill_storage.get_or_new_user_skill_storage("bob")
try:
assert s_alice is not s_bob
assert s_alice._user_id == "alice"
assert s_bob._user_id == "bob"
finally:
skill_storage.reset_skill_storage()
def test_reset_user_skill_storage_only_clears_target_user(monkeypatch):
"""Resetting alice's storage must not invalidate bob's."""
skill_storage.reset_skill_storage()
SlowUserSkillStorage.instances_created = 0
_patch_user_storage_resolution(monkeypatch)
s_alice = skill_storage.get_or_new_user_skill_storage("alice")
s_bob = skill_storage.get_or_new_user_skill_storage("bob")
skill_storage.reset_user_skill_storage("alice")
# Alice's storage is gone
s_alice_new = skill_storage.get_or_new_user_skill_storage("alice")
assert s_alice_new is not s_alice
# Bob's is still cached
s_bob_cached = skill_storage.get_or_new_user_skill_storage("bob")
assert s_bob_cached is s_bob
def test_reset_user_skill_storage_normalises_cache_key(monkeypatch):
"""reset_user_skill_storage must normalise the user_id so that the cache
key matches the one used by get_or_new_user_skill_storage.
Without normalisation, an IM-style user ID like ``feishu:ou_xxx`` would
fail to clear its stale cache entry because ``get_or_new`` stores by
``make_safe_user_id(user_id)`` but ``reset`` would try to pop by the raw
ID — a silent cache-invalidation failure.
"""
from deerflow.config.paths import make_safe_user_id
skill_storage.reset_skill_storage()
SlowUserSkillStorage.instances_created = 0
_patch_user_storage_resolution(monkeypatch)
raw_id = "feishu:ou_abc123"
safe_id = make_safe_user_id(raw_id)
# Create storage via the normal flow (which normalises the key)
s = skill_storage.get_or_new_user_skill_storage(raw_id)
assert s._user_id == safe_id
# Reset using the raw ID — must successfully clear the cache
skill_storage.reset_user_skill_storage(raw_id)
# A new storage should be created (old one was evicted)
s_new = skill_storage.get_or_new_user_skill_storage(raw_id)
assert s_new is not s