diff --git a/tests/tools/test_docker_environment.py b/tests/tools/test_docker_environment.py index 3598895a7b3..13eb08c9360 100644 --- a/tests/tools/test_docker_environment.py +++ b/tests/tools/test_docker_environment.py @@ -1368,3 +1368,120 @@ def test_container_finished_at_returns_none_on_zero_value(): ): result = docker_env._container_finished_at("/usr/bin/docker", "never-finished") assert result is None + + +def test_credential_mount_skipped_when_source_is_directory(monkeypatch, tmp_path, caplog): + """Credential mount should be skipped when source path is a directory. + + In Docker-in-Docker scenarios, Docker may auto-create the source path as + a directory when it doesn't exist on the host. Mounting a directory over + a file destination causes exit 125. + """ + # Create a directory that looks like a corrupted credential file path + corrupted_dir = tmp_path / "google_token.json" + corrupted_dir.mkdir() + + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + calls = _mock_subprocess_run(monkeypatch) + + # Mock get_credential_file_mounts to return the corrupted entry + fake_mounts = [ + {"host_path": str(corrupted_dir), "container_path": "/root/.hermes/google_token.json"}, + ] + monkeypatch.setattr( + "tools.credential_files.get_credential_file_mounts", + lambda: fake_mounts, + ) + monkeypatch.setattr( + "tools.credential_files.get_skills_directory_mount", + lambda: [], + ) + monkeypatch.setattr( + "tools.credential_files.get_cache_directory_mounts", + lambda: [], + ) + + with caplog.at_level(logging.WARNING): + _make_dummy_env() + + # The corrupted mount should be skipped + run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"] + assert run_calls, "docker run should have been called" + run_args_str = " ".join(run_calls[0][0]) + assert "google_token.json" not in run_args_str + + # Should log a warning about the directory source + assert any( + "source is a directory" in rec.getMessage() + for rec in caplog.records + ) + + +def test_credential_mount_skipped_when_source_missing(monkeypatch, tmp_path, caplog): + """Credential mount should be skipped when source file no longer exists.""" + missing_path = tmp_path / "deleted_token.json" + # Don't create the file — it's "missing" + + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + calls = _mock_subprocess_run(monkeypatch) + + fake_mounts = [ + {"host_path": str(missing_path), "container_path": "/root/.hermes/deleted_token.json"}, + ] + monkeypatch.setattr( + "tools.credential_files.get_credential_file_mounts", + lambda: fake_mounts, + ) + monkeypatch.setattr( + "tools.credential_files.get_skills_directory_mount", + lambda: [], + ) + monkeypatch.setattr( + "tools.credential_files.get_cache_directory_mounts", + lambda: [], + ) + + with caplog.at_level(logging.WARNING): + _make_dummy_env() + + run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"] + assert run_calls, "docker run should have been called" + run_args_str = " ".join(run_calls[0][0]) + assert "deleted_token.json" not in run_args_str + + assert any( + "source not found" in rec.getMessage() + for rec in caplog.records + ) + + +def test_credential_mount_works_when_source_is_valid_file(monkeypatch, tmp_path): + """Credential mount should proceed normally when source is a valid file.""" + valid_file = tmp_path / "token.json" + valid_file.write_text('{"token": "REDACTED"}') + + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + calls = _mock_subprocess_run(monkeypatch) + + fake_mounts = [ + {"host_path": str(valid_file), "container_path": "/root/.hermes/token.json"}, + ] + monkeypatch.setattr( + "tools.credential_files.get_credential_file_mounts", + lambda: fake_mounts, + ) + monkeypatch.setattr( + "tools.credential_files.get_skills_directory_mount", + lambda: [], + ) + monkeypatch.setattr( + "tools.credential_files.get_cache_directory_mounts", + lambda: [], + ) + + _make_dummy_env() + + run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"] + assert run_calls, "docker run should have been called" + run_args_str = " ".join(run_calls[0][0]) + assert "token.json" in run_args_str diff --git a/tools/environments/docker.py b/tools/environments/docker.py index 7a2728ebfe2..8ec098083f1 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -12,6 +12,7 @@ import shutil import subprocess import sys import uuid +from pathlib import Path from typing import Optional from tools.environments.base import BaseEnvironment, _popen_bash @@ -577,6 +578,22 @@ class DockerEnvironment(BaseEnvironment): ) for mount_entry in get_credential_file_mounts(): + src = Path(mount_entry["host_path"]) + if src.is_dir(): + # Docker-in-Docker: Docker auto-created the source path as + # a directory when it didn't exist on the host. Mounting a + # directory over a file destination causes exit 125. + logger.warning( + "Docker: skipping credential mount — source is a directory " + "(likely Docker-in-Docker auto-creation): %s", + src, + ) + continue + if not src.is_file(): + logger.warning( + "Docker: skipping credential mount — source not found: %s", src, + ) + continue volume_args.extend([ "-v", f"{mount_entry['host_path']}:{mount_entry['container_path']}:ro", @@ -590,6 +607,13 @@ class DockerEnvironment(BaseEnvironment): # Mount skill directories (local + external) so skill # scripts/templates are available inside the container. for skills_mount in get_skills_directory_mount(): + src = Path(skills_mount["host_path"]) + if not src.is_dir(): + logger.warning( + "Docker: skipping skills mount — source is not a directory: %s", + src, + ) + continue volume_args.extend([ "-v", f"{skills_mount['host_path']}:{skills_mount['container_path']}:ro", @@ -605,6 +629,13 @@ class DockerEnvironment(BaseEnvironment): # cached media from inside the container. Read-only — the # container reads these but the host gateway manages writes. for cache_mount in get_cache_directory_mounts(): + src = Path(cache_mount["host_path"]) + if not src.is_dir(): + logger.warning( + "Docker: skipping cache mount — source is not a directory: %s", + src, + ) + continue volume_args.extend([ "-v", f"{cache_mount['host_path']}:{cache_mount['container_path']}:ro",