From 897dc3a2bb3028cc21b6d227b1845f4990e42e07 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:22:37 -0700 Subject: [PATCH] fix(install+update): add /usr/local/bin PATH guard for RHEL root non-login shells (#16191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(install): add /usr/local/bin PATH guard for RHEL root non-login shells The FHS-layout branch assumed /usr/local/bin is on PATH for every standard shell. That holds for login shells (via /etc/profile's pathmunge) but breaks on RHEL/CentOS/Rocky/Alma 8+ root in non-login interactive shells (su, sudo -s, tmux panes, some web terminals) — /etc/bashrc does not add /usr/local/bin and /root/.bash_profile doesn't either. Result: hermes command links to /usr/local/bin/hermes but the user has to type the absolute path each time. Probe a fresh 'bash -i -c' (non-login interactive, matching the user scenario) after symlinking. If hermes isn't resolvable, append an idempotent PATH guard to /root/.bashrc and /root/.bash_profile, same grep pattern already used by the ~/.local/bin branch below. No change on distros where /usr/local/bin is already inherited. * fix(update): repair RHEL root PATH on hermes update Existing RHEL/CentOS/Rocky/Alma root installs won't be repaired by the install.sh fix alone because 'hermes update' is an in-place git pull, not a rerun of install.sh. Port the same probe + idempotent .bashrc write into cmd_update so affected users get fixed automatically on next update. _ensure_fhs_path_guard() runs after 'Update complete!': - Linux + root + FHS-layout install (command at /usr/local/bin/hermes) only - Probe: env -i bash -i -c 'command -v hermes' — fresh non-login interactive shell, same scenario the user reports - On failure, append PATH guard to /root/.bashrc and /root/.bash_profile, skipping if any uncommented PATH line already mentions /usr/local/bin - Silent no-op on macOS, non-root, legacy layout, or shells that already resolve hermes --- hermes_cli/main.py | 89 ++++++++++++++++++++++++++++++++++++++++++++++ scripts/install.sh | 31 ++++++++++++++-- 2 files changed, 118 insertions(+), 2 deletions(-) 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