Skip to content

Commit eb06fba

Browse files
authored
fix(pairing): complete node bootstrap handoff (#601)
1 parent 4351d6a commit eb06fba

4 files changed

Lines changed: 146 additions & 35 deletions

File tree

src/OpenClaw.Connection/ConnectionStateMachine.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ public bool CanTransition(ConnectionTrigger trigger)
4949
_operatorState is RoleConnectionState.Connecting or RoleConnectionState.Error,
5050

5151
ConnectionTrigger.HandshakeSucceeded =>
52-
_operatorState is RoleConnectionState.Connecting or RoleConnectionState.Error,
52+
_operatorState is RoleConnectionState.Connecting
53+
or RoleConnectionState.PairingRequired
54+
or RoleConnectionState.Error,
5355

5456
ConnectionTrigger.PairingPending =>
5557
_operatorState is RoleConnectionState.Connecting or RoleConnectionState.Connected or RoleConnectionState.Error,

src/OpenClaw.Shared/OpenClawGatewayClient.cs

Lines changed: 35 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1549,9 +1549,9 @@ private void HandleResponse(JsonElement root)
15491549
}
15501550
}
15511551

1552-
var newDeviceToken = _bootstrapPairAsNode
1553-
? TryGetHandshakeDeviceTokenCore(payload, OperatorRole, allowDirectDeviceTokenFallback: false)
1554-
: TryGetHandshakeDeviceTokenCore(payload, preferredRole: null);
1552+
var newDeviceToken = !_bootstrapPairAsNode
1553+
? TryGetHandshakeDeviceTokenCore(payload, preferredRole: null)
1554+
: TryGetHandshakeDeviceTokenCore(payload, OperatorRole, allowDirectDeviceTokenFallback: false);
15551555
if (!string.IsNullOrWhiteSpace(newDeviceToken))
15561556
{
15571557
var deviceTokenScopes = _bootstrapPairAsNode
@@ -2170,24 +2170,26 @@ internal static string ResolveEffectiveSessionKey(
21702170
return null;
21712171
}
21722172

2173-
if (!string.IsNullOrWhiteSpace(preferredRole) &&
2174-
authPayload.TryGetProperty("deviceTokens", out var deviceTokens) &&
2175-
deviceTokens.ValueKind == JsonValueKind.Array)
2173+
if (!string.IsNullOrWhiteSpace(preferredRole))
21762174
{
2177-
foreach (var entry in deviceTokens.EnumerateArray())
2175+
if (authPayload.TryGetProperty("deviceTokens", out var deviceTokens) &&
2176+
deviceTokens.ValueKind == JsonValueKind.Array)
21782177
{
2179-
if (entry.ValueKind != JsonValueKind.Object)
2180-
continue;
2181-
2182-
if (entry.TryGetProperty("role", out var role) &&
2183-
role.ValueKind == JsonValueKind.String &&
2184-
string.Equals(role.GetString(), preferredRole, StringComparison.OrdinalIgnoreCase) &&
2185-
entry.TryGetProperty("deviceToken", out var roleToken) &&
2186-
roleToken.ValueKind == JsonValueKind.String)
2178+
foreach (var entry in deviceTokens.EnumerateArray())
21872179
{
2188-
var roleTokenValue = roleToken.GetString();
2189-
if (!string.IsNullOrWhiteSpace(roleTokenValue))
2190-
return roleTokenValue;
2180+
if (entry.ValueKind != JsonValueKind.Object)
2181+
continue;
2182+
2183+
if (entry.TryGetProperty("role", out var role) &&
2184+
role.ValueKind == JsonValueKind.String &&
2185+
string.Equals(role.GetString(), preferredRole, StringComparison.OrdinalIgnoreCase) &&
2186+
entry.TryGetProperty("deviceToken", out var roleToken) &&
2187+
roleToken.ValueKind == JsonValueKind.String)
2188+
{
2189+
var roleTokenValue = roleToken.GetString();
2190+
if (!string.IsNullOrWhiteSpace(roleTokenValue))
2191+
return roleTokenValue;
2192+
}
21912193
}
21922194
}
21932195

@@ -2218,22 +2220,24 @@ internal static string ResolveEffectiveSessionKey(
22182220
return null;
22192221
}
22202222

2221-
if (!string.IsNullOrWhiteSpace(preferredRole) &&
2222-
authPayload.TryGetProperty("deviceTokens", out var deviceTokens) &&
2223-
deviceTokens.ValueKind == JsonValueKind.Array)
2223+
if (!string.IsNullOrWhiteSpace(preferredRole))
22242224
{
2225-
foreach (var entry in deviceTokens.EnumerateArray())
2225+
if (authPayload.TryGetProperty("deviceTokens", out var deviceTokens) &&
2226+
deviceTokens.ValueKind == JsonValueKind.Array)
22262227
{
2227-
if (entry.ValueKind != JsonValueKind.Object)
2228-
continue;
2229-
2230-
if (entry.TryGetProperty("role", out var role) &&
2231-
role.ValueKind == JsonValueKind.String &&
2232-
string.Equals(role.GetString(), preferredRole, StringComparison.OrdinalIgnoreCase))
2228+
foreach (var entry in deviceTokens.EnumerateArray())
22332229
{
2234-
return entry.TryGetProperty("scopes", out var roleScopes) && roleScopes.ValueKind == JsonValueKind.Array
2235-
? ReadStringArray(roleScopes)
2236-
: [];
2230+
if (entry.ValueKind != JsonValueKind.Object)
2231+
continue;
2232+
2233+
if (entry.TryGetProperty("role", out var role) &&
2234+
role.ValueKind == JsonValueKind.String &&
2235+
string.Equals(role.GetString(), preferredRole, StringComparison.OrdinalIgnoreCase))
2236+
{
2237+
return entry.TryGetProperty("scopes", out var roleScopes) && roleScopes.ValueKind == JsonValueKind.Array
2238+
? ReadStringArray(roleScopes)
2239+
: [];
2240+
}
22372241
}
22382242
}
22392243

tests/OpenClaw.Connection.Tests/ConnectionStateMachineTests.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,18 @@ public void PairingRequired_PairingApproved_TransitionsToConnecting()
9090
Assert.Equal(RoleConnectionState.Connecting, _sm.Current.OperatorState);
9191
}
9292

93+
[Fact]
94+
public void PairingRequired_HandshakeSucceeded_TransitionsOperatorToConnected()
95+
{
96+
_sm.TryTransition(ConnectionTrigger.ConnectRequested);
97+
_sm.TryTransition(ConnectionTrigger.PairingPending);
98+
99+
Assert.True(_sm.TryTransition(ConnectionTrigger.HandshakeSucceeded));
100+
Assert.Equal(OverallConnectionState.Ready, _sm.Current.OverallState);
101+
Assert.Equal(RoleConnectionState.Connected, _sm.Current.OperatorState);
102+
Assert.False(_sm.Current.OperatorPairingRequired);
103+
}
104+
93105
[Fact]
94106
public void PairingRequired_PairingRejected_TransitionsToError()
95107
{

tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.IO;
34
using System.Linq;
45
using System.Text.Json;
56
using Xunit;
@@ -16,14 +17,19 @@ private class GatewayClientTestHelper
1617

1718
public OpenClawGatewayClient Client => _client;
1819

19-
public GatewayClientTestHelper(bool tokenIsBootstrapToken = false, bool bootstrapPairAsNode = false, string gatewayUrl = "ws://localhost:18789")
20+
public GatewayClientTestHelper(
21+
bool tokenIsBootstrapToken = false,
22+
bool bootstrapPairAsNode = false,
23+
string gatewayUrl = "ws://localhost:18789",
24+
string? identityPath = null)
2025
{
2126
_client = new OpenClawGatewayClient(
2227
gatewayUrl,
2328
"test-token",
2429
new TestLogger(),
2530
tokenIsBootstrapToken,
26-
bootstrapPairAsNode);
31+
bootstrapPairAsNode,
32+
identityPath);
2733
}
2834

2935
public GatewayClientTestHelper(IOpenClawLogger logger)
@@ -343,6 +349,24 @@ public void SetDeviceTokenForTest(string? token, string[]? scopes = null)
343349
SetPrivateField("_connectAuthToken", token ?? "test-token");
344350
}
345351

352+
public string? GetStoredOperatorDeviceToken()
353+
{
354+
var identityField = typeof(OpenClawGatewayClient).GetField(
355+
"_deviceIdentity",
356+
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
357+
var identity = identityField!.GetValue(_client)!;
358+
return (string?)identity.GetType().GetProperty("DeviceToken")!.GetValue(identity);
359+
}
360+
361+
public string? GetStoredNodeDeviceToken()
362+
{
363+
var identityField = typeof(OpenClawGatewayClient).GetField(
364+
"_deviceIdentity",
365+
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
366+
var identity = identityField!.GetValue(_client)!;
367+
return (string?)identity.GetType().GetProperty("NodeDeviceToken")!.GetValue(identity);
368+
}
369+
346370
public string GetFallbackDeviceId()
347371
{
348372
var identityField = typeof(OpenClawGatewayClient).GetField(
@@ -412,6 +436,9 @@ public List<string> CaptureAuthenticationFailedEvents()
412436
}
413437
}
414438

439+
private static string CreateTempIdentityPath() =>
440+
Path.Combine(Path.GetTempPath(), "OpenClawGatewayClientTests", Guid.NewGuid().ToString("N"));
441+
415442
[Fact]
416443
public void OperatorConnect_FreshDevice_RequestsBootstrapHandoffScopes()
417444
{
@@ -536,7 +563,10 @@ public void OperatorConnect_PairedDeviceWithStoredScopes_RequestsStoredScopes()
536563
[Fact]
537564
public void BootstrapNodeHandoff_FreshDevice_RequestsNodeRoleWithoutScopes()
538565
{
539-
var helper = new GatewayClientTestHelper(tokenIsBootstrapToken: true, bootstrapPairAsNode: true);
566+
var helper = new GatewayClientTestHelper(
567+
tokenIsBootstrapToken: true,
568+
bootstrapPairAsNode: true,
569+
identityPath: CreateTempIdentityPath());
540570
helper.SetDeviceTokenForTest(null);
541571

542572
var auth = helper.BuildAuthPayload();
@@ -548,6 +578,69 @@ public void BootstrapNodeHandoff_FreshDevice_RequestsNodeRoleWithoutScopes()
548578
Assert.False(auth.ContainsKey("deviceToken"));
549579
}
550580

581+
[Fact]
582+
public void BootstrapNodeHandoff_HelloOkWithNodeRole_DoesNotStorePrimaryNodeTokenAsOperator()
583+
{
584+
var helper = new GatewayClientTestHelper(
585+
tokenIsBootstrapToken: true,
586+
bootstrapPairAsNode: true,
587+
identityPath: CreateTempIdentityPath());
588+
helper.SetDeviceTokenForTest(null);
589+
590+
helper.ProcessRawMessage("""
591+
{
592+
"type": "res",
593+
"id": "req-hello-node",
594+
"payload": {
595+
"type": "hello-ok",
596+
"auth": {
597+
"deviceToken": "node-token",
598+
"role": "node",
599+
"scopes": []
600+
}
601+
}
602+
}
603+
""");
604+
605+
Assert.Equal("node-token", helper.GetStoredNodeDeviceToken());
606+
Assert.Null(helper.GetStoredOperatorDeviceToken());
607+
}
608+
609+
[Fact]
610+
public void BootstrapNodeHandoff_HelloOkWithOperatorHandoffToken_StoresOperatorToken()
611+
{
612+
var helper = new GatewayClientTestHelper(
613+
tokenIsBootstrapToken: true,
614+
bootstrapPairAsNode: true,
615+
identityPath: CreateTempIdentityPath());
616+
helper.SetDeviceTokenForTest(null);
617+
618+
helper.ProcessRawMessage("""
619+
{
620+
"type": "res",
621+
"id": "req-hello-node",
622+
"payload": {
623+
"type": "hello-ok",
624+
"auth": {
625+
"deviceToken": "node-token",
626+
"role": "node",
627+
"scopes": [],
628+
"deviceTokens": [
629+
{
630+
"deviceToken": "operator-token",
631+
"role": "operator",
632+
"scopes": ["operator.read"]
633+
}
634+
]
635+
}
636+
}
637+
}
638+
""");
639+
640+
Assert.Equal("node-token", helper.GetStoredNodeDeviceToken());
641+
Assert.Equal("operator-token", helper.GetStoredOperatorDeviceToken());
642+
}
643+
551644
[Fact]
552645
public void BootstrapNodeHandoff_PrefersOperatorTokenFromAdditionalDeviceTokens()
553646
{

0 commit comments

Comments
 (0)