Merge branch 'master' into copilot/feature-allow-self-signed-tls
# Conflicts: # backend/app/api/gateways.py # backend/app/schemas/gateways.py # backend/app/services/openclaw/admin_service.py # backend/app/services/openclaw/gateway_resolver.py # backend/app/services/openclaw/gateway_rpc.py # backend/app/services/openclaw/provisioning.py # backend/app/services/openclaw/provisioning_db.py # frontend/src/api/generated/model/gatewayCreate.ts # frontend/src/api/generated/model/gatewayRead.ts # frontend/src/api/generated/model/gatewayUpdate.ts
This commit is contained in:
@@ -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
|
||||
@@ -168,7 +169,10 @@ class GatewayAdminLifecycleService(OpenClawDBService):
|
||||
if not gateway.url:
|
||||
return False
|
||||
config = GatewayClientConfig(
|
||||
url=gateway.url, token=gateway.token, allow_insecure_tls=gateway.allow_insecure_tls
|
||||
url=gateway.url,
|
||||
token=gateway.token,
|
||||
allow_insecure_tls=gateway.allow_insecure_tls,
|
||||
disable_device_pairing=gateway.disable_device_pairing,
|
||||
)
|
||||
target_id = GatewayAgentIdentity.openclaw_agent_id(gateway)
|
||||
try:
|
||||
@@ -181,16 +185,27 @@ class GatewayAdminLifecycleService(OpenClawDBService):
|
||||
return True
|
||||
|
||||
async def assert_gateway_runtime_compatible(
|
||||
self, *, url: str, token: str | None, allow_insecure_tls: bool = False
|
||||
self,
|
||||
*,
|
||||
url: str,
|
||||
token: str | None,
|
||||
allow_insecure_tls: bool = False,
|
||||
disable_device_pairing: bool = False,
|
||||
) -> None:
|
||||
"""Validate that a gateway runtime meets minimum supported version."""
|
||||
config = GatewayClientConfig(url=url, token=token, allow_insecure_tls=allow_insecure_tls)
|
||||
config = GatewayClientConfig(
|
||||
url=url,
|
||||
token=token,
|
||||
allow_insecure_tls=allow_insecure_tls,
|
||||
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(
|
||||
|
||||
167
backend/app/services/openclaw/device_identity.py
Normal file
167
backend/app/services/openclaw/device_identity.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""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
|
||||
@@ -7,7 +7,12 @@ from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.openclaw.gateway_rpc import GatewayConfig, OpenClawGatewayError, openclaw_call
|
||||
from app.services.openclaw.gateway_rpc import (
|
||||
GatewayConfig,
|
||||
OpenClawGatewayError,
|
||||
openclaw_call,
|
||||
openclaw_connect_metadata,
|
||||
)
|
||||
|
||||
_VERSION_PATTERN = re.compile(r"(?i)v?(?P<version>\d+(?:\.\d+)+)")
|
||||
_PRIMARY_VERSION_PATHS: tuple[tuple[str, ...], ...] = (
|
||||
@@ -192,12 +197,27 @@ async def _fetch_schema_metadata(config: GatewayConfig) -> object | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_connect_metadata(config: GatewayConfig) -> object | None:
|
||||
try:
|
||||
return await openclaw_connect_metadata(config=config)
|
||||
except OpenClawGatewayError:
|
||||
return None
|
||||
|
||||
|
||||
async def check_gateway_runtime_compatibility(
|
||||
config: GatewayConfig,
|
||||
*,
|
||||
minimum_version: str | None = None,
|
||||
) -> GatewayVersionCheckResult:
|
||||
"""Fetch runtime metadata and evaluate gateway version compatibility."""
|
||||
connect_payload = await _fetch_connect_metadata(config)
|
||||
current_version = extract_gateway_version(connect_payload)
|
||||
if current_version is not None:
|
||||
return evaluate_gateway_version(
|
||||
current_version=current_version,
|
||||
minimum_version=minimum_version,
|
||||
)
|
||||
|
||||
schema_payload = await _fetch_schema_metadata(config)
|
||||
current_version = extract_gateway_version(schema_payload)
|
||||
if current_version is not None:
|
||||
|
||||
@@ -33,7 +33,10 @@ def gateway_client_config(gateway: Gateway) -> GatewayClientConfig:
|
||||
)
|
||||
token = (gateway.token or "").strip() or None
|
||||
return GatewayClientConfig(
|
||||
url=url, token=token, allow_insecure_tls=gateway.allow_insecure_tls
|
||||
url=url,
|
||||
token=token,
|
||||
allow_insecure_tls=gateway.allow_insecure_tls,
|
||||
disable_device_pairing=gateway.disable_device_pairing,
|
||||
)
|
||||
|
||||
|
||||
@@ -46,7 +49,10 @@ def optional_gateway_client_config(gateway: Gateway | None) -> GatewayClientConf
|
||||
return None
|
||||
token = (gateway.token or "").strip() or None
|
||||
return GatewayClientConfig(
|
||||
url=url, token=token, allow_insecure_tls=gateway.allow_insecure_tls
|
||||
url=url,
|
||||
token=token,
|
||||
allow_insecure_tls=gateway.allow_insecure_tls,
|
||||
disable_device_pairing=gateway.disable_device_pairing,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import asyncio
|
||||
import json
|
||||
import ssl
|
||||
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
|
||||
|
||||
@@ -20,6 +20,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__)
|
||||
@@ -29,6 +35,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.
|
||||
@@ -162,6 +173,7 @@ class GatewayConfig:
|
||||
url: str
|
||||
token: str | None = None
|
||||
allow_insecure_tls: bool = False
|
||||
disable_device_pairing: bool = False
|
||||
|
||||
|
||||
def _build_gateway_url(config: GatewayConfig) -> str:
|
||||
@@ -183,24 +195,70 @@ def _redacted_url_for_log(raw_url: str) -> str:
|
||||
|
||||
|
||||
def _create_ssl_context(config: GatewayConfig) -> ssl.SSLContext | None:
|
||||
"""Create SSL context for websocket connection.
|
||||
|
||||
Returns None for non-SSL connections (ws://) or an SSL context for wss://.
|
||||
If allow_insecure_tls is True, the context will not verify certificates.
|
||||
"""
|
||||
"""Create an SSL context override when insecure TLS is explicitly enabled."""
|
||||
parsed = urlparse(config.url)
|
||||
if parsed.scheme != "wss":
|
||||
return None
|
||||
|
||||
if config.allow_insecure_tls:
|
||||
# Create SSL context that doesn't verify certificates
|
||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
return ssl_context
|
||||
|
||||
# Use default SSL context with certificate verification
|
||||
return None
|
||||
if not config.allow_insecure_tls:
|
||||
return None
|
||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
return ssl_context
|
||||
|
||||
|
||||
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(
|
||||
@@ -254,19 +312,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
|
||||
@@ -276,12 +351,19 @@ async def _ensure_connected(
|
||||
ws: websockets.ClientConnection,
|
||||
first_message: str | bytes | None,
|
||||
config: GatewayConfig,
|
||||
) -> None:
|
||||
) -> 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"),
|
||||
@@ -292,10 +374,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))
|
||||
await _await_response(ws, connect_id)
|
||||
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
|
||||
ssl_context = _create_ssl_context(config)
|
||||
connect_kwargs: dict[str, Any] = {"ping_interval": None}
|
||||
if origin is not None:
|
||||
connect_kwargs["origin"] = origin
|
||||
async with websockets.connect(gateway_url, ssl=ssl_context, **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
|
||||
ssl_context = _create_ssl_context(config)
|
||||
connect_kwargs: dict[str, Any] = {"ping_interval": None}
|
||||
if origin is not None:
|
||||
connect_kwargs["origin"] = origin
|
||||
async with websockets.connect(gateway_url, ssl=ssl_context, **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(
|
||||
@@ -306,31 +430,30 @@ async def openclaw_call(
|
||||
) -> object:
|
||||
"""Call a gateway RPC method and return the result payload."""
|
||||
gateway_url = _build_gateway_url(config)
|
||||
ssl_context = _create_ssl_context(config)
|
||||
started_at = perf_counter()
|
||||
logger.debug(
|
||||
"gateway.rpc.call.start method=%s gateway_url=%s allow_insecure_tls=%s",
|
||||
(
|
||||
"gateway.rpc.call.start method=%s gateway_url=%s allow_insecure_tls=%s "
|
||||
"disable_device_pairing=%s"
|
||||
),
|
||||
method,
|
||||
_redacted_url_for_log(gateway_url),
|
||||
config.allow_insecure_tls,
|
||||
config.disable_device_pairing,
|
||||
)
|
||||
try:
|
||||
async with websockets.connect(
|
||||
gateway_url, ping_interval=None, ssl=ssl_context
|
||||
) 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",
|
||||
@@ -354,6 +477,45 @@ async def openclaw_call(
|
||||
raise OpenClawGatewayError(str(exc)) from exc
|
||||
|
||||
|
||||
async def openclaw_connect_metadata(*, config: GatewayConfig) -> object:
|
||||
"""Open a gateway connection and return the connect/hello payload."""
|
||||
gateway_url = _build_gateway_url(config)
|
||||
started_at = perf_counter()
|
||||
logger.debug(
|
||||
"gateway.rpc.connect_metadata.start gateway_url=%s",
|
||||
_redacted_url_for_log(gateway_url),
|
||||
)
|
||||
try:
|
||||
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",
|
||||
int((perf_counter() - started_at) * 1000),
|
||||
)
|
||||
raise
|
||||
except (
|
||||
TimeoutError,
|
||||
ConnectionError,
|
||||
OSError,
|
||||
ValueError,
|
||||
WebSocketException,
|
||||
) as exc: # pragma: no cover - network/protocol errors
|
||||
logger.error(
|
||||
"gateway.rpc.connect_metadata.transport_error duration_ms=%s error_type=%s",
|
||||
int((perf_counter() - started_at) * 1000),
|
||||
exc.__class__.__name__,
|
||||
)
|
||||
raise OpenClawGatewayError(str(exc)) from exc
|
||||
|
||||
|
||||
async def send_message(
|
||||
message: str,
|
||||
*,
|
||||
|
||||
@@ -974,6 +974,7 @@ def _control_plane_for_gateway(gateway: Gateway) -> OpenClawGatewayControlPlane:
|
||||
url=gateway.url,
|
||||
token=gateway.token,
|
||||
allow_insecure_tls=gateway.allow_insecure_tls,
|
||||
disable_device_pairing=gateway.disable_device_pairing,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1104,7 +1105,10 @@ class OpenClawGatewayProvisioner:
|
||||
return
|
||||
|
||||
client_config = GatewayClientConfig(
|
||||
url=gateway.url, token=gateway.token, allow_insecure_tls=gateway.allow_insecure_tls
|
||||
url=gateway.url,
|
||||
token=gateway.token,
|
||||
allow_insecure_tls=gateway.allow_insecure_tls,
|
||||
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")
|
||||
|
||||
@@ -289,6 +289,7 @@ class OpenClawProvisioningService(OpenClawDBService):
|
||||
url=gateway.url,
|
||||
token=gateway.token,
|
||||
allow_insecure_tls=gateway.allow_insecure_tls,
|
||||
disable_device_pairing=gateway.disable_device_pairing,
|
||||
),
|
||||
)
|
||||
ctx = _SyncContext(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user