fix(skills): restore bundle/installed content-hash symmetry on Windows - #78082
fix(skills): restore bundle/installed content-hash symmetry on Windows#78082bbasketballer75 wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
Restores the documented contract that an in-memory skill bundle hash (bundle_content_hash) must match the on-disk installed skill hash (content_hash) for the same content, fixing Windows-specific byte/path canonicalization issues that caused hub-installed skills to be perpetually reported as update_available.
Changes:
- Normalize optional-skill bundle file keys to POSIX-style relative paths to avoid Windows separator differences in hashing and lookups.
- Write quarantined text files with
newline=""to prevent Windows newline translation from altering installed bytes. - Canonicalize installed-directory hashing order by sorting relative POSIX path strings (not
Pathobjects) to make digests platform-stable and symmetric with bundle hashing. - Strengthen tests to exercise the real quarantine writer and ensure byte-fidelity on Windows fixtures.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| tools/skills_hub.py | Normalizes bundle file keys and disables newline translation when writing quarantined text files. |
| tools/skills_guard.py | Makes installed content hashing canonical across platforms by sorting by relative POSIX path strings. |
| tests/tools/test_skills_hub.py | Updates tests to validate real install-byte fidelity and avoid fixture newline translation on Windows. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
f13ded1 to
54264b0
Compare
54264b0 to
ac80ed3
Compare
|
Rebased onto current The conflict resolved itself in your favourUpstream has since fixed the identifier line independently, and more thoroughly than this PR did: # this PR
identifier=f"official/{skill_dir.relative_to(self._optional_dir).as_posix()}"
# main today — a strict superset
identifier=f"official/{skill_dir.resolve().relative_to(self._optional_dir.resolve()).as_posix()}"I took The first commit is still neededBoth of its fixes are absent from
Tests
🤖 Rebased by Claude Code |
ac80ed3 to
f69f11e
Compare
`bundle_content_hash()` (hashes an in-memory bundle) and
`skills_guard.content_hash()` (hashes the installed directory) document that
they MUST agree for the same skill — `check_for_skill_updates()` compares the
lock file's disk-derived hash against a freshly fetched bundle hash to decide
up_to_date vs update_available. Three separate defects broke that on Windows.
1. Newline translation in `quarantine_bundle()`.
Text files were written with `write_text(content, encoding="utf-8")`, which
leaves newline=None and applies universal-newline translation: every "\n"
becomes "\r\n". That directory is exactly the tree `install_from_quarantine()`
moves into place and then hashes, so installed bytes never equalled bundle
bytes for any text file containing a newline. Now written with newline="".
2. Platform-dependent ordering in `_content_digest()`.
It iterated `sorted(skill_path.rglob("*"))` — sorting Path objects.
WindowsPath compares case-insensitively, so "references" sorts before
"SKILL.md" on Windows and after it on POSIX. `bundle_content_hash` sorts
relative path *strings*, so the two disagreed. Now sorts by the relative
POSIX string, matching its counterpart.
3. Native separators in `OptionalSkillSource.fetch()` bundle keys.
Keys were built with `str(f.relative_to(skill_dir))`, producing
"assets\neutts-cli\samples\jo.wav" on Windows. Those key strings are
themselves hashed by `bundle_content_hash`, are used as write paths by
`quarantine_bundle`, and are indexed with forward slashes by callers. Now
`.as_posix()`.
User-visible impact on Windows: every hub-installed skill was permanently
reported as "update_available", and reinstalling could never clear it because
the disk hash was recomputed the same broken way. Defect 2 also made
`full_content_hash()` — which binds scanner attestations and keys the scan
cache — non-canonical across platforms.
Also fixes the fixture in `test_fetch_preserves_binary_assets`, which wrote its
sample file with `write_text("hello\n")` and then asserted the bytes came back
as b"hello\n". That assertion tests byte preservation, so the fixture has to
control its own bytes; it now passes newline="". No assertion was weakened.
Verification on Windows, tests/tools/test_skills_hub.py:
before: 2 failed, 57 passed
after: 0 failed, 59 passed
newly broken: none (set-diff of failure names is empty)
tests/tools/test_skills_guard.py: 31 passed. `ruff check` clean on all three
files. Reverting the production changes makes
test_bundle_content_hash_matches_installed_content_hash fail again, so the
coverage genuinely pins the behaviour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f69f11e to
84f6be8
Compare
The contract that was broken
skills_hub.bundle_content_hash()(hashes an in-memory bundle) andskills_guard.content_hash()(hashes the installed directory) both document that they must produce the same digest for the same skill.check_for_skill_updates()relies on it: it compares the lock file's disk-derivedcontent_hashagainst a freshly fetchedbundle_content_hashto decideup_to_datevsupdate_available.Three separate defects broke that on Windows.
1. Newline translation in
quarantine_bundle()Text files were written with
write_text(content, encoding="utf-8")—newline=None, so universal-newline translation turns every\ninto\r\n. That directory is exactly the treeinstall_from_quarantine()moves into place and then hashes, so installed bytes never equalled bundle bytes for any text file containing a newline.2. Platform-dependent ordering in
_content_digest()It iterated
sorted(skill_path.rglob("*"))— sortingPathobjects.WindowsPathcompares case-insensitively, soreferencessorts beforeSKILL.mdon Windows and after it on POSIX.bundle_content_hashsorts relative path strings, so the two disagreed about file order.3. Native separators in
OptionalSkillSource.fetch()keysKeys came from
str(f.relative_to(skill_dir)), producingassets\neutts-cli\samples\jo.wav. Those key strings are themselves hashed bybundle_content_hash, are used as write paths byquarantine_bundle, and are indexed with forward slashes by callers.User-visible impact
On Windows, every hub-installed skill was permanently reported as
update_available, and reinstalling could never clear it — the disk hash was recomputed the same broken way each time.Defect 2 also makes
full_content_hash()non-canonical across platforms, and that value binds scanner attestations and keys the scan cache.Test change
test_fetch_preserves_binary_assetswrote its sample withwrite_text("hello\n")and then asserted the bytes returned asb"hello\n". Since the test verifies byte preservation, the fixture has to control its own bytes — it now passesnewline="". No assertion was weakened.Verification
tests/tools/test_skills_hub.pyon Windows:Newly broken: none — set-diff of failure names is empty.
tests/tools/test_skills_guard.py: 31 passed.ruff checkclean on all three files.Reverting the production changes makes
test_bundle_content_hash_matches_installed_content_hashfail again, so the coverage pins the behaviour rather than passing vacuously.🤖 Generated with Claude Code