From 392a2478baa11df7bd0a8e8b6c38f8d0499fef0e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 1 Jun 2026 03:39:45 +0100 Subject: [PATCH 1/2] fix(pairing): surface durable reconnect state --- .../ConnectionStateMachine.cs | 20 +++++ .../GatewayConnectionManager.cs | 5 ++ .../GatewayConnectionSnapshot.cs | 2 + .../Pages/CompletePage.xaml.cs | 2 + .../OpenClaw.SetupEngine.csproj | 1 + .../StartupTaskRegistration.cs | 27 ++++++ .../TrayArtifactCleanup.cs | 8 +- src/OpenClaw.Shared/InstanceMerger.cs | 17 +++- src/OpenClaw.Shared/OpenClaw.Shared.csproj | 1 + .../WindowsStartupTaskRegistration.cs | 89 +++++++++++++++++++ .../Pages/ConnectionPage.xaml.cs | 29 +++++- .../Pages/ConnectionPagePlan.cs | 20 ++++- .../Services/AutoStartManager.cs | 44 ++++++--- .../GatewayConnectionManagerTests.cs | 4 + .../TrayExecutableResolverTests.cs | 39 ++++++++ .../InstanceMergerTests.cs | 23 +++++ .../ConnectionPageApproveCommandTests.cs | 88 ++++++++++++++++++ 17 files changed, 400 insertions(+), 19 deletions(-) create mode 100644 src/OpenClaw.SetupEngine/StartupTaskRegistration.cs create mode 100644 src/OpenClaw.Shared/WindowsStartupTaskRegistration.cs diff --git a/src/OpenClaw.Connection/ConnectionStateMachine.cs b/src/OpenClaw.Connection/ConnectionStateMachine.cs index 1b9a288f8..7613e6122 100644 --- a/src/OpenClaw.Connection/ConnectionStateMachine.cs +++ b/src/OpenClaw.Connection/ConnectionStateMachine.cs @@ -13,6 +13,8 @@ internal sealed class ConnectionStateMachine private RoleConnectionState _nodeState = RoleConnectionState.Idle; private string? _operatorError; private string? _nodeError; + private string? _operatorCredentialSource; + private string? _nodeCredentialSource; private bool _nodeEnabled; /// @@ -134,6 +136,8 @@ public void Reset() _nodeState = _nodeEnabled ? RoleConnectionState.Idle : RoleConnectionState.Disabled; _operatorError = null; _nodeError = null; + _operatorCredentialSource = null; + _nodeCredentialSource = null; RebuildSnapshot(); } @@ -154,6 +158,12 @@ internal void SetOperatorDeviceId(string? deviceId) Current = Current with { OperatorDeviceId = deviceId }; } + internal void SetOperatorCredentialSource(string? source) + { + _operatorCredentialSource = source; + RebuildSnapshot(); + } + /// Update node info (device ID, pairing status, optional request ID) in the snapshot. internal void SetNodeInfo( string? deviceId, @@ -183,6 +193,12 @@ internal void SetNodeInfo( }; } + internal void SetNodeCredentialSource(string? source) + { + _nodeCredentialSource = source; + RebuildSnapshot(); + } + /// Update the operator pairing request ID in the snapshot. internal void SetOperatorPairingRequestId(string? requestId) { @@ -257,6 +273,8 @@ private void ApplyTransition(ConnectionTrigger trigger, string? detail) _nodeState = _nodeEnabled ? RoleConnectionState.Idle : RoleConnectionState.Disabled; _operatorError = null; _nodeError = null; + _operatorCredentialSource = null; + _nodeCredentialSource = null; break; case ConnectionTrigger.ReconnectScheduled: @@ -317,12 +335,14 @@ private void RebuildSnapshot() OverallState = GatewayConnectionSnapshot.DeriveOverall(_operatorState, _nodeState, _nodeEnabled), OperatorState = _operatorState, OperatorError = _operatorError, + OperatorCredentialSource = _operatorCredentialSource, OperatorPairingRequired = _operatorState == RoleConnectionState.PairingRequired, // Clear requestId when no longer in PairingRequired to prevent stale reads OperatorPairingRequestId = _operatorState == RoleConnectionState.PairingRequired ? Current.OperatorPairingRequestId : null, NodeState = _nodeState, NodeError = _nodeError, + NodeCredentialSource = _nodeCredentialSource, // Clear requestId when no longer in PairingRequired to prevent stale reads NodePairingRequestId = _nodeState == RoleConnectionState.PairingRequired ? Current.NodePairingRequestId : null, diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs index 30a5e8155..2fa987cb1 100644 --- a/src/OpenClaw.Connection/GatewayConnectionManager.cs +++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs @@ -209,6 +209,7 @@ private async Task ConnectCoreAsync(string? gatewayId = null) var prev = _stateMachine.Current.OverallState; // Must go through Connecting → Error since AuthenticationFailed requires Connecting state _stateMachine.TryTransition(ConnectionTrigger.ConnectRequested); + _stateMachine.SetOperatorCredentialSource(null); _stateMachine.TryTransition(ConnectionTrigger.AuthenticationFailed, "No credential available"); EmitStateChanged(prev); return; @@ -217,6 +218,7 @@ private async Task ConnectCoreAsync(string? gatewayId = null) // Transition to Connecting var prevState = _stateMachine.Current.OverallState; _stateMachine.TryTransition(ConnectionTrigger.ConnectRequested); + _stateMachine.SetOperatorCredentialSource(credential.Source); _diagnostics.RecordStateChange(prevState, _stateMachine.Current.OverallState); EmitStateChanged(prevState); @@ -393,6 +395,8 @@ tunnel.SshPort is < 1 or > 65535 || }; _diagnostics.RecordCredentialResolution(nodeCredential); + _stateMachine.SetOperatorCredentialSource(null); + _stateMachine.SetNodeCredentialSource(nodeCredential.Source); _diagnostics.Record("node", $"Starting node-only connection to {record.Url}", $"Credential source: {nodeCredential.Source}"); @@ -1214,6 +1218,7 @@ private async Task StartNodeConnectionCoreAsync( try { _stateMachine.SetNodeEnabled(true); + _stateMachine.SetNodeCredentialSource(nodeCredential.Source); } finally { diff --git a/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs b/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs index 1f4300c66..28fd05879 100644 --- a/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs +++ b/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs @@ -14,6 +14,7 @@ public sealed record GatewayConnectionSnapshot public string? OperatorError { get; init; } public bool OperatorPairingRequired { get; init; } public string? OperatorDeviceId { get; init; } + public string? OperatorCredentialSource { get; init; } /// /// The requestId returned by the gateway when operator pairing is required. /// Used by setup flows to approve the specific pairing request via CLI. @@ -25,6 +26,7 @@ public sealed record GatewayConnectionSnapshot public string? NodeError { get; init; } public OpenClaw.Shared.PairingStatus NodePairingStatus { get; init; } public string? NodeDeviceId { get; init; } + public string? NodeCredentialSource { get; init; } /// /// The requestId returned by the gateway when node pairing is required. /// Used by the connection page to show the correct approval command. diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml.cs index fb53c77eb..bd580f758 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml.cs @@ -11,6 +11,8 @@ namespace OpenClaw.SetupEngine.UI.Pages; public sealed partial class CompletePage : Page { + private const string StartupRunKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; + private const string StartupRunValue = "OpenClawTray"; private static readonly Regex s_urlRegex = new(@"https?://[^\s)]+", RegexOptions.Compiled | RegexOptions.IgnoreCase); private string? _logPath; diff --git a/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj b/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj index 270d591c1..2a54747e9 100644 --- a/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj +++ b/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj @@ -9,6 +9,7 @@ + diff --git a/src/OpenClaw.SetupEngine/StartupTaskRegistration.cs b/src/OpenClaw.SetupEngine/StartupTaskRegistration.cs new file mode 100644 index 000000000..8908fa962 --- /dev/null +++ b/src/OpenClaw.SetupEngine/StartupTaskRegistration.cs @@ -0,0 +1,27 @@ +using System.Diagnostics; +using OpenClaw.Shared; + +namespace OpenClaw.SetupEngine; + +public static class StartupTaskRegistration +{ + internal const string TaskName = WindowsStartupTaskRegistration.TaskName; + + public static bool Register(string trayExecutablePath) => + WindowsStartupTaskRegistration.Register(trayExecutablePath); + + public static bool Unregister() => + WindowsStartupTaskRegistration.Unregister(); + + internal static ProcessStartInfo CreateRegisterProcessStartInfo(string trayExecutablePath) => + WindowsStartupTaskRegistration.CreateRegisterProcessStartInfo(trayExecutablePath); + + internal static ProcessStartInfo CreateUnregisterProcessStartInfo() => + WindowsStartupTaskRegistration.CreateUnregisterProcessStartInfo(); + + internal static ProcessStartInfo CreateQueryProcessStartInfo() => + WindowsStartupTaskRegistration.CreateQueryProcessStartInfo(); + + internal static string ResolveSchtasksPath() => + WindowsStartupTaskRegistration.ResolveSchtasksPath(); +} diff --git a/src/OpenClaw.SetupEngine/TrayArtifactCleanup.cs b/src/OpenClaw.SetupEngine/TrayArtifactCleanup.cs index 49264b575..0dbec0ae0 100644 --- a/src/OpenClaw.SetupEngine/TrayArtifactCleanup.cs +++ b/src/OpenClaw.SetupEngine/TrayArtifactCleanup.cs @@ -1,6 +1,7 @@ using System.Runtime.Versioning; using Microsoft.Win32; using OpenClaw.Connection; +using OpenClaw.Shared; namespace OpenClaw.SetupEngine; @@ -20,7 +21,7 @@ public static void Run(SetupContext ctx, bool preserveLogs = false) var appDataDir = ctx.DataDir; // %APPDATA%\OpenClawTray var localDataDir = ctx.LocalDataDir; - // 1. Remove autostart registry key + // 1. Remove autostart entries try { using var key = Registry.CurrentUser.OpenSubKey(AutoStartKey, writable: true); @@ -39,6 +40,11 @@ public static void Run(SetupContext ctx, bool preserveLogs = false) logger.Warn($"[Uninstall] Failed to remove autostart registry key: {ex.Message}"); } + if (WindowsStartupTaskRegistration.Unregister()) + logger.Info("[Uninstall] Removed autostart scheduled task"); + else + logger.Info("[Uninstall] Autostart scheduled task already absent or unavailable"); + // 2. Delete run.marker DeleteFileIfExists(Path.Combine(localDataDir, "run.marker"), "run.marker", logger); diff --git a/src/OpenClaw.Shared/InstanceMerger.cs b/src/OpenClaw.Shared/InstanceMerger.cs index 61efc5aca..e397e9899 100644 --- a/src/OpenClaw.Shared/InstanceMerger.cs +++ b/src/OpenClaw.Shared/InstanceMerger.cs @@ -245,7 +245,7 @@ private static MergedInstance BuildFromPresence( IsThisInstance = !isGateway && IsLocalIdentity(node, p, options), DisplayName = node?.DisplayName is { Length: > 0 } dn ? dn : p.DisplayName, Ip = p.Ip ?? node?.RemoteIp, - Version = p.Version ?? node?.Version, + Version = p.Version ?? DisplayVersionForNode(node, hasPresence: true), Platform = p.Platform ?? node?.Platform, DeviceFamily = p.DeviceFamily ?? node?.DeviceFamily, ModelIdentifier = p.ModelIdentifier ?? node?.ModelIdentifier, @@ -279,7 +279,7 @@ private static MergedInstance BuildFromOrphanNode( IsThisInstance = IsLocalIdentity(node, presence: null, options), DisplayName = string.IsNullOrWhiteSpace(node.DisplayName) ? node.ShortId : node.DisplayName, Ip = node.RemoteIp, - Version = node.Version, + Version = DisplayVersionForNode(node, hasPresence: false), Platform = node.Platform, DeviceFamily = node.DeviceFamily, ModelIdentifier = node.ModelIdentifier, @@ -310,6 +310,19 @@ private static MergedInstance BuildFromOrphanNode( return n; } + private static string? DisplayVersionForNode(GatewayNodeInfo? node, bool hasPresence) + { + var version = node?.Version; + if (!hasPresence && + node is { IsOnline: false } && + string.Equals(version?.Trim(), "1.0.0", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return version; + } + private static PresenceStatus ClassifyPresence( PresenceEntry p, DateTime nowUtc, diff --git a/src/OpenClaw.Shared/OpenClaw.Shared.csproj b/src/OpenClaw.Shared/OpenClaw.Shared.csproj index 392129c7c..c04b9c398 100644 --- a/src/OpenClaw.Shared/OpenClaw.Shared.csproj +++ b/src/OpenClaw.Shared/OpenClaw.Shared.csproj @@ -8,6 +8,7 @@ + diff --git a/src/OpenClaw.Shared/WindowsStartupTaskRegistration.cs b/src/OpenClaw.Shared/WindowsStartupTaskRegistration.cs new file mode 100644 index 000000000..93cc0e91d --- /dev/null +++ b/src/OpenClaw.Shared/WindowsStartupTaskRegistration.cs @@ -0,0 +1,89 @@ +using System.Diagnostics; + +namespace OpenClaw.Shared; + +public static class WindowsStartupTaskRegistration +{ + public const string TaskName = "OpenClaw Companion"; + + public static bool Register(string trayExecutablePath) + { + if (string.IsNullOrWhiteSpace(trayExecutablePath) || !File.Exists(trayExecutablePath)) + return false; + + return Run(CreateRegisterProcessStartInfo(trayExecutablePath)); + } + + public static bool Unregister() => Run(CreateUnregisterProcessStartInfo()); + + public static bool Exists() => Run(CreateQueryProcessStartInfo()); + + internal static ProcessStartInfo CreateRegisterProcessStartInfo(string trayExecutablePath) + { + var fullPath = Path.GetFullPath(trayExecutablePath); + return CreateStartInfo( + "/Create", + "/TN", TaskName, + "/TR", Quote(fullPath), + "/SC", "ONLOGON", + "/F"); + } + + internal static ProcessStartInfo CreateUnregisterProcessStartInfo() => + CreateStartInfo( + "/Delete", + "/TN", TaskName, + "/F"); + + internal static ProcessStartInfo CreateQueryProcessStartInfo() => + CreateStartInfo( + "/Query", + "/TN", TaskName); + + private static bool Run(ProcessStartInfo startInfo) + { + try + { + using var process = Process.Start(startInfo); + if (process == null) + return false; + + process.WaitForExit(10_000); + return process.HasExited && process.ExitCode == 0; + } + catch + { + return false; + } + } + + internal static string ResolveSchtasksPath() + { + var systemRoot = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + if (string.IsNullOrWhiteSpace(systemRoot)) + systemRoot = Environment.GetEnvironmentVariable("SystemRoot"); + + return !string.IsNullOrWhiteSpace(systemRoot) + ? Path.Combine(systemRoot, "System32", "schtasks.exe") + : Path.Combine("C:\\", "Windows", "System32", "schtasks.exe"); + } + + private static ProcessStartInfo CreateStartInfo(params string[] arguments) + { + var startInfo = new ProcessStartInfo + { + FileName = ResolveSchtasksPath(), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + foreach (var argument in arguments) + startInfo.ArgumentList.Add(argument); + + return startInfo; + } + + private static string Quote(string value) => "\"" + value.Replace("\"", "\\\"") + "\""; +} diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index 75d7947b1..651e2ed3a 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -1205,9 +1205,7 @@ private void LoadSavedGateways() HasWslGateway = hostAccess.IsWslManaged, HasHostTerminal = hostAccess.CanOpenTerminal, HostTerminalLabel = hostAccess.TerminalLabel, - AuthModeLabel = isActive && !string.IsNullOrEmpty(activeAuthMode) - ? activeAuthMode! - : InferAuthModeLabel(gw), + AuthModeLabel = BuildAuthModeLabel(gw, isActive, _lastSnapshot, activeAuthMode), }); } if (all.Count > 0) emptyVisible = Visibility.Collapsed; @@ -1243,6 +1241,26 @@ private void LoadSavedGateways() : string.Format(LocalizationHelper.GetString("ConnectionPage_SavedGatewaysPlural"), items.Count); } + private static string BuildAuthModeLabel( + GatewayRecord rec, + bool isActive, + GatewayConnectionSnapshot snapshot, + string? activeAuthMode) + { + if (isActive) + { + var credentialLabel = ConnectionPagePlan.FormatCredentialSource( + snapshot.OperatorCredentialSource ?? snapshot.NodeCredentialSource); + if (!string.IsNullOrEmpty(credentialLabel)) + return credentialLabel; + + if (!string.IsNullOrEmpty(activeAuthMode)) + return NormalizeGatewayAuthMode(activeAuthMode!); + } + + return InferAuthModeLabel(rec); + } + private static string InferAuthModeLabel(GatewayRecord rec) { if (!string.IsNullOrEmpty(rec.BootstrapToken)) return LocalizationHelper.GetString("ConnectionPage_AuthModeBootstrap"); @@ -1252,6 +1270,11 @@ private static string InferAuthModeLabel(GatewayRecord rec) return LocalizationHelper.GetString("ConnectionPage_AuthModeDeviceToken"); } + private static string NormalizeGatewayAuthMode(string authMode) => + string.Equals(authMode, "device-token", StringComparison.OrdinalIgnoreCase) + ? "paired via device token" + : authMode; + private List BuildSavedGatewayRowControls(IEnumerable rows) { var list = new List(); diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs index f3f4fb6a2..c40725d4c 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs @@ -307,7 +307,7 @@ private static ConnectionPagePlan BuildCockpitConnected( string name) { var url = ConnectionCardPlanSanitizer.SanitizeGatewayUrl(rec?.Url ?? snap.GatewayUrl); - var sub = BuildConnectedDetailLine(rec, self); + var sub = BuildConnectedDetailLine(rec, self, snap); return new ConnectionPagePlan { @@ -363,7 +363,7 @@ private static ConnectionPagePlan BuildCockpitDegraded( NodeApproveCommand = BuildNodeApproveCommand(snap), NodeErrorDetail = ExtractNodeErrorDetail(snap), ActiveGatewayDisplayName = name, - ActiveGatewayDetailLine = BuildConnectedDetailLine(rec, self), + ActiveGatewayDetailLine = BuildConnectedDetailLine(rec, self, snap), ActiveGatewayHasSshTunnel = rec?.SshTunnel != null, RelevantGatewayId = rec?.Id, }; @@ -667,18 +667,32 @@ _ when CountEnabledCapabilities(settings) == 0 => NodeCardState.OnPermissionsInc return ConnectionCardPlanSanitizer.Sanitize(snap.NodeError!); } - private static string BuildConnectedDetailLine(GatewayRecord? rec, GatewaySelfInfo? self) + private static string BuildConnectedDetailLine(GatewayRecord? rec, GatewaySelfInfo? self, GatewayConnectionSnapshot snap) { var bits = new List(4); var url = ConnectionCardPlanSanitizer.SanitizeGatewayUrl(rec?.Url); if (!string.IsNullOrEmpty(url)) bits.Add(url); if (rec?.SshTunnel != null) bits.Add("via SSH tunnel"); + var credential = FormatCredentialSource(snap.OperatorCredentialSource ?? snap.NodeCredentialSource); + if (!string.IsNullOrEmpty(credential)) bits.Add(credential); if (!string.IsNullOrWhiteSpace(self?.ServerVersion)) bits.Add($"v{self!.ServerVersion}"); if (self?.UptimeMs is long uptime && uptime > 0) bits.Add($"up {FormatUptime(uptime)}"); return string.Join(" • ", bits); } + internal static string FormatCredentialSource(string? source) + { + return source switch + { + CredentialResolver.SourceNodeDeviceToken => "paired via node device token", + CredentialResolver.SourceDeviceToken => "paired via device token", + CredentialResolver.SourceSharedGatewayToken => "shared token", + CredentialResolver.SourceBootstrapToken => "bootstrap token", + _ => "", + }; + } + private static int CountEnabledCapabilities(SettingsManager s) { int n = 0; diff --git a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs index dd5d36da1..675124ed3 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs @@ -1,4 +1,5 @@ using Microsoft.Win32; +using OpenClaw.Shared; using System; namespace OpenClawTray.Services; @@ -16,34 +17,44 @@ public static bool IsAutoStartEnabled() try { using var key = Registry.CurrentUser.OpenSubKey(RegistryKey, false); - return key?.GetValue(AppName) != null; + if (key?.GetValue(AppName) != null) + return true; } catch { - return false; } + + return WindowsStartupTaskRegistration.Exists(); } public static void SetAutoStart(bool enable) { try { - using var key = Registry.CurrentUser.CreateSubKey(RegistryKey, true); - if (key == null) - { - Logger.Warn($"Auto-start registry key unavailable: HKCU\\{RegistryKey}"); - return; - } - if (enable) { var exePath = Environment.ProcessPath ?? System.Reflection.Assembly.GetExecutingAssembly().Location; + if (WindowsStartupTaskRegistration.Register(exePath)) + { + DeleteRunKey(); + Logger.Info("Auto-start enabled via scheduled task"); + return; + } + + using var key = Registry.CurrentUser.CreateSubKey(RegistryKey, true); + if (key == null) + { + Logger.Warn($"Auto-start registry key unavailable: HKCU\\{RegistryKey}"); + return; + } + key.SetValue(AppName, $"\"{exePath}\""); Logger.Info("Auto-start enabled"); } else { - key.DeleteValue(AppName, false); + DeleteRunKey(); + WindowsStartupTaskRegistration.Unregister(); Logger.Info("Auto-start disabled"); } } @@ -52,4 +63,17 @@ public static void SetAutoStart(bool enable) Logger.Error($"Failed to set auto-start: {ex.Message}"); } } + + private static void DeleteRunKey() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(RegistryKey, writable: true); + key?.DeleteValue(AppName, false); + } + catch (Exception ex) + { + Logger.Warn($"Failed to remove auto-start registry key: {ex.Message}"); + } + } } diff --git a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs index 3ef30f4ab..1b4679e2e 100644 --- a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs +++ b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs @@ -70,6 +70,7 @@ public async Task ConnectAsync_WithCredential_TransitionsToConnecting() Assert.Equal(OverallConnectionState.Connecting, _manager.CurrentSnapshot.OverallState); Assert.Equal("wss://test", _manager.ActiveGatewayUrl); Assert.Equal("gw-1", _manager.CurrentSnapshot.GatewayId); + Assert.Equal("test", _manager.CurrentSnapshot.OperatorCredentialSource); } /// @@ -582,6 +583,8 @@ public async Task ConnectNodeOnlyAsync_UsesNodeCredential_WhenOperatorCredential Assert.Empty(_factory.CreatedCredentials); Assert.Equal(1, node.ConnectCount); Assert.Equal("wss://test", node.LastGatewayUrl); + Assert.Null(manager.CurrentSnapshot.OperatorCredentialSource); + Assert.Equal(CredentialResolver.SourceNodeDeviceToken, manager.CurrentSnapshot.NodeCredentialSource); } [Fact] @@ -718,6 +721,7 @@ public async Task ConnectNodeOnlyAsync_StartsSshTunnel_WhenGatewayUsesTunnel() Assert.Equal("host.example", tunnel.LastConfig?.Host); Assert.Equal(2222, tunnel.LastConfig?.SshPort); Assert.Equal("ws://localhost:45678", node.LastGatewayUrl); + Assert.Equal(CredentialResolver.SourceNodeDeviceToken, manager.CurrentSnapshot.NodeCredentialSource); } [Fact] diff --git a/tests/OpenClaw.SetupEngine.Tests/TrayExecutableResolverTests.cs b/tests/OpenClaw.SetupEngine.Tests/TrayExecutableResolverTests.cs index dda51bfd2..a1ae9ea10 100644 --- a/tests/OpenClaw.SetupEngine.Tests/TrayExecutableResolverTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/TrayExecutableResolverTests.cs @@ -44,4 +44,43 @@ public void Resolve_ReturnsNull_WhenTrayExecutableIsMissing() Assert.Null(resolved); } + + [Fact] + public void StartupTaskRegistration_UsesLogonTrigger() + { + var trayPath = Path.Combine(_tempDir, "OpenClaw Tray", "OpenClaw.Tray.WinUI.exe"); + Directory.CreateDirectory(Path.GetDirectoryName(trayPath)!); + File.WriteAllText(trayPath, string.Empty); + + var psi = StartupTaskRegistration.CreateRegisterProcessStartInfo(trayPath); + + Assert.Equal(StartupTaskRegistration.ResolveSchtasksPath(), psi.FileName); + Assert.Contains("/SC", psi.ArgumentList); + Assert.Contains("ONLOGON", psi.ArgumentList); + Assert.Contains("OpenClaw Companion", psi.ArgumentList); + Assert.Contains("\"" + trayPath + "\"", psi.ArgumentList); + } + + [Fact] + public void StartupTaskRegistration_UnregisterDeletesTask() + { + var psi = StartupTaskRegistration.CreateUnregisterProcessStartInfo(); + + Assert.Equal(StartupTaskRegistration.ResolveSchtasksPath(), psi.FileName); + Assert.Contains("/Delete", psi.ArgumentList); + Assert.Contains("/TN", psi.ArgumentList); + Assert.Contains("OpenClaw Companion", psi.ArgumentList); + Assert.Contains("/F", psi.ArgumentList); + } + + [Fact] + public void StartupTaskRegistration_QueryChecksTask() + { + var psi = StartupTaskRegistration.CreateQueryProcessStartInfo(); + + Assert.Equal(StartupTaskRegistration.ResolveSchtasksPath(), psi.FileName); + Assert.Contains("/Query", psi.ArgumentList); + Assert.Contains("/TN", psi.ArgumentList); + Assert.Contains("OpenClaw Companion", psi.ArgumentList); + } } diff --git a/tests/OpenClaw.Shared.Tests/InstanceMergerTests.cs b/tests/OpenClaw.Shared.Tests/InstanceMergerTests.cs index ce21ffc70..3a2deea96 100644 --- a/tests/OpenClaw.Shared.Tests/InstanceMergerTests.cs +++ b/tests/OpenClaw.Shared.Tests/InstanceMergerTests.cs @@ -549,4 +549,27 @@ public void Strong_DeviceId_Match_Wins_Over_Earlier_Weak_Host_Match() // not the one that only matched by host (p1). Assert.Equal("alpha", managedRow.Presence?.DeviceId); } + + [Fact] + public void OfflineOrphanNode_HidesPlaceholderVersion() + { + var n = Node("stale-node", displayName: "Old Windows", isOnline: false); + n.Version = "1.0.0"; + + var result = InstanceMerger.Merge(new[] { n }, presence: null, Options()); + + Assert.Null(result[0].Version); + } + + [Fact] + public void MatchedNode_KeepsPlaceholderVersion() + { + var p = Presence(deviceId: "node-x", host: "PC"); + var n = Node("node-x", isOnline: true); + n.Version = "1.0.0"; + + var result = InstanceMerger.Merge(new[] { n }, new[] { p }, Options()); + + Assert.Equal("1.0.0", result[0].Version); + } } diff --git a/tests/OpenClaw.Tray.Tests/ConnectionPageApproveCommandTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionPageApproveCommandTests.cs index c6204f1e3..d2ee1cba1 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionPageApproveCommandTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionPageApproveCommandTests.cs @@ -12,6 +12,67 @@ namespace OpenClaw.Tray.Tests; /// public sealed class ConnectionPageApproveCommandTests { + private static string ReadPlanSource() + { + var path = Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Pages", "ConnectionPagePlan.cs"); + return File.ReadAllText(path); + } + + private static string ReadConnectionPageSource() + { + var path = Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Pages", "ConnectionPage.xaml.cs"); + return File.ReadAllText(path); + } + + private static string GetRepositoryRoot() + { + var env = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); + if (!string.IsNullOrWhiteSpace(env) && Directory.Exists(env)) + return env; + + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "openclaw-windows-node.slnx")) && + Directory.Exists(Path.Combine(directory.FullName, "src"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + var callerFile = ThisFile.Path; + if (!string.IsNullOrEmpty(callerFile) && File.Exists(callerFile)) + { + var probe = new DirectoryInfo(Path.GetDirectoryName(callerFile)!); + while (probe != null) + { + if (File.Exists(Path.Combine(probe.FullName, "openclaw-windows-node.slnx")) && + Directory.Exists(Path.Combine(probe.FullName, "src"))) + { + return probe.FullName; + } + + probe = probe.Parent; + } + } + + throw new InvalidOperationException( + "Could not find repository root. Set OPENCLAW_REPO_ROOT to the repo path."); + } + + private static class ThisFile + { + public static readonly string Path = Capture(); + private static string Capture([System.Runtime.CompilerServices.CallerFilePath] string filePath = "") + => filePath; + } + [Fact] public void NodeTrustApproveCommand_UsesNounFirstSubcommandBeforeNodeListArrives() { @@ -64,6 +125,33 @@ public void MissingDevicePairRequestId_EmitsDiscoveryCommand_NotDeviceId( AssertShellSafeCommand("openclaw devices list", plan.NodeApproveCommand); } + [Fact] + public void GatewayCredentialDisplay_PrefersOperatorCredentialOverNodeCredential() + { + var planSource = ReadPlanSource(); + var pageSource = ReadConnectionPageSource(); + + Assert.Contains( + "snap.OperatorCredentialSource ?? snap.NodeCredentialSource", + planSource); + Assert.DoesNotContain( + "snap.NodeCredentialSource ?? snap.OperatorCredentialSource", + planSource); + + Assert.Contains( + "snapshot.OperatorCredentialSource ?? snapshot.NodeCredentialSource", + pageSource); + Assert.DoesNotContain( + "snapshot.NodeCredentialSource ?? snapshot.OperatorCredentialSource", + pageSource); + } + + private static void AssertContainsCli(string source, string expected, string message) + { + if (!source.Contains(expected, System.StringComparison.Ordinal)) + Assert.Fail($"Expected source to contain `{expected}`. {message}"); + } + [Fact] public void MissingNodeTrustRequestId_EmitsShellSafeDiscoveryCommand_NotBareApprove() { From 12a84b80884a78f24e06abcbc8203e846fe07353 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Mon, 22 Jun 2026 15:03:26 -0700 Subject: [PATCH 2/2] fix(pairing): preserve credential source during node refresh Preserve the operator credential source when reconnecting the Windows node without replacing an existing same-gateway operator connection. Move the scheduled-task auto-start work off the WinUI thread and terminate schtasks.exe if it times out. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GatewayConnectionManager.cs | 5 ++++- .../Pages/CompletePage.xaml.cs | 2 -- .../WindowsStartupTaskRegistration.cs | 14 ++++++++++++-- src/OpenClaw.Tray.WinUI/App.xaml.cs | 16 ++++++++++++---- .../Pages/SettingsPage.xaml.cs | 11 +++++++++-- .../Services/AutoStartManager.cs | 6 +++++- .../GatewayConnectionManagerTests.cs | 8 ++++++-- .../ConnectionPageApproveCommandTests.cs | 6 ------ 8 files changed, 48 insertions(+), 20 deletions(-) diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs index 2fa987cb1..25d8125a6 100644 --- a/src/OpenClaw.Connection/GatewayConnectionManager.cs +++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs @@ -372,6 +372,9 @@ tunnel.SshPort is < 1 or > 65535 || string.Equals(_activeGatewayRecordId, record.Id, StringComparison.Ordinal) && string.Equals(_stateMachine.Current.GatewayUrl, record.Url, StringComparison.Ordinal) && Equals(_activeSshTunnel, record.SshTunnel); + var operatorCredentialSource = preservesOperatorConnection + ? _stateMachine.Current.OperatorCredentialSource + : null; var gen = Interlocked.Read(ref _generation); if (!preservesOperatorConnection) { @@ -395,7 +398,7 @@ tunnel.SshPort is < 1 or > 65535 || }; _diagnostics.RecordCredentialResolution(nodeCredential); - _stateMachine.SetOperatorCredentialSource(null); + _stateMachine.SetOperatorCredentialSource(operatorCredentialSource); _stateMachine.SetNodeCredentialSource(nodeCredential.Source); _diagnostics.Record("node", $"Starting node-only connection to {record.Url}", $"Credential source: {nodeCredential.Source}"); diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml.cs index bd580f758..fb53c77eb 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml.cs @@ -11,8 +11,6 @@ namespace OpenClaw.SetupEngine.UI.Pages; public sealed partial class CompletePage : Page { - private const string StartupRunKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; - private const string StartupRunValue = "OpenClawTray"; private static readonly Regex s_urlRegex = new(@"https?://[^\s)]+", RegexOptions.Compiled | RegexOptions.IgnoreCase); private string? _logPath; diff --git a/src/OpenClaw.Shared/WindowsStartupTaskRegistration.cs b/src/OpenClaw.Shared/WindowsStartupTaskRegistration.cs index 93cc0e91d..b36ca72bd 100644 --- a/src/OpenClaw.Shared/WindowsStartupTaskRegistration.cs +++ b/src/OpenClaw.Shared/WindowsStartupTaskRegistration.cs @@ -48,8 +48,18 @@ private static bool Run(ProcessStartInfo startInfo) if (process == null) return false; - process.WaitForExit(10_000); - return process.HasExited && process.ExitCode == 0; + if (process.WaitForExit(10_000)) + return process.ExitCode == 0; + + try + { + process.Kill(entireProcessTree: false); + } + catch + { + } + + return false; } catch { diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index d1a6e3372..79b7181f5 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -2900,7 +2900,9 @@ private void OnSettingsSaved(object? sender, EventArgs e) _globalHotkey?.Unregister(); } - AutoStartManager.SetAutoStart(_settings.AutoStart); + ObserveBackgroundFault( + AutoStartManager.SetAutoStartAsync(_settings.AutoStart), + "[App] Failed to apply auto-start setting"); // Notify ad-hoc listeners (e.g. ChatWindow may be alive but not // owned by the hub) that settings have changed. Marshal onto the @@ -3189,7 +3191,7 @@ private async Task RestartAfterSetupAsync(bool enableAutoStart) { try { - AutoStartManager.SetAutoStart(true); + await AutoStartManager.SetAutoStartAsync(true); } catch (Exception ex) { @@ -3402,12 +3404,18 @@ private async Task ToggleChannelAsync(string channelName) } } - private void ToggleAutoStart() + private void ToggleAutoStart() => + AsyncEventHandlerGuard.Run( + ToggleAutoStartAsync, + new AppLogger(), + nameof(ToggleAutoStart)); + + private async Task ToggleAutoStartAsync() { if (_settings == null) return; _settings.AutoStart = !_settings.AutoStart; _settings.Save(); - AutoStartManager.SetAutoStart(_settings.AutoStart); + await AutoStartManager.SetAutoStartAsync(_settings.AutoStart); } private void OpenLogFile() diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs index b54375182..540a9a739 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs @@ -8,6 +8,7 @@ using System.Diagnostics; using System.IO; using System.Threading; +using System.Threading.Tasks; namespace OpenClawTray.Pages; @@ -117,7 +118,13 @@ private void Persist(Action mutate) } } - private void PersistAutoStart() + private void PersistAutoStart() => + AsyncEventHandlerGuard.Run( + PersistAutoStartAsync, + new OpenClawTray.AppLogger(), + nameof(PersistAutoStart)); + + private async Task PersistAutoStartAsync() { if (_loading || CurrentApp.Settings == null) return; _saving = true; @@ -125,7 +132,7 @@ private void PersistAutoStart() { CurrentApp.Settings.AutoStart = AutoStartToggle.IsOn; CurrentApp.Settings.Save(); - AutoStartManager.SetAutoStart(CurrentApp.Settings.AutoStart); + await AutoStartManager.SetAutoStartAsync(CurrentApp.Settings.AutoStart); ((IAppCommands)CurrentApp).NotifySettingsSaved(); ShowSavedIndicator(); } diff --git a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs index 675124ed3..3cdae99a6 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs @@ -1,11 +1,12 @@ using Microsoft.Win32; using OpenClaw.Shared; using System; +using System.Threading.Tasks; namespace OpenClawTray.Services; /// -/// Manages Windows auto-start registry entries. +/// Manages Windows auto-start registration. /// public static class AutoStartManager { @@ -64,6 +65,9 @@ public static void SetAutoStart(bool enable) } } + public static Task SetAutoStartAsync(bool enable) => + Task.Run(() => SetAutoStart(enable)); + private static void DeleteRunKey() { try diff --git a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs index 1b4679e2e..8e82da628 100644 --- a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs +++ b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs @@ -591,8 +591,8 @@ public async Task ConnectNodeOnlyAsync_UsesNodeCredential_WhenOperatorCredential public async Task ConnectNodeOnlyAsync_PreservesConnectedOperatorForNodeListRefresh() { SetupGateway("gw-1", "wss://test"); - _resolver.OperatorCredential = new GatewayCredential("operator-token", false, "test"); - _resolver.NodeCredential = new GatewayCredential("node-token", false, "test"); + _resolver.OperatorCredential = new GatewayCredential("operator-token", false, CredentialResolver.SourceSharedGatewayToken); + _resolver.NodeCredential = new GatewayCredential("node-token", false, CredentialResolver.SourceNodeDeviceToken); var node = new CountingNodeConnector(); using var manager = new GatewayConnectionManager( _resolver, @@ -602,7 +602,9 @@ public async Task ConnectNodeOnlyAsync_PreservesConnectedOperatorForNodeListRefr nodeConnector: node); await manager.ConnectAsync("gw-1"); + Assert.Equal(CredentialResolver.SourceSharedGatewayToken, manager.CurrentSnapshot.OperatorCredentialSource); await InvokeHandshakeSucceededAsync(manager); + Assert.Equal(CredentialResolver.SourceSharedGatewayToken, manager.CurrentSnapshot.OperatorCredentialSource); var operatorLifecycle = Assert.Single(_factory.CreatedClients); var operatorClient = manager.OperatorClient; @@ -612,6 +614,8 @@ public async Task ConnectNodeOnlyAsync_PreservesConnectedOperatorForNodeListRefr Assert.Same(operatorClient, manager.OperatorClient); Assert.Single(_factory.CreatedClients); Assert.Equal(1, node.ConnectCount); + Assert.Equal(CredentialResolver.SourceSharedGatewayToken, manager.CurrentSnapshot.OperatorCredentialSource); + Assert.Equal(CredentialResolver.SourceNodeDeviceToken, manager.CurrentSnapshot.NodeCredentialSource); } [Fact] diff --git a/tests/OpenClaw.Tray.Tests/ConnectionPageApproveCommandTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionPageApproveCommandTests.cs index d2ee1cba1..47cc5d140 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionPageApproveCommandTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionPageApproveCommandTests.cs @@ -146,12 +146,6 @@ public void GatewayCredentialDisplay_PrefersOperatorCredentialOverNodeCredential pageSource); } - private static void AssertContainsCli(string source, string expected, string message) - { - if (!source.Contains(expected, System.StringComparison.Ordinal)) - Assert.Fail($"Expected source to contain `{expected}`. {message}"); - } - [Fact] public void MissingNodeTrustRequestId_EmitsShellSafeDiscoveryCommand_NotBareApprove() {