mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-10 00:18:36 +00:00
* 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>
334 lines
12 KiB
Python
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
|