feat: support device identity pairing#2061
Conversation
PR Review: feat: support device identity pairingThis PR adds Ed25519-based device identity pairing to the OpenClaw gateway extension and a corresponding frontend dialog. The overall design is sound and the UX flow is well thought out. Here are my findings: Bugs1. Untracked In asyncio.create_task(
self._reemit_startup_pairing_notice(...) # in _check_gateway_ready_on_start
)
asyncio.create_task(self._send_connect_background()) # in _handle_messageCPython can garbage-collect tasks that have no strong references before they complete. The recommended pattern is to keep a set and discard completed tasks: task = asyncio.create_task(...)
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)2.
3. Stored
Security4. Private key stored unencrypted; silent chmod failure The identity file is written with try:
os.chmod(identity_path, 0o600)
except OSError as e:
self.ten_env.log_warn(f"[openclaw] could not restrict permissions on identity file: {e}")5. Token included in signature payload
Code Quality6. Text-based pairing detection is fragile The backend 7.
8. Synchronous file I/O in
9. Manifest type name: The new Missing TestsThere are no tests for:
Even a small unit-test file covering the pure functions would significantly increase confidence in the pairing flow. Minor
Overall: The feature is well-structured and the UX for guiding users through gateway pairing is clear. Addressing the task-reference bug (#1) and the text-based detection fragility (#6) would be the highest-priority fixes before merge. |
PR Review: feat: support device identity pairingOverall this is a well-structured feature. The Ed25519 identity approach, background-task lifecycle management, and frontend pairing dialog are all sensible. A few issues worth addressing before merge: Bugs1. Wrong field used for In details: dict[str, Any] = {}
if isinstance(exc, GatewayRequestError):
details = exc.details # nested payload dict
...
code = str(details.get("code", "")).strip().lower() # looks inside details
is_pairing_required = (
...
or code == "pairing_required"
)The error code is stored in code = (exc.code if isinstance(exc, GatewayRequestError) else "").lower()2. When startup fails with a pairing error, Security3. Silent failure on try:
os.chmod(identity_path, 0o600)
except OSError:
passIf this fails (e.g., on certain filesystems or container mounts), the private key file is silently left world-readable. At minimum log a warning: except OSError as e:
self.ten_env.log_warn(f"[openclaw] could not restrict identity file permissions: {e}")Schema / Type System4. The TEN Framework type system uses "pairing_required": { "type": "bool" },
"exit_after_notify": { "type": "bool" }This may cause runtime schema validation failures or silent ignoring of these fields. Should be Dead Code / Unreachable Logic5.
If the intent is to handle pairing messages that arrive via a different code path, document it. Otherwise this code is dead and adds noise. 6. The payload sets Code Quality / Minor7. Unused The new code in 8. No tests for cryptographic core
9. Dialog deduplication key uses timestamp const dialogKey = `${approveCmd}|${Number(text.ts ?? 0)}`;If Summary
|
PR Review: feat: support device identity pairingOverall this is a solid feature implementation. The Ed25519 device identity flow, challenge-nonce handshake, and frontend pairing dialog are well-structured. A few concrete issues worth addressing before merging. Bug:
|
PR Review: feat: support device identity pairingOverall this is a solid feature that adds Ed25519-based device identity and a clean pairing approval UX. The architecture is reasonable and the deduplication logic for pairing events is thoughtful. Below are issues ranging from security-critical to minor. Security[Critical] Race condition / TOCTOU in private key file creation In identity_path.write_text(json.dumps(payload, indent=2) + "\n")
try:
os.chmod(identity_path, 0o600)
except OSError:
passThe file is written with default permissions, then Suggested fix — write to a temp file with restricted permissions and atomically rename: import tempfile
tmp_fd, tmp_path = tempfile.mkstemp(dir=identity_path.parent)
try:
os.chmod(tmp_path, 0o600)
with os.fdopen(tmp_fd, "w") as f:
f.write(json.dumps(payload, indent=2) + "\n")
os.replace(tmp_path, identity_path)
except Exception:
os.unlink(tmp_path)
raise[Medium] Delimiter injection in The signature payload is assembled by joining fields with return "|".join([
"v2", device_id, client_id, client_mode, role,
",".join(scopes), str(signed_at_ms), token or "", nonce,
])If any field contains return json.dumps(
["v2", device_id, client_id, client_mode, role, scopes, signed_at_ms, token or "", nonce],
separators=(",", ":"), ensure_ascii=True,
)Design[Medium] Frontend should use the structured
export function isPairingRequiredMessage(text: string): boolean {
return text.toLowerCase().includes("pairing is required");
}But the backend already sets if ("pairing_required" in text && text.pairing_required) { ... }Text-based detection breaks silently if the backend message wording ever changes. The helpers in [Minor] Polling loop in The original await asyncio.wait_for(
self._hello_event.wait(),
timeout=max(self.config.connect_timeout_ms, 1000) / 1000,
)If the goal is to detect a closed socket mid-wait, having the recv loop cancel the future or set an error event would be cleaner. Compatibility[Minor] Python 3.10+ union syntax used throughout New code uses Code Quality[Minor] Re-emit interval is tightly coupled to the dedup window retry_interval_s = self._pairing_emit_dedupe_window_s + 1.0Deriving the retry interval from the dedup window is fragile. If [Minor] No user feedback on clipboard copy failure } catch (_error) {
setPairingCopied(false);
}When [Minor] The background retry loop re-emits pairing notices for a fixed number of attempts even if the user has already approved pairing and the agent reconnected. This can cause stale pairing banners to reappear. Consider checking Positive notes
🤖 Generated with Claude Code |
PR Review: feat: support device identity pairingOverall this is a well-structured addition. The Ed25519 device identity flow, background task lifecycle management, and deduplication logic are all solid. The comments below cover issues ranging from a design concern to minor nits. Security[Minor] Private key stored in plaintext PEM Potential Bugs[Bug] In self._connect_nonce = self._extract_connect_nonce(payload)
self._create_background_task(self._send_connect_background())If the gateway sends a second Safer: pass the nonce directly to the background task so each closure captures its own value: nonce = self._extract_connect_nonce(payload)
self._create_background_task(self._send_connect_background(nonce))Then update [Bug] Synchronous file I/O in
Design Concerns[Design] Frontend detects pairing by parsing human-readable text instead of using the structured flag The await self._send_rtm_message({
"data_type": "openclaw_result",
"text": self._build_pairing_required_message(payload),
"ts": ts,
})The frontend ( return text.toLowerCase().includes("pairing is required");This means the frontend depends on a specific phrase being present in human-readable text, creating an invisible coupling between The RTM message could carry the structured flag directly: await self._send_rtm_message({
"data_type": "openclaw_result",
"pairing_required": True,
"pairing_approve_cmd": payload.get("pairing_approve_cmd", ""),
"pairing_list_cmd": payload.get("pairing_list_cmd", ""),
"text": self._build_pairing_required_message(payload),
"ts": ts,
})The frontend would then check [Minor] Inconsistent hint text
"pairing_hint": "Run these commands on the gateway host, then restart this agent."But One says "restart this agent", the other says "retry your request". The latter is more accurate (no restart required), so the hint in Breaking Change[Note] Default The defaults changed from Minor / NitsFrontend clipboard failure is silent In
The polling loop sleeps up to 0.2s per iteration. This is fine, but the log on No tests added The Summary
The nonce ownership issue and the structured-vs-text detection design concern are the most important items to address before merge. |
PR Review: feat: support device identity pairingThe overall design is solid — Ed25519 device identity with gateway-challenge/nonce signing, a dedup-aware pairing event pipeline, and a frontend dialog to guide users through approval. Below are issues worth addressing before merge. Bugs1. Synchronous file I/O blocks the event loop in
# extension.py – on_start
self._device_identity = self._load_or_create_device_identity() # blocks event loop2. Silent When try:
os.chmod(identity_path, 0o600)
except OSError:
pass # No warning loggedThis should at minimum emit a 3. Dialog deduplication key is fragile when In const dialogKey = `${approveCmd}|${Number(text.ts ?? 0)}`;When 4. } catch (_error) {
setPairingCopied(false);
}If the clipboard API fails (permission denied, non-secure context), the button just resets with no indication that the copy did not work. A brief error label ("Copy failed — select manually") would help users. 5. Pairing dialog permanently inaccessible after "Dismiss" Clicking "Dismiss" sets Security6. Pipe-delimited signature payload is vulnerable to field injection return "|".join([
"v2",
device_id, # SHA-256 hex – safe
client_id, # env-var controlled – can contain "|"
client_mode, # env-var controlled – can contain "|"
role, # hardcoded "operator"
",".join(scopes), # env-var controlled – can contain "|"
str(signed_at_ms),
token or "", # env-var controlled – can contain "|"
nonce,
])If any operator-controlled field ( 7. Private key stored unencrypted — needs clearer documentation The identity file stores the Ed25519 private key as a plaintext PEM string inside JSON. The README mentions "ensure the path is writable" but says nothing about protecting the file. In Docker/K8s deployments where identity volumes may be shared or snapshotted, this warrants an explicit security note. Code Quality8. The function returns 9. Missing tests
Minor Notes
|
PR Review: feat: support device identity pairingOverall this is a well-structured feature that adds Ed25519-based device identity and a pairing approval UX. The crypto primitives are used correctly and the background task lifecycle management is solid. Below are findings grouped by priority. Security[Medium] try:
os.chmod(identity_path, 0o600)
except OSError:
passIf this fails (e.g. on a FAT filesystem, certain Docker volume mounts, or restrictive containers), the private key file will be left world-readable with no indication. At minimum this should emit a except OSError as e:
self.ten_env.log_warn(
f"[openclaw_gateway_tool_python] could not restrict identity file permissions: {e}"
)[Low] Auth token included in the signing payload
Potential Bugs[High] Backward compatibility: gateways that don't send Previously # _handle_message
if event_name == "connect.challenge":
self._connect_nonce = self._extract_connect_nonce(payload)
self._create_background_task(self._send_connect_background())
returnIf a gateway version doesn't send [Medium]
export function isPairingRequiredMessage(text: string): boolean {
return text.toLowerCase().includes("pairing is required");
}The backend already sets [Medium] async def _reemit_startup_pairing_notice(self, *, exc: Exception, ...):
for _ in range(max(attempts, 0)):
if self._stopped:
return
await asyncio.sleep(max(interval_seconds, 0.1))
await self._emit_pairing_required_event(exc, ...)After the sleep, if the user has already approved pairing and a reconnection attempt succeeded, this will still emit the stale "pairing required" notice. Consider checking whether the connection is now healthy before re-emitting: if self._hello_event.is_set():
return # already connected, skip retry[Low] is_pairing_required = (
"pairing required" in message_lower
or "missing scope: operator.write" in message_lower # <-- too broad
or code == "pairing_required"
)
Breaking Changes (not documented in PR description)The defaults for
Users who deployed with the old defaults (without setting the env vars explicitly) will silently get different values after upgrading. This should be called out in the PR description and ideally in a changelog or migration note. Code QualityBackground task cleanup in The cleanup loop is: for task in list(self._background_tasks):
task.cancel()
self._background_tasks.clear()The clear happens before awaiting cancellation, so any task callbacks (like
The canonical string starts with Unrelated formatting changes in the same PR A large portion of the diff (Telnyx, Plivo, SIP voice-assistant examples) is purely cosmetic reformatting — trailing newlines, line-length adjustments, trailing commas. These are fine individually but mixing them with a security-sensitive feature makes review harder and obscures the functional diff. Consider a follow-up cleanup PR or keep the formatter changes separate. Test CoverageThere are no tests added for:
Given that the signing logic is security-critical and the pairing detection relies on string heuristics, unit tests would significantly improve confidence here. Minor / Nits
|
No description provided.