diff --git a/openhands/app_server/sandbox/docker_sandbox_service.py b/openhands/app_server/sandbox/docker_sandbox_service.py index a85d3c843d..f9f7dfc80b 100644 --- a/openhands/app_server/sandbox/docker_sandbox_service.py +++ b/openhands/app_server/sandbox/docker_sandbox_service.py @@ -27,6 +27,9 @@ from openhands.app_server.sandbox.sandbox_models import ( SandboxStatus, ) from openhands.app_server.sandbox.sandbox_service import ( + ALLOW_CORS_ORIGINS_VARIABLE, + SESSION_API_KEY_VARIABLE, + WEBHOOK_CALLBACK_VARIABLE, SandboxService, SandboxServiceInjector, ) @@ -37,8 +40,6 @@ from openhands.app_server.utils.docker_utils import ( ) _logger = logging.getLogger(__name__) -SESSION_API_KEY_VARIABLE = 'OH_SESSION_API_KEYS_0' -WEBHOOK_CALLBACK_VARIABLE = 'OH_WEBHOOKS_0_BASE_URL' STARTUP_GRACE_SECONDS = 15 @@ -79,6 +80,7 @@ class DockerSandboxService(SandboxService): health_check_path: str | None httpx_client: httpx.AsyncClient max_num_sandboxes: int + web_url: str | None = None 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 @@ -326,6 +328,12 @@ class DockerSandboxService(SandboxService): f'http://host.docker.internal:{self.host_port}/api/v1/webhooks' ) + # Set CORS origins for remote browser access when web_url is configured. + # This allows the agent-server container to accept requests from the + # frontend when running OpenHands on a remote machine. + if self.web_url: + 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: @@ -524,10 +532,15 @@ class DockerSandboxServiceInjector(SandboxServiceInjector): ) -> AsyncGenerator[SandboxService, None]: # Define inline to prevent circular lookup from openhands.app_server.config import ( + get_global_config, get_httpx_client, get_sandbox_spec_service, ) + # Get web_url from global config for CORS support + config = get_global_config() + web_url = config.web_url + async with ( get_httpx_client(state) as httpx_client, get_sandbox_spec_service(state) as sandbox_spec_service, @@ -542,6 +555,7 @@ class DockerSandboxServiceInjector(SandboxServiceInjector): health_check_path=self.health_check_path, httpx_client=httpx_client, max_num_sandboxes=self.max_num_sandboxes, + web_url=web_url, extra_hosts=self.extra_hosts, startup_grace_seconds=self.startup_grace_seconds, ) diff --git a/openhands/app_server/sandbox/remote_sandbox_service.py b/openhands/app_server/sandbox/remote_sandbox_service.py index d1d083f3fb..7249d2e38e 100644 --- a/openhands/app_server/sandbox/remote_sandbox_service.py +++ b/openhands/app_server/sandbox/remote_sandbox_service.py @@ -36,6 +36,8 @@ from openhands.app_server.sandbox.sandbox_models import ( SandboxStatus, ) from openhands.app_server.sandbox.sandbox_service import ( + ALLOW_CORS_ORIGINS_VARIABLE, + WEBHOOK_CALLBACK_VARIABLE, SandboxService, SandboxServiceInjector, ) @@ -48,8 +50,6 @@ from openhands.app_server.utils.sql_utils import Base, UtcDateTime from openhands.sdk.utils.paging import page_iterator _logger = logging.getLogger(__name__) -WEBHOOK_CALLBACK_VARIABLE = 'OH_WEBHOOKS_0_BASE_URL' -ALLOW_CORS_ORIGINS_VARIABLE = 'OH_ALLOW_CORS_ORIGINS_0' polling_task: asyncio.Task | None = None POD_STATUS_MAPPING = { 'ready': SandboxStatus.RUNNING, diff --git a/openhands/app_server/sandbox/sandbox_service.py b/openhands/app_server/sandbox/sandbox_service.py index efe8a9120c..8fbeb02626 100644 --- a/openhands/app_server/sandbox/sandbox_service.py +++ b/openhands/app_server/sandbox/sandbox_service.py @@ -18,6 +18,10 @@ from openhands.app_server.utils.docker_utils import ( from openhands.sdk.utils.models import DiscriminatedUnionMixin from openhands.sdk.utils.paging import page_iterator +SESSION_API_KEY_VARIABLE = 'OH_SESSION_API_KEYS_0' +WEBHOOK_CALLBACK_VARIABLE = 'OH_WEBHOOKS_0_BASE_URL' +ALLOW_CORS_ORIGINS_VARIABLE = 'OH_ALLOW_CORS_ORIGINS_0' + class SandboxService(ABC): """Service for accessing sandboxes in which conversations may be run.""" diff --git a/tests/unit/app_server/test_docker_sandbox_service.py b/tests/unit/app_server/test_docker_sandbox_service.py index 5a1e582a19..d62c90a83e 100644 --- a/tests/unit/app_server/test_docker_sandbox_service.py +++ b/tests/unit/app_server/test_docker_sandbox_service.py @@ -615,6 +615,127 @@ class TestDockerSandboxService: call_args = mock_docker_client.containers.run.call_args 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_with_cors_origins( + self, + mock_urandom, + mock_encodebytes, + mock_sandbox_spec_service, + mock_httpx_client, + mock_docker_client, + ): + """Test that CORS origins are set when web_url is 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 with web_url configured for CORS + service_with_cors = DockerSandboxService( + sandbox_spec_service=mock_sandbox_spec_service, + container_name_prefix='oh-test-', + host_port=3000, + container_url_pattern='http://192.168.1.100:{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, + web_url='http://192.168.1.100:3000', + docker_client=mock_docker_client, + ) + + with ( + patch.object(service_with_cors, '_find_unused_port', return_value=12345), + patch.object(service_with_cors, 'pause_old_sandboxes', return_value=[]), + ): + # Execute + await service_with_cors.start_sandbox() + + # Verify CORS origins environment variable was set + mock_docker_client.containers.run.assert_called_once() + call_args = mock_docker_client.containers.run.call_args + env_vars = call_args[1]['environment'] + assert 'OH_ALLOW_CORS_ORIGINS_0' in env_vars + assert env_vars['OH_ALLOW_CORS_ORIGINS_0'] == 'http://192.168.1.100:3000' + + @patch('openhands.app_server.sandbox.docker_sandbox_service.base62.encodebytes') + @patch('os.urandom') + async def test_start_sandbox_without_cors_origins( + self, + mock_urandom, + mock_encodebytes, + mock_sandbox_spec_service, + mock_httpx_client, + mock_docker_client, + ): + """Test that CORS origins are not set when web_url is None.""" + # 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 web_url (local development mode) + service_without_cors = 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, + web_url=None, # No web_url configured + docker_client=mock_docker_client, + ) + + with ( + patch.object(service_without_cors, '_find_unused_port', return_value=12345), + patch.object(service_without_cors, 'pause_old_sandboxes', return_value=[]), + ): + # Execute + await service_without_cors.start_sandbox() + + # Verify CORS origins environment variable was NOT set + mock_docker_client.containers.run.assert_called_once() + call_args = mock_docker_client.containers.run.call_args + env_vars = call_args[1]['environment'] + assert 'OH_ALLOW_CORS_ORIGINS_0' not in env_vars + async def test_resume_sandbox_from_paused(self, service): """Test resuming a paused sandbox.""" # Setup