feat: add disable_device_pairing option to gateway configuration
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
163
backend/app/services/openclaw/device_identity.py
Normal file
163
backend/app/services/openclaw/device_identity.py
Normal file
@@ -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)
|
||||
31
backend/app/services/openclaw/error_messages.py
Normal file
31
backend/app/services/openclaw/error_messages.py
Normal file
@@ -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<scope>[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
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
@@ -27,6 +27,7 @@ dependencies = [
|
||||
"websockets==16.0",
|
||||
"redis==6.3.0",
|
||||
"rq==2.6.0",
|
||||
"cryptography==45.0.7",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
67
backend/tests/test_gateway_device_identity.py
Normal file
67
backend/tests/test_gateway_device_identity.py
Normal file
@@ -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"))
|
||||
64
backend/tests/test_gateway_resolver.py
Normal file
64
backend/tests/test_gateway_resolver.py
Normal file
@@ -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
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
20
backend/uv.lock
generated
20
backend/uv.lock
generated
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
|
||||
import type {
|
||||
AgentCreate,
|
||||
AgentHeartbeatCreate,
|
||||
AgentHealthStatusResponse,
|
||||
AgentNudge,
|
||||
AgentRead,
|
||||
ApprovalCreate,
|
||||
@@ -67,6 +67,192 @@ import { customFetch } from "../../mutator";
|
||||
|
||||
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[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<agentHealthzApiV1AgentHealthzGetResponse> => {
|
||||
return customFetch<agentHealthzApiV1AgentHealthzGetResponse>(
|
||||
getAgentHealthzApiV1AgentHealthzGetUrl(),
|
||||
{
|
||||
...options,
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getAgentHealthzApiV1AgentHealthzGetQueryKey = () => {
|
||||
return [`/api/v1/agent/healthz`] as const;
|
||||
};
|
||||
|
||||
export const getAgentHealthzApiV1AgentHealthzGetQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError = HTTPValidationError,
|
||||
>(options?: {
|
||||
query?: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getAgentHealthzApiV1AgentHealthzGetQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>
|
||||
> = ({ signal }) =>
|
||||
agentHealthzApiV1AgentHealthzGet({ signal, ...requestOptions });
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
};
|
||||
|
||||
export type AgentHealthzApiV1AgentHealthzGetQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>
|
||||
>;
|
||||
export type AgentHealthzApiV1AgentHealthzGetQueryError = HTTPValidationError;
|
||||
|
||||
export function useAgentHealthzApiV1AgentHealthzGet<
|
||||
TData = Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError = HTTPValidationError,
|
||||
>(
|
||||
options: {
|
||||
query: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
> &
|
||||
Pick<
|
||||
DefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>
|
||||
>,
|
||||
"initialData"
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): DefinedUseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
};
|
||||
export function useAgentHealthzApiV1AgentHealthzGet<
|
||||
TData = Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError = HTTPValidationError,
|
||||
>(
|
||||
options?: {
|
||||
query?: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
> &
|
||||
Pick<
|
||||
UndefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>
|
||||
>,
|
||||
"initialData"
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
};
|
||||
export function useAgentHealthzApiV1AgentHealthzGet<
|
||||
TData = Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError = HTTPValidationError,
|
||||
>(
|
||||
options?: {
|
||||
query?: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
};
|
||||
/**
|
||||
* @summary Agent Auth Health Check
|
||||
*/
|
||||
|
||||
export function useAgentHealthzApiV1AgentHealthzGet<
|
||||
TData = Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError = HTTPValidationError,
|
||||
>(
|
||||
options?: {
|
||||
query?: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
} {
|
||||
const queryOptions = getAgentHealthzApiV1AgentHealthzGetQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
|
||||
TData,
|
||||
TError
|
||||
> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
|
||||
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<agentHeartbeatApiV1AgentHeartbeatPostResponse> => {
|
||||
return customFetch<agentHeartbeatApiV1AgentHeartbeatPostResponse>(
|
||||
@@ -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<ReturnType<typeof agentHeartbeatApiV1AgentHeartbeatPost>>,
|
||||
TError,
|
||||
{ data: AgentHeartbeatCreate },
|
||||
void,
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof agentHeartbeatApiV1AgentHeartbeatPost>>,
|
||||
TError,
|
||||
{ data: AgentHeartbeatCreate },
|
||||
void,
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["agentHeartbeatApiV1AgentHeartbeatPost"];
|
||||
@@ -3401,11 +3584,9 @@ export const getAgentHeartbeatApiV1AgentHeartbeatPostMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof agentHeartbeatApiV1AgentHeartbeatPost>>,
|
||||
{ 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<ReturnType<typeof agentHeartbeatApiV1AgentHeartbeatPost>>
|
||||
>;
|
||||
export type AgentHeartbeatApiV1AgentHeartbeatPostMutationBody =
|
||||
AgentHeartbeatCreate;
|
||||
|
||||
export type AgentHeartbeatApiV1AgentHeartbeatPostMutationError =
|
||||
HTTPValidationError;
|
||||
|
||||
@@ -3430,7 +3610,7 @@ export const useAgentHeartbeatApiV1AgentHeartbeatPost = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof agentHeartbeatApiV1AgentHeartbeatPost>>,
|
||||
TError,
|
||||
{ data: AgentHeartbeatCreate },
|
||||
void,
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
@@ -3439,7 +3619,7 @@ export const useAgentHeartbeatApiV1AgentHeartbeatPost = <
|
||||
): UseMutationResult<
|
||||
Awaited<ReturnType<typeof agentHeartbeatApiV1AgentHeartbeatPost>>,
|
||||
TError,
|
||||
{ data: AgentHeartbeatCreate },
|
||||
void,
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -12,5 +12,6 @@ export interface GatewayCreate {
|
||||
name: string;
|
||||
url: string;
|
||||
workspace_root: string;
|
||||
disable_device_pairing?: boolean;
|
||||
token?: string | null;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,4 +13,5 @@ export interface GatewayUpdate {
|
||||
url?: string | null;
|
||||
token?: string | null;
|
||||
workspace_root?: string | null;
|
||||
disable_device_pairing?: boolean | null;
|
||||
}
|
||||
|
||||
@@ -9,4 +9,5 @@ export type GatewaysStatusApiV1GatewaysStatusGetParams = {
|
||||
board_id?: string | null;
|
||||
gateway_url?: string | null;
|
||||
gateway_token?: string | null;
|
||||
gateway_disable_device_pairing?: boolean;
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -40,6 +40,9 @@ export default function EditGatewayPage() {
|
||||
const [gatewayToken, setGatewayToken] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [disableDevicePairing, setDisableDevicePairing] = useState<
|
||||
boolean | undefined
|
||||
>(undefined);
|
||||
const [workspaceRoot, setWorkspaceRoot] = useState<string | undefined>(
|
||||
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<HTMLFormElement>) => {
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
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}
|
||||
/>
|
||||
</DashboardPageLayout>
|
||||
|
||||
@@ -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)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-slate-400">
|
||||
Device pairing
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||
{gateway.disable_device_pairing ? "Disabled" : "Required"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<string | null>(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<HTMLFormElement>) => {
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
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}
|
||||
/>
|
||||
</DashboardPageLayout>
|
||||
|
||||
@@ -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<HTMLFormElement>) => void;
|
||||
onCancel: () => void;
|
||||
onRunGatewayCheck: () => Promise<void>;
|
||||
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({
|
||||
<Input
|
||||
value={gatewayUrl}
|
||||
onChange={(event) => onGatewayUrlChange(event.target.value)}
|
||||
onBlur={onRunGatewayCheck}
|
||||
placeholder="ws://gateway:18789"
|
||||
disabled={isLoading}
|
||||
className={gatewayUrlError ? "border-red-500" : undefined}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onRunGatewayCheck()}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
aria-label="Check gateway connection"
|
||||
>
|
||||
{gatewayCheckStatus === "checking" ? (
|
||||
<RefreshCcw className="h-4 w-4 animate-spin" />
|
||||
) : gatewayCheckStatus === "success" ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
) : gatewayCheckStatus === "error" ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{gatewayUrlError ? (
|
||||
<p className="text-xs text-red-500">{gatewayUrlError}</p>
|
||||
) : gatewayCheckMessage ? (
|
||||
<p
|
||||
className={
|
||||
gatewayCheckStatus === "success"
|
||||
? "text-xs text-emerald-600"
|
||||
: "text-xs text-red-500"
|
||||
}
|
||||
>
|
||||
{gatewayCheckMessage}
|
||||
</p>
|
||||
) : gatewayCheckStatus === "error" && gatewayCheckMessage ? (
|
||||
<p className="text-xs text-red-500">{gatewayCheckMessage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -121,23 +97,51 @@ export function GatewayForm({
|
||||
<Input
|
||||
value={gatewayToken}
|
||||
onChange={(event) => onGatewayTokenChange(event.target.value)}
|
||||
onBlur={onRunGatewayCheck}
|
||||
placeholder="Bearer token"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Workspace root <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={workspaceRoot}
|
||||
onChange={(event) => onWorkspaceRootChange(event.target.value)}
|
||||
placeholder={workspaceRootPlaceholder}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Workspace root <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={workspaceRoot}
|
||||
onChange={(event) => onWorkspaceRootChange(event.target.value)}
|
||||
placeholder={workspaceRootPlaceholder}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Disable device pairing
|
||||
</label>
|
||||
<label className="flex h-10 items-center gap-3 px-1 text-sm text-slate-900">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={disableDevicePairing}
|
||||
aria-label="Disable device pairing"
|
||||
onClick={() => onDisableDevicePairingChange(!disableDevicePairing)}
|
||||
disabled={isLoading}
|
||||
className={`inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
|
||||
disableDevicePairing
|
||||
? "border-emerald-600 bg-emerald-600"
|
||||
: "border-slate-300 bg-slate-200"
|
||||
} ${isLoading ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition ${
|
||||
disableDevicePairing ? "translate-x-5" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
|
||||
69
frontend/src/lib/gateway-form.test.ts
Normal file
69
frontend/src/lib/gateway-form.test.ts
Normal file
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user