Skip to content

fix(skills): normalize path separators and sort order for cross-platform hash parity - #71252

Open
ForeverAfter wants to merge 1 commit into
NousResearch:mainfrom
ForeverAfter:fix/skills-hash-sort-order-parity
Open

fix(skills): normalize path separators and sort order for cross-platform hash parity#71252
ForeverAfter wants to merge 1 commit into
NousResearch:mainfrom
ForeverAfter:fix/skills-hash-sort-order-parity

Conversation

@ForeverAfter

Copy link
Copy Markdown

Problem

Follow-up to #71246 — that PR fixes one of the three path/hash mismatches that cause the perpetual update_available loop on Windows (#71237). This PR fixes the remaining two so the loop is fully resolved.

Even with #71246 applied (backslash → posix in OptionalSkillSource.fetch), skills with subdirectories still loop on Windows because of two additional inconsistencies between bundle_content_hash and _content_digest:

  1. bundle_content_hash does not normalize separators. Any bundle whose file keys still carry backslashes (from a Windows-built bundle, a GitHub source on Windows, or a stale lock entry) diverges from the posix-normalized disk digest.
  2. _content_digest sorts Path objects, not strings. sorted(skill_path.rglob("*")) orders by path components — on NTFS this places subdirectory files before root-level files (e.g. references/cli.md before SKILL.md). bundle_content_hash sorts the posix strings alphabetically, where SKILL.md comes first. Different iteration order → different SHA-256 → permanent mismatch, even with identical separators and identical file content.

