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();