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" },
|
||||
|
||||
Reference in New Issue
Block a user