From 8d66ee75b8dbd5c234605b981f296e77463518b0 Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 23 Jun 2026 15:20:54 -0700 Subject: [PATCH 1/2] Add WSL gateway recovery actions to setup wizard When the gateway config wizard (provider/model/OAuth onboarding) fails on an app-managed WSL gateway, the wizard used to dead-end on a generic error with no way forward. This adds in-product recovery: the gateway error is shown verbatim and selectable (so the user can copy any install command it reports), plus two actions: - Open terminal - drops into the WSL distro to run the fix - Restart gateway - restarts the distro, reconnects, and re-enters the config wizard where the user left off We don't parse the gateway error text; the gateway's wording is outside our control and can change. The recovery actions are gated purely on the gateway being an app-managed WSL distro we control (GatewayHostAccess.CanControlWslGateway plus a known DistroName). WSL helpers (WslCommandRunner, WslGatewayController, GatewayHostAccess, GatewayTerminalLauncher) move from OpenClaw.Tray.WinUI/Services into OpenClaw.Connection so the SetupEngine UI can consume them (history-preserving renames). Hardening (from a dual-model review): - Restart handler claims the operation generation and locks the UI synchronously before the first await, re-checking after each await, so a double-click can't fire a stale restart. - WslGatewayController only reports 'distro not registered' when enumeration definitively lacks it; an empty list (probe failure/timeout) fails open. - Gateway restart uses a 2-minute timeout for slow cold-distro restarts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/ONBOARDING_WIZARD.md | 2 + .../GatewayHostAccess.cs | 13 +- .../GatewayTerminalLauncher.cs | 10 +- .../WslCommandRunner.cs | 2 +- .../WslGatewayController.cs | 17 ++- .../Pages/WizardPage.xaml | 21 +++- .../Pages/WizardPage.xaml.cs | 111 ++++++++++++++++++ .../Pages/SettingsPage.xaml.cs | 1 + .../GatewayHostAccessTests.cs | 1 - ...atewayTerminalLaunchCommandBuilderTests.cs | 1 - .../OpenClaw.Tray.Tests.csproj | 6 +- .../WslGatewayControllerTests.cs | 23 +++- 12 files changed, 182 insertions(+), 26 deletions(-) rename src/{OpenClaw.Tray.WinUI/Services => OpenClaw.Connection}/GatewayHostAccess.cs (93%) rename src/{OpenClaw.Tray.WinUI/Services => OpenClaw.Connection}/GatewayTerminalLauncher.cs (96%) rename src/{OpenClaw.Tray.WinUI/Services => OpenClaw.Connection}/WslCommandRunner.cs (99%) rename src/{OpenClaw.Tray.WinUI/Services => OpenClaw.Connection}/WslGatewayController.cs (78%) diff --git a/docs/ONBOARDING_WIZARD.md b/docs/ONBOARDING_WIZARD.md index d2e3944a6..0689a1990 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 a step fails because a tool is missing from the gateway (e.g. *"Gemini CLI not found … npm install -g … was not found in PATH"*) and the active gateway is an app-managed WSL distro, the error state also offers **Open terminal** and **Restart gateway**. `WizardDependencyError` heuristically classifies the failure text and extracts a copy-pasteable install command; 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 the freshly-installed tool is 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 @@ - + + +