Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/OpenClaw.Shared/WebSocketClientBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,10 @@ protected async Task ReconnectWithBackoffAsync()
while (!_disposed && !_cts.Token.IsCancellationRequested && ShouldAutoReconnect())
{
var delay = BackoffMs[Math.Min(_reconnectAttempts, BackoffMs.Length - 1)];
// Add 0-25% jitter to prevent thundering herd when multiple clients
// (operator + node) reconnect on the same schedule
var jitter = Random.Shared.Next(0, delay / 4);
delay += jitter;
_reconnectAttempts++;
_logger.Warn($"{ClientRole} reconnecting in {delay}ms (attempt {_reconnectAttempts})");
RaiseStatusChanged(ConnectionStatus.Connecting);
Expand Down
35 changes: 35 additions & 0 deletions src/OpenClaw.Shared/WindowsNodeClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ public class WindowsNodeClient : WebSocketClientBase
private bool _isPaired;
// Bridges the gap between an approval event and the next hello-ok when the gateway omits auth.deviceToken.
private bool _pairingApprovedAwaitingReconnect;
// Persists across disconnect/error so ShouldAutoReconnect can block reconnect
// even after OnDisconnected clears _isPendingApproval.
private volatile bool _pairingBlocked;
private volatile bool _rateLimited;
private readonly string _gatewayToken;
private readonly string? _bootstrapToken;

Expand Down Expand Up @@ -277,6 +281,7 @@ private void HandlePairingRequestedEvent(JsonElement root, string? eventType)

_isPendingApproval = true;
_isPaired = false;
_pairingBlocked = true;
_pairingApprovedAwaitingReconnect = false;

_logger.Info($"[NODE] Pairing requested for this device via {eventType}");
Expand Down Expand Up @@ -310,6 +315,7 @@ private async Task HandlePairingResolvedEventAsync(JsonElement root, string? eve
{
_isPendingApproval = false;
_isPaired = true;
_pairingBlocked = false; // Allow reconnect after approval
_pairingApprovedAwaitingReconnect = true;

PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
Expand Down Expand Up @@ -603,6 +609,7 @@ private void HandleResponse(JsonElement root)
PublishGatewaySelf(GatewaySelfInfo.FromHelloOk(payload));
var reconnectingAfterApproval = _pairingApprovedAwaitingReconnect;
_isConnected = true;
_rateLimited = false; // Clear transient rate-limit on successful connect
ResetReconnectAttempts();

// Extract node ID if returned
Expand Down Expand Up @@ -654,6 +661,7 @@ private void HandleResponse(JsonElement root)
{
_isPendingApproval = true;
_isPaired = false;
_pairingBlocked = true;
_logger.Info("Not yet paired - check 'openclaw devices list' for pending approval");
_logger.Info($"To approve, run: openclaw devices approve {_deviceIdentity.DeviceId}");
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
Expand Down Expand Up @@ -717,6 +725,7 @@ private void HandleRequestError(JsonElement root)

_isPendingApproval = true;
_isPaired = false;
_pairingBlocked = true;
_pairingApprovedAwaitingReconnect = false;

var detail = !string.IsNullOrWhiteSpace(pairingRequestId)
Expand All @@ -731,6 +740,18 @@ private void HandleRequestError(JsonElement root)
return;
}

// Rate-limit / terminal auth errors β€” stop reconnecting
if (error.Contains("too many failed", StringComparison.OrdinalIgnoreCase) ||
error.Contains("rate limit", StringComparison.OrdinalIgnoreCase) ||
error.Contains("origin not allowed", StringComparison.OrdinalIgnoreCase) ||
error.Contains("token mismatch", StringComparison.OrdinalIgnoreCase))
{
_rateLimited = true;
_logger.Warn($"[NODE] Terminal auth error; stopping reconnect. Error: {error}");
RaiseStatusChanged(ConnectionStatus.Error);
return;
}

_logger.Error($"Node registration failed: {error} (code: {errorCode})");
RaiseStatusChanged(ConnectionStatus.Error);
}
Expand Down Expand Up @@ -997,6 +1018,20 @@ private void PublishGatewaySelf(GatewaySelfInfo info)
GatewaySelfUpdated?.Invoke(this, info);
}

protected override bool ShouldAutoReconnect()
{
// Don't reconnect while awaiting pairing approval β€” each reconnect
// generates a new pairing request on the gateway, causing a storm.
// _pairingBlocked survives OnDisconnected (which clears _isPendingApproval).
if (_pairingBlocked)
return false;

if (_rateLimited)
return false;

return true;
}

protected override void OnDisconnected()
{
_isConnected = false;
Expand Down
33 changes: 29 additions & 4 deletions src/OpenClaw.Tray.WinUI/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
/// Ensures the managed SSH tunnel is started using the current settings.
/// Used by the onboarding ConnectionPage when the user picks the SSH topology.
/// </summary>
public void EnsureSshTunnelStarted() => _sshTunnelService?.EnsureStarted(_settings);

Check warning on line 48 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'settings' in 'void SshTunnelService.EnsureStarted(SettingsManager settings)'.

Check warning on line 48 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'settings' in 'void SshTunnelService.EnsureStarted(SettingsManager settings)'.

Check warning on line 48 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-x64)

Possible null reference argument for parameter 'settings' in 'void SshTunnelService.EnsureStarted(SettingsManager settings)'.

Check warning on line 48 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-x64)

