From 3dfb70cd907be6aa22277925fbc7658b78349175 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sun, 22 Feb 2026 19:19:26 +0530 Subject: [PATCH] feat: add disable_device_pairing option to gateway configuration --- backend/app/api/gateway.py | 2 + backend/app/api/gateways.py | 21 +- backend/app/models/gateways.py | 1 + backend/app/schemas/gateway_api.py | 1 + backend/app/schemas/gateways.py | 2 + .../app/services/openclaw/admin_service.py | 24 +- .../app/services/openclaw/device_identity.py | 163 +++++++++++++ .../app/services/openclaw/error_messages.py | 31 +++ .../app/services/openclaw/gateway_resolver.py | 12 +- backend/app/services/openclaw/gateway_rpc.py | 197 +++++++++++++--- backend/app/services/openclaw/provisioning.py | 12 +- .../app/services/openclaw/provisioning_db.py | 6 +- .../app/services/openclaw/session_service.py | 8 +- ..._add_disable_device_pairing_to_gateways.py | 37 +++ backend/pyproject.toml | 1 + backend/tests/test_gateway_device_identity.py | 67 ++++++ backend/tests/test_gateway_resolver.py | 64 ++++++ .../tests/test_gateway_rpc_connect_scopes.py | 176 +++++++++++++- backend/tests/test_gateway_version_compat.py | 40 ++++ backend/uv.lock | 20 +- docs/openclaw_baseline_config.md | 3 + frontend/src/api/generated/agent/agent.ts | 214 ++++++++++++++++-- .../model/agentHealthStatusResponse.ts | 24 ++ .../src/api/generated/model/gatewayCreate.ts | 1 + .../src/api/generated/model/gatewayRead.ts | 1 + .../src/api/generated/model/gatewayUpdate.ts | 1 + ...ewaysStatusApiV1GatewaysStatusGetParams.ts | 1 + frontend/src/api/generated/model/index.ts | 1 + .../app/gateways/[gatewayId]/edit/page.tsx | 55 +++-- .../src/app/gateways/[gatewayId]/page.tsx | 9 + frontend/src/app/gateways/new/page.tsx | 49 ++-- .../src/components/gateways/GatewayForm.tsx | 86 +++---- frontend/src/lib/gateway-form.test.ts | 69 ++++++ frontend/src/lib/gateway-form.ts | 8 +- 34 files changed, 1229 insertions(+), 178 deletions(-) create mode 100644 backend/app/services/openclaw/device_identity.py create mode 100644 backend/app/services/openclaw/error_messages.py create mode 100644 backend/migrations/versions/c5d1a2b3e4f6_add_disable_device_pairing_to_gateways.py create mode 100644 backend/tests/test_gateway_device_identity.py create mode 100644 backend/tests/test_gateway_resolver.py create mode 100644 frontend/src/api/generated/model/agentHealthStatusResponse.ts create mode 100644 frontend/src/lib/gateway-form.test.ts diff --git a/backend/app/api/gateway.py b/backend/app/api/gateway.py index 12b24117..5efc8552 100644 --- a/backend/app/api/gateway.py +++ b/backend/app/api/gateway.py @@ -37,11 +37,13 @@ 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), ) -> GatewayResolveQuery: return GatewaySessionService.to_resolve_query( board_id=board_id, gateway_url=gateway_url, gateway_token=gateway_token, + gateway_disable_device_pairing=gateway_disable_device_pairing, ) diff --git a/backend/app/api/gateways.py b/backend/app/api/gateways.py index 3f756ce7..1e567484 100644 --- a/backend/app/api/gateways.py +++ b/backend/app/api/gateways.py @@ -94,7 +94,11 @@ 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) + await service.assert_gateway_runtime_compatible( + url=payload.url, + token=payload.token, + disable_device_pairing=payload.disable_device_pairing, + ) data = payload.model_dump() gateway_id = uuid4() data["id"] = gateway_id @@ -134,12 +138,23 @@ async def update_gateway( organization_id=ctx.organization.id, ) updates = payload.model_dump(exclude_unset=True) - if "url" in updates or "token" in updates: + if ( + "url" in updates + or "token" in updates + or "disable_device_pairing" 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) + next_disable_device_pairing = bool( + updates.get("disable_device_pairing", gateway.disable_device_pairing), + ) if next_url: - await service.assert_gateway_runtime_compatible(url=next_url, token=next_token) + await service.assert_gateway_runtime_compatible( + url=next_url, + token=next_token, + disable_device_pairing=next_disable_device_pairing, + ) await crud.patch(session, gateway, updates) await service.ensure_main_agent(gateway, auth, action="update") return gateway diff --git a/backend/app/models/gateways.py b/backend/app/models/gateways.py index 954f144f..3e125195 100644 --- a/backend/app/models/gateways.py +++ b/backend/app/models/gateways.py @@ -23,6 +23,7 @@ class Gateway(QueryModel, table=True): name: str url: str token: str | None = Field(default=None) + disable_device_pairing: bool = Field(default=False) workspace_root: str created_at: datetime = Field(default_factory=utcnow) updated_at: datetime = Field(default_factory=utcnow) diff --git a/backend/app/schemas/gateway_api.py b/backend/app/schemas/gateway_api.py index 2ae97692..13cc2094 100644 --- a/backend/app/schemas/gateway_api.py +++ b/backend/app/schemas/gateway_api.py @@ -21,6 +21,7 @@ class GatewayResolveQuery(SQLModel): board_id: str | None = None gateway_url: str | None = None gateway_token: str | None = None + gateway_disable_device_pairing: bool = False class GatewaysStatusResponse(SQLModel): diff --git a/backend/app/schemas/gateways.py b/backend/app/schemas/gateways.py index 233a44d5..eb8d2299 100644 --- a/backend/app/schemas/gateways.py +++ b/backend/app/schemas/gateways.py @@ -17,6 +17,7 @@ class GatewayBase(SQLModel): name: str url: str workspace_root: str + disable_device_pairing: bool = False class GatewayCreate(GatewayBase): @@ -43,6 +44,7 @@ class GatewayUpdate(SQLModel): url: str | None = None token: str | None = None workspace_root: str | None = None + disable_device_pairing: bool | None = None @field_validator("token", mode="before") @classmethod diff --git a/backend/app/services/openclaw/admin_service.py b/backend/app/services/openclaw/admin_service.py index dfc0c2b6..28197be0 100644 --- a/backend/app/services/openclaw/admin_service.py +++ b/backend/app/services/openclaw/admin_service.py @@ -27,6 +27,7 @@ from app.services.openclaw.db_agent_state import ( mint_agent_token, ) from app.services.openclaw.db_service import OpenClawDBService +from app.services.openclaw.error_messages import normalize_gateway_error_message 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 @@ -167,7 +168,11 @@ class GatewayAdminLifecycleService(OpenClawDBService): async def gateway_has_main_agent_entry(self, gateway: Gateway) -> bool: if not gateway.url: return False - config = GatewayClientConfig(url=gateway.url, token=gateway.token) + config = GatewayClientConfig( + url=gateway.url, + token=gateway.token, + disable_device_pairing=gateway.disable_device_pairing, + ) target_id = GatewayAgentIdentity.openclaw_agent_id(gateway) try: await openclaw_call("agents.files.list", {"agentId": target_id}, config=config) @@ -178,15 +183,26 @@ class GatewayAdminLifecycleService(OpenClawDBService): return True return True - async def assert_gateway_runtime_compatible(self, *, url: str, token: str | None) -> None: + async def assert_gateway_runtime_compatible( + self, + *, + url: str, + token: str | None, + disable_device_pairing: bool = False, + ) -> None: """Validate that a gateway runtime meets minimum supported version.""" - config = GatewayClientConfig(url=url, token=token) + config = GatewayClientConfig( + url=url, + token=token, + disable_device_pairing=disable_device_pairing, + ) try: result = await check_gateway_runtime_compatibility(config) except OpenClawGatewayError as exc: + detail = normalize_gateway_error_message(str(exc)) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, - detail=f"Gateway compatibility check failed: {exc}", + detail=f"Gateway compatibility check failed: {detail}", ) from exc if not result.compatible: raise HTTPException( diff --git a/backend/app/services/openclaw/device_identity.py b/backend/app/services/openclaw/device_identity.py new file mode 100644 index 00000000..8a94b87c --- /dev/null +++ b/backend/app/services/openclaw/device_identity.py @@ -0,0 +1,163 @@ +"""OpenClaw-compatible device identity and connect-signature helpers.""" + +from __future__ import annotations + +import hashlib +import json +import os +from dataclasses import dataclass +from pathlib import Path +from time import time +from typing import Any, cast + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) + +DEFAULT_DEVICE_IDENTITY_PATH = Path.home() / ".openclaw" / "identity" / "device.json" + + +@dataclass(frozen=True) +class DeviceIdentity: + """Persisted gateway device identity used for connect signatures.""" + + device_id: str + public_key_pem: str + private_key_pem: str + + +def _identity_path() -> Path: + raw = os.getenv("OPENCLAW_GATEWAY_DEVICE_IDENTITY_PATH", "").strip() + if raw: + return Path(raw).expanduser().resolve() + return DEFAULT_DEVICE_IDENTITY_PATH + + +def _base64url_encode(raw: bytes) -> str: + import base64 + + return base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=") + + +def _derive_public_key_raw(public_key_pem: str) -> bytes: + loaded = serialization.load_pem_public_key(public_key_pem.encode("utf-8")) + if not isinstance(loaded, Ed25519PublicKey): + msg = "device identity public key is not Ed25519" + raise ValueError(msg) + return loaded.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + + +def _derive_device_id(public_key_pem: str) -> str: + return hashlib.sha256(_derive_public_key_raw(public_key_pem)).hexdigest() + + +def _write_identity(path: Path, identity: DeviceIdentity) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "version": 1, + "deviceId": identity.device_id, + "publicKeyPem": identity.public_key_pem, + "privateKeyPem": identity.private_key_pem, + "createdAtMs": int(time() * 1000), + } + path.write_text(f"{json.dumps(payload, indent=2)}\n", encoding="utf-8") + try: + path.chmod(0o600) + except OSError: + # Best effort on platforms/filesystems that ignore chmod. + pass + + +def _generate_identity() -> DeviceIdentity: + private_key = Ed25519PrivateKey.generate() + private_key_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + public_key_pem = private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("utf-8") + device_id = _derive_device_id(public_key_pem) + return DeviceIdentity( + device_id=device_id, + public_key_pem=public_key_pem, + private_key_pem=private_key_pem, + ) + + +def load_or_create_device_identity() -> DeviceIdentity: + """Load persisted device identity or create a new one when missing/invalid.""" + path = _identity_path() + try: + if path.exists(): + payload = cast(dict[str, Any], json.loads(path.read_text(encoding="utf-8"))) + device_id = str(payload.get("deviceId") or "").strip() + public_key_pem = str(payload.get("publicKeyPem") or "").strip() + private_key_pem = str(payload.get("privateKeyPem") or "").strip() + if device_id and public_key_pem and private_key_pem: + derived_id = _derive_device_id(public_key_pem) + identity = DeviceIdentity( + device_id=derived_id, + public_key_pem=public_key_pem, + private_key_pem=private_key_pem, + ) + if derived_id != device_id: + _write_identity(path, identity) + return identity + except (OSError, ValueError, json.JSONDecodeError): + # Fall through to regenerate. + pass + + identity = _generate_identity() + _write_identity(path, identity) + return identity + + +def public_key_raw_base64url_from_pem(public_key_pem: str) -> str: + """Return raw Ed25519 public key in base64url form expected by OpenClaw.""" + return _base64url_encode(_derive_public_key_raw(public_key_pem)) + + +def sign_device_payload(private_key_pem: str, payload: str) -> str: + """Sign a device payload with Ed25519 and return base64url signature.""" + loaded = serialization.load_pem_private_key(private_key_pem.encode("utf-8"), password=None) + if not isinstance(loaded, Ed25519PrivateKey): + msg = "device identity private key is not Ed25519" + raise ValueError(msg) + signature = loaded.sign(payload.encode("utf-8")) + return _base64url_encode(signature) + + +def build_device_auth_payload( + *, + device_id: str, + client_id: str, + client_mode: str, + role: str, + scopes: list[str], + signed_at_ms: int, + token: str | None, + nonce: str | None, +) -> str: + """Build the OpenClaw canonical payload string for device signatures.""" + version = "v2" if nonce else "v1" + parts = [ + version, + device_id, + client_id, + client_mode, + role, + ",".join(scopes), + str(signed_at_ms), + token or "", + ] + if version == "v2": + parts.append(nonce or "") + return "|".join(parts) diff --git a/backend/app/services/openclaw/error_messages.py b/backend/app/services/openclaw/error_messages.py new file mode 100644 index 00000000..c604eedd --- /dev/null +++ b/backend/app/services/openclaw/error_messages.py @@ -0,0 +1,31 @@ +"""Normalization helpers for user-facing OpenClaw gateway errors.""" + +from __future__ import annotations + +import re + +_MISSING_SCOPE_PATTERN = re.compile( + r"missing\s+scope\s*:\s*(?P[A-Za-z0-9._:-]+)", + re.IGNORECASE, +) + + +def normalize_gateway_error_message(message: str) -> str: + """Return a user-friendly message for common gateway auth failures.""" + raw_message = message.strip() + if not raw_message: + return "Gateway authentication failed. Verify gateway token and operator scopes." + + missing_scope = _MISSING_SCOPE_PATTERN.search(raw_message) + if missing_scope is not None: + scope = missing_scope.group("scope") + return ( + f"Gateway token is missing required scope `{scope}`. " + "Update the gateway token scopes and retry." + ) + + lowered = raw_message.lower() + if "unauthorized" in lowered or "forbidden" in lowered: + return "Gateway authentication failed. Verify gateway token and operator scopes." + + return raw_message diff --git a/backend/app/services/openclaw/gateway_resolver.py b/backend/app/services/openclaw/gateway_resolver.py index 7e31814f..58710116 100644 --- a/backend/app/services/openclaw/gateway_resolver.py +++ b/backend/app/services/openclaw/gateway_resolver.py @@ -32,7 +32,11 @@ def gateway_client_config(gateway: Gateway) -> GatewayClientConfig: detail="Gateway url is required", ) token = (gateway.token or "").strip() or None - return GatewayClientConfig(url=url, token=token) + return GatewayClientConfig( + url=url, + token=token, + disable_device_pairing=gateway.disable_device_pairing, + ) def optional_gateway_client_config(gateway: Gateway | None) -> GatewayClientConfig | None: @@ -43,7 +47,11 @@ def optional_gateway_client_config(gateway: Gateway | None) -> GatewayClientConf if not url: return None token = (gateway.token or "").strip() or None - return GatewayClientConfig(url=url, token=token) + return GatewayClientConfig( + url=url, + token=token, + disable_device_pairing=gateway.disable_device_pairing, + ) def require_gateway_workspace_root(gateway: Gateway) -> str: diff --git a/backend/app/services/openclaw/gateway_rpc.py b/backend/app/services/openclaw/gateway_rpc.py index 0765d54d..02928f5d 100644 --- a/backend/app/services/openclaw/gateway_rpc.py +++ b/backend/app/services/openclaw/gateway_rpc.py @@ -10,8 +10,8 @@ from __future__ import annotations import asyncio import json from dataclasses import dataclass -from time import perf_counter -from typing import Any +from time import perf_counter, time +from typing import Any, Literal from urllib.parse import urlencode, urlparse, urlunparse from uuid import uuid4 @@ -19,6 +19,12 @@ import websockets from websockets.exceptions import WebSocketException from app.core.logging import TRACE_LEVEL, get_logger +from app.services.openclaw.device_identity import ( + build_device_auth_payload, + load_or_create_device_identity, + public_key_raw_base64url_from_pem, + sign_device_payload, +) PROTOCOL_VERSION = 3 logger = get_logger(__name__) @@ -28,6 +34,11 @@ GATEWAY_OPERATOR_SCOPES = ( "operator.approvals", "operator.pairing", ) +DEFAULT_GATEWAY_CLIENT_ID = "gateway-client" +DEFAULT_GATEWAY_CLIENT_MODE = "backend" +CONTROL_UI_CLIENT_ID = "openclaw-control-ui" +CONTROL_UI_CLIENT_MODE = "ui" +GatewayConnectMode = Literal["device", "control_ui"] # NOTE: These are the base gateway methods from the OpenClaw gateway repo. # The gateway can expose additional methods at runtime via channel plugins. @@ -160,6 +171,7 @@ class GatewayConfig: url: str token: str | None = None + disable_device_pairing: bool = False def _build_gateway_url(config: GatewayConfig) -> str: @@ -180,6 +192,60 @@ def _redacted_url_for_log(raw_url: str) -> str: return str(urlunparse(parsed._replace(query="", fragment=""))) +def _build_control_ui_origin(gateway_url: str) -> str | None: + parsed = urlparse(gateway_url) + if not parsed.hostname: + return None + if parsed.scheme in {"ws", "http"}: + origin_scheme = "http" + elif parsed.scheme in {"wss", "https"}: + origin_scheme = "https" + else: + return None + host = parsed.hostname + if ":" in host and not host.startswith("["): + host = f"[{host}]" + if parsed.port is not None: + host = f"{host}:{parsed.port}" + return f"{origin_scheme}://{host}" + + +def _resolve_connect_mode(config: GatewayConfig) -> GatewayConnectMode: + return "control_ui" if config.disable_device_pairing else "device" + + +def _build_device_connect_payload( + *, + client_id: str, + client_mode: str, + role: str, + scopes: list[str], + auth_token: str | None, + connect_nonce: str | None, +) -> dict[str, Any]: + identity = load_or_create_device_identity() + signed_at_ms = int(time() * 1000) + payload = build_device_auth_payload( + device_id=identity.device_id, + client_id=client_id, + client_mode=client_mode, + role=role, + scopes=scopes, + signed_at_ms=signed_at_ms, + token=auth_token, + nonce=connect_nonce, + ) + device_payload: dict[str, Any] = { + "id": identity.device_id, + "publicKey": public_key_raw_base64url_from_pem(identity.public_key_pem), + "signature": sign_device_payload(identity.private_key_pem, payload), + "signedAt": signed_at_ms, + } + if connect_nonce: + device_payload["nonce"] = connect_nonce + return device_payload + + async def _await_response( ws: websockets.ClientConnection, request_id: str, @@ -231,19 +297,36 @@ async def _send_request( return await _await_response(ws, request_id) -def _build_connect_params(config: GatewayConfig) -> dict[str, Any]: +def _build_connect_params( + config: GatewayConfig, + *, + connect_nonce: str | None = None, +) -> dict[str, Any]: + role = "operator" + scopes = list(GATEWAY_OPERATOR_SCOPES) + connect_mode = _resolve_connect_mode(config) + use_control_ui = connect_mode == "control_ui" params: dict[str, Any] = { "minProtocol": PROTOCOL_VERSION, "maxProtocol": PROTOCOL_VERSION, - "role": "operator", - "scopes": list(GATEWAY_OPERATOR_SCOPES), + "role": role, + "scopes": scopes, "client": { - "id": "gateway-client", + "id": CONTROL_UI_CLIENT_ID if use_control_ui else DEFAULT_GATEWAY_CLIENT_ID, "version": "1.0.0", - "platform": "web", - "mode": "ui", + "platform": "python", + "mode": CONTROL_UI_CLIENT_MODE if use_control_ui else DEFAULT_GATEWAY_CLIENT_MODE, }, } + if not use_control_ui: + params["device"] = _build_device_connect_payload( + client_id=DEFAULT_GATEWAY_CLIENT_ID, + client_mode=DEFAULT_GATEWAY_CLIENT_MODE, + role=role, + scopes=scopes, + auth_token=config.token, + connect_nonce=connect_nonce, + ) if config.token: params["auth"] = {"token": config.token} return params @@ -254,11 +337,18 @@ async def _ensure_connected( first_message: str | bytes | None, config: GatewayConfig, ) -> object: + connect_nonce: str | None = None if first_message: if isinstance(first_message, bytes): first_message = first_message.decode("utf-8") data = json.loads(first_message) - if data.get("type") != "event" or data.get("event") != "connect.challenge": + if data.get("type") == "event" and data.get("event") == "connect.challenge": + payload = data.get("payload") + if isinstance(payload, dict): + nonce = payload.get("nonce") + if isinstance(nonce, str) and nonce.strip(): + connect_nonce = nonce.strip() + else: logger.warning( "gateway.rpc.connect.unexpected_first_message type=%s event=%s", data.get("type"), @@ -269,12 +359,52 @@ async def _ensure_connected( "type": "req", "id": connect_id, "method": "connect", - "params": _build_connect_params(config), + "params": _build_connect_params(config, connect_nonce=connect_nonce), } await ws.send(json.dumps(response)) return await _await_response(ws, connect_id) +async def _recv_first_message_or_none( + ws: websockets.ClientConnection, +) -> str | bytes | None: + try: + return await asyncio.wait_for(ws.recv(), timeout=2) + except TimeoutError: + return None + + +async def _openclaw_call_once( + method: str, + params: dict[str, Any] | None, + *, + config: GatewayConfig, + gateway_url: str, +) -> object: + origin = _build_control_ui_origin(gateway_url) if config.disable_device_pairing else None + connect_kwargs: dict[str, Any] = {"ping_interval": None} + if origin is not None: + connect_kwargs["origin"] = origin + 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) + + +async def _openclaw_connect_metadata_once( + *, + config: GatewayConfig, + gateway_url: str, +) -> object: + origin = _build_control_ui_origin(gateway_url) if config.disable_device_pairing else None + connect_kwargs: dict[str, Any] = {"ping_interval": None} + if origin is not None: + connect_kwargs["origin"] = origin + 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) + + async def openclaw_call( method: str, params: dict[str, Any] | None = None, @@ -290,20 +420,18 @@ async def openclaw_call( _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 - await _ensure_connected(ws, first_message, config) - payload = await _send_request(ws, method, params) - logger.debug( - "gateway.rpc.call.success method=%s duration_ms=%s", - method, - int((perf_counter() - started_at) * 1000), - ) - return payload + payload = await _openclaw_call_once( + method, + params, + config=config, + gateway_url=gateway_url, + ) + logger.debug( + "gateway.rpc.call.success method=%s duration_ms=%s", + method, + int((perf_counter() - started_at) * 1000), + ) + return payload except OpenClawGatewayError: logger.warning( "gateway.rpc.call.gateway_error method=%s duration_ms=%s", @@ -336,18 +464,15 @@ async def openclaw_connect_metadata(*, config: GatewayConfig) -> object: _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 + metadata = await _openclaw_connect_metadata_once( + config=config, + gateway_url=gateway_url, + ) + 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", diff --git a/backend/app/services/openclaw/provisioning.py b/backend/app/services/openclaw/provisioning.py index 0d2534a3..2eaba0e6 100644 --- a/backend/app/services/openclaw/provisioning.py +++ b/backend/app/services/openclaw/provisioning.py @@ -970,7 +970,11 @@ def _control_plane_for_gateway(gateway: Gateway) -> OpenClawGatewayControlPlane: msg = "Gateway url is required" raise OpenClawGatewayError(msg) return OpenClawGatewayControlPlane( - GatewayClientConfig(url=gateway.url, token=gateway.token), + GatewayClientConfig( + url=gateway.url, + token=gateway.token, + disable_device_pairing=gateway.disable_device_pairing, + ), ) @@ -1099,7 +1103,11 @@ class OpenClawGatewayProvisioner: if not wake: return - client_config = GatewayClientConfig(url=gateway.url, token=gateway.token) + client_config = GatewayClientConfig( + url=gateway.url, + token=gateway.token, + disable_device_pairing=gateway.disable_device_pairing, + ) await ensure_session(session_key, config=client_config, label=agent.name) verb = wakeup_verb or ("provisioned" if action == "provision" else "updated") await send_message( diff --git a/backend/app/services/openclaw/provisioning_db.py b/backend/app/services/openclaw/provisioning_db.py index 97fcb8f6..2fbc2ef3 100644 --- a/backend/app/services/openclaw/provisioning_db.py +++ b/backend/app/services/openclaw/provisioning_db.py @@ -285,7 +285,11 @@ class OpenClawProvisioningService(OpenClawDBService): return result control_plane = OpenClawGatewayControlPlane( - GatewayClientConfig(url=gateway.url, token=gateway.token), + GatewayClientConfig( + url=gateway.url, + token=gateway.token, + disable_device_pairing=gateway.disable_device_pairing, + ), ) ctx = _SyncContext( session=self.session, diff --git a/backend/app/services/openclaw/session_service.py b/backend/app/services/openclaw/session_service.py index c42d707a..377856f8 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.error_messages import normalize_gateway_error_message 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 @@ -64,11 +65,13 @@ class GatewaySessionService(OpenClawDBService): board_id: str | None, gateway_url: str | None, gateway_token: str | None, + gateway_disable_device_pairing: bool = False, ) -> GatewayResolveQuery: return GatewayResolveQuery( board_id=board_id, gateway_url=gateway_url, gateway_token=gateway_token, + gateway_disable_device_pairing=gateway_disable_device_pairing, ) @staticmethod @@ -109,6 +112,7 @@ class GatewaySessionService(OpenClawDBService): GatewayClientConfig( url=raw_url, token=(params.gateway_token or "").strip() or None, + disable_device_pairing=params.gateway_disable_device_pairing, ), None, ) @@ -195,7 +199,7 @@ class GatewaySessionService(OpenClawDBService): return GatewaysStatusResponse( connected=False, gateway_url=config.url, - error=str(exc), + error=normalize_gateway_error_message(str(exc)), ) if not compatibility.compatible: return GatewaysStatusResponse( @@ -234,7 +238,7 @@ class GatewaySessionService(OpenClawDBService): return GatewaysStatusResponse( connected=False, gateway_url=config.url, - error=str(exc), + error=normalize_gateway_error_message(str(exc)), ) async def get_sessions( diff --git a/backend/migrations/versions/c5d1a2b3e4f6_add_disable_device_pairing_to_gateways.py b/backend/migrations/versions/c5d1a2b3e4f6_add_disable_device_pairing_to_gateways.py new file mode 100644 index 00000000..b0ce0978 --- /dev/null +++ b/backend/migrations/versions/c5d1a2b3e4f6_add_disable_device_pairing_to_gateways.py @@ -0,0 +1,37 @@ +"""Add disable_device_pairing setting to gateways. + +Revision ID: c5d1a2b3e4f6 +Revises: b7a1d9c3e4f5 +Create Date: 2026-02-22 00:00:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "c5d1a2b3e4f6" +down_revision = "b7a1d9c3e4f5" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Add gateway toggle to bypass device pairing handshake.""" + op.add_column( + "gateways", + sa.Column( + "disable_device_pairing", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + op.alter_column("gateways", "disable_device_pairing", server_default=None) + + +def downgrade() -> None: + """Remove gateway toggle to bypass device pairing handshake.""" + op.drop_column("gateways", "disable_device_pairing") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c5ce49e5..fcd9af8a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "websockets==16.0", "redis==6.3.0", "rq==2.6.0", + "cryptography==45.0.7", ] [project.optional-dependencies] diff --git a/backend/tests/test_gateway_device_identity.py b/backend/tests/test_gateway_device_identity.py new file mode 100644 index 00000000..39db8cf8 --- /dev/null +++ b/backend/tests/test_gateway_device_identity.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import base64 + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from app.services.openclaw.device_identity import ( + build_device_auth_payload, + load_or_create_device_identity, + sign_device_payload, +) + + +def _base64url_decode(value: str) -> bytes: + padding = "=" * ((4 - len(value) % 4) % 4) + return base64.urlsafe_b64decode(f"{value}{padding}") + + +def test_load_or_create_device_identity_persists_same_identity( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + identity_path = tmp_path / "identity" / "device.json" + monkeypatch.setenv("OPENCLAW_GATEWAY_DEVICE_IDENTITY_PATH", str(identity_path)) + + first = load_or_create_device_identity() + second = load_or_create_device_identity() + + assert identity_path.exists() + assert first.device_id == second.device_id + assert first.public_key_pem.strip() == second.public_key_pem.strip() + assert first.private_key_pem.strip() == second.private_key_pem.strip() + + +def test_build_device_auth_payload_uses_nonce_for_v2() -> None: + payload = build_device_auth_payload( + device_id="dev", + client_id="gateway-client", + client_mode="backend", + role="operator", + scopes=["operator.read", "operator.admin"], + signed_at_ms=123, + token="token", + nonce="nonce-xyz", + ) + + assert payload == ( + "v2|dev|gateway-client|backend|operator|operator.read,operator.admin|123|token|nonce-xyz" + ) + + +def test_sign_device_payload_produces_valid_ed25519_signature( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + identity_path = tmp_path / "identity" / "device.json" + monkeypatch.setenv("OPENCLAW_GATEWAY_DEVICE_IDENTITY_PATH", str(identity_path)) + identity = load_or_create_device_identity() + + payload = "v1|device|client|backend|operator|operator.read|1|token" + signature = sign_device_payload(identity.private_key_pem, payload) + + loaded = serialization.load_pem_public_key(identity.public_key_pem.encode("utf-8")) + assert isinstance(loaded, Ed25519PublicKey) + loaded.verify(_base64url_decode(signature), payload.encode("utf-8")) diff --git a/backend/tests/test_gateway_resolver.py b/backend/tests/test_gateway_resolver.py new file mode 100644 index 00000000..ccbf673d --- /dev/null +++ b/backend/tests/test_gateway_resolver.py @@ -0,0 +1,64 @@ +# ruff: noqa: S101 +from __future__ import annotations + +from uuid import uuid4 + +from app.models.gateways import Gateway +from app.services.openclaw.gateway_resolver import ( + gateway_client_config, + optional_gateway_client_config, +) +from app.services.openclaw.session_service import GatewaySessionService + + +def _gateway( + *, + disable_device_pairing: bool, + url: str = "ws://gateway.example:18789/ws", + token: str | None = " secret-token ", +) -> Gateway: + return Gateway( + id=uuid4(), + organization_id=uuid4(), + name="Primary gateway", + url=url, + token=token, + workspace_root="~/.openclaw", + disable_device_pairing=disable_device_pairing, + ) + + +def test_gateway_client_config_maps_disable_device_pairing() -> None: + config = gateway_client_config(_gateway(disable_device_pairing=True)) + + assert config.url == "ws://gateway.example:18789/ws" + assert config.token == "secret-token" + assert config.disable_device_pairing is True + + +def test_optional_gateway_client_config_maps_disable_device_pairing() -> None: + config = optional_gateway_client_config(_gateway(disable_device_pairing=False)) + + assert config is not None + assert config.disable_device_pairing is False + + +def test_optional_gateway_client_config_returns_none_for_missing_or_blank_url() -> None: + assert optional_gateway_client_config(None) is None + assert ( + optional_gateway_client_config( + _gateway(disable_device_pairing=False, url=" "), + ) + is None + ) + + +def test_to_resolve_query_keeps_gateway_disable_device_pairing_value() -> None: + resolved = GatewaySessionService.to_resolve_query( + board_id=None, + gateway_url="ws://gateway.example:18789/ws", + gateway_token="secret-token", + gateway_disable_device_pairing=True, + ) + + assert resolved.gateway_disable_device_pairing is True diff --git a/backend/tests/test_gateway_rpc_connect_scopes.py b/backend/tests/test_gateway_rpc_connect_scopes.py index 962ee90d..0a2b2dad 100644 --- a/backend/tests/test_gateway_rpc_connect_scopes.py +++ b/backend/tests/test_gateway_rpc_connect_scopes.py @@ -1,24 +1,194 @@ from __future__ import annotations +import pytest + +import app.services.openclaw.gateway_rpc as gateway_rpc from app.services.openclaw.gateway_rpc import ( + CONTROL_UI_CLIENT_ID, + CONTROL_UI_CLIENT_MODE, + DEFAULT_GATEWAY_CLIENT_ID, + DEFAULT_GATEWAY_CLIENT_MODE, GATEWAY_OPERATOR_SCOPES, GatewayConfig, + OpenClawGatewayError, _build_connect_params, + _build_control_ui_origin, + openclaw_call, ) -def test_build_connect_params_sets_explicit_operator_role_and_scopes() -> None: +def test_build_connect_params_defaults_to_device_pairing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + expected_device_payload = { + "id": "device-id", + "publicKey": "public-key", + "signature": "signature", + "signedAt": 1, + } + + def _fake_build_device_connect_payload( + *, + client_id: str, + client_mode: str, + role: str, + scopes: list[str], + auth_token: str | None, + connect_nonce: str | None, + ) -> dict[str, object]: + captured["client_id"] = client_id + captured["client_mode"] = client_mode + captured["role"] = role + captured["scopes"] = scopes + captured["auth_token"] = auth_token + captured["connect_nonce"] = connect_nonce + return expected_device_payload + + monkeypatch.setattr( + gateway_rpc, + "_build_device_connect_payload", + _fake_build_device_connect_payload, + ) + params = _build_connect_params(GatewayConfig(url="ws://gateway.example/ws")) assert params["role"] == "operator" assert params["scopes"] == list(GATEWAY_OPERATOR_SCOPES) + assert params["client"]["id"] == DEFAULT_GATEWAY_CLIENT_ID + assert params["client"]["mode"] == DEFAULT_GATEWAY_CLIENT_MODE + assert params["device"] == expected_device_payload assert "auth" not in params + assert captured["client_id"] == DEFAULT_GATEWAY_CLIENT_ID + assert captured["client_mode"] == DEFAULT_GATEWAY_CLIENT_MODE + assert captured["role"] == "operator" + assert captured["scopes"] == list(GATEWAY_OPERATOR_SCOPES) + assert captured["auth_token"] is None + assert captured["connect_nonce"] is None -def test_build_connect_params_includes_auth_token_when_provided() -> None: +def test_build_connect_params_uses_control_ui_when_pairing_disabled() -> None: params = _build_connect_params( - GatewayConfig(url="ws://gateway.example/ws", token="secret-token"), + GatewayConfig( + url="ws://gateway.example/ws", + token="secret-token", + disable_device_pairing=True, + ), ) assert params["auth"] == {"token": "secret-token"} assert params["scopes"] == list(GATEWAY_OPERATOR_SCOPES) + assert params["client"]["id"] == CONTROL_UI_CLIENT_ID + assert params["client"]["mode"] == CONTROL_UI_CLIENT_MODE + assert "device" not in params + + +def test_build_connect_params_passes_nonce_to_device_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + def _fake_build_device_connect_payload( + *, + client_id: str, + client_mode: str, + role: str, + scopes: list[str], + auth_token: str | None, + connect_nonce: str | None, + ) -> dict[str, object]: + captured["client_id"] = client_id + captured["client_mode"] = client_mode + captured["role"] = role + captured["scopes"] = scopes + captured["auth_token"] = auth_token + captured["connect_nonce"] = connect_nonce + return {"id": "device-id", "nonce": connect_nonce} + + monkeypatch.setattr( + gateway_rpc, + "_build_device_connect_payload", + _fake_build_device_connect_payload, + ) + + params = _build_connect_params( + GatewayConfig(url="ws://gateway.example/ws", token="secret-token"), + connect_nonce="nonce-xyz", + ) + + assert params["auth"] == {"token": "secret-token"} + assert params["client"]["id"] == DEFAULT_GATEWAY_CLIENT_ID + assert params["client"]["mode"] == DEFAULT_GATEWAY_CLIENT_MODE + assert params["device"] == {"id": "device-id", "nonce": "nonce-xyz"} + assert captured["client_id"] == DEFAULT_GATEWAY_CLIENT_ID + assert captured["client_mode"] == DEFAULT_GATEWAY_CLIENT_MODE + assert captured["role"] == "operator" + assert captured["scopes"] == list(GATEWAY_OPERATOR_SCOPES) + assert captured["auth_token"] == "secret-token" + assert captured["connect_nonce"] == "nonce-xyz" + + +@pytest.mark.parametrize( + ("gateway_url", "expected_origin"), + [ + ("ws://gateway.example/ws", "http://gateway.example"), + ("wss://gateway.example/ws", "https://gateway.example"), + ("ws://gateway.example:8080/ws", "http://gateway.example:8080"), + ("wss://gateway.example:8443/ws", "https://gateway.example:8443"), + ("ws://[::1]:8000/ws", "http://[::1]:8000"), + ], +) +def test_build_control_ui_origin(gateway_url: str, expected_origin: str) -> None: + assert _build_control_ui_origin(gateway_url) == expected_origin + + +@pytest.mark.asyncio +async def test_openclaw_call_uses_single_connect_attempt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + call_count = 0 + + async def _fake_call_once( + method: str, + params: dict[str, object] | None, + *, + config: GatewayConfig, + gateway_url: str, + ) -> object: + nonlocal call_count + del method, params, config, gateway_url + call_count += 1 + return {"ok": True} + + monkeypatch.setattr(gateway_rpc, "_openclaw_call_once", _fake_call_once) + + payload = await openclaw_call( + "status", + config=GatewayConfig(url="ws://gateway.example/ws"), + ) + + assert payload == {"ok": True} + assert call_count == 1 + + +@pytest.mark.asyncio +async def test_openclaw_call_surfaces_scope_error_without_device_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_call_once( + method: str, + params: dict[str, object] | None, + *, + config: GatewayConfig, + gateway_url: str, + ) -> object: + del method, params, config, gateway_url + raise OpenClawGatewayError("missing scope: operator.read") + + monkeypatch.setattr(gateway_rpc, "_openclaw_call_once", _fake_call_once) + + with pytest.raises(OpenClawGatewayError, match="missing scope: operator.read"): + await openclaw_call( + "status", + config=GatewayConfig(url="ws://gateway.example/ws", token="secret-token"), + ) diff --git a/backend/tests/test_gateway_version_compat.py b/backend/tests/test_gateway_version_compat.py index c6e6b4d3..1de0da6d 100644 --- a/backend/tests/test_gateway_version_compat.py +++ b/backend/tests/test_gateway_version_compat.py @@ -200,6 +200,24 @@ async def test_admin_service_maps_gateway_transport_errors( assert "compatibility check failed" in str(exc_info.value.detail).lower() +@pytest.mark.asyncio +async def test_admin_service_maps_gateway_scope_errors_with_guidance( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object: + _ = (config, minimum_version) + raise OpenClawGatewayError("missing scope: operator.read") + + 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 "missing required scope `operator.read`" in str(exc_info.value.detail) + + @pytest.mark.asyncio async def test_gateway_status_reports_incompatible_version( monkeypatch: pytest.MonkeyPatch, @@ -226,6 +244,28 @@ async def test_gateway_status_reports_incompatible_version( assert response.error == "Gateway version 2026.1.0 is not supported." +@pytest.mark.asyncio +async def test_gateway_status_surfaces_scope_error_guidance( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object: + _ = (config, minimum_version) + raise OpenClawGatewayError("missing scope: operator.read") + + 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 is not None + assert "missing required scope `operator.read`" in response.error + + @pytest.mark.asyncio async def test_gateway_status_returns_sessions_when_version_compatible( monkeypatch: pytest.MonkeyPatch, diff --git a/backend/uv.lock b/backend/uv.lock index c01cab38..8b82574f 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -292,12 +292,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/4b/290b4c3efd6417a8b0c284896de19b1d5855e6dbdb97d2a35e68fa42de85/croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368", size = 25468, upload-time = "2024-12-17T17:17:45.359Z" }, ] -[[package]] -name = "crontab" -version = "1.0.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/36/a255b6f5a2e22df03fd2b2f3088974b44b8c9e9407e26b44742cb7cfbf5b/crontab-1.0.5.tar.gz", hash = "sha256:f80e01b4f07219763a9869f926dd17147278e7965a928089bca6d3dc80ae46d5", size = 21963, upload-time = "2025-07-09T17:09:38.264Z" } - [[package]] name = "cryptography" version = "45.0.7" @@ -377,18 +371,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, ] -[[package]] -name = "freezegun" -version = "1.5.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" }, -] - [[package]] name = "greenlet" version = "3.3.1" @@ -722,6 +704,7 @@ source = { virtual = "." } dependencies = [ { name = "alembic" }, { name = "clerk-backend-api" }, + { name = "cryptography" }, { name = "fastapi" }, { name = "fastapi-pagination" }, { name = "jinja2" }, @@ -759,6 +742,7 @@ requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = "==26.1.0" }, { name = "clerk-backend-api", specifier = "==4.2.0" }, { name = "coverage", extras = ["toml"], marker = "extra == 'dev'", specifier = "==7.13.4" }, + { name = "cryptography", specifier = "==45.0.7" }, { name = "fastapi", specifier = "==0.128.6" }, { name = "fastapi-pagination", specifier = "==0.15.10" }, { name = "flake8", marker = "extra == 'dev'", specifier = "==7.3.0" }, diff --git a/docs/openclaw_baseline_config.md b/docs/openclaw_baseline_config.md index daa0c746..4c28a3d1 100644 --- a/docs/openclaw_baseline_config.md +++ b/docs/openclaw_baseline_config.md @@ -479,6 +479,9 @@ When adding a gateway in Mission Control: - URL: `ws://127.0.0.1:18789` (or your host/IP with explicit port) - Token: provide only if your gateway requires token auth +- Device pairing: enabled by default and recommended + - Keep pairing enabled for normal operation. + - Optional bypass: enable `Disable device pairing` per gateway only when the gateway is explicitly configured for control UI auth bypass (for example `gateway.controlUi.dangerouslyDisableDeviceAuth: true` plus appropriate `gateway.controlUi.allowedOrigins`). - Workspace root (in Mission Control gateway config): align with `agents.defaults.workspace` when possible ## Security Notes diff --git a/frontend/src/api/generated/agent/agent.ts b/frontend/src/api/generated/agent/agent.ts index dbd5bfba..66627b96 100644 --- a/frontend/src/api/generated/agent/agent.ts +++ b/frontend/src/api/generated/agent/agent.ts @@ -22,7 +22,7 @@ import type { import type { AgentCreate, - AgentHeartbeatCreate, + AgentHealthStatusResponse, AgentNudge, AgentRead, ApprovalCreate, @@ -67,6 +67,192 @@ import { customFetch } from "../../mutator"; type SecondParameter unknown> = Parameters[1]; +/** + * Token-authenticated liveness probe for agent API clients. + +Use this endpoint when the caller needs to verify both service availability and agent-token validity in one request. + * @summary Agent Auth Health Check + */ +export type agentHealthzApiV1AgentHealthzGetResponse200 = { + data: AgentHealthStatusResponse; + status: 200; +}; + +export type agentHealthzApiV1AgentHealthzGetResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type agentHealthzApiV1AgentHealthzGetResponseSuccess = + agentHealthzApiV1AgentHealthzGetResponse200 & { + headers: Headers; + }; +export type agentHealthzApiV1AgentHealthzGetResponseError = + agentHealthzApiV1AgentHealthzGetResponse422 & { + headers: Headers; + }; + +export type agentHealthzApiV1AgentHealthzGetResponse = + | agentHealthzApiV1AgentHealthzGetResponseSuccess + | agentHealthzApiV1AgentHealthzGetResponseError; + +export const getAgentHealthzApiV1AgentHealthzGetUrl = () => { + return `/api/v1/agent/healthz`; +}; + +export const agentHealthzApiV1AgentHealthzGet = async ( + options?: RequestInit, +): Promise => { + return customFetch( + getAgentHealthzApiV1AgentHealthzGetUrl(), + { + ...options, + method: "GET", + }, + ); +}; + +export const getAgentHealthzApiV1AgentHealthzGetQueryKey = () => { + return [`/api/v1/agent/healthz`] as const; +}; + +export const getAgentHealthzApiV1AgentHealthzGetQueryOptions = < + TData = Awaited>, + TError = HTTPValidationError, +>(options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getAgentHealthzApiV1AgentHealthzGetQueryKey(); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + agentHealthzApiV1AgentHealthzGet({ signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type AgentHealthzApiV1AgentHealthzGetQueryResult = NonNullable< + Awaited> +>; +export type AgentHealthzApiV1AgentHealthzGetQueryError = HTTPValidationError; + +export function useAgentHealthzApiV1AgentHealthzGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useAgentHealthzApiV1AgentHealthzGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useAgentHealthzApiV1AgentHealthzGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Agent Auth Health Check + */ + +export function useAgentHealthzApiV1AgentHealthzGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getAgentHealthzApiV1AgentHealthzGetQueryOptions(options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + /** * Return boards the authenticated agent can access. @@ -3326,9 +3512,9 @@ export const useAgentLeadNudgeAgent = < ); }; /** - * Record liveness for the authenticated agent's current status. + * Record liveness for the authenticated agent. -Use this when the agent heartbeat loop reports status changes. +Use this when the agent heartbeat loop checks in. * @summary Upsert agent heartbeat */ export type agentHeartbeatApiV1AgentHeartbeatPostResponse200 = { @@ -3359,7 +3545,6 @@ export const getAgentHeartbeatApiV1AgentHeartbeatPostUrl = () => { }; export const agentHeartbeatApiV1AgentHeartbeatPost = async ( - agentHeartbeatCreate: AgentHeartbeatCreate, options?: RequestInit, ): Promise => { return customFetch( @@ -3367,8 +3552,6 @@ export const agentHeartbeatApiV1AgentHeartbeatPost = async ( { ...options, method: "POST", - headers: { "Content-Type": "application/json", ...options?.headers }, - body: JSON.stringify(agentHeartbeatCreate), }, ); }; @@ -3380,14 +3563,14 @@ export const getAgentHeartbeatApiV1AgentHeartbeatPostMutationOptions = < mutation?: UseMutationOptions< Awaited>, TError, - { data: AgentHeartbeatCreate }, + void, TContext >; request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, - { data: AgentHeartbeatCreate }, + void, TContext > => { const mutationKey = ["agentHeartbeatApiV1AgentHeartbeatPost"]; @@ -3401,11 +3584,9 @@ export const getAgentHeartbeatApiV1AgentHeartbeatPostMutationOptions = < const mutationFn: MutationFunction< Awaited>, - { data: AgentHeartbeatCreate } - > = (props) => { - const { data } = props ?? {}; - - return agentHeartbeatApiV1AgentHeartbeatPost(data, requestOptions); + void + > = () => { + return agentHeartbeatApiV1AgentHeartbeatPost(requestOptions); }; return { mutationFn, ...mutationOptions }; @@ -3414,8 +3595,7 @@ export const getAgentHeartbeatApiV1AgentHeartbeatPostMutationOptions = < export type AgentHeartbeatApiV1AgentHeartbeatPostMutationResult = NonNullable< Awaited> >; -export type AgentHeartbeatApiV1AgentHeartbeatPostMutationBody = - AgentHeartbeatCreate; + export type AgentHeartbeatApiV1AgentHeartbeatPostMutationError = HTTPValidationError; @@ -3430,7 +3610,7 @@ export const useAgentHeartbeatApiV1AgentHeartbeatPost = < mutation?: UseMutationOptions< Awaited>, TError, - { data: AgentHeartbeatCreate }, + void, TContext >; request?: SecondParameter; @@ -3439,7 +3619,7 @@ export const useAgentHeartbeatApiV1AgentHeartbeatPost = < ): UseMutationResult< Awaited>, TError, - { data: AgentHeartbeatCreate }, + void, TContext > => { return useMutation( diff --git a/frontend/src/api/generated/model/agentHealthStatusResponse.ts b/frontend/src/api/generated/model/agentHealthStatusResponse.ts new file mode 100644 index 00000000..067846b2 --- /dev/null +++ b/frontend/src/api/generated/model/agentHealthStatusResponse.ts @@ -0,0 +1,24 @@ +/** + * Generated by orval v8.3.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +/** + * Agent-authenticated liveness payload for agent route probes. + */ +export interface AgentHealthStatusResponse { + /** Indicates whether the probe check succeeded. */ + ok: boolean; + /** Authenticated agent id derived from `X-Agent-Token`. */ + agent_id: string; + /** Board scope for the authenticated agent, when applicable. */ + board_id?: string | null; + /** Gateway owning the authenticated agent. */ + gateway_id: string; + /** Current persisted lifecycle status for the authenticated agent. */ + status: string; + /** Whether the authenticated agent is the board lead. */ + is_board_lead: boolean; +} diff --git a/frontend/src/api/generated/model/gatewayCreate.ts b/frontend/src/api/generated/model/gatewayCreate.ts index 0fff7e4a..1fa81eb8 100644 --- a/frontend/src/api/generated/model/gatewayCreate.ts +++ b/frontend/src/api/generated/model/gatewayCreate.ts @@ -12,5 +12,6 @@ export interface GatewayCreate { name: string; url: string; workspace_root: string; + disable_device_pairing?: boolean; token?: string | null; } diff --git a/frontend/src/api/generated/model/gatewayRead.ts b/frontend/src/api/generated/model/gatewayRead.ts index 03dcc40c..58528e69 100644 --- a/frontend/src/api/generated/model/gatewayRead.ts +++ b/frontend/src/api/generated/model/gatewayRead.ts @@ -12,6 +12,7 @@ export interface GatewayRead { name: string; url: string; workspace_root: string; + disable_device_pairing?: boolean; id: string; organization_id: string; token?: string | null; diff --git a/frontend/src/api/generated/model/gatewayUpdate.ts b/frontend/src/api/generated/model/gatewayUpdate.ts index e5f237ef..75542c7a 100644 --- a/frontend/src/api/generated/model/gatewayUpdate.ts +++ b/frontend/src/api/generated/model/gatewayUpdate.ts @@ -13,4 +13,5 @@ export interface GatewayUpdate { url?: string | null; token?: string | null; workspace_root?: string | null; + disable_device_pairing?: boolean | null; } diff --git a/frontend/src/api/generated/model/gatewaysStatusApiV1GatewaysStatusGetParams.ts b/frontend/src/api/generated/model/gatewaysStatusApiV1GatewaysStatusGetParams.ts index 1c4bc7ce..21de85f1 100644 --- a/frontend/src/api/generated/model/gatewaysStatusApiV1GatewaysStatusGetParams.ts +++ b/frontend/src/api/generated/model/gatewaysStatusApiV1GatewaysStatusGetParams.ts @@ -9,4 +9,5 @@ export type GatewaysStatusApiV1GatewaysStatusGetParams = { board_id?: string | null; gateway_url?: string | null; gateway_token?: string | null; + gateway_disable_device_pairing?: boolean; }; diff --git a/frontend/src/api/generated/model/index.ts b/frontend/src/api/generated/model/index.ts index b2357468..a6b8ec2d 100644 --- a/frontend/src/api/generated/model/index.ts +++ b/frontend/src/api/generated/model/index.ts @@ -10,6 +10,7 @@ export * from "./activityTaskCommentFeedItemRead"; export * from "./agentCreate"; export * from "./agentCreateHeartbeatConfig"; export * from "./agentCreateIdentityProfile"; +export * from "./agentHealthStatusResponse"; export * from "./agentHeartbeat"; export * from "./agentHeartbeatCreate"; export * from "./agentNudge"; diff --git a/frontend/src/app/gateways/[gatewayId]/edit/page.tsx b/frontend/src/app/gateways/[gatewayId]/edit/page.tsx index 6b4e2120..0f461882 100644 --- a/frontend/src/app/gateways/[gatewayId]/edit/page.tsx +++ b/frontend/src/app/gateways/[gatewayId]/edit/page.tsx @@ -40,6 +40,9 @@ export default function EditGatewayPage() { const [gatewayToken, setGatewayToken] = useState( undefined, ); + const [disableDevicePairing, setDisableDevicePairing] = useState< + boolean | undefined + >(undefined); const [workspaceRoot, setWorkspaceRoot] = useState( undefined, ); @@ -82,38 +85,23 @@ export default function EditGatewayPage() { const resolvedName = name ?? loadedGateway?.name ?? ""; const resolvedGatewayUrl = gatewayUrl ?? loadedGateway?.url ?? ""; const resolvedGatewayToken = gatewayToken ?? loadedGateway?.token ?? ""; + const resolvedDisableDevicePairing = + disableDevicePairing ?? loadedGateway?.disable_device_pairing ?? false; const resolvedWorkspaceRoot = workspaceRoot ?? loadedGateway?.workspace_root ?? DEFAULT_WORKSPACE_ROOT; - const isLoading = gatewayQuery.isLoading || updateMutation.isPending; + const isLoading = + gatewayQuery.isLoading || + updateMutation.isPending || + gatewayCheckStatus === "checking"; const errorMessage = error ?? gatewayQuery.error?.message ?? null; const canSubmit = Boolean(resolvedName.trim()) && Boolean(resolvedGatewayUrl.trim()) && - Boolean(resolvedWorkspaceRoot.trim()) && - gatewayCheckStatus === "success"; + Boolean(resolvedWorkspaceRoot.trim()); - const runGatewayCheck = async () => { - const validationError = validateGatewayUrl(resolvedGatewayUrl); - setGatewayUrlError(validationError); - if (validationError) { - setGatewayCheckStatus("error"); - setGatewayCheckMessage(validationError); - return; - } - if (!isSignedIn) return; - setGatewayCheckStatus("checking"); - setGatewayCheckMessage(null); - const { ok, message } = await checkGatewayConnection({ - gatewayUrl: resolvedGatewayUrl, - gatewayToken: resolvedGatewayToken, - }); - setGatewayCheckStatus(ok ? "success" : "error"); - setGatewayCheckMessage(message); - }; - - const handleSubmit = (event: React.FormEvent) => { + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!isSignedIn || !gatewayId) return; @@ -133,12 +121,26 @@ export default function EditGatewayPage() { return; } + setGatewayCheckStatus("checking"); + setGatewayCheckMessage(null); + const { ok, message } = await checkGatewayConnection({ + gatewayUrl: resolvedGatewayUrl, + gatewayToken: resolvedGatewayToken, + gatewayDisableDevicePairing: resolvedDisableDevicePairing, + }); + setGatewayCheckStatus(ok ? "success" : "error"); + setGatewayCheckMessage(message); + if (!ok) { + return; + } + setError(null); const payload: GatewayUpdate = { name: resolvedName.trim(), url: resolvedGatewayUrl.trim(), token: resolvedGatewayToken.trim() || null, + disable_device_pairing: resolvedDisableDevicePairing, workspace_root: resolvedWorkspaceRoot.trim(), }; @@ -164,6 +166,7 @@ export default function EditGatewayPage() { name={resolvedName} gatewayUrl={resolvedGatewayUrl} gatewayToken={resolvedGatewayToken} + disableDevicePairing={resolvedDisableDevicePairing} workspaceRoot={resolvedWorkspaceRoot} gatewayUrlError={gatewayUrlError} gatewayCheckStatus={gatewayCheckStatus} @@ -177,7 +180,6 @@ export default function EditGatewayPage() { submitBusyLabel="Saving…" onSubmit={handleSubmit} onCancel={() => router.push("/gateways")} - onRunGatewayCheck={runGatewayCheck} onNameChange={setName} onGatewayUrlChange={(next) => { setGatewayUrl(next); @@ -190,6 +192,11 @@ export default function EditGatewayPage() { setGatewayCheckStatus("idle"); setGatewayCheckMessage(null); }} + onDisableDevicePairingChange={(next) => { + setDisableDevicePairing(next); + setGatewayCheckStatus("idle"); + setGatewayCheckMessage(null); + }} onWorkspaceRootChange={setWorkspaceRoot} /> diff --git a/frontend/src/app/gateways/[gatewayId]/page.tsx b/frontend/src/app/gateways/[gatewayId]/page.tsx index b3f4a94e..059d3963 100644 --- a/frontend/src/app/gateways/[gatewayId]/page.tsx +++ b/frontend/src/app/gateways/[gatewayId]/page.tsx @@ -115,6 +115,7 @@ export default function GatewayDetailPage() { ? { gateway_url: gateway.url, gateway_token: gateway.token ?? undefined, + gateway_disable_device_pairing: gateway.disable_device_pairing, } : {}; @@ -232,6 +233,14 @@ export default function GatewayDetailPage() { {maskToken(gateway.token)}

+
+

+ Device pairing +

+

+ {gateway.disable_device_pairing ? "Disabled" : "Required"} +

+
diff --git a/frontend/src/app/gateways/new/page.tsx b/frontend/src/app/gateways/new/page.tsx index f72db43e..e3463777 100644 --- a/frontend/src/app/gateways/new/page.tsx +++ b/frontend/src/app/gateways/new/page.tsx @@ -28,6 +28,7 @@ export default function NewGatewayPage() { const [name, setName] = useState(""); const [gatewayUrl, setGatewayUrl] = useState(""); const [gatewayToken, setGatewayToken] = useState(""); + const [disableDevicePairing, setDisableDevicePairing] = useState(false); const [workspaceRoot, setWorkspaceRoot] = useState(DEFAULT_WORKSPACE_ROOT); const [gatewayUrlError, setGatewayUrlError] = useState(null); @@ -52,34 +53,15 @@ export default function NewGatewayPage() { }, }); - const isLoading = createMutation.isPending; + const isLoading = + createMutation.isPending || gatewayCheckStatus === "checking"; const canSubmit = Boolean(name.trim()) && Boolean(gatewayUrl.trim()) && - Boolean(workspaceRoot.trim()) && - gatewayCheckStatus === "success"; + Boolean(workspaceRoot.trim()); - const runGatewayCheck = async () => { - const validationError = validateGatewayUrl(gatewayUrl); - setGatewayUrlError(validationError); - if (validationError) { - setGatewayCheckStatus("error"); - setGatewayCheckMessage(validationError); - return; - } - if (!isSignedIn) return; - setGatewayCheckStatus("checking"); - setGatewayCheckMessage(null); - const { ok, message } = await checkGatewayConnection({ - gatewayUrl, - gatewayToken, - }); - setGatewayCheckStatus(ok ? "success" : "error"); - setGatewayCheckMessage(message); - }; - - const handleSubmit = (event: React.FormEvent) => { + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!isSignedIn) return; @@ -99,12 +81,26 @@ export default function NewGatewayPage() { return; } + setGatewayCheckStatus("checking"); + setGatewayCheckMessage(null); + const { ok, message } = await checkGatewayConnection({ + gatewayUrl, + gatewayToken, + gatewayDisableDevicePairing: disableDevicePairing, + }); + setGatewayCheckStatus(ok ? "success" : "error"); + setGatewayCheckMessage(message); + if (!ok) { + return; + } + setError(null); createMutation.mutate({ data: { name: name.trim(), url: gatewayUrl.trim(), token: gatewayToken.trim() || null, + disable_device_pairing: disableDevicePairing, workspace_root: workspaceRoot.trim(), }, }); @@ -125,6 +121,7 @@ export default function NewGatewayPage() { name={name} gatewayUrl={gatewayUrl} gatewayToken={gatewayToken} + disableDevicePairing={disableDevicePairing} workspaceRoot={workspaceRoot} gatewayUrlError={gatewayUrlError} gatewayCheckStatus={gatewayCheckStatus} @@ -138,7 +135,6 @@ export default function NewGatewayPage() { submitBusyLabel="Creating…" onSubmit={handleSubmit} onCancel={() => router.push("/gateways")} - onRunGatewayCheck={runGatewayCheck} onNameChange={setName} onGatewayUrlChange={(next) => { setGatewayUrl(next); @@ -151,6 +147,11 @@ export default function NewGatewayPage() { setGatewayCheckStatus("idle"); setGatewayCheckMessage(null); }} + onDisableDevicePairingChange={(next) => { + setDisableDevicePairing(next); + setGatewayCheckStatus("idle"); + setGatewayCheckMessage(null); + }} onWorkspaceRootChange={setWorkspaceRoot} /> diff --git a/frontend/src/components/gateways/GatewayForm.tsx b/frontend/src/components/gateways/GatewayForm.tsx index f5068faf..79906817 100644 --- a/frontend/src/components/gateways/GatewayForm.tsx +++ b/frontend/src/components/gateways/GatewayForm.tsx @@ -1,5 +1,4 @@ import type { FormEvent } from "react"; -import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react"; import type { GatewayCheckStatus } from "@/lib/gateway-form"; import { Button } from "@/components/ui/button"; @@ -9,6 +8,7 @@ type GatewayFormProps = { name: string; gatewayUrl: string; gatewayToken: string; + disableDevicePairing: boolean; workspaceRoot: string; gatewayUrlError: string | null; gatewayCheckStatus: GatewayCheckStatus; @@ -22,10 +22,10 @@ type GatewayFormProps = { submitBusyLabel: string; onSubmit: (event: FormEvent) => void; onCancel: () => void; - onRunGatewayCheck: () => Promise; onNameChange: (next: string) => void; onGatewayUrlChange: (next: string) => void; onGatewayTokenChange: (next: string) => void; + onDisableDevicePairingChange: (next: boolean) => void; onWorkspaceRootChange: (next: string) => void; }; @@ -33,6 +33,7 @@ export function GatewayForm({ name, gatewayUrl, gatewayToken, + disableDevicePairing, workspaceRoot, gatewayUrlError, gatewayCheckStatus, @@ -46,10 +47,10 @@ export function GatewayForm({ submitBusyLabel, onSubmit, onCancel, - onRunGatewayCheck, onNameChange, onGatewayUrlChange, onGatewayTokenChange, + onDisableDevicePairingChange, onWorkspaceRootChange, }: GatewayFormProps) { return ( @@ -78,40 +79,15 @@ export function GatewayForm({ onGatewayUrlChange(event.target.value)} - onBlur={onRunGatewayCheck} placeholder="ws://gateway:18789" disabled={isLoading} className={gatewayUrlError ? "border-red-500" : undefined} /> - {gatewayUrlError ? (

{gatewayUrlError}

- ) : gatewayCheckMessage ? ( -

- {gatewayCheckMessage} -

+ ) : gatewayCheckStatus === "error" && gatewayCheckMessage ? ( +

{gatewayCheckMessage}

) : null}
@@ -121,23 +97,51 @@ export function GatewayForm({ onGatewayTokenChange(event.target.value)} - onBlur={onRunGatewayCheck} placeholder="Bearer token" disabled={isLoading} />
-
- - onWorkspaceRootChange(event.target.value)} - placeholder={workspaceRootPlaceholder} - disabled={isLoading} - /> +
+
+ + onWorkspaceRootChange(event.target.value)} + placeholder={workspaceRootPlaceholder} + disabled={isLoading} + /> +
+ +
+ + +
{errorMessage ? ( diff --git a/frontend/src/lib/gateway-form.test.ts b/frontend/src/lib/gateway-form.test.ts new file mode 100644 index 00000000..059174d9 --- /dev/null +++ b/frontend/src/lib/gateway-form.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { gatewaysStatusApiV1GatewaysStatusGet } from "@/api/generated/gateways/gateways"; + +import { checkGatewayConnection, validateGatewayUrl } from "./gateway-form"; + +vi.mock("@/api/generated/gateways/gateways", () => ({ + gatewaysStatusApiV1GatewaysStatusGet: vi.fn(), +})); + +const mockedGatewaysStatusApiV1GatewaysStatusGet = vi.mocked( + gatewaysStatusApiV1GatewaysStatusGet, +); + +describe("validateGatewayUrl", () => { + it("requires ws/wss with an explicit port", () => { + expect(validateGatewayUrl("https://gateway.example")).toBe( + "Gateway URL must start with ws:// or wss://.", + ); + expect(validateGatewayUrl("ws://gateway.example")).toBe( + "Gateway URL must include an explicit port.", + ); + expect(validateGatewayUrl("ws://gateway.example:18789")).toBeNull(); + }); +}); + +describe("checkGatewayConnection", () => { + beforeEach(() => { + mockedGatewaysStatusApiV1GatewaysStatusGet.mockReset(); + }); + + it("passes pairing toggle to gateway status API", async () => { + mockedGatewaysStatusApiV1GatewaysStatusGet.mockResolvedValue({ + status: 200, + data: { connected: true }, + } as never); + + const result = await checkGatewayConnection({ + gatewayUrl: "ws://gateway.example:18789", + gatewayToken: "secret-token", + gatewayDisableDevicePairing: true, + }); + + expect(mockedGatewaysStatusApiV1GatewaysStatusGet).toHaveBeenCalledWith({ + gateway_url: "ws://gateway.example:18789", + gateway_token: "secret-token", + gateway_disable_device_pairing: true, + }); + expect(result).toEqual({ ok: true, message: "Gateway reachable." }); + }); + + it("returns gateway-provided error message when offline", async () => { + mockedGatewaysStatusApiV1GatewaysStatusGet.mockResolvedValue({ + status: 200, + data: { + connected: false, + error: "missing required scope", + }, + } as never); + + const result = await checkGatewayConnection({ + gatewayUrl: "ws://gateway.example:18789", + gatewayToken: "", + gatewayDisableDevicePairing: false, + }); + + expect(result).toEqual({ ok: false, message: "missing required scope" }); + }); +}); diff --git a/frontend/src/lib/gateway-form.ts b/frontend/src/lib/gateway-form.ts index 5dec8c54..3c9e1171 100644 --- a/frontend/src/lib/gateway-form.ts +++ b/frontend/src/lib/gateway-form.ts @@ -24,10 +24,16 @@ export const validateGatewayUrl = (value: string) => { export async function checkGatewayConnection(params: { gatewayUrl: string; gatewayToken: string; + gatewayDisableDevicePairing: boolean; }): Promise<{ ok: boolean; message: string }> { try { - const requestParams: Record = { + const requestParams: { + gateway_url: string; + gateway_token?: string; + gateway_disable_device_pairing: boolean; + } = { gateway_url: params.gatewayUrl.trim(), + gateway_disable_device_pairing: params.gatewayDisableDevicePairing, }; if (params.gatewayToken.trim()) { requestParams.gateway_token = params.gatewayToken.trim();