fix(skills): fail closed on drifted projection namespace on all platforms (#4830)

* fix(skills): fail closed on drifted projection namespace on all platforms

* test(skills): add regression test simulating swallowed unlink on drifted namespace
This commit is contained in:
Nefelibata 2026-08-16 23:44:55 +08:00 committed by GitHub
parent ae099c11ec
commit adf6c422c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 32 additions and 1 deletions

View File

@ -158,11 +158,18 @@ def _validate_projection_relative_path(relative_path: Path) -> None:
def _remove_projection_relative(root: Path, relative_path: Path) -> None:
"""Remove a projected package without following a drifted namespace symlink."""
current = root
for part in relative_path.parts:
parts = relative_path.parts
for index, part in enumerate(parts):
current /= part
if current.is_symlink():
current.unlink()
return
# A namespace component that exists as a regular file means the
# projection was externally replaced (drifted). Fail closed on every
# platform: os.unlink() reports this as ENOTDIR on POSIX but ENOENT
# on Windows, where unlink(missing_ok=True) swallows the ENOENT.
if index < len(parts) - 1 and current.exists() and not current.is_dir():
raise NotADirectoryError(errno.ENOTDIR, f"Projection namespace drifted to a file: {current}")
_remove_projection_entry(current)

View File

@ -283,6 +283,30 @@ def test_targeted_removal_failure_clears_drifted_projection_scope(projection_env
assert not manifest.exists()
def test_targeted_removal_drift_check_raises_when_underlying_unlink_swallows_error(projection_env, monkeypatch) -> None:
"""Explicit drift check raises NotADirectoryError even if underlying unlink would swallow ENOENT (Windows semantics)."""
env = projection_env
_write_skill(env.skills_root / "public" / "team", "helper")
projected = rebuild_skill_projections(env.storage)
manifest = projected.public.parent / ".projection-manifest.json"
namespace = projected.public / "team"
shutil.rmtree(namespace)
namespace.write_text("drifted file", encoding="utf-8")
from deerflow.skills import projection as projection_module
# Simulate Windows Path.unlink(missing_ok=True) semantics where file-in-path
# returns ENOENT and is swallowed, so underlying removal would not raise ENOTDIR.
monkeypatch.setattr(projection_module, "_remove_projection_entry", lambda _target: None)
with pytest.raises(NotADirectoryError, match="Projection namespace drifted to a file"):
with skill_projection_mutation(env.storage, "public", remove_names=("helper",)):
env.extensions.skills["helper"] = SkillStateConfig(enabled=False)
assert list(projected.public.iterdir()) == []
assert not manifest.exists()
def test_user_custom_skill_replaces_legacy_projection(projection_env) -> None:
env = projection_env
_write_skill(env.skills_root / "custom", "legacy-skill")