diff --git a/openhands/app_server/sandbox/docker_sandbox_service.py b/openhands/app_server/sandbox/docker_sandbox_service.py index f9f7dfc80b..1114fde577 100644 --- a/openhands/app_server/sandbox/docker_sandbox_service.py +++ b/openhands/app_server/sandbox/docker_sandbox_service.py @@ -84,6 +84,7 @@ class DockerSandboxService(SandboxService): extra_hosts: dict[str, str] = field(default_factory=dict) docker_client: docker.DockerClient = field(default_factory=get_docker_client) startup_grace_seconds: int = STARTUP_GRACE_SECONDS + use_host_network: bool = False def _find_unused_port(self) -> int: """Find an unused port on the host machine.""" @@ -140,36 +141,61 @@ class DockerSandboxService(SandboxService): env = self._get_container_env_vars(container) session_api_key = env.get(SESSION_API_KEY_VARIABLE) - # Get the first exposed port mapping + # Get the exposed port mappings exposed_urls = [] - port_bindings = container.attrs.get('NetworkSettings', {}).get('Ports', {}) - if port_bindings: - for container_port, host_bindings in port_bindings.items(): - if host_bindings: - host_port = host_bindings[0]['HostPort'] - exposed_port = next( - ( - exposed_port - for exposed_port in self.exposed_ports - if container_port - == f'{exposed_port.container_port}/tcp' - ), - None, + + # Check if container is using host network mode + network_mode = container.attrs.get('HostConfig', {}).get('NetworkMode', '') + is_host_network = network_mode == 'host' + + if is_host_network: + # Host network mode: container ports are directly accessible on host + for exposed_port in self.exposed_ports: + host_port = exposed_port.container_port + url = self.container_url_pattern.format(port=host_port) + + # VSCode URLs require the api_key and working dir + if exposed_port.name == VSCODE: + url += f'/?tkn={session_api_key}&folder={container.attrs["Config"]["WorkingDir"]}' + + exposed_urls.append( + ExposedUrl( + name=exposed_port.name, + url=url, + port=host_port, ) - if exposed_port: - url = self.container_url_pattern.format(port=host_port) - - # VSCode URLs require the api_key and working dir - if exposed_port.name == VSCODE: - url += f'/?tkn={session_api_key}&folder={container.attrs["Config"]["WorkingDir"]}' - - exposed_urls.append( - ExposedUrl( - name=exposed_port.name, - url=url, - port=host_port, - ) + ) + else: + # Bridge network mode: use port bindings + port_bindings = container.attrs.get('NetworkSettings', {}).get( + 'Ports', {} + ) + if port_bindings: + for container_port, host_bindings in port_bindings.items(): + if host_bindings: + host_port = int(host_bindings[0]['HostPort']) + matching_port = next( + ( + ep + for ep in self.exposed_ports + if container_port == f'{ep.container_port}/tcp' + ), + None, ) + if matching_port: + url = self.container_url_pattern.format(port=host_port) + + # VSCode URLs require the api_key and working dir + if matching_port.name == VSCODE: + url += f'/?tkn={session_api_key}&folder={container.attrs["Config"]["WorkingDir"]}' + + exposed_urls.append( + ExposedUrl( + name=matching_port.name, + url=url, + port=host_port, + ) + ) return SandboxInfo( id=container.name, @@ -300,6 +326,15 @@ class DockerSandboxService(SandboxService): self, sandbox_spec_id: str | None = None, sandbox_id: str | None = None ) -> SandboxInfo: """Start a new sandbox.""" + # Warn about port collision risk when using host network mode with multiple sandboxes + if self.use_host_network and self.max_num_sandboxes > 1: + _logger.warning( + 'Host network mode is enabled with max_num_sandboxes > 1. ' + 'Multiple sandboxes will attempt to bind to the same ports, ' + 'which may cause port collision errors. Consider setting ' + 'max_num_sandboxes=1 when using host network mode.' + ) + # Enforce sandbox limits by cleaning up old sandboxes await self.pause_old_sandboxes(self.max_num_sandboxes - 1) @@ -335,12 +370,20 @@ class DockerSandboxService(SandboxService): env_vars[ALLOW_CORS_ORIGINS_VARIABLE] = self.web_url # Prepare port mappings and add port environment variables - port_mappings = {} - for exposed_port in self.exposed_ports: - host_port = self._find_unused_port() - port_mappings[exposed_port.container_port] = host_port - # Add port as environment variable - env_vars[exposed_port.name] = str(host_port) + # When using host network, container ports are directly accessible on the host + # so we use the container ports directly instead of mapping to random host ports + port_mappings: dict[int, int] | None = None + if self.use_host_network: + # Host network mode: container ports are directly accessible + for exposed_port in self.exposed_ports: + env_vars[exposed_port.name] = str(exposed_port.container_port) + else: + # Bridge network mode: map container ports to random host ports + port_mappings = {} + for exposed_port in self.exposed_ports: + host_port = self._find_unused_port() + port_mappings[exposed_port.container_port] = host_port + env_vars[exposed_port.name] = str(host_port) # Prepare labels labels = { @@ -356,6 +399,12 @@ class DockerSandboxService(SandboxService): for mount in self.mounts } + # Determine network mode + network_mode = 'host' if self.use_host_network else None + + if self.use_host_network: + _logger.info(f'Starting sandbox {container_name} with host network mode') + try: # Create and start the container container = self.docker_client.containers.run( # type: ignore[call-overload] @@ -374,7 +423,12 @@ class DockerSandboxService(SandboxService): 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, + # Note: extra_hosts is not needed with host network mode + extra_hosts=self.extra_hosts + if self.extra_hosts and not self.use_host_network + else None, + # Network mode: 'host' for host networking, None for default bridge + network_mode=network_mode, ) sandbox_info = await self._container_to_sandbox_info(container) @@ -526,6 +580,21 @@ class DockerSandboxServiceInjector(SandboxServiceInjector): 'before it is considered an error' ), ) + use_host_network: bool = Field( + default=os.getenv('SANDBOX_USE_HOST_NETWORK', '').lower() + in ( + 'true', + '1', + 'yes', + ), + description=( + 'Whether to use host networking mode for sandbox containers. ' + 'When enabled, containers share the host network namespace, ' + 'making all container ports directly accessible on the host. ' + 'This is useful for reverse proxy setups where dynamic port mapping ' + 'is problematic. Configure via OH_SANDBOX_USE_HOST_NETWORK environment variable.' + ), + ) async def inject( self, state: InjectorState, request: Request | None = None @@ -558,4 +627,5 @@ class DockerSandboxServiceInjector(SandboxServiceInjector): web_url=web_url, extra_hosts=self.extra_hosts, startup_grace_seconds=self.startup_grace_seconds, + use_host_network=self.use_host_network, ) diff --git a/tests/unit/app_server/test_docker_sandbox_service.py b/tests/unit/app_server/test_docker_sandbox_service.py index d62c90a83e..b951167d02 100644 --- a/tests/unit/app_server/test_docker_sandbox_service.py +++ b/tests/unit/app_server/test_docker_sandbox_service.py @@ -1181,6 +1181,24 @@ class TestDockerSandboxServiceInjector: assert injector.host_port == 4000 assert injector.container_url_pattern == 'http://192.168.1.100:{port}' + def test_use_host_network_default_value(self): + """Test that use_host_network field defaults to False.""" + from openhands.app_server.sandbox.docker_sandbox_service import ( + DockerSandboxServiceInjector, + ) + + injector = DockerSandboxServiceInjector() + assert injector.use_host_network is False + + def test_use_host_network_can_be_enabled(self): + """Test that use_host_network field can be set to True.""" + from openhands.app_server.sandbox.docker_sandbox_service import ( + DockerSandboxServiceInjector, + ) + + injector = DockerSandboxServiceInjector(use_host_network=True) + assert injector.use_host_network is True + class TestDockerSandboxServiceInjectorFromEnv: """Test cases for DockerSandboxServiceInjector environment variable configuration.""" @@ -1246,3 +1264,280 @@ class TestDockerSandboxServiceInjectorFromEnv: assert config.sandbox is not None assert config.sandbox.host_port == 4000 assert config.sandbox.container_url_pattern == 'http://192.168.1.100:{port}' + + +class TestDockerSandboxServiceHostNetwork: + """Test cases for DockerSandboxService with host network mode.""" + + @pytest.fixture + def service_with_host_network( + self, mock_sandbox_spec_service, mock_httpx_client, mock_docker_client + ): + """Create DockerSandboxService instance with host network enabled.""" + return 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 + ), + ExposedPort( + name=VSCODE, description='VSCode server', container_port=8001 + ), + ], + health_check_path='/health', + httpx_client=mock_httpx_client, + max_num_sandboxes=3, + docker_client=mock_docker_client, + use_host_network=True, + ) + + @pytest.fixture + def mock_host_network_container(self): + """Create a mock container running with host network mode.""" + container = MagicMock() + container.name = 'oh-test-abc123' + container.status = 'running' + container.image.tags = ['spec456'] + container.attrs = { + 'Created': '2024-01-15T10:30:00.000000000Z', + 'Config': { + 'Env': [ + 'OH_SESSION_API_KEYS_0=session_key_123', + 'OTHER_VAR=other_value', + ], + 'WorkingDir': '/workspace', + }, + 'HostConfig': { + 'NetworkMode': 'host', + }, + 'NetworkSettings': { + 'Ports': None, + }, + } + return container + + @patch('openhands.app_server.sandbox.docker_sandbox_service.base62.encodebytes') + @patch('os.urandom') + async def test_start_sandbox_with_host_network( + self, mock_urandom, mock_encodebytes, service_with_host_network + ): + """Test starting sandbox with host network mode.""" + 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', + ], + 'WorkingDir': '/workspace', + }, + 'HostConfig': {'NetworkMode': 'host'}, + 'NetworkSettings': {'Ports': None}, + } + + service_with_host_network.docker_client.containers.run.return_value = ( + mock_container + ) + + with patch.object( + service_with_host_network, 'pause_old_sandboxes', return_value=[] + ): + result = await service_with_host_network.start_sandbox() + + assert result is not None + assert result.id == 'oh-test-test_container_id' + + call_args = service_with_host_network.docker_client.containers.run.call_args + assert call_args[1]['network_mode'] == 'host' + assert call_args[1]['ports'] is None + assert call_args[1]['extra_hosts'] is None + + @patch('openhands.app_server.sandbox.docker_sandbox_service.base62.encodebytes') + @patch('os.urandom') + async def test_start_sandbox_host_network_uses_container_ports( + self, mock_urandom, mock_encodebytes, service_with_host_network + ): + """Test that host network mode uses container ports directly in env vars.""" + 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'], + 'WorkingDir': '/workspace', + }, + 'HostConfig': {'NetworkMode': 'host'}, + 'NetworkSettings': {'Ports': None}, + } + + service_with_host_network.docker_client.containers.run.return_value = ( + mock_container + ) + + with patch.object( + service_with_host_network, 'pause_old_sandboxes', return_value=[] + ): + await service_with_host_network.start_sandbox() + + call_args = service_with_host_network.docker_client.containers.run.call_args + env_vars = call_args[1]['environment'] + assert env_vars[AGENT_SERVER] == '8000' + assert env_vars[VSCODE] == '8001' + + async def test_container_to_sandbox_info_host_network( + self, service_with_host_network, mock_host_network_container + ): + """Test conversion of host network container to SandboxInfo.""" + result = await service_with_host_network._container_to_sandbox_info( + mock_host_network_container + ) + + assert result is not None + assert result.id == 'oh-test-abc123' + assert result.status == SandboxStatus.RUNNING + assert result.session_api_key == 'session_key_123' + assert len(result.exposed_urls) == 2 + + agent_url = next(url for url in result.exposed_urls if url.name == AGENT_SERVER) + assert agent_url.url == 'http://localhost:8000' + assert agent_url.port == 8000 + + vscode_url = next(url for url in result.exposed_urls if url.name == VSCODE) + assert ( + vscode_url.url + == 'http://localhost:8001/?tkn=session_key_123&folder=/workspace' + ) + assert vscode_url.port == 8001 + + @patch('openhands.app_server.sandbox.docker_sandbox_service._logger') + @patch('openhands.app_server.sandbox.docker_sandbox_service.base62.encodebytes') + @patch('os.urandom') + async def test_start_sandbox_host_network_warns_multiple_sandboxes( + self, + mock_urandom, + mock_encodebytes, + mock_logger, + mock_sandbox_spec_service, + mock_httpx_client, + mock_docker_client, + ): + """Test that warning is logged when use_host_network=True and max_num_sandboxes > 1.""" + 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'], + 'WorkingDir': '/workspace', + }, + 'HostConfig': {'NetworkMode': 'host'}, + 'NetworkSettings': {'Ports': None}, + } + mock_docker_client.containers.run.return_value = mock_container + + # Create service with host network AND max_num_sandboxes > 1 + service = 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, # > 1 + docker_client=mock_docker_client, + use_host_network=True, + ) + + with patch.object(service, 'pause_old_sandboxes', return_value=[]): + await service.start_sandbox() + + # Verify warning was logged about port collision risk + mock_logger.warning.assert_called_once() + warning_message = mock_logger.warning.call_args[0][0] + assert ( + 'Host network mode is enabled with max_num_sandboxes > 1' in warning_message + ) + assert 'port collision' in warning_message.lower() + + @patch('openhands.app_server.sandbox.docker_sandbox_service._logger') + @patch('openhands.app_server.sandbox.docker_sandbox_service.base62.encodebytes') + @patch('os.urandom') + async def test_start_sandbox_host_network_no_warning_single_sandbox( + self, + mock_urandom, + mock_encodebytes, + mock_logger, + mock_sandbox_spec_service, + mock_httpx_client, + mock_docker_client, + ): + """Test that no warning is logged when use_host_network=True and max_num_sandboxes=1.""" + 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'], + 'WorkingDir': '/workspace', + }, + 'HostConfig': {'NetworkMode': 'host'}, + 'NetworkSettings': {'Ports': None}, + } + mock_docker_client.containers.run.return_value = mock_container + + # Create service with host network AND max_num_sandboxes = 1 + service = 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=1, # = 1, no warning expected + docker_client=mock_docker_client, + use_host_network=True, + ) + + with patch.object(service, 'pause_old_sandboxes', return_value=[]): + await service.start_sandbox() + + # Verify no warning was logged about port collision + mock_logger.warning.assert_not_called()