fix(bootstrap): discover agent dir via hermes CLI shebang

`discover_agent_dir()` only checked four hard-coded layouts:

  - HERMES_WEBUI_AGENT_DIR
  - $HERMES_HOME/hermes-agent
  - <webui-parent>/hermes-agent
  - ~/.hermes/hermes-agent / ~/hermes-agent

Users who clone hermes-agent somewhere else (e.g. ~/Projects/GitHub/hermes-agent)
hit:

    [bootstrap] ERROR: Python environment cannot import both WebUI dependencies
    and Hermes Agent. Set HERMES_WEBUI_PYTHON to the Hermes Agent venv Python
    or install the WebUI requirements into that environment.

…even though the `hermes` CLI is on PATH and works fine. The CLI is a
console-script with a venv-relative shebang:

    #!/path/to/hermes-agent/venv/bin/python3

After the explicit candidates miss, fall back to introspecting that shebang
and walking up parents until we find `run_agent.py`. That's a reliable
pointer to the install root regardless of where the user cloned the repo.

Tests cover happy path, no `hermes` on PATH, missing/invalid shebang,
shebang pointing outside any agent install (e.g. /usr/bin/python3), and
explicit candidates winning over the shebang fallback.

Verified end-to-end: with hermes-agent at a non-standard path,
`uv run bootstrap.py` now succeeds without any HERMES_WEBUI_AGENT_DIR
override.
This commit is contained in:
Igor Tarasenko
2026-05-07 15:22:11 +02:00
committed by nesquena-hermes
parent 1706bbdcef
commit 9f72472896
2 changed files with 143 additions and 1 deletions

View File

