{"repo":"NousResearch/hermes-agent","public_id":"34972","display_label":"Skills reset read-only file and transaction fixes","summary":"These issues and PRs address bugs in the skills reset --restore command related to manifest corruption when rmtree encounters read-only Nix store directories, and transaction ordering problems in bundled skill reset logic.","subsystem":"skills","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":35251,"merged_at":"2026-05-30T09:05:11+00:00"},"resolution":{"state":"resolved","by":"pr-35251"},"freshness":{"state":"verified","last_verified_at":"2026-05-30T09:05:11+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":34860,"pr_number":35251,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-30T09:05:11+00:00"},{"issue_number":34972,"pr_number":35251,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-30T09:05:11+00:00"}],"members":[{"kind":"issue","number":34972,"title":"[Bug]: hermes skills reset --restore corrupts manifest when rmtree fails on read-only Nix-store dirs","url":null,"state":"closed"},{"kind":"pr","number":35131,"title":"fix(skills): fix transaction ordering in reset_bundled_skill and handle read-only files in rmtree","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"83a7d0b6016495a5d67f341a5252642ab8128f14","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":35182,"title":"fix(skills): handle read-only files when cleaning up skill sync backups","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#35251","number":35251},{"kind":"pr","value":"#34972","number":34972}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":35251,"title":"fix(skills): transactional skills reset --restore + read-only Nix tree cleanup (#34972)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_skills_sync.py","tools/skills_sync.py"],"fix_hunks":[{"file":"tests/tools/test_skills_sync.py","hunks":["@@ -845,3 +845,85 @@ def test_reset_no_op_when_already_clean(self, tmp_path):\n             post_manifest = _read_manifest()\n             assert \"google-workspace\" in post_manifest\n         assert (skills_dir / \"productivity\" / \"google-workspace\" / \"SKILL.md\").exists()\n+\n+    def test_reset_restore_succeeds_on_readonly_nix_tree(self, tmp_path):\n+        \"\"\"#34972: --restore must succeed even when the user copy is a fully\n+        read-only tree (r-xr-xr-x dirs + files), as produced by copying a\n+        Nix-store source. The manifest is re-baselined and bundled re-copied.\"\"\"\n+        import os\n+        import stat\n+\n+        bundled = self._setup_bundled(tmp_path)\n+        skills_dir = tmp_path / \"user_skills\"\n+        manifest_file = skills_dir / \".bundled_manifest\"\n+\n+        dest = skills_dir / \"productivity\" / \"google-workspace\"\n+        sub = dest / \"references\"\n+        sub.mkdir(parents=True)\n+        (dest / \"SKILL.md\").write_text(\"# user version\\n\")\n+        (sub / \"ref.md\").write_text(\"# nested ref\\n\")\n+        manifest_file.write_text(\n+            \"google-workspace:STALEHASH000000000000000000000000\\n\"\n+        )\n+\n+        # Read-only files AND directories — the real Nix-store case.\n+        ro_dir = (\n+            stat.S_IRUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP\n+            | stat.S_IROTH | stat.S_IXOTH\n+        )\n+        os.chmod(sub / \"ref.md\", stat.S_IREAD)\n+        os.chmod(dest / \"SKILL.md\", stat.S_IREAD)\n+        os.chmod(sub, ro_dir)\n+        os.chmod(dest, ro_dir)\n+\n+        try:\n+            with self._patches(bundled, skills_dir, manifest_file):\n+                result = reset_bundled_skill(\"google-workspace\", restore=True)\n+\n+            assert result[\"ok\"] is True\n+            assert result[\"action\"] == \"restored\"\n+            # Bundled version was re-copied over the (deleted) user copy.\n+            assert \"upstream\" in (dest / \"SKILL.md\").read_text()\n+            # The read-only nested user dir/file was fully removed, not left behind.\n+            assert not (sub / \"ref.md\").exists()\n+            # sync ran and re-copied the skill (not stuck in limbo).\n+            assert \"google-workspace\" in result[\"synced\"][\"copied\"]\n+        finally:\n+            # Restore perms so tmp_path teardown can remove anything left.\n+            for p in (sub, dest):\n+                if p.exists():\n+                    os.chmod(p, stat.S_IRWXU)\n+\n+    def test_reset_restore_preserves_manifest_on_rmtree_failure(self, tmp_path):\n+        \"\"\"#34972: when the user copy genuinely cannot be removed, the manifest\n+        entry must NOT be deleted — otherwise the skill enters a limbo state\n+        where future syncs silently skip it forever.\"\"\"\n+        bundled = self._setup_bundled(tmp_path)\n+        skills_dir = tmp_path / \"user_skills\"\n+        manifest_file = skills_dir / \".bundled_manifest\"\n+\n+        dest = skills_dir / \"productivity\" / \"google-workspace\"\n+        dest.mkdir(parents=True)\n+        (dest / \"SKILL.md\").write_text(\"# user version\\n\")\n+        manifest_file.write_text(\n+            \"google-workspace:STALEHASH000000000000000000000000\\n\"\n+        )\n+\n+        # Simulate an unremovable tree (e.g. a busy mountpoint or a path even\n+        # chmod can't rescue) by making the removal helper raise.\n+        def _boom(_path):\n+            raise PermissionError(13, \"Permission denied\")\n+\n+        with self._patches(bundled, skills_dir, manifest_file), patch(\n+            \"tools.skills_sync._rmtree_writable\", side_effect=_boom\n+        ):\n+            result = reset_bundled_skill(\"google-workspace\", restore=True)\n+\n+        # Restore failed, and the manifest must be left untouched.\n+        assert result[\"ok\"] is False\n+        assert result[\"action\"] == \"not_reset\"\n+        assert \"Manifest entry preserved\" in result[\"message\"]\n+        manifest_after = manifest_file.read_text()\n+        assert \"google-workspace\" in manifest_after\n+        # User copy is still on disk (we changed nothing).\n+        assert (dest / \"SKILL.md\").exists()"]},{"file":"tools/skills_sync.py","hunks":["@@ -517,7 +517,10 @@ def sync_skills(quiet: bool = False) -> dict:\n                         if not quiet:\n                             print(f\"  ↑ {skill_name} (updated)\")\n                         # Remove backup after successful copy\n-                        shutil.rmtree(backup, ignore_errors=True)\n+                        try:\n+                            _rmtree_writable(backup)\n+                        except (OSError, IOError):\n+                            logger.debug(\"Could not remove backup %s\", backup, exc_info=True)\n                     except (OSError, IOError):\n                         # Restore from backup\n                         if backup.exists() and not dest.exists():","@@ -563,6 +566,30 @@ def sync_skills(quiet: bool = False) -> dict:\n     }\n \n \n+def _rmtree_writable(path: Path) -> None:\n+    \"\"\"Remove a directory tree, making read-only entries writable first.\n+\n+    Handles immutable package sources (Nix store, deb/rpm installs) that\n+    preserve read-only permissions on copied files *and* directories\n+    (``r-xr-xr-x``).  Removing a child requires write permission on its\n+    parent directory, so the retry handler makes the failing path **and its\n+    parent** writable before re-attempting.  See #34860, #34972.\n+    \"\"\"\n+    import stat\n+\n+    def _on_error(func, fpath, exc_info):\n+        # Unlinking a child requires the parent dir to be writable, so chmod\n+        # the parent as well as the failing path, then retry.\n+        for target in (os.path.dirname(fpath), fpath):\n+            try:\n+                os.chmod(target, stat.S_IRWXU)\n+            except OSError:\n+                pass\n+        func(fpath)\n+\n+    shutil.rmtree(path, onerror=_on_error)\n+\n+\n def reset_bundled_skill(name: str, restore: bool = False) -> dict:\n     \"\"\"\n     Reset a bundled skill's manifest tracking so future syncs work normally.","@@ -606,12 +633,9 @@ def reset_bundled_skill(name: str, restore: bool = False) -> dict:\n             \"synced\": None,\n         }\n \n-    # Step 1: drop the manifest entry so next sync treats it as new\n-    if in_manifest:\n-        del manifest[name]\n-        _write_manifest(manifest)\n-\n-    # Step 2 (optional): delete the user's copy so next sync re-copies bundled\n+    # Step 1 (optional): delete the user's copy so next sync re-copies bundled.\n+    # Must happen BEFORE manifest deletion so that a failed rmtree does not\n+    # leave the skill in a manifest-less limbo state (see #34972).\n     deleted_user_copy = False\n     if restore:\n         if not is_bundled:","@@ -619,28 +643,32 @@ def reset_bundled_skill(name: str, restore: bool = False) -> dict:\n                 \"ok\": False,\n                 \"action\": \"bundled_missing\",\n                 \"message\": (\n-                    f\"'{name}' has no bundled source — manifest entry cleared \"\n+                    f\"'{name}' has no bundled source — manifest entry preserved \"\n                     f\"but cannot restore from bundled (skill was removed upstream).\"\n                 ),\n                 \"synced\": None,\n             }\n-        # The destination mirrors the bundled path relative to bundled_dir.\n         dest = _compute_relative_dest(bundled_by_name[name], bundled_dir)\n         if dest.exists():\n             try:\n-                shutil.rmtree(dest)\n+                _rmtree_writable(dest)\n                 deleted_user_copy = True\n             except (OSError, IOError) as e:\n                 return {\n                     \"ok\": False,\n-                    \"action\": \"manifest_cleared\",\n+                    \"action\": \"not_reset\",\n                     \"message\": (\n-                        f\"Cleared manifest entry for '{name}' but could not \"\n-                        f\"delete user copy at {dest}: {e}\"\n+                        f\"Could not delete user copy at {dest}: {e}. \"\n+                        f\"Manifest entry preserved — nothing was changed.\"\n                     ),\n                     \"synced\": None,\n                 }\n \n+    # Step 2: drop the manifest entry so next sync treats it as new\n+    if in_manifest:\n+        del manifest[name]\n+        _write_manifest(manifest)\n+\n     # Step 3: run sync to re-baseline (or re-copy if we deleted)\n     synced = sync_skills(quiet=True)\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}