{"repo":"NousResearch/hermes-agent","public_id":"2739","display_label":"Skills bundle hash crashes on bytes content","summary":"A bug where bundle_content_hash raises an AttributeError when encountering bytes values during skills check/update operations. Multiple fix attempts address handling bytes alongside string content in the hashing logic.","subsystem":"hermes_cli","counts":{"members":17,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":1,"attempt_prs":12},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":19575},"resolution":{"state":"partially_resolved","by":"pr-19575"},"freshness":{"state":"verified","last_verified_at":"2026-05-04T08:28:13+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.14088235294117646},"fix_edges":[{"issue_number":13408,"pr_number":19575,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":19073,"pr_number":19575,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":19090,"pr_number":19575,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":2739,"title":"[Bug]: `hermes skills update` fails","url":null,"state":"closed"},{"kind":"issue","number":13408,"title":"fix: bundle_content_hash crashes with bytes values (AttributeError)","url":null,"state":"closed"},{"kind":"issue","number":19073,"title":"AttributeError in bundle_content_hash when checking skill updates","url":null,"state":"closed"},{"kind":"issue","number":19090,"title":"Bug: hermes skills check/update crashes with AttributeError in bundle_content_hash","url":null,"state":"closed"},{"kind":"pr","number":2740,"title":"fix(skills): handle bytes content in bundle_content_hash","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3072e5543","number":0},{"kind":"commit","value":"3072e5543ba01c23358772db5fe1a2e770ee108a","number":0},{"kind":"commit","value":"91ea3ae4b","number":0},{"kind":"commit","value":"91ea3ae4b2e5214390f0865329648f9cffbbc8bc","number":0},{"kind":"pr","value":"#9925","number":9925}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":3751,"title":"Handle byte-backed skill bundle hashes","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3072e5543ba01c23358772db5fe1a2e770ee108a","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":12518,"title":"fix(skills_hub): handle bytes content in bundle_content_hash","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2740","number":2740}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":13424,"title":"fix(skills_hub): handle bytes content in bundle_content_hash","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2740","number":2740}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":13531,"title":"fix(skills): handle bytes in bundle content hash","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#3751","number":3751}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":14108,"title":"fix(skills): hash byte-backed bundle files","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#13531","number":13531}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":19079,"title":"fix: handle bytes values in bundle_content_hash()","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19575","number":19575},{"kind":"pr","value":"#19328","number":19328}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":19081,"title":"fix(skills): handle bytes values in bundle_content_hash","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19575","number":19575},{"kind":"pr","value":"#19328","number":19328}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":19116,"title":"fix(skills): hash bytes bundle contents","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#9925","number":9925},{"kind":"pr","value":"#9925","number":9925},{"kind":"pr","value":"#14108","number":14108},{"kind":"pr","value":"#13531","number":13531},{"kind":"pr","value":"#13408","number":13408}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":19120,"title":"fix(skills): handle bytes values in bundle_content_hash — fixes AttributeError on skills check/update (#19090)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19575","number":19575},{"kind":"pr","value":"#19328","number":19328}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":19145,"title":"fix(skills): handle bytes in bundle update hash","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19575","number":19575},{"kind":"pr","value":"#19328","number":19328}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":19328,"title":"fix(skills): hash byte-backed bundle files (closes #13408)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19575","number":19575}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":19575,"title":"fix(skills): hash byte-backed bundle files (closes #13408)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/release.py","tests/tools/test_skills_hub.py","tools/skills_hub.py"],"fix_hunks":[{"file":"scripts/release.py","hunks":["@@ -633,6 +633,8 @@\n     \"[redacted:email]\": \"CIRWEL\",\n     \"[redacted:email]\": \"molvikar\",\n     \"[redacted:email]\": \"nftpoetrist\",\n+    \"[redacted:email]\": \"colorcross\",\n+    \"[redacted:email]\": \"zhao0112\",\n     \"[redacted:email]\": \"leozeli\",\n     \"[redacted:email]\": \"LehaoLin\",\n     \"[redacted:email]\": \"I3eg1nner\","]},{"file":"tests/tools/test_skills_hub.py","hunks":["@@ -901,6 +901,69 @@ def test_bundle_content_hash_matches_installed_content_hash(self, tmp_path):\n \n         assert bundle_content_hash(bundle) == content_hash(skill_dir)\n \n+    def test_bundle_content_hash_accepts_binary_files(self):\n+        bundle = SkillBundle(\n+            name=\"demo-binary-skill\",\n+            files={\n+                \"SKILL.md\": \"# Demo\\n\",\n+                \"assets/logo.png\": b\"\\x89PNG\\r\\n\\x1a\\nbinary\",\n+            },\n+            source=\"github\",\n+            identifier=\"owner/repo/demo-binary-skill\",\n+            trust_level=\"community\",\n+        )\n+\n+        digest = bundle_content_hash(bundle)\n+\n+        assert digest.startswith(\"sha256:\")\n+\n+    def test_bundle_content_hash_bytes_matches_str_equivalent(self):\n+        \"\"\"Bytes content must hash identically to its str-decoded form.\"\"\"\n+        text_bundle = SkillBundle(\n+            name=\"demo-skill\",\n+            files={\n+                \"SKILL.md\": \"same content\",\n+                \"references/checklist.md\": \"- [ ] security\\n\",\n+            },\n+            source=\"github\",\n+            identifier=\"owner/repo/demo-skill\",\n+            trust_level=\"community\",\n+        )\n+        bytes_bundle = SkillBundle(\n+            name=\"demo-skill\",\n+            files={\n+                \"SKILL.md\": b\"same content\",\n+                \"references/checklist.md\": b\"- [ ] security\\n\",\n+            },\n+            source=\"github\",\n+            identifier=\"owner/repo/demo-skill\",\n+            trust_level=\"community\",\n+        )\n+\n+        assert bundle_content_hash(bytes_bundle) == bundle_content_hash(text_bundle)\n+\n+    def test_bundle_content_hash_mixed_matches_on_disk(self, tmp_path):\n+        \"\"\"In-memory bundle hash must equal on-disk content_hash for mixed bytes+str.\"\"\"\n+        from tools.skills_guard import content_hash\n+\n+        bundle = SkillBundle(\n+            name=\"demo-skill\",\n+            files={\n+                \"SKILL.md\": b\"# Demo Skill\\n\",\n+                \"references/checklist.md\": \"- [ ] security\\n\",\n+            },\n+            source=\"github\",\n+            identifier=\"owner/repo/demo-skill\",\n+            trust_level=\"community\",\n+        )\n+        skill_dir = tmp_path / \"demo-skill\"\n+        skill_dir.mkdir()\n+        (skill_dir / \"SKILL.md\").write_bytes(b\"# Demo Skill\\n\")\n+        (skill_dir / \"references\").mkdir()\n+        (skill_dir / \"references\" / \"checklist.md\").write_text(\"- [ ] security\\n\")\n+\n+        assert bundle_content_hash(bundle) == content_hash(skill_dir)\n+\n     def test_reports_update_when_remote_hash_differs(self):\n         lock = MagicMock()\n         lock.list_installed.return_value = [{"]},{"file":"tools/skills_hub.py","hunks":["@@ -2801,7 +2801,11 @@ def bundle_content_hash(bundle: SkillBundle) -> str:\n     \"\"\"Compute a deterministic hash for an in-memory skill bundle.\"\"\"\n     h = hashlib.sha256()\n     for rel_path in sorted(bundle.files):\n-        h.update(bundle.files[rel_path].encode(\"utf-8\"))\n+        content = bundle.files[rel_path]\n+        if isinstance(content, bytes):\n+            h.update(content)\n+        else:\n+            h.update(content.encode(\"utf-8\"))\n     return f\"sha256:{h.hexdigest()[:16]}\"\n \n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}