diff --git a/src/OpenClaw.Connection/DeviceIdentityStore.cs b/src/OpenClaw.Connection/DeviceIdentityStore.cs
index 1c491e8bf..9c80e731b 100644
--- a/src/OpenClaw.Connection/DeviceIdentityStore.cs
+++ b/src/OpenClaw.Connection/DeviceIdentityStore.cs
@@ -34,31 +34,14 @@ public void StoreToken(string identityPath, string token, string[]? scopes, stri
/// Clear stored device tokens from an identity file, keeping the keypair intact.
/// Strips DeviceToken, DeviceTokenScopes, NodeDeviceToken, and NodeDeviceTokenScopes
/// from the identity JSON while preserving keys, deviceId, algorithm, etc.
+ /// Writes atomically via temp-file + rename to prevent torn writes from
+ /// silently rotating device identity on crash/power-loss.
///
public static void ClearStoredTokens(string identityDir, IOpenClawLogger? logger = null)
{
- var keyPath = Path.Combine(identityDir, "device-key-ed25519.json");
- if (!File.Exists(keyPath)) return;
try
{
- var json = File.ReadAllText(keyPath);
- var doc = System.Text.Json.JsonDocument.Parse(json);
- var root = doc.RootElement;
-
- using var ms = new MemoryStream();
- using var writer = new System.Text.Json.Utf8JsonWriter(ms, new System.Text.Json.JsonWriterOptions { Indented = true });
- writer.WriteStartObject();
- foreach (var prop in root.EnumerateObject())
- {
- if (prop.Name is "DeviceToken" or "DeviceTokenScopes" or "NodeDeviceToken" or "NodeDeviceTokenScopes")
- continue;
- prop.WriteTo(writer);
- }
- writer.WriteEndObject();
- writer.Flush();
-
- File.WriteAllBytes(keyPath, ms.ToArray());
- logger?.Info($"[IdentityStore] Cleared stored device tokens from {identityDir}");
+ DeviceIdentity.TryClearAllDeviceTokens(identityDir, logger);
}
catch (Exception ex)
{
diff --git a/src/OpenClaw.Shared/DeviceIdentity.cs b/src/OpenClaw.Shared/DeviceIdentity.cs
index 3d66b4d75..bcb24cd7a 100644
--- a/src/OpenClaw.Shared/DeviceIdentity.cs
+++ b/src/OpenClaw.Shared/DeviceIdentity.cs
@@ -94,6 +94,77 @@ public static bool HasStoredDeviceTokenForRole(string dataPath, string role, IOp
public static bool TryClearDeviceToken(string dataPath, IOpenClawLogger? logger = null) =>
TryClearDeviceTokenForRole(dataPath, "operator", logger);
+ ///
+ /// Atomically clears all device-token fields (DeviceToken,
+ /// DeviceTokenScopes, NodeDeviceToken, NodeDeviceTokenScopes) from
+ /// device-key-ed25519.json while preserving the Ed25519 keypair,
+ /// deviceId, algorithm, and all other properties. Uses raw JSON filtering
+ /// so unknown/extra fields are preserved, and writes atomically via
+ /// temp-file + rename.
+ ///
+ ///
+ /// true if at least one token field was present and cleared;
+ /// false if the file was absent or already had no tokens.
+ ///
+ public static bool TryClearAllDeviceTokens(string dataPath, IOpenClawLogger? logger = null)
+ {
+ var keyPath = Path.Combine(dataPath, "device-key-ed25519.json");
+ if (!File.Exists(keyPath))
+ return false;
+
+ try
+ {
+ var json = File.ReadAllText(keyPath);
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ logger?.Warn("Failed to clear all device tokens: device-key-ed25519.json root is not a JSON object.");
+ return false;
+ }
+
+ bool hadTokens = false;
+ using var ms = new MemoryStream();
+ using (var writer = new System.Text.Json.Utf8JsonWriter(ms, new System.Text.Json.JsonWriterOptions { Indented = true }))
+ {
+ writer.WriteStartObject();
+ foreach (var prop in root.EnumerateObject())
+ {
+ if (prop.Name is "DeviceToken" or "DeviceTokenScopes" or "NodeDeviceToken" or "NodeDeviceTokenScopes")
+ {
+ hadTokens = true;
+ continue;
+ }
+ prop.WriteTo(writer);
+ }
+ writer.WriteEndObject();
+ }
+
+ if (!hadTokens)
+ return false;
+
+ var content = System.Text.Encoding.UTF8.GetString(ms.ToArray());
+ AtomicWriteKeyFileRaw(keyPath, content);
+ logger?.Info("All device tokens cleared from device-key-ed25519.json (keypair preserved).");
+ return true;
+ }
+ catch (IOException ex)
+ {
+ logger?.Warn($"Failed to clear all device tokens: {ex.Message}");
+ return false;
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ logger?.Warn($"Failed to clear all device tokens: {ex.Message}");
+ return false;
+ }
+ catch (JsonException ex)
+ {
+ logger?.Warn($"Failed to clear all device tokens: {ex.Message}");
+ return false;
+ }
+ }
+
///
/// Sets the role-specific device token field to null in
/// device-key-ed25519.json without deleting the file. Preserves the
@@ -523,12 +594,22 @@ private void StoreNodeDeviceTokenCore(string token, string[]? scopes)
private static void AtomicWriteKeyFile(string path, DeviceKeyData data)
{
var json = JsonSerializer.Serialize(data, JsonSerializerOptionsCache.WriteIndented);
+ AtomicWriteKeyFileRaw(path, json);
+ }
+
+ ///
+ /// Atomically writes pre-serialized JSON content to a device-key file path
+ /// using temp-file + rename. Use this when restoring a backup or writing
+ /// content that is already serialized.
+ ///
+ public static void AtomicWriteKeyFileRaw(string path, string jsonContent)
+ {
var dir = Path.GetDirectoryName(path);
var tempDir = string.IsNullOrEmpty(dir) ? Environment.CurrentDirectory : dir;
var tempPath = Path.Combine(tempDir, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
try
{
- File.WriteAllText(tempPath, json);
+ File.WriteAllText(tempPath, jsonContent);
McpAuthToken.TryRestrictSensitiveFileAcl(tempPath);
File.Move(tempPath, path, overwrite: true);
}
diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs
index dc5fb8ce2..0498ad20e 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs
@@ -2747,7 +2747,7 @@ private void RollbackDirectConnect(
fileUnchanged = true;
}
if (fileUnchanged)
- File.WriteAllText(identityKeyPath, identityBackup);
+ DeviceIdentity.AtomicWriteKeyFileRaw(identityKeyPath, identityBackup);
// else: another writer touched the file; preserve it.
}
catch (Exception ex)
diff --git a/tests/OpenClaw.Connection.Tests/DeviceIdentityStoreTests.cs b/tests/OpenClaw.Connection.Tests/DeviceIdentityStoreTests.cs
index e17f9c1f2..94b45bbc7 100644
--- a/tests/OpenClaw.Connection.Tests/DeviceIdentityStoreTests.cs
+++ b/tests/OpenClaw.Connection.Tests/DeviceIdentityStoreTests.cs
@@ -111,6 +111,18 @@ public void ClearStoredTokens_WhenFileAbsent_DoesNotThrow()
Assert.Null(ex);
}
+ [Fact]
+ public void ClearStoredTokens_WhenJsonRootIsNotObject_DoesNotThrow()
+ {
+ var path = Path.Combine(_tempDir, "device-key-ed25519.json");
+ File.WriteAllText(path, "[]");
+
+ var ex = Record.Exception(() => DeviceIdentityStore.ClearStoredTokens(_tempDir));
+
+ Assert.Null(ex);
+ Assert.Equal("[]", File.ReadAllText(path));
+ }
+
[Fact]
public void ClearStoredTokens_WhenNoTokenFields_PreservesAllProperties()
{