diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e10af44cd9..1bca6f0e5f 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5956,6 +5956,88 @@ def _cmd_update_check(): print(f" Run '{recommended_update_command()}' to install.") +def _ensure_fhs_path_guard() -> None: + """Ensure /usr/local/bin is on PATH for RHEL-family root non-login shells. + + Mirrors the post-symlink probe added to ``scripts/install.sh`` so that + existing FHS-layout root installs on RHEL/CentOS/Rocky/Alma 8+ get + repaired on ``hermes update`` without requiring a reinstall. The + installer's assumption that ``/usr/local/bin`` is on PATH for every + standard shell breaks on those distros in non-login interactive shells + (su, sudo -s, tmux panes, some web terminals): /etc/bashrc doesn't + add /usr/local/bin and /root/.bash_profile doesn't either. Symptom: + ``hermes`` prints ``command not found`` even though the symlink lives + at /usr/local/bin/hermes. + + Silent no-op on: non-Linux, non-root, non-FHS installs, and any system + where ``bash -i -c 'command -v hermes'`` already resolves. Idempotent. + """ + if sys.platform != "linux": + return + try: + if os.geteuid() != 0: + return + except AttributeError: + return + # Only act when this is actually an FHS-layout install (command link at + # /usr/local/bin/hermes, code at /usr/local/lib/hermes-agent). + fhs_link = Path("/usr/local/bin/hermes") + if not fhs_link.is_symlink() and not fhs_link.exists(): + return + + # Probe a fresh non-login interactive bash the way the user will use it. + # ``bash -i -c`` sources ~/.bashrc but NOT ~/.bash_profile or /etc/profile, + # which is the exact scenario where RHEL root loses /usr/local/bin. + home = os.environ.get("HOME") or "/root" + try: + probe = subprocess.run( + ["env", "-i", + f"HOME={home}", + f"TERM={os.environ.get('TERM', 'dumb')}", + "bash", "-i", "-c", "command -v hermes"], + capture_output=True, text=True, timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return # no bash or probe hung — don't block update on this + if probe.returncode == 0: + return # already on PATH, nothing to do + + path_line = 'export PATH="/usr/local/bin:$PATH"' + path_comment = ( + "# Hermes Agent — ensure /usr/local/bin is on PATH " + "(RHEL non-login shells)" + ) + wrote_any = False + for candidate in (".bashrc", ".bash_profile"): + cfg = Path(home) / candidate + if not cfg.is_file(): + continue + try: + existing = cfg.read_text(errors="replace") + except OSError: + continue + # Idempotency: skip if any uncommented PATH= line already references + # /usr/local/bin. Mirrors the grep pattern used by install.sh. + already_guarded = any( + "/usr/local/bin" in line + and "PATH" in line + and not line.lstrip().startswith("#") + for line in existing.splitlines() + ) + if already_guarded: + continue + try: + with cfg.open("a", encoding="utf-8") as f: + f.write("\n" + path_comment + "\n" + path_line + "\n") + except OSError as e: + print(f" ⚠ Could not update {cfg}: {e}") + continue + print(f" ✓ Added /usr/local/bin to PATH in {cfg}") + wrote_any = True + if wrote_any: + print(" (reload your shell or run 'source ~/.bashrc' to pick it up)") + + def cmd_update(args): """Update Hermes Agent to the latest version. @@ -6399,6 +6481,13 @@ def _cmd_update_impl(args, gateway_mode: bool): print() print("✓ Update complete!") + # Repair RHEL-family root installs where /usr/local/bin isn't on PATH + # for non-login interactive shells. No-op on every other platform. + try: + _ensure_fhs_path_guard() + except Exception as e: + logger.debug("FHS PATH guard check failed: %s", e) + # Write exit code *before* the gateway restart attempt. # When running as ``hermes update --gateway`` (spawned by the gateway's # /update command), this process lives inside the gateway's systemd diff --git a/scripts/install.sh b/scripts/install.sh index e9a6aae992..8e8b4d9a13 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1055,10 +1055,37 @@ setup_path() { return 0 fi - # FHS layout: /usr/local/bin is on PATH for every standard shell, nothing to inject. + # FHS layout: /usr/local/bin is normally on PATH for login shells (via + # /etc/profile pathmunge), but on RHEL/CentOS/Rocky/Alma 8+ non-login + # interactive root shells (su, sudo -s, tmux panes, some web terminals) + # only source /etc/bashrc, which does NOT add /usr/local/bin — and + # /root/.bash_profile doesn't either. So verify with `command -v` and + # fall back to writing a PATH guard into /root/.bashrc when needed. if [ "$ROOT_FHS_LAYOUT" = true ]; then export PATH="$command_link_dir:$PATH" - log_info "/usr/local/bin is already on PATH for all shells" + # Probe a fresh non-login interactive bash the way the user will use it. + # `bash -i -c` sources ~/.bashrc but NOT ~/.bash_profile or /etc/profile, + # which is the exact scenario where RHEL root loses /usr/local/bin. + if env -i HOME="$HOME" TERM="${TERM:-dumb}" bash -i -c 'command -v hermes' \ + >/dev/null 2>&1; then + log_info "/usr/local/bin is already on PATH for all shells" + log_success "hermes command ready" + return 0 + fi + + log_info "hermes not on PATH in non-login shells (common on RHEL-family)" + PATH_LINE='export PATH="/usr/local/bin:$PATH"' + PATH_COMMENT='# Hermes Agent — ensure /usr/local/bin is on PATH (RHEL non-login shells)' + for SHELL_CONFIG in "$HOME/.bashrc" "$HOME/.bash_profile"; do + [ -f "$SHELL_CONFIG" ] || continue + if ! grep -v '^[[:space:]]*#' "$SHELL_CONFIG" 2>/dev/null \ + | grep -qE 'PATH=.*(/usr/local/bin|\$command_link_dir)'; then + echo "" >> "$SHELL_CONFIG" + echo "$PATH_COMMENT" >> "$SHELL_CONFIG" + echo "$PATH_LINE" >> "$SHELL_CONFIG" + log_success "Added /usr/local/bin to PATH in $SHELL_CONFIG" + fi + done log_success "hermes command ready" return 0 fi