From 24731667d4f9dc1dad6219f1acce7445b2d2c662 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sun, 15 Feb 2026 15:59:55 +0530 Subject: [PATCH] feat: add gateway runtime compatibility checks and minimum version enforcement --- backend/.env.example | 1 + backend/app/api/gateways.py | 7 + backend/app/core/config.py | 3 + .../app/services/openclaw/admin_service.py | 17 ++ .../app/services/openclaw/gateway_compat.py | 221 ++++++++++++++++++ .../app/services/openclaw/session_service.py | 15 ++ backend/tests/test_gateway_version_compat.py | 215 +++++++++++++++++ 7 files changed, 479 insertions(+) create mode 100644 backend/app/services/openclaw/gateway_compat.py create mode 100644 backend/tests/test_gateway_version_compat.py diff --git a/backend/.env.example b/backend/.env.example index abd27e3d..dd3f52c6 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -24,3 +24,4 @@ RQ_REDIS_URL=redis://localhost:6379/0 RQ_QUEUE_NAME=default RQ_DISPATCH_THROTTLE_SECONDS=15.0 RQ_DISPATCH_MAX_RETRIES=3 +GATEWAY_MIN_VERSION=2026.02.9 diff --git a/backend/app/api/gateways.py b/backend/app/api/gateways.py index 2e5cbac9..3f756ce7 100644 --- a/backend/app/api/gateways.py +++ b/backend/app/api/gateways.py @@ -94,6 +94,7 @@ async def create_gateway( ) -> Gateway: """Create a gateway and provision or refresh its main agent.""" service = GatewayAdminLifecycleService(session) + await service.assert_gateway_runtime_compatible(url=payload.url, token=payload.token) data = payload.model_dump() gateway_id = uuid4() data["id"] = gateway_id @@ -133,6 +134,12 @@ async def update_gateway( organization_id=ctx.organization.id, ) updates = payload.model_dump(exclude_unset=True) + if "url" in updates or "token" in updates: + raw_next_url = updates.get("url", gateway.url) + next_url = raw_next_url.strip() if isinstance(raw_next_url, str) else "" + next_token = updates.get("token", gateway.token) + if next_url: + await service.assert_gateway_runtime_compatible(url=next_url, token=next_token) await crud.patch(session, gateway, updates) await service.ensure_main_agent(gateway, auth, action="update") return gateway diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 8df4cb1f..a6df6a01 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -61,6 +61,9 @@ class Settings(BaseSettings): rq_dispatch_retry_base_seconds: float = 10.0 rq_dispatch_retry_max_seconds: float = 120.0 + # OpenClaw gateway runtime compatibility + gateway_min_version: str = "2026.02.9" + # Logging log_level: str = "INFO" log_format: str = "text" diff --git a/backend/app/services/openclaw/admin_service.py b/backend/app/services/openclaw/admin_service.py index c156ec6b..1c25aaa8 100644 --- a/backend/app/services/openclaw/admin_service.py +++ b/backend/app/services/openclaw/admin_service.py @@ -26,6 +26,7 @@ from app.services.openclaw.db_agent_state import ( mint_agent_token, ) from app.services.openclaw.db_service import OpenClawDBService +from app.services.openclaw.gateway_compat import check_gateway_runtime_compatibility from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig from app.services.openclaw.gateway_rpc import OpenClawGatewayError, openclaw_call from app.services.openclaw.provisioning import OpenClawGatewayProvisioner @@ -176,6 +177,22 @@ class GatewayAdminLifecycleService(OpenClawDBService): return True return True + async def assert_gateway_runtime_compatible(self, *, url: str, token: str | None) -> None: + """Validate that a gateway runtime meets minimum supported version.""" + config = GatewayClientConfig(url=url, token=token) + try: + result = await check_gateway_runtime_compatibility(config) + except OpenClawGatewayError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Gateway compatibility check failed: {exc}", + ) from exc + if not result.compatible: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=result.message or "Gateway runtime version is not supported.", + ) + async def provision_main_agent_record( self, gateway: Gateway, diff --git a/backend/app/services/openclaw/gateway_compat.py b/backend/app/services/openclaw/gateway_compat.py new file mode 100644 index 00000000..ace829c4 --- /dev/null +++ b/backend/app/services/openclaw/gateway_compat.py @@ -0,0 +1,221 @@ +"""Gateway runtime version compatibility checks.""" + +from __future__ import annotations + +import re +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 + +_VERSION_PATTERN = re.compile(r"(?i)v?(?P\d+(?:\.\d+)+)") +_PRIMARY_VERSION_PATHS: tuple[tuple[str, ...], ...] = ( + ("version",), + ("gatewayVersion",), + ("appVersion",), + ("buildVersion",), + ("gateway", "version"), + ("app", "version"), + ("server", "version"), + ("runtime", "version"), + ("meta", "version"), + ("build", "version"), + ("info", "version"), +) + + +@dataclass(frozen=True, slots=True) +class GatewayVersionCheckResult: + """Compatibility verdict for a gateway runtime version.""" + + compatible: bool + minimum_version: str + current_version: str | None + message: str | None = None + + +def _normalized_minimum_version() -> str: + raw = (settings.gateway_min_version or "").strip() + return raw or "2026.1.30" + + +def _parse_version_parts(value: str) -> tuple[int, ...] | None: + match = _VERSION_PATTERN.search(value.strip()) + if match is None: + return None + numeric = match.group("version") + return tuple(int(part) for part in numeric.split(".")) + + +def _compare_versions(left: tuple[int, ...], right: tuple[int, ...]) -> int: + width = max(len(left), len(right)) + left_padded = left + (0,) * (width - len(left)) + right_padded = right + (0,) * (width - len(right)) + if left_padded < right_padded: + return -1 + if left_padded > right_padded: + return 1 + return 0 + + +def _value_at_path(payload: object, path: tuple[str, ...]) -> object | None: + current = payload + for segment in path: + if not isinstance(current, dict): + return None + if segment not in current: + return None + current = current[segment] + return current + + +def _coerce_version_string(value: object) -> str | None: + if isinstance(value, str): + normalized = value.strip() + return normalized or None + if isinstance(value, (int, float)): + return str(value) + return None + + +def _iter_fallback_version_values(payload: object) -> list[str]: + if not isinstance(payload, dict): + return [] + stack: list[dict[str, Any]] = [payload] + discovered: list[str] = [] + while stack: + node = stack.pop() + for key, value in node.items(): + if isinstance(value, dict): + stack.append(value) + key_lower = key.lower() + if "version" not in key_lower or "protocol" in key_lower: + continue + candidate = _coerce_version_string(value) + if candidate is not None: + discovered.append(candidate) + return discovered + + +def extract_gateway_version(payload: object) -> str | None: + """Extract a gateway runtime version string from status/health payloads.""" + for path in _PRIMARY_VERSION_PATHS: + candidate = _coerce_version_string(_value_at_path(payload, path)) + if candidate is not None: + return candidate + + for candidate in _iter_fallback_version_values(payload): + if _parse_version_parts(candidate) is not None: + return candidate + return None + + +def evaluate_gateway_version( + *, + current_version: str | None, + minimum_version: str | None = None, +) -> GatewayVersionCheckResult: + """Return compatibility result for the reported gateway version.""" + min_version = (minimum_version or _normalized_minimum_version()).strip() + min_parts = _parse_version_parts(min_version) + if min_parts is None: + msg = ( + "Server configuration error: GATEWAY_MIN_VERSION is invalid. " + f"Expected a dotted numeric version, got '{min_version}'." + ) + return GatewayVersionCheckResult( + compatible=False, + minimum_version=min_version, + current_version=current_version, + message=msg, + ) + + if current_version is None: + return GatewayVersionCheckResult( + compatible=False, + minimum_version=min_version, + current_version=None, + message=( + "Unable to determine gateway version from runtime metadata. " + f"Minimum supported version is {min_version}." + ), + ) + + current_parts = _parse_version_parts(current_version) + if current_parts is None: + return GatewayVersionCheckResult( + compatible=False, + minimum_version=min_version, + current_version=current_version, + message=( + f"Gateway reported an unsupported version format '{current_version}'. " + f"Minimum supported version is {min_version}." + ), + ) + + if _compare_versions(current_parts, min_parts) < 0: + return GatewayVersionCheckResult( + compatible=False, + minimum_version=min_version, + current_version=current_version, + message=( + f"Gateway version {current_version} is not supported. " + f"Minimum supported version is {min_version}." + ), + ) + + return GatewayVersionCheckResult( + compatible=True, + minimum_version=min_version, + current_version=current_version, + ) + + +async def _fetch_runtime_metadata(config: GatewayConfig) -> object: + last_error: OpenClawGatewayError | None = None + for method in ("status", "health"): + try: + return await openclaw_call(method, config=config) + except OpenClawGatewayError as exc: + last_error = exc + continue + if last_error is not None: + raise last_error + return {} + + +async def _fetch_schema_metadata(config: GatewayConfig) -> object | None: + try: + return await openclaw_call("config.schema", 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.""" + schema_payload = await _fetch_schema_metadata(config) + current_version = extract_gateway_version(schema_payload) + if current_version is not None: + return evaluate_gateway_version( + current_version=current_version, + minimum_version=minimum_version, + ) + + payload = await _fetch_runtime_metadata(config) + current_version = extract_gateway_version(payload) + if current_version is None: + try: + health_payload = await openclaw_call("health", config=config) + except OpenClawGatewayError: + health_payload = None + if health_payload is not None: + current_version = extract_gateway_version(health_payload) + return evaluate_gateway_version( + current_version=current_version, + minimum_version=minimum_version, + ) diff --git a/backend/app/services/openclaw/session_service.py b/backend/app/services/openclaw/session_service.py index 3669b0fd..1be1dd28 100644 --- a/backend/app/services/openclaw/session_service.py +++ b/backend/app/services/openclaw/session_service.py @@ -20,6 +20,7 @@ from app.schemas.gateway_api import ( GatewaysStatusResponse, ) from app.services.openclaw.db_service import OpenClawDBService +from app.services.openclaw.gateway_compat import check_gateway_runtime_compatibility from app.services.openclaw.gateway_resolver import gateway_client_config, require_gateway_for_board from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig from app.services.openclaw.gateway_rpc import ( @@ -188,6 +189,20 @@ class GatewaySessionService(OpenClawDBService): ) -> GatewaysStatusResponse: board, config, main_session = await self.resolve_gateway(params, user=user) self._require_same_org(board, organization_id) + try: + compatibility = await check_gateway_runtime_compatibility(config) + except OpenClawGatewayError as exc: + return GatewaysStatusResponse( + connected=False, + gateway_url=config.url, + error=str(exc), + ) + if not compatibility.compatible: + return GatewaysStatusResponse( + connected=False, + gateway_url=config.url, + error=compatibility.message, + ) try: sessions = await openclaw_call("sessions.list", config=config) if isinstance(sessions, dict): diff --git a/backend/tests/test_gateway_version_compat.py b/backend/tests/test_gateway_version_compat.py new file mode 100644 index 00000000..37f177cb --- /dev/null +++ b/backend/tests/test_gateway_version_compat.py @@ -0,0 +1,215 @@ +# ruff: noqa: S101 +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +import app.services.openclaw.admin_service as admin_service +import app.services.openclaw.gateway_compat as gateway_compat +import app.services.openclaw.session_service as session_service +from app.schemas.gateway_api import GatewayResolveQuery +from app.services.openclaw.admin_service import GatewayAdminLifecycleService +from app.services.openclaw.gateway_compat import GatewayVersionCheckResult +from app.services.openclaw.gateway_rpc import GatewayConfig, OpenClawGatewayError +from app.services.openclaw.session_service import GatewaySessionService + + +def test_extract_gateway_version_prefers_primary_path() -> None: + payload = { + "gateway": {"version": "2026.2.1"}, + "protocolVersion": 3, + "meta": {"version": "2026.1.30"}, + } + + assert gateway_compat.extract_gateway_version(payload) == "2026.2.1" + + +def test_evaluate_gateway_version_detects_old_runtime() -> None: + result = gateway_compat.evaluate_gateway_version( + current_version="2025.12.1", + minimum_version="2026.1.30", + ) + + assert result.compatible is False + assert result.minimum_version == "2026.1.30" + assert "Minimum supported version is 2026.1.30" in (result.message or "") + + +@pytest.mark.asyncio +async def test_check_gateway_runtime_compatibility_prefers_schema_version( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[str] = [] + + async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object: + _ = (params, config) + calls.append(method) + if method == "config.schema": + return {"version": "2026.2.13"} + raise AssertionError(f"unexpected method: {method}") + + 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 == ["config.schema"] + assert result.compatible is True + assert result.current_version == "2026.2.13" + + +@pytest.mark.asyncio +async def test_check_gateway_runtime_compatibility_falls_back_to_health( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[str] = [] + + async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object: + _ = (params, config) + calls.append(method) + if method == "config.schema": + raise OpenClawGatewayError("unknown method") + if method == "status": + raise OpenClawGatewayError("unknown method") + return {"version": "2026.2.0"} + + 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 == ["config.schema", "status", "health"] + assert result.compatible is True + assert result.current_version == "2026.2.0" + + +@pytest.mark.asyncio +async def test_check_gateway_runtime_compatibility_uses_health_when_status_has_no_version( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[str] = [] + + async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object: + _ = (params, config) + calls.append(method) + if method == "config.schema": + return {"schema": {"title": "Gateway schema"}} + if method == "status": + return {"uptime": 1234} + return {"version": "2026.2.0"} + + 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 == ["config.schema", "status", "health"] + assert result.compatible is True + assert result.current_version == "2026.2.0" + + +@pytest.mark.asyncio +async def test_admin_service_rejects_incompatible_gateway( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object: + _ = (config, minimum_version) + return GatewayVersionCheckResult( + compatible=False, + minimum_version="2026.1.30", + current_version="2026.1.0", + message="Gateway version 2026.1.0 is not supported.", + ) + + monkeypatch.setattr(admin_service, "check_gateway_runtime_compatibility", _fake_check) + + service = GatewayAdminLifecycleService(session=object()) # type: ignore[arg-type] + with pytest.raises(HTTPException) as exc_info: + await service.assert_gateway_runtime_compatible(url="ws://gateway.example/ws", token=None) + + assert exc_info.value.status_code == 422 + assert "not supported" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_admin_service_maps_gateway_transport_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object: + _ = (config, minimum_version) + raise OpenClawGatewayError("connection refused") + + monkeypatch.setattr(admin_service, "check_gateway_runtime_compatibility", _fake_check) + + service = GatewayAdminLifecycleService(session=object()) # type: ignore[arg-type] + with pytest.raises(HTTPException) as exc_info: + await service.assert_gateway_runtime_compatible(url="ws://gateway.example/ws", token=None) + + assert exc_info.value.status_code == 502 + assert "compatibility check failed" in str(exc_info.value.detail).lower() + + +@pytest.mark.asyncio +async def test_gateway_status_reports_incompatible_version( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object: + _ = (config, minimum_version) + return GatewayVersionCheckResult( + compatible=False, + minimum_version="2026.1.30", + current_version="2026.1.0", + message="Gateway version 2026.1.0 is not supported.", + ) + + monkeypatch.setattr(session_service, "check_gateway_runtime_compatibility", _fake_check) + + service = GatewaySessionService(session=object()) # type: ignore[arg-type] + response = await service.get_status( + params=GatewayResolveQuery(gateway_url="ws://gateway.example/ws"), + organization_id=uuid4(), + user=None, + ) + + assert response.connected is False + assert response.error == "Gateway version 2026.1.0 is not supported." + + +@pytest.mark.asyncio +async def test_gateway_status_returns_sessions_when_version_compatible( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object: + _ = (config, minimum_version) + return GatewayVersionCheckResult( + compatible=True, + minimum_version="2026.1.30", + current_version="2026.2.0", + message=None, + ) + + async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object: + _ = (params, config) + assert method == "sessions.list" + return {"sessions": [{"key": "agent:main"}]} + + monkeypatch.setattr(session_service, "check_gateway_runtime_compatibility", _fake_check) + monkeypatch.setattr(session_service, "openclaw_call", _fake_openclaw_call) + + service = GatewaySessionService(session=object()) # type: ignore[arg-type] + response = await service.get_status( + params=GatewayResolveQuery(gateway_url="ws://gateway.example/ws"), + organization_id=uuid4(), + user=None, + ) + + assert response.connected is True + assert response.sessions_count == 1