fix(skills_sync): keep bundled skill copies writable on Nix store - #34577
fix(skills_sync): keep bundled skill copies writable on Nix store#34577legacynode wants to merge 1 commit into
Conversation
|
Heads up — the read-only skill-sync issue (#34860) was fixed on main via 83a7d0b + 8ae0802. _rmtree_writable now makes read-only files and directories (Nix-store r-xr-xr-x) writable before removal, and reset_bundled_skill deletes the user copy before dropping the manifest entry to avoid a limbo state. Your draft's keep-copies-writable-on-copy approach is no longer needed for that issue, but feel free to repurpose or close. Thanks for the early report. |
0b230d2 to
ad5b186
Compare
|
Thanks for the heads up — I've merged main and verified both fixes coexist cleanly (61 tests pass, 0 conflicts). Your fix handles "how do we remove read-only trees". This PR handles "how do we prevent read-only trees from being created in the first place." They're complementary — yours treats the symptoms, this one prevents the disease. You're right that This PR fixes a different problem — the copy side. I verified this on a live NixOS system: Real Nix store file modes: End-to-end test —
After # main: tools/skills_sync.py lines 474, 514, 551
shutil.copytree(skill_src, dest) # inherits source modes
shutil.copy2(desc_md, dest_desc) # inherits source modesThose copies are then unwritable for |
|
Thanks for isolating the copy-mode issue; the premise remains valid on current main: Problems
Suggested changes
Automated hermes-sweeper review. |
c1bf5b2 to
72b551a
Compare
|
Thanks for the detailed review — both points are addressed. Problem 1 — pre-existing hash-identical read-only copies never reachedConfirmed: the "bundled unchanged, user unchanged" no-op branch (and the v1-migration branch that sets a baseline hash) never repaired an existing copy, since content parity means it never routes through Problem 2 — symlink traversal in the repair sweepConfirmed and reproduced for real, not just in the unit test: Re-verified end to endBeyond the unit tests (305 passing via |
72b551a to
648fadd
Compare
|
Rebased onto current main. Diffed against @alt-glitch's salvage (#69473) — |
b9dee70 to
54797d5
Compare
54797d5 to
eaf125a
Compare
shutil.copy2 and shutil.copytree both preserve source-file mode bits. When bundled skills are sourced from a read-only filesystem — the Nix store (mode 0444/0555), squashfs, or an OCI image layer — the user copy in ~/.hermes/skills/ inherits those bits. Later edits via skill_manage, the curator, or even the shutil.rmtree(*.bak) cleanup at the end of an update then fail with PermissionError (silently swallowed by ignore_errors=True, so stale *.bak directories accumulate). Three helpers fix the root cause at the copy boundary: - _ensure_owner_writable(path) — adds the owner-write bit on a single file or directory without touching other mode bits (skips symlinks: os.chmod follows them by default, and a copied symlink pointing outside the profile/tree must not have its external target mutated). - _copy_file_writable(src, dst) — drop-in shutil.copy2 replacement, also used as copytree's copy_function. - _copytree_writable(src, dst) — drop-in shutil.copytree replacement; sweeps the destination tree afterwards since copytree reapplies source-directory metadata after file copies. Wired into every copy site that may read from a read-only bundled source: tools/skills_sync.py (sync_skills new + update paths, restore_official_optional_skill, the DESCRIPTION.md copy, and the reset_bundled_skill rmtree), hermes_cli/profiles.py (--clone and --clone-all), and hermes_cli/profile_distribution.py (apply_distribution). Also repairs two states the copy-boundary fix alone doesn't reach: - Migration for existing installs: a user copy that predates this fix can be hash-identical to the bundled source, so sync_skills takes the "unchanged" no-op path and would never repair it. Both the v1-migration branch and the "bundled unchanged, user unchanged" branch now sweep _make_tree_owner_writable(dest) unconditionally. - Symlink safety in the profile-clone repair sweep: profiles.py clones skills with shutil.copytree(..., symlinks=True, ...), so a skill that is itself a symlink to a shared/vendored directory outside the profile reaches the writable-mode repair as a real symlink entry. _ensure_owner_writable skips symlinks rather than chmod-ing through them into whatever they still point at. Salvages closed PR NousResearch#20135 (closed by author, not maintainer-rejected) and extends its coverage. Complements NousResearch#34860 (83a7d0b / 8ae0802), which fixed the removal side (_rmtree_writable making read-only trees removable) — this fixes the copy side (preventing read-only trees from being created in the first place).
eaf125a to
ccd404c
Compare
What does this PR do?
shutil.copy2andshutil.copytreeboth preserve source-file mode bits by default. When bundled skills are sourced from/nix/store/.../share/hermes-agent/skills(mode0444/0555because the Nix store is read-only) — or any other read-only filesystem (squashfs, OCI image layer) — the user copy in~/.hermes/skills/inherits those bits and later edits viaskill_manage, the curator, or even theshutil.rmtree(*.bak)cleanup at the end of an update fail withPermissionError.The
rmtreecleanup usesignore_errors=True, so the failure is silently swallowed and stale*.bakdirectories accumulate over time.This PR fixes the root cause at the copy boundary so user copies are always owner-writable, regardless of the source filesystem.
Related Issue
No tracking issue. Salvages closed PR #20135 (closed by author, not maintainer-rejected) and extends its coverage.
Type of Change
Changes Made
tools/skills_sync.py— three new helpers (PR #20135 architecture):_ensure_owner_writable(path)— adds the owner-write bit on a single file or directory without touching other mode bits (executable scripts keep+x)._copy_file_writable(src, dst)— drop-in replacement forshutil.copy2that finishes with_ensure_owner_writable. Also serves as thecopy_function=forshutil.copytreeso per-file modes are corrected during the copy itself._copytree_writable(src, dst)— drop-in replacement forshutil.copytreethat uses_copy_file_writableper file, then sweeps the destination tree to grant the owner-write bit on directories (copytreereapplies source-directory metadata after files are copied, so the per-file override is not enough on its own).Wrappers used at every call site that copies from a potentially read-only bundled source:
tools/skills_sync.py—sync_skills(new + update paths),restore_official_optional_skill, theDESCRIPTION.mdcopy, and thereset_bundled_skillrmtree (which previously failed on user copies created by older Hermes builds that already inherited the bits). The update path also restores writability on the*.bakbackup so thermtree(backup, ignore_errors=True)cleanup succeeds rather than silently leaking the directory.hermes_cli/profiles.py—--cloneand--clone-allflows. Cloning from a profile that itself still carries inherited read-only mode bits would otherwise propagate them to the new profile.hermes_cli/profile_distribution.py—apply_distribution. Distributed payloads can originate from the same read-only Nix store as the primary install.AGENTS.md— new "Known Pitfalls" entry pointing future contributors at the helpers + the list of call sites that already use them.How to Test
Reproduction
Tests
pytest tests/tools/test_skills_sync.py— 59 tests pass (50 pre-existing + 9 new):TestEnsureOwnerWritable— unit tests for the helper, including executable-bit preservation property and idempotency on already-writable targets.TestCopyFileWritable— verifies file copies from0444/0555sources end up writable while preserving+x.TestMakeTreeOwnerWritable— verifies the post-copytree directory sweep on a depth-first locked-down tree.TestCopytreeWritable— end-to-end: copy a Nix-store-style read-only source tree and assert askill_manage-style append on a copied file succeeds (the regression: previously raisedPermissionError).TestSyncSkillsReadOnlyBundledSource— fullsync_skillsrun against a read-only fake bundled source, asserting the user copy is writable.pytest tests/hermes_cli/test_profiles.py tests/hermes_cli/test_profile_distribution.py— 171 tests pass, no regressions in the profile flows touched by this PR.Checklist
Code
fix(skills_sync):)scripts/run_tests.shand the affected suites passDocumentation & Housekeeping
AGENTS.mdwith a Known Pitfalls entry — N/A forcli-config.yaml.exampleSymptoms in the wild
This PR was developed against a NixOS WSL2 install with the
services.hermes-agentmodule. Concrete user-visible failures the fix addresses:skill_manage write_file / patchfails withPermissionErroron~/.hermes/skills/<...>/.SKILL.md.tmp.<random>.87+ *.bakdirectories accumulate in~/.hermes/skills/because theshutil.rmtree(backup, ignore_errors=True)cleanup silently swallows the inherited read-only mode bits.~/.hermes/skills/ends up mode0555— multiplied across N profiles (a clone-driven layout with one Kanban-worker profile per role brings the total close to 2.5k unwritable directories).