deer-flow/backend/tests/test_three_way_skills_mount_e2e.py
Aari 317577e285
fix: enforce custom agent skill allowlists in sandboxes (#5077)
* fix: enforce agent skill allowlists in sandboxes

* fix: guard E2B skill projection resets

* fix: preserve agent skill isolation across delegation

* fix: close sandbox skill isolation bypasses

* fix(sandbox): close skill isolation review gaps

* fix(sandbox): harden skill isolation lifecycle
2026-08-31 14:35:08 +08:00

657 lines
32 KiB
Python

"""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"
)