fix(bootstrap): discover agent installed as root (FHS) or behind the bash launcher
A fresh root install on Linux (e.g. a new Proxmox/Ubuntu VM) places the Hermes Agent at /usr/local/lib/hermes-agent and links the CLI into /usr/local/bin, while the modern `hermes` launcher is a `#!/usr/bin/env bash` wrapper that exec's the venv entrypoint. The old discovery only checked ~/.hermes/hermes-agent-style paths and parsed the launcher's shebang as a Python interpreter, so on a root VM discover_agent_dir() returned None, bootstrap built a WebUI-only .venv, and chat aborted at launch with "Python environment cannot import both WebUI dependencies and Hermes Agent." Two complementary fixes: - Add /usr/local/lib/hermes-agent to discover_agent_dir()'s candidate list. - Harden _agent_dir_from_hermes_cli() to follow the launcher's exec target (any absolute path quoted in the wrapper) up to run_agent.py, not just a Python shebang. Skips /usr/bin/env indirection. Now `python3 bootstrap.py` works out of the box for root/container installs with no HERMES_WEBUI_PYTHON override needed. Adds tests for the bash-wrapper shape, the env-indirection no-false-positive case, and the FHS candidate probe; updates docs/troubleshooting.md.
This commit is contained in:
1 parent
f5a0d047ed
commit
11625aac1a
4 files changed
+145
-19
No files matched your search
@@ -3,6 +3,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`bootstrap.py` now finds the Hermes Agent when it was installed as root (FHS layout) or behind the current shell-wrapper `hermes` launcher (#root-install).** A fresh root install on Linux places the agent at `/usr/local/lib/hermes-agent` (CLI linked into `/usr/local/bin`), and the modern `hermes` launcher is a `#!/usr/bin/env bash` wrapper that `exec`s the venv entrypoint. The old discovery only checked `~/.hermes/hermes-agent`-style paths and parsed the launcher's shebang as a Python interpreter, so on a root VM it found nothing, built a WebUI-only `.venv`, and aborted with "Python environment cannot import both WebUI dependencies and Hermes Agent." Discovery now also probes `/usr/local/lib/hermes-agent` and follows the launcher's `exec` target (any absolute path inside the script) up to `run_agent.py`, so `python3 bootstrap.py` works out of the box for root/Proxmox/container installs with no `HERMES_WEBUI_PYTHON` override needed.
|
||||
|
||||
## [v0.51.560] — 2026-06-21 — Release TS (recovered run-journal output reaches the model)
|
||||
|
||||
### Fixed
|
||||
|
||||
+62
-19
@@ -6,6 +6,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -103,18 +104,36 @@ def ensure_supported_platform() -> None:
|
||||
)
|
||||
|
||||
|
||||
def _walk_up_for_run_agent(start: Path) -> Path | None:
|
||||
"""Walk up the parents of ``start`` and return the first dir with run_agent.py."""
|
||||
for parent in start.parents:
|
||||
if (parent / "run_agent.py").exists():
|
||||
return parent.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def _agent_dir_from_hermes_cli() -> Path | None:
|
||||
"""Resolve the agent install root by inspecting the `hermes` CLI shebang.
|
||||
"""Resolve the agent install root by inspecting the `hermes` CLI launcher.
|
||||
|
||||
The Hermes Agent installer drops a `hermes` console-script in the user's
|
||||
PATH whose shebang points at the agent's bundled venv:
|
||||
The Hermes Agent installer drops a `hermes` launcher in the user's PATH.
|
||||
It comes in two shapes depending on installer version:
|
||||
|
||||
#!/path/to/hermes-agent/venv/bin/python3
|
||||
1. A Python console-script whose shebang points at the agent's venv::
|
||||
|
||||
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.
|
||||
#!/path/to/hermes-agent/venv/bin/python3
|
||||
|
||||
2. A small POSIX shell wrapper that ``exec``s the real venv entrypoint
|
||||
(the current installer shape — clears PYTHONPATH/PYTHONHOME first)::
|
||||
|
||||
#!/usr/bin/env bash
|
||||
exec "/path/to/hermes-agent/venv/bin/hermes" "$@"
|
||||
|
||||
In both cases an absolute path inside the launcher points into the agent's
|
||||
venv. Walking up its parents until we find a directory containing
|
||||
`run_agent.py` recovers the install root regardless of where the agent
|
||||
lives — e.g. the root-on-Linux FHS layout (`/usr/local/lib/hermes-agent`)
|
||||
or a custom clone (`~/Projects/GitHub/hermes-agent`) — neither of which the
|
||||
hard-coded candidate list in :func:`discover_agent_dir` can know about.
|
||||
|
||||
Last-resort only: this is invoked after every explicit candidate
|
||||
(`HERMES_WEBUI_AGENT_DIR`, `$HERMES_HOME/hermes-agent`, etc.) has missed.
|
||||
@@ -126,21 +145,40 @@ def _agent_dir_from_hermes_cli() -> Path | None:
|
||||
if not hermes_path:
|
||||
return None
|
||||
try:
|
||||
# The launcher is tiny; read a bounded prefix so we never slurp a huge
|
||||
# file if `hermes` resolves to something unexpected.
|
||||
with open(hermes_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
first_line = f.readline().strip()
|
||||
lines = [f.readline() for _ in range(20)]
|
||||
except OSError:
|
||||
return None
|
||||
if not first_line.startswith("#!"):
|
||||
if not lines or not lines[0].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()
|
||||
|
||||
# Collect every absolute path the launcher references — the shebang
|
||||
# interpreter (Python-console-script shape) plus any quoted path in an
|
||||
# `exec`/wrapper line (shell-wrapper shape). A `#!/usr/bin/env bash`
|
||||
# shebang yields a useless `/usr/bin/env`, so the wrapper's exec target is
|
||||
# what actually points at the agent venv.
|
||||
candidate_paths: list[Path] = []
|
||||
|
||||
shebang_field = lines[0][2:].strip().split(None, 1)
|
||||
if shebang_field:
|
||||
interp = Path(shebang_field[0])
|
||||
# Skip env-style indirection (`/usr/bin/env bash`) — env itself is not
|
||||
# in the agent tree; the real target is the wrapped exec line below.
|
||||
if interp.is_absolute() and interp.name != "env":
|
||||
candidate_paths.append(interp)
|
||||
|
||||
for line in lines[1:]:
|
||||
for match in re.findall(r"""['"](/[^'"]+)['"]""", line):
|
||||
candidate_paths.append(Path(match))
|
||||
|
||||
for candidate in candidate_paths:
|
||||
if not candidate.is_absolute():
|
||||
continue
|
||||
found = _walk_up_for_run_agent(candidate)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
@@ -152,6 +190,11 @@ def discover_agent_dir() -> Path | None:
|
||||
str(REPO_ROOT.parent / "hermes-agent"),
|
||||
str(Path.home() / ".hermes" / "hermes-agent"),
|
||||
str(Path.home() / "hermes-agent"),
|
||||
# Root-on-Linux FHS layout: the installer puts agent code under
|
||||
# /usr/local/lib and links the CLI into /usr/local/bin (matches
|
||||
# Claude Code / Codex). HERMES_HOME stays at /root/.hermes, so the
|
||||
# `home / "hermes-agent"` candidate above does NOT cover this case.
|
||||
"/usr/local/lib/hermes-agent",
|
||||
]
|
||||
for raw in candidates:
|
||||
if not raw:
|
||||
|
||||
@@ -15,6 +15,7 @@ If your symptom isn't listed and the diagnostics don't narrow it down, file a bu
|
||||
1. **Agent installed but not on `sys.path`.** Most common. The agent is checked out somewhere (e.g. `~/Programmes/hermes-agent`), the WebUI was launched with a Python that doesn't know about it, and there's no `pip install -e .` linking the two.
|
||||
2. **Symlink with a typo or wrong target.** A symlink to the agent looks correct on `ls`, but `readlink` resolves to a path that doesn't exist or doesn't contain `agent/__init__.py`.
|
||||
3. **`HERMES_WEBUI_AGENT_DIR` set to the wrong directory.** Override env var beats auto-discovery and points at a directory that has no agent code.
|
||||
4. **Agent installed as root, under the FHS layout.** When the Hermes Agent installer runs as root on Linux it places the agent at `/usr/local/lib/hermes-agent` (CLI linked into `/usr/local/bin`), not `~/.hermes/hermes-agent`. Older `bootstrap.py` didn't probe that path, so it built a WebUI-only `.venv` and failed at launch with **"Python environment cannot import both WebUI dependencies and Hermes Agent."** `git pull` to update the WebUI (current `bootstrap.py` auto-discovers the FHS layout and follows the `hermes` launcher to the agent), or set `HERMES_WEBUI_PYTHON=/usr/local/lib/hermes-agent/venv/bin/python` and relaunch.
|
||||
|
||||
### Step 1 — confirm the agent location
|
||||
|
||||
|
||||
@@ -46,6 +46,30 @@ def _make_hermes_cli(tmp_path, shebang_target: str | None):
|
||||
return hermes
|
||||
|
||||
|
||||
def _make_hermes_bash_wrapper(tmp_path, exec_target: str):
|
||||
"""Write a `hermes` POSIX shell wrapper that ``exec``s the venv entrypoint.
|
||||
|
||||
This is the current installer shape: a bash wrapper whose shebang is
|
||||
``#!/usr/bin/env bash`` (so the shebang itself points at /usr/bin/env, not
|
||||
the agent), and whose ``exec`` line carries the real venv path.
|
||||
"""
|
||||
bin_dir = tmp_path / "user-bin"
|
||||
bin_dir.mkdir(exist_ok=True)
|
||||
hermes = bin_dir / "hermes"
|
||||
hermes.write_text(
|
||||
textwrap.dedent(
|
||||
f"""\
|
||||
#!/usr/bin/env bash
|
||||
unset PYTHONPATH
|
||||
unset PYTHONHOME
|
||||
exec "{exec_target}" "$@"
|
||||
"""
|
||||
),
|
||||
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)
|
||||
@@ -111,3 +135,57 @@ def test_explicit_candidate_takes_precedence_over_shebang(monkeypatch, tmp_path)
|
||||
monkeypatch.setenv("HERMES_WEBUI_AGENT_DIR", str(explicit_install))
|
||||
|
||||
assert bootstrap.discover_agent_dir() == explicit_install.resolve()
|
||||
|
||||
|
||||
def test_discovers_agent_dir_from_hermes_bash_wrapper(monkeypatch, tmp_path):
|
||||
"""Current installer shape: a `#!/usr/bin/env bash` wrapper that execs the
|
||||
venv entrypoint. The shebang is useless (/usr/bin/env), so discovery must
|
||||
follow the quoted exec target up to run_agent.py. Regression for the
|
||||
root-on-Linux report where bootstrap built a deps-only local venv and chat
|
||||
failed with 'cannot import both WebUI dependencies and Hermes Agent'."""
|
||||
install, _venv_python = _make_agent_install(tmp_path)
|
||||
venv_hermes = install / "venv" / "bin" / "hermes"
|
||||
venv_hermes.write_text("", encoding="utf-8")
|
||||
hermes = _make_hermes_bash_wrapper(tmp_path, str(venv_hermes))
|
||||
_isolate_discover_agent_dir(monkeypatch, tmp_path, hermes)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
assert bootstrap.discover_agent_dir() == install.resolve()
|
||||
|
||||
|
||||
def test_root_fhs_layout_is_in_candidate_list(monkeypatch, tmp_path):
|
||||
"""Root-on-Linux installs put agent code at /usr/local/lib/hermes-agent and
|
||||
link the CLI into /usr/local/bin. HERMES_HOME stays at /root/.hermes, so the
|
||||
`home / 'hermes-agent'` candidate never covers it. Verify the explicit FHS
|
||||
path is probed by discover_agent_dir() — we can't create a real /usr/local
|
||||
dir in tests, so capture the candidates the function actually checks by
|
||||
stubbing Path.exists to record probed paths."""
|
||||
monkeypatch.setattr(bootstrap.shutil, "which", lambda name: None)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "root-dot-hermes"))
|
||||
monkeypatch.delenv("HERMES_WEBUI_AGENT_DIR", raising=False)
|
||||
monkeypatch.setattr(bootstrap, "REPO_ROOT", tmp_path / "isolated-repo-root")
|
||||
monkeypatch.setattr(bootstrap.Path, "home", classmethod(lambda cls: tmp_path / "isolated-home"))
|
||||
|
||||
probed: list[str] = []
|
||||
real_exists = bootstrap.Path.exists
|
||||
|
||||
def recording_exists(self):
|
||||
probed.append(str(self))
|
||||
return real_exists(self)
|
||||
|
||||
monkeypatch.setattr(bootstrap.Path, "exists", recording_exists)
|
||||
|
||||
bootstrap.discover_agent_dir()
|
||||
|
||||
assert any(p == "/usr/local/lib/hermes-agent" for p in probed), (
|
||||
f"/usr/local/lib/hermes-agent was not probed; checked: {probed}"
|
||||
)
|
||||
|
||||
|
||||
def test_bash_wrapper_without_agent_target_returns_none(monkeypatch, tmp_path):
|
||||
"""A bash wrapper whose exec target is a system path (no run_agent.py in any
|
||||
parent) must not false-positive."""
|
||||
hermes = _make_hermes_bash_wrapper(tmp_path, "/usr/bin/python3")
|
||||
_isolate_discover_agent_dir(monkeypatch, tmp_path, hermes)
|
||||
|
||||
assert bootstrap.discover_agent_dir() is None
|
||||
Reference in new issue
Block a user