diff --git a/src/OpenClaw.Shared/WebSocketClientBase.cs b/src/OpenClaw.Shared/WebSocketClientBase.cs
index 72c4d10..761c52f 100644
--- a/src/OpenClaw.Shared/WebSocketClientBase.cs
+++ b/src/OpenClaw.Shared/WebSocketClientBase.cs
@@ -186,30 +186,39 @@ private async Task ListenForMessagesAsync()
protected async Task ReconnectWithBackoffAsync()
{
- var delay = BackoffMs[Math.Min(_reconnectAttempts, BackoffMs.Length - 1)];
- _reconnectAttempts++;
- _logger.Warn($"{ClientRole} reconnecting in {delay}ms (attempt {_reconnectAttempts})");
- RaiseStatusChanged(ConnectionStatus.Connecting);
-
- try
+ while (!_disposed)
{
- await Task.Delay(delay, _cts.Token);
+ var delay = BackoffMs[Math.Min(_reconnectAttempts, BackoffMs.Length - 1)];
+ _reconnectAttempts++;
+ _logger.Warn($"{ClientRole} reconnecting in {delay}ms (attempt {_reconnectAttempts})");
+ RaiseStatusChanged(ConnectionStatus.Connecting);
- // Check cancellation after delay
- if (_cts.Token.IsCancellationRequested) return;
+ try
+ {
+ await Task.Delay(delay, _cts.Token);
- // Safely dispose old socket
- var oldSocket = _webSocket;
- _webSocket = null;
- try { oldSocket?.Dispose(); } catch { /* ignore dispose errors */ }
+ if (_cts.Token.IsCancellationRequested) return;
- await ConnectAsync();
- }
- catch (OperationCanceledException) { }
- catch (Exception ex)
- {
- _logger.Error($"{ClientRole} reconnect failed", ex);
- RaiseStatusChanged(ConnectionStatus.Error);
+ // Safely dispose old socket before retrying the connection.
+ var oldSocket = _webSocket;
+ _webSocket = null;
+ try { oldSocket?.Dispose(); } catch { /* ignore dispose errors */ }
+
+ await ConnectAsync();
+ if (IsConnected)
+ {
+ return;
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ catch (Exception ex)
+ {
+ _logger.Error($"{ClientRole} reconnect failed", ex);
+ RaiseStatusChanged(ConnectionStatus.Error);
+ }
}
}
diff --git a/src/OpenClaw.Shared/WindowsNodeClient.cs b/src/OpenClaw.Shared/WindowsNodeClient.cs
index 98df1e8..0ae9017 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
@@ -182,9 +184,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"));
}
}
@@ -430,6 +516,12 @@ 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))
{
@@ -442,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
@@ -450,77 +543,148 @@ private void HandleResponse(JsonElement root)
_nodeId = nodeIdProp.GetString();
}
+ bool receivedDeviceToken = false;
+ bool hasAuthPayload = payload.TryGetProperty("auth", out var authPayload);
+
// Check for device token in auth (means we're paired!)
- if (payload.TryGetProperty("auth", out var authPayload))
+ if (hasAuthPayload && 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!"));
- }
- }
+ receivedDeviceToken = true;
+ _isPendingApproval = false;
+ _isPaired = true;
+ _pairingApprovedAwaitingReconnect = false;
+ _logger.Info("Received device token in hello-ok - we are now paired!");
+ _deviceIdentity.StoreDeviceToken(deviceToken);
}
}
-
+ else if (_pairingApprovedAwaitingReconnect)
+ {
+ _logger.Info("hello-ok arrived after pairing approval without auth.deviceToken; keeping local state paired.");
+ }
+
_logger.Info($"Node registered successfully! ID: {_nodeId ?? _deviceIdentity.DeviceId.Substring(0, 16)}");
+ _logger.Info($"[NODE] hello-ok auth present={hasAuthPayload}, receivedDeviceToken={receivedDeviceToken}, storedDeviceToken={!string.IsNullOrEmpty(_deviceIdentity.DeviceToken)}, pendingApproval={_isPendingApproval}, awaitingReconnect={_pairingApprovedAwaitingReconnect}");
- // Pairing happens at connect time via device identity, no separate request needed
- if (string.IsNullOrEmpty(_deviceIdentity.DeviceToken))
+ _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)
{
- _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,
+ PairingStatus.Paired,
_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));
+ "Pairing approved!"));
}
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))
{
- if (errorProp.TryGetProperty("message", out var msgProp))
+ error = msgProp.GetString() ?? error;
+ }
+ if (errorProp.TryGetProperty("code", out var codeProp))
+ {
+ 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)
{
if (!root.TryGetProperty("method", out var methodProp)) return;
diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs
index de0780f..ed17dfc 100644
--- a/src/OpenClaw.Tray.WinUI/App.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs
@@ -54,6 +54,7 @@ public partial class App : Application
private GatewayCostUsageInfo? _lastUsageCost;
private DateTime _lastCheckTime = DateTime.Now;
private DateTime _lastUsageActivityLogUtc = DateTime.MinValue;
+ private OpenClaw.Shared.PairingStatus? _lastNodePairingStatus;
// Session-aware activity tracking
private readonly Dictionary _sessionActivities = new();
@@ -1151,43 +1152,43 @@ private void OnNodeStatusChanged(object? sender, ConnectionStatus status)
_currentStatus = status;
UpdateTrayIcon();
}
-
- // Don't show "connected" toast if waiting for pairing - we'll show pairing status instead
- if (status == ConnectionStatus.Connected && _nodeService?.IsPaired == true)
- {
- try
- {
- new ToastContentBuilder()
- .AddText(LocalizationHelper.GetString("Toast_NodeModeActive"))
- .AddText(LocalizationHelper.GetString("Toast_NodeModeActiveDetail"))
- .Show();
- }
- catch { /* ignore */ }
- }
}
private void OnPairingStatusChanged(object? sender, OpenClaw.Shared.PairingStatusEventArgs args)
{
Logger.Info($"Pairing status: {args.Status}");
+
+ var previousStatus = _lastNodePairingStatus;
+ _lastNodePairingStatus = args.Status;
try
{
if (args.Status == OpenClaw.Shared.PairingStatus.Pending)
{
- AddRecentActivity("Node pairing pending", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId);
- // Show toast with approval instructions
- new ToastContentBuilder()
- .AddText(LocalizationHelper.GetString("Toast_PairingPending"))
- .AddText(string.Format(LocalizationHelper.GetString("Toast_PairingPendingDetail"), args.DeviceId.Substring(0, 16)))
- .Show();
+ if (previousStatus != OpenClaw.Shared.PairingStatus.Pending)
+ {
+ AddRecentActivity("Node pairing pending", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId);
+ new ToastContentBuilder()
+ .AddText(LocalizationHelper.GetString("Toast_PairingPending"))
+ .AddText(string.Format(LocalizationHelper.GetString("Toast_PairingPendingDetail"), args.DeviceId.Substring(0, 16)))
+ .Show();
+ }
}
else if (args.Status == OpenClaw.Shared.PairingStatus.Paired)
{
- AddRecentActivity("Node paired", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId);
- new ToastContentBuilder()
- .AddText(LocalizationHelper.GetString("Toast_NodePaired"))
- .AddText(LocalizationHelper.GetString("Toast_NodePairedDetail"))
- .Show();
+ if (previousStatus != OpenClaw.Shared.PairingStatus.Paired)
+ {
+ AddRecentActivity("Node paired", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId);
+ new ToastContentBuilder()
+ .AddText(LocalizationHelper.GetString("Toast_NodePaired"))
+ .AddText(LocalizationHelper.GetString("Toast_NodePairedDetail"))
+ .Show();
+ }
+ }
+ else if (args.Status == OpenClaw.Shared.PairingStatus.Unknown)
+ {
+ AddRecentActivity("Node pairing requires repair", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId);
+ Logger.Warn($"Node pairing state is unknown for {args.DeviceId.Substring(0, 16)}. Repair the device token from the gateway or CLI, then reconnect.");
}
}
catch { /* ignore */ }