Possible null reference argument for parameter 'settings' in 'void SshTunnelService.EnsureStarted(SettingsManager settings)'.

Check warning on line 48 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build-msix (win-x64)

Possible null reference argument for parameter 'settings' in 'void SshTunnelService.EnsureStarted(SettingsManager settings)'.

Check warning on line 48 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-arm64)

Possible null reference argument for parameter 'settings' in 'void SshTunnelService.EnsureStarted(SettingsManager settings)'.

Check warning on line 48 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-arm64)

Possible null reference argument for parameter 'settings' in 'void SshTunnelService.EnsureStarted(SettingsManager settings)'.

Check warning on line 48 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build-msix (win-arm64)

Possible null reference argument for parameter 'settings' in 'void SshTunnelService.EnsureStarted(SettingsManager settings)'.

/// <summary>
/// Returns the HWND of the active onboarding window, or IntPtr.Zero if none.
Expand Down Expand Up @@ -376,7 +376,7 @@
// Register global hotkey if enabled
if (_settings.GlobalHotkeyEnabled)
{
_globalHotkey = new GlobalHotkeyService();

Check warning on line 379 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / test

Dereference of a possibly null reference.

Check warning on line 379 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / test

Dereference of a possibly null reference.

Check warning on line 379 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-x64)

Dereference of a possibly null reference.

Check warning on line 379 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-x64)

Dereference of a possibly null reference.

Check warning on line 379 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build-msix (win-x64)

Dereference of a possibly null reference.

Check warning on line 379 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-arm64)

Dereference of a possibly null reference.

Check warning on line 379 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-arm64)

Dereference of a possibly null reference.

Check warning on line 379 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build-msix (win-arm64)

Dereference of a possibly null reference.
_globalHotkey.HotkeyPressed += OnGlobalHotkeyPressed;
_globalHotkey.Register();
}
Expand Down Expand Up @@ -1491,19 +1491,30 @@
return;
}

if (string.IsNullOrWhiteSpace(_settings.Token))
// Need either a regular token or a bootstrap token to connect
var effectiveToken = _settings.Token;
if (string.IsNullOrWhiteSpace(effectiveToken))
{
Logger.Info("Gateway token not configured β€” skipping operator client initialization");
return;
if (useBootstrapHandoffAuth && !string.IsNullOrWhiteSpace(_settings.BootstrapToken))
{
// Bootstrap-only flow (setup code / QR): use bootstrap token for initial pairing
effectiveToken = _settings.BootstrapToken;
}
else
{
Logger.Info("Gateway token not configured β€” skipping operator client initialization");
return;
}
}

// Unsubscribe from old client if exists
UnsubscribeGatewayEvents();
_gatewayClient?.Dispose();
_lastGatewaySelf = null;