Reproduction (Windows, even with #71246 applied)

hermes skills install hyperframes   # skill has references/ and scripts/ subdirs
hermes skills check                 # → update_available
hermes skills update                # → "Updated 1 skill(s)"
hermes skills check                 # → update_available (still!)

Observed hashes on the same skill, same content:

Hash source Value Why it differs
_content_digest (disk, sorted by Path) sha256:1f1c139f07f16489 Subdir files first
bundle_content_hash (bundle, backslash keys) sha256:0ae3a5fa4ec70721 \ instead of /
Both fixed (this PR) sha256:f6d7c9ac8e6a55a0 Posix + string sort = match ✓

Fix

Three changes (two files). Change 1 is identical to #71246 and is included so this PR is self-contained and can merge independently.

1. tools/skills_hub.pyOptionalSkillSource.fetch(): posix keys

-                rel_path = str(f.relative_to(skill_dir))
+                rel_path = f.relative_to(skill_dir).as_posix()

2. tools/skills_hub.pybundle_content_hash(): normalize backslashes

     for rel_path in sorted(bundle.files):
-        h.update(rel_path.encode("utf-8"))
+        norm_path = rel_path.replace("\\", "/")
+        h.update(norm_path.encode("utf-8"))
         h.update(b"\x00")

Defense-in-depth normalization: even if a bundle's file keys arrive with backslashes (GitHub source on Windows, hand-crafted bundles, or future sources), the hash is computed on the posix form, matching what _content_digest produces on disk.

3. tools/skills_guard.py_content_digest(): sort as posix strings

     if skill_path.is_dir():
-        for file_path in sorted(skill_path.rglob("*")):
+        rel_files = []
+        for file_path in skill_path.rglob("*"):
             if file_path.is_file():
-                rel = file_path.relative_to(skill_path).as_posix()
-                h.update(rel.encode("utf-8") + b"\x00")
-                h.update(file_path.read_bytes())
+                rel_files.append(file_path.relative_to(skill_path).as_posix())
+        for rel in sorted(rel_files):
+            h.update(rel.encode("utf-8") + b"\x00")
+            h.update((skill_path / rel).read_bytes())

sorted(Path) compares Path objects component-by-component; combined with rglob traversal this yields a different file order than sorted(str) on posix-joined paths. Collecting to strings first and then sorting makes _content_digest and bundle_content_hash iterate in the same deterministic order on every platform. The docstring of content_hash already requires the two functions to stay symmetric — this restores that invariant on Windows.

Tests

Adds tests/tools/test_skills_hash_parity.py with three regression tests:

  • test_disk_hash_matches_bundle_hash_with_subdirectories — builds a skill with references/ and scripts/ subdirs on disk, asserts content_hash(dir) == bundle_content_hash(bundle) on all platforms (catches the sort-order bug).
  • test_bundle_hash_normalizes_backslash_keys — asserts a backslash-keyed bundle hashes identically to the posix-keyed equivalent and to the disk hash (catches the separator bug).
  • test_flat_skill_still_matches — flat single-file skills never regressed; keeps it that way.

After the fix (verified on Windows 11, v0.19.0)

hyperframes: up_to_date  (lock=sha256:f6d7c9ac…, bundle=sha256:f6d7c9ac…)
0 update(s) available across 1 checked skill(s)

Relationship to #71246

Fixes #71237

🤖 Generated with Claude Code

…orm hash parity

content_hash (disk) and bundle_content_hash (in-memory bundle) must be
symmetric, but on Windows they diverged for skills with subdirectories,
causing a perpetual update_available loop (NousResearch#71237):

- OptionalSkillSource.fetch() built bundle keys with str(relative_to()),
  yielding backslash-separated paths on Windows while the disk digest
  uses Path.as_posix().
- bundle_content_hash() hashed rel paths verbatim, so backslash-keyed
  bundles never matched the posix-normalized disk digest.
- _content_digest() iterated sorted(Path.rglob()), ordering by Path
  components, while bundle_content_hash sorts posix strings — a
  different file order and therefore a different SHA-256 even with
  identical separators and content.

Normalize bundle keys to posix, normalize separators defensively in
bundle_content_hash, and sort the disk digest by posix strings so both
sides iterate identically on every platform. Adds regression tests
asserting disk/bundle hash parity for nested and flat skills and for
backslash-keyed bundles.

Fixes NousResearch#71237

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists tool/skills Skills system (list, view, manage) platform/windows Native Windows-specific behavior or breakage sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows labels Jul 25, 2026
@eevans-d

Copy link
Copy Markdown

Independent verification on current main (41a07f5b8451f88a8b8b5adfc0cfdc2ada0a1f90) confirms the sort-order issue is not Windows-only, and found three remaining edge cases in the current PR implementation.

A deterministic Linux reproducer with a file/directory prefix collision produces different clean-main hashes (sha256:9230d467d45f5907 bundle vs sha256:063408e28f574ce7 disk). Testing the exact #71252 algorithm against real optional-skill trees while simulating native Windows bundle keys also gives:

  • baoyu-article-illustrator: PR algorithm sha256:15694c74d4ec835e; canonical disk sha256:49c09a0ee50b2e59 — mismatch
  • baoyu-comic: match

The path-dependent mismatch remains because sorted(bundle.files) orders raw backslash keys, then replace("\\", "/") only changes bytes fed to SHA-256. Two additional installer-parity cases also need handling:

  • canonical aliases (a\\b and a/b): install_skill writes in mapping order, so the final disk tree is last-write-wins;
  • outer whitespace: the installer uses _normalize_bundle_path, which strips and canonicalizes before writing.

A complete fix is to reuse _normalize_bundle_path before sorting, fold entries into a canonical dictionary (matching installer's last-write-wins behavior), then sort canonical keys with the same component ordering as the disk hash:

canonical_files = {}
for raw_path, content in bundle.files.items():
    canonical = _normalize_bundle_path(
        raw_path,
        field_name="bundle file path",
        allow_nested=True,
    )
    canonical_files[canonical] = content

for canonical in sorted(
    canonical_files,
    key=lambda path: PurePosixPath(path).parts,
):
    ...

Validation of that variant:

  • four targeted regressions: POSIX file/directory prefix, Windows-key normalization-before-sort, canonical aliases, installer whitespace trimming
  • tests/tools/test_skills_hub.py: 164 passed
  • tests/tools/test_skill_bundle_provenance.py: 9 passed
  • tests/hermes_cli/test_skills_hub.py: 31 passed
  • 500 generated path trees with POSIX, Windows and mixed separators: all disk/bundle hashes matched
  • real baoyu-article-illustrator: up_to_date, identical sha256:49c09a0ee50b2e59
  • Ruff and git diff --check: pass

This keeps validation and normalization semantics in one helper and fixes both separator and ordering parity without hashing a tree different from the one actually installed.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tracing the disk/bundle hash symmetry issue. The current-main premise is valid: tools/skills_guard.py:695-699 orders disk entries as Path objects, while tools/skills_hub.py:3604-3614 orders raw bundle strings and OptionalSkillSource.fetch still emits native-string paths at tools/skills_hub.py:3131-3140.

Problems

  • The added norm_path = rel_path.replace("\\", "/") occurs after sorted(bundle.files). It therefore does not make ordering canonical. For example, raw a-z and a\\b sort differently from the disk-side component order after a\\b becomes a/b.
  • The hash must reflect what installation writes. quarantine_bundle calls _normalize_bundle_path before writing at tools/skills_hub.py:3464-3479; the PR does not apply that normalization before hashing, so whitespace and canonical aliases can still diverge from the installed tree.

Suggested changes

  • Normalize keys with _normalize_bundle_path, collapse canonical duplicates with installer-equivalent last-write-wins behavior, and use one explicit component-order key in both hash functions.
  • Add regressions for the ordering, alias, and whitespace cases above.

Automated hermes-sweeper review.

Comment thread tools/skills_hub.py
# platforms. Without this, Windows bundles produce a different digest
# than the installed files, causing a perpetual "update_available".
norm_path = rel_path.replace("\\", "/")
h.update(norm_path.encode("utf-8"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This normalizes only after raw-key sorting. For raw keys a-z and a\\b, the bundle can hash a-z before normalized a/b, while disk Path-component ordering puts a/b first. Normalize/canonicalize before sorting, then use the same explicit component-order key on both hash paths.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P2 Medium — degraded but workaround exists platform/windows Native Windows-specific behavior or breakage sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows tool/skills Skills system (list, view, manage) type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Windows: hub-installed skills with subdirectories stuck in a perpetual update_available loop (bundle vs disk hash mismatch)

4 participants