Spaces:
Running
Running
shaunx Claude Opus 4.6 commited on
Commit ·
7936d3a
1
Parent(s): 65b8ac0
fix: add device identity signing for OpenClaw >= 2026.3.10 compatibility
Browse filesOpenClaw 2026.3.10+ requires device identity in WebSocket connect
requests to preserve operator scopes. Without it, scopes are cleared
and chat.send fails with "missing scope: operator.write".
The bridge now loads the local device identity from
~/.openclaw/identity/device.json and signs connect requests using the
Ed25519 v3 payload format. Falls back gracefully when no identity exists.
Also removes the reachy_mini upper version pin (<1.3.0) and adds
cryptography as a dependency for Ed25519 signing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- pyproject.toml +4 -1
- src/reachy_mini_openclaw/openclaw_bridge.py +113 -13
pyproject.toml
CHANGED
|
@@ -45,6 +45,9 @@ dependencies = [
|
|
| 45 |
# OpenClaw gateway client (WebSocket protocol)
|
| 46 |
"websockets>=12.0",
|
| 47 |
|
|
|
|
|
|
|
|
|
|
| 48 |
# Gradio UI
|
| 49 |
"gradio>=4.0.0",
|
| 50 |
|
|
@@ -52,7 +55,7 @@ dependencies = [
|
|
| 52 |
"python-dotenv",
|
| 53 |
|
| 54 |
# Reachy Mini robot SDK
|
| 55 |
-
"reachy_mini>=1.2.13
|
| 56 |
]
|
| 57 |
|
| 58 |
[project.optional-dependencies]
|
|
|
|
| 45 |
# OpenClaw gateway client (WebSocket protocol)
|
| 46 |
"websockets>=12.0",
|
| 47 |
|
| 48 |
+
# Device identity signing for OpenClaw gateway auth
|
| 49 |
+
"cryptography>=41.0",
|
| 50 |
+
|
| 51 |
# Gradio UI
|
| 52 |
"gradio>=4.0.0",
|
| 53 |
|
|
|
|
| 55 |
"python-dotenv",
|
| 56 |
|
| 57 |
# Reachy Mini robot SDK
|
| 58 |
+
"reachy_mini>=1.2.13",
|
| 59 |
]
|
| 60 |
|
| 61 |
[project.optional-dependencies]
|
src/reachy_mini_openclaw/openclaw_bridge.py
CHANGED
|
@@ -11,6 +11,9 @@ import json
|
|
| 11 |
import asyncio
|
| 12 |
import logging
|
| 13 |
import uuid
|
|
|
|
|
|
|
|
|
|
| 14 |
from typing import Optional, Any, AsyncIterator
|
| 15 |
from dataclasses import dataclass
|
| 16 |
|
|
@@ -24,6 +27,72 @@ logger = logging.getLogger(__name__)
|
|
| 24 |
PROTOCOL_VERSION = 3
|
| 25 |
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
@dataclass
|
| 28 |
class OpenClawResponse:
|
| 29 |
"""Response from OpenClaw gateway."""
|
|
@@ -93,6 +162,9 @@ class OpenClawBridge:
|
|
| 93 |
or "main"
|
| 94 |
)
|
| 95 |
|
|
|
|
|
|
|
|
|
|
| 96 |
# Persistent WebSocket state
|
| 97 |
self._ws: Optional[websockets.WebSocketClientProtocol] = None
|
| 98 |
self._connected = False
|
|
@@ -148,25 +220,53 @@ class OpenClawBridge:
|
|
| 148 |
if challenge.get("event") != "connect.challenge":
|
| 149 |
logger.warning("Unexpected first frame: %s", challenge.get("event"))
|
| 150 |
|
|
|
|
|
|
|
| 151 |
# 2. Send connect request
|
| 152 |
req_id = str(uuid.uuid4())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
connect_req = {
|
| 154 |
"type": "req",
|
| 155 |
"id": req_id,
|
| 156 |
"method": "connect",
|
| 157 |
-
"params":
|
| 158 |
-
"minProtocol": PROTOCOL_VERSION,
|
| 159 |
-
"maxProtocol": PROTOCOL_VERSION,
|
| 160 |
-
"auth": {"token": self.gateway_token} if self.gateway_token else {},
|
| 161 |
-
"client": {
|
| 162 |
-
"id": "cli",
|
| 163 |
-
"version": "1.0.0",
|
| 164 |
-
"platform": "darwin",
|
| 165 |
-
"mode": "cli",
|
| 166 |
-
},
|
| 167 |
-
"role": "operator",
|
| 168 |
-
"scopes": ["chat", "operator.write", "operator.read"],
|
| 169 |
-
},
|
| 170 |
}
|
| 171 |
await self._ws.send(json.dumps(connect_req))
|
| 172 |
|
|
|
|
| 11 |
import asyncio
|
| 12 |
import logging
|
| 13 |
import uuid
|
| 14 |
+
import base64
|
| 15 |
+
import time
|
| 16 |
+
from pathlib import Path
|
| 17 |
from typing import Optional, Any, AsyncIterator
|
| 18 |
from dataclasses import dataclass
|
| 19 |
|
|
|
|
| 27 |
PROTOCOL_VERSION = 3
|
| 28 |
|
| 29 |
|
| 30 |
+
def _load_device_identity() -> Optional[dict]:
|
| 31 |
+
"""Load OpenClaw device identity from ~/.openclaw/identity/device.json."""
|
| 32 |
+
device_path = Path.home() / ".openclaw" / "identity" / "device.json"
|
| 33 |
+
if not device_path.exists():
|
| 34 |
+
logger.debug("No device identity at %s", device_path)
|
| 35 |
+
return None
|
| 36 |
+
try:
|
| 37 |
+
with open(device_path) as f:
|
| 38 |
+
data = json.load(f)
|
| 39 |
+
if data.get("deviceId") and data.get("privateKeyPem") and data.get("publicKeyPem"):
|
| 40 |
+
logger.info("Loaded device identity %s…", data["deviceId"][:16])
|
| 41 |
+
return data
|
| 42 |
+
except Exception as e:
|
| 43 |
+
logger.warning("Failed to load device identity: %s", e)
|
| 44 |
+
return None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _sign_device_connect(
|
| 48 |
+
identity: dict,
|
| 49 |
+
nonce: str,
|
| 50 |
+
role: str,
|
| 51 |
+
scopes: list[str],
|
| 52 |
+
token: Optional[str],
|
| 53 |
+
client_id: str,
|
| 54 |
+
client_mode: str,
|
| 55 |
+
platform: str,
|
| 56 |
+
device_family: str,
|
| 57 |
+
) -> dict:
|
| 58 |
+
"""Build a signed device identity block for the connect request.
|
| 59 |
+
|
| 60 |
+
Implements the v3 device auth payload used by OpenClaw >= 2026.3.10.
|
| 61 |
+
"""
|
| 62 |
+
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
| 63 |
+
|
| 64 |
+
signed_at_ms = int(time.time() * 1000)
|
| 65 |
+
|
| 66 |
+
# Build v3 payload: "v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily"
|
| 67 |
+
payload_parts = [
|
| 68 |
+
"v3",
|
| 69 |
+
identity["deviceId"],
|
| 70 |
+
client_id,
|
| 71 |
+
client_mode,
|
| 72 |
+
role,
|
| 73 |
+
",".join(scopes),
|
| 74 |
+
str(signed_at_ms),
|
| 75 |
+
token or "",
|
| 76 |
+
nonce,
|
| 77 |
+
platform.lower() if platform else "",
|
| 78 |
+
device_family.lower() if device_family else "",
|
| 79 |
+
]
|
| 80 |
+
payload = "|".join(payload_parts)
|
| 81 |
+
|
| 82 |
+
# Ed25519 sign
|
| 83 |
+
private_key = load_pem_private_key(identity["privateKeyPem"].encode(), password=None)
|
| 84 |
+
signature_bytes = private_key.sign(payload.encode("utf-8"))
|
| 85 |
+
signature_b64url = base64.urlsafe_b64encode(signature_bytes).rstrip(b"=").decode()
|
| 86 |
+
|
| 87 |
+
return {
|
| 88 |
+
"id": identity["deviceId"],
|
| 89 |
+
"publicKey": identity["publicKeyPem"],
|
| 90 |
+
"signature": signature_b64url,
|
| 91 |
+
"signedAt": signed_at_ms,
|
| 92 |
+
"nonce": nonce,
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
|
| 96 |
@dataclass
|
| 97 |
class OpenClawResponse:
|
| 98 |
"""Response from OpenClaw gateway."""
|
|
|
|
| 162 |
or "main"
|
| 163 |
)
|
| 164 |
|
| 165 |
+
# Device identity for gateway auth (OpenClaw >= 2026.3.10 requires this)
|
| 166 |
+
self._device_identity = _load_device_identity()
|
| 167 |
+
|
| 168 |
# Persistent WebSocket state
|
| 169 |
self._ws: Optional[websockets.WebSocketClientProtocol] = None
|
| 170 |
self._connected = False
|
|
|
|
| 220 |
if challenge.get("event") != "connect.challenge":
|
| 221 |
logger.warning("Unexpected first frame: %s", challenge.get("event"))
|
| 222 |
|
| 223 |
+
challenge_nonce = challenge.get("payload", {}).get("nonce", "")
|
| 224 |
+
|
| 225 |
# 2. Send connect request
|
| 226 |
req_id = str(uuid.uuid4())
|
| 227 |
+
role = "operator"
|
| 228 |
+
scopes = ["chat", "operator.write", "operator.read"]
|
| 229 |
+
client_id = "cli"
|
| 230 |
+
client_mode = "cli"
|
| 231 |
+
client_platform = "darwin"
|
| 232 |
+
|
| 233 |
+
params = {
|
| 234 |
+
"minProtocol": PROTOCOL_VERSION,
|
| 235 |
+
"maxProtocol": PROTOCOL_VERSION,
|
| 236 |
+
"auth": {"token": self.gateway_token} if self.gateway_token else {},
|
| 237 |
+
"client": {
|
| 238 |
+
"id": client_id,
|
| 239 |
+
"version": "1.0.0",
|
| 240 |
+
"platform": client_platform,
|
| 241 |
+
"mode": client_mode,
|
| 242 |
+
},
|
| 243 |
+
"role": role,
|
| 244 |
+
"scopes": scopes,
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
# Add signed device identity (required by OpenClaw >= 2026.3.10)
|
| 248 |
+
if self._device_identity and challenge_nonce:
|
| 249 |
+
try:
|
| 250 |
+
params["device"] = _sign_device_connect(
|
| 251 |
+
identity=self._device_identity,
|
| 252 |
+
nonce=challenge_nonce,
|
| 253 |
+
role=role,
|
| 254 |
+
scopes=scopes,
|
| 255 |
+
token=self.gateway_token,
|
| 256 |
+
client_id=client_id,
|
| 257 |
+
client_mode=client_mode,
|
| 258 |
+
platform=client_platform,
|
| 259 |
+
device_family="",
|
| 260 |
+
)
|
| 261 |
+
logger.info("Device identity attached to connect request")
|
| 262 |
+
except Exception as e:
|
| 263 |
+
logger.warning("Failed to sign device identity: %s", e)
|
| 264 |
+
|
| 265 |
connect_req = {
|
| 266 |
"type": "req",
|
| 267 |
"id": req_id,
|
| 268 |
"method": "connect",
|
| 269 |
+
"params": params,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
}
|
| 271 |
await self._ws.send(json.dumps(connect_req))
|
| 272 |
|