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 files

OpenClaw 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 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,<1.3.0",
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