diff --git a/docs/ONBOARDING_WIZARD.md b/docs/ONBOARDING_WIZARD.md
index d2e3944a6..79443d607 100644
--- a/docs/ONBOARDING_WIZARD.md
+++ b/docs/ONBOARDING_WIZARD.md
@@ -38,6 +38,8 @@ If the gateway doesn't support the wizard protocol or is unreachable, this scree
The wizard keeps recovery choices visible while setup steps are running so users can start the wizard again or skip it for now if an auth flow stalls. If the gateway restarts or the wizard connection is lost while setup is running, the same recovery choices are presented in the error state so the user is not trapped retrying a broken session.
+When the gateway config wizard surfaces an error and the active gateway is an app-managed WSL distro, the error state also offers **Open terminal** and **Restart gateway**. The wizard does not parse or classify the gateway's error text; it leaves the message visible and selectable so the user can copy any command the gateway reports. The buttons reuse the shared `GatewayTerminalLauncher` and `WslGatewayController` (in `OpenClaw.Connection`, also used by the Connections tab). Restart re-enters the gateway config wizard (the provider/model onboarding step — not the whole V2 onboarding, and without re-installing the WSL distro) so fixes such as newly-installed tools are picked up on `PATH`. Because the gateway restart clears its wizard session, this resumes at the first config question rather than the exact step that failed. Detection is gated on `GatewayRecord.SetupManagedDistroName`, so it never appears for remote/SSH gateways.
+
### Permissions
Checks 5 Windows permissions using native APIs and registry:
- Notifications (Toast capability)
diff --git a/src/OpenClaw.Tray.WinUI/Services/GatewayHostAccess.cs b/src/OpenClaw.Connection/GatewayHostAccess.cs
similarity index 93%
rename from src/OpenClaw.Tray.WinUI/Services/GatewayHostAccess.cs
rename to src/OpenClaw.Connection/GatewayHostAccess.cs
index 8967457e7..77e94bd45 100644
--- a/src/OpenClaw.Tray.WinUI/Services/GatewayHostAccess.cs
+++ b/src/OpenClaw.Connection/GatewayHostAccess.cs
@@ -1,9 +1,8 @@
using System;
-using OpenClaw.Connection;
-namespace OpenClawTray.Services;
+namespace OpenClaw.Connection;
-internal enum GatewayTerminalTarget
+public enum GatewayTerminalTarget
{
None,
Wsl,
@@ -16,13 +15,13 @@ internal enum GatewayTerminalTarget
/// without a WinUI runtime. App.xaml.cs wires these up to LocalizationHelper
/// at startup so the running app sees real localized strings.
///
-internal static class GatewayHostAccessLocalization
+public static class GatewayHostAccessLocalization
{
public static Func GetString { get; set; } = key => key;
public static Func Format { get; set; } = (key, _) => key;
}
-internal sealed record GatewayHostAccessPlan(
+public sealed record GatewayHostAccessPlan(
string? GatewayId,
GatewayTerminalTarget TerminalTarget,
string? DistroName,
@@ -53,7 +52,7 @@ public static GatewayHostAccessPlan None(string? gatewayId = null, string? disab
}
}
-internal static class GatewayHostAccessClassifier
+public static class GatewayHostAccessClassifier
{
public static GatewayHostAccessPlan Classify(GatewayRecord? record)
{
@@ -104,4 +103,4 @@ public static GatewayHostAccessPlan Classify(GatewayRecord? record)
var trimmed = value?.Trim();
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
}
-}
+}
\ No newline at end of file
diff --git a/src/OpenClaw.Tray.WinUI/Services/GatewayTerminalLauncher.cs b/src/OpenClaw.Connection/GatewayTerminalLauncher.cs
similarity index 96%
rename from src/OpenClaw.Tray.WinUI/Services/GatewayTerminalLauncher.cs
rename to src/OpenClaw.Connection/GatewayTerminalLauncher.cs
index 7c6280b40..06f21696b 100644
--- a/src/OpenClaw.Tray.WinUI/Services/GatewayTerminalLauncher.cs
+++ b/src/OpenClaw.Connection/GatewayTerminalLauncher.cs
@@ -2,21 +2,21 @@
using System.Diagnostics;
using OpenClaw.Shared;
-namespace OpenClawTray.Services;
+namespace OpenClaw.Connection;
-internal interface IGatewayTerminalLauncher
+public interface IGatewayTerminalLauncher
{
void Open(GatewayHostAccessPlan accessPlan);
void OpenGatewayDoctor(GatewayHostAccessPlan accessPlan);
}
-internal sealed record GatewayTerminalLaunchCommand(
+public sealed record GatewayTerminalLaunchCommand(
string FileName,
IReadOnlyList Arguments,
bool UsesWindowsTerminal);
-internal static class GatewayTerminalLaunchCommandBuilder
+public static class GatewayTerminalLaunchCommandBuilder
{
public static GatewayTerminalLaunchCommand Build(GatewayHostAccessPlan accessPlan, string? windowsTerminalPath)
{
@@ -144,7 +144,7 @@ private static string RequireValue(string? value, string message)
}
}
-internal sealed class GatewayTerminalLauncher(IOpenClawLogger logger) : IGatewayTerminalLauncher
+public sealed class GatewayTerminalLauncher(IOpenClawLogger logger) : IGatewayTerminalLauncher
{
public void Open(GatewayHostAccessPlan accessPlan)
{
diff --git a/src/OpenClaw.Tray.WinUI/Services/WslCommandRunner.cs b/src/OpenClaw.Connection/WslCommandRunner.cs
similarity index 99%
rename from src/OpenClaw.Tray.WinUI/Services/WslCommandRunner.cs
rename to src/OpenClaw.Connection/WslCommandRunner.cs
index 0bc4ad9ae..8dfd3ef50 100644
--- a/src/OpenClaw.Tray.WinUI/Services/WslCommandRunner.cs
+++ b/src/OpenClaw.Connection/WslCommandRunner.cs
@@ -3,7 +3,7 @@
using System.Globalization;
using System.Text;
-namespace OpenClawTray.Services;
+namespace OpenClaw.Connection;
public sealed record WslCommandResult(int ExitCode, string StandardOutput, string StandardError)
{
diff --git a/src/OpenClaw.Tray.WinUI/Services/WslGatewayController.cs b/src/OpenClaw.Connection/WslGatewayController.cs
similarity index 78%
rename from src/OpenClaw.Tray.WinUI/Services/WslGatewayController.cs
rename to src/OpenClaw.Connection/WslGatewayController.cs
index 4db6ec314..b28dd3824 100644
--- a/src/OpenClaw.Tray.WinUI/Services/WslGatewayController.cs
+++ b/src/OpenClaw.Connection/WslGatewayController.cs
@@ -1,7 +1,7 @@
using System.Collections.ObjectModel;
using OpenClaw.Shared;
-namespace OpenClawTray.Services;
+namespace OpenClaw.Connection;
public enum WslGatewayControlAction
{
@@ -10,7 +10,7 @@ public enum WslGatewayControlAction
Restart
}
-internal sealed record WslGatewayControlResult(
+public sealed record WslGatewayControlResult(
string DistroName,
WslGatewayControlAction Action,
int ExitCode,
@@ -29,7 +29,7 @@ public string OutputSummary
}
}
-internal static class WslGatewayControlCommandBuilder
+public static class WslGatewayControlCommandBuilder
{
internal const string OpenClawWslPathPrefix = "export PATH=\"/home/openclaw/.openclaw/bin:/opt/openclaw/bin:/usr/local/bin:$PATH\"";
@@ -54,7 +54,7 @@ public static string ToVerb(WslGatewayControlAction action)
}
}
-internal sealed class WslGatewayController(IWslCommandRunner commandRunner, IOpenClawLogger logger)
+public sealed class WslGatewayController(IWslCommandRunner commandRunner, IOpenClawLogger logger)
{
public async Task RunAsync(
string distroName,
@@ -68,7 +68,14 @@ public async Task RunAsync(
var normalizedDistroName = distroName.Trim();
var distros = await commandRunner.ListDistrosAsync(cancellationToken).ConfigureAwait(false);
- if (!distros.Any(distro => string.Equals(distro.Name, normalizedDistroName, StringComparison.OrdinalIgnoreCase)))
+
+ // Only short-circuit as "not registered" when the probe returned a non-empty
+ // enumeration that definitively lacks the distro. An empty list is ambiguous:
+ // `wsl --list` may have failed or timed out (ListDistrosAsync collapses any
+ // failure to an empty list), so fail open and let the actual control command
+ // surface the real error instead of dead-ending recovery with a misleading message.
+ if (distros.Count > 0 &&
+ !distros.Any(distro => string.Equals(distro.Name, normalizedDistroName, StringComparison.OrdinalIgnoreCase)))
{
return new WslGatewayControlResult(
normalizedDistroName,
diff --git a/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml
index 1097c29bf..e349a95fb 100644
--- a/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml
+++ b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml
@@ -42,7 +42,26 @@
-
+
+
+
+
+
diff --git a/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs
index bc2a32a47..e95c0b340 100644
--- a/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs
+++ b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs
@@ -30,6 +30,10 @@ public sealed partial class WizardPage : Page
// (OAuth URLs, install fallback messages, etc) inline on the active step.
// wizard.payload frames don't carry this content.
private WizardConsoleTail? _consoleTail;
+ // Host access for the active gateway, captured on connect. Drives the
+ // "Open terminal" / "Restart gateway" recovery affordances shown when the
+ // wizard fails because a tool is missing from an app-managed WSL gateway.
+ private GatewayHostAccessPlan _hostAccessPlan = GatewayHostAccessPlan.None();
public WizardPage()
{
@@ -92,6 +96,7 @@ private async Task ConnectClientAsync()
var registry = new GatewayRegistry(dataDir);
registry.Load();
var record = registry.GetActive() ?? throw new InvalidOperationException("No active gateway record found.");
+ _hostAccessPlan = GatewayHostAccessClassifier.Classify(record);
var identityPath = registry.GetIdentityDirectory(record.Id);
var token = DeviceIdentity.TryReadStoredDeviceToken(identityPath)
?? record.SharedGatewayToken
@@ -557,6 +562,7 @@ private void ResetInputs()
TextInput.Visibility = Visibility.Collapsed;
SecretInput.Visibility = Visibility.Collapsed;
MessagePanel.Children.Clear();
+ HideGatewayRecovery();
}
private void RenderMessage(string message)
@@ -727,6 +733,7 @@ private void SetBusy(string status)
BusyRing.IsActive = true;
PrimaryButton.IsEnabled = false;
SecondaryButton.IsEnabled = false;
+ HideGatewayRecovery();
}
private void ShowError(string message)
@@ -743,6 +750,7 @@ private void ShowError(string message)
SecondaryButton.IsEnabled = true;
SecondaryButton.Visibility = Visibility.Visible;
HideRecoveryActions();
+ MaybeShowGatewayRecovery();
}
private async Task EnterWizardErrorAsync(string detail)
@@ -758,6 +766,109 @@ private async Task EnterWizardErrorAsync(string detail)
ShowError(detail);
}
+ // Shows the WSL recovery affordances (open a terminal / restart the gateway)
+ // whenever the wizard surfaces an error AND the active gateway is an
+ // app-managed WSL distro we can control. We deliberately do not parse the
+ // gateway's error text: its wording is outside our control and can change, so
+ // the user reads the (selectable) error message and decides what to run.
+ private void MaybeShowGatewayRecovery()
+ {
+ HideGatewayRecovery();
+
+ if (!_hostAccessPlan.CanControlWslGateway || string.IsNullOrWhiteSpace(_hostAccessPlan.DistroName))
+ return;
+
+ OpenGatewayTerminalButton.IsEnabled = true;
+ RestartGatewayButton.IsEnabled = true;
+ GatewayRecovery.Visibility = Visibility.Visible;
+ }
+
+ private void HideGatewayRecovery()
+ {
+ GatewayRecovery.Visibility = Visibility.Collapsed;
+ }
+
+ private void OpenGatewayTerminal_Click(object sender, RoutedEventArgs e)
+ {
+ if (!_hostAccessPlan.CanOpenTerminal)
+ return;
+
+ try
+ {
+ new GatewayTerminalLauncher(NullLogger.Instance).Open(_hostAccessPlan);
+ StatusText.Text = $"Opened a terminal in {_hostAccessPlan.DistroName}. Install the tool, then choose Restart gateway.";
+ }
+ catch (Exception ex)
+ {
+ ErrorText.Text = $"Couldn't open a terminal: {ex.Message}";
+ ErrorText.Visibility = Visibility.Visible;
+ }
+ }
+
+ private void RestartGateway_Click(object sender, RoutedEventArgs e) =>
+ AsyncEventHandlerGuard.Run(
+ RestartGatewayAsync,
+ NullLogger.Instance,
+ nameof(RestartGateway_Click));
+
+ private async Task RestartGatewayAsync()
+ {
+ var distro = _hostAccessPlan.DistroName;
+ if (!_hostAccessPlan.CanControlWslGateway || string.IsNullOrWhiteSpace(distro))
+ return;
+
+ // Claim this operation and lock the UI synchronously, before the first await,
+ // so a second Restart/Skip/Open-terminal click during the disconnect can't race
+ // us. SetBusy disables the primary/secondary buttons and collapses the recovery
+ // panel (which hosts the Restart/Open-terminal buttons), so they stop hit-testing.
+ var generation = AdvanceOperationGeneration();
+ _errorState = false;
+ ErrorText.Visibility = Visibility.Collapsed;
+ SetBusy($"Restarting {distro}...");
+
+ // Detach the current wizard client first so the restart-induced disconnect
+ // doesn't surface as a spurious "connection lost" error mid-restart.
+ await DisconnectAsync();
+ if (generation != _operationGeneration)
+ return;
+
+ try
+ {
+ // A gateway restart spins up a fresh login shell and restarts the daemon
+ // inside the distro, which can take well over the runner's default 30s
+ // ceiling on a cold distro. Give it a generous timeout so a slow-but-healthy
+ // restart isn't reported as a spurious timeout failure.
+ var runner = new WslExeCommandRunner(NullLogger.Instance, defaultTimeout: TimeSpan.FromMinutes(2));
+ var controller = new WslGatewayController(runner, NullLogger.Instance);
+ var result = await controller.RunAsync(distro, WslGatewayControlAction.Restart);
+ if (generation != _operationGeneration)
+ return;
+
+ if (!result.Success)
+ {
+ var details = string.IsNullOrWhiteSpace(result.OutputSummary)
+ ? $"wsl.exe exited with code {result.ExitCode}."
+ : result.OutputSummary;
+ await EnterWizardErrorAsync($"Restarting the gateway failed: {details}");
+ return;
+ }
+
+ // Gateway is back up with the freshly-installed tool on PATH. Stay on
+ // this page and re-enter the gateway config wizard (provider/model
+ // onboarding) — we do NOT return to Welcome or re-install WSL. The
+ // gateway restart wiped its wizard session, so this resumes at the
+ // first config question rather than the exact step that failed.
+ await StartWizardAsync();
+ }
+ catch (Exception ex)
+ {
+ if (generation != _operationGeneration)
+ return;
+
+ await EnterWizardErrorAsync($"Restarting the gateway failed: {ex.Message}");
+ }
+ }
+
private async Task SkipWizardAsync()
{
AdvanceOperationGeneration();
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs
index 540a9a739..68aab357f 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs
@@ -1,6 +1,7 @@
using Microsoft.Toolkit.Uwp.Notifications;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
+using OpenClaw.Connection;
using OpenClaw.Shared;
using OpenClawTray.Helpers;
using OpenClawTray.Services;
diff --git a/tests/OpenClaw.Tray.Tests/GatewayHostAccessTests.cs b/tests/OpenClaw.Tray.Tests/GatewayHostAccessTests.cs
index 0a4e8c97f..7b99d7350 100644
--- a/tests/OpenClaw.Tray.Tests/GatewayHostAccessTests.cs
+++ b/tests/OpenClaw.Tray.Tests/GatewayHostAccessTests.cs
@@ -1,5 +1,4 @@
using OpenClaw.Connection;
-using OpenClawTray.Services;
namespace OpenClaw.Tray.Tests;
diff --git a/tests/OpenClaw.Tray.Tests/GatewayTerminalLaunchCommandBuilderTests.cs b/tests/OpenClaw.Tray.Tests/GatewayTerminalLaunchCommandBuilderTests.cs
index 9b8f76def..dca5ff026 100644
--- a/tests/OpenClaw.Tray.Tests/GatewayTerminalLaunchCommandBuilderTests.cs
+++ b/tests/OpenClaw.Tray.Tests/GatewayTerminalLaunchCommandBuilderTests.cs
@@ -1,5 +1,4 @@
using OpenClaw.Connection;
-using OpenClawTray.Services;
namespace OpenClaw.Tray.Tests;
diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
index a59c5d637..745e07aa4 100644
--- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
+++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
@@ -47,10 +47,8 @@
-
-
-
-
+
diff --git a/tests/OpenClaw.Tray.Tests/WslGatewayControllerTests.cs b/tests/OpenClaw.Tray.Tests/WslGatewayControllerTests.cs
index 6070d07e3..5358f14dd 100644
--- a/tests/OpenClaw.Tray.Tests/WslGatewayControllerTests.cs
+++ b/tests/OpenClaw.Tray.Tests/WslGatewayControllerTests.cs
@@ -1,5 +1,5 @@
+using OpenClaw.Connection;
using OpenClaw.Shared;
-using OpenClawTray.Services;
namespace OpenClaw.Tray.Tests;
@@ -53,6 +53,27 @@ public async Task RunAsync_ReturnsFailure_WhenDistroIsNotRegistered()
Assert.Contains("not registered", result.StandardError);
}
+ [Fact]
+ public async Task RunAsync_AttemptsCommand_WhenDistroEnumerationIsEmpty()
+ {
+ // An empty distro list is ambiguous — the `wsl --list` probe may have failed
+ // or timed out — so the controller should fail open and still attempt the
+ // control command rather than dead-ending with a misleading "not registered".
+ var runner = new FakeWslCommandRunner
+ {
+ Distros = [],
+ Result = new WslCommandResult(0, "restarted", string.Empty),
+ };
+ var controller = new WslGatewayController(runner, NullLogger.Instance);
+
+ var result = await controller.RunAsync("OpenClawGateway", WslGatewayControlAction.Restart);
+
+ Assert.True(result.Success);
+ Assert.Equal("OpenClawGateway", runner.LastDistroName);
+ Assert.Equal(WslGatewayControlCommandBuilder.Build(WslGatewayControlAction.Restart), runner.LastDistroCommand);
+ Assert.DoesNotContain("not registered", result.StandardError);
+ }
+
private sealed class FakeWslCommandRunner : IWslCommandRunner
{
public IReadOnlyList Distros { get; init; } = [];