From 27e3d7fe5d638f237a8aec90b100897f9cd3a0d7 Mon Sep 17 00:00:00 2001 From: Michel Belleau Date: Fri, 3 Apr 2026 21:50:24 -0400 Subject: [PATCH 1/2] feat(terminal): wire docker_network through docker backend --- cli.py | 2 + hermes_cli/config.py | 2 + tests/hermes_cli/test_set_config_value.py | 7 +++ tests/tools/test_docker_environment.py | 31 +++++++++++ tests/tools/test_parse_env_var.py | 63 +++++++++++++++++++++++ tools/environments/docker.py | 4 ++ tools/terminal_tool.py | 4 ++ 7 files changed, 113 insertions(+) diff --git a/cli.py b/cli.py index 8e1054e8cba61..06bfbcc5b84ca 100644 --- a/cli.py +++ b/cli.py @@ -155,6 +155,7 @@ def load_cli_config() -> Dict[str, Any]: "lifetime_seconds": 300, "docker_image": "nikolaik/python-nodejs:python3.11-nodejs20", "docker_forward_env": [], + "docker_network": "", "singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20", "modal_image": "nikolaik/python-nodejs:python3.11-nodejs20", "daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20", @@ -348,6 +349,7 @@ def load_cli_config() -> Dict[str, Any]: "lifetime_seconds": "TERMINAL_LIFETIME_SECONDS", "docker_image": "TERMINAL_DOCKER_IMAGE", "docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV", + "docker_network": "TERMINAL_DOCKER_NETWORK", "singularity_image": "TERMINAL_SINGULARITY_IMAGE", "modal_image": "TERMINAL_MODAL_IMAGE", "daytona_image": "TERMINAL_DAYTONA_IMAGE", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index da266eedac289..4e08dfb1d1f69 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -222,6 +222,7 @@ def ensure_hermes_home(): "env_passthrough": [], "docker_image": "nikolaik/python-nodejs:python3.11-nodejs20", "docker_forward_env": [], + "docker_network": "", "singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20", "modal_image": "nikolaik/python-nodejs:python3.11-nodejs20", "daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20", @@ -2103,6 +2104,7 @@ def set_config_value(key: str, value: str): "terminal.backend": "TERMINAL_ENV", "terminal.modal_mode": "TERMINAL_MODAL_MODE", "terminal.docker_image": "TERMINAL_DOCKER_IMAGE", + "terminal.docker_network": "TERMINAL_DOCKER_NETWORK", "terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE", "terminal.modal_image": "TERMINAL_MODAL_IMAGE", "terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE", diff --git a/tests/hermes_cli/test_set_config_value.py b/tests/hermes_cli/test_set_config_value.py index fbd71dbb53ba9..2d699866947ba 100644 --- a/tests/hermes_cli/test_set_config_value.py +++ b/tests/hermes_cli/test_set_config_value.py @@ -127,6 +127,13 @@ def test_terminal_docker_cwd_mount_flag_goes_to_config_and_env(self, _isolated_h or "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE=True" in env_content ) + def test_terminal_docker_network_goes_to_config_and_env(self, _isolated_hermes_home): + set_config_value("terminal.docker_network", "hermes-net") + config = _read_config(_isolated_hermes_home) + env_content = _read_env(_isolated_hermes_home) + assert "docker_network: hermes-net" in config + assert "TERMINAL_DOCKER_NETWORK=hermes-net" in env_content + # --------------------------------------------------------------------------- # Empty / falsy values — regression tests for #4277 diff --git a/tests/tools/test_docker_environment.py b/tests/tools/test_docker_environment.py index 002776ca34413..9f19df8c25eac 100644 --- a/tests/tools/test_docker_environment.py +++ b/tests/tools/test_docker_environment.py @@ -42,6 +42,7 @@ def _make_dummy_env(**kwargs): task_id=kwargs.get("task_id", "test-task"), volumes=kwargs.get("volumes", []), network=kwargs.get("network", True), + docker_network=kwargs.get("docker_network", ""), host_cwd=kwargs.get("host_cwd"), auto_mount_cwd=kwargs.get("auto_mount_cwd", False), ) @@ -133,6 +134,36 @@ def test_auto_mount_host_cwd_adds_volume(monkeypatch, tmp_path): assert f"{project_dir}:/workspace" in run_args_str +def test_docker_network_adds_named_network_arg(monkeypatch): + """A configured Docker network should be passed through to docker run.""" + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + calls = _mock_subprocess_run(monkeypatch) + + _make_dummy_env(docker_network="hermes-net") + + 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 = run_calls[0][0] + assert "--network" in run_args + assert "hermes-net" in run_args + assert "--network=none" not in run_args + + +def test_network_false_overrides_named_docker_network(monkeypatch): + """Explicit network disablement should win over a named Docker network.""" + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + calls = _mock_subprocess_run(monkeypatch) + + _make_dummy_env(network=False, docker_network="hermes-net") + + 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 = run_calls[0][0] + assert "--network=none" in run_args + assert "--network" not in run_args + assert "hermes-net" not in run_args + + def test_auto_mount_disabled_by_default(monkeypatch, tmp_path): """Host cwd should not be mounted unless the caller explicitly opts in.""" project_dir = tmp_path / "my-project" diff --git a/tests/tools/test_parse_env_var.py b/tests/tools/test_parse_env_var.py index cffee7c9af0fd..ada29414a535a 100644 --- a/tests/tools/test_parse_env_var.py +++ b/tests/tools/test_parse_env_var.py @@ -1,6 +1,7 @@ """Tests for _parse_env_var and _get_env_config env-var validation.""" import json +from types import SimpleNamespace from unittest.mock import patch import pytest @@ -38,6 +39,14 @@ def test_get_env_config_parses_docker_forward_env_json(self): config = _tt_mod._get_env_config() assert config["docker_forward_env"] == ["GITHUB_TOKEN", "NPM_TOKEN"] + def test_get_env_config_reads_docker_network(self): + with patch.dict("os.environ", { + "TERMINAL_ENV": "docker", + "TERMINAL_DOCKER_NETWORK": "hermes-net", + }, clear=False): + config = _tt_mod._get_env_config() + assert config["docker_network"] == "hermes-net" + def test_create_environment_passes_docker_forward_env(self): fake_env = object() with patch.object(_tt_mod, "_DockerEnvironment", return_value=fake_env) as mock_docker: @@ -52,6 +61,60 @@ def test_create_environment_passes_docker_forward_env(self): assert result is fake_env assert mock_docker.call_args.kwargs["forward_env"] == ["GITHUB_TOKEN"] + def test_create_environment_passes_docker_network(self): + fake_env = object() + with patch.object(_tt_mod, "_DockerEnvironment", return_value=fake_env) as mock_docker: + result = _tt_mod._create_environment( + "docker", + image="python:3.11", + cwd="/root", + timeout=180, + container_config={"docker_network": "hermes-net"}, + ) + + assert result is fake_env + assert mock_docker.call_args.kwargs["docker_network"] == "hermes-net" + + def test_terminal_tool_passes_docker_network_into_container_config(self, monkeypatch): + captured = {} + fake_env = SimpleNamespace(execute=lambda command, **kwargs: {"output": "ok", "returncode": 0}) + + monkeypatch.setattr(_tt_mod, "_active_environments", {}) + monkeypatch.setattr(_tt_mod, "_last_activity", {}) + monkeypatch.setattr(_tt_mod, "_start_cleanup_thread", lambda: None) + monkeypatch.setattr( + _tt_mod, + "_get_env_config", + lambda: { + "env_type": "docker", + "docker_image": "python:3.11", + "docker_network": "hermes-net", + "docker_volumes": [], + "docker_mount_cwd_to_workspace": False, + "cwd": "/root", + "host_cwd": None, + "timeout": 180, + "container_cpu": 1, + "container_memory": 5120, + "container_disk": 51200, + "container_persistent": True, + "modal_mode": "auto", + }, + ) + + def _fake_create_environment(**kwargs): + captured.update(kwargs) + return fake_env + + monkeypatch.setattr(_tt_mod, "_create_environment", _fake_create_environment) + + result = json.loads( + _tt_mod.terminal_tool("echo ok", task_id="docker-network-test", force=True) + ) + + assert result["exit_code"] == 0 + assert captured["container_config"]["docker_network"] == "hermes-net" + def test_falls_back_to_default(self): with patch.dict("os.environ", {}, clear=False): # Remove the var if it exists, rely on default diff --git a/tools/environments/docker.py b/tools/environments/docker.py index 19889ea35ffea..6a6509e52e9aa 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -211,6 +211,7 @@ def __init__( volumes: list = None, forward_env: list[str] | None = None, network: bool = True, + docker_network: str = "", host_cwd: str = None, auto_mount_cwd: bool = False, ): @@ -245,8 +246,11 @@ def __init__( "Docker storage driver does not support per-container disk limits " "(requires overlay2 on XFS with pquota). Container will run without disk quota." ) + docker_network = docker_network.strip() if not network: resource_args.append("--network=none") + elif docker_network: + resource_args.extend(["--network", docker_network]) # Persistent workspace via bind mounts from a configurable host directory # (TERMINAL_SANDBOX_DIR, default ~/.hermes/sandboxes/). Non-persistent diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index e11f9d43454b6..f1a96d7ede235 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -516,6 +516,7 @@ def _get_env_config() -> Dict[str, Any]: "modal_mode": coerce_modal_mode(os.getenv("TERMINAL_MODAL_MODE", "auto")), "docker_image": os.getenv("TERMINAL_DOCKER_IMAGE", default_image), "docker_forward_env": _parse_env_var("TERMINAL_DOCKER_FORWARD_ENV", "[]", json.loads, "valid JSON"), + "docker_network": os.getenv("TERMINAL_DOCKER_NETWORK", "").strip(), "singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"), "modal_image": os.getenv("TERMINAL_MODAL_IMAGE", default_image), "daytona_image": os.getenv("TERMINAL_DAYTONA_IMAGE", default_image), @@ -583,6 +584,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, persistent = cc.get("container_persistent", True) volumes = cc.get("docker_volumes", []) docker_forward_env = cc.get("docker_forward_env", []) + docker_network = cc.get("docker_network", "") if env_type == "local": lc = local_config or {} @@ -598,6 +600,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, host_cwd=host_cwd, auto_mount_cwd=cc.get("docker_mount_cwd_to_workspace", False), forward_env=docker_forward_env, + docker_network=docker_network, ) elif env_type == "singularity": @@ -1020,6 +1023,7 @@ def terminal_tool( "container_persistent": config.get("container_persistent", True), "modal_mode": config.get("modal_mode", "auto"), "docker_volumes": config.get("docker_volumes", []), + "docker_network": config.get("docker_network", ""), "docker_mount_cwd_to_workspace": config.get("docker_mount_cwd_to_workspace", False), } From 14e24a07e415fa12f6ae61bec2f88ebaf7e7b0b0 Mon Sep 17 00:00:00 2001 From: angelos Date: Sat, 4 Apr 2026 02:26:24 +0000 Subject: [PATCH 2/2] fix(gateway): wire docker_network through gateway run config bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli.py and hermes_cli/config.py now map terminal.docker_network to TERMINAL_DOCKER_NETWORK for CLI invocations (hermes chat, etc.), but gateway/run.py has its own _terminal_env_map that bridges config.yaml to env vars for the hermes gateway run path (i.e. the long-running Telegram/WhatsApp bot process). Without this change, docker_network set in config.yaml has no effect when Hermes is started as a gateway — sandbox containers silently fall back to the default network and cannot resolve sibling service hostnames like hermes-litellm. Co-Authored-By: Claude Sonnet 4.6 --- gateway/run.py | 1 + 1 file changed, 1 insertion(+) diff --git a/gateway/run.py b/gateway/run.py index 37fc3d8f13098..8f90044dc80f7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -126,6 +126,7 @@ def _ensure_ssl_certs() -> None: "container_disk": "TERMINAL_CONTAINER_DISK", "container_persistent": "TERMINAL_CONTAINER_PERSISTENT", "docker_volumes": "TERMINAL_DOCKER_VOLUMES", + "docker_network": "TERMINAL_DOCKER_NETWORK", "sandbox_dir": "TERMINAL_SANDBOX_DIR", "persistent_shell": "TERMINAL_PERSISTENT_SHELL", }