mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-09 16:08:31 +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>
80 lines
3.2 KiB
Python
80 lines
3.2 KiB
Python
"""Tests for Gateway internal auth token handling."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
|
|
|
|
def test_internal_auth_uses_shared_env_token(monkeypatch):
|
|
import app.gateway.internal_auth as internal_auth
|
|
|
|
monkeypatch.setenv("DEER_FLOW_INTERNAL_AUTH_TOKEN", "shared-token")
|
|
reloaded = importlib.reload(internal_auth)
|
|
try:
|
|
headers = reloaded.create_internal_auth_headers()
|
|
|
|
assert headers[reloaded.INTERNAL_AUTH_HEADER_NAME] == "shared-token"
|
|
assert reloaded.is_valid_internal_auth_token("shared-token") is True
|
|
assert reloaded.is_valid_internal_auth_token("other-token") is False
|
|
finally:
|
|
monkeypatch.delenv("DEER_FLOW_INTERNAL_AUTH_TOKEN", raising=False)
|
|
importlib.reload(reloaded)
|
|
|
|
|
|
def test_internal_auth_generates_process_local_fallback(monkeypatch):
|
|
import app.gateway.internal_auth as internal_auth
|
|
|
|
monkeypatch.delenv("DEER_FLOW_INTERNAL_AUTH_TOKEN", raising=False)
|
|
reloaded = importlib.reload(internal_auth)
|
|
try:
|
|
token = reloaded.create_internal_auth_headers()[reloaded.INTERNAL_AUTH_HEADER_NAME]
|
|
|
|
assert token
|
|
assert reloaded.is_valid_internal_auth_token(token) is True
|
|
finally:
|
|
importlib.reload(reloaded)
|
|
|
|
|
|
def test_internal_auth_headers_can_carry_owner_user_id(monkeypatch):
|
|
import app.gateway.internal_auth as internal_auth
|
|
|
|
monkeypatch.setenv("DEER_FLOW_INTERNAL_AUTH_TOKEN", "shared-token")
|
|
reloaded = importlib.reload(internal_auth)
|
|
try:
|
|
headers = reloaded.create_internal_auth_headers(owner_user_id="owner-1")
|
|
|
|
assert headers[reloaded.INTERNAL_AUTH_HEADER_NAME] == "shared-token"
|
|
assert headers[reloaded.INTERNAL_OWNER_USER_ID_HEADER_NAME] == "owner-1"
|
|
finally:
|
|
monkeypatch.delenv("DEER_FLOW_INTERNAL_AUTH_TOKEN", raising=False)
|
|
importlib.reload(reloaded)
|
|
|
|
|
|
def test_get_internal_user_normalises_unsafe_owner_user_id():
|
|
"""P2-3: X-DeerFlow-Owner-User-Id is at the trust boundary, so the
|
|
synthetic internal user must use a path-safe id. ``make_safe_user_id``
|
|
is lossy but deterministic; two distinct raw inputs never collide.
|
|
"""
|
|
import app.gateway.internal_auth as internal_auth
|
|
from deerflow.config.paths import make_safe_user_id
|
|
|
|
# Path-traversal-style payloads must be normalised away.
|
|
user_a = internal_auth.get_internal_user(owner_user_id="ou_abc/../../etc/passwd")
|
|
user_b = internal_auth.get_internal_user(owner_user_id="ou_abc/../../etc/passwd")
|
|
assert user_a.id == user_b.id
|
|
assert "/" not in user_a.id
|
|
assert ".." not in user_a.id
|
|
|
|
# Negative chat ids and unsafe punctuation must be normalised.
|
|
user_neg = internal_auth.get_internal_user(owner_user_id="-1001234567890:alice")
|
|
assert user_neg.id == make_safe_user_id("-1001234567890:alice")
|
|
assert ":" not in user_neg.id
|
|
assert user_neg.system_role == "internal"
|
|
|
|
# Already-safe ids pass through unchanged.
|
|
user_safe = internal_auth.get_internal_user(owner_user_id="alice_42")
|
|
assert user_safe.id == "alice_42"
|
|
|
|
# Empty / None falls back to default.
|
|
assert internal_auth.get_internal_user().id == "default"
|
|
assert internal_auth.get_internal_user(owner_user_id="").id == "default"
|