/ tests / hermes_cli / test_container_aware_cli.py
test_container_aware_cli.py
  1  """Tests for container-aware CLI routing (NixOS container mode).
  2  
  3  When container.enable = true in the NixOS module, the activation script
  4  writes a .container-mode metadata file. The host CLI detects this and
  5  execs into the container instead of running locally.
  6  """
  7  import os
  8  import subprocess
  9  from pathlib import Path
 10  from unittest.mock import MagicMock, patch
 11  
 12  import pytest
 13  
 14  from hermes_cli.config import (
 15      get_container_exec_info,
 16  )
 17  
 18  
 19  # =============================================================================
 20  # get_container_exec_info
 21  # =============================================================================
 22  
 23  
 24  @pytest.fixture
 25  def container_env(tmp_path, monkeypatch):
 26      """Set up a fake HERMES_HOME with .container-mode file."""
 27      hermes_home = tmp_path / ".hermes"
 28      hermes_home.mkdir()
 29      monkeypatch.setenv("HERMES_HOME", str(hermes_home))
 30      monkeypatch.delenv("HERMES_DEV", raising=False)
 31  
 32      container_mode = hermes_home / ".container-mode"
 33      container_mode.write_text(
 34          "# Written by NixOS activation script. Do not edit manually.\n"
 35          "backend=podman\n"
 36          "container_name=hermes-agent\n"
 37          "exec_user=hermes\n"
 38          "hermes_bin=/data/current-package/bin/hermes\n"
 39      )
 40      return hermes_home
 41  
 42  
 43  def test_get_container_exec_info_returns_metadata(container_env):
 44      """Reads .container-mode and returns all fields including exec_user."""
 45      with patch("hermes_constants.is_container", return_value=False):
 46          info = get_container_exec_info()
 47  
 48      assert info is not None
 49      assert info["backend"] == "podman"
 50      assert info["container_name"] == "hermes-agent"
 51      assert info["exec_user"] == "hermes"
 52      assert info["hermes_bin"] == "/data/current-package/bin/hermes"
 53  
 54  
 55  def test_get_container_exec_info_none_inside_container(container_env):
 56      """Returns None when we're already inside a container."""
 57      with patch("hermes_constants.is_container", return_value=True):
 58          info = get_container_exec_info()
 59  
 60      assert info is None
 61  
 62  
 63  def test_get_container_exec_info_none_without_file(tmp_path, monkeypatch):
 64      """Returns None when .container-mode doesn't exist (native mode)."""
 65      hermes_home = tmp_path / ".hermes"
 66      hermes_home.mkdir()
 67      monkeypatch.setenv("HERMES_HOME", str(hermes_home))
 68      monkeypatch.delenv("HERMES_DEV", raising=False)
 69  
 70      with patch("hermes_constants.is_container", return_value=False):
 71          info = get_container_exec_info()
 72  
 73      assert info is None
 74  
 75  
 76  def test_get_container_exec_info_skipped_when_hermes_dev(container_env, monkeypatch):
 77      """Returns None when HERMES_DEV=1 is set (dev mode bypass)."""
 78      monkeypatch.setenv("HERMES_DEV", "1")
 79  
 80      with patch("hermes_constants.is_container", return_value=False):
 81          info = get_container_exec_info()
 82  
 83      assert info is None
 84  
 85  
 86  def test_get_container_exec_info_not_skipped_when_hermes_dev_zero(container_env, monkeypatch):
 87      """HERMES_DEV=0 does NOT trigger bypass — only '1' does."""
 88      monkeypatch.setenv("HERMES_DEV", "0")
 89  
 90      with patch("hermes_constants.is_container", return_value=False):
 91          info = get_container_exec_info()
 92  
 93      assert info is not None
 94  
 95  
 96  def test_get_container_exec_info_defaults():
 97      """Falls back to defaults for missing keys."""
 98      import tempfile
 99  
