feat: add disable_device_pairing option to gateway configuration

This commit is contained in:
Abhimanyu Saharan
2026-02-22 19:19:26 +05:30
parent e39b2069fb
commit 3dfb70cd90
34 changed files with 1229 additions and 178 deletions

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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):

View File

@@ -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

View File

@@ -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(

View 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)

View 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

View File

@@ -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:

View File

@@ -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",

View File

@@ -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(

View File

@@ -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,

View File

@@ -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(

View File

@@ -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")

View File

@@ -27,6 +27,7 @@ dependencies = [
"websockets==16.0",
"redis==6.3.0",
"rq==2.6.0",
"cryptography==45.0.7",
]
[project.optional-dependencies]

View 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"))

View 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

View File

@@ -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"),
)

View File

@@ -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
View File

@@ -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" },

View File

@@ -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

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -12,5 +12,6 @@ export interface GatewayCreate {
name: string;
url: string;
workspace_root: string;
disable_device_pairing?: boolean;
token?: string | null;
}

View File

@@ -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;

View File

@@ -13,4 +13,5 @@ export interface GatewayUpdate {
url?: string | null;
token?: string | null;
workspace_root?: string | null;
disable_device_pairing?: boolean | null;
}

View File

@@ -9,4 +9,5 @@ export type GatewaysStatusApiV1GatewaysStatusGetParams = {
board_id?: string | null;
gateway_url?: string | null;
gateway_token?: string | null;
gateway_disable_device_pairing?: boolean;
};

View File

@@ -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";

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 ? (

View 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" });
});
});

View File

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