Repository navigation
fix(skills): skill_manage follows directory symlinks; rollback and ownership stay on the link - #112321
fix(skills): skill_manage follows directory symlinks; rollback and ownership stay on the link#112321banozz0 wants to merge 2 commits into
Conversation
… and ownership on the link skill_manage could read and invoke a skill installed as a directory symlink under skills.external_dirs (skill_view walks with followlinks=True) but not patch it: _iter_skill_dirs used Path.rglob, which never descends into a linked directory. Three changes, one seam: - tools/skill_manager_tool.py: _iter_skill_dirs walks with the shared iter_skill_index_files, so discovery matches skill_view and returns the lexical alias path (fixes the lookup in NousResearch#54195; same substitution as NousResearch#67748, rebased onto the _iter_skill_dirs helper current main has). - tools/skill_manager_batch.py: snapshot and restore operate on the resolved link target. Restoring onto the alias renamed the link aside and copied the snapshot into a real directory at the alias, detaching the skill from its shared source and leaving the source with any op that had already landed. - agent/skill_utils.py: is_external_skill_path checks lexical containment before resolved containment, so a link under an external root that points elsewhere is still externally owned and the background curator refuses to write to it. Regression tests go through the public skill_manage(operations=[...]) call: in-place patch with the alias preserved; failed single- and multi-op batches keep the symlink and restore the shared source; background review refuses the linked skill as externally owned. Refs NousResearch#54195, NousResearch#67748 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dnv1xcpg2T2LGP4isptHAB
…back Resolving post_dir for every rollback let a failed batch that had created a skill through a pre-existing alias rmtree the link TARGET. Keep the resolved path for snapshot restores (where it is load-bearing) and remove a created skill at its lexical path, so rmtree refuses a symlink there as it always did. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dnv1xcpg2T2LGP4isptHAB
|
Hi — related, and I think complementary rather than overlapping: this PR changes where the
With this PR's hunks applied the park becomes a real directory (good), but it is still parked No files touched here overlap with this PR ( |
…ill writes through A failed multi-op skill_manage batch rolls every touched skill back through tools/skill_manager_batch.py::_restore_snapshot(). For a farm entry that is a per-skill SYMLINK into the shared tree, the symlink branch unlinked the entry, recreated the symlink and returned -- it restored the entry's SHAPE only. Ops write THROUGH the link, so the batch's mutation lives on the link's TARGET (the shared SKILL.md); the snapshot taken by _snapshot_skills follows the link and holds that reachable pre-state, but it was never written back. The batch reported "all touched skills rolled back" while the shared corpus stayed mutated -- non-atomic through a symlink. Revert the snapshotted reachable content at the resolved CONTROLLING TARGET before recreating the lexical symlink, via a factored _swap_in_snapshot (the same move-aside/copytree dance the real-directory path already used, so the helper is shared, not duplicated). Guards keep the restore safe: only a target whose pre-state we snapshotted (snap is not None) AND that is still a directory is touched, so a dangling, batch-created or out-of-band-repointed alias can never delete or overwrite an unrelated directory. _snapshot_skills is untouched, the index walker is untouched, and NousResearch#118550 / NousResearch#112321 stay external. The regression test now injects the finder a symlink-aware index walk supplies (the standalone walker does not descend symlinked directories, so on this base the batch aborts at operations[0] and every assertion would hold trivially) and asserts the batch actually landed a mutation -- failed_index == 1, completed_before_failure == 1 -- that both shared targets come back containing ORIGINAL BODY and without EDITED BODY, and that an alias the batch never mutated is left byte-identical. Agent: platform-coder
…ill writes through A failed multi-op skill_manage batch rolls every touched skill back through tools/skill_manager_batch.py::_restore_snapshot(). For a farm entry that is a per-skill SYMLINK into the shared tree, the symlink branch unlinked the entry, recreated the symlink and returned -- it restored the entry's SHAPE only. Ops write THROUGH the link, so the batch's mutation lives on the link's TARGET (the shared SKILL.md); the snapshot taken by _snapshot_skills follows the link and holds that reachable pre-state, but it was never written back. The batch reported "all touched skills rolled back" while the shared corpus stayed mutated -- non-atomic through a symlink. Revert the snapshotted reachable content at the resolved CONTROLLING TARGET before recreating the lexical symlink, via a factored _swap_in_snapshot (the same move-aside/copytree dance the real-directory path already used, so the helper is shared, not duplicated). Guards keep the restore safe: only a target whose pre-state we snapshotted (snap is not None) AND that is still a directory is touched, so a dangling, batch-created or out-of-band-repointed alias can never delete or overwrite an unrelated directory. _snapshot_skills is untouched, the index walker is untouched, and NousResearch#118550 / NousResearch#112321 stay external. The regression test now injects the finder a symlink-aware index walk supplies (the standalone walker does not descend symlinked directories, so on this base the batch aborts at operations[0] and every assertion would hold trivially) and asserts the batch actually landed a mutation -- failed_index == 1, completed_before_failure == 1 -- that both shared targets come back containing ORIGINAL BODY and without EDITED BODY, and that an alias the batch never mutated is left byte-identical. Agent: platform-coder
What does this PR do?
A skill installed as a directory symlink under
skills.external_dirs(<root>/<skill> -> ../_src/<skill>, the layout a shared-skills tree uses across profiles) is visible toskill_view—iter_skill_index_fileswalks withfollowlinks=True— butskill_managecannot patch it:_iter_skill_dirsusedPath.rglob, which never descends into a linked directory. Same defect and same substitution as #67748, rebased onto the_iter_skill_dirshelper currentmainhas (neither hunk of #67748 applies any more), plus two safeguards that only become reachable once discovery works.Three changes, one seam (
skill_manage(operations=[...])):_iter_skill_dirswalks with the sharediter_skill_index_files, soskill_manageresolves exactly whatskill_viewresolves and returns the lexical alias path. This also meansskill_managenow honours the walker's_orgmirror gating and support-dir pruning, whichrglobdid not — that is the module's documented contract, and it is what the reader side already does._restore_snapshotrenamed the alias aside andcopytree'd the snapshot into a real directory at the alias, detaching the skill from its shared source;rmtree(aside)on a symlink is a swallowed error, so a.rollback-brokenlink was left behind too. A failed multi-op batch left the first op applied in the shared source while reporting a full rollback. A batch-created skill is still removed at its lexical path, so a pre-existing alias there is refused (loud rollback failure) rather than followed.is_external_skill_pathchecks lexical containment before resolved containment. Before: the alias resolved outside the external root → "not external" → with a curator-managed record and the read mark set, the background curator wrote to the shared source. The new predicate is a strict superset of the old one (it can only add Trues), and every caller treats "external" as read-only / ineligible.Related Issue
Fixes #54195. Extends #67748 (see its discussion for the rollback and ownership analysis) rather than duplicating it; happy to fold this into that PR if one head is preferred.
Type of Change
Changes Made
tools/skill_manager_tool.py—_iter_skill_dirs:Path.rglob→iter_skill_index_files(+3/−2).tools/skill_manager_batch.py—_real_skill_dir;_snapshot_skillsand_rollbacksnapshot/restore on the resolved target; batch-created skills removed at the lexical path.agent/skill_utils.py—_lexical_for_skill_ownership;is_external_skill_pathchecks lexical, then resolved containment.tests/tools/test_skill_manager_tool.py—TestSymlinkedExternalSkill(5 tests) through the publicskill_manage(operations=[...])call: in-place patch with the alias preserved · failed single-op batch keeps the symlink and source · failed multi-op batch undoes the first op in the shared source · background review refuses the linked skill as externally owned · failed create-through-alias batch never deletes the link target.test_symlinked_skill_dir_refused(symlink delete refusal) is unchanged and still passes.How to Test
main, apply only the test file:scripts/run_tests.sh tests/tools/test_skill_manager_tool.py -k TestSymlinkedExternalSkill→ 3 failed, 2 passed (the two rollback tests only turn red once discovery works; with the discovery hunk alone applied it is 4 failed, 1 passed).scripts/run_tests.sh tests/tools/test_skill_manager_tool.py tests/tools/test_skill_manage_batch.py tests/agent/test_skill_utils.py tests/agent/test_project_skills.py tests/agent/test_org_skill_namespace.py tests/tools/test_skill_usage.py tests/tools/test_skills_sync_client.py tests/tools/test_skills_tool.py(and the other 14 files that touch these seams) → 536 passed.skills.external_dirsat a directory whose entries are symlinks to skill folders elsewhere, thenskill_manage(operations=[{"name": "<skill>", "action": "patch", ...}])— "not found" before, patched in place after, and the entry is still a symlink.Checklist
Code
fix(scope):,feat(scope):, etc.)scripts/run_tests.sh(macOS, Python 3.11): 48,959 passed / 287 failed / 632 skipped. Every failure is environmental and reproduces on pristinemainin the same venv:acpandanthropicoptional deps absent (collection errors), load-sensitivetests/tools/test_search_*cases that flip between runs, and a transientaiohttpimport gap during the heavy 16-worker run (aiohttp is installed; the same files collect and run on re-run). Re-running exactly the 149 failing files onmainvs this branch gives the same failure set apart from those search flips (155 vs 152). The 22 test files touching the changed seams: 536/536 green.Documentation & Housekeeping
docs/, docstrings) — docstrings on the three changed functions; no user-facing docs describe this pathcli-config.yaml.exampleif I added/changed config keys — N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/Apytest.skipwhere directory symlinks are unavailable, matching the pattern in fix(skills): follow symlinks in skill management #67748🤖 Generated with Claude Code
https://claude.ai/code/session_01Dnv1xcpg2T2LGP4isptHAB