diff --git a/src/OpenClaw.Shared/WindowsNodeClient.cs b/src/OpenClaw.Shared/WindowsNodeClient.cs index 98df1e8..d05a12e 100644 --- a/src/OpenClaw.Shared/WindowsNodeClient.cs +++ b/src/OpenClaw.Shared/WindowsNodeClient.cs @@ -450,51 +450,52 @@ private void HandleResponse(JsonElement root) _nodeId = nodeIdProp.GetString(); } - // Check for device token in auth (means we're paired!) - if (payload.TryGetProperty("auth", out var authPayload)) + // 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)) { - if (authPayload.TryGetProperty("deviceToken", out var deviceTokenProp)) + var deviceToken = deviceTokenProp.GetString(); + if (!string.IsNullOrEmpty(deviceToken)) { - var deviceToken = deviceTokenProp.GetString(); - if (!string.IsNullOrEmpty(deviceToken)) - { - var wasWaiting = _isPendingApproval; - _isPendingApproval = false; - _logger.Info("Received device token - we are now paired!"); - _deviceIdentity.StoreDeviceToken(deviceToken); - - // Fire pairing event if we were waiting - if (wasWaiting) - { - PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( - PairingStatus.Paired, - _deviceIdentity.DeviceId, - "Pairing approved!")); - } - } + gotNewToken = true; + var wasWaiting = _isPendingApproval; + _isPendingApproval = false; + _logger.Info("Received device token - we are now paired!"); + _deviceIdentity.StoreDeviceToken(deviceToken); + PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( + PairingStatus.Paired, + _deviceIdentity.DeviceId, + wasWaiting ? "Pairing approved!" : null)); } } _logger.Info($"Node registered successfully! ID: {_nodeId ?? _deviceIdentity.DeviceId.Substring(0, 16)}"); - // Pairing happens at connect time via device identity, no separate request needed - if (string.IsNullOrEmpty(_deviceIdentity.DeviceToken)) + // 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) { - _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)); + 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)); + } } RaiseStatusChanged(ConnectionStatus.Connected); diff --git a/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs b/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs index 8e9f269..97765fd 100644 --- a/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Reflection; +using System.Text.Json; using OpenClaw.Shared; using Xunit; @@ -34,4 +37,109 @@ public void Constructor_NormalizesGatewayUrl(string inputUrl, string expectedUrl } } } + + /// + /// Regression test: when hello-ok includes auth.deviceToken, PairingStatusChanged must + /// fire exactly once — not twice (once from the token block and again from the DeviceToken + /// fallback check that follows it). + /// + [Fact] + public void HandleResponse_HelloOkWithDeviceToken_FiresPairingChangedExactlyOnce() + { + 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); + + // Put client into pending-approval state (simulates first-connect, no stored token) + var isPendingField = typeof(WindowsNodeClient).GetField( + "_isPendingApproval", + BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(isPendingField); + isPendingField!.SetValue(client, true); + + var pairingEvents = new List(); + client.PairingStatusChanged += (_, e) => pairingEvents.Add(e); + + // Build a hello-ok payload that includes auth.deviceToken + var json = """ + { + "type": "res", + "ok": true, + "payload": { + "type": "hello-ok", + "nodeId": "test-node-id", + "auth": { + "deviceToken": "test-device-token-abc123" + } + } + } + """; + var root = JsonDocument.Parse(json).RootElement; + + var handleResponseMethod = typeof(WindowsNodeClient).GetMethod( + "HandleResponse", + BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(handleResponseMethod); + handleResponseMethod!.Invoke(client, [root]); + + Assert.Single(pairingEvents); + Assert.Equal(PairingStatus.Paired, pairingEvents[0].Status); + Assert.Equal("Pairing approved!", pairingEvents[0].Message); + } + finally + { + if (Directory.Exists(dataPath)) + { + Directory.Delete(dataPath, true); + } + } + } + + /// + /// When hello-ok has no token and no stored token, fires exactly one Pending event. + /// + [Fact] + public void HandleResponse_HelloOkNoToken_FiresPendingExactlyOnce() + { + 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": true, + "payload": { + "type": "hello-ok", + "nodeId": "test-node-id" + } + } + """; + 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); + } + finally + { + if (Directory.Exists(dataPath)) + { + Directory.Delete(dataPath, true); + } + } + } }