{"repo":"NousResearch/hermes-agent","public_id":"9069","display_label":"Gateway process detection ps command compatibility issues","summary":"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.","subsystem":"hermes_cli","counts":{"members":11,"open_issues":1,"open_issues_distinct":1,"open_prs":1,"closed_issues":2,"merged_prs":2,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":22860,"merged_at":"2026-05-10T00:54:18+00:00"},"resolution":{"state":"partially_resolved","by":"pr-10569"},"freshness":{"state":"verified","last_verified_at":"2026-05-10T00:54:18+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.15854545454545452},"fix_edges":[{"issue_number":9723,"pr_number":10569,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-16T00:07:22+00:00"},{"issue_number":10761,"pr_number":22860,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-10T00:54:18+00:00"}],"members":[{"kind":"issue","number":9069,"title":"[Bug]: hermes cron list: Gateway process detection fails on FreeBSD due to `ps eww` output format","url":null,"state":"open"},{"kind":"issue","number":9723,"title":"[Bug]: `ps eww -ax` fails in Docker  — gateway falsely reported as not running","url":null,"state":"closed"},{"kind":"issue","number":10761,"title":"Gateway status detection fails in Docker container (ps eww -ax incompatibility)","url":null,"state":"closed"},{"kind":"pr","number":9100,"title":"fix: use ps without environment for gateway scan","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#9396","number":9396},{"kind":"pr","value":"#9069","number":9069}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":9396,"title":"Fix FreeBSD gateway process detection","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":9762,"title":"fix: use POSIX `-e` instead of BSD `-ax` in ps for Docker compatibility","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#9723","number":9723},{"kind":"pr","value":"#10569","number":10569}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":10186,"title":"fix(cli): replace ps -ax with POSIX -e for Docker compatibility","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10815","number":10815}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":10569,"title":"fix: use POSIX ps -A instead of BSD -ax for Docker compat","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/gateway.py"],"fix_hunks":[{"file":"hermes_cli/gateway.py","hunks":["@@ -222,7 +222,7 @@ def _matches_current_profile(command: str) -> bool:\n                     current_cmd = \"\"\n         else:\n             result = subprocess.run(\n-                [\"ps\", \"eww\", \"-ax\", \"-o\", \"pid=,command=\"],\n+                [\"ps\", \"-A\", \"eww\", \"-o\", \"pid=,command=\"],\n                 capture_output=True,\n                 text=True,\n                 timeout=10,\n"]}]},{"kind":"pr","number":10806,"title":"Fix gateway status detection inside Docker containers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22860","number":22860}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":10968,"title":"fix(gateway): use ps ww for gateway pid detection","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":22860,"title":"fix(gateway): detect gateway process via /proc in Docker without procps (salvage #22693)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/gateway.py","tests/hermes_cli/test_gateway.py","tests/hermes_cli/test_gateway_proc_fallback.py"],"fix_hunks":[{"file":"hermes_cli/gateway.py","hunks":["@@ -394,42 +394,68 @@ def _matches_current_profile(command: str) -> bool:\n                             pass\n                     current_cmd = \"\"\n         else:\n-            result = subprocess.run(\n-                [\"ps\", \"-A\", \"eww\", \"-o\", \"pid=,command=\"],\n-                capture_output=True,\n-                text=True,\n-                timeout=10,\n-            )\n-            if result.returncode != 0:\n-                return []\n-            for line in result.stdout.split(\"\\n\"):\n-                stripped = line.strip()\n-                if not stripped or \"grep\" in stripped:\n-                    continue\n+            # Try /proc first (works in Docker without procps installed),\n+            # fall back to ps -A eww.\n+            _found_via_proc = False\n+            if os.path.isdir(\"/proc\"):\n+                try:\n+                    my_pid = os.getpid()\n+                    for entry in os.listdir(\"/proc\"):\n+                        if not entry.isdigit():\n+                            continue\n+                        pid = int(entry)\n+                        if pid == my_pid or pid in exclude_pids:\n+                            continue\n+                        try:\n+                            cmdline = open(f\"/proc/{pid}/cmdline\", \"rb\").read().decode(\"utf-8\", errors=\"replace\")\n+                            cmdline = cmdline.replace(\"\\x00\", \" \")\n+                            if any(p in cmdline for p in patterns) and (\n+                                all_profiles or _matches_current_profile(cmdline)\n+                            ):\n+                                _append_unique_pid(pids, pid, exclude_pids)\n+                        except (OSError, PermissionError):\n+                            continue\n+                    _found_via_proc = True\n+                except Exception:\n+                    pass\n \n-                pid = None\n-                command = \"\"\n+            if not _found_via_proc:\n+                result = subprocess.run(\n+                    [\"ps\", \"-A\", \"eww\", \"-o\", \"pid=,command=\"],\n+                    capture_output=True,\n+                    text=True,\n+                    timeout=10,\n+                )\n+                if result.returncode != 0:\n+                    return []\n+                for line in result.stdout.split(\"\\n\"):\n+                    stripped = line.strip()\n+                    if not stripped or \"grep\" in stripped:\n+                        continue\n \n-                parts = stripped.split(None, 1)\n-                if len(parts) == 2:\n-                    try:\n-                        pid = int(parts[0])\n-                        command = parts[1]\n-                    except ValueError:\n-                        pid = None\n-\n-                if pid is None:\n-                    aux_parts = stripped.split()\n-                    if len(aux_parts) > 10 and aux_parts[1].isdigit():\n-                        pid = int(aux_parts[1])\n-                        command = \" \".join(aux_parts[10:])\n-\n-                if pid is None:\n-                    continue\n-                if any(pattern in command for pattern in patterns) and (\n-                    all_profiles or _matches_current_profile(command)\n-                ):\n-                    _append_unique_pid(pids, pid, exclude_pids)\n+                    pid = None\n+                    command = \"\"\n+\n+                    parts = stripped.split(None, 1)\n+                    if len(parts) == 2:\n+                        try:\n+                            pid = int(parts[0])\n+                            command = parts[1]\n+                        except ValueError:\n+                            pid = None\n+\n+                    if pid is None:\n+                        aux_parts = stripped.split()\n+                        if len(aux_parts) > 10 and aux_parts[1].isdigit():\n+                            pid = int(aux_parts[1])\n+                            command = \" \".join(aux_parts[10:])\n+\n+                    if pid is None:\n+                        continue\n+                    if any(pattern in command for pattern in patterns) and (\n+                        all_profiles or _matches_current_profile(command)\n+                    ):\n+                        _append_unique_pid(pids, pid, exclude_pids)\n     except (OSError, subprocess.TimeoutExpired):\n         return []\n "]},{"file":"tests/hermes_cli/test_gateway.py","hunks":["@@ -404,6 +404,15 @@ def test_find_gateway_pids_falls_back_to_pid_file_when_process_scan_fails(monkey\n     monkeypatch.setattr(gateway, \"is_windows\", lambda: False)\n     monkeypatch.setattr(\"gateway.status.get_running_pid\", lambda: 321)\n \n+    # /proc walk is the first path tried (#22693). Force os.listdir on /proc\n+    # to raise so the function falls back to ps, where fake_run takes over.\n+    _real_listdir = gateway.os.listdir\n+    def _no_proc_listdir(path):\n+        if path == \"/proc\":\n+            raise OSError(\"test stub: /proc unavailable\")\n+        return _real_listdir(path)\n+    monkeypatch.setattr(gateway.os, \"listdir\", _no_proc_listdir)\n+\n     def fake_run(cmd, **kwargs):\n         if cmd[:4] == [\"ps\", \"-A\", \"eww\", \"-o\"]:\n             return SimpleNamespace(returncode=1, stdout=\"\", stderr=\"ps failed\")"]},{"file":"tests/hermes_cli/test_gateway_proc_fallback.py","hunks":["@@ -0,0 +1,138 @@\n+\"\"\"Tests for /proc-based gateway PID detection in Docker environments.\n+\n+Verifies that _scan_gateway_pids() uses /proc/*/cmdline when available\n+(Docker without procps) and falls back to ps only when /proc is absent.\n+\n+See: NousResearch/hermes-agent#7622\n+\"\"\"\n+\n+import os\n+from unittest.mock import MagicMock, patch\n+\n+import hermes_cli.gateway as gateway_mod\n+\n+\n+# ---------------------------------------------------------------------------\n+# Helpers\n+# ---------------------------------------------------------------------------\n+\n+_GATEWAY_CMD = \"python -m hermes_cli.main gateway run\"\n+_OTHER_CMD = \"python -m some_other_thing\"\n+\n+\n+def _fake_proc_dir(entries: dict):\n+    \"\"\"Return side_effects that simulate /proc: isdir → True, listdir → pids,\n+    open(cmdline) → null-delimited command bytes.\"\"\"\n+    def _isdir(path):\n+        return str(path) == \"/proc\"\n+\n+    def _listdir(path):\n+        if str(path) == \"/proc\":\n+            return [str(pid) for pid in entries] + [\"self\", \"version\"]\n+        raise FileNotFoundError(path)\n+\n+    def _open(path, mode=\"r\", **kwargs):\n+        path_str = str(path)\n+        if \"/cmdline\" in path_str:\n+            pid = int(path_str.split(\"/proc/\")[1].split(\"/\")[0])\n+            raw = entries.get(pid, \"\").encode(\"utf-8\").replace(b\" \", b\"\\x00\")\n+            m = MagicMock()\n+            m.read.return_value = raw\n+            m.__enter__ = lambda s: s\n+            m.__exit__ = MagicMock(return_value=False)\n+            return m\n+        raise FileNotFoundError(path)\n+\n+    return _isdir, _listdir, _open\n+\n+\n+# ---------------------------------------------------------------------------\n+# Tests\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestProcFallback:\n+    \"\"\"_scan_gateway_pids reads /proc when available, skips ps.\"\"\"\n+\n+    def test_detects_gateway_pid_via_proc(self):\n+        my_pid = os.getpid()\n+        entries = {\n+            my_pid: \"python -m hermes_cli.main\",   # own process — excluded\n+            12345: _GATEWAY_CMD,\n+            99999: _OTHER_CMD,\n+        }\n+        _isdir, _listdir, _open = _fake_proc_dir(entries)\n+\n+        with (\n+            patch(\"hermes_cli.gateway.is_windows\", return_value=False),\n+            patch(\"os.path.isdir\", side_effect=_isdir),\n+            patch(\"os.listdir\", side_effect=_listdir),\n+            patch(\"builtins.open\", side_effect=_open),\n+            patch(\"hermes_cli.gateway._get_ancestor_pids\", return_value=set()),\n+            patch(\"subprocess.run\") as mock_ps,\n+        ):\n+            pids = gateway_mod._scan_gateway_pids(set(), all_profiles=True)\n+\n+        assert 12345 in pids\n+        assert 99999 not in pids\n+        mock_ps.assert_not_called()  # ps must NOT be called when /proc worked\n+\n+    def test_excludes_own_pid_from_proc_scan(self):\n+        my_pid = os.getpid()\n+        entries = {my_pid: _GATEWAY_CMD}\n+        _isdir, _listdir, _open = _fake_proc_dir(entries)\n+\n+        with (\n+            patch(\"hermes_cli.gateway.is_windows\", return_value=False),\n+            patch(\"os.path.isdir\", side_effect=_isdir),\n+            patch(\"os.listdir\", side_effect=_listdir),\n+            patch(\"builtins.open\", side_effect=_open),\n+            patch(\"hermes_cli.gateway._get_ancestor_pids\", return_value=set()),\n+            patch(\"subprocess.run\"),\n+        ):\n+            pids = gateway_mod._scan_gateway_pids(set(), all_profiles=True)\n+\n+        assert my_pid not in pids\n+\n+    def test_falls_back_to_ps_when_proc_absent(self):\n+        ps_output = f\"12345 {_GATEWAY_CMD}\\n99999 {_OTHER_CMD}\\n\"\n+        mock_result = MagicMock()\n+        mock_result.returncode = 0\n+        mock_result.stdout = ps_output\n+\n+        with (\n+            patch(\"hermes_cli.gateway.is_windows\", return_value=False),\n+            patch(\"os.path.isdir\", return_value=False),\n+            patch(\"hermes_cli.gateway._get_ancestor_pids\", return_value=set()),\n+            patch(\"subprocess.run\", return_value=mock_result) as mock_ps,\n+        ):\n+            pids = gateway_mod._scan_gateway_pids(set(), all_profiles=True)\n+\n+        mock_ps.assert_called_once()\n+        assert 12345 in pids\n+\n+    def test_proc_permission_error_skips_pid(self):\n+        def _isdir(path):\n+            return str(path) == \"/proc\"\n+\n+        def _listdir(path):\n+            if str(path) == \"/proc\":\n+                return [\"12345\", \"self\"]\n+            raise FileNotFoundError\n+\n+        def _open(path, mode=\"r\", **kwargs):\n+            raise PermissionError(\"no access\")\n+\n+        with (\n+            patch(\"hermes_cli.gateway.is_windows\", return_value=False),\n+            patch(\"os.path.isdir\", side_effect=_isdir),\n+            patch(\"os.listdir\", side_effect=_listdir),\n+            patch(\"builtins.open\", side_effect=_open),\n+            patch(\"hermes_cli.gateway._get_ancestor_pids\", return_value=set()),\n+            patch(\"subprocess.run\") as mock_ps,\n+        ):\n+            pids = gateway_mod._scan_gateway_pids(set(), all_profiles=True)\n+\n+        # PermissionError swallowed — empty result, no crash\n+        assert 12345 not in pids\n+        mock_ps.assert_not_called()  # /proc dir existed, so ps not called\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}