From 8c73c87583959170c91701ecb0d39b76ddc810c6 Mon Sep 17 00:00:00 2001 From: Tim O'Farrell Date: Fri, 2 Jan 2026 22:41:31 -0700 Subject: [PATCH] Add extra_hosts support to agent-server containers (#12236) Co-authored-by: openhands --- .../sandbox/docker_sandbox_service.py | 14 ++ .../app_server/test_docker_sandbox_service.py | 132 ++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/openhands/app_server/sandbox/docker_sandbox_service.py b/openhands/app_server/sandbox/docker_sandbox_service.py index e93b73b031..2969a2ebbd 100644 --- a/openhands/app_server/sandbox/docker_sandbox_service.py +++ b/openhands/app_server/sandbox/docker_sandbox_service.py @@ -78,6 +78,7 @@ class DockerSandboxService(SandboxService): health_check_path: str | None httpx_client: httpx.AsyncClient max_num_sandboxes: int + extra_hosts: dict[str, str] = field(default_factory=dict) docker_client: docker.DockerClient = field(default_factory=get_docker_client) def _find_unused_port(self) -> int: @@ -349,6 +350,9 @@ class DockerSandboxService(SandboxService): # Use Docker's tini init process to ensure proper signal handling and reaping of # zombie child processes. init=True, + # Allow agent-server containers to resolve host.docker.internal + # and other custom hostnames for LAN deployments + extra_hosts=self.extra_hosts if self.extra_hosts else None, ) sandbox_info = await self._container_to_sandbox_info(container) @@ -469,6 +473,15 @@ class DockerSandboxServiceInjector(SandboxServiceInjector): 'determine whether the server is running' ), ) + extra_hosts: dict[str, str] = Field( + default_factory=lambda: {'host.docker.internal': 'host-gateway'}, + description=( + 'Extra hostname mappings to add to agent-server containers. ' + 'This allows containers to resolve hostnames like host.docker.internal ' + 'for LAN deployments and MCP connections. ' + 'Format: {"hostname": "ip_or_gateway"}' + ), + ) async def inject( self, state: InjectorState, request: Request | None = None @@ -493,4 +506,5 @@ class DockerSandboxServiceInjector(SandboxServiceInjector): health_check_path=self.health_check_path, httpx_client=httpx_client, max_num_sandboxes=self.max_num_sandboxes, + extra_hosts=self.extra_hosts, ) diff --git a/tests/unit/app_server/test_docker_sandbox_service.py b/tests/unit/app_server/test_docker_sandbox_service.py index d428b3b664..b6b6774de0 100644 --- a/tests/unit/app_server/test_docker_sandbox_service.py +++ b/tests/unit/app_server/test_docker_sandbox_service.py @@ -444,6 +444,138 @@ class TestDockerSandboxService: ): await service.start_sandbox() + @patch('openhands.app_server.sandbox.docker_sandbox_service.base62.encodebytes') + @patch('os.urandom') + async def test_start_sandbox_with_extra_hosts( + self, + mock_urandom, + mock_encodebytes, + mock_sandbox_spec_service, + mock_httpx_client, + mock_docker_client, + ): + """Test that extra_hosts are passed to container creation.""" + # Setup + mock_urandom.side_effect = [b'container_id', b'session_key'] + mock_encodebytes.side_effect = ['test_container_id', 'test_session_key'] + + mock_container = MagicMock() + mock_container.name = 'oh-test-test_container_id' + mock_container.status = 'running' + mock_container.image.tags = ['test-image:latest'] + mock_container.attrs = { + 'Created': '2024-01-15T10:30:00.000000000Z', + 'Config': { + 'Env': ['OH_SESSION_API_KEYS_0=test_session_key', 'TEST_VAR=test_value'] + }, + 'NetworkSettings': {'Ports': {}}, + } + mock_docker_client.containers.run.return_value = mock_container + + # Create service with extra_hosts + service_with_extra_hosts = DockerSandboxService( + sandbox_spec_service=mock_sandbox_spec_service, + container_name_prefix='oh-test-', + host_port=3000, + container_url_pattern='http://localhost:{port}', + mounts=[], + exposed_ports=[ + ExposedPort( + name=AGENT_SERVER, description='Agent server', container_port=8000 + ), + ], + health_check_path='/health', + httpx_client=mock_httpx_client, + max_num_sandboxes=3, + extra_hosts={ + 'host.docker.internal': 'host-gateway', + 'custom.host': '192.168.1.100', + }, + docker_client=mock_docker_client, + ) + + with ( + patch.object( + service_with_extra_hosts, '_find_unused_port', return_value=12345 + ), + patch.object( + service_with_extra_hosts, 'pause_old_sandboxes', return_value=[] + ), + ): + # Execute + await service_with_extra_hosts.start_sandbox() + + # Verify extra_hosts was passed to container creation + mock_docker_client.containers.run.assert_called_once() + call_args = mock_docker_client.containers.run.call_args + assert call_args[1]['extra_hosts'] == { + 'host.docker.internal': 'host-gateway', + 'custom.host': '192.168.1.100', + } + + @patch('openhands.app_server.sandbox.docker_sandbox_service.base62.encodebytes') + @patch('os.urandom') + async def test_start_sandbox_without_extra_hosts( + self, + mock_urandom, + mock_encodebytes, + mock_sandbox_spec_service, + mock_httpx_client, + mock_docker_client, + ): + """Test that extra_hosts is None when not configured.""" + # Setup + mock_urandom.side_effect = [b'container_id', b'session_key'] + mock_encodebytes.side_effect = ['test_container_id', 'test_session_key'] + + mock_container = MagicMock() + mock_container.name = 'oh-test-test_container_id' + mock_container.status = 'running' + mock_container.image.tags = ['test-image:latest'] + mock_container.attrs = { + 'Created': '2024-01-15T10:30:00.000000000Z', + 'Config': { + 'Env': ['OH_SESSION_API_KEYS_0=test_session_key', 'TEST_VAR=test_value'] + }, + 'NetworkSettings': {'Ports': {}}, + } + mock_docker_client.containers.run.return_value = mock_container + + # Create service without extra_hosts (empty dict) + service_without_extra_hosts = DockerSandboxService( + sandbox_spec_service=mock_sandbox_spec_service, + container_name_prefix='oh-test-', + host_port=3000, + container_url_pattern='http://localhost:{port}', + mounts=[], + exposed_ports=[ + ExposedPort( + name=AGENT_SERVER, description='Agent server', container_port=8000 + ), + ], + health_check_path='/health', + httpx_client=mock_httpx_client, + max_num_sandboxes=3, + extra_hosts={}, + docker_client=mock_docker_client, + ) + + with ( + patch.object( + service_without_extra_hosts, '_find_unused_port', return_value=12345 + ), + patch.object( + service_without_extra_hosts, 'pause_old_sandboxes', return_value=[] + ), + ): + # Execute + await service_without_extra_hosts.start_sandbox() + + # Verify extra_hosts is None when empty dict is provided + mock_docker_client.containers.run.assert_called_once() + call_args = mock_docker_client.containers.run.call_args + assert call_args[1]['extra_hosts'] is None + async def test_resume_sandbox_from_paused(self, service): """Test resuming a paused sandbox.""" # Setup