Skip to content

Commit 260fb90

Browse files
vincentkocshanselmanCopilot
authored
fix(pairing): clarify reconnect and startup state (#616)
* fix(pairing): surface durable reconnect state * 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> --------- Co-authored-by: Scott Hanselman <scott@hanselman.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a346b94 commit 260fb90

18 files changed

Lines changed: 437 additions & 28 deletions

src/OpenClaw.Connection/ConnectionStateMachine.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ internal sealed class ConnectionStateMachine
1313
private RoleConnectionState _nodeState = RoleConnectionState.Idle;
1414
private string? _operatorError;
1515
private string? _nodeError;
16+
private string? _operatorCredentialSource;
17+
private string? _nodeCredentialSource;
1618
private bool _nodeEnabled;
1719

1820
/// <summary>
@@ -134,6 +136,8 @@ public void Reset()
134136
_nodeState = _nodeEnabled ? RoleConnectionState.Idle : RoleConnectionState.Disabled;
135137
_operatorError = null;
136138
_nodeError = null;
139+
_operatorCredentialSource = null;
140+
_nodeCredentialSource = null;
137141
RebuildSnapshot();
138142
}
139143

@@ -154,6 +158,12 @@ internal void SetOperatorDeviceId(string? deviceId)
154158
Current = Current with { OperatorDeviceId = deviceId };
155159
}
156160

161+
internal void SetOperatorCredentialSource(string? source)
162+
{
163+
_operatorCredentialSource = source;
164+
RebuildSnapshot();
165+
}
166+
157167
/// <summary>Update node info (device ID, pairing status, optional request ID) in the snapshot.</summary>
158168
internal void SetNodeInfo(
159169
string? deviceId,
@@ -183,6 +193,12 @@ internal void SetNodeInfo(
183193
};
184194
}
185195

