fix(skills): resolve skills dir before relative_to so junction installs work (#86971)

This commit is contained in:
Chen Jin
2026-08-15 20:47:55 +08:00
committed by Teknium
parent b3df990832
commit ed0a8a480e
3 changed files with 67 additions and 3 deletions

View File

@@ -737,7 +737,7 @@ def do_install(identifier: str, category: str = "", force: bool = False,
bundle.trust_level, "invalid_path", str(exc))
return
from tools.skills_hub import SKILLS_DIR
c.print(f"[bold green]Installed:[/] {install_dir.relative_to(SKILLS_DIR)}")
c.print(f"[bold green]Installed:[/] {install_dir.resolve().relative_to(Path(SKILLS_DIR).resolve()).as_posix()}")
c.print(f"[dim]Files: {', '.join(bundle.files.keys())}[/]\n")
# Blueprint detection: if the installed skill declares a

View File

@@ -2,6 +2,7 @@
import json
import subprocess
import sys
import threading
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
@@ -165,6 +166,69 @@ def test_real_temp_repo_and_home_install_e2e(served_repo, monkeypatch, tmp_path)
assert "Scan provenance: fresh" in sink.getvalue()
def _make_skills_redirect(link: Path, target: Path) -> bool:
"""Make *link* a directory redirect (Windows junction or POSIX symlink)
pointing at *target*. Junctions need no admin rights, unlike symlinks."""
if sys.platform == "win32":
try:
subprocess.run(
["cmd", "/c", "mklink", "/J", str(link), str(target)],
check=True,
capture_output=True,
)
return True
except (subprocess.CalledProcessError, OSError):
return False
try:
link.symlink_to(target, target_is_directory=True)
return True
except OSError:
return False
def test_install_with_junctioned_skills_dir(served_repo, monkeypatch, tmp_path):
"""#86971: install must not mix resolved and unresolved paths when the
skills directory is a junction/symlink redirect.
install_dir is resolved by _resolve_lock_install_path (following the
redirect), so relative_to() must receive the resolved skills root or it
raises ValueError after the files have already been moved, leaving a lock
entry without a content_hash (which then poisons 'hermes skills check').
"""
from hermes_cli.skills_hub import do_install
import tools.skills_hub as hub
_repo, url = served_repo
home = tmp_path / "home"
home.mkdir()
real_skills = tmp_path / "real-skills"
real_skills.mkdir()
skills_link = home / "skills"
if not _make_skills_redirect(skills_link, real_skills):
pytest.skip("Cannot create a junction/symlink in this environment")
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr("tools.skills_hub.is_safe_url", lambda _url: True)
monkeypatch.setattr("tools.skills_hub.check_website_access", lambda _url: None)
monkeypatch.setattr(hub, "create_source_router", lambda auth=None: [UrlSource()])
sink = StringIO()
do_install(url, console=Console(file=sink, force_terminal=False), skip_confirm=True)
# Files landed in the real target, reached through the junction.
installed = real_skills / "demo-bundle"
assert (installed / "SKILL.md").is_file()
assert (installed / "references" / "guide.md").read_text() == "safe guide\n"
# Lock entry got a valid relative install_path AND the content hash — the
# record_install call the pre-fix ValueError used to skip.
entry = json.loads((home / "skills" / ".hub" / "lock.json").read_text())["installed"]["demo-bundle"]
assert entry["install_path"] == "demo-bundle"
assert entry["content_hash"].startswith("sha256:")
# The post-install "Installed:" line (relative_to on the display path)
# renders instead of raising.
assert "Installed:" in sink.getvalue()
def test_bundled_optional_source_still_includes_support_files(tmp_path, monkeypatch):
from tools.skills_hub import OptionalSkillSource

View File

@@ -3394,7 +3394,7 @@ class OptionalSkillSource(SkillSource):
name=name,
files=files,
source="official",
identifier=f"official/{skill_dir.relative_to(self._optional_dir)}",
identifier=f"official/{skill_dir.resolve().relative_to(self._optional_dir.resolve()).as_posix()}",
trust_level="builtin",
)
@@ -3999,7 +3999,7 @@ def install_from_quarantine(
trust_level=bundle.trust_level,
scan_verdict=scan_result.verdict,
skill_hash=content_hash(install_dir),
install_path=str(install_dir.relative_to(_skills_dir())),
install_path=install_dir.resolve().relative_to(_skills_dir().resolve()).as_posix(),
files=list(bundle.files.keys()),
metadata=bundle.metadata,
scan_provenance=scan_provenance or getattr(scan_result, "scan_provenance", None),