"""End-to-end tests for enabled-only skill mounts across sandbox providers. Verifies that public, per-user custom, legacy global-custom, and managed integration skills all resolve to correct container paths that the sandbox providers actually mount — covering ``LocalSandboxProvider`` and ``AioSandboxProvider`` (DooD / local-backend path). Includes a full-pipeline test that exercises the actual path the model takes: ``UserScopedSkillStorage`` category assignment → ``Skill.get_container_file_path()`` → ``sandbox.read_file()``. """ import importlib from pathlib import Path from types import SimpleNamespace from unittest.mock import patch import pytest from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig from deerflow.config.paths import Paths from deerflow.sandbox.local.local_sandbox import PathMapping from deerflow.sandbox.local.local_sandbox_provider import LocalSandboxProvider from deerflow.sandbox.tools import read_file_tool from deerflow.skills.projection import ( ensure_thread_skill_projection, rebuild_skill_projections, ) from deerflow.skills.storage import reset_user_skill_storage from deerflow.skills.storage.user_scoped_skill_storage import UserScopedSkillStorage from deerflow.skills.types import SKILL_MD_FILE, Skill, SkillCategory _AIO_MODULE = "deerflow.community.aio_sandbox.aio_sandbox_provider" _AIO_GET_CONFIG = f"{_AIO_MODULE}.get_app_config" def _write_skill(base: Path, name: str, description: str = "test skill") -> Path: skill_dir = base / name skill_dir.mkdir(parents=True, exist_ok=True) skill_md = skill_dir / SKILL_MD_FILE skill_md.write_text( f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n", encoding="utf-8", ) return skill_md def _build_config(skills_root: Path): from deerflow.config.sandbox_config import SandboxConfig return SimpleNamespace( skills=SimpleNamespace( container_path="/mnt/skills", get_skills_path=lambda sk=skills_root: sk, use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage", ), sandbox=SandboxConfig( use="deerflow.sandbox.local:LocalSandboxProvider", mounts=[], ), ) def _local_mounts(provider: LocalSandboxProvider, thread_id: str, user_id: str) -> dict[str, PathMapping]: mappings = list(provider._path_mappings) + provider._build_thread_path_mappings(thread_id, user_id=user_id) return {m.container_path: m for m in mappings} @pytest.fixture def skills_fs(tmp_path: Path) -> dict: root = tmp_path / "skills" pub = root / "public" legacy = root / "custom" users_dir = tmp_path / "users" user_custom = users_dir / "user-1" / "skills" / "custom" integrations = tmp_path / "integrations" / "skills" / "demo-provider" return { "root": root, "public": pub, "legacy_global": legacy, "user_custom": user_custom, "users_dir": users_dir, "pub_skill": _write_skill(pub, "pub-skill", "public skill"), "legacy_skill": _write_skill(legacy, "leg-skill", "legacy skill"), "user_skill": _write_skill(user_custom, "usr-skill", "user custom skill"), "integration_skill": _write_skill(integrations, "int-skill", "integration skill"), } @pytest.fixture def aio_mod(): return importlib.import_module(_AIO_MODULE) class TestThreeWayMountEndToEnd: # ── LocalSandboxProvider: mount structure ────────────────────────── def test_local_public_skill_mounted(self, skills_fs): cfg = _build_config(skills_fs["root"]) paths = Paths(base_dir=skills_fs["users_dir"].parent) with patch("deerflow.config.get_app_config", return_value=cfg), patch("deerflow.config.paths.get_paths", return_value=paths): provider = LocalSandboxProvider() idx = _local_mounts(provider, "thread-1", user_id="user-1") assert "/mnt/skills/public" in idx assert idx["/mnt/skills/public"].read_only is True assert Path(idx["/mnt/skills/public"].local_path) == paths.public_skills_view_dir def test_local_acquire_recovers_public_mount_after_initial_projection_failure(self, skills_fs): cfg = _build_config(skills_fs["root"]) paths = Paths(base_dir=skills_fs["users_dir"].parent) projection = SimpleNamespace( public=paths.public_skills_view_dir, custom=paths.user_custom_skills_view_dir("user-1"), legacy=paths.user_legacy_skills_view_dir("user-1"), integrations=paths.user_integration_skills_view_dir("user-1"), ) for root in (projection.public, projection.custom, projection.legacy, projection.integrations): root.mkdir(parents=True, exist_ok=True) with ( patch("deerflow.config.get_app_config", return_value=cfg), patch("deerflow.config.paths.get_paths", return_value=paths), patch.object(LocalSandboxProvider, "_ensure_skills_projection", side_effect=[OSError("transient"), projection]), ): provider = LocalSandboxProvider() sandbox_id = provider.acquire("thread-1", user_id="user-1") sandbox = provider.get(sandbox_id) mappings = {mapping.container_path: mapping for mapping in sandbox.path_mappings} assert Path(mappings["/mnt/skills/public"].local_path) == projection.public assert mappings["/mnt/skills/public"].read_only is True def test_local_per_user_custom_skill_mounted(self, skills_fs): cfg = _build_config(skills_fs["root"]) paths = Paths(base_dir=skills_fs["users_dir"].parent) with patch("deerflow.config.get_app_config", return_value=cfg), patch("deerflow.config.paths.get_paths", return_value=paths): provider = LocalSandboxProvider() idx = _local_mounts(provider, "thread-1", user_id="user-1") assert "/mnt/skills/custom" in idx assert Path(idx["/mnt/skills/custom"].local_path) == paths.user_custom_skills_view_dir("user-1") def test_local_managed_integrations_use_per_user_projection(self, skills_fs): cfg = _build_config(skills_fs["root"]) paths = Paths(base_dir=skills_fs["users_dir"].parent) with patch("deerflow.config.get_app_config", return_value=cfg), patch("deerflow.config.paths.get_paths", return_value=paths): provider = LocalSandboxProvider() idx = _local_mounts(provider, "thread-1", user_id="user-1") assert "/mnt/skills/integrations" in idx assert Path(idx["/mnt/skills/integrations"].local_path) == paths.user_integration_skills_view_dir("user-1") assert idx["/mnt/skills/integrations"].read_only is True def test_local_legacy_mounted_for_user_without_custom(self, skills_fs): cfg = _build_config(skills_fs["root"]) paths = Paths(base_dir=skills_fs["users_dir"].parent) with patch("deerflow.config.get_app_config", return_value=cfg), patch("deerflow.config.paths.get_paths", return_value=paths): provider = LocalSandboxProvider() idx = _local_mounts(provider, "thread-1", user_id="noob") assert "/mnt/skills/legacy" in idx assert Path(idx["/mnt/skills/legacy"].local_path) == paths.user_legacy_skills_view_dir("noob") def test_local_legacy_not_mounted_when_user_has_custom(self, skills_fs): cfg = _build_config(skills_fs["root"]) paths = Paths(base_dir=skills_fs["users_dir"].parent) with patch("deerflow.config.get_app_config", return_value=cfg), patch("deerflow.config.paths.get_paths", return_value=paths): provider = LocalSandboxProvider() idx = _local_mounts(provider, "thread-1", user_id="user-1") assert "/mnt/skills/legacy" in idx assert list(Path(idx["/mnt/skills/legacy"].local_path).iterdir()) == [] def test_local_legacy_still_mounted_when_user_has_only_non_skill_subdir(self, skills_fs): (skills_fs["users_dir"] / "ghost" / "skills" / "custom" / "dangling-dir").mkdir(parents=True, exist_ok=True) cfg = _build_config(skills_fs["root"]) paths = Paths(base_dir=skills_fs["users_dir"].parent) with patch("deerflow.config.get_app_config", return_value=cfg), patch("deerflow.config.paths.get_paths", return_value=paths): provider = LocalSandboxProvider() idx = _local_mounts(provider, "thread-1", user_id="ghost") assert "/mnt/skills/legacy" in idx # ── LocalSandboxProvider: read_file on container paths ───────────── def test_local_read_file_resolves_public_and_custom(self, skills_fs): cfg = _build_config(skills_fs["root"]) paths = Paths(base_dir=skills_fs["users_dir"].parent) with patch("deerflow.config.get_app_config", return_value=cfg), patch("deerflow.config.paths.get_paths", return_value=paths): provider = LocalSandboxProvider() sid = provider.acquire("thread-1", user_id="user-1") sandbox = provider.get(sid) assert "pub-skill" in sandbox.read_file("/mnt/skills/public/pub-skill/SKILL.md") assert "usr-skill" in sandbox.read_file("/mnt/skills/custom/usr-skill/SKILL.md") def test_local_read_file_resolves_legacy_skill(self, skills_fs): cfg = _build_config(skills_fs["root"]) paths = Paths(base_dir=skills_fs["users_dir"].parent) with patch("deerflow.config.get_app_config", return_value=cfg), patch("deerflow.config.paths.get_paths", return_value=paths): provider = LocalSandboxProvider() sid = provider.acquire("thread-1", user_id="noob") sandbox = provider.get(sid) assert "leg-skill" in sandbox.read_file("/mnt/skills/legacy/leg-skill/SKILL.md") def test_read_file_tool_uses_enabled_projection_for_every_skill_category(self, skills_fs, monkeypatch): """Exercise the model-visible tool through real provider mappings. The runtime identity deliberately differs from the ambient ContextVar. Acquire-time ``PathMapping`` must remain authoritative for both full and ranged reads; the tool layer must never reconstruct a raw host path. """ from deerflow.runtime.user_context import reset_current_user, set_current_user cfg = _build_config(skills_fs["root"]) paths = Paths(base_dir=skills_fs["users_dir"].parent) extensions = ExtensionsConfig() reset_user_skill_storage() with ( patch("deerflow.config.get_app_config", return_value=cfg), patch("deerflow.config.paths.get_paths", return_value=paths), patch("deerflow.config.extensions_config.ExtensionsConfig.from_file", return_value=extensions), patch("deerflow.config.extensions_config.get_extensions_config", return_value=extensions), ): provider = LocalSandboxProvider() sandbox_ids = { "user-1": provider.acquire("thread-user", user_id="user-1"), "noob": provider.acquire("thread-noob", user_id="noob"), } def _sandbox_for(runtime): return provider.get(runtime.state["sandbox"]["sandbox_id"]) def _must_not_pre_resolve(_path: str) -> str: raise AssertionError("skill paths must stay virtual until the sandbox provider") monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", _sandbox_for) monkeypatch.setattr("deerflow.sandbox.tools._resolve_skills_path", _must_not_pre_resolve) token = set_current_user(SimpleNamespace(id="wrong-context-user")) try: cases = [ ("user-1", "thread-user", "/mnt/skills/public/pub-skill/SKILL.md", "pub-skill"), ("user-1", "thread-user", "/mnt/skills/custom/usr-skill/SKILL.md", "usr-skill"), ("noob", "thread-noob", "/mnt/skills/legacy/leg-skill/SKILL.md", "leg-skill"), ("user-1", "thread-user", "/mnt/skills/integrations/demo-provider/int-skill/SKILL.md", "int-skill"), ] for user_id, thread_id, virtual_path, skill_name in cases: runtime = SimpleNamespace( state={ "sandbox": {"sandbox_id": sandbox_ids[user_id]}, "thread_data": { "workspace_path": str(paths.sandbox_work_dir(thread_id, user_id=user_id)), "uploads_path": str(paths.sandbox_uploads_dir(thread_id, user_id=user_id)), "outputs_path": str(paths.sandbox_outputs_dir(thread_id, user_id=user_id)), }, }, context={"thread_id": thread_id, "user_id": user_id}, ) full = read_file_tool.func(runtime=runtime, description="read skill", path=virtual_path) ranged = read_file_tool.func( runtime=runtime, description="read skill name", path=virtual_path, start_line=2, end_line=2, ) assert f"# {skill_name}" in full assert ranged == f"name: {skill_name}" finally: reset_current_user(token) reset_user_skill_storage() # ── Full pipeline: registry → container path → sandbox read ──────── def test_registry_to_sandbox_full_pipeline(self, skills_fs): """Model's exact path: storage category → get_container_file_path → sandbox.read_file.""" from deerflow.skills.storage.user_scoped_skill_storage import UserScopedSkillStorage cfg = _build_config(skills_fs["root"]) paths = Paths(base_dir=skills_fs["users_dir"].parent) with patch("deerflow.config.get_app_config", return_value=cfg), patch("deerflow.config.paths.get_paths", return_value=paths): provider = LocalSandboxProvider() sid_user = provider.acquire("t1", user_id="user-1") sid_noob = provider.acquire("t2", user_id="noob") sandbox_user = provider.get(sid_user) sandbox_noob = provider.get(sid_noob) # user-1 storage: sees public + custom, no legacy with patch("deerflow.config.paths.get_paths", return_value=paths): storage = UserScopedSkillStorage(user_id="user-1", host_path=str(skills_fs["root"])) skills = list(storage._iter_skill_files()) by_name = {sf.parent.name: (cat, sf) for cat, _root, sf in skills} # public assert "pub-skill" in by_name cat, _ = by_name["pub-skill"] assert cat == SkillCategory.PUBLIC s = Skill(name="pub-skill", description="p", license=None, skill_dir=skills_fs["public"] / "pub-skill", skill_file=skills_fs["pub_skill"], relative_path=Path("pub-skill"), category=cat) cp = s.get_container_file_path("/mnt/skills") assert cp == "/mnt/skills/public/pub-skill/SKILL.md" assert "pub-skill" in sandbox_user.read_file(cp) # custom assert "usr-skill" in by_name cat, _ = by_name["usr-skill"] assert cat == SkillCategory.CUSTOM s = Skill(name="usr-skill", description="u", license=None, skill_dir=skills_fs["user_custom"] / "usr-skill", skill_file=skills_fs["user_skill"], relative_path=Path("usr-skill"), category=cat) cp = s.get_container_file_path("/mnt/skills") assert cp == "/mnt/skills/custom/usr-skill/SKILL.md" assert "usr-skill" in sandbox_user.read_file(cp) # noob storage: sees public + legacy (no per-user custom) with patch("deerflow.config.paths.get_paths", return_value=paths): storage = UserScopedSkillStorage(user_id="noob", host_path=str(skills_fs["root"])) skills = list(storage._iter_skill_files()) by_name = {sf.parent.name: (cat, sf) for cat, _root, sf in skills} assert "leg-skill" in by_name cat, _ = by_name["leg-skill"] assert cat == SkillCategory.LEGACY s = Skill(name="leg-skill", description="l", license=None, skill_dir=skills_fs["legacy_global"] / "leg-skill", skill_file=skills_fs["legacy_skill"], relative_path=Path("leg-skill"), category=cat) cp = s.get_container_file_path("/mnt/skills") assert cp == "/mnt/skills/legacy/leg-skill/SKILL.md" assert "leg-skill" in sandbox_noob.read_file(cp) def test_local_tools_observe_toggle_without_sandbox_recreation(self, tmp_path, monkeypatch): skills_root = tmp_path / "skills" _write_skill(skills_root / "public", "secret-skill", "SECRET_PROCEDURE") paths = Paths(base_dir=tmp_path) cfg = _build_config(skills_root) extensions = ExtensionsConfig(skills={"secret-skill": SkillStateConfig(enabled=False)}) with ( patch("deerflow.config.get_app_config", return_value=cfg), patch("deerflow.config.paths.get_paths", return_value=paths), patch("deerflow.config.extensions_config.ExtensionsConfig.from_file", return_value=extensions), patch("deerflow.config.extensions_config.get_extensions_config", return_value=extensions), ): storage = UserScopedSkillStorage("user-1", host_path=str(skills_root), app_config=cfg) rebuild_skill_projections(storage) provider = LocalSandboxProvider() sandbox_id = provider.acquire("thread-1", user_id="user-1") sandbox = provider.get(sandbox_id) assert sandbox is not None runtime = SimpleNamespace( state={ "sandbox": {"sandbox_id": sandbox_id}, "thread_data": { "workspace_path": str(paths.sandbox_work_dir("thread-1", user_id="user-1")), "uploads_path": str(paths.sandbox_uploads_dir("thread-1", user_id="user-1")), "outputs_path": str(paths.sandbox_outputs_dir("thread-1", user_id="user-1")), }, }, context={"thread_id": "thread-1", "user_id": "user-1"}, ) monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda _runtime: sandbox) virtual_path = "/mnt/skills/public/secret-skill/SKILL.md" disabled = sandbox.execute_command(f"cat {virtual_path}") assert "SECRET_PROCEDURE" not in disabled structured_disabled = read_file_tool.func(runtime=runtime, description="read disabled skill", path=virtual_path) assert "SECRET_PROCEDURE" not in structured_disabled assert "disabled" in structured_disabled.lower() assert not (paths.public_skills_view_dir / "secret-skill").exists() assert (skills_root / "public" / "secret-skill" / "SKILL.md").is_file() extensions.skills["secret-skill"] = SkillStateConfig(enabled=True) rebuild_skill_projections(storage) enabled = sandbox.execute_command(f"cat {virtual_path}") assert "SECRET_PROCEDURE" in enabled assert "SECRET_PROCEDURE" in read_file_tool.func(runtime=runtime, description="read enabled skill", path=virtual_path) assert provider.acquire("thread-1", user_id="user-1") == sandbox_id extensions.skills["secret-skill"] = SkillStateConfig(enabled=False) rebuild_skill_projections(storage) disabled_again = sandbox.execute_command(f"cat {virtual_path}") assert "SECRET_PROCEDURE" not in disabled_again structured_disabled_again = read_file_tool.func(runtime=runtime, description="read disabled skill", path=virtual_path) assert "SECRET_PROCEDURE" not in structured_disabled_again assert "disabled" in structured_disabled_again.lower() def test_local_agent_allowlist_is_enforced_by_every_filesystem_path( self, tmp_path, ): skills_root = tmp_path / "skills" _write_skill(skills_root / "public", "allowed-skill", "ALLOWED_MARKER") _write_skill(skills_root / "public", "excluded-skill", "EXCLUDED_MARKER") (skills_root / "custom").mkdir(parents=True) paths = Paths(base_dir=tmp_path) cfg = _build_config(skills_root) extensions = ExtensionsConfig() with ( patch("deerflow.config.get_app_config", return_value=cfg), patch("deerflow.config.paths.get_paths", return_value=paths), patch( "deerflow.config.extensions_config.ExtensionsConfig.from_file", return_value=extensions, ), patch( "deerflow.config.extensions_config.get_extensions_config", return_value=extensions, ), ): storage = UserScopedSkillStorage( "user-1", host_path=str(skills_root), app_config=cfg, ) projection = ensure_thread_skill_projection( storage, "thread-policy", {"allowed-skill"}, ) assert projection is not None provider = LocalSandboxProvider() sandbox_id = provider.acquire("thread-policy", user_id="user-1") sandbox = provider.get(sandbox_id) assert sandbox is not None mappings = {mapping.container_path: mapping for mapping in sandbox.path_mappings} assert set(path for path in mappings if path.startswith("/mnt/skills")) == {"/mnt/skills"} assert Path(mappings["/mnt/skills"].local_path) == projection.public.parent listing = "\n".join(sandbox.list_dir("/mnt/skills", max_depth=4)) assert "allowed-skill" in listing assert "excluded-skill" not in listing assert "EXCLUDED_MARKER" not in sandbox.execute_command("find /mnt/skills -name SKILL.md -print -exec cat {} \\;") excluded_path = "/mnt/skills/public/excluded-skill/SKILL.md" with pytest.raises(FileNotFoundError): sandbox.read_file(excluded_path) globbed, _ = sandbox.glob("/mnt/skills", "**/SKILL.md") assert any("allowed-skill" in path for path in globbed) assert all("excluded-skill" not in path for path in globbed) grepped, _ = sandbox.grep( "/mnt/skills", "EXCLUDED_MARKER", literal=True, ) assert grepped == [] absolute_read = sandbox.execute_command(f"cat {excluded_path}") relative_read = sandbox.execute_command("cd /mnt/skills/public && cat excluded-skill/SKILL.md") python_read = sandbox.execute_command("python3 -c \"from pathlib import Path; print(Path('/mnt/skills/public/excluded-skill/SKILL.md').read_text())\"") symlink_read = sandbox.execute_command("ln -s /mnt/skills/public/excluded-skill /mnt/skills/public/excluded-link && cat /mnt/skills/public/excluded-link/SKILL.md") for result in ( absolute_read, relative_read, python_read, symlink_read, ): assert "EXCLUDED_MARKER" not in result assert "Exit Code:" in result def test_local_empty_agent_allowlist_exposes_no_business_skill( self, tmp_path, ): skills_root = tmp_path / "skills" _write_skill(skills_root / "public", "public-skill", "PUBLIC_MARKER") (skills_root / "custom").mkdir(parents=True) paths = Paths(base_dir=tmp_path) cfg = _build_config(skills_root) extensions = ExtensionsConfig() with ( patch("deerflow.config.get_app_config", return_value=cfg), patch("deerflow.config.paths.get_paths", return_value=paths), patch( "deerflow.config.extensions_config.ExtensionsConfig.from_file", return_value=extensions, ), patch( "deerflow.config.extensions_config.get_extensions_config", return_value=extensions, ), ): storage = UserScopedSkillStorage( "user-1", host_path=str(skills_root), app_config=cfg, ) storage.write_custom_skill( "custom-skill", "SKILL.md", "---\nname: custom-skill\ndescription: CUSTOM_MARKER\n---\n", ) projection = ensure_thread_skill_projection( storage, "thread-empty-policy", set(), ) assert projection is not None provider = LocalSandboxProvider() sandbox_id = provider.acquire( "thread-empty-policy", user_id="user-1", ) sandbox = provider.get(sandbox_id) assert sandbox is not None listing = "\n".join(sandbox.list_dir("/mnt/skills", max_depth=4)) assert "public-skill" not in listing assert "custom-skill" not in listing shell_listing = sandbox.execute_command("ls -R /mnt/skills") assert "public-skill" not in shell_listing assert "custom-skill" not in shell_listing assert "MARKER" not in sandbox.execute_command("find /mnt/skills -name SKILL.md -print -exec cat {} \\;") with pytest.raises(FileNotFoundError): sandbox.read_file("/mnt/skills/public/public-skill/SKILL.md") globbed, _ = sandbox.glob("/mnt/skills", "**/SKILL.md") assert globbed == [] grepped, _ = sandbox.grep( "/mnt/skills", "MARKER", literal=True, ) assert grepped == [] # ── AioSandboxProvider ────────────────────────────────────────────── def test_aio_public_skill_mount(self, skills_fs, aio_mod): cfg = _build_config(skills_fs["root"]) with patch(_AIO_GET_CONFIG, return_value=cfg): mounts = aio_mod.AioSandboxProvider._get_skills_mounts(user_id="user-1") idx = {m[1]: m for m in mounts} assert "/mnt/skills/public" in idx host, _, _ = idx["/mnt/skills/public"] assert "skills_view/public" in host.replace("\\", "/") def test_aio_per_user_custom_skill_mount(self, skills_fs, aio_mod, monkeypatch): cfg = _build_config(skills_fs["root"]) paths = Paths(base_dir=skills_fs["users_dir"].parent) monkeypatch.setattr(aio_mod, "get_paths", lambda: paths) with patch(_AIO_GET_CONFIG, return_value=cfg), patch("deerflow.config.paths.get_paths", return_value=paths): mounts = aio_mod.AioSandboxProvider._get_skills_mounts(user_id="user-1") idx = {m[1]: m for m in mounts} assert "/mnt/skills/custom" in idx host, _, _ = idx["/mnt/skills/custom"] assert "users/user-1/skills_view/custom" in host.replace("\\", "/") def test_aio_legacy_mounted_for_user_without_custom(self, skills_fs, aio_mod, monkeypatch): cfg = _build_config(skills_fs["root"]) paths = Paths(base_dir=skills_fs["users_dir"].parent) monkeypatch.setattr(aio_mod, "get_paths", lambda: paths) with patch(_AIO_GET_CONFIG, return_value=cfg), patch("deerflow.config.paths.get_paths", return_value=paths): mounts = aio_mod.AioSandboxProvider._get_skills_mounts(user_id="noob") idx = {m[1]: m for m in mounts} assert "/mnt/skills/legacy" in idx def test_aio_legacy_not_mounted_when_user_has_custom(self, skills_fs, aio_mod, monkeypatch): cfg = _build_config(skills_fs["root"]) paths = Paths(base_dir=skills_fs["users_dir"].parent) monkeypatch.setattr(aio_mod, "get_paths", lambda: paths) with patch(_AIO_GET_CONFIG, return_value=cfg), patch("deerflow.config.paths.get_paths", return_value=paths): mounts = aio_mod.AioSandboxProvider._get_skills_mounts(user_id="user-1") idx = {m[1]: m for m in mounts} assert "/mnt/skills/legacy" in idx def test_aio_legacy_still_mounted_when_user_has_only_non_skill_subdir(self, skills_fs, aio_mod, monkeypatch): (skills_fs["users_dir"] / "ghost" / "skills" / "custom" / "dangling-dir").mkdir(parents=True, exist_ok=True) cfg = _build_config(skills_fs["root"]) paths = Paths(base_dir=skills_fs["users_dir"].parent) monkeypatch.setattr(aio_mod, "get_paths", lambda: paths) with patch(_AIO_GET_CONFIG, return_value=cfg), patch("deerflow.config.paths.get_paths", return_value=paths): mounts = aio_mod.AioSandboxProvider._get_skills_mounts(user_id="ghost") idx = {m[1]: m for m in mounts} assert "/mnt/skills/legacy" in idx # ── AIO → Docker --mount translation ─────────────────────────────── def test_aio_extra_mounts_translate_to_docker_bind_mounts(self, skills_fs, aio_mod, monkeypatch): """extra_mounts → _format_container_mount → correct Docker --mount args.""" from deerflow.community.aio_sandbox.local_backend import _format_container_mount cfg = _build_config(skills_fs["root"]) paths = Paths(base_dir=skills_fs["users_dir"].parent) monkeypatch.setattr(aio_mod, "get_paths", lambda: paths) with patch(_AIO_GET_CONFIG, return_value=cfg), patch("deerflow.config.paths.get_paths", return_value=paths): extra = aio_mod.AioSandboxProvider._get_extra_mounts( aio_mod.AioSandboxProvider.__new__(aio_mod.AioSandboxProvider), "thread-1", user_id="noob", ) # extra includes thread mounts + skills mounts docker_args: list[str] = [] mount_entries: dict[str, str] = {} for host, container, ro in extra: args = _format_container_mount("docker", host, container, ro) docker_args.extend(args) if args[0] == "--mount": mount_entries[container] = args[1] assert "--mount" in docker_args # Skills mounts must be present assert "/mnt/skills/public" in mount_entries assert "dst=/mnt/skills/public" in mount_entries["/mnt/skills/public"] assert "readonly" in mount_entries["/mnt/skills/public"] assert "/mnt/skills/custom" in mount_entries assert "dst=/mnt/skills/custom" in mount_entries["/mnt/skills/custom"] assert "users/noob/skills_view/custom" in mount_entries["/mnt/skills/custom"] assert "/mnt/skills/integrations" in mount_entries assert "dst=/mnt/skills/integrations" in mount_entries["/mnt/skills/integrations"] assert "users/noob/skills_view/integrations" in mount_entries["/mnt/skills/integrations"] # noob has no per-user custom → legacy is mounted assert "/mnt/skills/legacy" in mount_entries assert "dst=/mnt/skills/legacy" in mount_entries["/mnt/skills/legacy"] # ── Path alignment ────────────────────────────────────────────────── def test_skill_container_paths_match_expected_mounts(self, skills_fs): cr = "/mnt/skills" assert ( Skill( name="p", description="", license=None, skill_dir=skills_fs["public"] / "pub-skill", skill_file=skills_fs["pub_skill"], relative_path=Path("pub-skill"), category=SkillCategory.PUBLIC, ).get_container_path(cr) == "/mnt/skills/public/pub-skill" ) assert ( Skill( name="u", description="", license=None, skill_dir=skills_fs["user_custom"] / "usr-skill", skill_file=skills_fs["user_skill"], relative_path=Path("usr-skill"), category=SkillCategory.CUSTOM, ).get_container_path(cr) == "/mnt/skills/custom/usr-skill" ) assert ( Skill( name="l", description="", license=None, skill_dir=skills_fs["legacy_global"] / "leg-skill", skill_file=skills_fs["legacy_skill"], relative_path=Path("leg-skill"), category=SkillCategory.LEGACY, ).get_container_path(cr) == "/mnt/skills/legacy/leg-skill" )