_gatewayClient = new OpenClawGatewayClient(
gatewayUrl,
_settings.Token,
effectiveToken,
new AppLogger(),
useBootstrapHandoffAuth);
_gatewayClient.SetUserRules(_settings.UserRules.Count > 0 ? _settings.UserRules : null);
Expand Down Expand Up @@ -1944,6 +1955,19 @@
if (_hubWindow != null && !_hubWindow.IsClosed)
_hubWindow.LastAuthError = null;
}

// Clear stale data when disconnected so tray menu doesn't show old sessions/nodes
if (status == ConnectionStatus.Disconnected || status == ConnectionStatus.Error)
{
_lastSessions = Array.Empty<SessionInfo>();
_lastChannels = Array.Empty<ChannelHealth>();
_lastNodes = Array.Empty<GatewayNodeInfo>();
_lastNodePairList = null;
_lastDevicePairList = null;
_lastModelsList = null;
_lastGatewaySelf = null;
}

UpdateTrayIcon();
_dispatcherQueue?.TryEnqueue(UpdateStatusDetailWindow);

Expand Down Expand Up @@ -2497,6 +2521,7 @@
_hubWindow.OpenDashboardAction = OpenDashboard;
_hubWindow.CheckForUpdatesAction = () => _ = CheckForUpdatesUserInitiatedAsync();
_hubWindow.QuickSendAction = () => ShowQuickSend();
_hubWindow.OpenSetupAction = () => _ = ShowOnboardingAsync();
_hubWindow.ConnectAction = () =>
{
InitializeGatewayClient();
Expand Down
49 changes: 16 additions & 33 deletions src/OpenClaw.Tray.WinUI/Onboarding/Pages/ConnectionPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,21 +136,23 @@ void SelectMode(ConnectionMode m)

void OnSetupCodeChanged(string code)
{
setSetupCode(code);
if (string.IsNullOrWhiteSpace(code)) return;

var result = SetupCodeDecoder.Decode(code);

if (!result.Success)
{
// Not a valid setup code β€” user might be still typing
// Not a valid setup code β€” user might be still typing.
// Don't call setSetupCode here to avoid re-render that steals focus.
if (code.Length > 2048)
Logger.Warn("[Connection] Setup code rejected: exceeds 2048 character limit");
else
Logger.Debug($"[Connection] Setup code parse attempt failed: {result.Error}");
return;
}

// Valid setup code decoded β€” now update state (will re-render)
setSetupCode(code);
if (result.Url != null)
{
setUrl(result.Url);
Expand All @@ -159,7 +161,8 @@ void OnSetupCodeChanged(string code)
if (result.Token != null)
{
setToken(result.Token);
Props.Settings.Token = result.Token;
// Bootstrap token goes to BootstrapToken only β€” it's single-use for pairing.
// Don't save as Settings.Token (causes reconnect storms on restart).
Props.Settings.BootstrapToken = result.Token;
}
setStatusMsg($"βœ… {LocalizationHelper.GetString("Onboarding_Connection_StatusDecoded")}");
Expand Down Expand Up @@ -205,7 +208,13 @@ bool TryCopyPairingCommand(string command)
async void TestConnection()
{
Props.Settings.GatewayUrl = url;
Props.Settings.Token = token;
// Only save to Settings.Token if the user entered a manual token,
// not a decoded bootstrap token (which belongs in BootstrapToken only).
if (string.IsNullOrWhiteSpace(Props.Settings.BootstrapToken) ||
!string.Equals(token, Props.Settings.BootstrapToken, StringComparison.Ordinal))
{
Props.Settings.Token = token;
}

// When SSH mode, start the managed tunnel before health-checking the local URL.
if (mode == ConnectionMode.Ssh)
Expand Down Expand Up @@ -473,40 +482,14 @@ void PasteSetupCode()
catch { /* clipboard unavailable β€” ignore */ }
}

// Setup code row: TextField + Paste + QR buttons (Grid keeps the field expanding)
// Setup code row: TextField + Paste + QR buttons
cardChildren.Add(
Grid(["1*", "Auto", "Auto"], ["Auto"],
TextField(setupCode, OnSetupCodeChanged,
placeholder: LocalizationHelper.GetString("Onboarding_Connection_SetupCodePlaceholder"),
header: LocalizationHelper.GetString("Onboarding_Connection_SetupCode"))
.OnGotFocus((sender, _) =>
{
if (sender is Microsoft.UI.Xaml.Controls.TextBox tb && string.IsNullOrEmpty(tb.Text))
{
try
{
var content = global::Windows.ApplicationModel.DataTransfer.Clipboard.GetContent();
if (content.Contains(global::Windows.ApplicationModel.DataTransfer.StandardDataFormats.Text))
{
var task = content.GetTextAsync();
task.Completed = (op, status) =>
{
if (status == global::Windows.Foundation.AsyncStatus.Completed)
{
var text = op.GetResults();
tb.DispatcherQueue.TryEnqueue(() =>
{
tb.Text = text;
OnSetupCodeChanged(text);
});
}
};
}
}
catch { }
}
})
.Grid(row: 0, column: 0),
.Grid(row: 0, column: 0)
.Set(tb => Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(tb, "OnboardingSetupCode")),
Button(LocalizationHelper.GetString("Onboarding_Connection_PasteSetup"), PasteSetupCode)
.VAlign(VerticalAlignment.Bottom)
.Margin(6, 0, 0, 0)
Expand Down
2 changes: 1 addition & 1 deletion src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<PasswordBox x:Uid="TokenPromptBox" x:Name="TokenPromptBox" Grid.Column="0"
<TextBox x:Uid="TokenPromptBox" x:Name="TokenPromptBox" Grid.Column="0"
PlaceholderText="Gateway token" Header="Token"/>
<Button x:Uid="ConnectionPage_Button_105" Grid.Column="1" Content="Cancel"
VerticalAlignment="Bottom" Click="OnCancelTokenPrompt"/>
Expand Down
9 changes: 4 additions & 5 deletions src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -473,14 +473,14 @@ private void OnConnectToGateway(object sender, RoutedEventArgs e)
_pendingGatewayUrl = gw.ConnectionUrl;
_pendingGatewayId = gw.Id;
TokenPromptText.Text = $"Connect to gateway at {gw.Host}:{gw.Port}";
TokenPromptBox.Password = _hub.Settings.Token ?? "";
TokenPromptBox.Text = _hub.Settings.Token ?? "";
TokenPromptPanel.Visibility = Visibility.Visible;
TokenPromptBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic);
}

private void OnConnectWithToken(object sender, RoutedEventArgs e)
{
var token = TokenPromptBox.Password?.Trim();
var token = TokenPromptBox.Text?.Trim();
if (string.IsNullOrEmpty(token) || _hub?.Settings == null || string.IsNullOrEmpty(_pendingGatewayUrl))
return;

Expand Down Expand Up @@ -535,10 +535,9 @@ private void OnApplySetupCode(object sender, RoutedEventArgs e)
settings.GatewayUrl = result.Url;
if (!string.IsNullOrEmpty(result.Token))
{
// Bootstrap token goes to BootstrapToken only β€” it's single-use for pairing.
// Don't save it as Settings.Token, which would cause reconnect storms on restart.
settings.BootstrapToken = result.Token;
// Also set as the operator token so InitializeGatewayClient can connect
if (string.IsNullOrWhiteSpace(settings.Token))
settings.Token = result.Token;
}

settings.Save();
Expand Down
1 change: 1 addition & 0 deletions src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
<Button x:Uid="DebugPage_Button_113" Content="πŸ“ Open Diagnostics Folder" Click="OnOpenDiagnosticsFolder" HorizontalAlignment="Stretch"/>
<Button x:Uid="DebugPage_Button_114" Content="πŸ“‹ Copy Support Context" Click="OnCopySupportContext"
Style="{ThemeResource AccentButtonStyle}" HorizontalAlignment="Stretch"/>
<Button Content="πŸ”„ Relaunch First-Run Setup" Click="OnRelaunchOnboarding" HorizontalAlignment="Stretch"/>
</StackPanel>
</Expander>

Expand Down
5 changes: 5 additions & 0 deletions src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -196,4 +196,9 @@ private void OnCopySupportContext(object sender, RoutedEventArgs e)
timer.Start();
}
}

private void OnRelaunchOnboarding(object sender, RoutedEventArgs e)
{
_hub?.OpenSetupAction?.Invoke();
}
}
1 change: 1 addition & 0 deletions src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public sealed partial class HubWindow : WindowEx
public Action? ConnectAction { get; set; }
public Action? DisconnectAction { get; set; }
public Action? ReconnectAction { get; set; }
public Action? OpenSetupAction { get; set; }

// Node service state (set by App.xaml.cs in ShowHub)
public bool NodeIsConnected { get; set; }
Expand Down
14 changes: 7 additions & 7 deletions src/OpenClaw.Tray.WinUI/Windows/SetupWizardWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public sealed class SetupWizardWindow : WindowEx
// Step 0: Setup code + manual entry
private readonly TextBox _setupCodeBox;
private readonly TextBox _gatewayUrlBox;
private readonly PasswordBox _tokenBox;
private readonly TextBox _tokenBox;
private readonly TextBlock _testStatusLabel;
private readonly Button _testButton;
private readonly StackPanel _manualEntryPanel;
Expand Down Expand Up @@ -196,17 +196,17 @@ public SetupWizardWindow(SettingsManager settings)
Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"],
Foreground = (SolidColorBrush)Application.Current.Resources["TextFillColorSecondaryBrush"]
});
_tokenBox = new PasswordBox
_tokenBox = new TextBox
{
Header = LocalizationHelper.GetString("Setup_TokenHeader"),
PlaceholderText = LocalizationHelper.GetString("Setup_TokenPlaceholder"),
Password = _draftToken
Text = _draftToken
};
AutomationProperties.SetAutomationId(_tokenBox, "TokenBox");
_tokenBox.PasswordChanged += (s, e) => _connectionTested = false;
_tokenBox.PasswordChanged += (s, e) =>
_tokenBox.TextChanged += (s, e) => _connectionTested = false;
_tokenBox.TextChanged += (s, e) =>
{
_draftToken = _tokenBox.Password;
_draftToken = _tokenBox.Text;
UpdatePairingStatusText();
};
_manualEntryPanel.Children.Add(_tokenBox);
Expand Down Expand Up @@ -718,7 +718,7 @@ private void UpdateNodeModePairingVisibility(bool showPairing)
private async void OnTestConnection(object sender, RoutedEventArgs e)
{
_draftGatewayUrl = _gatewayUrlBox.Text.Trim();
_draftToken = _tokenBox.Password;
_draftToken = _tokenBox.Text;
UpdatePairingStatusText();

if (!GatewayUrlHelper.IsValidGatewayUrl(_draftGatewayUrl))
Expand Down
2 changes: 1 addition & 1 deletion tests/OpenClaw.Shared.Tests/SystemRunTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,7 @@ public async Task Run_EchoCommand_Powershell()
{
Command = "Write-Output 'hello world'",
Shell = "powershell",
TimeoutMs = 10000
TimeoutMs = 30000
});

Assert.Equal(0, result.ExitCode);
Expand Down
2 changes: 1 addition & 1 deletion tests/OpenClaw.Shared.Tests/WebSocketClientBaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ await WaitForConditionAsync(

Assert.Contains(ConnectionStatus.Error, statuses);
Assert.True(statuses.Count(s => s == ConnectionStatus.Connecting) >= 2);
Assert.Contains(_logger.Logs, line => line.Contains("reconnecting in 1000ms", StringComparison.OrdinalIgnoreCase));
Assert.Contains(_logger.Logs, line => line.Contains("reconnecting in 1", StringComparison.OrdinalIgnoreCase) && line.Contains("ms (attempt 1)", StringComparison.OrdinalIgnoreCase));

client.Dispose();
}
Expand Down
Loading