fix(doctor): honor selected PM runtimes and launcher ownership

This commit is contained in:
ethernet
2026-09-11 18:20:54 -04:00
parent 596f0216a5
commit 69d6dfd077
3 changed files with 233 additions and 131 deletions

View File

@@ -18,8 +18,17 @@ from hermes_cli.doctor_report import (
from hermes_constants import is_termux as _is_termux from hermes_constants import is_termux as _is_termux
def _python_install_cmd() -> str: def _python_repair_hint() -> str:
return "python -m pip install" if _is_termux() else "uv pip install" from hermes_cli.config import detect_install_method
from hermes_cli.doctor import PROJECT_ROOT
method = detect_install_method(PROJECT_ROOT)
if is_nix_install_method(method):
return recommended_update_command_for_method(method)
if method in ("docker", "apt"):
command = recommended_update_command_for_method(method)
return f"Run `{command}`" + (", then recreate the Hermes container" if method == "docker" else "")
return "Run `hermes pm repair`, then restart Hermes"
def _system_package_install_cmd(pkg: str) -> str: def _system_package_install_cmd(pkg: str) -> str:
@@ -198,7 +207,7 @@ def check_certificates(should_fix: bool = False, issues: "list | None" = None) -
ssl.create_default_context() ssl.create_default_context()
except Exception as e: except Exception as e:
_fail_and_issue("TLS default SSL context cannot be constructed", str(e), _fail_and_issue("TLS default SSL context cannot be constructed", str(e),
"Recreate the venv or reinstall Hermes — the TLS stack is broken.", issues) _python_repair_hint() + "; if TLS still fails, repair Python through the installation owner.", issues)
return return
if platform_store: if platform_store:
check_ok("TLS platform trust store configured; default SSL context available") check_ok("TLS platform trust store configured; default SSL context available")
@@ -354,10 +363,10 @@ def _staged_venv_dir() -> "Path | None":
"""pm's provisioned runtime venv, or None when nothing is staged. """pm's provisioned runtime venv, or None when nothing is staged.
``pm.packages.Venv().venv_dir()`` is pm's public authority for where ``pm.packages.Venv().venv_dir()`` is pm's public authority for where
the runtime venv lives (sealed installs: the mutable venv in the the runtime venv lives, including an external selected generation or
writable hermes root, seeded from the payload; dev installs: the repo the original source/payload environment before first sync. A resolved
venv). A resolved path without a venv marker is not a provisioned path without a venv marker is not a provisioned venv — pm also returns
venv — pm also returns the intended location before first sync, and the intended location before first sync, and
doctor must not read an empty directory as staged dependencies. doctor must not read an empty directory as staged dependencies.
""" """
try: try:
@@ -392,16 +401,14 @@ def _check_python_environment(should_fix: bool, f: Finding) -> None:
if src: if src:
check_info(f"SQLite source id: {(src[:48] + '…') if len(src) > 48 else src}") check_info(f"SQLite source id: {(src[:48] + '…') if len(src) > 48 else src}")
_report_database_journal_modes() _report_database_journal_modes()
# Staged dependencies vs the running interpreter, reported as two # PM launchers run base Python with the selected dependency tree on
# distinct facts. When pm has provisioned the runtime venv, its # sys.path. Neither sys.prefix nor a stale PYTHONPATH proves activation.
# resolved location IS the answer ("dependencies staged"); whether
# THIS process runs inside THAT venv is a separate comparison of
# resolved prefixes (sys.prefix != base_prefix alone would also be
# true for an unrelated venv). Only when nothing is staged does the
# legacy interpreter probe stand alone.
staged = _staged_venv_dir() staged = _staged_venv_dir()
if staged is not None: if staged is not None:
running_here = Path(sys.prefix).resolve() == staged.resolve() from hermes_cli.runtime_paths import site_packages
selected_site = site_packages(staged).resolve()
running_here = selected_site.is_dir() and any(Path(entry).resolve() == selected_site for entry in sys.path)
check_ok(f"Runtime venv staged ({staged})", check_ok(f"Runtime venv staged ({staged})",
"(active in this process)" if running_here else "(this process runs outside it)") "(active in this process)" if running_here else "(this process runs outside it)")
else: else:
@@ -443,7 +450,7 @@ def _check_required_packages(should_fix: bool, f: Finding) -> None:
if optional: if optional:
check_warn(name, "(optional, not installed)") check_warn(name, "(optional, not installed)")
else: else:
_fail_and_issue(name, "(missing)", f"Install {name}: {_python_install_cmd()} {module}", f.issues) _fail_and_issue(name, "(missing)", f"Repair {name}: {_python_repair_hint()}", f.issues)
@doctor_check() @doctor_check()
@@ -454,16 +461,36 @@ def _check_gateway_supervision(should_fix: bool, f: Finding) -> None:
@doctor_check() @doctor_check()
def _check_command_installation(should_fix: bool, f: Finding) -> None: def _check_command_installation(should_fix: bool, f: Finding) -> None:
"""Venv entry point and the ~/.local/bin (or $PREFIX/bin) symlink; skipped on Windows.""" """Check the install-owned launch contract without replacing custom commands."""
from hermes_cli.doctor import PROJECT_ROOT from hermes_cli.doctor import PROJECT_ROOT
if sys.platform == "win32": if sys.platform == "win32":
return return
_section("Command Installation") _section("Command Installation")
venv_bin = next((c for c in (PROJECT_ROOT / n / "bin" / "hermes" for n in ("venv", ".venv")) if c.exists()), None) from hermes_cli.config import detect_install_method
if venv_bin is None:
check_warn("Venv entry point not found", "(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')") method = detect_install_method(PROJECT_ROOT)
return f.manual_issues.append(f"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'") if is_nix_install_method(method) or method in ("docker", "apt"):
check_ok(f"Venv entry point exists ({venv_bin.relative_to(PROJECT_ROOT)})") command = shutil.which("hermes")
if command:
check_ok(f"Hermes command managed by {method} ({command})")
else:
check_warn(f"Hermes command not on PATH ({method}-managed)")
f.manual_issues.append(_python_repair_hint())
return
from hermes_cli._launchers import resolve_store_python
from hermes_cli.runtime_paths import base_venv, selected_venv
try:
selected = selected_venv(PROJECT_ROOT)
except (OSError, ValueError, RuntimeError) as exc:
check_fail("Cannot resolve selected dependencies", str(exc))
return f.manual_issues.append(_python_repair_hint())
pm_launcher = selected != base_venv(PROJECT_ROOT) or resolve_store_python(PROJECT_ROOT) is not None
venv_bin = PROJECT_ROOT / "hermes" if pm_launcher else selected / "bin" / "hermes"
if not venv_bin.is_file():
check_warn("Hermes entry point not found", f"({venv_bin})")
return f.manual_issues.append("Repair or reinstall the Hermes launcher through the installation owner")
check_ok(f"Hermes entry point exists ({venv_bin})")
# Expected command link directory (mirrors install.sh logic). # Expected command link directory (mirrors install.sh logic).
prefix = os.environ.get("PREFIX", "") prefix = os.environ.get("PREFIX", "")
termux = prefix and (os.environ.get("TERMUX_VERSION") or "com.termux/files/usr" in prefix) termux = prefix and (os.environ.get("TERMUX_VERSION") or "com.termux/files/usr" in prefix)
@@ -474,9 +501,11 @@ def _check_command_installation(should_fix: bool, f: Finding) -> None:
if target == expected: if target == expected:
return check_ok(f"{display}/hermes → correct target") return check_ok(f"{display}/hermes → correct target")
check_warn(f"{display}/hermes points to wrong target", f"(→ {target}, expected → {expected})") check_warn(f"{display}/hermes points to wrong target", f"(→ {target}, expected → {expected})")
owned_targets = {(PROJECT_ROOT / name / "bin" / "hermes").resolve() for name in ("venv", ".venv")}
if target not in owned_targets:
return f.manual_issues.append(f"Review {display}/hermes manually; its target is user-managed and was not changed")
if not should_fix: if not should_fix:
return f.issues.append(f"Broken symlink at {display}/hermes — run 'hermes doctor --fix'") return f.issues.append(f"Broken symlink at {display}/hermes — run 'hermes doctor --fix'")
link.unlink()
verb = "Fixed" verb = "Fixed"
elif link.exists(): # regular file (wrapper script), not a symlink elif link.exists(): # regular file (wrapper script), not a symlink
return check_ok(f"{display}/hermes exists (non-symlink)") return check_ok(f"{display}/hermes exists (non-symlink)")
@@ -486,8 +515,18 @@ def _check_command_installation(should_fix: bool, f: Finding) -> None:
return f.issues.append(f"Missing {display}/hermes symlink — run 'hermes doctor --fix'") return f.issues.append(f"Missing {display}/hermes symlink — run 'hermes doctor --fix'")
link_dir.mkdir(parents=True, exist_ok=True) link_dir.mkdir(parents=True, exist_ok=True)
verb = "Created" verb = "Created"
link.symlink_to(venv_bin) if pm_launcher:
check_ok(f"{verb} symlink: {display}/hermes → {venv_bin}") from hermes_cli._launchers import stage_launcher
if stage_launcher("hermes", PROJECT_ROOT, link_dir) is None:
check_fail("Could not publish Hermes launcher")
return f.manual_issues.append("Repair the PM store interpreter through the installation owner, then rerun 'hermes doctor --fix'")
check_ok(f"{verb} PM launcher: {display}/hermes")
else:
if link.is_symlink():
link.unlink()
link.symlink_to(venv_bin)
check_ok(f"{verb} symlink: {display}/hermes → {venv_bin}")
f.fixed += 1 f.fixed += 1
if verb == "Created" and str(link_dir) not in os.environ.get("PATH", "").split(os.pathsep): if verb == "Created" and str(link_dir) not in os.environ.get("PATH", "").split(os.pathsep):
check_warn(f"{display} is not on your PATH", "(add it to your shell config: export PATH=\"$HOME/.local/bin:$PATH\")") check_warn(f"{display} is not on your PATH", "(add it to your shell config: export PATH=\"$HOME/.local/bin:$PATH\")")

View File

@@ -1763,11 +1763,13 @@ class TestStagedRuntimeVenv:
assert "runs outside it" in out assert "runs outside it" in out
assert "active in this process" not in out assert "active in this process" not in out
def test_staged_and_active_names_both_facts(self, monkeypatch, capsys): def test_staged_and_active_names_both_facts(self, monkeypatch, capsys, tmp_path):
staged = Path("/payload/venv") from hermes_cli.runtime_paths import site_packages
staged = tmp_path / "venv"
site_packages(staged).mkdir(parents=True)
monkeypatch.setattr(doctor_platform, "_staged_venv_dir", lambda: staged) monkeypatch.setattr(doctor_platform, "_staged_venv_dir", lambda: staged)
monkeypatch.setattr(doctor_platform.sys, "prefix", str(staged)) monkeypatch.syspath_prepend(str(site_packages(staged)))
monkeypatch.setattr(doctor_platform.sys, "base_prefix", "/usr")
doctor_platform._check_python_environment(False) doctor_platform._check_python_environment(False)

View File

@@ -1,132 +1,193 @@
"""Tests for the Command Installation check in hermes doctor.""" """Command diagnostics use the same selection and launch contract as setup."""
import sys import json
import types
from argparse import Namespace from argparse import Namespace
from pathlib import Path from pathlib import Path
import shutil
import subprocess
import sys
import pytest import pytest
import hermes_cli.doctor as doctor_mod from hermes_cli import _launchers, doctor, doctor_platform
from hermes_cli.runtime_paths import install_state_dir, site_packages
def _setup_doctor_env(monkeypatch, tmp_path, venv_name="venv"): def _tree(tmp_path, monkeypatch):
"""Create a minimal HERMES_HOME + PROJECT_ROOT for doctor tests.""" home = tmp_path / "data"
home = tmp_path / ".hermes" home.mkdir()
home.mkdir(parents=True, exist_ok=True) project = tmp_path / "source"
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8") project.mkdir()
(project / ".install_method").write_text("git", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(home / "tools"))
monkeypatch.delenv("PREFIX", raising=False)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setattr(doctor, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor, "HERMES_HOME", home)
monkeypatch.setattr(doctor, "DOCTOR_CHECKS", ((None, doctor_platform._check_command_installation),))
command = tmp_path / ".local" / "bin" / "hermes"
command.parent.mkdir(parents=True)
monkeypatch.setenv("PATH", str(command.parent))
return project, home, command
project = tmp_path / "project"
project.mkdir(exist_ok=True)
# Create a fake venv entry point def _generation(project):
venv_bin_dir = project / venv_name / "bin" selected = install_state_dir(project) / "environments" / "selected" / "venv"
venv_bin_dir.mkdir(parents=True, exist_ok=True) site_packages(selected).mkdir(parents=True)
hermes_bin = venv_bin_dir / "hermes" (selected / "pyvenv.cfg").write_text("home = fixture\n", encoding="utf-8")
hermes_bin.write_text("#!/usr/bin/env python\n# entry point\n") (install_state_dir(project) / "facts.json").write_text(
hermes_bin.chmod(0o755) json.dumps({"packages": {"venv": {"environment": str(selected)}}}), encoding="utf-8"
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
# Stub model_tools so doctor doesn't fail on import
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
) )
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) return selected
# Stub auth checks
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
except Exception:
pass
# Stub httpx.get to avoid network calls
try:
import httpx
monkeypatch.setattr(httpx, "get", lambda *a, **kw: types.SimpleNamespace(status_code=200))
except Exception:
pass
return home, project, hermes_bin
def _run_doctor(fix=False): def _pm_source(project, home):
"""Run doctor and capture stdout.""" root = Path(__file__).resolve().parents[2]
import io for relative in (
import contextlib "hermes", "hermes_bootstrap.py", "hermes_constants.py", "hermes_cli/__init__.py",
"hermes_cli/runtime_paths.py", "hermes_cli/runtime_state.py",
buf = io.StringIO() "hermes_cli/_early_recovery.py", "hermes_cli/_parser.py",
with contextlib.redirect_stdout(buf): ):
doctor_mod.run_doctor(Namespace(fix=fix)) target = project / relative
return buf.getvalue() target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(root / relative, target)
(project / "hermes_cli/main.py").write_text(
"def main():\n import selected_probe\n print(selected_probe.VALUE)\n return 0\n",
encoding="utf-8",
)
interpreter = home / "tools" / "python-fixture" / "bin" / "python3"
interpreter.parent.mkdir(parents=True)
interpreter.symlink_to(Path(sys._base_executable).resolve())
(home / "tools" / "facts.json").write_text(
json.dumps({"packages": {"python": {"entry": "python-fixture"}}}), encoding="utf-8"
)
return interpreter
class TestDoctorCommandInstallation: @pytest.mark.platforms("posix")
"""Tests for the ◆ Command Installation section.""" def test_pm_generation_does_not_require_a_legacy_console_script(tmp_path, monkeypatch, capsys):
project, home, command = _tree(tmp_path, monkeypatch)
selected = _generation(project)
_pm_source(project, home)
assert _launchers.stage_launcher("hermes", project, command.parent) == command
doctor.run_doctor(Namespace(fix=True))
out = capsys.readouterr().out
assert "All checks passed" in out
assert not (project / "venv").exists()
assert not (selected / "bin" / "hermes").exists()
assert command.is_file() and not command.is_symlink()
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("prior", ["missing", "legacy"])
def test_pm_fix_publishes_a_generation_aware_launcher(tmp_path, monkeypatch, capsys, prior):
project, home, command = _tree(tmp_path, monkeypatch)
_pm_source(project, home)
selected = _generation(project)
(site_packages(selected) / "selected_probe.py").write_text("VALUE = 'selected'\n", encoding="utf-8")
stale = project / "venv" / "bin" / "hermes"
stale.parent.mkdir(parents=True)
stale.write_text("#!/bin/sh\nexit 99\n", encoding="utf-8")
stale.chmod(0o755)
if prior == "legacy":
command.symlink_to(stale)
doctor.run_doctor(Namespace(fix=True))
assert "Fixed 1 issue" in capsys.readouterr().out
assert not command.is_symlink()
result = subprocess.run([str(command)], cwd=tmp_path, capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stdout + result.stderr
assert result.stdout.strip() == "selected"
assert stale.is_file()
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("kind", ["wrapper", "symlink"])
def test_fix_preserves_user_managed_commands(tmp_path, monkeypatch, capsys, kind):
project, home, command = _tree(tmp_path, monkeypatch)
_pm_source(project, home)
_generation(project)
wrapper = tmp_path / "custom-wrapper"
body = "#!/bin/sh\nexit 42\n"
wrapper.write_text(body, encoding="utf-8")
wrapper.chmod(0o755)
if kind == "symlink":
command.symlink_to(wrapper)
else:
shutil.copy2(wrapper, command)
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only") doctor.run_doctor(Namespace(fix=True))
def test_fix_repairs_wrong_symlink(self, monkeypatch, tmp_path):
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
# Create a symlink pointing to wrong target out = capsys.readouterr().out
cmd_link_dir = tmp_path / ".local" / "bin" assert "Fixed 1 issue" not in out
cmd_link_dir.mkdir(parents=True) assert command.read_text(encoding="utf-8") == body
cmd_link = cmd_link_dir / "hermes" if kind == "symlink":
wrong_target = tmp_path / "wrong_hermes" assert command.is_symlink() and command.resolve() == wrapper
wrong_target.write_text("#!/usr/bin/env python\n") assert "manual" in out.lower()
cmd_link.symlink_to(wrong_target)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out = _run_doctor(fix=True) @pytest.mark.parametrize("layout", ["legacy", "generation"])
assert "Fixed symlink" in out @pytest.mark.parametrize("active", [True, False])
def test_doctor_reports_selected_import_tree_not_interpreter_prefix(tmp_path, monkeypatch, capsys, layout, active):
import importlib
import pm.paths
# Verify the symlink now points to the correct target project, _home, _command = _tree(tmp_path, monkeypatch)
assert cmd_link.is_symlink() if layout == "generation":
assert cmd_link.resolve() == hermes_bin.resolve() selected = _generation(project)
else:
selected = project / "venv"
site_packages(selected).mkdir(parents=True)
(selected / "pyvenv.cfg").write_text("home = fixture\n", encoding="utf-8")
monkeypatch.setattr(pm.paths, "repo_root", lambda: project)
monkeypatch.setattr(doctor, "DOCTOR_CHECKS", ((None, doctor_platform._check_python_environment),))
# A venv prefix with no selected imports is not evidence of activation;
# conversely PM's base interpreter can import the selected tree directly.
monkeypatch.setattr(sys, "prefix", sys.base_prefix if active else str(selected))
if active:
module = site_packages(selected) / "doctor_selected_probe.py"
module.write_text("VALUE = 'selected'\n", encoding="utf-8")
monkeypatch.syspath_prepend(str(site_packages(selected)))
monkeypatch.delitem(sys.modules, "doctor_selected_probe", raising=False)
loaded = importlib.import_module("doctor_selected_probe")
assert Path(loaded.__file__) == module
monkeypatch.delitem(sys.modules, "doctor_selected_probe")
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only") doctor.run_doctor(Namespace(fix=False))
def test_missing_venv_entry_point_shows_warn(self, monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
project = tmp_path / "project" out = capsys.readouterr().out
project.mkdir(exist_ok=True) assert str(selected) in out
# Do NOT create any venv entry point assert ("active in this process" in out) is active
assert ("runs outside it" in out) is not active
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
fake_model_tools = types.SimpleNamespace( @pytest.mark.platforms("posix")
check_tool_availability=lambda *a, **kw: ([], []), @pytest.mark.parametrize("method, remedy", [
TOOLSET_REQUIREMENTS={}, ("git", "hermes pm repair"), ("nix", "Nix"), ("docker", "docker pull"), ("apt", "pkg upgrade"),
) ])
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) def test_remedies_and_launcher_repairs_respect_install_owner(tmp_path, monkeypatch, capsys, method, remedy):
try: project, _home, command = _tree(tmp_path, monkeypatch)
from hermes_cli import auth as _auth_mod (project / ".install_method").write_text(method, encoding="utf-8")
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) entry = project / "venv" / "bin" / "hermes"
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) entry.parent.mkdir(parents=True)
except Exception: entry.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
pass entry.chmod(0o755)
try: monkeypatch.setattr(doctor_platform, "_PACKAGES", (("doctor_missing_dependency_probe", "Required dependency", False),))
import httpx monkeypatch.setattr(doctor, "DOCTOR_CHECKS", (
monkeypatch.setattr(httpx, "get", lambda *a, **kw: types.SimpleNamespace(status_code=200)) (None, doctor_platform._check_required_packages), (None, doctor_platform._check_command_installation),
except Exception: ))
pass
out = _run_doctor(fix=False) doctor.run_doctor(Namespace(fix=True))
assert "Command Installation" in out
assert "Venv entry point not found" in out
out = capsys.readouterr().out
assert remedy in out
assert "pip install" not in out
if method == "git":
assert command.is_symlink() and command.resolve() == entry
else:
assert not command.exists()
assert "hermes pm repair" not in out