deer-flow/backend/tests/test_three_way_skills_mount_e2e.py
Hyeonsang Cho 67d30f84ac
fix(sandbox): stop pinning a stale agent skill view once host bash is allowed (#6344)
* fix(sandbox): stop pinning a stale agent skill view once host bash is allowed

A restricted Agent run leaves a thread skill view behind. SandboxMiddleware
refreshes that view only while the provider can enforce skill isolation;
with host bash allowed LocalSandboxProvider cannot, so the middleware skips
it and restricted runs are rejected. The provider still mounted the view
whenever it existed, so later unrestricted runs on the thread kept the old
allowlist and could not read other enabled or newly added skills.

Look up the thread view only while isolation is supported, so those runs
get the shared projection the middleware expects. The view stays on disk
and is enforced again once host bash is turned off.

* docs(changelog): reference #6344 for the stale skill view fix

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-06 10:03:56 +08:00

718 lines
36 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 _host_path_helpers import posix_path
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"
_LOCAL_HOST_BASH_ALLOWED = "deerflow.sandbox.local.local_sandbox_provider.is_host_bash_allowed"
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,
),
# The provider enforces a thread view only while host bash is off.
patch(_LOCAL_HOST_BASH_ALLOWED, return_value=False),
):
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,
),
# The provider enforces a thread view only while host bash is off.
patch(_LOCAL_HOST_BASH_ALLOWED, return_value=False),
):
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 == []
def test_local_host_bash_run_does_not_inherit_a_stale_agent_view(self, tmp_path):
"""A restricted run leaves a thread view behind. Once host bash is
allowed, SandboxMiddleware no longer refreshes that view, so the
provider must not mount it: later unrestricted runs on the thread would
otherwise keep the old allowlist and never see newly added skills."""
from deerflow.sandbox.middleware import SandboxMiddleware
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)
provider = LocalSandboxProvider()
def run(available_skills, *, host_bash: bool):
with (
patch(_LOCAL_HOST_BASH_ALLOWED, return_value=host_bash),
patch("deerflow.sandbox.middleware.get_sandbox_provider", return_value=provider),
patch("deerflow.skills.storage.get_or_new_user_skill_storage", return_value=storage),
):
SandboxMiddleware(available_skills=available_skills)._prepare_agent_skill_projection("thread-switch", user_id="user-1")
sandbox = provider.get(provider.acquire("thread-switch", user_id="user-1"))
assert sandbox is not None
return sandbox
def readable(sandbox, name: str) -> bool:
try:
sandbox.read_file(f"/mnt/skills/public/{name}/SKILL.md")
except FileNotFoundError:
return False
return True
restricted = run({"allowed-skill"}, host_bash=False)
assert (readable(restricted, "allowed-skill"), readable(restricted, "excluded-skill")) == (True, False)
_write_skill(skills_root / "public", "added-skill", "ADDED_MARKER")
unrestricted = run(None, host_bash=True)
assert all(readable(unrestricted, name) for name in ("allowed-skill", "excluded-skill", "added-skill"))
assert "/mnt/skills" not in {mapping.container_path for mapping in unrestricted.path_mappings}
# The view was left in place, so the allowlist is enforced again
# as soon as host bash is turned back off.
restricted_again = run({"allowed-skill"}, host_bash=False)
assert [readable(restricted_again, name) for name in ("allowed-skill", "excluded-skill", "added-skill")] == [True, False, False]
# ── 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 posix_path(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 posix_path(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"
)