Support stored device-token node startup

Allow WindowsNodeClient to bootstrap from an already-paired device token when the original gateway/bootstrap token is no longer present in settings.

Add a side-effect-free DeviceIdentity helper for reading a persisted device token without generating a new identity file.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Scott Hanselman 2026-05-01 13:27:42 -07:00
parent 24dfd6aebe
commit 5be08f4bc8
3 changed files with 70 additions and 2 deletions

View File

@ -24,6 +24,43 @@ public class DeviceIdentity
public string DeviceId => _deviceId ?? throw new InvalidOperationException("Device not initialized");
public string PublicKeyBase64Url => _publicKey != null ? Base64UrlEncode(_publicKey.Export(KeyBlobFormat.RawPublicKey)) : throw new InvalidOperationException("Device not initialized");
public string? DeviceToken => _deviceToken;
public static string? TryReadStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null)
{
var keyPath = Path.Combine(dataPath, "device-key-ed25519.json");
if (!File.Exists(keyPath))
{
return null;
}
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(keyPath));
if (doc.RootElement.TryGetProperty(nameof(DeviceKeyData.DeviceToken), out var deviceToken) &&
deviceToken.ValueKind == JsonValueKind.String)
{
var value = deviceToken.GetString();
return string.IsNullOrWhiteSpace(value) ? null : value;
}
}
catch (IOException ex)
{
logger?.Warn($"Failed to read stored device token: {ex.Message}");
}
catch (UnauthorizedAccessException ex)
{
logger?.Warn($"Failed to read stored device token: {ex.Message}");
}
catch (JsonException ex)
{
logger?.Warn($"Failed to read stored device token: {ex.Message}");
}
return null;
}
public static bool HasStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null) =>
!string.IsNullOrWhiteSpace(TryReadStoredDeviceToken(dataPath, logger));
public DeviceIdentity(string dataPath, IOpenClawLogger? logger = null)
{

View File

@ -74,7 +74,7 @@ public class WindowsNodeClient : WebSocketClientBase
protected override string ClientRole => "node";
public WindowsNodeClient(string gatewayUrl, string token, string dataPath, IOpenClawLogger? logger = null, string? bootstrapToken = null)
: base(gatewayUrl, ResolveRequiredCredential(token, bootstrapToken), logger)
: base(gatewayUrl, ResolveRequiredCredential(token, bootstrapToken, dataPath), logger)
{
_gatewayToken = NormalizeOptionalCredential(token);
_bootstrapToken = NormalizeOptionalCredential(bootstrapToken);
@ -98,7 +98,7 @@ public class WindowsNodeClient : WebSocketClientBase
return string.IsNullOrWhiteSpace(credential) ? string.Empty : credential;
}
private static string ResolveRequiredCredential(string? token, string? bootstrapToken)
private static string ResolveRequiredCredential(string? token, string? bootstrapToken, string dataPath)
{
var gatewayToken = NormalizeOptionalCredential(token);
if (!string.IsNullOrEmpty(gatewayToken))
@ -112,6 +112,12 @@ public class WindowsNodeClient : WebSocketClientBase
return bootstrap;
}
var storedDeviceToken = DeviceIdentity.TryReadStoredDeviceToken(dataPath);
if (!string.IsNullOrEmpty(storedDeviceToken))
{
return storedDeviceToken;
}
throw new ArgumentException("Token or bootstrap token is required.", nameof(token));
}

View File

@ -41,6 +41,31 @@ public class WindowsNodeClientTests
}
}
[Fact]
public void Constructor_AllowsStoredDeviceTokenWithoutGatewayOrBootstrapToken()
{
var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(dataPath);
try
{
var identity = new DeviceIdentity(dataPath);
identity.Initialize();
identity.StoreDeviceToken("stored-device-token");
using var client = new WindowsNodeClient("ws://localhost:18789", "", dataPath);
Assert.True(client.IsPaired);
}
finally
{
if (Directory.Exists(dataPath))
{
Directory.Delete(dataPath, true);
}
}
}
/// <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