test_docker_environment.py
1 import logging 2 from io import StringIO 3 import subprocess 4 import sys 5 import types 6 7 import pytest 8 9 from tools.environments import docker as docker_env 10 11 12 def _mock_subprocess_run(monkeypatch): 13 """Mock subprocess.run to intercept docker run -d and docker version calls. 14 15 Returns a list of captured (cmd, kwargs) tuples for inspection. 16 """ 17 calls = [] 18 19 def _run(cmd, **kwargs): 20 calls.append((list(cmd) if isinstance(cmd, list) else cmd, kwargs)) 21 if isinstance(cmd, list) and len(cmd) >= 2: 22 if cmd[1] == "version": 23 return subprocess.CompletedProcess(cmd, 0, stdout="Docker version", stderr="") 24 if cmd[1] == "run": 25 return subprocess.CompletedProcess(cmd, 0, stdout="fake-container-id\n", stderr="") 26 return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") 27 28 monkeypatch.setattr(docker_env.subprocess, "run", _run) 29 return calls 30 31 32 def _make_dummy_env(**kwargs): 33 """Helper to construct DockerEnvironment with minimal required args.""" 34 return docker_env.DockerEnvironment( 35 image=kwargs.get("image", "python:3.11"), 36 cwd=kwargs.get("cwd", "/root"), 37 timeout=kwargs.get("timeout", 60), 38 cpu=kwargs.get("cpu", 0), 39 memory=kwargs.get("memory", 0), 40 disk=kwargs.get("disk", 0), 41 persistent_filesystem=kwargs.get("persistent_filesystem", False), 42 task_id=kwargs.get("task_id", "test-task"), 43 volumes=kwargs.get("volumes", []), 44 network=kwargs.get("network", True), 45 host_cwd=kwargs.get("host_cwd"), 46 auto_mount_cwd=kwargs.get("auto_mount_cwd", False), 47 env=kwargs.get("env"), 48 run_as_host_user=kwargs.get("run_as_host_user", False), 49 ) 50 51 52 def test_ensure_docker_available_logs_and_raises_when_not_found(monkeypatch, caplog): 53 """When docker cannot be found, raise a clear error before container setup.""" 54 55 monkeypatch.setattr(docker_env, "find_docker", lambda: None) 56 monkeypatch.setattr( 57 docker_env.subprocess, 58 "run", 59 lambda *args, **kwargs: pytest.fail("subprocess.run should not be called when docker is missing"), 60 ) 61 62 with caplog.at_level(logging.ERROR): 63 with pytest.raises(RuntimeError) as excinfo: 64 _make_dummy_env() 65 66 assert "Docker executable not found in PATH or known install locations" in str(excinfo.value) 67 assert any( 68 "no docker executable was found in PATH or known install locations" 69 in record.getMessage() 70 for record in caplog.records 71 ) 72 73 74 def test_ensure_docker_available_logs_and_raises_on_timeout(monkeypatch, caplog): 75 """When docker version times out, surface a helpful error instead of hanging.""" 76 77 def _raise_timeout(*args, **kwargs): 78 raise subprocess.TimeoutExpired(cmd=["/custom/docker", "version"], timeout=5) 79 80 monkeypatch.setattr(docker_env, "find_docker", lambda: "/custom/docker") 81 monkeypatch.setattr(docker_env.subprocess, "run", _raise_timeout) 82 83 with caplog.at_level(logging.ERROR): 84 with pytest.raises(RuntimeError) as excinfo: 85 _make_dummy_env() 86 87 assert "Docker daemon is not responding" in str(excinfo.value) 88 assert any( 89 "/custom/docker version' timed out" in record.getMessage() 90 for record in caplog.records 91 ) 92 93 94 def test_ensure_docker_available_uses_resolved_executable(monkeypatch): 95 """When docker is found outside PATH, preflight should use that resolved path.""" 96 97 calls = [] 98 99 def _run(cmd, **kwargs): 100 calls.append((cmd, kwargs)) 101 return subprocess.CompletedProcess(cmd, 0, stdout="Docker version", stderr="") 102 103 monkeypatch.setattr(docker_env, "find_docker", lambda: "/opt/homebrew/bin/docker") 104 monkeypatch.setattr(docker_env.subprocess, "run", _run) 105 106 docker_env._ensure_docker_available() 107 108 assert calls == [ 109 (["/opt/homebrew/bin/docker", "version"], { 110 "capture_output": True, 111 "text": True, 112 "timeout": 5, 113 }) 114 ] 115 116 117 def test_auto_mount_host_cwd_adds_volume(monkeypatch, tmp_path): 118 """Opt-in docker cwd mounting should bind the host cwd to /workspace.""" 119 project_dir = tmp_path / "my-project" 120 project_dir.mkdir() 121 122 monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") 123 calls = _mock_subprocess_run(monkeypatch) 124 125 _make_dummy_env( 126 cwd="/workspace", 127 host_cwd=str(project_dir), 128 auto_mount_cwd=True, 129 ) 130 131 # Find the docker run call and check its args 132 run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"] 133 assert run_calls, "docker run should have been called" 134 run_args_str = " ".join(run_calls[0][0]) 135 assert f"{project_dir}:/workspace" in run_args_str 136 137 138 def test_auto_mount_disabled_by_default(monkeypatch, tmp_path): 139 """Host cwd should not be mounted unless the caller explicitly opts in.""" 140 project_dir = tmp_path / "my-project" 141 project_dir.mkdir() 142 143 monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") 144 calls = _mock_subprocess_run(monkeypatch) 145 146 _make_dummy_env( 147 cwd="/root", 148 host_cwd=str(project_dir), 149 auto_mount_cwd=False, 150 ) 151 152 run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"] 153 assert run_calls, "docker run should have been called" 154 run_args_str = " ".join(run_calls[0][0]) 155 assert f"{project_dir}:/workspace" not in run_args_str 156 157 158 def test_auto_mount_skipped_when_workspace_already_mounted(monkeypatch, tmp_path): 159 """Explicit user volumes for /workspace should take precedence over cwd mount.""" 160 project_dir = tmp_path / "my-project" 161 project_dir.mkdir() 162 other_dir = tmp_path / "other" 163 other_dir.mkdir() 164 165 monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") 166 calls = _mock_subprocess_run(monkeypatch) 167 168 _make_dummy_env( 169 cwd="/workspace", 170 host_cwd=str(project_dir), 171 auto_mount_cwd=True, 172 volumes=[f"{other_dir}:/workspace"], 173 ) 174 175 run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"] 176 assert run_calls, "docker run should have been called" 177 run_args_str = " ".join(run_calls[0][0]) 178 assert f"{other_dir}:/workspace" in run_args_str 179 assert run_args_str.count(":/workspace") == 1 180 181 182 def test_auto_mount_replaces_persistent_workspace_bind(monkeypatch, tmp_path): 183 """Persistent mode should still prefer the configured host cwd at /workspace.""" 184 project_dir = tmp_path / "my-project" 185 project_dir.mkdir() 186 187 monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") 188 calls = _mock_subprocess_run(monkeypatch) 189 190 _make_dummy_env( 191 cwd="/workspace", 192 persistent_filesystem=True, 193 host_cwd=str(project_dir), 194 auto_mount_cwd=True, 195 task_id="test-persistent-auto-mount", 196 ) 197 198 run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"] 199 assert run_calls, "docker run should have been called" 200 run_args_str = " ".join(run_calls[0][0]) 201 assert f"{project_dir}:/workspace" in run_args_str 202 assert "/sandboxes/docker/test-persistent-auto-mount/workspace:/workspace" not in run_args_str 203 204 205 def test_non_persistent_cleanup_removes_container(monkeypatch): 206 """When persistent=false, cleanup() must schedule docker stop + rm.""" 207 monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") 208 calls = _mock_subprocess_run(monkeypatch) 209 210 popen_cmds = [] 211 monkeypatch.setattr( 212 docker_env.subprocess, "Popen", 213 lambda cmd, **kw: (popen_cmds.append(cmd), type("P", (), {"poll": lambda s: 0, "wait": lambda s, **k: None, "returncode": 0, "stdout": iter([]), "stdin": None})())[1], 214 ) 215 216 env = _make_dummy_env(persistent_filesystem=False, task_id="ephemeral-task") 217 assert env._container_id 218 container_id = env._container_id 219 220 env.cleanup() 221 222 # Should have stop and rm calls via Popen 223 stop_cmds = [c for c in popen_cmds if container_id in str(c) and "stop" in str(c)] 224 assert len(stop_cmds) >= 1, f"cleanup() should schedule docker stop for {container_id}" 225 226 227 class _FakePopen: 228 def __init__(self, cmd, **kwargs): 229 self.cmd = cmd 230 self.kwargs = kwargs 231 self.stdout = StringIO("") 232 self.stdin = None 233 self.returncode = 0 234 235 def poll(self): 236 return self.returncode 237 238 239 def _make_execute_only_env(forward_env=None): 240 env = docker_env.DockerEnvironment.__new__(docker_env.DockerEnvironment) 241 env.cwd = "/root" 242 env.timeout = 60 243 env._forward_env = forward_env or [] 244 env._env = {} 245 env._prepare_command = lambda command: (command, None) 246 env._timeout_result = lambda timeout: {"output": f"timed out after {timeout}", "returncode": 124} 247 env._container_id = "test-container" 248 env._docker_exe = "/usr/bin/docker" 249 # Base class attributes needed by unified execute() 250 env._session_id = "test123" 251 env._snapshot_path = "/tmp/hermes-snap-test123.sh" 252 env._cwd_file = "/tmp/hermes-cwd-test123.txt" 253 env._cwd_marker = "__HERMES_CWD_test123__" 254 env._snapshot_ready = True 255 env._last_sync_time = None 256 env._init_env_args = [] 257 return env 258 259 260 def test_init_env_args_uses_hermes_dotenv_for_allowlisted_env(monkeypatch): 261 """_build_init_env_args picks up forwarded env vars from .env file at init time.""" 262 # Use a var that is NOT in _HERMES_PROVIDER_ENV_BLOCKLIST (GITHUB_TOKEN 263 # is in the copilot provider's api_key_env_vars and gets stripped). 264 env = _make_execute_only_env(["DATABASE_URL"]) 265 266 monkeypatch.delenv("DATABASE_URL", raising=False) 267 monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"DATABASE_URL": "value_from_dotenv"}) 268 269 args = env._build_init_env_args() 270 args_str = " ".join(args) 271 272 assert "DATABASE_URL=value_from_dotenv" in args_str 273 274 275 def test_init_env_args_prefers_shell_env_over_hermes_dotenv(monkeypatch): 276 """Shell env vars take priority over .env file values in init env args.""" 277 env = _make_execute_only_env(["DATABASE_URL"]) 278 279 monkeypatch.setenv("DATABASE_URL", "value_from_shell") 280 monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"DATABASE_URL": "value_from_dotenv"}) 281 282 args = env._build_init_env_args() 283 args_str = " ".join(args) 284 285 assert "DATABASE_URL=value_from_shell" in args_str 286 assert "value_from_dotenv" not in args_str 287 288 289 # ── docker_env tests ────────────────────────────────────────────── 290 291 292 def test_docker_env_appears_in_run_command(monkeypatch): 293 """Explicit docker_env values should be passed via -e at docker run time.""" 294 monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") 295 calls = _mock_subprocess_run(monkeypatch) 296 297 _make_dummy_env(env={"SSH_AUTH_SOCK": "/run/user/1000/ssh-agent.sock", "GNUPGHOME": "/root/.gnupg"}) 298 299 run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"] 300 assert run_calls, "docker run should have been called" 301 run_args = run_calls[0][0] 302 run_args_str = " ".join(run_args) 303 assert "SSH_AUTH_SOCK=/run/user/1000/ssh-agent.sock" in run_args_str 304 assert "GNUPGHOME=/root/.gnupg" in run_args_str 305 306 307 def test_docker_env_appears_in_init_env_args(monkeypatch): 308 """Explicit docker_env values should appear in _build_init_env_args.""" 309 env = _make_execute_only_env() 310 env._env = {"MY_VAR": "my_value"} 311 312 args = env._build_init_env_args() 313 args_str = " ".join(args) 314 315 assert "MY_VAR=my_value" in args_str 316 317 318 def test_forward_env_overrides_docker_env_in_init_args(monkeypatch): 319 """docker_forward_env should override docker_env for the same key.""" 320 env = _make_execute_only_env(forward_env=["MY_KEY"]) 321 env._env = {"MY_KEY": "static_value"} 322 323 monkeypatch.setenv("MY_KEY", "dynamic_value") 324 monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {}) 325 326 args = env._build_init_env_args() 327 args_str = " ".join(args) 328 329 assert "MY_KEY=dynamic_value" in args_str 330 assert "MY_KEY=static_value" not in args_str 331 332 333 def test_docker_env_and_forward_env_merge_in_init_args(monkeypatch): 334 """docker_env and docker_forward_env with different keys should both appear.""" 335 env = _make_execute_only_env(forward_env=["TOKEN"]) 336 env._env = {"SSH_AUTH_SOCK": "/run/user/1000/agent.sock"} 337 338 monkeypatch.setenv("TOKEN", "secret123") 339 monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {}) 340 341 args = env._build_init_env_args() 342 args_str = " ".join(args) 343 344 assert "SSH_AUTH_SOCK=/run/user/1000/agent.sock" in args_str 345 assert "TOKEN=secret123" in args_str 346 347 348 349 def test_normalize_env_dict_filters_invalid_keys(): 350 """_normalize_env_dict should reject invalid variable names.""" 351 result = docker_env._normalize_env_dict({ 352 "VALID_KEY": "ok", 353 "123bad": "rejected", 354 "": "rejected", 355 "also valid": "rejected", # spaces invalid 356 "GOOD": "ok", 357 }) 358 assert result == {"VALID_KEY": "ok", "GOOD": "ok"} 359 360 361 def test_normalize_env_dict_coerces_scalars(): 362 """_normalize_env_dict should coerce int/float/bool to str.""" 363 result = docker_env._normalize_env_dict({ 364 "PORT": 8080, 365 "DEBUG": True, 366 "RATIO": 0.5, 367 }) 368 assert result == {"PORT": "8080", "DEBUG": "True", "RATIO": "0.5"} 369 370 371 def test_normalize_env_dict_rejects_non_dict(): 372 """_normalize_env_dict should return empty dict for non-dict input.""" 373 assert docker_env._normalize_env_dict("not a dict") == {} 374 assert docker_env._normalize_env_dict(None) == {} 375 assert docker_env._normalize_env_dict([]) == {} 376 377 378 def test_normalize_env_dict_rejects_complex_values(): 379 """_normalize_env_dict should reject list/dict values.""" 380 result = docker_env._normalize_env_dict({ 381 "GOOD": "string", 382 "BAD_LIST": [1, 2, 3], 383 "BAD_DICT": {"nested": True}, 384 }) 385 assert result == {"GOOD": "string"} 386 387 388 def test_security_args_include_setuid_setgid_for_gosu_drop(monkeypatch): 389 """The default (run_as_host_user=False) invocation must include SETUID and 390 SETGID caps so the image entrypoint can drop from root to the non-root 391 `hermes` user via gosu. 392 393 Without these caps gosu exits with 394 ``error: failed switching to 'hermes': operation not permitted`` 395 and the container exits immediately (exit 1) before running any work. 396 397 `no-new-privileges` is kept, so gosu still cannot escalate back to root 398 after the drop — the drop is a one-way transition performed before the 399 `no_new_privs` bit is enforced on the exec boundary. 400 """ 401 monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") 402 calls = _mock_subprocess_run(monkeypatch) 403 404 _make_dummy_env() 405 406 run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"] 407 assert run_calls, "docker run should have been called" 408 run_args = run_calls[0][0] 409 410 added = { 411 run_args[i + 1] 412 for i, flag in enumerate(run_args[:-1]) 413 if flag == "--cap-add" 414 } 415 assert "SETUID" in added, "SETUID cap missing — gosu drop in entrypoint will fail" 416 assert "SETGID" in added, "SETGID cap missing — gosu drop in entrypoint will fail" 417 418 419 # ── run_as_host_user tests ──────────────────────────────────────── 420 421 422 def test_run_as_host_user_passes_uid_gid(monkeypatch): 423 """With run_as_host_user=True, --user <uid>:<gid> is added to docker run.""" 424 monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") 425 monkeypatch.setattr(docker_env.os, "getuid", lambda: 1234, raising=False) 426 monkeypatch.setattr(docker_env.os, "getgid", lambda: 5678, raising=False) 427 calls = _mock_subprocess_run(monkeypatch) 428 429 _make_dummy_env(run_as_host_user=True) 430 431 run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"] 432 assert run_calls, "docker run should have been called" 433 run_args = run_calls[0][0] 434 435 # --user must be present and must be paired with "1234:5678" 436 assert "--user" in run_args, f"--user flag missing from docker run args: {run_args}" 437 idx = run_args.index("--user") 438 assert run_args[idx + 1] == "1234:5678", ( 439 f"expected --user 1234:5678, got --user {run_args[idx + 1]}" 440 ) 441 442 443 def test_run_as_host_user_drops_setuid_setgid_caps(monkeypatch): 444 """When --user is passed, the container never needs gosu, so SETUID/SETGID 445 caps are omitted for a tighter security posture.""" 446 monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") 447 monkeypatch.setattr(docker_env.os, "getuid", lambda: 1000, raising=False) 448 monkeypatch.setattr(docker_env.os, "getgid", lambda: 1000, raising=False) 449 calls = _mock_subprocess_run(monkeypatch) 450 451 _make_dummy_env(run_as_host_user=True) 452 453 run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"] 454 run_args = run_calls[0][0] 455 456 added = { 457 run_args[i + 1] 458 for i, flag in enumerate(run_args[:-1]) 459 if flag == "--cap-add" 460 } 461 assert "SETUID" not in added, ( 462 "SETUID cap should be dropped when running as host user — no gosu drop is needed" 463 ) 464 assert "SETGID" not in added, ( 465 "SETGID cap should be dropped when running as host user — no gosu drop is needed" 466 ) 467 # Core non-privilege-drop caps must still be there (pip/npm/apt need them). 468 assert "DAC_OVERRIDE" in added 469 assert "CHOWN" in added 470 assert "FOWNER" in added 471 472 473 def test_run_as_host_user_default_off(monkeypatch): 474 """Without the opt-in, no --user flag is emitted — preserving existing behavior.""" 475 monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") 476 calls = _mock_subprocess_run(monkeypatch) 477 478 _make_dummy_env() # run_as_host_user defaults to False 479 480 run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"] 481 run_args = run_calls[0][0] 482 assert "--user" not in run_args, ( 483 f"--user should not be in docker run args when opt-in is off: {run_args}" 484 ) 485 486 487 def test_run_as_host_user_warns_and_skips_when_no_posix_ids(monkeypatch, caplog): 488 """On platforms without POSIX getuid/getgid, log a warning and leave the 489 container at its image default user (no --user flag, full cap set).""" 490 monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") 491 # Simulate a platform where os.getuid is absent (e.g. Windows host). 492 monkeypatch.delattr(docker_env.os, "getuid", raising=False) 493 monkeypatch.delattr(docker_env.os, "getgid", raising=False) 494 calls = _mock_subprocess_run(monkeypatch) 495 496 with caplog.at_level(logging.WARNING): 497 _make_dummy_env(run_as_host_user=True) 498 499 run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"] 500 run_args = run_calls[0][0] 501 502 assert "--user" not in run_args 503 # Fall back to the full cap set since the container still starts as root. 504 added = { 505 run_args[i + 1] 506 for i, flag in enumerate(run_args[:-1]) 507 if flag == "--cap-add" 508 } 509 assert "SETUID" in added 510 assert "SETGID" in added 511 assert any( 512 "does not expose POSIX uid/gid" in rec.getMessage() 513 for rec in caplog.records 514 ), "expected a warning when POSIX ids are unavailable"