Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 38 additions & 37 deletions src/OpenClaw.Shared/WindowsNodeClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
108 changes: 108 additions & 0 deletions tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -34,4 +37,109 @@ public void Constructor_NormalizesGatewayUrl(string inputUrl, string expectedUrl
}
}
}

/// <summary>
/// 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).
/// </summary>
[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<PairingStatusEventArgs>();
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);
}
}
}

/// <summary>
/// When hello-ok has no token and no stored token, fires exactly one Pending event.
/// </summary>
[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<PairingStatusEventArgs>();
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);
}
}
}
}