diff --git a/bootstrap.py b/bootstrap.py index fde976b0..71db9640 100644 --- a/bootstrap.py +++ b/bootstrap.py @@ -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: diff --git a/tests/test_bootstrap_discover_agent.py b/tests/test_bootstrap_discover_agent.py new file mode 100644 index 00000000..3e60fbe8 --- /dev/null +++ b/tests/test_bootstrap_discover_agent.py @@ -0,0 +1,107 @@ +"""Tests for `discover_agent_dir` shebang-based fallback. + +When the standard candidate paths (`~/.hermes/hermes-agent`, `~/hermes-agent`, +`/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()