Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
2 changes: 2 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions tests/hermes_cli/test_set_config_value.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions tests/tools/test_docker_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
Expand Down Expand Up @@ -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"
Expand Down
63 changes: 63 additions & 0 deletions tests/tools/test_parse_env_var.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions tools/environments/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
):
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions tools/terminal_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 {}
Expand All @@ -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":
Expand Down Expand Up @@ -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),
}

Expand Down