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;
+ }
}