@@ -90,6 +90,41 @@ def ensure_supported_platform() -> None:
)
def _agent_dir_from_hermes_cli() -> Path | None:
"""Resolve the agent install root by inspecting the `hermes` CLI shebang.
The Hermes Agent installer drops a `hermes` console-script in the user's
PATH whose shebang points at the agent's bundled venv:
#!/path/to/hermes-agent/venv/bin/python3
Walking up the parents until we find a directory that contains
`run_agent.py` recovers the install root regardless of where the user
chose to clone the agent (e.g. ~/Projects/GitHub/hermes-agent), which
the hard-coded candidate list in :func:`discover_agent_dir` cannot.
"""
hermes_path = shutil.which("hermes")
if not hermes_path:
return None
try:
with open(hermes_path, "r", encoding="utf-8", errors="replace") as f:
first_line = f.readline().strip()
except OSError:
return None
if not first_line.startswith("#!"):
return None
interp_field = first_line[2:].strip().split(None, 1)
if not interp_field:
return None
interp = Path(interp_field[0])
if not interp.is_absolute():
return None
for parent in interp.parents:
if (parent / "run_agent.py").exists():
return parent.resolve()
return None
def discover_agent_dir() -> Path | None:
home = Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser()
candidates = [
@@ -105,7 +140,7 @@ def discover_agent_dir() -> Path | None:
candidate = Path(raw).expanduser().resolve()
if candidate.exists() and (candidate / "run_agent.py").exists():
return candidate
return None
return _agent_dir_from_hermes_cli()
def discover_launcher_python(agent_dir: Path | None) -> str:

View File

@@ -0,0 +1,107 @@
"""Tests for `discover_agent_dir` shebang-based fallback.
When the standard candidate paths (`~/.hermes/hermes-agent`, `~/hermes-agent`,
`<webui-parent>/hermes-agent`, `HERMES_WEBUI_AGENT_DIR`) don't match, bootstrap
should fall back to introspecting the `hermes` console-script's shebang —
that's a reliable pointer to the install root because the installer writes the
venv-relative interpreter path there.
"""
from __future__ import annotations
import textwrap
import bootstrap
def _make_agent_install(tmp_path, *, with_run_agent: bool = True):
"""Build a fake hermes-agent install with venv/bin/python3 + run_agent.py."""
install = tmp_path / "agent"
venv_python = install / "venv" / "bin" / "python3"
venv_python.parent.mkdir(parents=True)
venv_python.write_text("", encoding="utf-8")
if with_run_agent:
(install / "run_agent.py").write_text("", encoding="utf-8")
return install, venv_python
def _make_hermes_cli(tmp_path, shebang_target: str | None):
"""Write a `hermes` console-script with the given shebang interpreter."""
bin_dir = tmp_path / "user-bin"
bin_dir.mkdir()
hermes = bin_dir / "hermes"
if shebang_target is None:
hermes.write_text("not a script", encoding="utf-8")
else:
hermes.write_text(
textwrap.dedent(
f"""\
#!{shebang_target}
from hermes_cli.main import main
main()
"""
),
encoding="utf-8",
)
return hermes
def _isolate_discover_agent_dir(monkeypatch, tmp_path, hermes_path):
"""Point `which("hermes")` at our fake CLI and clear all standard candidates."""
monkeypatch.setattr(bootstrap.shutil, "which", lambda name: str(hermes_path) if name == "hermes" else None)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "no-such-hermes-home"))
monkeypatch.delenv("HERMES_WEBUI_AGENT_DIR", raising=False)
# Force REPO_ROOT.parent to a dir that won't accidentally contain a
# `hermes-agent` sibling on the dev machine running these tests.
monkeypatch.setattr(bootstrap, "REPO_ROOT", tmp_path / "isolated-repo-root")
def test_discovers_agent_dir_from_hermes_shebang(monkeypatch, tmp_path):
"""Happy path: hermes shebang → walk up parents → find run_agent.py → return install."""
install, venv_python = _make_agent_install(tmp_path)
hermes = _make_hermes_cli(tmp_path, str(venv_python))
_isolate_discover_agent_dir(monkeypatch, tmp_path, hermes)
monkeypatch.chdir(tmp_path) # make Path.home() candidates won't match install
assert bootstrap.discover_agent_dir() == install.resolve()
def test_returns_none_when_hermes_not_on_path(monkeypatch, tmp_path):
_make_agent_install(tmp_path) # install exists, but no `hermes` CLI to point at it
_isolate_discover_agent_dir(monkeypatch, tmp_path, hermes_path=tmp_path / "missing")
monkeypatch.setattr(bootstrap.shutil, "which", lambda name: None)
assert bootstrap.discover_agent_dir() is None
def test_returns_none_when_hermes_has_no_shebang(monkeypatch, tmp_path):
"""A `hermes` file without a #! line gives us nothing to introspect."""
_make_agent_install(tmp_path)
hermes = _make_hermes_cli(tmp_path, shebang_target=None)
_isolate_discover_agent_dir(monkeypatch, tmp_path, hermes)
assert bootstrap.discover_agent_dir() is None
def test_returns_none_when_shebang_interpreter_does_not_have_run_agent(monkeypatch, tmp_path):
"""Shebang points at /usr/bin/python3 — no install root walks up to run_agent.py."""
_make_agent_install(tmp_path, with_run_agent=False) # install exists but no run_agent.py
hermes = _make_hermes_cli(tmp_path, "/usr/bin/python3")
_isolate_discover_agent_dir(monkeypatch, tmp_path, hermes)
assert bootstrap.discover_agent_dir() is None
def test_explicit_candidate_takes_precedence_over_shebang(monkeypatch, tmp_path):
"""HERMES_WEBUI_AGENT_DIR and the standard layout still win when present."""
explicit_install = tmp_path / "explicit"
(explicit_install).mkdir()
(explicit_install / "run_agent.py").write_text("", encoding="utf-8")
# Also set up a hermes-shebang install at a different location — this should NOT win.
other_install, venv_python = _make_agent_install(tmp_path)
hermes = _make_hermes_cli(tmp_path, str(venv_python))
_isolate_discover_agent_dir(monkeypatch, tmp_path, hermes)
monkeypatch.setenv("HERMES_WEBUI_AGENT_DIR", str(explicit_install))
assert bootstrap.discover_agent_dir() == explicit_install.resolve()