From adf6c422c2aa2aa023d6534369999981594f12e3 Mon Sep 17 00:00:00 2001 From: Nefelibata <124799179+MeiSiristhebest@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:44:55 +0800 Subject: [PATCH] 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 --- .../harness/deerflow/skills/projection.py | 9 ++++++- backend/tests/test_skill_projection.py | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/backend/packages/harness/deerflow/skills/projection.py b/backend/packages/harness/deerflow/skills/projection.py index ec9a3bf9a..9acd2ab02 100644 --- a/backend/packages/harness/deerflow/skills/projection.py +++ b/backend/packages/harness/deerflow/skills/projection.py @@ -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) diff --git a/backend/tests/test_skill_projection.py b/backend/tests/test_skill_projection.py index c7922f6e3..211e2ce36 100644 --- a/backend/tests/test_skill_projection.py +++ b/backend/tests/test_skill_projection.py @@ -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")