diff --git a/backend/app/services/openclaw/gateway_compat.py b/backend/app/services/openclaw/gateway_compat.py index ace829c4..2ef7ff8d 100644 --- a/backend/app/services/openclaw/gateway_compat.py +++ b/backend/app/services/openclaw/gateway_compat.py @@ -7,7 +7,12 @@ from dataclasses import dataclass from typing import Any from app.core.config import settings -from app.services.openclaw.gateway_rpc import GatewayConfig, OpenClawGatewayError, openclaw_call +from app.services.openclaw.gateway_rpc import ( + GatewayConfig, + OpenClawGatewayError, + openclaw_call, + openclaw_connect_metadata, +) _VERSION_PATTERN = re.compile(r"(?i)v?(?P\d+(?:\.\d+)+)") _PRIMARY_VERSION_PATHS: tuple[tuple[str, ...], ...] = ( @@ -192,12 +197,27 @@ async def _fetch_schema_metadata(config: GatewayConfig) -> object | None: return None +async def _fetch_connect_metadata(config: GatewayConfig) -> object | None: + try: + return await openclaw_connect_metadata(config=config) + except OpenClawGatewayError: + return None + + async def check_gateway_runtime_compatibility( config: GatewayConfig, *, minimum_version: str | None = None, ) -> GatewayVersionCheckResult: """Fetch runtime metadata and evaluate gateway version compatibility.""" + connect_payload = await _fetch_connect_metadata(config) + current_version = extract_gateway_version(connect_payload) + if current_version is not None: + return evaluate_gateway_version( + current_version=current_version, + minimum_version=minimum_version, + ) + schema_payload = await _fetch_schema_metadata(config) current_version = extract_gateway_version(schema_payload) if current_version is not None: diff --git a/backend/app/services/openclaw/gateway_rpc.py b/backend/app/services/openclaw/gateway_rpc.py index a4fdefa1..0765d54d 100644 --- a/backend/app/services/openclaw/gateway_rpc.py +++ b/backend/app/services/openclaw/gateway_rpc.py @@ -253,7 +253,7 @@ async def _ensure_connected( ws: websockets.ClientConnection, first_message: str | bytes | None, config: GatewayConfig, -) -> None: +) -> object: if first_message: if isinstance(first_message, bytes): first_message = first_message.decode("utf-8") @@ -272,7 +272,7 @@ async def _ensure_connected( "params": _build_connect_params(config), } await ws.send(json.dumps(response)) - await _await_response(ws, connect_id) + return await _await_response(ws, connect_id) async def openclaw_call( @@ -327,6 +327,48 @@ async def openclaw_call( raise OpenClawGatewayError(str(exc)) from exc +async def openclaw_connect_metadata(*, config: GatewayConfig) -> object: + """Open a gateway connection and return the connect/hello payload.""" + gateway_url = _build_gateway_url(config) + started_at = perf_counter() + logger.debug( + "gateway.rpc.connect_metadata.start gateway_url=%s", + _redacted_url_for_log(gateway_url), + ) + try: + async with websockets.connect(gateway_url, ping_interval=None) as ws: + first_message = None + try: + first_message = await asyncio.wait_for(ws.recv(), timeout=2) + except TimeoutError: + first_message = None + metadata = await _ensure_connected(ws, first_message, config) + logger.debug( + "gateway.rpc.connect_metadata.success duration_ms=%s", + int((perf_counter() - started_at) * 1000), + ) + return metadata + except OpenClawGatewayError: + logger.warning( + "gateway.rpc.connect_metadata.gateway_error duration_ms=%s", + int((perf_counter() - started_at) * 1000), + ) + raise + except ( + TimeoutError, + ConnectionError, + OSError, + ValueError, + WebSocketException, + ) as exc: # pragma: no cover - network/protocol errors + logger.error( + "gateway.rpc.connect_metadata.transport_error duration_ms=%s error_type=%s", + int((perf_counter() - started_at) * 1000), + exc.__class__.__name__, + ) + raise OpenClawGatewayError(str(exc)) from exc + + async def send_message( message: str, *, diff --git a/backend/tests/test_gateway_version_compat.py b/backend/tests/test_gateway_version_compat.py index 37f177cb..c6e6b4d3 100644 --- a/backend/tests/test_gateway_version_compat.py +++ b/backend/tests/test_gateway_version_compat.py @@ -50,6 +50,11 @@ async def test_check_gateway_runtime_compatibility_prefers_schema_version( return {"version": "2026.2.13"} raise AssertionError(f"unexpected method: {method}") + async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None: + _ = config + return None + + monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata) monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call) result = await gateway_compat.check_gateway_runtime_compatibility( @@ -62,6 +67,34 @@ async def test_check_gateway_runtime_compatibility_prefers_schema_version( assert result.current_version == "2026.2.13" +@pytest.mark.asyncio +async def test_check_gateway_runtime_compatibility_prefers_connect_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[str] = [] + + async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None: + _ = config + return {"server": {"version": "2026.2.21-2"}} + + async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object: + _ = (params, config) + calls.append(method) + raise AssertionError(f"unexpected method: {method}") + + monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata) + monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call) + + result = await gateway_compat.check_gateway_runtime_compatibility( + GatewayConfig(url="ws://gateway.example/ws"), + minimum_version="2026.1.30", + ) + + assert calls == [] + assert result.compatible is True + assert result.current_version == "2026.2.21-2" + + @pytest.mark.asyncio async def test_check_gateway_runtime_compatibility_falls_back_to_health( monkeypatch: pytest.MonkeyPatch, @@ -77,6 +110,11 @@ async def test_check_gateway_runtime_compatibility_falls_back_to_health( raise OpenClawGatewayError("unknown method") return {"version": "2026.2.0"} + async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None: + _ = config + return None + + monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata) monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call) result = await gateway_compat.check_gateway_runtime_compatibility( @@ -104,6 +142,11 @@ async def test_check_gateway_runtime_compatibility_uses_health_when_status_has_n return {"uptime": 1234} return {"version": "2026.2.0"} + async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None: + _ = config + return None + + monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata) monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call) result = await gateway_compat.check_gateway_runtime_compatibility(