{"repo":"NousResearch/hermes-agent","public_id":"3724","display_label":"Fix hermes crashes on Linux without systemctl","summary":"These issues and PRs address a bug where hermes crashes or fails when running on Linux runtimes that lack systemctl. Fixes span the CLI, gateway, and status command handling.","subsystem":"hermes_cli","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":8614},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-12T23:36:12+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":3724,"pr_number":8614,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":7420,"pr_number":8614,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":3724,"title":"[Bug]: hermes status crashes on Linux runtimes lacking systemctl","url":null,"state":"closed"},{"kind":"pr","number":3727,"title":"fix(cli): prevent crash on Linux runtimes lacking systemctl","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#8614","number":8614},{"kind":"pr","value":"#8573","number":8573}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":3743,"title":"Handle hermes status when systemctl is unavailable","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#8614","number":8614},{"kind":"pr","value":"#8573","number":8573}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":3761,"title":"Status: handle Linux runtimes without systemctl","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#8614","number":8614},{"kind":"pr","value":"#8573","number":8573}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":8614,"title":"fix(gateway): harden Docker/container gateway pathway","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/config.py","hermes_cli/dump.py","hermes_cli/gateway.py","hermes_cli/setup.py","hermes_cli/status.py","hermes_constants.py","tests/hermes_cli/test_container_aware_cli.py","tests/hermes_cli/test_gateway_service.py","tests/hermes_cli/test_setup.py","tests/test_hermes_constants.py","tools/voice_mode.py"],"fix_hunks":[{"file":"hermes_cli/config.py","hunks":["@@ -148,25 +148,6 @@ def managed_error(action: str = \"modify configuration\"):\n # Container-aware CLI (NixOS container mode)\n # =============================================================================\n \n-def _is_inside_container() -> bool:\n-    \"\"\"Detect if we're already running inside a Docker/Podman container.\"\"\"\n-    # Standard Docker/Podman indicators\n-    if os.path.exists(\"/.dockerenv\"):\n-        return True\n-    # Podman uses /run/.containerenv\n-    if os.path.exists(\"/run/.containerenv\"):\n-        return True\n-    # Check cgroup for container runtime evidence (works for both Docker & Podman)\n-    try:\n-        with open(\"/proc/1/cgroup\", \"r\") as f:\n-            cgroup = f.read()\n-            if \"docker\" in cgroup or \"podman\" in cgroup or \"/lxc/\" in cgroup:\n-                return True\n-    except OSError:\n-        pass\n-    return False\n-\n-\n def get_container_exec_info() -> Optional[dict]:\n     \"\"\"Read container mode metadata from HERMES_HOME/.container-mode.\n ","@@ -181,7 +162,8 @@ def get_container_exec_info() -> Optional[dict]:\n     if os.environ.get(\"HERMES_DEV\") == \"1\":\n         return None\n \n-    if _is_inside_container():\n+    from hermes_constants import is_container\n+    if is_container():\n         return None\n \n     container_mode_file = get_hermes_home() / \".container-mode\""]},{"file":"hermes_cli/dump.py","hunks":["@@ -44,6 +44,16 @@ def _redact(value: str) -> str:\n def _gateway_status() -> str:\n     \"\"\"Return a short gateway status string.\"\"\"\n     if sys.platform.startswith(\"linux\"):\n+        from hermes_constants import is_container\n+        if is_container():\n+            try:\n+                from hermes_cli.gateway import find_gateway_pids\n+                pids = find_gateway_pids()\n+                if pids:\n+                    return f\"running (docker, pid {pids[0]})\"\n+                return \"stopped (docker)\"\n+            except Exception:\n+                return \"stopped (docker)\"\n         try:\n             from hermes_cli.gateway import get_service_name\n             svc = get_service_name()"]},{"file":"hermes_cli/gateway.py","hunks":["@@ -331,7 +331,7 @@ def is_linux() -> bool:\n     return sys.platform.startswith('linux')\n \n \n-from hermes_constants import is_termux, is_wsl\n+from hermes_constants import is_container, is_termux, is_wsl\n \n \n def _wsl_systemd_operational() -> bool:","@@ -353,7 +353,9 @@ def _wsl_systemd_operational() -> bool:\n \n \n def supports_systemd_services() -> bool:\n-    if not is_linux() or is_termux():\n+    if not is_linux() or is_termux() or is_container():\n+        return False\n+    if shutil.which(\"systemctl\") is None:\n         return False\n     if is_wsl():\n         return _wsl_systemd_operational()","@@ -483,6 +485,21 @@ def _journalctl_cmd(system: bool = False) -> list[str]:\n     return [\"journalctl\"] if system else [\"journalctl\", \"--user\"]\n \n \n+def _run_systemctl(args: list[str], *, system: bool = False, **kwargs) -> subprocess.CompletedProcess:\n+    \"\"\"Run a systemctl command, raising RuntimeError if systemctl is missing.\n+\n+    Defense-in-depth: callers are gated by ``supports_systemd_services()``,\n+    but this ensures any future caller that bypasses the gate still gets a\n+    clear error instead of a raw ``FileNotFoundError`` traceback.\n+    \"\"\"\n+    try:\n+        return subprocess.run(_systemctl_cmd(system) + args, **kwargs)\n+    except FileNotFoundError:\n+        raise RuntimeError(\n+            \"systemctl is not available on this system\"\n+        ) from None\n+\n+\n def _service_scope_label(system: bool = False) -> str:\n     return \"system\" if system else \"user\"\n ","@@ -929,7 +946,7 @@ def refresh_systemd_unit_if_needed(system: bool = False) -> bool:\n \n     expected_user = _read_systemd_user_from_unit(unit_path) if system else None\n     unit_path.write_text(generate_systemd_unit(system=system, run_as_user=expected_user), encoding=\"utf-8\")\n-    subprocess.run(_systemctl_cmd(system) + [\"daemon-reload\"], check=True, timeout=30)\n+    _run_systemctl([\"daemon-reload\"], system=system, check=True, timeout=30)\n     print(f\"↻ Updated gateway {_service_scope_label(system)} service definition to match the current Hermes install\")\n     return True\n ","@@ -1025,7 +1042,7 @@ def systemd_install(force: bool = False, system: bool = False, run_as_user: str\n         if not systemd_unit_is_current(system=system):\n             print(f\"↻ Repairing outdated {_service_scope_label(system)} systemd service at: {unit_path}\")\n             refresh_systemd_unit_if_needed(system=system)\n-            subprocess.run(_systemctl_cmd(system) + [\"enable\", get_service_name()], check=True, timeout=30)\n+            _run_systemctl([\"enable\", get_service_name()], system=system, check=True, timeout=30)\n             print(f\"✓ {_service_scope_label(system).capitalize()} service definition updated\")\n             return\n         print(f\"Service already installed at: {unit_path}\")","@@ -1036,8 +1053,8 @@ def systemd_install(force: bool = False, system: bool = False, run_as_user: str\n     print(f\"Installing {_service_scope_label(system)} systemd service to: {unit_path}\")\n     unit_path.write_text(generate_systemd_unit(system=system, run_as_user=run_as_user), encoding=\"utf-8\")\n \n-    subprocess.run(_systemctl_cmd(system) + [\"daemon-reload\"], check=True, timeout=30)\n-    subprocess.run(_systemctl_cmd(system) + [\"enable\", get_service_name()], check=True, timeout=30)\n+    _run_systemctl([\"daemon-reload\"], system=system, check=True, timeout=30)\n+    _run_systemctl([\"enable\", get_service_name()], system=system, check=True, timeout=30)\n \n     print()\n     print(f\"✓ {_service_scope_label(system).capitalize()} service installed and enabled!\")","@@ -1063,15 +1080,15 @@ def systemd_uninstall(system: bool = False):\n     if system:\n         _require_root_for_system_service(\"uninstall\")\n \n-    subprocess.run(_systemctl_cmd(system) + [\"stop\", get_service_name()], check=False, timeout=90)\n-    subprocess.run(_systemctl_cmd(system) + [\"disable\", get_service_name()], check=False, timeout=30)\n+    _run_systemctl([\"stop\", get_service_name()], system=system, check=False, timeout=90)\n+    _run_systemctl([\"disable\", get_service_name()], system=system, check=False, timeout=30)\n \n     unit_path = get_systemd_unit_path(system=system)\n     if unit_path.exists():\n         unit_path.unlink()\n         print(f\"✓ Removed {unit_path}\")\n \n-    subprocess.run(_systemctl_cmd(system) + [\"daemon-reload\"], check=True, timeout=30)\n+    _run_systemctl([\"daemon-reload\"], system=system, check=True, timeout=30)\n     print(f\"✓ {_service_scope_label(system).capitalize()} service uninstalled\")\n \n ","@@ -1080,7 +1097,7 @@ def systemd_start(system: bool = False):\n     if system:\n         _require_root_for_system_service(\"start\")\n     refresh_systemd_unit_if_needed(system=system)\n-    subprocess.run(_systemctl_cmd(system) + [\"start\", get_service_name()], check=True, timeout=30)\n+    _run_systemctl([\"start\", get_service_name()], system=system, check=True, timeout=30)\n     print(f\"✓ {_service_scope_label(system).capitalize()} service started\")\n \n ","@@ -1089,7 +1106,7 @@ def systemd_stop(system: bool = False):\n     system = _select_systemd_scope(system)\n     if system:\n         _require_root_for_system_service(\"stop\")\n-    subprocess.run(_systemctl_cmd(system) + [\"stop\", get_service_name()], check=True, timeout=90)\n+    _run_systemctl([\"stop\", get_service_name()], system=system, check=True, timeout=90)\n     print(f\"✓ {_service_scope_label(system).capitalize()} service stopped\")\n \n ","@@ -1105,7 +1122,7 @@ def systemd_restart(system: bool = False):\n     if pid is not None and _request_gateway_self_restart(pid):\n         print(f\"✓ {_service_scope_label(system).capitalize()} service restart requested\")\n         return\n-    subprocess.run(_systemctl_cmd(system) + [\"reload-or-restart\", get_service_name()], check=True, timeout=90)\n+    _run_systemctl([\"reload-or-restart\", get_service_name()], system=system, check=True, timeout=90)\n     print(f\"✓ {_service_scope_label(system).capitalize()} service restarted\")\n \n ","@@ -1129,14 +1146,16 @@ def systemd_status(deep: bool = False, system: bool = False):\n         print(f\"  Run: {'sudo ' if system else ''}hermes gateway restart{scope_flag}  # auto-refreshes the unit\")\n         print()\n \n-    subprocess.run(\n-        _systemctl_cmd(system) + [\"status\", get_service_name(), \"--no-pager\"],\n+    _run_systemctl(\n+        [\"status\", get_service_name(), \"--no-pager\"],\n+        system=system,\n         capture_output=False,\n         timeout=10,\n     )\n \n-    result = subprocess.run(\n-        _systemctl_cmd(system) + [\"is-active\", get_service_name()],\n+    result = _run_systemctl(\n+        [\"is-active\", get_service_name()],\n+        system=system,\n         capture_output=True,\n         text=True,\n         timeout=10,","@@ -2123,24 +2142,24 @@ def _is_service_running() -> bool:\n \n         if user_unit_exists:\n             try:\n-                result = subprocess.run(\n-                    _systemctl_cmd(False) + [\"is-active\", get_service_name()],\n-                    capture_output=True, text=True, timeout=10,\n+                result = _run_systemctl(\n+                    [\"is-active\", get_service_name()],\n+                    system=False, capture_output=True, text=True, timeout=10,\n                 )\n                 if result.stdout.strip() == \"active\":\n                     return True\n-            except subprocess.TimeoutExpired:\n+            except (RuntimeError, subprocess.TimeoutExpired):\n                 pass\n \n         if system_unit_exists:\n             try:\n-                result = subprocess.run(\n-                    _systemctl_cmd(True) + [\"is-active\", get_service_name()],\n-                    capture_output=True, text=True, timeout=10,\n+                result = _run_systemctl(\n+                    [\"is-active\", get_service_name()],\n+                    system=True, capture_output=True, text=True, timeout=10,\n                 )\n                 if result.stdout.strip() == \"active\":\n                     return True\n-            except subprocess.TimeoutExpired:\n+            except (RuntimeError, subprocess.TimeoutExpired):\n                 pass\n \n         return False","@@ -2774,6 +2793,15 @@ def gateway_command(args):\n             print(\"  tmux new -s hermes 'hermes gateway run'         # persistent via tmux\")\n             print(\"  nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 &  # background\")\n             sys.exit(1)\n+        elif is_container():\n+            print(\"Service installation is not needed inside a Docker container.\")\n+            print(\"The container runtime is your service manager — use Docker restart policies instead:\")\n+            print()\n+            print(\"  docker run --restart unless-stopped ...   # auto-restart on crash/reboot\")\n+            print(\"  docker restart <container>                # manual restart\")\n+            print()\n+            print(\"To run the gateway: hermes gateway run\")\n+            sys.exit(0)\n         else:\n             print(\"Service installation not supported on this platform.\")\n             print(\"Run manually: hermes gateway run\")","@@ -2792,10 +2820,17 @@ def gateway_command(args):\n             systemd_uninstall(system=system)\n         elif is_macos():\n             launchd_uninstall()\n+        elif is_container():\n+            print(\"Service uninstall is not applicable inside a Docker container.\")\n+            print(\"To stop the gateway, stop or remove the container:\")\n+            print()\n+            print(\"  docker stop <container>\")\n+            print(\"  docker rm <container>\")\n+            sys.exit(0)\n         else:\n             print(\"Not supported on this platform.\")\n             sys.exit(1)\n-    \n+\n     elif subcmd == \"start\":\n         system = getattr(args, 'system', False)\n         if is_termux():","@@ -2816,10 +2851,19 @@ def gateway_command(args):\n             print()\n             print(\"To enable systemd: add systemd=true to /etc/wsl.conf and run 'wsl --shutdown' from PowerShell.\")\n             sys.exit(1)\n+        elif is_container():\n+            print(\"Service start is not applicable inside a Docker container.\")\n+            print(\"The gateway runs as the container's main process.\")\n+            print()\n+            print(\"  docker start <container>     # start a stopped container\")\n+            print(\"  docker restart <container>   # restart a running container\")\n+            print()\n+            print(\"Or run the gateway directly: hermes gateway run\")\n+            sys.exit(0)\n         else:\n             print(\"Not supported on this platform.\")\n             sys.exit(1)\n-    \n+\n     elif subcmd == \"stop\":\n         stop_all = getattr(args, 'all', False)\n         system = getattr(args, 'system', False)"]},{"file":"hermes_cli/setup.py","hunks":["@@ -2232,6 +2232,7 @@ def setup_gateway(config: dict):\n         from hermes_cli.gateway import (\n             _is_service_installed,\n             _is_service_running,\n+            supports_systemd_services,\n             has_conflicting_systemd_units,\n             install_linux_gateway_from_setup,\n             print_systemd_scope_conflict_warning,","@@ -2244,16 +2245,18 @@ def setup_gateway(config: dict):\n \n         service_installed = _is_service_installed()\n         service_running = _is_service_running()\n+        supports_systemd = supports_systemd_services()\n+        supports_service_manager = supports_systemd or _is_macos\n \n         print()\n-        if _is_linux and has_conflicting_systemd_units():\n+        if supports_systemd and has_conflicting_systemd_units():\n             print_systemd_scope_conflict_warning()\n             print()\n \n         if service_running:\n             if prompt_yes_no(\"  Restart the gateway to pick up changes?\", True):\n                 try:\n-                    if _is_linux:\n+                    if supports_systemd:\n                         systemd_restart()\n                     elif _is_macos:\n                         launchd_restart()","@@ -2262,14 +2265,14 @@ def setup_gateway(config: dict):\n         elif service_installed:\n             if prompt_yes_no(\"  Start the gateway service?\", True):\n                 try:\n-                    if _is_linux:\n+                    if supports_systemd:\n                         systemd_start()\n                     elif _is_macos:\n                         launchd_start()\n                 except Exception as e:\n                     print_error(f\"  Start failed: {e}\")\n-        elif _is_linux or _is_macos:\n-            svc_name = \"systemd\" if _is_linux else \"launchd\"\n+        elif supports_service_manager:\n+            svc_name = \"systemd\" if supports_systemd else \"launchd\"\n             if prompt_yes_no(\n                 f\"  Install the gateway as a {svc_name} service? (runs in background, starts on boot)\",\n                 True,","@@ -2277,7 +2280,7 @@ def setup_gateway(config: dict):\n                 try:\n                     installed_scope = None\n                     did_install = False\n-                    if _is_linux:\n+                    if supports_systemd:\n                         installed_scope, did_install = install_linux_gateway_from_setup(force=False)\n                     else:\n                         launchd_install(force=False)","@@ -2285,7 +2288,7 @@ def setup_gateway(config: dict):\n                     print()\n                     if did_install and prompt_yes_no(\"  Start the service now?\", True):\n                         try:\n-                            if _is_linux:\n+                            if supports_systemd:\n                                 systemd_start(system=installed_scope == \"system\")\n                             elif _is_macos:\n                                 launchd_start()","@@ -2296,12 +2299,21 @@ def setup_gateway(config: dict):\n                     print_info(\"  You can try manually: hermes gateway install\")\n             else:\n                 print_info(\"  You can install later: hermes gateway install\")\n-                if _is_linux:\n+                if supports_systemd:\n                     print_info(\"  Or as a boot-time service: sudo hermes gateway install --system\")\n                 print_info(\"  Or run in foreground:  hermes gateway\")\n         else:\n-            print_info(\"Start the gateway to bring your bots online:\")\n-            print_info(\"   hermes gateway              # Run in foreground\")\n+            from hermes_constants import is_container\n+            if is_container():\n+                print_info(\"Start the gateway to bring your bots online:\")\n+                print_info(\"   hermes gateway run          # Run as container main process\")\n+                print_info(\"\")\n+                print_info(\"For automatic restarts, use a Docker restart policy:\")\n+                print_info(\"   docker run --restart unless-stopped ...\")\n+                print_info(\"   docker restart <container>  # Manual restart\")\n+            else:\n+                print_info(\"Start the gateway to bring your bots online:\")\n+                print_info(\"   hermes gateway              # Run in foreground\")\n \n         print_info(\"━\" * 50)\n "]},{"file":"hermes_cli/status.py","hunks":["@@ -346,23 +346,35 @@ def show_status(args):\n             print(\"  Note:         Android may stop background jobs when Termux is suspended\")\n \n     elif sys.platform.startswith('linux'):\n-        try:\n-            from hermes_cli.gateway import get_service_name\n-            _gw_svc = get_service_name()\n-        except Exception:\n-            _gw_svc = \"hermes-gateway\"\n-        try:\n-            result = subprocess.run(\n-                [\"systemctl\", \"--user\", \"is-active\", _gw_svc],\n-                capture_output=True,\n-                text=True,\n-                timeout=5\n-            )\n-            is_active = result.stdout.strip() == \"active\"\n-        except (FileNotFoundError, subprocess.TimeoutExpired):\n-            is_active = False\n-        print(f\"  Status:       {check_mark(is_active)} {'running' if is_active else 'stopped'}\")\n-        print(\"  Manager:      systemd (user)\")\n+        from hermes_constants import is_container\n+        if is_container():\n+            # Docker/Podman: no systemd — check for running gateway processes\n+            try:\n+                from hermes_cli.gateway import find_gateway_pids\n+                gateway_pids = find_gateway_pids()\n+                is_active = len(gateway_pids) > 0\n+            except Exception:\n+                is_active = False\n+            print(f\"  Status:       {check_mark(is_active)} {'running' if is_active else 'stopped'}\")\n+            print(\"  Manager:      docker (foreground)\")\n+        else:\n+            try:\n+                from hermes_cli.gateway import get_service_name\n+                _gw_svc = get_service_name()\n+            except Exception:\n+                _gw_svc = \"hermes-gateway\"\n+            try:\n+                result = subprocess.run(\n+                    [\"systemctl\", \"--user\", \"is-active\", _gw_svc],\n+                    capture_output=True,\n+                    text=True,\n+                    timeout=5\n+                )\n+                is_active = result.stdout.strip() == \"active\"\n+            except (FileNotFoundError, subprocess.TimeoutExpired):\n+                is_active = False\n+            print(f\"  Status:       {check_mark(is_active)} {'running' if is_active else 'stopped'}\")\n+            print(\"  Manager:      systemd (user)\")\n         \n     elif sys.platform == 'darwin':\n         from hermes_cli.gateway import get_launchd_label"]},{"file":"hermes_constants.py","hunks":["@@ -189,6 +189,37 @@ def is_wsl() -> bool:\n     return _wsl_detected\n \n \n+_container_detected: bool | None = None\n+\n+\n+def is_container() -> bool:\n+    \"\"\"Return True when running inside a Docker/Podman container.\n+\n+    Checks ``/.dockerenv`` (Docker), ``/run/.containerenv`` (Podman),\n+    and ``/proc/1/cgroup`` for container runtime markers.  Result is\n+    cached for the process lifetime.  Import-safe — no heavy deps.\n+    \"\"\"\n+    global _container_detected\n+    if _container_detected is not None:\n+        return _container_detected\n+    if os.path.exists(\"/.dockerenv\"):\n+        _container_detected = True\n+        return True\n+    if os.path.exists(\"/run/.containerenv\"):\n+        _container_detected = True\n+        return True\n+    try:\n+        with open(\"/proc/1/cgroup\", \"r\") as f:\n+            cgroup = f.read()\n+            if \"docker\" in cgroup or \"podman\" in cgroup or \"/lxc/\" in cgroup:\n+                _container_detected = True\n+                return True\n+    except OSError:\n+        pass\n+    _container_detected = False\n+    return False\n+\n+\n # ─── Well-Known Paths ─────────────────────────────────────────────────────────\n \n "]},{"file":"tests/hermes_cli/test_container_aware_cli.py","hunks":["@@ -12,49 +12,10 @@\n import pytest\n \n from hermes_cli.config import (\n-    _is_inside_container,\n     get_container_exec_info,\n )\n \n \n-# =============================================================================\n-# _is_inside_container\n-# =============================================================================\n-\n-\n-def test_is_inside_container_dockerenv():\n-    \"\"\"Detects /.dockerenv marker file.\"\"\"\n-    with patch(\"os.path.exists\") as mock_exists:\n-        mock_exists.side_effect = lambda p: p == \"/.dockerenv\"\n-        assert _is_inside_container() is True\n-\n-\n-def test_is_inside_container_containerenv():\n-    \"\"\"Detects Podman's /run/.containerenv marker.\"\"\"\n-    with patch(\"os.path.exists\") as mock_exists:\n-        mock_exists.side_effect = lambda p: p == \"/run/.containerenv\"\n-        assert _is_inside_container() is True\n-\n-\n-def test_is_inside_container_cgroup_docker():\n-    \"\"\"Detects 'docker' in /proc/1/cgroup.\"\"\"\n-    with patch(\"os.path.exists\", return_value=False), \\\n-         patch(\"builtins.open\", create=True) as mock_open:\n-        mock_open.return_value.__enter__ = lambda s: s\n-        mock_open.return_value.__exit__ = MagicMock(return_value=False)\n-        mock_open.return_value.read = MagicMock(\n-            return_value=\"12:memory:/docker/abc123\\n\"\n-        )\n-        assert _is_inside_container() is True\n-\n-\n-def test_is_inside_container_false_on_host():\n-    \"\"\"Returns False when none of the container indicators are present.\"\"\"\n-    with patch(\"os.path.exists\", return_value=False), \\\n-         patch(\"builtins.open\", side_effect=OSError(\"no such file\")):\n-        assert _is_inside_container() is False\n-\n-\n # =============================================================================\n # get_container_exec_info\n # =============================================================================","@@ -81,7 +42,7 @@ def container_env(tmp_path, monkeypatch):\n \n def test_get_container_exec_info_returns_metadata(container_env):\n     \"\"\"Reads .container-mode and returns all fields including exec_user.\"\"\"\n-    with patch(\"hermes_cli.config._is_inside_container\", return_value=False):\n+    with patch(\"hermes_constants.is_container\", return_value=False):\n         info = get_container_exec_info()\n \n     assert info is not None","@@ -93,7 +54,7 @@ def test_get_container_exec_info_returns_metadata(container_env):\n \n def test_get_container_exec_info_none_inside_container(container_env):\n     \"\"\"Returns None when we're already inside a container.\"\"\"\n-    with patch(\"hermes_cli.config._is_inside_container\", return_value=True):\n+    with patch(\"hermes_constants.is_container\", return_value=True):\n         info = get_container_exec_info()\n \n     assert info is None","@@ -106,7 +67,7 @@ def test_get_container_exec_info_none_without_file(tmp_path, monkeypatch):\n     monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n     monkeypatch.delenv(\"HERMES_DEV\", raising=False)\n \n-    with patch(\"hermes_cli.config._is_inside_container\", return_value=False):\n+    with patch(\"hermes_constants.is_container\", return_value=False):\n         info = get_container_exec_info()\n \n     assert info is None","@@ -116,7 +77,7 @@ def test_get_container_exec_info_skipped_when_hermes_dev(container_env, monkeypa\n     \"\"\"Returns None when HERMES_DEV=1 is set (dev mode bypass).\"\"\"\n     monkeypatch.setenv(\"HERMES_DEV\", \"1\")\n \n-    with patch(\"hermes_cli.config._is_inside_container\", return_value=False):\n+    with patch(\"hermes_constants.is_container\", return_value=False):\n         info = get_container_exec_info()\n \n     assert info is None","@@ -126,7 +87,7 @@ def test_get_container_exec_info_not_skipped_when_hermes_dev_zero(container_env,\n     \"\"\"HERMES_DEV=0 does NOT trigger bypass — only '1' does.\"\"\"\n     monkeypatch.setenv(\"HERMES_DEV\", \"0\")\n \n-    with patch(\"hermes_cli.config._is_inside_container\", return_value=False):\n+    with patch(\"hermes_constants.is_container\", return_value=False):\n         info = get_container_exec_info()\n \n     assert info is not None","@@ -143,7 +104,7 @@ def test_get_container_exec_info_defaults():\n             \"# minimal file with no keys\\n\"\n         )\n \n-        with patch(\"hermes_cli.config._is_inside_container\", return_value=False), \\\n+        with patch(\"hermes_constants.is_container\", return_value=False), \\\n              patch(\"hermes_cli.config.get_hermes_home\", return_value=hermes_home), \\\n              patch.dict(os.environ, {}, clear=False):\n             os.environ.pop(\"HERMES_DEV\", None)","@@ -165,7 +126,7 @@ def test_get_container_exec_info_docker_backend(container_env):\n         \"hermes_bin=/opt/hermes/bin/hermes\\n\"\n     )\n \n-    with patch(\"hermes_cli.config._is_inside_container\", return_value=False):\n+    with patch(\"hermes_constants.is_container\", return_value=False):\n         info = get_container_exec_info()\n \n     assert info[\"backend\"] == \"docker\"","@@ -176,7 +137,7 @@ def test_get_container_exec_info_docker_backend(container_env):\n \n def test_get_container_exec_info_crashes_on_permission_error(container_env):\n     \"\"\"PermissionError propagates instead of being silently swallowed.\"\"\"\n-    with patch(\"hermes_cli.config._is_inside_container\", return_value=False), \\\n+    with patch(\"hermes_constants.is_container\", return_value=False), \\\n          patch(\"builtins.open\", side_effect=PermissionError(\"permission denied\")):\n         with pytest.raises(PermissionError):\n             get_container_exec_info()"]},{"file":"tests/hermes_cli/test_gateway_service.py","hunks":["@@ -394,6 +394,21 @@ def test_launchd_status_reports_local_stale_plist_when_unloaded(self, tmp_path,\n \n \n class TestGatewayServiceDetection:\n+    def test_supports_systemd_services_requires_systemctl_binary(self, monkeypatch):\n+        monkeypatch.setattr(gateway_cli, \"is_linux\", lambda: True)\n+        monkeypatch.setattr(gateway_cli, \"is_termux\", lambda: False)\n+        monkeypatch.setattr(gateway_cli.shutil, \"which\", lambda name: None)\n+\n+        assert gateway_cli.supports_systemd_services() is False\n+\n+    def test_supports_systemd_services_returns_true_when_systemctl_present(self, monkeypatch):\n+        monkeypatch.setattr(gateway_cli, \"is_linux\", lambda: True)\n+        monkeypatch.setattr(gateway_cli, \"is_termux\", lambda: False)\n+        monkeypatch.setattr(gateway_cli, \"is_wsl\", lambda: False)\n+        monkeypatch.setattr(gateway_cli.shutil, \"which\", lambda name: \"/usr/bin/systemctl\")\n+\n+        assert gateway_cli.supports_systemd_services() is True\n+\n     def test_is_service_running_checks_system_scope_when_user_scope_is_inactive(self, monkeypatch):\n         user_unit = SimpleNamespace(exists=lambda: True)\n         system_unit = SimpleNamespace(exists=lambda: True)","@@ -418,6 +433,23 @@ def fake_run(cmd, capture_output=True, text=True, **kwargs):\n \n         assert gateway_cli._is_service_running() is True\n \n+    def test_is_service_running_returns_false_when_systemctl_missing(self, monkeypatch):\n+        unit = SimpleNamespace(exists=lambda: True)\n+\n+        monkeypatch.setattr(gateway_cli, \"supports_systemd_services\", lambda: True)\n+        monkeypatch.setattr(\n+            gateway_cli,\n+            \"get_systemd_unit_path\",\n+            lambda system=False: unit,\n+        )\n+\n+        def fake_run(*args, **kwargs):\n+            raise FileNotFoundError(\"systemctl\")\n+\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+\n+        assert gateway_cli._is_service_running() is False\n+\n \n class TestGatewaySystemServiceRouting:\n     def test_systemd_restart_self_requests_graceful_restart_without_reload_or_restart(self, monkeypatch, capsys):","@@ -1001,3 +1033,91 @@ def test_system_unit_has_no_root_paths(self, monkeypatch, tmp_path):\n         # Target user paths should be present\n         assert \"/home/alice\" in unit\n         assert \"WorkingDirectory=/home/alice/.hermes/hermes-agent\" in unit\n+\n+\n+class TestDockerAwareGateway:\n+    \"\"\"Tests for Docker container awareness in gateway commands.\"\"\"\n+\n+    def test_run_systemctl_raises_runtimeerror_when_missing(self, monkeypatch):\n+        \"\"\"_run_systemctl raises RuntimeError with container guidance when systemctl is absent.\"\"\"\n+        import pytest\n+\n+        def fake_run(cmd, **kwargs):\n+            raise FileNotFoundError(\"systemctl\")\n+\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+\n+        with pytest.raises(RuntimeError, match=\"systemctl is not available\"):\n+            gateway_cli._run_systemctl([\"start\", \"hermes-gateway\"])\n+\n+    def test_run_systemctl_passes_through_on_success(self, monkeypatch):\n+        \"\"\"_run_systemctl delegates to subprocess.run when systemctl exists.\"\"\"\n+        calls = []\n+\n+        def fake_run(cmd, **kwargs):\n+            calls.append(cmd)\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+\n+        result = gateway_cli._run_systemctl([\"status\", \"hermes-gateway\"])\n+        assert result.returncode == 0\n+        assert len(calls) == 1\n+        assert \"status\" in calls[0]\n+\n+    def test_install_in_container_prints_docker_guidance(self, monkeypatch, capsys):\n+        \"\"\"'hermes gateway install' inside Docker exits 0 with container guidance.\"\"\"\n+        import pytest\n+\n+        monkeypatch.setattr(gateway_cli, \"is_managed\", lambda: False)\n+        monkeypatch.setattr(gateway_cli, \"is_termux\", lambda: False)\n+        monkeypatch.setattr(gateway_cli, \"supports_systemd_services\", lambda: False)\n+        monkeypatch.setattr(gateway_cli, \"is_macos\", lambda: False)\n+        monkeypatch.setattr(gateway_cli, \"is_wsl\", lambda: False)\n+        monkeypatch.setattr(gateway_cli, \"is_container\", lambda: True)\n+\n+        args = SimpleNamespace(gateway_command=\"install\", force=False, system=False, run_as_user=None)\n+        with pytest.raises(SystemExit) as exc_info:\n+            gateway_cli.gateway_command(args)\n+\n+        assert exc_info.value.code == 0\n+        out = capsys.readouterr().out\n+        assert \"Docker\" in out or \"docker\" in out\n+        assert \"restart\" in out.lower()\n+\n+    def test_uninstall_in_container_prints_docker_guidance(self, monkeypatch, capsys):\n+        \"\"\"'hermes gateway uninstall' inside Docker exits 0 with container guidance.\"\"\"\n+        import pytest\n+\n+        monkeypatch.setattr(gateway_cli, \"is_managed\", lambda: False)\n+        monkeypatch.setattr(gateway_cli, \"is_termux\", lambda: False)\n+        monkeypatch.setattr(gateway_cli, \"supports_systemd_services\", lambda: False)\n+        monkeypatch.setattr(gateway_cli, \"is_macos\", lambda: False)\n+        monkeypatch.setattr(gateway_cli, \"is_container\", lambda: True)\n+\n+        args = SimpleNamespace(gateway_command=\"uninstall\", system=False)\n+        with pytest.raises(SystemExit) as exc_info:\n+            gateway_cli.gateway_command(args)\n+\n+        assert exc_info.value.code == 0\n+        out = capsys.readouterr().out\n+        assert \"docker\" in out.lower()\n+\n+    def test_start_in_container_prints_docker_guidance(self, monkeypatch, capsys):\n+        \"\"\"'hermes gateway start' inside Docker exits 0 with container guidance.\"\"\"\n+        import pytest\n+\n+        monkeypatch.setattr(gateway_cli, \"is_termux\", lambda: False)\n+        monkeypatch.setattr(gateway_cli, \"supports_systemd_services\", lambda: False)\n+        monkeypatch.setattr(gateway_cli, \"is_macos\", lambda: False)\n+        monkeypatch.setattr(gateway_cli, \"is_wsl\", lambda: False)\n+        monkeypatch.setattr(gateway_cli, \"is_container\", lambda: True)\n+\n+        args = SimpleNamespace(gateway_command=\"start\", system=False)\n+        with pytest.raises(SystemExit) as exc_info:\n+            gateway_cli.gateway_command(args)\n+\n+        assert exc_info.value.code == 0\n+        out = capsys.readouterr().out\n+        assert \"docker\" in out.lower()\n+        assert \"hermes gateway run\" in out"]},{"file":"tests/hermes_cli/test_setup.py","hunks":["@@ -1,5 +1,4 @@\n-\"\"\"Tests for setup_model_provider — verifies the delegation to\n-select_provider_and_model() and config dict sync.\"\"\"\n+\"\"\"Tests for setup.py configuration flows.\"\"\"\n import json\n import sys\n import types","@@ -8,6 +7,7 @@\n \n from hermes_cli.auth import get_active_provider\n from hermes_cli.config import load_config, save_config\n+from hermes_cli import setup as setup_mod\n from hermes_cli.setup import setup_model_provider\n \n ","@@ -144,6 +144,85 @@ def fake_select():\n     assert reloaded.get(\"custom_providers\") == [{\"name\": \"Local\", \"base_url\": \"http://localhost:8080/v1\"}]\n \n \n+def test_setup_gateway_skips_service_install_when_systemctl_missing(monkeypatch, capsys):\n+    env = {\n+        \"TELEGRAM_BOT_TOKEN\": \"\",\n+        \"TELEGRAM_HOME_CHANNEL\": \"\",\n+        \"DISCORD_BOT_TOKEN\": \"\",\n+        \"DISCORD_HOME_CHANNEL\": \"\",\n+        \"SLACK_BOT_TOKEN\": \"\",\n+        \"SLACK_HOME_CHANNEL\": \"\",\n+        \"MATRIX_HOMESERVER\": \"https://matrix.example.com\",\n+        \"MATRIX_USER_ID\": \"@alice:example.com\",\n+        \"MATRIX_PASSWORD\": \"\",\n+        \"MATRIX_ACCESS_TOKEN\": \"token\",\n+        \"BLUEBUBBLES_SERVER_URL\": \"\",\n+        \"BLUEBUBBLES_HOME_CHANNEL\": \"\",\n+        \"WHATSAPP_ENABLED\": \"\",\n+        \"WEBHOOK_ENABLED\": \"\",\n+    }\n+\n+    monkeypatch.setattr(setup_mod, \"get_env_value\", lambda key: env.get(key, \"\"))\n+    monkeypatch.setattr(setup_mod, \"prompt_yes_no\", lambda *args, **kwargs: False)\n+    monkeypatch.setattr(\"platform.system\", lambda: \"Linux\")\n+\n+    import hermes_cli.gateway as gateway_mod\n+\n+    monkeypatch.setattr(gateway_mod, \"supports_systemd_services\", lambda: False)\n+    monkeypatch.setattr(gateway_mod, \"is_macos\", lambda: False)\n+    monkeypatch.setattr(gateway_mod, \"_is_service_installed\", lambda: False)\n+    monkeypatch.setattr(gateway_mod, \"_is_service_running\", lambda: False)\n+\n+    setup_mod.setup_gateway({})\n+\n+    out = capsys.readouterr().out\n+    assert \"Messaging platforms configured!\" in out\n+    assert \"Start the gateway to bring your bots online:\" in out\n+    assert \"hermes gateway\" in out\n+\n+\n+def test_setup_gateway_in_container_shows_docker_guidance(monkeypatch, capsys):\n+    \"\"\"setup_gateway() in a Docker container shows Docker-specific restart instructions.\"\"\"\n+    env = {\n+        \"TELEGRAM_BOT_TOKEN\": \"\",\n+        \"TELEGRAM_HOME_CHANNEL\": \"\",\n+        \"DISCORD_BOT_TOKEN\": \"\",\n+        \"DISCORD_HOME_CHANNEL\": \"\",\n+        \"SLACK_BOT_TOKEN\": \"\",\n+        \"SLACK_HOME_CHANNEL\": \"\",\n+        \"MATRIX_HOMESERVER\": \"https://matrix.example.com\",\n+        \"MATRIX_USER_ID\": \"@alice:example.com\",\n+        \"MATRIX_PASSWORD\": \"\",\n+        \"MATRIX_ACCESS_TOKEN\": \"token\",\n+        \"BLUEBUBBLES_SERVER_URL\": \"\",\n+        \"BLUEBUBBLES_HOME_CHANNEL\": \"\",\n+        \"WHATSAPP_ENABLED\": \"\",\n+        \"WEBHOOK_ENABLED\": \"\",\n+    }\n+\n+    monkeypatch.setattr(setup_mod, \"get_env_value\", lambda key: env.get(key, \"\"))\n+    monkeypatch.setattr(setup_mod, \"prompt_yes_no\", lambda *args, **kwargs: False)\n+    monkeypatch.setattr(\"platform.system\", lambda: \"Linux\")\n+\n+    import hermes_cli.gateway as gateway_mod\n+\n+    monkeypatch.setattr(gateway_mod, \"supports_systemd_services\", lambda: False)\n+    monkeypatch.setattr(gateway_mod, \"is_macos\", lambda: False)\n+    monkeypatch.setattr(gateway_mod, \"_is_service_installed\", lambda: False)\n+    monkeypatch.setattr(gateway_mod, \"_is_service_running\", lambda: False)\n+\n+    # Patch is_container at the import location in setup.py\n+    import hermes_constants\n+    monkeypatch.setattr(hermes_constants, \"is_container\", lambda: True)\n+\n+    setup_mod.setup_gateway({})\n+\n+    out = capsys.readouterr().out\n+    assert \"Messaging platforms configured!\" in out\n+    assert \"docker\" in out.lower() or \"Docker\" in out\n+    assert \"restart\" in out.lower()\n+\n+\n def test_setup_syncs_custom_provider_removal_from_disk(tmp_path, monkeypatch):\n     \"\"\"Removing the last custom provider in model setup should persist.\"\"\"\n     monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))"]},{"file":"tests/test_hermes_constants.py","hunks":["@@ -6,7 +6,8 @@\n \n import pytest\n \n-from hermes_constants import get_default_hermes_root\n+import hermes_constants\n+from hermes_constants import get_default_hermes_root, is_container\n \n \n class TestGetDefaultHermesRoot:","@@ -60,3 +61,53 @@ def test_docker_profile_active(self, tmp_path, monkeypatch):\n         monkeypatch.setattr(Path, \"home\", lambda: tmp_path)\n         monkeypatch.setenv(\"HERMES_HOME\", str(profile))\n         assert get_default_hermes_root() == docker_root\n+\n+\n+class TestIsContainer:\n+    \"\"\"Tests for is_container() — Docker/Podman detection.\"\"\"\n+\n+    def _reset_cache(self, monkeypatch):\n+        \"\"\"Reset the cached detection result before each test.\"\"\"\n+        monkeypatch.setattr(hermes_constants, \"_container_detected\", None)\n+\n+    def test_detects_dockerenv(self, monkeypatch, tmp_path):\n+        \"\"\"/.dockerenv triggers container detection.\"\"\"\n+        self._reset_cache(monkeypatch)\n+        monkeypatch.setattr(os.path, \"exists\", lambda p: p == \"/.dockerenv\")\n+        assert is_container() is True\n+\n+    def test_detects_containerenv(self, monkeypatch, tmp_path):\n+        \"\"\"/run/.containerenv triggers container detection (Podman).\"\"\"\n+        self._reset_cache(monkeypatch)\n+        monkeypatch.setattr(os.path, \"exists\", lambda p: p == \"/run/.containerenv\")\n+        assert is_container() is True\n+\n+    def test_detects_cgroup_docker(self, monkeypatch, tmp_path):\n+        \"\"\"/proc/1/cgroup containing 'docker' triggers detection.\"\"\"\n+        import builtins\n+        self._reset_cache(monkeypatch)\n+        monkeypatch.setattr(os.path, \"exists\", lambda p: False)\n+        cgroup_file = tmp_path / \"cgroup\"\n+        cgroup_file.write_text(\"12:memory:/docker/abc123\\n\")\n+        _real_open = builtins.open\n+        monkeypatch.setattr(\"builtins.open\", lambda p, *a, **kw: _real_open(str(cgroup_file), *a, **kw) if p == \"/proc/1/cgroup\" else _real_open(p, *a, **kw))\n+        assert is_container() is True\n+\n+    def test_negative_case(self, monkeypatch, tmp_path):\n+        \"\"\"Returns False on a regular Linux host.\"\"\"\n+        import builtins\n+        self._reset_cache(monkeypatch)\n+        monkeypatch.setattr(os.path, \"exists\", lambda p: False)\n+        cgroup_file = tmp_path / \"cgroup\"\n+        cgroup_file.write_text(\"12:memory:/\\n\")\n+        _real_open = builtins.open\n+        monkeypatch.setattr(\"builtins.open\", lambda p, *a, **kw: _real_open(str(cgroup_file), *a, **kw) if p == \"/proc/1/cgroup\" else _real_open(p, *a, **kw))\n+        assert is_container() is False\n+\n+    def test_caches_result(self, monkeypatch):\n+        \"\"\"Second call uses cached value without re-probing.\"\"\"\n+        monkeypatch.setattr(hermes_constants, \"_container_detected\", True)\n+        assert is_container() is True\n+        # Even if we make os.path.exists return False, cached value wins\n+        monkeypatch.setattr(os.path, \"exists\", lambda p: False)\n+        assert is_container() is True"]},{"file":"tools/voice_mode.py","hunks":["@@ -106,8 +106,9 @@ def detect_audio_environment() -> dict:\n     if any(os.environ.get(v) for v in ('SSH_CLIENT', 'SSH_TTY', 'SSH_CONNECTION')):\n         warnings.append(\"Running over SSH -- no audio devices available\")\n \n-    # Docker detection\n-    if os.path.exists('/.dockerenv'):\n+    # Docker/Podman container detection\n+    from hermes_constants import is_container\n+    if is_container():\n         warnings.append(\"Running inside Docker container -- no audio devices\")\n \n     # WSL detection — PulseAudio bridge makes audio work in WSL.\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}