NousResearch/hermes-agent

Gateway process detection ps command compatibility issues

Cluster 9069 · hermes cli. Data as of

2 open items. Fix merged in #22860 on 10 May 2026. Check it covers your case.

The short answer

1open issue
1open PR
11items grouped
Known?
Yes. 11 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #22860. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #10569. That is separate from the fix event.
Still open
1 issue and 1 PR.

Multiple issues and fixes addressing incompatibilities with the ps command used for gateway process detection, particularly affecting Docker containers and FreeBSD where BSD-style flags like -ax and eww are not available or behave differently. Proposed solutions include switching to POSIX-compliant flags and/or installing the procps package.

Open issues1

  • Issue#9069[Bug]: hermes cron list: Gateway process detection fails on FreeBSD due to `ps eww` output format

Open PRs1

  • PR#9396Fix FreeBSD gateway process detection

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #10569 fix: use POSIX ps -A instead of BSD -ax for Docker compat

    merged to main

    files: hermes_cli/gateway.py

    Show the diff
    • hermes_cli/gateway.py
      @@ -222,7 +222,7 @@ def _matches_current_profile(command: str) -> bool:
                           current_cmd = ""
               else:
                   result = subprocess.run(
      -                ["ps", "eww", "-ax", "-o", "pid=,command="],
      +                ["ps", "-A", "eww", "-o", "pid=,command="],
                       capture_output=True,
                       text=True,
                       timeout=10,
  • #22860 fix(gateway): detect gateway process via /proc in Docker without procps (salvage #22693)

    merged to main

    files: hermes_cli/gateway.py, tests/hermes_cli/test_gateway.py, tests/hermes_cli/test_gateway_proc_fallback.py

    Show the diff
    • hermes_cli/gateway.py
      @@ -394,42 +394,68 @@ def _matches_current_profile(command: str) -> bool:
                                   pass
                           current_cmd = ""
               else:
      -            result = subprocess.run(
      -                ["ps", "-A", "eww", "-o", "pid=,command="],
      -                capture_output=True,
      -                text=True,
      -                timeout=10,
      -            )
      -            if result.returncode != 0:
      -                return []
      -            for line in result.stdout.split("\n"):
      -                stripped = line.strip()
      -                if not stripped or "grep" in stripped:
      -                    continue
      +            # Try /proc first (works in Docker without procps installed),
      +            # fall back to ps -A eww.
      +            _found_via_proc = False
      +            if os.path.isdir("/proc"):
      +                try:
      +                    my_pid = os.getpid()
      +                    for entry in os.listdir("/proc"):
      +                        if not entry.isdigit():
      +                            continue
      +                        pid = int(entry)
      +                        if pid == my_pid or pid in exclude_pids:
      +                            continue
      +                        try:
      +                            cmdline = open(f"/proc/{pid}/cmdline", "rb").read().decode("utf-8", errors="replace")
      +                            cmdline = cmdline.replace("\x00", " ")
      +                            if any(p in cmdline for p in patterns) and (
      +                                all_profiles or _matches_current_profile(cmdline)
      +                            ):
      +                                _append_unique_pid(pids, pid, exclude_pids)
      +                        except (OSError, PermissionError):
      +                            continue
      +                    _found_via_proc = True
      +                except Exception:
      +                    pass
       
      -                pid = None
      -                command = ""
      +            if not _found_via_proc:
      +                result = subprocess.run(
      +                    ["ps", "-A", "eww", "-o", "pid=,command="],
      +                    capture_output=True,
      +                    text=True,
      +                    timeout=10,
      +                )
      +                if result.returncode != 0:
      +                    return []
      +                for line in result.stdout.split("\n"):
      +                    stripped = line.strip()
      +                    if not stripped or "grep" in stripped:
      +                        continue
       
      -                parts = stripped.split(None, 1)
      -                if len(parts) == 2:
      -                    try:
      -                        pid = int(parts[0])
      -                        command = parts[1]
      -                    except ValueError:
      -                        pid = None
      -
      -                if pid is None:
      -                    aux_parts = stripped.split()
      -                    if len(aux_parts) > 10 and aux_parts[1].isdigit():
      -                        pid = int(aux_parts[1])
      -                        command = " ".join(aux_parts[10:])
      -
      -                if pid is None:
      -                    continue
      -                if any(pattern in command for pattern in patterns) and (
      -                    all_profiles or _matches_current_profile(command)
      -                ):
      -                    _append_unique_pid(pids, pid, exclude_pids)
      +                    pid = None
      +                    command = ""
      +
      +                    parts = stripped.split(None, 1)
      +                    if len(parts) == 2:
      +                        try:
      +                            pid = int(parts[0])
      +                            command = parts[1]
      +                        except ValueError:
      +                            pid = None
      +
      +                    if pid is None:
      +                        aux_parts = stripped.split()
      +                        if len(aux_parts) > 10 and aux_parts[1].isdigit():
      +                            pid = int(aux_parts[1])
      +                            command = " ".join(aux_parts[10:])
      +
      +                    if pid is None:
      +                        continue
      +                    if any(pattern in command for pattern in patterns) and (
      +                        all_profiles or _matches_current_profile(command)
      +                    ):
      +                        _append_unique_pid(pids, pid, exclude_pids)
           except (OSError, subprocess.TimeoutExpired):
               return []
       
    • tests/hermes_cli/test_gateway.py
      @@ -404,6 +404,15 @@ def test_find_gateway_pids_falls_back_to_pid_file_when_process_scan_fails(monkey
           monkeypatch.setattr(gateway, "is_windows", lambda: False)
           monkeypatch.setattr("gateway.status.get_running_pid", lambda: 321)
       
      +    # /proc walk is the first path tried (#22693). Force os.listdir on /proc
      +    # to raise so the function falls back to ps, where fake_run takes over.
      +    _real_listdir = gateway.os.listdir
      +    def _no_proc_listdir(path):
      +        if path == "/proc":
      +            raise OSError("test stub: /proc unavailable")
      +        return _real_listdir(path)
      +    monkeypatch.setattr(gateway.os, "listdir", _no_proc_listdir)
      +
           def fake_run(cmd, **kwargs):
               if cmd[:4] == ["ps", "-A", "eww", "-o"]:
                   return SimpleNamespace(returncode=1, stdout="", stderr="ps failed")
    • tests/hermes_cli/test_gateway_proc_fallback.py
      @@ -0,0 +1,138 @@
      +"""Tests for /proc-based gateway PID detection in Docker environments.
      +
      +Verifies that _scan_gateway_pids() uses /proc/*/cmdline when available
      +(Docker without procps) and falls back to ps only when /proc is absent.
      +
      +See: NousResearch/hermes-agent#7622
      +"""
      +
      +import os
      +from unittest.mock import MagicMock, patch
      +
      +import hermes_cli.gateway as gateway_mod
      +
      +
      +# ---------------------------------------------------------------------------
      +# Helpers
      +# ---------------------------------------------------------------------------
      +
      +_GATEWAY_CMD = "python -m hermes_cli.main gateway run"
      +_OTHER_CMD = "python -m some_other_thing"
      +
      +
      +def _fake_proc_dir(entries: dict):
      +    """Return side_effects that simulate /proc: isdir → True, listdir → pids,
      +    open(cmdline) → null-delimited command bytes."""
      +    def _isdir(path):
      +        return str(path) == "/proc"
      +
      +    def _listdir(path):
      +        if str(path) == "/proc":
      +            return [str(pid) for pid in entries] + ["self", "version"]
      +        raise FileNotFoundError(path)
      +
      +    def _open(path, mode="r", **kwargs):
      +        path_str = str(path)
      +        if "/cmdline" in path_str:
      +            pid = int(path_str.split("/proc/")[1].split("/")[0])
      +            raw = entries.get(pid, "").encode("utf-8").replace(b" ", b"\x00")
      +            m = MagicMock()
      +            m.read.return_value = raw
      +            m.__enter__ = lambda s: s
      +            m.__exit__ = MagicMock(return_value=False)
      +            return m
      +        raise FileNotFoundError(path)
      +
      +    return _isdir, _listdir, _open
      +
      +
      +# ---------------------------------------------------------------------------
      +# Tests
      +# ---------------------------------------------------------------------------
      +
      +
      +class TestProcFallback:
      +    """_scan_gateway_pids reads /proc when available, skips ps."""
      +
      +    def test_detects_gateway_pid_via_proc(self):
      +        my_pid = os.getpid()
      +        entries = {
      +            my_pid: "python -m hermes_cli.main",   # own process — excluded
      +            12345: _GATEWAY_CMD,
      +            99999: _OTHER_CMD,
      +        }
      +        _isdir, _listdir, _open = _fake_proc_dir(entries)
      +
      +        with (
      +            patch("hermes_cli.gateway.is_windows", return_value=False),
      +            patch("os.path.isdir", side_effect=_isdir),
      +            patch("os.listdir", side_effect=_listdir),
      +            patch("builtins.open", side_effect=_open),
      +            patch("hermes_cli.gateway._get_ancestor_pids", return_value=set()),
      +            patch("subprocess.run") as mock_ps,
      +        ):
      +            pids = gateway_mod._scan_gateway_pids(set(), all_profiles=True)
      +
      +        assert 12345 in pids
      +        assert 99999 not in pids
      +        mock_ps.assert_not_called()  # ps must NOT be called when /proc worked
      +
      +    def test_excludes_own_pid_from_proc_scan(self):
      +        my_pid = os.getpid()
      +        entries = {my_pid: _GATEWAY_CMD}
      +        _isdir, _listdir, _open = _fake_proc_dir(entries)
      +
      +        with (
      +            patch("hermes_cli.gateway.is_windows", return_value=False),
      +            patch("os.path.isdir", side_effect=_isdir),
      +            patch("os.listdir", side_effect=_listdir),
      +            patch("builtins.open", side_effect=_open),
      +            patch("hermes_cli.gateway._get_ancestor_pids", return_value=set()),
      +            patch("subprocess.run"),
      +        ):
      +            pids = gateway_mod._scan_gateway_pids(set(), all_profiles=True)
      +
      +        assert my_pid not in pids
      +
      +    def test_falls_back_to_ps_when_proc_absent(self):
      +        ps_output = f"12345 {_GATEWAY_CMD}\n99999 {_OTHER_CMD}\n"
      +        mock_result = MagicMock()
      +        mock_result.returncode = 0
      +        mock_result.stdout = ps_output
      +
      +        with (
      +            patch("hermes_cli.gateway.is_windows", return_value=False),
      +            patch("os.path.isdir", return_value=False),
      +            patch("hermes_cli.gateway._get_ancestor_pids", return_value=set()),
      +            patch("subprocess.run", return_value=mock_result) as mock_ps,
      +        ):
      +            pids = gateway_mod._scan_gateway_pids(set(), all_profiles=True)
      +
      +        mock_ps.assert_called_once()
      +        assert 12345 in pids
      +
      +    def test_proc_permission_error_skips_pid(self):
      +        def _isdir(path):
      +            return str(path) == "/proc"
      +
      +        def _listdir(path):
      +            if str(path) == "/proc":
      +                return ["12345", "self"]
      +            raise FileNotFoundError
      +
      +        def _open(path, mode="r", **kwargs):
      +            raise PermissionError("no access")
      +
      +        with (
      +            patch("hermes_cli.gateway.is_windows", return_value=False),
      +            patch("os.path.isdir", side_effect=_isdir),
      +            patch("os.listdir", side_effect=_listdir),
      +            patch("builtins.open", side_effect=_open),
      +            patch("hermes_cli.gateway._get_ancestor_pids", return_value=set()),
      +            patch("subprocess.run") as mock_ps,
      +        ):
      +            pids = gateway_mod._scan_gateway_pids(set(), all_profiles=True)
      +
      +        # PermissionError swallowed — empty result, no crash
      +        assert 12345 not in pids
      +        mock_ps.assert_not_called()  # /proc dir existed, so ps not called

5 PRs closed without merging

From the close comments: done elsewhere 2 · superseded by another PR 2 · duplicate 1

  • #9100 fix: use ps without environment for gateway scan · superseded by another PR → #9396, #9069
  • #9762 fix: use POSIX `-e` instead of BSD `-ax` in ps for Docker compatibility · done elsewhere → #9723, #10569
  • #10186 fix(cli): replace ps -ax with POSIX -e for Docker compatibility · duplicate → #10815
  • #10806 Fix gateway status detection inside Docker containers · superseded by another PR → #22860
  • #10968 fix(gateway): use ps ww for gateway pid detection · done elsewhere
Closed issues · 2
  • Closed#9723[Bug]: `ps eww -ax` fails in Docker — gateway falsely reported as not running
  • Closed#10761Gateway status detection fails in Docker container (ps eww -ax incompatibility)
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.16: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#10569, #22860
Closed issues
2
Attempts
5 PRs closed without merging
Fix link checked
10 May 2026

For your agent

The same facts, typed.