diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index 77b7ad92..13469634 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -768,10 +768,27 @@ class BoxService: 'recent_error_count': len(self._recent_errors), 'connector_error': str(exc), } - return { + # Backend state can be unavailable even when the connector is healthy + # (operator selected nsjail but the binary is missing, Docker daemon + # went down after the runtime started, E2B credentials wrong, ...). + # Report the combined state in the top-level ``available`` so the + # frontend banner / ``useBoxStatus`` hook / native-tool gate all + # agree on "actually usable" rather than "connector alive". The + # detailed ``backend`` object stays in the payload so the dialog + # can still show which backend was tried. + backend_info = runtime_status.get('backend') if isinstance(runtime_status, dict) else None + backend_ok = bool(backend_info and backend_info.get('available', False)) + payload = { **runtime_status, - 'available': True, + 'available': backend_ok, 'enabled': self._enabled, 'profile': self.profile.name, 'recent_error_count': len(self._recent_errors), } + if not backend_ok and 'connector_error' not in payload: + backend_name = backend_info.get('name') if backend_info else None + if backend_name: + payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable' + else: + payload['connector_error'] = 'No supported sandbox backend (Docker / nsjail / E2B) is available' + return payload diff --git a/tests/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py index e154fe33..44f42ec1 100644 --- a/tests/unit_tests/box/test_box_service.py +++ b/tests/unit_tests/box/test_box_service.py @@ -1274,6 +1274,52 @@ class TestBoxDisabledByConfig: assert status['enabled'] is True assert 'docker daemon' in status['connector_error'] + @pytest.mark.asyncio + async def test_get_status_downgrades_available_when_backend_dead(self): + """The connector can be healthy while the runtime reports no usable + backend (operator selected nsjail but binary missing, Docker daemon + crashed after handshake, ...). The top-level ``available`` must + reflect the combined state so the dashboard / useBoxStatus hook / + skill_service gate stay consistent with the native-tool gate.""" + logger = Mock() + client = Mock(spec=BoxRuntimeClient) + client.initialize = AsyncMock() + client.get_status = AsyncMock( + return_value={ + 'backend': {'name': 'nsjail', 'available': False}, + 'active_sessions': 0, + } + ) + service = BoxService(make_app(logger, enabled=True), client=client) + await service.initialize() + + status = await service.get_status() + assert status['available'] is False + assert status['enabled'] is True + # The detailed backend object is preserved for the dialog + assert status['backend'] == {'name': 'nsjail', 'available': False} + assert 'nsjail' in status['connector_error'] + + @pytest.mark.asyncio + async def test_get_status_keeps_available_true_when_backend_ok(self): + logger = Mock() + client = Mock(spec=BoxRuntimeClient) + client.initialize = AsyncMock() + client.get_status = AsyncMock( + return_value={ + 'backend': {'name': 'docker', 'available': True}, + 'active_sessions': 2, + } + ) + service = BoxService(make_app(logger, enabled=True), client=client) + await service.initialize() + + status = await service.get_status() + assert status['available'] is True + assert status['backend'] == {'name': 'docker', 'available': True} + # No spurious connector_error overlay when everything is healthy + assert 'connector_error' not in status or not status['connector_error'] + @pytest.mark.asyncio async def test_disconnect_callback_is_no_op_when_disabled(self): logger = Mock()