/ tests / tools / test_docker_environment.py
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"