deer-flow/backend/tests/test_three_way_skills_mount_e2e.py
Xinmin Zeng f2e832330e
fix(sandbox): enforce disabled skills in filesystem views (#4178)
* fix(sandbox): project enabled skills into sandbox views

* fix(skills): keep projection mutations consistent

* fix(skills): fail closed on projection errors

* fix(skills): isolate per-scope failures during boot projection rebuild

rebuild_all_skill_projections() propagated any exception from the public
rebuild or from a single user's rebuild straight out of the gateway
lifespan startup, uncaught. A single broken user directory (bad
permissions, corrupted _skill_states.json, unreadable content) would
therefore abort gateway boot for every user, not just that one -
_rebuild_*_locked already fails closed internally (clears the view and
re-raises), so the boot loop only needed to stop treating that re-raise
as fatal.

Each scope's rebuild now fails closed independently and boot continues;
a scope left empty by a boot failure self-heals on the next sandbox
acquire via ensure_skill_projections().

Also patches deerflow.skills.projection.rebuild_all_skill_projections in
the memory-flush lifespan test fixture, matching the two sibling
fixtures in the same file — this call is now on the lifespan startup
path and the fixture's minimal SimpleNamespace config predates it.

* test(skills): update authz test for the projection-aware public toggle

_persist_shared_skill_state (introduced earlier in this branch) reads
the shared extensions_config.json fresh from disk under the projection
lock instead of through the cached get_extensions_config() singleton -
that's the whole point of the fix (stale worker caches must not clobber
another worker's concurrent update). The name no longer exists on the
skills router module, so the test's monkeypatch of it started raising
AttributeError instead of exercising the endpoint.

The mock storage in this test isn't a real LocalSkillStorage instance,
so _persist_shared_skill_state's projection-mutation branch is already
skipped (nullcontext) and it falls back to a fresh ExtensionsConfig()
for the nonexistent tmp config_path - no replacement monkeypatch needed.

* fix(sandbox): make skill projection ensure best-effort in acquire

acquire() called _ensure_skills_projection() directly, outside any
try/except, in both LocalSandboxProvider and AioSandboxProvider. Every
other skill-mount setup path in these providers has always caught
exceptions and logged a warning rather than failing sandbox acquire
outright (e.g. when config.yaml can't be resolved) - these two new call
sites broke that contract, so any projection failure (including simply
not having a config.yaml, as in CI's test environment) now failed
acquire() itself instead of just leaving skill mounts off.

_ensure_skills_projection now catches its own exceptions and returns
None; both providers' callers already tolerate that (a None projection
skips the skill-specific mounts, matching the existing degrade path)
after making _append_public_skill_mapping and the custom/legacy mount
block in LocalSandboxProvider explicitly None-safe.

Caught by running the full suite with config.yaml removed, matching
CI's environment - not caught locally because a real config.yaml was
present, masking the failure.

* fix(sandbox): make E2B skill projection mounts best-effort

_skill_projection_mounts called ensure_skill_projections with no guard,
unlike Local/AIO's _ensure_skills_projection. A raise propagated out of
_apply_mounts before the configured-mounts loop ran, so a skills
projection failure dropped the operator's own configured mounts too -
only caught by create()'s outer warning, with nothing applied at all.

Swallow here and return an empty mount list on failure, matching the
Local/AIO pattern: still fail-closed for skills, but no longer widens
the blast radius to unrelated configured mounts.

Review feedback from PR #4178.

* docs(skills): document projection trade-offs flagged in review

- _update_tree_digest: note the metadata-only (not content) hashing
  trade-off and why runtime writes through this codebase are still
  covered regardless (rebuild-under-lock + rename always changes inode).
- LocalSandboxProvider.acquire: note the acquire-time self-heal cost
  (cheap on a fresh manifest, ~400ms rebuild under lock on stale/drift).
- skill_projection_mutation: drop the no-op except-Exception-then-raise;
  a raise from the mutation already propagates past the yield with the
  view left cleared, no explicit re-raise needed.
- provisioner README: spell out that hostPath skills volumes require
  the gateway and K8s node to share DEER_FLOW_HOST_BASE_DIR (single-node
  or shared storage), and that the custom/legacy volumes' hostPath type
  Directory (not DirectoryOrCreate) makes a violation of that assumption
  a visible Pod-creation failure instead of a silent empty mount.

Review feedback from PR #4178.

* fix(skills): lazily repair user projections

* fix(skills): close projection review gaps

* fix(skills): refresh user projection enable state

* fix(skills): close projection review follow-ups

* fix(skills): preserve state across projection writes

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-31 17:55:24 +08:00

417 lines
21 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.skills.projection import rebuild_skill_projections
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"
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"),
}
@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")
# ── 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_bash_observes_toggle_without_sandbox_recreation(self, tmp_path):
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
disabled = sandbox.execute_command("cat /mnt/skills/public/secret-skill/SKILL.md")
assert "SECRET_PROCEDURE" not in disabled
extensions.skills["secret-skill"] = SkillStateConfig(enabled=True)
rebuild_skill_projections(storage)
enabled = sandbox.execute_command("cat /mnt/skills/public/secret-skill/SKILL.md")
assert "SECRET_PROCEDURE" in enabled
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("cat /mnt/skills/public/secret-skill/SKILL.md")
assert "SECRET_PROCEDURE" not in disabled_again
# ── 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"
)