From daf6e73f107ba71603de6693a4bbee1d6dc451e5 Mon Sep 17 00:00:00 2001 From: Andy Eskridge Date: Fri, 3 Apr 2026 15:42:33 -0500 Subject: [PATCH] fix: handle node pairing events on current master --- src/OpenClaw.Shared/WindowsNodeClient.cs | 271 ++++++++++++++---- .../WindowsNodeClientTests.cs | 233 ++++++++++++++- 2 files changed, 450 insertions(+), 54 deletions(-) diff --git a/src/OpenClaw.Shared/WindowsNodeClient.cs b/src/OpenClaw.Shared/WindowsNodeClient.cs index 0068d68..196f0e5 100644 --- a/src/OpenClaw.Shared/WindowsNodeClient.cs +++ b/src/OpenClaw.Shared/WindowsNodeClient.cs @@ -25,6 +25,8 @@ public class WindowsNodeClient : WebSocketClientBase private string? _nodeId; private string? _pendingNonce; // Store nonce from challenge for signing private bool _isPendingApproval; // True when connected but awaiting pairing approval + private bool _isPaired; + private bool _pairingApprovedAwaitingReconnect; // True after approval event until the next successful reconnect // Cached serialization/validation — reused on every message instead of allocating per-call private static readonly JsonSerializerOptions s_ignoreNullOptions = new() @@ -46,8 +48,8 @@ public class WindowsNodeClient : WebSocketClientBase /// True if connected but waiting for pairing approval on gateway public bool IsPendingApproval => _isPendingApproval; - /// True if device is paired (has a device token) - public bool IsPaired => !string.IsNullOrEmpty(_deviceIdentity.DeviceToken); + /// True if device is paired or approved for use by the gateway + public bool IsPaired => _isPaired || !string.IsNullOrEmpty(_deviceIdentity.DeviceToken); /// Device ID for display/approval (first 16 chars of full ID) public string ShortDeviceId => _deviceIdentity.DeviceId.Length > 16 @@ -180,9 +182,93 @@ private async Task HandleEventAsync(JsonElement root) case "connect.challenge": await HandleConnectChallengeAsync(root); break; + case "node.pair.requested": + case "device.pair.requested": + HandlePairingRequestedEvent(root, eventType); + break; case "node.invoke.request": await HandleNodeInvokeEventAsync(root); break; + case "node.pair.resolved": + case "device.pair.resolved": + await HandlePairingResolvedEventAsync(root, eventType); + break; + } + } + + private void HandlePairingRequestedEvent(JsonElement root, string? eventType) + { + if (!root.TryGetProperty("payload", out var payload)) + { + _logger.Warn($"[NODE] {eventType} has no payload"); + return; + } + + if (!PayloadTargetsCurrentDevice(payload)) + { + return; + } + + if (_isPendingApproval) + { + return; + } + + _isPendingApproval = true; + _isPaired = false; + _pairingApprovedAwaitingReconnect = false; + _logger.Info($"[NODE] Pairing request received for this device via {eventType}"); + _logger.Info($"To approve, run: openclaw devices approve {_deviceIdentity.DeviceId}"); + PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( + PairingStatus.Pending, + _deviceIdentity.DeviceId, + $"Run: openclaw devices approve {ShortDeviceId}...")); + } + + private async Task HandlePairingResolvedEventAsync(JsonElement root, string? eventType) + { + if (!root.TryGetProperty("payload", out var payload)) + { + _logger.Warn($"[NODE] {eventType} has no payload"); + return; + } + + if (!PayloadTargetsCurrentDevice(payload)) + { + return; + } + + var decision = payload.TryGetProperty("decision", out var decisionProp) + ? decisionProp.GetString() + : null; + + _logger.Info($"[NODE] Pairing resolution received for this device: decision={decision ?? "unknown"}"); + + if (string.Equals(decision, "approved", StringComparison.OrdinalIgnoreCase)) + { + _isPendingApproval = false; + _isPaired = true; + _pairingApprovedAwaitingReconnect = true; + PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( + PairingStatus.Paired, + _deviceIdentity.DeviceId, + "Pairing approved; reconnecting to refresh node state.")); + + // Force a fresh handshake so the approved connection can settle into its + // steady-state paired behavior on the next reconnect. + _logger.Info("[NODE] Closing socket after pairing approval to refresh node connection..."); + await CloseWebSocketAsync(); + return; + } + + if (string.Equals(decision, "rejected", StringComparison.OrdinalIgnoreCase)) + { + _isPendingApproval = false; + _isPaired = false; + PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( + PairingStatus.Rejected, + _deviceIdentity.DeviceId, + "Pairing rejected")); } } @@ -428,6 +514,13 @@ private void HandleResponse(JsonElement root) { // DEBUG: Log entire response structure _logger.Debug($"[NODE] HandleResponse - ok: {(root.TryGetProperty("ok", out var okVal) ? okVal.ToString() : "missing")}"); + + if (root.TryGetProperty("ok", out var okProp) && + okProp.ValueKind == JsonValueKind.False) + { + HandleRequestError(root); + return; + } if (!root.TryGetProperty("payload", out var payload)) { @@ -441,6 +534,7 @@ private void HandleResponse(JsonElement root) // Handle hello-ok (successful registration) if (payload.TryGetProperty("type", out var t) && t.GetString() == "hello-ok") { + var wasPairedBeforeHello = IsPaired; _isConnected = true; // Extract node ID if returned @@ -449,76 +543,153 @@ private void HandleResponse(JsonElement root) _nodeId = nodeIdProp.GetString(); } - // Check for device token in auth — if present, pairing is confirmed in this response. - // Use gotNewToken to guard the fallback check below and avoid a double-fire of - // PairingStatusChanged when the gateway includes auth.deviceToken in hello-ok. - bool gotNewToken = false; - if (payload.TryGetProperty("auth", out var authPayload) && - authPayload.TryGetProperty("deviceToken", out var deviceTokenProp)) + bool receivedDeviceToken = false; + bool hasAuthPayload = payload.TryGetProperty("auth", out var authPayload); + + // Check for device token in auth (means we're paired!) + if (hasAuthPayload && authPayload.TryGetProperty("deviceToken", out var deviceTokenProp)) { var deviceToken = deviceTokenProp.GetString(); if (!string.IsNullOrEmpty(deviceToken)) { - gotNewToken = true; - var wasWaiting = _isPendingApproval; + receivedDeviceToken = true; _isPendingApproval = false; - _logger.Info("Received device token - we are now paired!"); + _isPaired = true; + _pairingApprovedAwaitingReconnect = false; + _logger.Info("Received device token in hello-ok - we are now paired!"); _deviceIdentity.StoreDeviceToken(deviceToken); - PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( - PairingStatus.Paired, - _deviceIdentity.DeviceId, - wasWaiting ? "Pairing approved!" : null)); } } - + else if (_pairingApprovedAwaitingReconnect) + { + _logger.Info("hello-ok arrived after pairing approval without auth.deviceToken; keeping local state paired."); + _pairingApprovedAwaitingReconnect = false; + } + _logger.Info($"Node registered successfully! ID: {_nodeId ?? _deviceIdentity.DeviceId.Substring(0, 16)}"); - - // Pairing happens at connect time via device identity, no separate request needed. - // Skip this block if we already fired PairingStatusChanged above via gotNewToken. - if (!gotNewToken) + _logger.Info($"[NODE] hello-ok auth present={hasAuthPayload}, receivedDeviceToken={receivedDeviceToken}, storedDeviceToken={!string.IsNullOrEmpty(_deviceIdentity.DeviceToken)}, pendingApproval={_isPendingApproval}, awaitingReconnect={_pairingApprovedAwaitingReconnect}"); + + // Current gateways only send hello-ok for approved/accepted nodes, even when + // auth.deviceToken is omitted, so treat handshake acceptance as paired state. + _isPendingApproval = false; + _isPaired = true; + _logger.Info(string.IsNullOrEmpty(_deviceIdentity.DeviceToken) + ? "Gateway accepted the node without returning a device token; treating this device as paired" + : "Already paired with stored device token"); + if (!wasPairedBeforeHello) { - if (string.IsNullOrEmpty(_deviceIdentity.DeviceToken)) - { - _isPendingApproval = true; - _logger.Info("Not yet paired - check 'openclaw devices list' for pending approval"); - _logger.Info($"To approve, run: openclaw devices approve {_deviceIdentity.DeviceId}"); - PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( - PairingStatus.Pending, - _deviceIdentity.DeviceId, - $"Run: openclaw devices approve {ShortDeviceId}...")); - } - else - { - _isPendingApproval = false; - _logger.Info("Already paired with stored device token"); - PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( - PairingStatus.Paired, - _deviceIdentity.DeviceId)); - } + var pairingMessage = receivedDeviceToken + ? "Pairing approved!" + : "Node registration accepted"; + + PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( + PairingStatus.Paired, + _deviceIdentity.DeviceId, + pairingMessage)); } RaiseStatusChanged(ConnectionStatus.Connected); + return; } - - // Handle errors - if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean()) + + _logger.Debug("[NODE] Unhandled response payload"); + } + + private void HandleRequestError(JsonElement root) + { + var error = "Unknown error"; + var errorCode = "none"; + string? pairingReason = null; + string? pairingRequestId = null; + if (root.TryGetProperty("error", out var errorProp)) { - var error = "Unknown error"; - var errorCode = "none"; - if (root.TryGetProperty("error", out var errorProp)) + if (errorProp.TryGetProperty("message", out var msgProp)) + { + error = msgProp.GetString() ?? error; + } + if (errorProp.TryGetProperty("code", out var codeProp)) { - if (errorProp.TryGetProperty("message", out var msgProp)) + errorCode = codeProp.ToString(); + } + if (errorProp.TryGetProperty("details", out var detailsProp)) + { + if (detailsProp.TryGetProperty("reason", out var reasonProp)) { - error = msgProp.GetString() ?? error; + pairingReason = reasonProp.GetString(); } - if (errorProp.TryGetProperty("code", out var codeProp)) + if (detailsProp.TryGetProperty("requestId", out var requestIdProp)) { - errorCode = codeProp.ToString(); + pairingRequestId = requestIdProp.GetString(); } } - _logger.Error($"Node registration failed: {error} (code: {errorCode})"); - RaiseStatusChanged(ConnectionStatus.Error); } + + if (string.Equals(errorCode, "NOT_PAIRED", StringComparison.OrdinalIgnoreCase)) + { + if (_isPendingApproval) + { + return; + } + + _isPendingApproval = true; + _isPaired = false; + _pairingApprovedAwaitingReconnect = false; + + var detail = $"Device {ShortDeviceId} requires approval"; + if (!string.IsNullOrWhiteSpace(pairingRequestId)) + { + detail += $" (request {pairingRequestId})"; + } + + _logger.Info($"[NODE] Pairing required for this device; waiting for gateway approval. reason={pairingReason ?? "unknown"}, requestId={pairingRequestId ?? "none"}"); + PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( + PairingStatus.Pending, + _deviceIdentity.DeviceId, + detail)); + return; + } + + _logger.Error($"Node registration failed: {error} (code: {errorCode})"); + RaiseStatusChanged(ConnectionStatus.Error); + } + + private bool PayloadTargetsCurrentDevice(JsonElement payload) + { + if (TryGetString(payload, "deviceId", out var deviceId) && + string.Equals(deviceId, _deviceIdentity.DeviceId, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (TryGetString(payload, "nodeId", out var nodeId)) + { + if (!string.IsNullOrEmpty(_nodeId)) + { + return string.Equals(nodeId, _nodeId, StringComparison.OrdinalIgnoreCase); + } + + return string.Equals(nodeId, _deviceIdentity.DeviceId, StringComparison.OrdinalIgnoreCase); + } + + if (TryGetString(payload, "instanceId", out var instanceId) && + string.Equals(instanceId, _deviceIdentity.DeviceId, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return false; + } + + private static bool TryGetString(JsonElement element, string propertyName, out string? value) + { + value = null; + if (!element.TryGetProperty(propertyName, out var prop)) + { + return false; + } + + value = prop.GetString(); + return !string.IsNullOrEmpty(value); } private async Task HandleRequestAsync(JsonElement root) diff --git a/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs b/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs index 97765fd..9222de2 100644 --- a/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs @@ -3,6 +3,7 @@ using System.IO; using System.Reflection; using System.Text.Json; +using System.Threading.Tasks; using OpenClaw.Shared; using Xunit; @@ -98,11 +99,8 @@ public void HandleResponse_HelloOkWithDeviceToken_FiresPairingChangedExactlyOnce } } - /// - /// When hello-ok has no token and no stored token, fires exactly one Pending event. - /// [Fact] - public void HandleResponse_HelloOkNoToken_FiresPendingExactlyOnce() + public void HandleResponse_HelloOkWithoutDeviceToken_EmitsNeutralPairedMessage() { var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}"); Directory.CreateDirectory(dataPath); @@ -131,8 +129,93 @@ public void HandleResponse_HelloOkNoToken_FiresPendingExactlyOnce() BindingFlags.NonPublic | BindingFlags.Instance); handleResponseMethod!.Invoke(client, [root]); + Assert.Single(pairingEvents); + Assert.Equal(PairingStatus.Paired, pairingEvents[0].Status); + Assert.Equal("Node registration accepted", pairingEvents[0].Message); + } + finally + { + if (Directory.Exists(dataPath)) + { + Directory.Delete(dataPath, true); + } + } + } + + [Fact] + public void HandleResponse_NotPairedError_EmitsPendingPairingRequest() + { + var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dataPath); + + try + { + using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath); + + var pairingEvents = new List(); + client.PairingStatusChanged += (_, e) => pairingEvents.Add(e); + + var json = """ + { + "type": "res", + "ok": false, + "error": { + "message": "Device approval required", + "code": "NOT_PAIRED", + "details": { + "reason": "first-connect", + "requestId": "req-123" + } + } + } + """; + var root = JsonDocument.Parse(json).RootElement; + + var handleResponseMethod = typeof(WindowsNodeClient).GetMethod( + "HandleResponse", + BindingFlags.NonPublic | BindingFlags.Instance); + handleResponseMethod!.Invoke(client, [root]); + + Assert.Single(pairingEvents); + Assert.Equal(PairingStatus.Pending, pairingEvents[0].Status); + Assert.Contains("req-123", pairingEvents[0].Message); + Assert.True(client.IsPendingApproval); + } + finally + { + if (Directory.Exists(dataPath)) + { + Directory.Delete(dataPath, true); + } + } + } + + [Fact] + public async Task HandleEvent_NodePairRequestedForCurrentDevice_EmitsPending() + { + var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dataPath); + + try + { + using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath); + + var pairingEvents = new List(); + client.PairingStatusChanged += (_, e) => pairingEvents.Add(e); + + await InvokeHandleEventAsync(client, $$""" + { + "type": "event", + "event": "node.pair.requested", + "payload": { + "deviceId": "{{client.FullDeviceId}}" + } + } + """); + Assert.Single(pairingEvents); Assert.Equal(PairingStatus.Pending, pairingEvents[0].Status); + Assert.True(client.IsPendingApproval); } finally { @@ -142,4 +225,146 @@ public void HandleResponse_HelloOkNoToken_FiresPendingExactlyOnce() } } } + + [Fact] + public async Task HandleEvent_NodePairRequestedForDifferentDevice_IsIgnored() + { + var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dataPath); + + try + { + using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath); + + var pairingEvents = new List(); + client.PairingStatusChanged += (_, e) => pairingEvents.Add(e); + + await InvokeHandleEventAsync(client, """ + { + "type": "event", + "event": "node.pair.requested", + "payload": { + "deviceId": "some-other-device" + } + } + """); + + Assert.Empty(pairingEvents); + Assert.False(client.IsPendingApproval); + } + finally + { + if (Directory.Exists(dataPath)) + { + Directory.Delete(dataPath, true); + } + } + } + + [Fact] + public async Task HandleEvent_NodePairResolvedApproved_ForCurrentDevice_EmitsPairedAndMarksReconnect() + { + var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dataPath); + + try + { + using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath); + + var isPendingField = typeof(WindowsNodeClient).GetField( + "_isPendingApproval", + BindingFlags.NonPublic | BindingFlags.Instance); + isPendingField!.SetValue(client, true); + + var pairingEvents = new List(); + client.PairingStatusChanged += (_, e) => pairingEvents.Add(e); + + await InvokeHandleEventAsync(client, $$""" + { + "type": "event", + "event": "node.pair.resolved", + "payload": { + "deviceId": "{{client.FullDeviceId}}", + "decision": "approved" + } + } + """); + + Assert.Single(pairingEvents); + Assert.Equal(PairingStatus.Paired, pairingEvents[0].Status); + Assert.Equal("Pairing approved; reconnecting to refresh node state.", pairingEvents[0].Message); + + var awaitingReconnectField = typeof(WindowsNodeClient).GetField( + "_pairingApprovedAwaitingReconnect", + BindingFlags.NonPublic | BindingFlags.Instance); + Assert.True((bool)awaitingReconnectField!.GetValue(client)!); + } + finally + { + if (Directory.Exists(dataPath)) + { + Directory.Delete(dataPath, true); + } + } + } + + [Fact] + public void HandleResponse_HelloOkWithoutDeviceTokenAfterApproval_ClearsAwaitingReconnect() + { + var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dataPath); + + try + { + using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath); + + var isPairedField = typeof(WindowsNodeClient).GetField( + "_isPaired", + BindingFlags.NonPublic | BindingFlags.Instance); + isPairedField!.SetValue(client, true); + + var awaitingReconnectField = typeof(WindowsNodeClient).GetField( + "_pairingApprovedAwaitingReconnect", + BindingFlags.NonPublic | BindingFlags.Instance); + awaitingReconnectField!.SetValue(client, true); + + var json = """ + { + "type": "res", + "ok": true, + "payload": { + "type": "hello-ok", + "nodeId": "node-123" + } + } + """; + var root = JsonDocument.Parse(json).RootElement; + + var handleResponseMethod = typeof(WindowsNodeClient).GetMethod( + "HandleResponse", + BindingFlags.NonPublic | BindingFlags.Instance); + handleResponseMethod!.Invoke(client, [root]); + + Assert.False((bool)awaitingReconnectField.GetValue(client)!); + Assert.True(client.IsPaired); + } + finally + { + if (Directory.Exists(dataPath)) + { + Directory.Delete(dataPath, true); + } + } + } + + private static async Task InvokeHandleEventAsync(WindowsNodeClient client, string json) + { + using var doc = JsonDocument.Parse(json); + var handleEventMethod = typeof(WindowsNodeClient).GetMethod( + "HandleEventAsync", + BindingFlags.NonPublic | BindingFlags.Instance); + + var task = (Task)handleEventMethod!.Invoke(client, [doc.RootElement.Clone()])!; + await task; + } }