openclaw-windows-node/pr117_diff.txt
Sytone 1d836390e9
feat: SSH tunnel gateway, device identity, reconnect hardening
Adds SSH local port-forward support for secure remote gateway access, Ed25519 device identity for operator auth, enhanced Quick Send with error remediation, reconnect resilience, and OpenClaw.Cli validator tool.

Includes security fix: SSH user/host input validation to prevent command injection.

615 tests pass (516 shared + 99 tray).

Contributed by @sytone
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 00:03:43 -07:00

3149 lines
118 KiB
Plaintext

From c263c5ce18a349379be9aa21c6bdd7de46ad087d Mon Sep 17 00:00:00 2001
From: sytone <github@sytone.com>
Date: Sat, 28 Mar 2026 15:41:57 -0700
Subject: [PATCH 1/3] feat: add SSH tunnel configuration and management to
settings
---
src/OpenClaw.Shared/SettingsData.cs | 5 +
src/OpenClaw.Tray.WinUI/App.xaml.cs | 62 ++++++-
.../Services/SettingsManager.cs | 25 +++
.../Services/SshTunnelService.cs | 165 ++++++++++++++++++
.../Windows/SettingsWindow.xaml | 29 +++
.../Windows/SettingsWindow.xaml.cs | 150 +++++++++++++++-
.../SettingsRoundTripTests.cs | 20 +++
7 files changed, 446 insertions(+), 10 deletions(-)
create mode 100644 src/OpenClaw.Tray.WinUI/Services/SshTunnelService.cs
diff --git a/src/OpenClaw.Shared/SettingsData.cs b/src/OpenClaw.Shared/SettingsData.cs
index 4c7b075..7a2d4b5 100644
--- a/src/OpenClaw.Shared/SettingsData.cs
+++ b/src/OpenClaw.Shared/SettingsData.cs
@@ -9,6 +9,11 @@ public class SettingsData
{
public string? GatewayUrl { get; set; }
public string? Token { get; set; }
+ public bool UseSshTunnel { get; set; } = false;
+ public string? SshTunnelUser { get; set; }
+ public string? SshTunnelHost { get; set; }
+ public int SshTunnelRemotePort { get; set; } = 18789;
+ public int SshTunnelLocalPort { get; set; } = 18789;
public bool AutoStart { get; set; }
public bool GlobalHotkeyEnabled { get; set; } = true;
public bool ShowNotifications { get; set; } = true;
diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs
index de0780f..caff372 100644
--- a/src/OpenClaw.Tray.WinUI/App.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs
@@ -34,6 +34,7 @@ public partial class App : Application
private TrayIcon? _trayIcon;
private OpenClawGatewayClient? _gatewayClient;
private SettingsManager? _settings;
+ private SshTunnelService? _sshTunnelService;
private GlobalHotkeyService? _globalHotkey;
private System.Timers.Timer? _healthCheckTimer;
private System.Timers.Timer? _sessionPollTimer;
@@ -250,6 +251,7 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args)
// Initialize settings
_settings = new SettingsManager();
+ _sshTunnelService = new SshTunnelService(new AppLogger());
// First-run check
if (string.IsNullOrWhiteSpace(_settings.Token))
@@ -1080,11 +1082,12 @@ private void BuildTrayMenu(MenuFlyout flyout)
private void InitializeGatewayClient()
{
if (_settings == null) return;
+ if (!EnsureSshTunnelConfigured()) return;
// Unsubscribe from old client if exists
UnsubscribeGatewayEvents();
- _gatewayClient = new OpenClawGatewayClient(_settings.GatewayUrl, _settings.Token, new AppLogger());
+ _gatewayClient = new OpenClawGatewayClient(_settings.GetEffectiveGatewayUrl(), _settings.Token, new AppLogger());
_gatewayClient.StatusChanged += OnConnectionStatusChanged;
_gatewayClient.ActivityChanged += OnActivityChanged;
_gatewayClient.NotificationReceived += OnNotificationReceived;
@@ -1121,6 +1124,7 @@ private void InitializeNodeService()
{
if (_settings == null || !_settings.EnableNodeMode) return;
if (_dispatcherQueue == null) return;
+ if (!EnsureSshTunnelConfigured()) return;
try
{
@@ -1132,7 +1136,7 @@ private void InitializeNodeService()
_nodeService.PairingStatusChanged += OnPairingStatusChanged;
// Connect to gateway as a node (separate connection from operator)
- _ = _nodeService.ConnectAsync(_settings.GatewayUrl, _settings.Token);
+ _ = _nodeService.ConnectAsync(_settings.GetEffectiveGatewayUrl(), _settings.Token);
}
catch (Exception ex)
{
@@ -1609,6 +1613,10 @@ private void OnSettingsSaved(object? sender, EventArgs e)
var oldNodeService = _nodeService;
_nodeService = null;
try { oldNodeService?.Dispose(); } catch (Exception ex) { Logger.Warn($"Node dispose error: {ex.Message}"); }
+ if (_settings?.UseSshTunnel != true)
+ {
+ _sshTunnelService?.Stop();
+ }
if (_settings?.EnableNodeMode == true)
{
@@ -1638,9 +1646,12 @@ private void OnSettingsSaved(object? sender, EventArgs e)
private void ShowWebChat()
{
+ if (_settings == null) return;
+ if (!EnsureSshTunnelConfigured()) return;
+
if (_webChatWindow == null || _webChatWindow.IsClosed)
{
- _webChatWindow = new WebChatWindow(_settings!.GatewayUrl, _settings.Token);
+ _webChatWindow = new WebChatWindow(_settings.GetEffectiveGatewayUrl(), _settings.Token);
_webChatWindow.Closed += (s, e) => _webChatWindow = null;
}
_webChatWindow.Activate();
@@ -1770,8 +1781,9 @@ private void ShowSurfaceImprovementsTipIfNeeded()
private void OpenDashboard(string? path = null)
{
if (_settings == null) return;
+ if (!EnsureSshTunnelConfigured()) return;
- var baseUrl = _settings.GatewayUrl
+ var baseUrl = _settings.GetEffectiveGatewayUrl()
.Replace("ws://", "http://")
.Replace("wss://", "https://")
.TrimEnd('/');
@@ -2063,6 +2075,7 @@ private void ExitApplication()
// Unsubscribe and dispose gateway client
UnsubscribeGatewayEvents();
_gatewayClient?.Dispose();
+ _sshTunnelService?.Dispose();
// Dispose tray and mutex
_trayIcon?.Dispose();
@@ -2074,6 +2087,47 @@ private void ExitApplication()
Exit();
}
+ private bool EnsureSshTunnelConfigured()
+ {
+ if (_settings == null)
+ {
+ return false;
+ }
+
+ if (_settings.UseSshTunnel)
+ {
+ if (string.IsNullOrWhiteSpace(_settings.SshTunnelUser) ||
+ string.IsNullOrWhiteSpace(_settings.SshTunnelHost) ||
+ _settings.SshTunnelRemotePort is < 1 or > 65535 ||
+ _settings.SshTunnelLocalPort is < 1 or > 65535)
+ {
+ Logger.Warn("SSH tunnel is enabled but settings are incomplete");
+ _currentStatus = ConnectionStatus.Error;
+ UpdateTrayIcon();
+ return false;
+ }
+
+ try
+ {
+ _sshTunnelService ??= new SshTunnelService(new AppLogger());
+ _sshTunnelService.EnsureStarted(_settings);
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Failed to start SSH tunnel: {ex.Message}");
+ _currentStatus = ConnectionStatus.Error;
+ UpdateTrayIcon();
+ return false;
+ }
+ }
+ else
+ {
+ _sshTunnelService?.Stop();
+ }
+
+ return true;
+ }
+
#endregion
private Microsoft.UI.Dispatching.DispatcherQueue? AppDispatcherQueue =>
diff --git a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs
index 0c343f1..5347a6b 100644
--- a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs
@@ -19,6 +19,11 @@ public class SettingsManager
// Connection
public string GatewayUrl { get; set; } = "ws://localhost:18789";
public string Token { get; set; } = "";
+ public bool UseSshTunnel { get; set; } = false;
+ public string SshTunnelUser { get; set; } = "";
+ public string SshTunnelHost { get; set; } = "";
+ public int SshTunnelRemotePort { get; set; } = 18789;
+ public int SshTunnelLocalPort { get; set; } = 18789;
// Startup
public bool AutoStart { get; set; } = false;
@@ -64,6 +69,11 @@ public void Load()
{
GatewayUrl = loaded.GatewayUrl ?? GatewayUrl;
Token = loaded.Token ?? Token;
+ UseSshTunnel = loaded.UseSshTunnel;
+ SshTunnelUser = loaded.SshTunnelUser ?? SshTunnelUser;
+ SshTunnelHost = loaded.SshTunnelHost ?? SshTunnelHost;
+ SshTunnelRemotePort = loaded.SshTunnelRemotePort <= 0 ? SshTunnelRemotePort : loaded.SshTunnelRemotePort;
+ SshTunnelLocalPort = loaded.SshTunnelLocalPort <= 0 ? SshTunnelLocalPort : loaded.SshTunnelLocalPort;
AutoStart = loaded.AutoStart;
GlobalHotkeyEnabled = loaded.GlobalHotkeyEnabled;
ShowNotifications = loaded.ShowNotifications;
@@ -101,6 +111,11 @@ public void Save()
{
GatewayUrl = GatewayUrl,
Token = Token,
+ UseSshTunnel = UseSshTunnel,
+ SshTunnelUser = SshTunnelUser,
+ SshTunnelHost = SshTunnelHost,
+ SshTunnelRemotePort = SshTunnelRemotePort,
+ SshTunnelLocalPort = SshTunnelLocalPort,
AutoStart = AutoStart,
GlobalHotkeyEnabled = GlobalHotkeyEnabled,
ShowNotifications = ShowNotifications,
@@ -130,4 +145,14 @@ public void Save()
Logger.Error($"Failed to save settings: {ex.Message}");
}
}
+
+ public string GetEffectiveGatewayUrl()
+ {
+ if (!UseSshTunnel)
+ {
+ return GatewayUrl;
+ }
+
+ return $"ws://127.0.0.1:{SshTunnelLocalPort}";
+ }
}
diff --git a/src/OpenClaw.Tray.WinUI/Services/SshTunnelService.cs b/src/OpenClaw.Tray.WinUI/Services/SshTunnelService.cs
new file mode 100644
index 0000000..18b7764
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Services/SshTunnelService.cs
@@ -0,0 +1,165 @@
+using OpenClaw.Shared;
+using System;
+using System.Diagnostics;
+using System.Text;
+
+namespace OpenClawTray.Services;
+
+/// <summary>
+/// Manages an SSH local port-forward process for gateway access.
+/// </summary>
+public sealed class SshTunnelService : IDisposable
+{
+ private readonly IOpenClawLogger _logger;
+ private Process? _process;
+ private string? _lastSpec;
+
+ public SshTunnelService(IOpenClawLogger logger)
+ {
+ _logger = logger;
+ }
+
+ public bool IsRunning => _process is { HasExited: false };
+
+ public void EnsureStarted(SettingsManager settings)
+ {
+ if (!settings.UseSshTunnel)
+ {
+ Stop();
+ return;
+ }
+
+ EnsureStarted(
+ settings.SshTunnelUser,
+ settings.SshTunnelHost,
+ settings.SshTunnelRemotePort,
+ settings.SshTunnelLocalPort);
+ }
+
+ public void EnsureStarted(string user, string host, int remotePort, int localPort)
+ {
+ user = user.Trim();
+ host = host.Trim();
+
+ var spec = BuildSpec(user, host, remotePort, localPort);
+
+ if (IsRunning && string.Equals(_lastSpec, spec, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ Stop();
+ StartProcess(user, host, remotePort, localPort);
+ _lastSpec = spec;
+ }
+
+ public void Stop()
+ {
+ if (_process == null)
+ {
+ return;
+ }
+
+ try
+ {
+ if (!_process.HasExited)
+ {
+ _process.Kill(entireProcessTree: true);
+ _process.WaitForExit(3000);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Warn($"SSH tunnel stop failed: {ex.Message}");
+ }
+ finally
+ {
+ try { _process.Dispose(); } catch { }
+ _process = null;
+ _lastSpec = null;
+ }
+ }
+
+ private void StartProcess(string user, string host, int remotePort, int localPort)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = "ssh",
+ Arguments = BuildArguments(user, host, remotePort, localPort),
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ };
+
+ var process = new Process
+ {
+ StartInfo = psi,
+ EnableRaisingEvents = true,
+ };
+
+ process.OutputDataReceived += (_, e) =>
+ {
+ if (!string.IsNullOrWhiteSpace(e.Data))
+ {
+ _logger.Info($"[SSH] {e.Data}");
+ }
+ };
+
+ process.ErrorDataReceived += (_, e) =>
+ {
+ if (!string.IsNullOrWhiteSpace(e.Data))
+ {
+ _logger.Warn($"[SSH] {e.Data}");
+ }
+ };
+
+ process.Exited += (_, _) =>
+ {
+ var exitCode = process.ExitCode;
+ _logger.Warn($"SSH tunnel exited (code {exitCode})");
+ };
+
+ try
+ {
+ if (!process.Start())
+ {
+ throw new InvalidOperationException("Failed to start ssh process");
+ }
+ }
+ catch (Exception ex)
+ {
+ process.Dispose();
+ throw new InvalidOperationException("Unable to start SSH tunnel process. Ensure OpenSSH client is installed and available in PATH.", ex);
+ }
+
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+ _process = process;
+
+ _logger.Info($"SSH tunnel started: 127.0.0.1:{localPort} -> 127.0.0.1:{remotePort} via {user}@{host}");
+ }
+
+ private static string BuildSpec(string user, string host, int remotePort, int localPort)
+ => $"{user}@{host}:{localPort}:{remotePort}";
+
+ private static string BuildArguments(string user, string host, int remotePort, int localPort)
+ {
+ var sb = new StringBuilder();
+ sb.Append("-N ");
+ sb.Append("-L ");
+ sb.Append(localPort);
+ sb.Append(":127.0.0.1:");
+ sb.Append(remotePort);
+ sb.Append(' ');
+ sb.Append(user);
+ sb.Append('@');
+ sb.Append(host);
+ return sb.ToString();
+ }
+
+ public void Dispose()
+ {
+ Stop();
+ }
+}
diff --git a/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml
index f8631f5..e0f15ac 100644
--- a/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml
+++ b/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml
@@ -25,6 +25,35 @@
<StackPanel Spacing="8">
<TextBlock x:Uid="SettingsConnectionHeader" Text="CONNECTION" Style="{StaticResource CaptionTextBlockStyle}"
Foreground="#E74C3C" FontWeight="Bold"/>
+
+ <ToggleSwitch x:Name="UseSshTunnelToggle" Header="Connect through SSH tunnel" Toggled="OnUseSshTunnelToggled"/>
+
+ <StackPanel x:Name="SshTunnelDetailsPanel" Spacing="8" Visibility="Collapsed">
+ <TextBlock Text="When enabled, the app will connect through ws://127.0.0.1:&lt;local-port&gt; and create an SSH forward to the remote gateway host."
+ Style="{StaticResource CaptionTextBlockStyle}"
+ Foreground="{ThemeResource TextFillColorSecondaryBrush}"
+ TextWrapping="Wrap"/>
+
+ <Grid ColumnSpacing="8">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="*"/>
+ <ColumnDefinition Width="*"/>
+ </Grid.ColumnDefinitions>
+
+ <TextBox x:Name="SshTunnelUserTextBox" Grid.Column="0" Header="SSH User" PlaceholderText="user"/>
+ <TextBox x:Name="SshTunnelHostTextBox" Grid.Column="1" Header="SSH Host" PlaceholderText="machine-name or IP"/>
+ </Grid>
+
+ <Grid ColumnSpacing="8">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="*"/>
+ <ColumnDefinition Width="*"/>
+ </Grid.ColumnDefinitions>
+
+ <TextBox x:Name="SshTunnelRemotePortTextBox" Grid.Column="0" Header="Remote Gateway Port" PlaceholderText="18789"/>
+ <TextBox x:Name="SshTunnelLocalPortTextBox" Grid.Column="1" Header="Local Forward Port" PlaceholderText="18789" TextChanged="OnSshTunnelLocalPortTextChanged"/>
+ </Grid>
+ </StackPanel>
<TextBox x:Name="GatewayUrlTextBox" x:Uid="SettingsGatewayUrlTextBox" Header="Gateway URL"
PlaceholderText="ws://localhost:18789 or https://host.tailnet.ts.net"/>
diff --git a/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml.cs
index e4224a8..2308c6f 100644
--- a/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml.cs
@@ -12,6 +12,7 @@ namespace OpenClawTray.Windows;
public sealed partial class SettingsWindow : WindowEx
{
private readonly SettingsManager _settings;
+ private string _manualGatewayUrl = "";
public bool IsClosed { get; private set; }
public event EventHandler? SettingsSaved;
@@ -37,7 +38,14 @@ public SettingsWindow(SettingsManager settings)
private void LoadSettings()
{
+ UseSshTunnelToggle.IsOn = _settings.UseSshTunnel;
+ SshTunnelUserTextBox.Text = _settings.SshTunnelUser;
+ SshTunnelHostTextBox.Text = _settings.SshTunnelHost;
+ SshTunnelRemotePortTextBox.Text = _settings.SshTunnelRemotePort.ToString();
+ SshTunnelLocalPortTextBox.Text = _settings.SshTunnelLocalPort.ToString();
+ _manualGatewayUrl = _settings.GatewayUrl;
GatewayUrlTextBox.Text = _settings.GatewayUrl;
+ UpdateSshTunnelUiState();
TokenTextBox.Text = _settings.Token;
AutoStartToggle.IsOn = _settings.AutoStart;
GlobalHotkeyToggle.IsOn = _settings.GlobalHotkeyEnabled;
@@ -72,7 +80,16 @@ private void LoadSettings()
private void SaveSettings()
{
- _settings.GatewayUrl = GatewayUrlTextBox.Text.Trim();
+ _settings.UseSshTunnel = UseSshTunnelToggle.IsOn;
+ _settings.SshTunnelUser = SshTunnelUserTextBox.Text.Trim();
+ _settings.SshTunnelHost = SshTunnelHostTextBox.Text.Trim();
+ _settings.SshTunnelRemotePort = ParsePortOrDefault(SshTunnelRemotePortTextBox.Text, _settings.SshTunnelRemotePort);
+ _settings.SshTunnelLocalPort = ParsePortOrDefault(SshTunnelLocalPortTextBox.Text, _settings.SshTunnelLocalPort);
+ if (!_settings.UseSshTunnel)
+ {
+ _settings.GatewayUrl = GatewayUrlTextBox.Text.Trim();
+ _manualGatewayUrl = _settings.GatewayUrl;
+ }
_settings.Token = TokenTextBox.Text.Trim();
_settings.AutoStart = AutoStartToggle.IsOn;
_settings.GlobalHotkeyEnabled = GlobalHotkeyToggle.IsOn;
@@ -101,13 +118,26 @@ private void SaveSettings()
private async void OnTestConnection(object sender, RoutedEventArgs e)
{
+ var useSshTunnel = UseSshTunnelToggle.IsOn;
+ var sshUser = "";
+ var sshHost = "";
+ var remotePort = 0;
+ var localPort = 0;
+ SshTunnelService? testTunnel = null;
+
var gatewayUrl = GatewayUrlTextBox.Text.Trim();
- if (!GatewayUrlHelper.IsValidGatewayUrl(gatewayUrl))
+ if (!useSshTunnel && !GatewayUrlHelper.IsValidGatewayUrl(gatewayUrl))
{
StatusLabel.Text = $"❌ {GatewayUrlHelper.ValidationMessage}";
return;
}
+ if (useSshTunnel && !TryReadTunnelSettings(out sshUser, out sshHost, out remotePort, out localPort, out var tunnelError))
+ {
+ StatusLabel.Text = $"❌ {tunnelError}";
+ return;
+ }
+
Logger.Info("[Settings] Test connection initiated");
StatusLabel.Text = LocalizationHelper.GetString("Status_Testing");
TestConnectionButton.IsEnabled = false;
@@ -115,8 +145,15 @@ private async void OnTestConnection(object sender, RoutedEventArgs e)
try
{
var testLogger = new TestLogger();
+ if (useSshTunnel)
+ {
+ testTunnel = new SshTunnelService(testLogger);
+ Logger.Info($"[Settings] Starting temporary SSH tunnel for test: {sshUser}@{sshHost} local:{localPort} remote:{remotePort}");
+ testTunnel.EnsureStarted(sshUser, sshHost, remotePort, localPort);
+ }
+
var client = new OpenClawGatewayClient(
- gatewayUrl,
+ useSshTunnel ? $"ws://127.0.0.1:{localPort}" : gatewayUrl,
TokenTextBox.Text.Trim(),
testLogger);
@@ -167,6 +204,7 @@ private async void OnTestConnection(object sender, RoutedEventArgs e)
}
finally
{
+ testTunnel?.Dispose();
TestConnectionButton.IsEnabled = true;
}
}
@@ -188,14 +226,22 @@ private void OnTestNotification(object sender, RoutedEventArgs e)
private void OnSave(object sender, RoutedEventArgs e)
{
+ var useSshTunnel = UseSshTunnelToggle.IsOn;
var gatewayUrl = GatewayUrlTextBox.Text.Trim();
- if (!GatewayUrlHelper.IsValidGatewayUrl(gatewayUrl))
+ if (!useSshTunnel && !GatewayUrlHelper.IsValidGatewayUrl(gatewayUrl))
{
Logger.Warn($"[Settings] Save blocked ΓÇö invalid gateway URL");
StatusLabel.Text = $"❌ {GatewayUrlHelper.ValidationMessage}";
return;
}
+ if (useSshTunnel && !TryReadTunnelSettings(out _, out _, out _, out _, out var tunnelError))
+ {
+ Logger.Warn("[Settings] Save blocked ΓÇö invalid SSH tunnel settings");
+ StatusLabel.Text = $"❌ {tunnelError}";
+ return;
+ }
+
// Log key setting changes before saving
var oldGateway = _settings.GatewayUrl;
var oldAutoStart = _settings.AutoStart;
@@ -220,6 +266,96 @@ private void OnCancel(object sender, RoutedEventArgs e)
Close();
}
+ private static int ParsePortOrDefault(string? value, int fallback)
+ {
+ if (int.TryParse(value?.Trim(), out var parsed) && parsed is >= 1 and <= 65535)
+ {
+ return parsed;
+ }
+
+ return fallback;
+ }
+
+ private bool TryReadTunnelSettings(
+ out string user,
+ out string host,
+ out int remotePort,
+ out int localPort,
+ out string? error)
+ {
+ user = SshTunnelUserTextBox.Text.Trim();
+ host = SshTunnelHostTextBox.Text.Trim();
+ remotePort = 0;
+ localPort = 0;
+ error = null;
+
+ if (string.IsNullOrWhiteSpace(user))
+ {
+ error = "SSH User is required when tunnel mode is enabled.";
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(host))
+ {
+ error = "SSH Host is required when tunnel mode is enabled.";
+ return false;
+ }
+
+ if (!int.TryParse(SshTunnelRemotePortTextBox.Text.Trim(), out remotePort) || remotePort is < 1 or > 65535)
+ {
+ error = "Remote Gateway Port must be a number from 1 to 65535.";
+ return false;
+ }
+
+ if (!int.TryParse(SshTunnelLocalPortTextBox.Text.Trim(), out localPort) || localPort is < 1 or > 65535)
+ {
+ error = "Local Forward Port must be a number from 1 to 65535.";
+ return false;
+ }
+
+ return true;
+ }
+
+ private void OnUseSshTunnelToggled(object sender, RoutedEventArgs e)
+ {
+ UpdateSshTunnelUiState();
+ }
+
+ private void OnSshTunnelLocalPortTextChanged(object sender, Microsoft.UI.Xaml.Controls.TextChangedEventArgs e)
+ {
+ if (UseSshTunnelToggle.IsOn)
+ {
+ UpdateSshTunnelUiState();
+ }
+ }
+
+ private void UpdateSshTunnelUiState()
+ {
+ var useSshTunnel = UseSshTunnelToggle.IsOn;
+ var wasReadOnly = GatewayUrlTextBox.IsReadOnly;
+
+ SshTunnelDetailsPanel.Visibility = useSshTunnel ? Visibility.Visible : Visibility.Collapsed;
+ GatewayUrlTextBox.IsReadOnly = useSshTunnel;
+
+ if (useSshTunnel)
+ {
+ if (!wasReadOnly)
+ {
+ _manualGatewayUrl = GatewayUrlTextBox.Text.Trim();
+ }
+
+ var localPort = ParsePortOrDefault(SshTunnelLocalPortTextBox.Text, 18789);
+ GatewayUrlTextBox.Text = $"ws://127.0.0.1:{localPort}";
+ }
+ else
+ {
+ if (GatewayUrlTextBox.Text.StartsWith("ws://127.0.0.1:", StringComparison.OrdinalIgnoreCase))
+ {
+ GatewayUrlTextBox.Text = _manualGatewayUrl;
+ }
+ }
+ }
+
private class TestLogger : IOpenClawLogger
{
public string? LastError { get; private set; }
@@ -233,8 +369,10 @@ public void Warn(string message)
}
public void Error(string message, Exception? ex = null)
{
- LastError = message;
- Logger.Error($"[Settings:TestClient] {message}");
+ LastError = ex != null
+ ? $"{message}: {ex.Message}"
+ : message;
+ Logger.Error($"[Settings:TestClient] {LastError}");
}
}
}
diff --git a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs
index 8b09519..887df5b 100644
--- a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs
+++ b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs
@@ -12,6 +12,11 @@ public void RoundTrip_AllFields_Preserved()
{
GatewayUrl = "ws://localhost:18789",
Token = "secret-token",
+ UseSshTunnel = true,
+ SshTunnelUser = "user1",
+ SshTunnelHost = "remote-host",
+ SshTunnelRemotePort = 18789,
+ SshTunnelLocalPort = 28789,
AutoStart = true,
GlobalHotkeyEnabled = false,
ShowNotifications = true,
@@ -40,6 +45,11 @@ public void RoundTrip_AllFields_Preserved()
Assert.NotNull(restored);
Assert.Equal(original.GatewayUrl, restored.GatewayUrl);
Assert.Equal(original.Token, restored.Token);
+ Assert.Equal(original.UseSshTunnel, restored.UseSshTunnel);
+ Assert.Equal(original.SshTunnelUser, restored.SshTunnelUser);
+ Assert.Equal(original.SshTunnelHost, restored.SshTunnelHost);
+ Assert.Equal(original.SshTunnelRemotePort, restored.SshTunnelRemotePort);
+ Assert.Equal(original.SshTunnelLocalPort, restored.SshTunnelLocalPort);
Assert.Equal(original.AutoStart, restored.AutoStart);
Assert.Equal(original.GlobalHotkeyEnabled, restored.GlobalHotkeyEnabled);
Assert.Equal(original.ShowNotifications, restored.ShowNotifications);
@@ -85,6 +95,11 @@ public void MissingFields_UseDefaults()
Assert.NotNull(settings);
Assert.Null(settings.GatewayUrl);
Assert.Null(settings.Token);
+ Assert.False(settings.UseSshTunnel);
+ Assert.Null(settings.SshTunnelUser);
+ Assert.Null(settings.SshTunnelHost);
+ Assert.Equal(18789, settings.SshTunnelRemotePort);
+ Assert.Equal(18789, settings.SshTunnelLocalPort);
Assert.False(settings.AutoStart);
Assert.True(settings.GlobalHotkeyEnabled);
Assert.True(settings.ShowNotifications);
@@ -131,6 +146,11 @@ public void BackwardCompatibility_OldSettingsWithoutNewFields()
Assert.NotNull(settings);
Assert.Equal("ws://localhost:18789", settings.GatewayUrl);
Assert.Equal("abc", settings.Token);
+ Assert.False(settings.UseSshTunnel);
+ Assert.Null(settings.SshTunnelUser);
+ Assert.Null(settings.SshTunnelHost);
+ Assert.Equal(18789, settings.SshTunnelRemotePort);
+ Assert.Equal(18789, settings.SshTunnelLocalPort);
// New fields should have sensible defaults
Assert.True(settings.NotifyChatResponses);
Assert.True(settings.PreferStructuredCategories);
From 98f48c2ef96656ce622f28f92a7eb15e0359f134 Mon Sep 17 00:00:00 2001
From: sytone <github@sytone.com>
Date: Sat, 28 Mar 2026 18:22:15 -0700
Subject: [PATCH 2/3] feat: Add SkippedUpdateTag to settings and enhance update
handling
- Introduced SkippedUpdateTag property in SettingsData and SettingsManager to remember skipped updates.
- Updated App.xaml.cs to initialize settings before update checks and handle skipped updates.
- Enhanced QuickSendDialog to provide detailed error messages and focus handling.
- Improved WebSocketClientBase with better auto-reconnect logic and error handling.
- Added integration tests for DeviceIdentity payload formats and OpenClawGatewayClient response handling.
- Updated SettingsRoundTripTests to validate SkippedUpdateTag persistence.
---
AGENTS.md | 25 +
README.md | 35 +-
build.ps1 | 7 +-
moltbot-windows-hub.slnx | 1 +
src/OpenClaw.Cli/OpenClaw.Cli.csproj | 12 +
src/OpenClaw.Cli/Program.cs | 300 +++++++++++
src/OpenClaw.Shared/DeviceIdentity.cs | 112 +++++
src/OpenClaw.Shared/OpenClawGatewayClient.cs | 465 +++++++++++++++++-
src/OpenClaw.Shared/SettingsData.cs | 1 +
src/OpenClaw.Shared/WebSocketClientBase.cs | 72 ++-
src/OpenClaw.Tray.WinUI/App.xaml.cs | 167 ++++++-
.../Dialogs/QuickSendDialog.cs | 205 +++++++-
.../Services/SettingsManager.cs | 3 +
.../Services/SshTunnelService.cs | 14 +-
.../DeviceIdentityTests.cs | 60 +++
.../OpenClawGatewayClientTests.cs | 141 ++++++
.../WebSocketClientBaseTests.cs | 4 +-
.../SettingsRoundTripTests.cs | 4 +
18 files changed, 1553 insertions(+), 75 deletions(-)
create mode 100644 AGENTS.md
create mode 100644 src/OpenClaw.Cli/OpenClaw.Cli.csproj
create mode 100644 src/OpenClaw.Cli/Program.cs
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..07e3ac6
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,25 @@
+# AGENTS.md
+
+## Required Validation After Every Change
+
+All agents working in this repository must run validation after each code change before marking work complete.
+
+Required steps:
+
+1. Run full repo build:
+ - `./build.ps1`
+2. Run shared tests:
+ - `dotnet test ./tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj --no-restore`
+3. Run tray tests:
+ - `dotnet test ./tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj --no-restore`
+
+If a command fails:
+
+1. Fix the issue.
+2. Re-run the failed command.
+3. Re-run all required validation commands before completion.
+
+Notes:
+
+- If a build/test is blocked by an environmental lock (for example running executable locking output assemblies), stop/close the locking process and rerun.
+- Do not claim completion without reporting validation results.
diff --git a/README.md b/README.md
index b0c3e40..d74620a 100644
--- a/README.md
+++ b/README.md
@@ -10,12 +10,13 @@ A Windows companion suite for [OpenClaw](https://openclaw.ai) - the AI-powered p
## Projects
-This monorepo contains three projects:
+This monorepo contains four projects:
| Project | Description |
|---------|-------------|
| **OpenClaw.Tray.WinUI** | System tray application (WinUI 3) for quick access to OpenClaw |
| **OpenClaw.Shared** | Shared gateway client library |
+| **OpenClaw.Cli** | CLI validator for WebSocket connect/send/probe using tray settings |
| **OpenClaw.CommandPalette** | PowerToys Command Palette extension |
## 🚀 Quick Start
@@ -65,6 +66,24 @@ dotnet build src/OpenClaw.Tray.WinUI -r win-x64 -p:PackageMsix=true # x64 MSI
.\src\OpenClaw.Tray.WinUI\bin\Debug\net10.0-windows10.0.19041.0\win-x64\OpenClaw.Tray.WinUI.exe # x64
```
+### Run CLI WebSocket Validator
+
+Use the CLI to validate gateway connectivity and `chat.send` outside the tray UI.
+
+```powershell
+# Show help
+dotnet run --project src/OpenClaw.Cli -- --help
+
+# Use tray settings from %APPDATA%\OpenClawTray\settings.json and send one message
+dotnet run --project src/OpenClaw.Cli -- --message "quick send validation"
+
+# Loop sends and also probe sessions/usage/nodes APIs
+dotnet run --project src/OpenClaw.Cli -- --repeat 5 --delay-ms 1000 --probe-read --verbose
+
+# Override gateway URL/token for isolated testing
+dotnet run --project src/OpenClaw.Cli -- --url ws://127.0.0.1:18789 --token "<token>" --message "override test"
+```
+
## 📦 OpenClaw.Tray (Molty)
Modern Windows 11-style system tray companion that connects to your local OpenClaw gateway.
@@ -85,6 +104,20 @@ Modern Windows 11-style system tray companion that connects to your local OpenCl
- ⚙️ **Settings** - Full configuration dialog
- 🎯 **First-run experience** - Welcome dialog guides new users
+#### Quick Send scope requirement
+
+Quick Send uses the gateway `chat.send` method and requires the operator device to have `operator.write` scope.
+
+If Quick Send fails with `missing scope: operator.write`, Molty now copies identity + remediation guidance to your clipboard, including:
+
+- operator role and `client.id` used by the tray app
+- gateway-reported operator device id (if provided)
+- currently granted scopes (if provided)
+
+For this specific error (`missing scope: operator.write`), the cause is an **operator token scope issue**. Update the token used by the tray app so it includes `operator.write`, then retry Quick Send.
+
+If Quick Send fails with `pairing required` / `NOT_PAIRED`, that is a **device approval** issue. Approve the tray device in gateway pairing approvals, reconnect, and retry.
+
### Menu Sections
- **Status** - Gateway connection status with click-to-view details
- **Sessions** - Active agent sessions with preview and per-session controls
diff --git a/build.ps1 b/build.ps1
index bd24b54..13cb7e8 100644
--- a/build.ps1
+++ b/build.ps1
@@ -6,7 +6,7 @@
Builds all projects, checks prerequisites, and provides clear guidance.
.PARAMETER Project
- Which project to build: All, Tray, WinUI, Shared, CommandPalette
+ Which project to build: All, Tray, WinUI, Shared, CommandPalette, Cli
Default: All
.PARAMETER Configuration
@@ -23,7 +23,7 @@
#>
param(
- [ValidateSet("All", "Tray", "WinUI", "Shared", "CommandPalette")]
+ [ValidateSet("All", "Tray", "WinUI", "Shared", "CommandPalette", "Cli")]
[string]$Project = "All",
[ValidateSet("Debug", "Release")]
@@ -187,12 +187,13 @@ function Build-Project($name, $path, $useRid = $false) {
$projects = @{
"Shared" = @{ Path = "src/OpenClaw.Shared/OpenClaw.Shared.csproj"; UseRid = $false }
+ "Cli" = @{ Path = "src/OpenClaw.Cli/OpenClaw.Cli.csproj"; UseRid = $false }
"Tray" = @{ Path = "src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj"; UseRid = $true }
"WinUI" = @{ Path = "src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj"; UseRid = $true }
"CommandPalette" = @{ Path = "src/OpenClaw.CommandPalette/OpenClaw.CommandPalette.csproj"; UseRid = $false }
}
-$toBuild = if ($Project -eq "All") { @("Shared", "WinUI") } else { @($Project) }
+$toBuild = if ($Project -eq "All") { @("Shared", "Cli", "WinUI") } else { @($Project) }
# Always build Shared first if building other projects
if ($Project -ne "Shared" -and $Project -ne "All" -and $toBuild -notcontains "Shared") {
diff --git a/moltbot-windows-hub.slnx b/moltbot-windows-hub.slnx
index 627f0f5..79eaf12 100644
--- a/moltbot-windows-hub.slnx
+++ b/moltbot-windows-hub.slnx
@@ -1,5 +1,6 @@
<Solution>
<Folder Name="/src/">
+ <Project Path="src/OpenClaw.Cli/OpenClaw.Cli.csproj" />
<Project Path="src/OpenClaw.CommandPalette/OpenClaw.CommandPalette.csproj">
<Platform Project="x64" />
</Project>
diff --git a/src/OpenClaw.Cli/OpenClaw.Cli.csproj b/src/OpenClaw.Cli/OpenClaw.Cli.csproj
new file mode 100644
index 0000000..2eecce4
--- /dev/null
+++ b/src/OpenClaw.Cli/OpenClaw.Cli.csproj
@@ -0,0 +1,12 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <OutputType>Exe</OutputType>
+ <TargetFramework>net10.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\OpenClaw.Shared\OpenClaw.Shared.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/src/OpenClaw.Cli/Program.cs b/src/OpenClaw.Cli/Program.cs
new file mode 100644
index 0000000..7fb544c
--- /dev/null
+++ b/src/OpenClaw.Cli/Program.cs
@@ -0,0 +1,300 @@
+using System.Globalization;
+using System.Text;
+using OpenClaw.Shared;
+
+internal sealed class CliOptions
+{
+ public string SettingsPath { get; set; } = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "OpenClawTray",
+ "settings.json");
+
+ public string? GatewayUrlOverride { get; set; }
+ public string? TokenOverride { get; set; }
+ public string Message { get; set; } = "openclaw-cli validation ping";
+ public int Repeat { get; set; } = 1;
+ public int DelayMs { get; set; } = 500;
+ public int ConnectTimeoutMs { get; set; } = 10000;
+ public bool ProbeReadApis { get; set; }
+ public bool Verbose { get; set; }
+}
+
+internal static class Program
+{
+ private static async Task<int> Main(string[] args)
+ {
+ if (args.Any(a => a is "--help" or "-h"))
+ {
+ PrintUsage();
+ return 0;
+ }
+
+ CliOptions options;
+ try
+ {
+ options = ParseArgs(args);
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Argument error: {ex.Message}");
+ PrintUsage();
+ return 2;
+ }
+
+ var (gatewayUrl, token, loaded) = LoadConnectionFromSettings(options);
+ if (string.IsNullOrWhiteSpace(gatewayUrl))
+ {
+ Console.Error.WriteLine("Gateway URL is missing. Set it in tray settings or pass --url.");
+ return 2;
+ }
+
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ Console.Error.WriteLine("Token is missing. Set it in tray settings or pass --token.");
+ return 2;
+ }
+
+ Console.WriteLine($"Settings file: {options.SettingsPath}");
+ Console.WriteLine($"Gateway URL: {GatewayUrlHelper.SanitizeForDisplay(gatewayUrl)}");
+ Console.WriteLine($"Token source: {(options.TokenOverride is null ? "settings" : "--token override")}");
+ if (loaded is not null)
+ {
+ Console.WriteLine($"Node mode in settings: {loaded.EnableNodeMode}");
+ Console.WriteLine($"SSH tunnel in settings: {loaded.UseSshTunnel} (local port {loaded.SshTunnelLocalPort})");
+ }
+
+ IOpenClawLogger logger = options.Verbose ? new ConsoleLogger() : NullLogger.Instance;
+ using var client = new OpenClawGatewayClient(gatewayUrl, token, logger);
+
+ var lastStatus = ConnectionStatus.Disconnected;
+ var connectedTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+ var errorTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ client.StatusChanged += (_, status) =>
+ {
+ lastStatus = status;
+ Console.WriteLine($"Status: {status}");
+ if (status == ConnectionStatus.Connected)
+ {
+ connectedTcs.TrySetResult(true);
+ }
+ else if (status == ConnectionStatus.Error)
+ {
+ errorTcs.TrySetResult(true);
+ }
+ };
+
+ client.SessionsUpdated += (_, sessions) => Console.WriteLine($"sessions.list -> {sessions.Length} session(s)");
+ client.UsageUpdated += (_, usage) => Console.WriteLine($"usage -> tokens {usage.TotalTokens}, requests {usage.RequestCount}, cost ${usage.CostUsd:F4}");
+ client.NodesUpdated += (_, nodes) => Console.WriteLine($"node.list -> {nodes.Length} node(s)");
+
+ Console.WriteLine("Connecting...");
+ await client.ConnectAsync();
+
+ var connected = await WaitForConnectedAsync(connectedTcs.Task, errorTcs.Task, options.ConnectTimeoutMs);
+ if (!connected)
+ {
+ Console.Error.WriteLine($"Connection did not reach Connected within {options.ConnectTimeoutMs}ms (last status: {lastStatus}).");
+ return 1;
+ }
+
+ Console.WriteLine($"Connected. Device ID: {client.OperatorDeviceId ?? "(unknown)"}");
+ Console.WriteLine($"Granted scopes: {string.Join(", ", client.GrantedOperatorScopes)}");
+
+ if (options.ProbeReadApis)
+ {
+ Console.WriteLine("Probing read APIs (sessions/usage/nodes)...");
+ await client.RequestSessionsAsync();
+ await client.RequestUsageAsync();
+ await client.RequestNodesAsync();
+ await Task.Delay(1200);
+ }
+
+ var failures = 0;
+ for (var i = 1; i <= options.Repeat; i++)
+ {
+ var message = options.Repeat == 1
+ ? options.Message
+ : $"{options.Message} [attempt {i}/{options.Repeat}]";
+
+ try
+ {
+ Console.WriteLine($"chat.send #{i} -> \"{message}\"");
+ await client.SendChatMessageAsync(message);
+ Console.WriteLine($"chat.send #{i} OK");
+ }
+ catch (Exception ex)
+ {
+ failures++;
+ Console.Error.WriteLine($"chat.send #{i} FAILED: {ex.Message}");
+ }
+
+ if (i < options.Repeat)
+ {
+ await Task.Delay(options.DelayMs);
+ }
+ }
+
+ if (failures > 0)
+ {
+ Console.Error.WriteLine($"Completed with {failures} failed send(s).");
+ return 1;
+ }
+
+ Console.WriteLine("All sends succeeded.");
+ return 0;
+ }
+
+ private static async Task<bool> WaitForConnectedAsync(Task connected, Task error, int timeoutMs)
+ {
+ using var timeoutCts = new CancellationTokenSource(timeoutMs);
+ var timeoutTask = Task.Delay(Timeout.InfiniteTimeSpan, timeoutCts.Token);
+
+ var completed = await Task.WhenAny(connected, error, timeoutTask);
+ if (completed == connected)
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ private static (string GatewayUrl, string Token, SettingsData? Loaded) LoadConnectionFromSettings(CliOptions options)
+ {
+ var loaded = LoadSettings(options.SettingsPath);
+
+ var gatewayUrl = options.GatewayUrlOverride;
+ if (string.IsNullOrWhiteSpace(gatewayUrl))
+ {
+ gatewayUrl = BuildEffectiveGatewayUrl(loaded);
+ }
+
+ var token = options.TokenOverride;
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ token = loaded?.Token;
+ }
+
+ return (gatewayUrl ?? string.Empty, token ?? string.Empty, loaded);
+ }
+
+ private static SettingsData? LoadSettings(string path)
+ {
+ if (!File.Exists(path))
+ {
+ throw new FileNotFoundException("Settings file not found", path);
+ }
+
+ var json = File.ReadAllText(path, Encoding.UTF8);
+ var settings = SettingsData.FromJson(json);
+ if (settings is null)
+ {
+ throw new InvalidOperationException("Settings JSON could not be parsed");
+ }
+
+ return settings;
+ }
+
+ private static string? BuildEffectiveGatewayUrl(SettingsData? settings)
+ {
+ if (settings is null)
+ {
+ return null;
+ }
+
+ if (!settings.UseSshTunnel)
+ {
+ return settings.GatewayUrl;
+ }
+
+ var port = settings.SshTunnelLocalPort <= 0 ? 18789 : settings.SshTunnelLocalPort;
+ return $"ws://127.0.0.1:{port}";
+ }
+
+ private static CliOptions ParseArgs(string[] args)
+ {
+ var options = new CliOptions();
+
+ for (var i = 0; i < args.Length; i++)
+ {
+ var arg = args[i];
+ switch (arg)
+ {
+ case "--settings":
+ options.SettingsPath = RequireValue(args, ref i, arg);
+ break;
+ case "--url":
+ options.GatewayUrlOverride = RequireValue(args, ref i, arg);
+ break;
+ case "--token":
+ options.TokenOverride = RequireValue(args, ref i, arg);
+ break;
+ case "--message":
+ options.Message = RequireValue(args, ref i, arg);
+ break;
+ case "--repeat":
+ options.Repeat = ParseInt(RequireValue(args, ref i, arg), min: 1, name: arg);
+ break;
+ case "--delay-ms":
+ options.DelayMs = ParseInt(RequireValue(args, ref i, arg), min: 0, name: arg);
+ break;
+ case "--connect-timeout-ms":
+ options.ConnectTimeoutMs = ParseInt(RequireValue(args, ref i, arg), min: 1000, name: arg);
+ break;
+ case "--probe-read":
+ options.ProbeReadApis = true;
+ break;
+ case "--verbose":
+ options.Verbose = true;
+ break;
+ default:
+ throw new ArgumentException($"Unknown argument: {arg}");
+ }
+ }
+
+ return options;
+ }
+
+ private static string RequireValue(string[] args, ref int index, string name)
+ {
+ if (index + 1 >= args.Length)
+ {
+ throw new ArgumentException($"Missing value for {name}");
+ }
+
+ index++;
+ return args[index];
+ }
+
+ private static int ParseInt(string value, int min, string name)
+ {
+ if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || parsed < min)
+ {
+ throw new ArgumentException($"{name} must be an integer >= {min}");
+ }
+
+ return parsed;
+ }
+
+ private static void PrintUsage()
+ {
+ Console.WriteLine("OpenClaw CLI WebSocket validator");
+ Console.WriteLine();
+ Console.WriteLine("Reads the same tray settings file and runs chat.send checks over gateway WebSocket.");
+ Console.WriteLine();
+ Console.WriteLine("Usage:");
+ Console.WriteLine(" dotnet run --project src/OpenClaw.Cli -- [options]");
+ Console.WriteLine();
+ Console.WriteLine("Options:");
+ Console.WriteLine(" --settings <path> Settings file (default: %APPDATA%\\OpenClawTray\\settings.json)");
+ Console.WriteLine(" --url <ws://...> Override gateway URL");
+ Console.WriteLine(" --token <token> Override token");
+ Console.WriteLine(" --message <text> Message to send");
+ Console.WriteLine(" --repeat <n> Number of sends (default: 1)");
+ Console.WriteLine(" --delay-ms <n> Delay between sends (default: 500)");
+ Console.WriteLine(" --connect-timeout-ms <n> Wait for Connected state (default: 10000)");
+ Console.WriteLine(" --probe-read Request sessions/usage/nodes once");
+ Console.WriteLine(" --verbose Enable shared client console logs");
+ Console.WriteLine(" --help, -h Show this help");
+ }
+}
diff --git a/src/OpenClaw.Shared/DeviceIdentity.cs b/src/OpenClaw.Shared/DeviceIdentity.cs
index 1e96a46..ff2f07b 100644
--- a/src/OpenClaw.Shared/DeviceIdentity.cs
+++ b/src/OpenClaw.Shared/DeviceIdentity.cs
@@ -134,6 +134,118 @@ public string SignPayload(string nonce, long signedAtMs, string clientId, string
// Return base64url encoded signature
return Base64UrlEncode(signature);
}
+
+ /// <summary>
+ /// Sign a v3 connect payload for operator/client connections.
+ /// Format: v3|{deviceId}|{clientId}|{clientMode}|{role}|{scopesCsv}|{signedAtMs}|{tokenOrEmpty}|{nonce}|{platform}|{deviceFamily}
+ /// </summary>
+ public string SignConnectPayloadV3(
+ string nonce,
+ long signedAtMs,
+ string clientId,
+ string clientMode,
+ string role,
+ IEnumerable<string> scopes,
+ string authToken,
+ string platform,
+ string deviceFamily)
+ {
+ if (_privateKey == null)
+ throw new InvalidOperationException("Device not initialized");
+
+ var payload = BuildConnectPayloadV3(
+ nonce,
+ signedAtMs,
+ clientId,
+ clientMode,
+ role,
+ scopes,
+ authToken,
+ platform,
+ deviceFamily);
+
+ var dataBytes = Encoding.UTF8.GetBytes(payload);
+ var signature = Ed25519Algorithm.Sign(_privateKey, dataBytes);
+ return Base64UrlEncode(signature);
+ }
+
+ /// <summary>
+ /// Build the v3 connect payload string for signing/debugging.
+ /// Format: v3|{deviceId}|{clientId}|{clientMode}|{role}|{scopesCsv}|{signedAtMs}|{tokenOrEmpty}|{nonce}|{platform}|{deviceFamily}
+ /// </summary>
+ public string BuildConnectPayloadV3(
+ string nonce,
+ long signedAtMs,
+ string clientId,
+ string clientMode,
+ string role,
+ IEnumerable<string> scopes,
+ string authToken,
+ string platform,
+ string deviceFamily)
+ {
+ if (_deviceId == null)
+ throw new InvalidOperationException("Device not initialized");
+
+ var scopesCsv = string.Join(",", scopes ?? Array.Empty<string>());
+ var safeToken = authToken ?? string.Empty;
+ var safeNonce = nonce ?? string.Empty;
+
+ return $"v3|{_deviceId}|{clientId}|{clientMode}|{role}|{scopesCsv}|{signedAtMs}|{safeToken}|{safeNonce}|{platform}|{deviceFamily}";
+ }
+
+ /// <summary>
+ /// Sign a v2 connect payload for compatibility mode.
+ /// Format: v2|{deviceId}|{clientId}|{clientMode}|{role}|{scopesCsv}|{signedAtMs}|{tokenOrEmpty}|{nonce}
+ /// </summary>
+ public string SignConnectPayloadV2(
+ string nonce,
+ long signedAtMs,
+ string clientId,
+ string clientMode,
+ string role,
+ IEnumerable<string> scopes,
+ string authToken)
+ {
+ if (_privateKey == null)
+ throw new InvalidOperationException("Device not initialized");
+
+ var payload = BuildConnectPayloadV2(
+ nonce,
+ signedAtMs,
+ clientId,
+ clientMode,
+ role,
+ scopes,
+ authToken);
+
+ var dataBytes = Encoding.UTF8.GetBytes(payload);
+ var signature = Ed25519Algorithm.Sign(_privateKey, dataBytes);
+ return Base64UrlEncode(signature);
+ }
+
+ /// <summary>
+ /// Build the v2 connect payload string for signing/debugging.
+ /// Format: v2|{deviceId}|{clientId}|{clientMode}|{role}|{scopesCsv}|{signedAtMs}|{tokenOrEmpty}|{nonce}
+ /// </summary>
+ public string BuildConnectPayloadV2(
+ string nonce,
+ long signedAtMs,
+ string clientId,
+ string clientMode,
+ string role,
+ IEnumerable<string> scopes,
+ string authToken)
+ {
+ if (_deviceId == null)
+ throw new InvalidOperationException("Device not initialized");
+
+ var scopesCsv = string.Join(",", scopes ?? Array.Empty<string>());
+ var safeToken = authToken ?? string.Empty;
+ var safeNonce = nonce ?? string.Empty;
+
+ return $"v2|{_deviceId}|{clientId}|{clientMode}|{role}|{scopesCsv}|{signedAtMs}|{safeToken}|{safeNonce}";
+ }
/// <summary>
/// Build the payload string (for debugging)
diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs
index 0e21836..4872758 100644
--- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs
+++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
+using System.IO;
+using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -8,6 +10,29 @@ namespace OpenClaw.Shared;
public class OpenClawGatewayClient : WebSocketClientBase
{
+ private const string OperatorClientId = "cli";
+ private const string OperatorClientDisplayName = "OpenClaw Windows Tray";
+ private const string OperatorClientMode = "cli";
+ private const string OperatorRole = "operator";
+ private const string OperatorPlatform = "windows";
+ private const string OperatorDeviceFamily = "desktop";
+ private static readonly string[] s_operatorScopes =
+ [
+ "operator.admin",
+ "operator.read",
+ "operator.write",
+ "operator.approvals",
+ "operator.pairing"
+ ];
+
+ private enum SignatureTokenMode
+ {
+ V3AuthToken,
+ V3EmptyToken,
+ V2AuthToken,
+ V2EmptyToken
+ }
+
// Tracked state
private readonly Dictionary<string, SessionInfo> _sessions = new();
private readonly Dictionary<string, GatewayNodeInfo> _nodes = new();
@@ -15,13 +40,24 @@ public class OpenClawGatewayClient : WebSocketClientBase
private GatewayUsageStatusInfo? _usageStatus;
private GatewayCostUsageInfo? _usageCost;
private readonly Dictionary<string, string> _pendingRequestMethods = new();
+ private readonly Dictionary<string, TaskCompletionSource<bool>> _pendingChatSendRequests = new();
private readonly object _pendingRequestLock = new();
+ private readonly object _pendingChatSendLock = new();
private readonly object _sessionsLock = new();
private readonly object _nodesLock = new();
+ private readonly DeviceIdentity _deviceIdentity;
+ private string _mainSessionKey = "main";
+ private string? _operatorDeviceId;
+ private string[] _grantedOperatorScopes = Array.Empty<string>();
+ private string _connectAuthToken;
+ private SignatureTokenMode _signatureTokenMode = SignatureTokenMode.V3AuthToken;
+ private long? _challengeTimestampMs;
private bool _usageStatusUnsupported;
private bool _usageCostUnsupported;
private bool _sessionPreviewUnsupported;
private bool _nodeListUnsupported;
+ private bool _operatorReadScopeUnavailable;
+ private bool _pairingRequiredAwaitingApproval;
private void ResetUnsupportedMethodFlags()
{
@@ -29,6 +65,7 @@ private void ResetUnsupportedMethodFlags()
_usageCostUnsupported = false;
_sessionPreviewUnsupported = false;
_nodeListUnsupported = false;
+ _operatorReadScopeUnavailable = false;
}
protected override int ReceiveBufferSize => 16384;
@@ -46,6 +83,11 @@ protected override Task OnConnectedAsync()
return Task.CompletedTask;
}
+ protected override bool ShouldAutoReconnect()
+ {
+ return !_pairingRequiredAwaitingApproval;
+ }
+
protected override void OnDisconnected()
{
ClearPendingRequests();
@@ -68,9 +110,20 @@ protected override void OnDisposing()
public event EventHandler<SessionsPreviewPayloadInfo>? SessionPreviewUpdated;
public event EventHandler<SessionCommandResult>? SessionCommandCompleted;
+ public string? OperatorDeviceId => _operatorDeviceId;
+ public IReadOnlyList<string> GrantedOperatorScopes => _grantedOperatorScopes;
+ public bool IsConnectedToGateway => IsConnected;
+
public OpenClawGatewayClient(string gatewayUrl, string token, IOpenClawLogger? logger = null)
: base(gatewayUrl, token, logger)
{
+ var dataPath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "OpenClawTray");
+
+ _deviceIdentity = new DeviceIdentity(dataPath, _logger);
+ _deviceIdentity.Initialize();
+ _connectAuthToken = _deviceIdentity.DeviceToken ?? _token;
}
public async Task DisconnectAsync()
@@ -118,31 +171,58 @@ public async Task CheckHealthAsync()
}
}
- public async Task SendChatMessageAsync(string message)
+ public async Task SendChatMessageAsync(string message, string? sessionKey = null)
{
if (!IsConnected)
throw new InvalidOperationException("Gateway connection is not open");
+ if (string.IsNullOrWhiteSpace(message))
+ throw new ArgumentException("Message is required", nameof(message));
+
+ var effectiveSessionKey = string.IsNullOrWhiteSpace(sessionKey)
+ ? _mainSessionKey
+ : sessionKey.Trim();
+
+ var requestId = Guid.NewGuid().ToString();
+ var completion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+ TrackPendingChatSend(requestId, completion);
var req = new
{
type = "req",
- id = Guid.NewGuid().ToString(),
+ id = requestId,
method = "chat.send",
- @params = new { message }
+ @params = new
+ {
+ sessionKey = effectiveSessionKey,
+ message,
+ idempotencyKey = Guid.NewGuid().ToString()
+ }
};
+
await SendRawAsync(JsonSerializer.Serialize(req));
+
+ var completedTask = await Task.WhenAny(completion.Task, Task.Delay(5000, CancellationToken));
+ if (completedTask != completion.Task)
+ {
+ RemovePendingChatSend(requestId);
+ throw new TimeoutException("Timed out waiting for chat.send response from gateway");
+ }
+
+ await completion.Task;
_logger.Info($"Sent chat message ({message.Length} chars)");
}
/// <summary>Request session list from gateway.</summary>
public async Task RequestSessionsAsync()
{
+ if (_operatorReadScopeUnavailable) return;
await SendTrackedRequestAsync("sessions.list");
}
/// <summary>Request usage/context info from gateway (may not be supported on all gateways).</summary>
public async Task RequestUsageAsync()
{
+ if (_operatorReadScopeUnavailable) return;
if (!IsConnected) return;
try
{
@@ -167,6 +247,7 @@ public async Task RequestUsageAsync()
/// <summary>Request connected node inventory from gateway.</summary>
public async Task RequestNodesAsync()
{
+ if (_operatorReadScopeUnavailable) return;
if (_nodeListUnsupported) return;
await SendTrackedRequestAsync("node.list");
}
@@ -281,11 +362,40 @@ public async Task<bool> StopChannelAsync(string channelName)
private async Task SendConnectMessageAsync(string? nonce = null)
{
+ var requestId = Guid.NewGuid().ToString();
+ TrackPendingRequest(requestId, "connect");
+
+ var signedAt = _challengeTimestampMs ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ var connectNonce = nonce ?? string.Empty;
+ var signatureToken = _signatureTokenMode is SignatureTokenMode.V3EmptyToken or SignatureTokenMode.V2EmptyToken
+ ? string.Empty
+ : _connectAuthToken;
+
+ var signature = _signatureTokenMode is SignatureTokenMode.V2AuthToken or SignatureTokenMode.V2EmptyToken
+ ? _deviceIdentity.SignConnectPayloadV2(
+ connectNonce,
+ signedAt,
+ OperatorClientId,
+ OperatorClientMode,
+ OperatorRole,
+ s_operatorScopes,
+ signatureToken)
+ : _deviceIdentity.SignConnectPayloadV3(
+ connectNonce,
+ signedAt,
+ OperatorClientId,
+ OperatorClientMode,
+ OperatorRole,
+ s_operatorScopes,
+ signatureToken,
+ OperatorPlatform,
+ OperatorDeviceFamily);
+
// Use "cli" client ID for native apps - no browser security checks
var msg = new
{
type = "req",
- id = Guid.NewGuid().ToString(),
+ id = requestId,
method = "connect",
@params = new
{
@@ -293,23 +403,40 @@ private async Task SendConnectMessageAsync(string? nonce = null)
maxProtocol = 3,
client = new
{
- id = "cli", // Native client ID
+ id = OperatorClientId, // Native client ID
version = "1.0.0",
- platform = "windows",
- mode = "cli",
- displayName = "OpenClaw Windows Tray"
+ platform = OperatorPlatform,
+ mode = OperatorClientMode,
+ displayName = OperatorClientDisplayName
},
- role = "operator",
- scopes = new[] { "operator.admin", "operator.approvals", "operator.pairing" },
+ role = OperatorRole,
+ scopes = s_operatorScopes,
caps = Array.Empty<string>(),
commands = Array.Empty<string>(),
permissions = new { },
- auth = new { token = _token },
+ auth = new { token = _connectAuthToken },
locale = "en-US",
- userAgent = "openclaw-windows-tray/1.0.0"
+ userAgent = "openclaw-windows-tray/1.0.0",
+ device = new
+ {
+ id = _deviceIdentity.DeviceId,
+ publicKey = _deviceIdentity.PublicKeyBase64Url,
+ signature,
+ signedAt,
+ nonce = connectNonce
+ }
}
};
- await SendRawAsync(JsonSerializer.Serialize(msg));
+
+ try
+ {
+ await SendRawAsync(JsonSerializer.Serialize(msg));
+ }
+ catch
+ {
+ RemovePendingRequest(requestId);
+ throw;
+ }
}
private async Task SendTrackedRequestAsync(string method, object? parameters = null)
@@ -397,6 +524,51 @@ private void ClearPendingRequests()
{
_pendingRequestMethods.Clear();
}
+
+ lock (_pendingChatSendLock)
+ {
+ foreach (var completion in _pendingChatSendRequests.Values)
+ {
+ completion.TrySetException(new OperationCanceledException("Request canceled"));
+ }
+
+ _pendingChatSendRequests.Clear();
+ }
+ }
+
+ private void TrackPendingChatSend(string requestId, TaskCompletionSource<bool> completion)
+ {
+ lock (_pendingChatSendLock)
+ {
+ _pendingChatSendRequests[requestId] = completion;
+ }
+ }
+
+ private void RemovePendingChatSend(string requestId)
+ {
+ lock (_pendingChatSendLock)
+ {
+ _pendingChatSendRequests.Remove(requestId);
+ }
+ }
+
+ private TaskCompletionSource<bool>? TakePendingChatSend(string? requestId)
+ {
+ if (string.IsNullOrWhiteSpace(requestId))
+ {
+ return null;
+ }
+
+ lock (_pendingChatSendLock)
+ {
+ if (!_pendingChatSendRequests.TryGetValue(requestId, out var completion))
+ {
+ return null;
+ }
+
+ _pendingChatSendRequests.Remove(requestId);
+ return completion;
+ }
}
// --- Message processing ---
@@ -434,9 +606,27 @@ private void ProcessMessage(string json)
private void HandleResponse(JsonElement root)
{
string? requestMethod = null;
+ string? requestId = null;
if (root.TryGetProperty("id", out var idProp))
{
- requestMethod = TakePendingRequestMethod(idProp.GetString());
+ requestId = idProp.GetString();
+ requestMethod = TakePendingRequestMethod(requestId);
+ }
+
+ var pendingChatSend = TakePendingChatSend(requestId);
+ if (pendingChatSend != null)
+ {
+ if (root.TryGetProperty("ok", out var okChatProp) &&
+ okChatProp.ValueKind == JsonValueKind.False)
+ {
+ var message = TryGetErrorMessage(root) ?? "request failed";
+ _logger.Warn($"chat.send failed: {message}");
+ pendingChatSend.TrySetException(new InvalidOperationException(message));
+ return;
+ }
+
+ pendingChatSend.TrySetResult(true);
+ return;
}
if (root.TryGetProperty("ok", out var okProp) &&
@@ -453,10 +643,31 @@ private void HandleResponse(JsonElement root)
return;
}
- // Handle hello-ok
+ // Handle handshake acknowledgement payload.
if (payload.TryGetProperty("type", out var t) && t.GetString() == "hello-ok")
{
+ _pairingRequiredAwaitingApproval = false;
+ _operatorDeviceId = TryGetHandshakeDeviceId(payload);
+ _grantedOperatorScopes = TryGetHandshakeScopes(payload);
+ _mainSessionKey = TryGetHandshakeMainSessionKey(payload) ?? "main";
+ var newDeviceToken = TryGetHandshakeDeviceToken(payload);
+ if (!string.IsNullOrWhiteSpace(newDeviceToken))
+ {
+ _deviceIdentity.StoreDeviceToken(newDeviceToken);
+ _connectAuthToken = newDeviceToken;
+ _logger.Info("Operator device token stored for reconnect");
+ }
+
_logger.Info("Handshake complete (hello-ok)");
+ if (!string.IsNullOrWhiteSpace(_operatorDeviceId))
+ {
+ _logger.Info($"Operator device ID: {_operatorDeviceId}");
+ }
+ if (_grantedOperatorScopes.Length > 0)
+ {
+ _logger.Info($"Granted operator scopes: {string.Join(", ", _grantedOperatorScopes)}");
+ }
+ _logger.Info($"Main session key: {_mainSessionKey}");
RaiseStatusChanged(ConnectionStatus.Connected);
// Request initial state after handshake
@@ -543,6 +754,49 @@ private void HandleRequestError(string? method, JsonElement root)
return;
}
+ if (method == "connect" &&
+ message.Contains("device signature invalid", StringComparison.OrdinalIgnoreCase))
+ {
+ var previousMode = _signatureTokenMode;
+ _signatureTokenMode = _signatureTokenMode switch
+ {
+ SignatureTokenMode.V3AuthToken => SignatureTokenMode.V3EmptyToken,
+ SignatureTokenMode.V3EmptyToken => SignatureTokenMode.V2AuthToken,
+ SignatureTokenMode.V2AuthToken => SignatureTokenMode.V2EmptyToken,
+ _ => SignatureTokenMode.V2EmptyToken
+ };
+
+ if (_signatureTokenMode != previousMode)
+ {
+ _logger.Warn($"Gateway rejected device signature with mode {previousMode}; retrying with mode {_signatureTokenMode}");
+ return;
+ }
+
+ _logger.Warn("Gateway rejected device signature in all supported payload modes");
+ return;
+ }
+
+ if (method == "connect" &&
+ message.Contains("pairing required", StringComparison.OrdinalIgnoreCase))
+ {
+ _pairingRequiredAwaitingApproval = true;
+ _logger.Warn("Pairing approval required for this device; auto-reconnect paused until manual reconnect or app restart");
+ RaiseStatusChanged(ConnectionStatus.Error);
+ return;
+ }
+
+ if (IsMissingScopeError(message, "operator.read") &&
+ method is "sessions.list" or "usage.status" or "usage.cost" or "node.list")
+ {
+ if (!_operatorReadScopeUnavailable)
+ {
+ _logger.Warn("Gateway token lacks operator.read; disabling sessions/usage/nodes polling");
+ }
+
+ _operatorReadScopeUnavailable = true;
+ return;
+ }
+
if (IsUnknownMethodError(message))
{
switch (method)
@@ -631,11 +885,184 @@ private static bool IsUnknownMethodError(string errorMessage)
return errorMessage.Contains("unknown method", StringComparison.OrdinalIgnoreCase);
}
+ private static bool IsMissingScopeError(string errorMessage, string scope)
+ {
+ if (string.IsNullOrWhiteSpace(errorMessage) || string.IsNullOrWhiteSpace(scope))
+ return false;
+
+ var expected = $"missing scope: {scope}";
+ return errorMessage.Contains(expected, StringComparison.OrdinalIgnoreCase);
+ }
+
private static bool IsSessionCommandMethod(string method)
{
return method is "sessions.patch" or "sessions.reset" or "sessions.delete" or "sessions.compact";
}
+ private static string? TryGetHandshakeDeviceId(JsonElement payload)
+ {
+ if (payload.TryGetProperty("deviceId", out var deviceIdProp) &&
+ deviceIdProp.ValueKind == JsonValueKind.String)
+ {
+ return deviceIdProp.GetString();
+ }
+
+ if (payload.TryGetProperty("device", out var deviceProp) &&
+ deviceProp.ValueKind == JsonValueKind.Object)
+ {
+ if (deviceProp.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String)
+ {
+ return idProp.GetString();
+ }
+
+ if (deviceProp.TryGetProperty("deviceId", out var didProp) && didProp.ValueKind == JsonValueKind.String)
+ {
+ return didProp.GetString();
+ }
+ }
+
+ return null;
+ }
+
+ private static string[] TryGetHandshakeScopes(JsonElement payload)
+ {
+ if (payload.TryGetProperty("scopes", out var scopesProp) &&
+ scopesProp.ValueKind == JsonValueKind.Array)
+ {
+ var scopes = new List<string>();
+ foreach (var scope in scopesProp.EnumerateArray())
+ {
+ if (scope.ValueKind == JsonValueKind.String)
+ {
+ var value = scope.GetString();
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ scopes.Add(value);
+ }
+ }
+ }
+
+ return scopes.ToArray();
+ }
+
+ return Array.Empty<string>();
+ }
+
+ private static string? TryGetHandshakeMainSessionKey(JsonElement payload)
+ {
+ if (!payload.TryGetProperty("snapshot", out var snapshot) || snapshot.ValueKind != JsonValueKind.Object)
+ {
+ return null;
+ }
+
+ if (!snapshot.TryGetProperty("sessionDefaults", out var sessionDefaults) || sessionDefaults.ValueKind != JsonValueKind.Object)
+ {
+ return null;
+ }
+
+ if (!sessionDefaults.TryGetProperty("mainKey", out var mainKey) || mainKey.ValueKind != JsonValueKind.String)
+ {
+ return null;
+ }
+
+ var value = mainKey.GetString();
+ return string.IsNullOrWhiteSpace(value) ? null : value;
+ }
+
+ private static string? TryGetHandshakeDeviceToken(JsonElement payload)
+ {
+ if (!payload.TryGetProperty("auth", out var authPayload) || authPayload.ValueKind != JsonValueKind.Object)
+ {
+ return null;
+ }
+
+ if (!authPayload.TryGetProperty("deviceToken", out var deviceToken) || deviceToken.ValueKind != JsonValueKind.String)
+ {
+ return null;
+ }
+
+ var value = deviceToken.GetString();
+ return string.IsNullOrWhiteSpace(value) ? null : value;
+ }
+
+ public string BuildMissingScopeFixCommands(string missingScope)
+ {
+ var scope = string.IsNullOrWhiteSpace(missingScope) ? "operator.write" : missingScope.Trim();
+ var grantedScopes = _grantedOperatorScopes.Length == 0
+ ? "(none reported by gateway)"
+ : string.Join(", ", _grantedOperatorScopes);
+ var deviceId = string.IsNullOrWhiteSpace(_operatorDeviceId)
+ ? "(not reported for this operator connection)"
+ : _operatorDeviceId;
+ var likelyNodeToken = _grantedOperatorScopes.Any(s => s.StartsWith("node.", StringComparison.OrdinalIgnoreCase));
+
+ var sb = new StringBuilder();
+ sb.AppendLine("Quick Send is connected, but your token is missing required permission.");
+ sb.AppendLine($"Missing scope: {scope}");
+ sb.AppendLine("Note: requested connect scopes are declarative; the gateway may grant fewer scopes based on token/policy/device state.");
+ sb.AppendLine();
+ sb.AppendLine("Do this in Windows Tray right now:");
+ sb.AppendLine("1. Right-click the tray icon and open Settings.");
+ sb.AppendLine("2. Replace Gateway Token with an OPERATOR token that includes operator.write.");
+ sb.AppendLine("3. Click Save.");
+ sb.AppendLine("4. Reconnect from the tray menu (or restart the tray app).");
+ sb.AppendLine("5. Retry Quick Send.");
+ sb.AppendLine();
+ sb.AppendLine("Token requirements for Quick Send:");
+ sb.AppendLine("- Role: operator");
+ sb.AppendLine("- Required scope: operator.write");
+ sb.AppendLine("- Recommended scopes: operator.admin, operator.read, operator.approvals, operator.pairing, operator.write");
+
+ if (likelyNodeToken)
+ {
+ sb.AppendLine();
+ sb.AppendLine("Detected node.* scopes. This usually means a node token was pasted into Gateway Token.");
+ sb.AppendLine("Quick Send requires an operator token, not a node token.");
+ }
+
+ sb.AppendLine();
+ sb.AppendLine("Connection details from this app (for debugging/support):");
+ sb.AppendLine($"- role: operator");
+ sb.AppendLine($"- client.id: {OperatorClientId}");
+ sb.AppendLine($"- client.displayName: {OperatorClientDisplayName}");
+ sb.AppendLine($"- operator device id: {deviceId}");
+ sb.AppendLine($"- granted scopes: {grantedScopes}");
+ sb.AppendLine();
+ sb.AppendLine("If this still fails after updating the token, copy this block and share it with your gateway admin.");
+ return sb.ToString().TrimEnd();
+ }
+
+ public string BuildPairingApprovalFixCommands()
+ {
+ var deviceId = !string.IsNullOrWhiteSpace(_operatorDeviceId)
+ ? _operatorDeviceId
+ : _deviceIdentity.DeviceId;
+ var grantedScopes = _grantedOperatorScopes.Length == 0
+ ? "(none reported by gateway yet)"
+ : string.Join(", ", _grantedOperatorScopes);
+
+ var sb = new StringBuilder();
+ sb.AppendLine("Quick Send requires this device to be approved (paired) in the gateway.");
+ sb.AppendLine("Gateway reported: pairing required");
+ sb.AppendLine();
+ sb.AppendLine("Do this now:");
+ sb.AppendLine("1. Open the gateway admin UI.");
+ sb.AppendLine("2. Go to pending pairing/device approvals.");
+ sb.AppendLine("3. Approve this Windows tray device ID.");
+ sb.AppendLine("4. Return to tray and reconnect (or restart tray app).");
+ sb.AppendLine("5. Retry Quick Send.");
+ sb.AppendLine();
+ sb.AppendLine("Connection details from this app (for debugging/support):");
+ sb.AppendLine("- role: operator");
+ sb.AppendLine($"- client.id: {OperatorClientId}");
+ sb.AppendLine($"- client.displayName: {OperatorClientDisplayName}");
+ sb.AppendLine($"- operator device id: {deviceId}");
+ sb.AppendLine($"- granted scopes: {grantedScopes}");
+ sb.AppendLine();
+ sb.AppendLine("If approval keeps failing, share this block with your gateway admin.");
+ return sb.ToString().TrimEnd();
+ }
+
private void HandleEvent(JsonElement root)
{
if (!root.TryGetProperty("event", out var eventProp)) return;
@@ -666,11 +1093,19 @@ private void HandleEvent(JsonElement root)
private void HandleConnectChallenge(JsonElement root)
{
string? nonce = null;
+ long? ts = null;
if (root.TryGetProperty("payload", out var payload) &&
payload.TryGetProperty("nonce", out var nonceProp))
{
nonce = nonceProp.GetString();
+
+ if (payload.TryGetProperty("ts", out var tsProp) && tsProp.ValueKind == JsonValueKind.Number)
+ {
+ ts = tsProp.GetInt64();
+ }
}
+
+ _challengeTimestampMs = ts;
_logger.Info($"Received challenge, nonce: {nonce}");
_ = SendConnectMessageAsync(nonce);
diff --git a/src/OpenClaw.Shared/SettingsData.cs b/src/OpenClaw.Shared/SettingsData.cs
index 7a2d4b5..27ec9a0 100644
--- a/src/OpenClaw.Shared/SettingsData.cs
+++ b/src/OpenClaw.Shared/SettingsData.cs
@@ -28,6 +28,7 @@ public class SettingsData
public bool NotifyInfo { get; set; } = true;
public bool EnableNodeMode { get; set; } = false;
public bool HasSeenActivityStreamTip { get; set; } = false;
+ public string? SkippedUpdateTag { get; set; }
public bool NotifyChatResponses { get; set; } = true;
public bool PreferStructuredCategories { get; set; } = true;
public List<UserNotificationRule>? UserRules { get; set; }
diff --git a/src/OpenClaw.Shared/WebSocketClientBase.cs b/src/OpenClaw.Shared/WebSocketClientBase.cs
index 72c4d10..0a633e2 100644
--- a/src/OpenClaw.Shared/WebSocketClientBase.cs
+++ b/src/OpenClaw.Shared/WebSocketClientBase.cs
@@ -19,6 +19,7 @@ public abstract class WebSocketClientBase : IDisposable
private CancellationTokenSource _cts;
private bool _disposed;
private int _reconnectAttempts;
+ private int _reconnectLoopActive;
private static readonly int[] BackoffMs = { 1000, 2000, 4000, 8000, 15000, 30000, 60000 };
protected readonly string _token;
@@ -68,6 +69,12 @@ protected virtual void OnError(Exception ex) { }
/// <summary>Called at the start of Dispose, before CTS cancellation.</summary>
protected virtual void OnDisposing() { }
+ /// <summary>
+ /// Whether auto-reconnect should run after an unexpected disconnect.
+ /// Subclasses can return false for known terminal states (for example awaiting pairing approval).
+ /// </summary>
+ protected virtual bool ShouldAutoReconnect() => true;
+
protected WebSocketClientBase(string gatewayUrl, string token, IOpenClawLogger? logger = null)
{
if (string.IsNullOrEmpty(gatewayUrl))
@@ -85,6 +92,12 @@ protected WebSocketClientBase(string gatewayUrl, string token, IOpenClawLogger?
public async Task ConnectAsync()
{
+ if (_disposed)
+ {
+ _logger.Debug($"Skipping {ClientRole} connect: client already disposed");
+ return;
+ }
+
try
{
RaiseStatusChanged(ConnectionStatus.Connecting);
@@ -116,10 +129,23 @@ public async Task ConnectAsync()
_ = Task.Run(() => ListenForMessagesAsync(), _cts.Token);
}
+ catch (OperationCanceledException)
+ {
+ _logger.Debug($"{ClientRole} connect canceled (likely shutdown)");
+ }
+ catch (ObjectDisposedException)
+ {
+ _logger.Debug($"{ClientRole} connect aborted after dispose");
+ }
catch (Exception ex)
{
_logger.Error($"{ClientRole} connection failed", ex);
RaiseStatusChanged(ConnectionStatus.Error);
+
+ if (!_disposed && !_cts.Token.IsCancellationRequested && ShouldAutoReconnect())
+ {
+ _ = ReconnectWithBackoffAsync();
+ }
}
}
@@ -175,7 +201,7 @@ private async Task ListenForMessagesAsync()
{
try
{
- if (!_cts.Token.IsCancellationRequested)
+ if (!_cts.Token.IsCancellationRequested && ShouldAutoReconnect())
{
await ReconnectWithBackoffAsync();
}
@@ -186,31 +212,51 @@ private async Task ListenForMessagesAsync()
protected async Task ReconnectWithBackoffAsync()
{
- var delay = BackoffMs[Math.Min(_reconnectAttempts, BackoffMs.Length - 1)];
- _reconnectAttempts++;
- _logger.Warn($"{ClientRole} reconnecting in {delay}ms (attempt {_reconnectAttempts})");
- RaiseStatusChanged(ConnectionStatus.Connecting);
+ if (Interlocked.CompareExchange(ref _reconnectLoopActive, 1, 0) != 0)
+ {
+ return;
+ }
try
{
- await Task.Delay(delay, _cts.Token);
+ while (!_disposed && !_cts.Token.IsCancellationRequested && ShouldAutoReconnect())
+ {
+ var delay = BackoffMs[Math.Min(_reconnectAttempts, BackoffMs.Length - 1)];
+ _reconnectAttempts++;
+ _logger.Warn($"{ClientRole} reconnecting in {delay}ms (attempt {_reconnectAttempts})");
+ RaiseStatusChanged(ConnectionStatus.Connecting);
+
+ await Task.Delay(delay, _cts.Token);
+
+ if (_cts.Token.IsCancellationRequested || _disposed || !ShouldAutoReconnect())
+ {
+ break;
+ }
- // Check cancellation after delay
- if (_cts.Token.IsCancellationRequested) return;
+ // Safely dispose old socket
+ var oldSocket = _webSocket;
+ _webSocket = null;
+ try { oldSocket?.Dispose(); } catch { /* ignore dispose errors */ }
- // Safely dispose old socket
- var oldSocket = _webSocket;
- _webSocket = null;
- try { oldSocket?.Dispose(); } catch { /* ignore dispose errors */ }
+ await ConnectAsync();
- await ConnectAsync();
+ if (IsConnected)
+ {
+ break;
+ }
+ }
}
catch (OperationCanceledException) { }
+ catch (ObjectDisposedException) { }
catch (Exception ex)
{
_logger.Error($"{ClientRole} reconnect failed", ex);
RaiseStatusChanged(ConnectionStatus.Error);
}
+ finally
+ {
+ Interlocked.Exchange(ref _reconnectLoopActive, 0);
+ }
}
/// <summary>Send a text message over the WebSocket. Thread-safe.</summary>
diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs
index caff372..a68ad7d 100644
--- a/src/OpenClaw.Tray.WinUI/App.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs
@@ -41,6 +41,7 @@ public partial class App : Application
private Mutex? _mutex;
private Microsoft.UI.Dispatching.DispatcherQueue? _dispatcherQueue;
private CancellationTokenSource? _deepLinkCts;
+ private bool _isExiting;
private ConnectionStatus _currentStatus = ConnectionStatus.Disconnected;
private AgentActivity? _currentActivity;
@@ -235,6 +236,9 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args)
// Store protocol URI for processing after setup
_pendingProtocolUri = protocolUri;
+ // Initialize settings before update check so skip selections can be remembered.
+ _settings = new SettingsManager();
+
// Register URI scheme on first run
DeepLinkHandler.RegisterUriScheme();
@@ -249,8 +253,6 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args)
// Register toast activation handler
ToastNotificationManagerCompat.OnActivated += OnToastActivated;
- // Initialize settings
- _settings = new SettingsManager();
_sshTunnelService = new SshTunnelService(new AppLogger());
// First-run check
@@ -1680,7 +1682,7 @@ private void ShowQuickSend(string? prefillMessage = null)
else
{
Logger.Info("QuickSend dialog already open; activating");
- _quickSendDialog.Activate();
+ _quickSendDialog.ShowAsync();
return;
}
}
@@ -1695,7 +1697,7 @@ private void ShowQuickSend(string? prefillMessage = null)
}
};
_quickSendDialog = dialog;
- dialog.Activate();
+ dialog.ShowAsync();
}
catch (Exception ex)
{
@@ -1894,15 +1896,33 @@ private async Task<bool> CheckForUpdatesAsync()
var changelog = AppUpdater.GetChangelog(true) ?? "No release notes available.";
Logger.Info($"Update available: {release.TagName}");
+ if (!string.IsNullOrWhiteSpace(_settings?.SkippedUpdateTag) &&
+ string.Equals(_settings.SkippedUpdateTag, release.TagName, StringComparison.OrdinalIgnoreCase))
+ {
+ Logger.Info($"Skipping update prompt for remembered version {release.TagName}");
+ return true;
+ }
+
var dialog = new UpdateDialog(release.TagName, changelog);
var result = await dialog.ShowAsync();
if (result == UpdateDialogResult.Download)
{
+ if (_settings != null)
+ {
+ _settings.SkippedUpdateTag = string.Empty;
+ _settings.Save();
+ }
var installed = await DownloadAndInstallUpdateAsync();
return !installed; // Don't launch if update succeeded
}
+ if (result == UpdateDialogResult.Skip && _settings != null)
+ {
+ _settings.SkippedUpdateTag = release.TagName ?? string.Empty;
+ _settings.Save();
+ }
+
return true; // RemindLater or Skip - continue
}
catch (Exception ex)
@@ -1969,6 +1989,7 @@ private void StartDeepLinkServer()
}
catch (OperationCanceledException)
{
+ Logger.Info("Deep link server stopping (canceled)");
break; // Normal shutdown
}
catch (Exception ex)
@@ -2058,35 +2079,133 @@ private void OnToastActivated(ToastNotificationActivatedEventArgsCompat args)
private void ExitApplication()
{
+ if (_isExiting)
+ {
+ Logger.Info("Exit requested while shutdown already in progress");
+ return;
+ }
+
+ _isExiting = true;
Logger.Info("Application exiting");
-
+
// Cancel background tasks
- _deepLinkCts?.Cancel();
-
+ if (_deepLinkCts != null)
+ {
+ Logger.Info("Shutdown: canceling deep link server");
+ try { _deepLinkCts.Cancel(); } catch (Exception ex) { Logger.Warn($"Shutdown: deep link cancel failed: {ex.Message}"); }
+ }
+
// Stop timers
- _healthCheckTimer?.Stop();
- _healthCheckTimer?.Dispose();
- _sessionPollTimer?.Stop();
- _sessionPollTimer?.Dispose();
-
+ SafeShutdownStep("health timer", () =>
+ {
+ _healthCheckTimer?.Stop();
+ _healthCheckTimer?.Dispose();
+ _healthCheckTimer = null;
+ });
+
+ SafeShutdownStep("session poll timer", () =>
+ {
+ _sessionPollTimer?.Stop();
+ _sessionPollTimer?.Dispose();
+ _sessionPollTimer = null;
+ });
+
// Cleanup hotkey
- _globalHotkey?.Dispose();
-
- // Unsubscribe and dispose gateway client
- UnsubscribeGatewayEvents();
- _gatewayClient?.Dispose();
- _sshTunnelService?.Dispose();
-
+ SafeShutdownStep("global hotkey", () =>
+ {
+ _globalHotkey?.Dispose();
+ _globalHotkey = null;
+ });
+
+ // Dispose runtime services
+ SafeShutdownStep("gateway client", () =>
+ {
+ UnsubscribeGatewayEvents();
+ _gatewayClient?.Dispose();
+ _gatewayClient = null;
+ });
+
+ SafeShutdownStep("node service", () =>
+ {
+ _nodeService?.Dispose();
+ _nodeService = null;
+ });
+
+ SafeShutdownStep("ssh tunnel service", () =>
+ {
+ _sshTunnelService?.Dispose();
+ _sshTunnelService = null;
+ });
+
+ // Close windows explicitly for deterministic shutdown tracing.
+ SafeShutdownStep("settings window", () => CloseWindow(_settingsWindow));
+ _settingsWindow = null;
+ SafeShutdownStep("web chat window", () => CloseWindow(_webChatWindow));
+ _webChatWindow = null;
+ SafeShutdownStep("status detail window", () => CloseWindow(_statusDetailWindow));
+ _statusDetailWindow = null;
+ SafeShutdownStep("notification history window", () => CloseWindow(_notificationHistoryWindow));
+ _notificationHistoryWindow = null;
+ SafeShutdownStep("activity stream window", () => CloseWindow(_activityStreamWindow));
+ _activityStreamWindow = null;
+ SafeShutdownStep("tray menu window", () => CloseWindow(_trayMenuWindow));
+ _trayMenuWindow = null;
+ SafeShutdownStep("quick send dialog", () => CloseWindow(_quickSendDialog));
+ _quickSendDialog = null;
+ SafeShutdownStep("keep alive window", () => CloseWindow(_keepAliveWindow));
+ _keepAliveWindow = null;
+
// Dispose tray and mutex
- _trayIcon?.Dispose();
- _mutex?.Dispose();
-
+ SafeShutdownStep("tray icon", () =>
+ {
+ _trayIcon?.Dispose();
+ _trayIcon = null;
+ });
+
+ SafeShutdownStep("single-instance mutex", () =>
+ {
+ _mutex?.Dispose();
+ _mutex = null;
+ });
+
// Dispose cancellation token source
- _deepLinkCts?.Dispose();
-
+ SafeShutdownStep("deep link token source", () =>
+ {
+ _deepLinkCts?.Dispose();
+ _deepLinkCts = null;
+ });
+
+ Logger.Info("Shutdown complete; calling Exit() now");
Exit();
}
+ private static void CloseWindow(Window? window)
+ {
+ try
+ {
+ window?.Close();
+ }
+ catch
+ {
+ // Let caller log specific failure context.
+ throw;
+ }
+ }
+
+ private static void SafeShutdownStep(string name, Action action)
+ {
+ try
+ {
+ Logger.Info($"Shutdown: disposing {name}");
+ action();
+ Logger.Info($"Shutdown: disposed {name}");
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"Shutdown: failed disposing {name}: {ex.Message}");
+ }
+ }
+
private bool EnsureSshTunnelConfigured()
{
if (_settings == null)
diff --git a/src/OpenClaw.Tray.WinUI/Dialogs/QuickSendDialog.cs b/src/OpenClaw.Tray.WinUI/Dialogs/QuickSendDialog.cs
index c8ea27b..1c54f20 100644
--- a/src/OpenClaw.Tray.WinUI/Dialogs/QuickSendDialog.cs
+++ b/src/OpenClaw.Tray.WinUI/Dialogs/QuickSendDialog.cs
@@ -9,6 +9,7 @@
using System;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
+using System.Text.RegularExpressions;
using WinUIEx;
namespace OpenClawTray.Dialogs;
@@ -20,8 +21,8 @@ public sealed class QuickSendDialog : WindowEx
{
private readonly OpenClawGatewayClient _client;
private readonly TextBox _messageTextBox;
+ private readonly TextBox _errorDetailsTextBox;
private readonly Button _sendButton;
- private readonly TextBlock _statusText;
private bool _isSending;
[DllImport("user32.dll")]
@@ -52,7 +53,7 @@ public QuickSendDialog(OpenClawGatewayClient client, string? prefillMessage = nu
// Window setup
Title = LocalizationHelper.GetString("WindowTitle_QuickSend");
- this.SetWindowSize(400, 200);
+ this.SetWindowSize(420, 260);
this.CenterOnScreen();
this.SetIcon(IconHelper.GetStatusIconPath(ConnectionStatus.Connected));
@@ -65,17 +66,21 @@ public QuickSendDialog(OpenClawGatewayClient client, string? prefillMessage = nu
this.IsAlwaysOnTop = true;
// Build UI programmatically (simple dialog)
- var root = new StackPanel
+ var root = new Grid
{
- Spacing = 12,
- Padding = new Thickness(24)
+ RowSpacing = 12
};
+ root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+ root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+ root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
+ root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
var header = new TextBlock
{
Text = LocalizationHelper.GetString("QuickSend_Header"),
Style = (Style)Application.Current.Resources["SubtitleTextBlockStyle"]
};
+ Grid.SetRow(header, 0);
root.Children.Add(header);
_messageTextBox = new TextBox
@@ -85,8 +90,24 @@ public QuickSendDialog(OpenClawGatewayClient client, string? prefillMessage = nu
Text = prefillMessage ?? ""
};
_messageTextBox.KeyDown += OnKeyDown;
+ Grid.SetRow(_messageTextBox, 1);
root.Children.Add(_messageTextBox);
+ _errorDetailsTextBox = new TextBox
+ {
+ Visibility = Visibility.Collapsed,
+ IsReadOnly = true,
+ IsTabStop = true,
+ AcceptsReturn = true,
+ TextWrapping = TextWrapping.Wrap,
+ MinHeight = 80,
+ MaxHeight = 240,
+ VerticalAlignment = VerticalAlignment.Stretch
+ };
+ ScrollViewer.SetVerticalScrollBarVisibility(_errorDetailsTextBox, ScrollBarVisibility.Auto);
+ Grid.SetRow(_errorDetailsTextBox, 2);
+ root.Children.Add(_errorDetailsTextBox);
+
var buttonPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
@@ -94,13 +115,6 @@ public QuickSendDialog(OpenClawGatewayClient client, string? prefillMessage = nu
HorizontalAlignment = HorizontalAlignment.Right
};
- _statusText = new TextBlock
- {
- VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(0, 0, 12, 0)
- };
- buttonPanel.Children.Add(_statusText);
-
var cancelButton = new Button { Content = LocalizationHelper.GetString("QuickSend_CancelButton") };
cancelButton.Click += (s, e) => Close();
buttonPanel.Children.Add(cancelButton);
@@ -113,15 +127,20 @@ public QuickSendDialog(OpenClawGatewayClient client, string? prefillMessage = nu
_sendButton.Click += OnSendClick;
buttonPanel.Children.Add(_sendButton);
+ Grid.SetRow(buttonPanel, 3);
root.Children.Add(buttonPanel);
- Content = root;
+ Content = new Border
+ {
+ Padding = new Thickness(24),
+ Child = root
+ };
// Focus the text box when shown
Activated += (s, e) =>
{
- _messageTextBox.Focus(FocusState.Programmatic);
TryBringToFront();
+ RequestInputFocus();
};
Closed += (s, e) => Logger.Info("[QuickSend] Dialog closed");
@@ -170,13 +189,22 @@ private async Task SendMessageAsync()
var message = _messageTextBox.Text?.Trim();
if (string.IsNullOrEmpty(message)) return;
+ _errorDetailsTextBox.Visibility = Visibility.Collapsed;
+ _errorDetailsTextBox.Text = string.Empty;
+ this.SetWindowSize(420, 260);
+
_isSending = true;
_sendButton.IsEnabled = false;
_messageTextBox.IsEnabled = false;
- _statusText.Text = LocalizationHelper.GetString("QuickSend_Sending");
+ ShowDetails(LocalizationHelper.GetString("QuickSend_Sending"));
try
{
+ if (!await EnsureGatewayConnectedAsync())
+ {
+ throw new InvalidOperationException("Gateway connection is not open");
+ }
+
await _client.SendChatMessageAsync(message);
Logger.Info($"[QuickSend] Message sent ({message.Length} chars)");
new ToastContentBuilder()
@@ -188,15 +216,160 @@ private async Task SendMessageAsync()
catch (Exception ex)
{
Logger.Error($"Quick send failed: {ex.Message}");
- _statusText.Text = LocalizationHelper.GetString("QuickSend_Failed");
+ if (IsPairingRequired(ex.Message))
+ {
+ var commands = _client.BuildPairingApprovalFixCommands();
+ CopyTextToClipboard(commands);
+
+ ShowErrorDetails($"Pairing approval required\n\n{commands}");
+ new ToastContentBuilder()
+ .AddText("Quick Send device approval required")
+ .AddText("Gateway reported pairing required. Approval guidance copied to clipboard.")
+ .Show();
+ Logger.Warn($"[QuickSend] Pairing required. Commands copied to clipboard.\n{commands}");
+ }
+ else if (TryExtractMissingScope(ex.Message, out var missingScope))
+ {
+ var commands = _client.BuildMissingScopeFixCommands(missingScope);
+ CopyTextToClipboard(commands);
+
+ ShowErrorDetails($"Missing scope: {missingScope}\n\n{commands}");
+ new ToastContentBuilder()
+ .AddText("Quick Send permission required")
+ .AddText($"Missing scope '{missingScope}'. Identity + remediation guidance copied to clipboard.")
+ .Show();
+ Logger.Warn($"[QuickSend] Missing scope '{missingScope}'. Commands copied to clipboard.\n{commands}");
+ }
+ else
+ {
+ ShowErrorDetails(ex.Message);
+ }
+
_sendButton.IsEnabled = true;
_messageTextBox.IsEnabled = true;
_isSending = false;
}
}
+ private void ShowErrorDetails(string details)
+ {
+ _errorDetailsTextBox.Header = LocalizationHelper.GetString("QuickSend_Failed");
+ _errorDetailsTextBox.MinHeight = 140;
+ _errorDetailsTextBox.Text = details;
+ _errorDetailsTextBox.Visibility = Visibility.Visible;
+ this.SetWindowSize(520, 400);
+
+ // Move focus to the details box so users can immediately select/copy text.
+ _errorDetailsTextBox.Focus(FocusState.Programmatic);
+ }
+
+ private void ShowDetails(string details)
+ {
+ _errorDetailsTextBox.Header = null;
+ _errorDetailsTextBox.MinHeight = 80;
+ _errorDetailsTextBox.Text = details;
+ _errorDetailsTextBox.Visibility = Visibility.Visible;
+ this.SetWindowSize(500, 320);
+ }
+
+ private static bool TryExtractMissingScope(string? message, out string scope)
+ {
+ scope = string.Empty;
+ if (string.IsNullOrWhiteSpace(message))
+ {
+ return false;
+ }
+
+ var match = Regex.Match(message, @"missing\s+scope\s*:\s*([A-Za-z0-9._-]+)", RegexOptions.IgnoreCase);
+ if (!match.Success)
+ {
+ return false;
+ }
+
+ scope = match.Groups[1].Value;
+ return !string.IsNullOrWhiteSpace(scope);
+ }
+
+ private static bool IsPairingRequired(string? message)
+ {
+ if (string.IsNullOrWhiteSpace(message))
+ {
+ return false;
+ }
+
+ return message.Contains("pairing required", StringComparison.OrdinalIgnoreCase)
+ || message.Contains("not paired", StringComparison.OrdinalIgnoreCase)
+ || message.Contains("NOT_PAIRED", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static void CopyTextToClipboard(string text)
+ {
+ var data = new global::Windows.ApplicationModel.DataTransfer.DataPackage();
+ data.SetText(text);
+ global::Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(data);
+ }
+
+ private void QueueFocusMessageInput()
+ {
+ DispatcherQueue?.TryEnqueue(FocusMessageInput);
+ }
+
+ private void RequestInputFocus()
+ {
+ QueueFocusMessageInput();
+ _ = RetryFocusMessageInputAsync();
+ }
+
+ private async Task RetryFocusMessageInputAsync()
+ {
+ var delaysMs = new[] { 60, 160, 320 };
+ foreach (var delay in delaysMs)
+ {
+ await Task.Delay(delay);
+ TryBringToFront();
+ QueueFocusMessageInput();
+ }
+ }
+
+ private async Task<bool> EnsureGatewayConnectedAsync(int timeoutMs = 3000)
+ {
+ if (_client.IsConnectedToGateway)
+ {
+ return true;
+ }
+
+ try
+ {
+ await _client.ConnectAsync();
+ }
+ catch
+ {
+ // Connect errors are handled by the send flow.
+ }
+
+ var started = Environment.TickCount64;
+ while (Environment.TickCount64 - started < timeoutMs)
+ {
+ if (_client.IsConnectedToGateway)
+ {
+ return true;
+ }
+
+ await Task.Delay(120);
+ }
+
+ return _client.IsConnectedToGateway;
+ }
+
+ public void FocusMessageInput()
+ {
+ _messageTextBox.Focus(FocusState.Programmatic);
+ _messageTextBox.SelectionStart = _messageTextBox.Text?.Length ?? 0;
+ }
+
public new void ShowAsync()
{
Activate();
+ RequestInputFocus();
}
}
diff --git a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs
index 5347a6b..f89e513 100644
--- a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs
@@ -51,6 +51,7 @@ public class SettingsManager
// Node mode (enables Windows as a node, not just operator)
public bool EnableNodeMode { get; set; } = false;
public bool HasSeenActivityStreamTip { get; set; } = false;
+ public string SkippedUpdateTag { get; set; } = "";
public SettingsManager()
{
@@ -88,6 +89,7 @@ public void Load()
NotifyInfo = loaded.NotifyInfo;
EnableNodeMode = loaded.EnableNodeMode;
HasSeenActivityStreamTip = loaded.HasSeenActivityStreamTip;
+ SkippedUpdateTag = loaded.SkippedUpdateTag ?? SkippedUpdateTag;
NotifyChatResponses = loaded.NotifyChatResponses;
PreferStructuredCategories = loaded.PreferStructuredCategories;
if (loaded.UserRules != null)
@@ -130,6 +132,7 @@ public void Save()
NotifyInfo = NotifyInfo,
EnableNodeMode = EnableNodeMode,
HasSeenActivityStreamTip = HasSeenActivityStreamTip,
+ SkippedUpdateTag = string.IsNullOrWhiteSpace(SkippedUpdateTag) ? null : SkippedUpdateTag,
NotifyChatResponses = NotifyChatResponses,
PreferStructuredCategories = PreferStructuredCategories,
UserRules = UserRules
diff --git a/src/OpenClaw.Tray.WinUI/Services/SshTunnelService.cs b/src/OpenClaw.Tray.WinUI/Services/SshTunnelService.cs
index 18b7764..556ec71 100644
--- a/src/OpenClaw.Tray.WinUI/Services/SshTunnelService.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/SshTunnelService.cs
@@ -13,6 +13,7 @@ public sealed class SshTunnelService : IDisposable
private readonly IOpenClawLogger _logger;
private Process? _process;
private string? _lastSpec;
+ private bool _stopping;
public SshTunnelService(IOpenClawLogger logger)
{
@@ -60,6 +61,9 @@ public void Stop()
return;
}
+ _stopping = true;
+ _logger.Info("Stopping SSH tunnel process");
+
try
{
if (!_process.HasExited)
@@ -77,6 +81,7 @@ public void Stop()
try { _process.Dispose(); } catch { }
_process = null;
_lastSpec = null;
+ _stopping = false;
}
}
@@ -117,7 +122,14 @@ private void StartProcess(string user, string host, int remotePort, int localPor
process.Exited += (_, _) =>
{
var exitCode = process.ExitCode;
- _logger.Warn($"SSH tunnel exited (code {exitCode})");
+ if (_stopping)
+ {
+ _logger.Info($"SSH tunnel exited during shutdown (code {exitCode})");
+ }
+ else
+ {
+ _logger.Warn($"SSH tunnel exited unexpectedly (code {exitCode})");
+ }
};
try
diff --git a/tests/OpenClaw.Shared.Tests/DeviceIdentityTests.cs b/tests/OpenClaw.Shared.Tests/DeviceIdentityTests.cs
index bf5a59f..654830d 100644
--- a/tests/OpenClaw.Shared.Tests/DeviceIdentityTests.cs
+++ b/tests/OpenClaw.Shared.Tests/DeviceIdentityTests.cs
@@ -120,6 +120,66 @@ public void BuildDebugPayload_HasCorrectFormat()
finally { Directory.Delete(dir, true); }
}
+ [IntegrationFact]
+ public void BuildConnectPayloadV3_HasCorrectFormat()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var identity = new DeviceIdentity(dir);
+ identity.Initialize();
+
+ var payload = identity.BuildConnectPayloadV3(
+ nonce: "challenge-nonce",
+ signedAtMs: 1711648000000,
+ clientId: "cli",
+ clientMode: "cli",
+ role: "operator",
+ scopes: new[] { "operator.admin", "operator.read", "operator.write" },
+ authToken: "mytoken123",
+ platform: "windows",
+ deviceFamily: "desktop");
+
+ Assert.StartsWith("v3|", payload);
+ Assert.Contains(identity.DeviceId, payload);
+ Assert.Contains("|cli|cli|operator|operator.admin,operator.read,operator.write|", payload);
+ Assert.Contains("|1711648000000|mytoken123|challenge-nonce|windows|desktop", payload);
+
+ var parts = payload.Split('|');
+ Assert.Equal(11, parts.Length);
+ }
+ finally { Directory.Delete(dir, true); }
+ }
+
+ [IntegrationFact]
+ public void BuildConnectPayloadV2_HasCorrectFormat()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var identity = new DeviceIdentity(dir);
+ identity.Initialize();
+
+ var payload = identity.BuildConnectPayloadV2(
+ nonce: "challenge-nonce",
+ signedAtMs: 1711648000000,
+ clientId: "cli",
+ clientMode: "cli",
+ role: "operator",
+ scopes: new[] { "operator.admin", "operator.read", "operator.write" },
+ authToken: "mytoken123");
+
+ Assert.StartsWith("v2|", payload);
+ Assert.Contains(identity.DeviceId, payload);
+ Assert.Contains("|cli|cli|operator|operator.admin,operator.read,operator.write|", payload);
+ Assert.Contains("|1711648000000|mytoken123|challenge-nonce", payload);
+
+ var parts = payload.Split('|');
+ Assert.Equal(9, parts.Length);
+ }
+ finally { Directory.Delete(dir, true); }
+ }
+
[IntegrationFact]
public void StoreDeviceToken_PersistsAcrossReload()
{
diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs
index 424182d..a364231 100644
--- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs
+++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs
@@ -61,6 +61,24 @@ public string TruncateLabel(string text, int maxLen = 60)
return (string)result!;
}
+ public Task<bool> RegisterPendingChatSend(string requestId)
+ {
+ var completion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+ var method = typeof(OpenClawGatewayClient).GetMethod(
+ "TrackPendingChatSend",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ method!.Invoke(_client, new object[] { requestId, completion });
+ return completion.Task;
+ }
+
+ public void ProcessRawMessage(string json)
+ {
+ var method = typeof(OpenClawGatewayClient).GetMethod(
+ "ProcessMessage",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ method!.Invoke(_client, new object[] { json });
+ }
+
public SessionInfo[] GetSessionList()
{
return _client.GetSessionList();
@@ -140,6 +158,26 @@ public GatewayNodeInfo[] ParseNodeListPayload(string payloadJson)
return parsed;
}
+ public string? ParseHandshakeMainSessionKey(string payloadJson)
+ {
+ using var doc = JsonDocument.Parse(payloadJson);
+ var method = typeof(OpenClawGatewayClient).GetMethod(
+ "TryGetHandshakeMainSessionKey",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
+ var result = method!.Invoke(null, new object[] { doc.RootElement.Clone() });
+ return result as string;
+ }
+
+ public string? ParseHandshakeDeviceToken(string payloadJson)
+ {
+ using var doc = JsonDocument.Parse(payloadJson);
+ var method = typeof(OpenClawGatewayClient).GetMethod(
+ "TryGetHandshakeDeviceToken",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
+ var result = method!.Invoke(null, new object[] { doc.RootElement.Clone() });
+ return result as string;
+ }
+
public (ChannelHealth[] channels, bool eventFired) ParseChannelHealthPayload(string payloadJson)
{
ChannelHealth[]? parsed = null;
@@ -343,6 +381,109 @@ public void ClassifyTool_MapsEdit()
Assert.Equal(ActivityKind.Edit, helper.ClassifyTool("edit"));
}
+ [Fact]
+ public async Task PendingChatSend_CompletesOnSuccessfulResponse()
+ {
+ var helper = new GatewayClientTestHelper();
+ var task = helper.RegisterPendingChatSend("chat-1");
+
+ helper.ProcessRawMessage("""
+ {
+ "type": "res",
+ "id": "chat-1",
+ "ok": true,
+ "payload": { "accepted": true }
+ }
+ """);
+
+ Assert.True(await task);
+ }
+
+ [Fact]
+ public async Task PendingChatSend_FailsOnErrorResponse()
+ {
+ var helper = new GatewayClientTestHelper();
+ var task = helper.RegisterPendingChatSend("chat-2");
+
+ helper.ProcessRawMessage("""
+ {
+ "type": "res",
+ "id": "chat-2",
+ "ok": false,
+ "error": "missing scope: operator.write"
+ }
+ """);
+
+ var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await task);
+ Assert.Contains("operator.write", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void ParseHandshakeMainSessionKey_ReturnsMainKey_WhenPresent()
+ {
+ var helper = new GatewayClientTestHelper();
+ var key = helper.ParseHandshakeMainSessionKey("""
+ {
+ "type": "hello-ok",
+ "snapshot": {
+ "sessionDefaults": {
+ "mainKey": "agent:main:123"
+ }
+ }
+ }
+ """);
+
+ Assert.Equal("agent:main:123", key);
+ }
+
+ [Fact]
+ public void ParseHandshakeMainSessionKey_ReturnsNull_WhenMissing()
+ {
+ var helper = new GatewayClientTestHelper();
+ var key = helper.ParseHandshakeMainSessionKey("""
+ {
+ "type": "hello-ok",
+ "snapshot": {
+ "sessionDefaults": {
+ }
+ }
+ }
+ """);
+
+ Assert.Null(key);
+ }
+
+ [Fact]
+ public void ParseHandshakeDeviceToken_ReturnsValue_WhenPresent()
+ {
+ var helper = new GatewayClientTestHelper();
+ var token = helper.ParseHandshakeDeviceToken("""
+ {
+ "type": "hello-ok",
+ "auth": {
+ "deviceToken": "device-token-123"
+ }
+ }
+ """);
+
+ Assert.Equal("device-token-123", token);
+ }
+
+ [Fact]
+ public void ParseHandshakeDeviceToken_ReturnsNull_WhenMissing()
+ {
+ var helper = new GatewayClientTestHelper();
+ var token = helper.ParseHandshakeDeviceToken("""
+ {
+ "type": "hello-ok",
+ "auth": {
+ }
+ }
+ """);
+
+ Assert.Null(token);
+ }
+
[Fact]
public void ClassifyTool_MapsWebSearch()
{
diff --git a/tests/OpenClaw.Shared.Tests/WebSocketClientBaseTests.cs b/tests/OpenClaw.Shared.Tests/WebSocketClientBaseTests.cs
index c5106db..fbb8b81 100644
--- a/tests/OpenClaw.Shared.Tests/WebSocketClientBaseTests.cs
+++ b/tests/OpenClaw.Shared.Tests/WebSocketClientBaseTests.cs
@@ -225,11 +225,11 @@ public async Task ConnectAsync_RaisesStatusChangedConnecting()
var statuses = new List<ConnectionStatus>();
client.StatusChanged += (_, s) => statuses.Add(s);
- // ConnectAsync will fail (no real server) but should still fire Connecting then Error
+ // ConnectAsync should always emit Connecting.
+ // Depending on timing/shutdown races, it may then emit Error or be canceled.
await client.ConnectAsync();
Assert.Contains(ConnectionStatus.Connecting, statuses);
- Assert.Contains(ConnectionStatus.Error, statuses);
client.Dispose();
}
}
diff --git a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs
index 887df5b..523fc37 100644
--- a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs
+++ b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs
@@ -31,6 +31,7 @@ public void RoundTrip_AllFields_Preserved()
NotifyInfo = true,
EnableNodeMode = true,
HasSeenActivityStreamTip = true,
+ SkippedUpdateTag = "v1.2.3",
NotifyChatResponses = false,
PreferStructuredCategories = true,
UserRules = new List<UserNotificationRule>
@@ -64,6 +65,7 @@ public void RoundTrip_AllFields_Preserved()
Assert.Equal(original.NotifyInfo, restored.NotifyInfo);
Assert.Equal(original.EnableNodeMode, restored.EnableNodeMode);
Assert.Equal(original.HasSeenActivityStreamTip, restored.HasSeenActivityStreamTip);
+ Assert.Equal(original.SkippedUpdateTag, restored.SkippedUpdateTag);
Assert.Equal(original.NotifyChatResponses, restored.NotifyChatResponses);
Assert.Equal(original.PreferStructuredCategories, restored.PreferStructuredCategories);
Assert.NotNull(restored.UserRules);
@@ -114,6 +116,7 @@ public void MissingFields_UseDefaults()
Assert.True(settings.NotifyInfo);
Assert.False(settings.EnableNodeMode);
Assert.False(settings.HasSeenActivityStreamTip);
+ Assert.Null(settings.SkippedUpdateTag);
Assert.True(settings.NotifyChatResponses);
Assert.True(settings.PreferStructuredCategories);
Assert.Null(settings.UserRules);
@@ -156,6 +159,7 @@ public void BackwardCompatibility_OldSettingsWithoutNewFields()
Assert.True(settings.PreferStructuredCategories);
Assert.False(settings.EnableNodeMode);
Assert.False(settings.HasSeenActivityStreamTip);
+ Assert.Null(settings.SkippedUpdateTag);
Assert.True(settings.GlobalHotkeyEnabled);
Assert.Null(settings.UserRules);
}
From 94fb82e3d5a0624686365b26cfaea99bee937cb3 Mon Sep 17 00:00:00 2001
From: sytone <github@sytone.com>
Date: Sat, 28 Mar 2026 18:32:12 -0700
Subject: [PATCH 3/3] feat: Implement BringToFront method to manage window
focus and visibility
---
.../Services/NodeService.cs | 1 +
.../Windows/CanvasWindow.xaml.cs | 45 +++++++++++++++++++
2 files changed, 46 insertions(+)
diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
index 1bd3883..731359f 100644
--- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
@@ -194,6 +194,7 @@ private void OnCanvasPresent(object? sender, CanvasPresentArgs args)
// Show window
_canvasWindow.Activate();
+ _canvasWindow.BringToFront(args.AlwaysOnTop);
_logger.Info($"Canvas presented: {args.Width}x{args.Height}");
}
diff --git a/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml.cs
index 8ba163d..5f4a4f8 100644
--- a/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml.cs
@@ -2,6 +2,7 @@
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
+using System.Runtime.InteropServices;
using Microsoft.UI.Xaml;
using Microsoft.Web.WebView2.Core;
using OpenClawTray.Helpers;
@@ -16,6 +17,22 @@ namespace OpenClawTray.Windows;
/// </summary>
public sealed partial class CanvasWindow : WindowEx
{
+ [DllImport("user32.dll")]
+ private static extern bool SetForegroundWindow(IntPtr hWnd);
+
+ [DllImport("user32.dll")]
+ private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
+
+ [DllImport("user32.dll")]
+ private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
+
+ private static readonly IntPtr HWND_TOPMOST = new(-1);
+ private static readonly IntPtr HWND_NOTOPMOST = new(-2);
+ private const int SW_SHOWNORMAL = 1;
+ private const uint SWP_NOMOVE = 0x0002;
+ private const uint SWP_NOSIZE = 0x0001;
+ private const uint SWP_SHOWWINDOW = 0x0040;
+
private bool _isWebViewInitialized;
private string? _pendingUrl;
private string? _pendingHtml;
@@ -331,6 +348,34 @@ public void SetAlwaysOnTop(bool alwaysOnTop)
{
this.IsAlwaysOnTop = alwaysOnTop;
}
+
+ /// <summary>
+ /// Force the window to the front so canvas content is visible immediately.
+ /// </summary>
+ public void BringToFront(bool keepTopMost)
+ {
+ try
+ {
+ var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
+ if (hwnd == IntPtr.Zero)
+ {
+ return;
+ }
+
+ ShowWindow(hwnd, SW_SHOWNORMAL);
+ SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW);
+ SetForegroundWindow(hwnd);
+
+ if (!keepTopMost)
+ {
+ SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW);
+ }
+ }
+ catch
+ {
+ // Best-effort focus behavior only.
+ }
+ }
public async Task EnsureA2UIHostAsync(string url)
{