100      with tempfile.TemporaryDirectory() as tmpdir:
101          hermes_home = Path(tmpdir) / ".hermes"
102          hermes_home.mkdir()
103          (hermes_home / ".container-mode").write_text(
104              "# minimal file with no keys\n"
105          )
106  
107          with patch("hermes_constants.is_container", return_value=False), \
108               patch.dict(get_container_exec_info.__globals__, {"get_hermes_home": lambda: hermes_home}), \
109               patch.dict(os.environ, {}, clear=False):
110              os.environ.pop("HERMES_DEV", None)
111              info = get_container_exec_info()
112  
113          assert info is not None
114          assert info["backend"] == "docker"
115          assert info["container_name"] == "hermes-agent"
116          assert info["exec_user"] == "hermes"
117          assert info["hermes_bin"] == "/data/current-package/bin/hermes"
118  
119  
120  def test_get_container_exec_info_docker_backend(container_env):
121      """Correctly reads docker backend with custom exec_user."""
122      (container_env / ".container-mode").write_text(
123          "backend=docker\n"
124          "container_name=hermes-custom\n"
125          "exec_user=myuser\n"
126          "hermes_bin=/opt/hermes/bin/hermes\n"
127      )
128  
129      with patch("hermes_constants.is_container", return_value=False):
130          info = get_container_exec_info()
131  
132      assert info["backend"] == "docker"
133      assert info["container_name"] == "hermes-custom"
134      assert info["exec_user"] == "myuser"
135      assert info["hermes_bin"] == "/opt/hermes/bin/hermes"
136  
137  
138  def test_get_container_exec_info_crashes_on_permission_error(container_env):
139      """PermissionError propagates instead of being silently swallowed."""
140      with patch("hermes_constants.is_container", return_value=False), \
141           patch("builtins.open", side_effect=PermissionError("permission denied")):
142          with pytest.raises(PermissionError):
143              get_container_exec_info()
144  
145  
146  # =============================================================================
147  # _exec_in_container
148  # =============================================================================
149  
150  
151  @pytest.fixture
152  def docker_container_info():
153      return {
154          "backend": "docker",
155          "container_name": "hermes-agent",
156          "exec_user": "hermes",
157          "hermes_bin": "/data/current-package/bin/hermes",
158      }
159  
160  
161  @pytest.fixture
162  def podman_container_info():
163      return {
164          "backend": "podman",
165          "container_name": "hermes-agent",
166          "exec_user": "hermes",
167          "hermes_bin": "/data/current-package/bin/hermes",
168      }
169  
170  
171  def test_exec_in_container_calls_execvp(docker_container_info):
172      """Verifies os.execvp is called with correct args: runtime, tty flags,
173      user, env vars, container name, binary, and CLI args."""
174      from hermes_cli.main import _exec_in_container
175  
176      with patch("shutil.which", return_value="/usr/bin/docker"), \
177           patch("subprocess.run") as mock_run, \
178           patch("sys.stdin") as mock_stdin, \
179           patch("os.execvp") as mock_execvp, \
180           patch.dict(os.environ, {"TERM": "xterm-256color", "LANG": "en_US.UTF-8"},
181                      clear=False):
182          mock_stdin.isatty.return_value = True
183          mock_run.return_value = MagicMock(returncode=0)
184  
185          _exec_in_container(docker_container_info, ["chat", "-m", "opus"])
186  
187      mock_execvp.assert_called_once()
188      cmd = mock_execvp.call_args[0][1]
189      assert cmd[0] == "/usr/bin/docker"
190      assert cmd[1] == "exec"
191      assert "-it" in cmd
192      idx_u = cmd.index("-u")
193      assert cmd[idx_u + 1] == "hermes"
194      e_indices = [i for i, v in enumerate(cmd) if v == "-e"]
195      e_values = [cmd[i + 1] for i in e_indices]
196      assert "TERM=xterm-256color" in e_values
197      assert "LANG=en_US.UTF-8" in e_values
198      assert "hermes-agent" in cmd
199      assert "/data/current-package/bin/hermes" in cmd
200      assert "chat" in cmd
201  
202  
203  def test_exec_in_container_non_tty_uses_i_only(docker_container_info):
204      """Non-TTY mode uses -i instead of -it."""
205      from hermes_cli.main import _exec_in_container
206  
207      with patch("shutil.which", return_value="/usr/bin/docker"), \
208           patch("subprocess.run") as mock_run, \
209           patch("sys.stdin") as mock_stdin, \
210           patch("os.execvp") as mock_execvp:
211          mock_stdin.isatty.return_value = False
212          mock_run.return_value = MagicMock(returncode=0)
213  
214          _exec_in_container(docker_container_info, ["sessions", "list"])
215  
216      cmd = mock_execvp.call_args[0][1]
217      assert "-i" in cmd
218      assert "-it" not in cmd
219  
220  
221  def test_exec_in_container_no_runtime_hard_fails(podman_container_info):
222      """Hard fails when runtime not found (no fallback)."""
223      from hermes_cli.main import _exec_in_container
224  
225      with patch("shutil.which", return_value=None), \
226           patch("subprocess.run") as mock_run, \
227           patch("os.execvp") as mock_execvp, \
228           pytest.raises(SystemExit) as exc_info:
229          _exec_in_container(podman_container_info, ["chat"])
230  
231      mock_run.assert_not_called()
232      mock_execvp.assert_not_called()
233      assert exc_info.value.code != 0
234  
235  
236  def test_exec_in_container_sudo_probe_sets_prefix(podman_container_info):
237      """When first probe fails and sudo probe succeeds, execvp is called
238      with sudo -n prefix."""
239      from hermes_cli.main import _exec_in_container
240  
241      def which_side_effect(name):
242          if name == "podman":
243              return "/usr/bin/podman"
244          if name == "sudo":
245              return "/usr/bin/sudo"
246          return None
247  
248      with patch("shutil.which", side_effect=which_side_effect), \
249           patch("subprocess.run") as mock_run, \
250           patch("sys.stdin") as mock_stdin, \
251           patch("os.execvp") as mock_execvp:
252          mock_stdin.isatty.return_value = True
253          mock_run.side_effect = [
254              MagicMock(returncode=1),  # direct probe fails
255              MagicMock(returncode=0),  # sudo probe succeeds
256          ]
257  
258          _exec_in_container(podman_container_info, ["chat"])
259  
260      mock_execvp.assert_called_once()
261      cmd = mock_execvp.call_args[0][1]
262      assert cmd[0] == "/usr/bin/sudo"
263      assert cmd[1] == "-n"
264      assert cmd[2] == "/usr/bin/podman"
265      assert cmd[3] == "exec"
266  
267  
268  def test_exec_in_container_probe_timeout_prints_message(docker_container_info):
269      """TimeoutExpired from probe produces a human-readable error, not a
270      raw traceback."""
271      from hermes_cli.main import _exec_in_container
272  
273      with patch("shutil.which", return_value="/usr/bin/docker"), \
274           patch("subprocess.run", side_effect=subprocess.TimeoutExpired(
275               cmd=["docker", "inspect"], timeout=15)), \
276           patch("os.execvp") as mock_execvp, \
277           pytest.raises(SystemExit) as exc_info:
278          _exec_in_container(docker_container_info, ["chat"])
279  
280      mock_execvp.assert_not_called()
281      assert exc_info.value.code == 1
282  
283  
284  def test_exec_in_container_container_not_running_no_sudo(docker_container_info):
285      """When runtime exists but container not found and no sudo available,
286      prints helpful error about root containers."""
287      from hermes_cli.main import _exec_in_container
288  
289      def which_side_effect(name):
290          if name == "docker":
291              return "/usr/bin/docker"
292          return None
293  
294      with patch("shutil.which", side_effect=which_side_effect), \
295           patch("subprocess.run") as mock_run, \
296           patch("os.execvp") as mock_execvp, \
297           pytest.raises(SystemExit) as exc_info:
298          mock_run.return_value = MagicMock(returncode=1)
299  
300          _exec_in_container(docker_container_info, ["chat"])
301  
302      mock_execvp.assert_not_called()
303      assert exc_info.value.code == 1