diff --git a/backend/app/api/gateway.py b/backend/app/api/gateway.py index a3e2d807..32fea432 100644 --- a/backend/app/api/gateway.py +++ b/backend/app/api/gateway.py @@ -37,8 +37,8 @@ def _query_to_resolve_input( board_id: str | None = Query(default=None), gateway_url: str | None = Query(default=None), gateway_token: str | None = Query(default=None), - gateway_disable_device_pairing: bool = Query(default=False), - gateway_allow_insecure_tls: bool = Query(default=False), + gateway_disable_device_pairing: bool | None = Query(default=None), + gateway_allow_insecure_tls: bool | None = Query(default=None), ) -> GatewayResolveQuery: return GatewaySessionService.to_resolve_query( board_id=board_id, diff --git a/backend/app/schemas/gateway_api.py b/backend/app/schemas/gateway_api.py index 188a25b2..f2dd097f 100644 --- a/backend/app/schemas/gateway_api.py +++ b/backend/app/schemas/gateway_api.py @@ -21,8 +21,8 @@ class GatewayResolveQuery(SQLModel): board_id: str | None = None gateway_url: str | None = None gateway_token: str | None = None - gateway_disable_device_pairing: bool = False - gateway_allow_insecure_tls: bool = False + gateway_disable_device_pairing: bool | None = None + gateway_allow_insecure_tls: bool | None = None class GatewaysStatusResponse(SQLModel): diff --git a/backend/app/services/openclaw/gateway_rpc.py b/backend/app/services/openclaw/gateway_rpc.py index 563bac49..85a67124 100644 --- a/backend/app/services/openclaw/gateway_rpc.py +++ b/backend/app/services/openclaw/gateway_rpc.py @@ -406,7 +406,9 @@ async def _openclaw_call_once( connect_kwargs: dict[str, Any] = {"ping_interval": None} if origin is not None: connect_kwargs["origin"] = origin - async with websockets.connect(gateway_url, ssl=ssl_context, **connect_kwargs) as ws: + if ssl_context is not None: + connect_kwargs["ssl"] = ssl_context + async with websockets.connect(gateway_url, **connect_kwargs) as ws: first_message = await _recv_first_message_or_none(ws) await _ensure_connected(ws, first_message, config) return await _send_request(ws, method, params) @@ -422,7 +424,9 @@ async def _openclaw_connect_metadata_once( connect_kwargs: dict[str, Any] = {"ping_interval": None} if origin is not None: connect_kwargs["origin"] = origin - async with websockets.connect(gateway_url, ssl=ssl_context, **connect_kwargs) as ws: + if ssl_context is not None: + connect_kwargs["ssl"] = ssl_context + async with websockets.connect(gateway_url, **connect_kwargs) as ws: first_message = await _recv_first_message_or_none(ws) return await _ensure_connected(ws, first_message, config) diff --git a/backend/app/services/openclaw/session_service.py b/backend/app/services/openclaw/session_service.py index 9d7059d5..948fec7b 100644 --- a/backend/app/services/openclaw/session_service.py +++ b/backend/app/services/openclaw/session_service.py @@ -11,6 +11,7 @@ from fastapi import HTTPException, status from app.core.logging import TRACE_LEVEL from app.models.boards import Board +from app.models.gateways import Gateway from app.schemas.gateway_api import ( GatewayResolveQuery, GatewaySessionHistoryResponse, @@ -65,8 +66,8 @@ class GatewaySessionService(OpenClawDBService): board_id: str | None, gateway_url: str | None, gateway_token: str | None, - gateway_disable_device_pairing: bool = False, - gateway_allow_insecure_tls: bool = False, + gateway_disable_device_pairing: bool | None = None, + gateway_allow_insecure_tls: bool | None = None, ) -> GatewayResolveQuery: return GatewayResolveQuery( board_id=board_id, @@ -95,6 +96,7 @@ class GatewaySessionService(OpenClawDBService): params: GatewayResolveQuery, *, user: User | None = None, + organization_id: UUID | None = None, ) -> tuple[Board | None, GatewayClientConfig, str | None]: self.logger.log( TRACE_LEVEL, @@ -109,13 +111,34 @@ class GatewaySessionService(OpenClawDBService): status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="board_id or gateway_url is required", ) + token = (params.gateway_token or "").strip() or None + gateway: Gateway | None = None + can_query_saved_gateway = organization_id is not None and hasattr(self.session, "exec") + if can_query_saved_gateway and ( + params.gateway_allow_insecure_tls is None + or params.gateway_disable_device_pairing is None + ): + gateway_query = Gateway.objects.filter_by(url=raw_url) + if organization_id is not None: + gateway_query = gateway_query.filter_by(organization_id=organization_id) + gateway = await gateway_query.first(self.session) + allow_insecure_tls = ( + params.gateway_allow_insecure_tls + if params.gateway_allow_insecure_tls is not None + else (gateway.allow_insecure_tls if gateway is not None else False) + ) + disable_device_pairing = ( + params.gateway_disable_device_pairing + if params.gateway_disable_device_pairing is not None + else (gateway.disable_device_pairing if gateway is not None else False) + ) return ( None, GatewayClientConfig( url=raw_url, - token=(params.gateway_token or "").strip() or None, - allow_insecure_tls=params.gateway_allow_insecure_tls, - disable_device_pairing=params.gateway_disable_device_pairing, + token=token, + allow_insecure_tls=allow_insecure_tls, + disable_device_pairing=disable_device_pairing, ), None, ) @@ -194,7 +217,11 @@ class GatewaySessionService(OpenClawDBService): organization_id: UUID, user: User | None, ) -> GatewaysStatusResponse: - board, config, main_session = await self.resolve_gateway(params, user=user) + board, config, main_session = await self.resolve_gateway( + params, + user=user, + organization_id=organization_id, + ) self._require_same_org(board, organization_id) try: compatibility = await check_gateway_version_compatibility(config) diff --git a/backend/tests/test_gateway_resolver.py b/backend/tests/test_gateway_resolver.py index 2c5da95a..84d988bd 100644 --- a/backend/tests/test_gateway_resolver.py +++ b/backend/tests/test_gateway_resolver.py @@ -5,6 +5,7 @@ from uuid import uuid4 import pytest +import app.services.openclaw.session_service as session_service from app.models.gateways import Gateway from app.schemas.gateway_api import GatewayResolveQuery from app.services.openclaw.gateway_resolver import ( @@ -100,3 +101,82 @@ async def test_resolve_gateway_keeps_gateway_allow_insecure_tls_for_direct_url() ) assert config.allow_insecure_tls is True + + +class _FakeGatewayQuery: + def __init__(self, gateway: Gateway | None) -> None: + self._gateway = gateway + self.filters: list[dict[str, object]] = [] + + def filter_by(self, **kwargs: object) -> _FakeGatewayQuery: + self.filters.append(kwargs) + return self + + async def first(self, _session: object) -> Gateway | None: + return self._gateway + + +class _FakeAsyncSession: + async def exec( + self, *_args: object, **_kwargs: object + ) -> None: # pragma: no cover - guard only + return None + + +@pytest.mark.asyncio +async def test_resolve_gateway_uses_saved_gateway_settings_when_direct_flags_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = _gateway( + disable_device_pairing=True, + allow_insecure_tls=True, + url="wss://gateway.example:18789/ws", + token=" db-token ", + ) + fake_query = _FakeGatewayQuery(gateway) + monkeypatch.setattr(session_service.Gateway, "objects", fake_query) + + service = GatewaySessionService(session=_FakeAsyncSession()) # type: ignore[arg-type] + _, config, _ = await service.resolve_gateway( + GatewayResolveQuery(gateway_url=gateway.url), + user=None, + organization_id=gateway.organization_id, + ) + + assert config.token is None + assert config.allow_insecure_tls is True + assert config.disable_device_pairing is True + assert fake_query.filters == [ + {"url": gateway.url}, + {"organization_id": gateway.organization_id}, + ] + + +@pytest.mark.asyncio +async def test_resolve_gateway_prefers_explicit_direct_flags_over_saved_settings( + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = _gateway( + disable_device_pairing=True, + allow_insecure_tls=True, + url="wss://gateway.example:18789/ws", + token="db-token", + ) + fake_query = _FakeGatewayQuery(gateway) + monkeypatch.setattr(session_service.Gateway, "objects", fake_query) + + service = GatewaySessionService(session=object()) # type: ignore[arg-type] + _, config, _ = await service.resolve_gateway( + GatewayResolveQuery( + gateway_url=gateway.url, + gateway_token="explicit-token", + gateway_allow_insecure_tls=False, + gateway_disable_device_pairing=False, + ), + user=None, + organization_id=gateway.organization_id, + ) + + assert config.token == "explicit-token" + assert config.allow_insecure_tls is False + assert config.disable_device_pairing is False diff --git a/backend/tests/test_gateway_rpc_connect_scopes.py b/backend/tests/test_gateway_rpc_connect_scopes.py index 0a2b2dad..00a1db86 100644 --- a/backend/tests/test_gateway_rpc_connect_scopes.py +++ b/backend/tests/test_gateway_rpc_connect_scopes.py @@ -192,3 +192,93 @@ async def test_openclaw_call_surfaces_scope_error_without_device_fallback( "status", config=GatewayConfig(url="ws://gateway.example/ws", token="secret-token"), ) + + +class _FakeConnectContext: + async def __aenter__(self) -> object: + return object() + + async def __aexit__(self, _exc_type: object, _exc: object, _tb: object) -> bool: + return False + + +@pytest.mark.asyncio +async def test_openclaw_call_once_does_not_pass_ssl_none_for_wss( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + def _fake_connect(url: str, **kwargs: object) -> _FakeConnectContext: + captured["url"] = url + captured["kwargs"] = kwargs + return _FakeConnectContext() + + async def _fake_recv_first(_ws: object) -> None: + return None + + async def _fake_ensure_connected( + _ws: object, _first_message: object, _config: GatewayConfig + ) -> None: + return None + + async def _fake_send_request(_ws: object, _method: str, _params: object) -> object: + return {"ok": True} + + monkeypatch.setattr(gateway_rpc.websockets, "connect", _fake_connect) + monkeypatch.setattr(gateway_rpc, "_recv_first_message_or_none", _fake_recv_first) + monkeypatch.setattr(gateway_rpc, "_ensure_connected", _fake_ensure_connected) + monkeypatch.setattr(gateway_rpc, "_send_request", _fake_send_request) + + payload = await gateway_rpc._openclaw_call_once( + "status", + None, + config=GatewayConfig(url="wss://gateway.example/ws", allow_insecure_tls=False), + gateway_url="wss://gateway.example/ws", + ) + + assert payload == {"ok": True} + assert captured["url"] == "wss://gateway.example/ws" + kwargs = captured["kwargs"] + assert isinstance(kwargs, dict) + assert "ssl" not in kwargs + + +@pytest.mark.asyncio +async def test_openclaw_call_once_passes_ssl_context_for_insecure_wss( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + def _fake_connect(url: str, **kwargs: object) -> _FakeConnectContext: + captured["url"] = url + captured["kwargs"] = kwargs + return _FakeConnectContext() + + async def _fake_recv_first(_ws: object) -> None: + return None + + async def _fake_ensure_connected( + _ws: object, _first_message: object, _config: GatewayConfig + ) -> None: + return None + + async def _fake_send_request(_ws: object, _method: str, _params: object) -> object: + return {"ok": True} + + monkeypatch.setattr(gateway_rpc.websockets, "connect", _fake_connect) + monkeypatch.setattr(gateway_rpc, "_recv_first_message_or_none", _fake_recv_first) + monkeypatch.setattr(gateway_rpc, "_ensure_connected", _fake_ensure_connected) + monkeypatch.setattr(gateway_rpc, "_send_request", _fake_send_request) + + payload = await gateway_rpc._openclaw_call_once( + "status", + None, + config=GatewayConfig(url="wss://gateway.example/ws", allow_insecure_tls=True), + gateway_url="wss://gateway.example/ws", + ) + + assert payload == {"ok": True} + assert captured["url"] == "wss://gateway.example/ws" + kwargs = captured["kwargs"] + assert isinstance(kwargs, dict) + assert kwargs.get("ssl") is not None diff --git a/frontend/src/app/gateways/[gatewayId]/page.tsx b/frontend/src/app/gateways/[gatewayId]/page.tsx index 059d3963..577e0f3b 100644 --- a/frontend/src/app/gateways/[gatewayId]/page.tsx +++ b/frontend/src/app/gateways/[gatewayId]/page.tsx @@ -116,6 +116,7 @@ export default function GatewayDetailPage() { gateway_url: gateway.url, gateway_token: gateway.token ?? undefined, gateway_disable_device_pairing: gateway.disable_device_pairing, + gateway_allow_insecure_tls: gateway.allow_insecure_tls, } : {};