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