196+
internal void SetNodeCredentialSource(string? source)
197+
{
198+
_nodeCredentialSource = source;
199+
RebuildSnapshot();
200+
}
201+
186202
/// <summary>Update the operator pairing request ID in the snapshot.</summary>
187203
internal void SetOperatorPairingRequestId(string? requestId)
188204
{
@@ -257,6 +273,8 @@ private void ApplyTransition(ConnectionTrigger trigger, string? detail)
257273
_nodeState = _nodeEnabled ? RoleConnectionState.Idle : RoleConnectionState.Disabled;
258274
_operatorError = null;
259275
_nodeError = null;
276+
_operatorCredentialSource = null;
277+
_nodeCredentialSource = null;
260278
break;
261279

262280
case ConnectionTrigger.ReconnectScheduled:
@@ -317,12 +335,14 @@ private void RebuildSnapshot()
317335
OverallState = GatewayConnectionSnapshot.DeriveOverall(_operatorState, _nodeState, _nodeEnabled),
318336
OperatorState = _operatorState,
319337
OperatorError = _operatorError,
338+
OperatorCredentialSource = _operatorCredentialSource,
320339
OperatorPairingRequired = _operatorState == RoleConnectionState.PairingRequired,
321340
// Clear requestId when no longer in PairingRequired to prevent stale reads
322341
OperatorPairingRequestId = _operatorState == RoleConnectionState.PairingRequired
323342
? Current.OperatorPairingRequestId : null,
324343
NodeState = _nodeState,
325344
NodeError = _nodeError,
345+
NodeCredentialSource = _nodeCredentialSource,
326346
// Clear requestId when no longer in PairingRequired to prevent stale reads
327347
NodePairingRequestId = _nodeState == RoleConnectionState.PairingRequired
328348
? Current.NodePairingRequestId : null,

src/OpenClaw.Connection/GatewayConnectionManager.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ private async Task ConnectCoreAsync(string? gatewayId = null)
209209
var prev = _stateMachine.Current.OverallState;
210210
// Must go through Connecting → Error since AuthenticationFailed requires Connecting state
211211
_stateMachine.TryTransition(ConnectionTrigger.ConnectRequested);
212+
_stateMachine.SetOperatorCredentialSource(null);
212213
_stateMachine.TryTransition(ConnectionTrigger.AuthenticationFailed, "No credential available");
213214
EmitStateChanged(prev);
214215
return;
@@ -217,6 +218,7 @@ private async Task ConnectCoreAsync(string? gatewayId = null)
217218
// Transition to Connecting
218219
var prevState = _stateMachine.Current.OverallState;
219220
_stateMachine.TryTransition(ConnectionTrigger.ConnectRequested);
221+
_stateMachine.SetOperatorCredentialSource(credential.Source);
220222
_diagnostics.RecordStateChange(prevState, _stateMachine.Current.OverallState);
221223
EmitStateChanged(prevState);
222224

@@ -370,6 +372,9 @@ tunnel.SshPort is < 1 or > 65535 ||
370372
string.Equals(_activeGatewayRecordId, record.Id, StringComparison.Ordinal) &&
371373
string.Equals(_stateMachine.Current.GatewayUrl, record.Url, StringComparison.Ordinal) &&
372374
Equals(_activeSshTunnel, record.SshTunnel);
375+
var operatorCredentialSource = preservesOperatorConnection
376+
? _stateMachine.Current.OperatorCredentialSource
377+
: null;
373378
var gen = Interlocked.Read(ref _generation);
374379
if (!preservesOperatorConnection)
375380
{
@@ -393,6 +398,8 @@ tunnel.SshPort is < 1 or > 65535 ||
393398
};
394399

395400
_diagnostics.RecordCredentialResolution(nodeCredential);
401+
_stateMachine.SetOperatorCredentialSource(operatorCredentialSource);
402+
_stateMachine.SetNodeCredentialSource(nodeCredential.Source);
396403
_diagnostics.Record("node", $"Starting node-only connection to {record.Url}",
397404
$"Credential source: {nodeCredential.Source}");
398405

@@ -1214,6 +1221,7 @@ private async Task<bool> StartNodeConnectionCoreAsync(
12141221
try
12151222
{
12161223
_stateMachine.SetNodeEnabled(true);
1224+
_stateMachine.SetNodeCredentialSource(nodeCredential.Source);
12171225
}
12181226
finally
12191227
{

src/OpenClaw.Connection/GatewayConnectionSnapshot.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public sealed record GatewayConnectionSnapshot
1414
public string? OperatorError { get; init; }
1515
public bool OperatorPairingRequired { get; init; }
1616
public string? OperatorDeviceId { get; init; }
17+
public string? OperatorCredentialSource { get; init; }
1718
/// <summary>
1819
/// The requestId returned by the gateway when operator pairing is required.
1920
/// Used by setup flows to approve the specific pairing request via CLI.
@@ -25,6 +26,7 @@ public sealed record GatewayConnectionSnapshot
2526
public string? NodeError { get; init; }
2627
public OpenClaw.Shared.PairingStatus NodePairingStatus { get; init; }
2728
public string? NodeDeviceId { get; init; }
29+
public string? NodeCredentialSource { get; init; }
2830
/// <summary>
2931
/// The requestId returned by the gateway when node pairing is required.
3032
/// Used by the connection page to show the correct approval command.

src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
<ItemGroup>
1111
<ProjectReference Include="..\OpenClaw.Connection\OpenClaw.Connection.csproj" />
12+
<ProjectReference Include="..\OpenClaw.Shared\OpenClaw.Shared.csproj" />
1213
</ItemGroup>
1314

1415
</Project>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using System.Diagnostics;
2+
using OpenClaw.Shared;
3+
4+
namespace OpenClaw.SetupEngine;
5+
6+
public static class StartupTaskRegistration
7+
{
8+
internal const string TaskName = WindowsStartupTaskRegistration.TaskName;
9+
10+
public static bool Register(string trayExecutablePath) =>
11+
WindowsStartupTaskRegistration.Register(trayExecutablePath);
12+
13+
public static bool Unregister() =>
14+
WindowsStartupTaskRegistration.Unregister();
15+
16+
internal static ProcessStartInfo CreateRegisterProcessStartInfo(string trayExecutablePath) =>
17+
WindowsStartupTaskRegistration.CreateRegisterProcessStartInfo(trayExecutablePath);
18+
19+
internal static ProcessStartInfo CreateUnregisterProcessStartInfo() =>
20+
WindowsStartupTaskRegistration.CreateUnregisterProcessStartInfo();
21+
22+
internal static ProcessStartInfo CreateQueryProcessStartInfo() =>
23+
WindowsStartupTaskRegistration.CreateQueryProcessStartInfo();
24+
25+
internal static string ResolveSchtasksPath() =>
26+
WindowsStartupTaskRegistration.ResolveSchtasksPath();
27+
}

src/OpenClaw.SetupEngine/TrayArtifactCleanup.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Runtime.Versioning;
22
using Microsoft.Win32;
33
using OpenClaw.Connection;
4+
using OpenClaw.Shared;
45

56
namespace OpenClaw.SetupEngine;
67

@@ -20,7 +21,7 @@ public static void Run(SetupContext ctx, bool preserveLogs = false)
2021
var appDataDir = ctx.DataDir; // %APPDATA%\OpenClawTray
2122
var localDataDir = ctx.LocalDataDir;
2223

23-
// 1. Remove autostart registry key
24+
// 1. Remove autostart entries
2425
try
2526
{
2627
using var key = Registry.CurrentUser.OpenSubKey(AutoStartKey, writable: true);
@@ -39,6 +40,11 @@ public static void Run(SetupContext ctx, bool preserveLogs = false)
3940
logger.Warn($"[Uninstall] Failed to remove autostart registry key: {ex.Message}");
4041
}
4142

43+
if (WindowsStartupTaskRegistration.Unregister())
44+
logger.Info("[Uninstall] Removed autostart scheduled task");
45+
else
46+
logger.Info("[Uninstall] Autostart scheduled task already absent or unavailable");
47+
4248
// 2. Delete run.marker
4349
DeleteFileIfExists(Path.Combine(localDataDir, "run.marker"), "run.marker", logger);
4450

src/OpenClaw.Shared/InstanceMerger.cs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ private static MergedInstance BuildFromPresence(
245245
IsThisInstance = !isGateway && IsLocalIdentity(node, p, options),
246246
DisplayName = node?.DisplayName is { Length: > 0 } dn ? dn : p.DisplayName,
247247
Ip = p.Ip ?? node?.RemoteIp,
248-
Version = p.Version ?? node?.Version,
248+
Version = p.Version ?? DisplayVersionForNode(node, hasPresence: true),
249249
Platform = p.Platform ?? node?.Platform,
250250
DeviceFamily = p.DeviceFamily ?? node?.DeviceFamily,
251251
ModelIdentifier = p.ModelIdentifier ?? node?.ModelIdentifier,
@@ -279,7 +279,7 @@ private static MergedInstance BuildFromOrphanNode(
279279
IsThisInstance = IsLocalIdentity(node, presence: null, options),
280280
DisplayName = string.IsNullOrWhiteSpace(node.DisplayName) ? node.ShortId : node.DisplayName,
281281
Ip = node.RemoteIp,
282-
Version = node.Version,
282+
Version = DisplayVersionForNode(node, hasPresence: false),
283283
Platform = node.Platform,
284284
DeviceFamily = node.DeviceFamily,
285285
ModelIdentifier = node.ModelIdentifier,
@@ -310,6 +310,19 @@ private static MergedInstance BuildFromOrphanNode(
310310
return n;
311311
}
312312

313+
private static string? DisplayVersionForNode(GatewayNodeInfo? node, bool hasPresence)
314+
{
315+
var version = node?.Version;
316+
if (!hasPresence &&
317+
node is { IsOnline: false } &&
318+
string.Equals(version?.Trim(), "1.0.0", StringComparison.OrdinalIgnoreCase))
319+
{
320+
return null;
321+
}
322+
323+
return version;
324+
}
325+
313326
private static PresenceStatus ClassifyPresence(
314327
PresenceEntry p,
315328
DateTime nowUtc,

src/OpenClaw.Shared/OpenClaw.Shared.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
</PropertyGroup>
99

1010
<ItemGroup>
11+
<InternalsVisibleTo Include="OpenClaw.SetupEngine" />
1112
<InternalsVisibleTo Include="OpenClaw.Shared.Tests" />
1213
<InternalsVisibleTo Include="OpenClaw.Tray.WinUI" />
1314
</ItemGroup>
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
using System.Diagnostics;
2+
3+
namespace OpenClaw.Shared;
4+
5+
public static class WindowsStartupTaskRegistration
6+
{
7+
public const string TaskName = "OpenClaw Companion";
8+
9+
public static bool Register(string trayExecutablePath)
10+
{
11+
if (string.IsNullOrWhiteSpace(trayExecutablePath) || !File.Exists(trayExecutablePath))
12+
return false;
13+
14+
return Run(CreateRegisterProcessStartInfo(trayExecutablePath));
15+
}
16+
17+
public static bool Unregister() => Run(CreateUnregisterProcessStartInfo());
18+
19+
public static bool Exists() => Run(CreateQueryProcessStartInfo());
20+
21+
internal static ProcessStartInfo CreateRegisterProcessStartInfo(string trayExecutablePath)
22+
{
23+
var fullPath = Path.GetFullPath(trayExecutablePath);
24+
return CreateStartInfo(
25+
"/Create",
26+
"/TN", TaskName,
27+
"/TR", Quote(fullPath),
28+
"/SC", "ONLOGON",
29+
"/F");
30+
}
31+
32+
internal static ProcessStartInfo CreateUnregisterProcessStartInfo() =>
33+
CreateStartInfo(
34+
"/Delete",
35+
"/TN", TaskName,
36+
"/F");
37+
38+
internal static ProcessStartInfo CreateQueryProcessStartInfo() =>
39+
CreateStartInfo(
40+
"/Query",
41+
"/TN", TaskName);
42+
43+
private static bool Run(ProcessStartInfo startInfo)
44+
{
45+
try
46+
{
47+
using var process = Process.Start(startInfo);
48+
if (process == null)
49+
return false;
50+
51+
if (process.WaitForExit(10_000))
52+
return process.ExitCode == 0;
53+
54+
try
55+
{
56+
process.Kill(entireProcessTree: false);
57+
}
58+
catch
59+
{
60+
}
61+
62+
return false;
63+
}
64+
catch
65+
{
66+
return false;
67+
}
68+
}
69+
70+
internal static string ResolveSchtasksPath()
71+
{
72+
var systemRoot = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
73+
if (string.IsNullOrWhiteSpace(systemRoot))
74+
systemRoot = Environment.GetEnvironmentVariable("SystemRoot");
75+
76+
return !string.IsNullOrWhiteSpace(systemRoot)
77+
? Path.Combine(systemRoot, "System32", "schtasks.exe")
78+
: Path.Combine("C:\\", "Windows", "System32", "schtasks.exe");
79+
}
80+
81+
private static ProcessStartInfo CreateStartInfo(params string[] arguments)
82+
{
83+
var startInfo = new ProcessStartInfo
84+
{
85+
FileName = ResolveSchtasksPath(),
86+
UseShellExecute = false,
87+
CreateNoWindow = true,
88+
RedirectStandardOutput = true,
89+
RedirectStandardError = true,
90+
};
91+
92+
foreach (var argument in arguments)
93+
startInfo.ArgumentList.Add(argument);
94+
95+
return startInfo;
96+
}
97+
98+
private static string Quote(string value) => "\"" + value.Replace("\"", "\\\"") + "\"";
99+
}

src/OpenClaw.Tray.WinUI/App.xaml.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2900,7 +2900,9 @@ private void OnSettingsSaved(object? sender, EventArgs e)
29002900
_globalHotkey?.Unregister();
29012901
}
29022902

2903-
AutoStartManager.SetAutoStart(_settings.AutoStart);
2903+
ObserveBackgroundFault(
2904+
AutoStartManager.SetAutoStartAsync(_settings.AutoStart),
2905+
"[App] Failed to apply auto-start setting");
29042906

29052907
// Notify ad-hoc listeners (e.g. ChatWindow may be alive but not
29062908
// owned by the hub) that settings have changed. Marshal onto the
@@ -3189,7 +3191,7 @@ private async Task RestartAfterSetupAsync(bool enableAutoStart)
31893191
{
31903192
try
31913193
{
3192-
AutoStartManager.SetAutoStart(true);
3194+
await AutoStartManager.SetAutoStartAsync(true);
31933195
}
31943196
catch (Exception ex)
31953197
{
@@ -3402,12 +3404,18 @@ private async Task ToggleChannelAsync(string channelName)
34023404
}
34033405
}
34043406

3405-
private void ToggleAutoStart()
3407+
private void ToggleAutoStart() =>
3408+
AsyncEventHandlerGuard.Run(
3409+
ToggleAutoStartAsync,
3410+
new AppLogger(),
3411+
nameof(ToggleAutoStart));
3412+
3413+
private async Task ToggleAutoStartAsync()
34063414
{
34073415
if (_settings == null) return;
34083416
_settings.AutoStart = !_settings.AutoStart;
34093417
_settings.Save();
3410-
AutoStartManager.SetAutoStart(_settings.AutoStart);
3418+
await AutoStartManager.SetAutoStartAsync(_settings.AutoStart);
34113419
}
34123420

34133421
private void OpenLogFile()

0 commit comments

Comments
 (0)