deer-flow/backend/tests/test_skills_custom_router.py
AochenShen99 658c39ccf7
Some checks failed
Backend Blocking IO / backend-blocking-io (push) Waiting to run
Unit Tests / backend-unit-tests (push) Waiting to run
Frontend Unit Tests / frontend-unit-tests (push) Waiting to run
Lint Check / lint-backend (push) Waiting to run
Lint Check / lint-frontend (push) Waiting to run
Replay E2E (front-back contract) / Layer 1 — backend golden (no API key) (push) Waiting to run
Replay E2E (front-back contract) / Layer 2 — full-stack render (no API key) (push) Waiting to run
E2E Tests / e2e-tests (push) Has been cancelled
feat(skills): Add native SkillScan phase 1 for skills (#3033)
* Add phase 1 skill static scanning

* Rework SkillScan phase 1 as native scanner

* refactor(skillscan): align phase 1 with trimmed RFC contract

- SecurityFinding: 7 fields (rule_id, severity, file, line, message,
  remediation, evidence); category/analyzer derive from the rule_id
  prefix, confidence/column/fingerprint/metadata removed
- scan_archive_preflight()/scan_skill_dir() are pure functions: no
  ScanContext, no policy schema; CRITICAL-blocks is a code constant and
  skill_scan.enabled is applied by enforce_static_scan()/callers
- secret-* evidence is redacted before findings leave the scanner
- de-dup keys on (rule_id, file, line) so repeated occurrences keep
  distinct locations for agent self-correction
- cloud-metadata detection consolidated into network-cloud-metadata
- nested zip members get a one-level stdlib magic-byte peek; an
  executable member escalates package-nested-archive to CRITICAL
- install metadata sidecar removed (Phase 7 decides if it is needed)
- rule specs moved next to their analyzers; skillscan/rules/ removed
- tests updated + new anchors: redaction, dedup lines, nested-zip
  escalation, single cloud-metadata rule, bundled-skill zero-CRITICAL

* fix(skillscan): tighten reverse-shell/secret/archive scan rules from review

Address PR #3033 review feedback on the native SkillScan analyzers:

- Reverse-shell false positives: split shell detection by signal strength
  (/dev/tcp/, nc -e stay CRITICAL; bash -i, mkfifo -> new HIGH
  shell-reverse-shell-heuristic, warn->LLM). The Python check is now
  AST-anchored on real socket.socket/os.dup2/subprocess call sites instead
  of raw-text substring matching, so prose/docstrings no longer hard-block.
- Secret evidence: _redact_secret_evidence returns [redacted] with no secret
  bytes (was value[:6], which leaked 2 real token bytes past the prefix).
- Archive DoS: cap outer archive member count (_MAX_ARCHIVE_MEMBERS=4096);
  scan_archive_preflight early-aborts with a package-too-many-members CRITICAL
  finding (routes through the existing blocked->400 fail-closed path).
- shell-destructive-command: broaden the rm -rf matcher to sensitive system
  roots (/home, /usr, /*, --no-preserve-root /) while leaving safe subpaths
  unflagged.
- Dead code: collapse _decode_text_for_analysis to a single decode path and
  drop the unused _TEXT_SUFFIXES set and _has_text_shebang helper.
- local_skill_storage: document why the host_path branch keeps app_config
  possibly-None (lazy kill-switch resolution; avoids eager get_app_config in
  config-free environments such as CI).

Tests: new negative/positive coverage in test_skillscan_native.py. Full
backend suite 6616 passed, 26 skipped.
2026-07-07 21:44:28 +08:00

1021 lines
48 KiB
Python

import errno
import json
import stat
import zipfile
from io import BytesIO
from pathlib import Path
from types import SimpleNamespace
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_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_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_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.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=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.get_extensions_config", lambda: __import__("deerflow.config.extensions_config", fromlist=["ExtensionsConfig"]).ExtensionsConfig.from_file(config_path))
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
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.get_extensions_config", lambda: SimpleNamespace(mcp_servers={}, skills={}))
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.get_extensions_config", lambda: SimpleNamespace(mcp_servers={}, skills={}))
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