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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/ONBOARDING_WIZARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
using System;
using OpenClaw.Connection;

namespace OpenClawTray.Services;
namespace OpenClaw.Connection;

internal enum GatewayTerminalTarget
public enum GatewayTerminalTarget
{
None,
Wsl,
Expand All @@ -16,13 +15,13 @@ internal enum GatewayTerminalTarget
/// without a WinUI runtime. <c>App.xaml.cs</c> wires these up to <c>LocalizationHelper</c>
/// at startup so the running app sees real localized strings.
/// </summary>
internal static class GatewayHostAccessLocalization
public static class GatewayHostAccessLocalization
{
public static Func<string, string> GetString { get; set; } = key => key;
public static Func<string, object?[], string> Format { get; set; } = (key, _) => key;
}

internal sealed record GatewayHostAccessPlan(
public sealed record GatewayHostAccessPlan(
string? GatewayId,
GatewayTerminalTarget TerminalTarget,
string? DistroName,
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -104,4 +103,4 @@ public static GatewayHostAccessPlan Classify(GatewayRecord? record)
var trimmed = value?.Trim();
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> Arguments,
bool UsesWindowsTerminal);

internal static class GatewayTerminalLaunchCommandBuilder
public static class GatewayTerminalLaunchCommandBuilder
{
public static GatewayTerminalLaunchCommand Build(GatewayHostAccessPlan accessPlan, string? windowsTerminalPath)
{
Expand Down Expand Up @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using System.Collections.ObjectModel;
using OpenClaw.Shared;

namespace OpenClawTray.Services;
namespace OpenClaw.Connection;

public enum WslGatewayControlAction
{
Expand All @@ -10,7 +10,7 @@ public enum WslGatewayControlAction
Restart
}

internal sealed record WslGatewayControlResult(
public sealed record WslGatewayControlResult(
string DistroName,
WslGatewayControlAction Action,
int ExitCode,
Expand All @@ -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\"";

Expand All @@ -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<WslGatewayControlResult> RunAsync(
string distroName,
Expand All @@ -68,7 +68,14 @@ public async Task<WslGatewayControlResult> 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,
Expand Down
21 changes: 20 additions & 1 deletion src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,26 @@
<StackPanel x:Name="MultiOptions" Visibility="Collapsed" Spacing="8" />
<TextBox x:Name="TextInput" Visibility="Collapsed" PlaceholderText="Enter value" />
<PasswordBox x:Name="SecretInput" Visibility="Collapsed" PlaceholderText="Enter value" />
<TextBlock x:Name="ErrorText" Visibility="Collapsed" Foreground="#FF6B6B" TextWrapping="Wrap" />
<TextBlock x:Name="ErrorText"
Visibility="Collapsed"
Foreground="#FF6B6B"
TextWrapping="Wrap"
IsTextSelectionEnabled="True" />
<StackPanel x:Name="GatewayRecovery"
Visibility="Collapsed"
Orientation="Horizontal"
Spacing="12">
<Button x:Name="OpenGatewayTerminalButton"
Content="Open terminal"
Height="40"
MinWidth="120"
Click="OpenGatewayTerminal_Click" />
<Button x:Name="RestartGatewayButton"
Content="Restart gateway"
Height="40"
MinWidth="120"
Click="RestartGateway_Click" />
</StackPanel>
</StackPanel>
</Border>
</ScrollViewer>
Expand Down
111 changes: 111 additions & 0 deletions src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -92,6 +96,7 @@ private async Task<OpenClawGatewayClient> 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
Expand Down Expand Up @@ -557,6 +562,7 @@ private void ResetInputs()
TextInput.Visibility = Visibility.Collapsed;
SecretInput.Visibility = Visibility.Collapsed;
MessagePanel.Children.Clear();
HideGatewayRecovery();
}

private void RenderMessage(string message)
Expand Down Expand Up @@ -727,6 +733,7 @@ private void SetBusy(string status)
BusyRing.IsActive = true;
PrimaryButton.IsEnabled = false;
SecondaryButton.IsEnabled = false;
HideGatewayRecovery();
}

private void ShowError(string message)
Expand All @@ -743,6 +750,7 @@ private void ShowError(string message)
SecondaryButton.IsEnabled = true;
SecondaryButton.Visibility = Visibility.Visible;
HideRecoveryActions();
MaybeShowGatewayRecovery();
}

private async Task EnterWizardErrorAsync(string detail)
Expand All @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
1 change: 0 additions & 1 deletion tests/OpenClaw.Tray.Tests/GatewayHostAccessTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using OpenClaw.Connection;
using OpenClawTray.Services;

namespace OpenClaw.Tray.Tests;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using OpenClaw.Connection;
using OpenClawTray.Services;

namespace OpenClaw.Tray.Tests;

Expand Down
6 changes: 2 additions & 4 deletions tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,8 @@
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\OnboardingChatBootstrapper.cs" Link="Services\OnboardingChatBootstrapper.cs" />
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\StartupSetupState.cs" Link="Services\StartupSetupState.cs" />
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\SetupExistingGatewayClassifier.cs" Link="Services\SetupExistingGatewayClassifier.cs" />
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\GatewayHostAccess.cs" Link="Services\GatewayHostAccess.cs" />
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\GatewayTerminalLauncher.cs" Link="Services\GatewayTerminalLauncher.cs" />
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\WslGatewayController.cs" Link="Services\WslGatewayController.cs" />
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\WslCommandRunner.cs" Link="Services\WslCommandRunner.cs" />
<!-- GatewayHostAccess, GatewayTerminalLauncher, WslGatewayController and WslCommandRunner
now live in OpenClaw.Connection (referenced above), so they no longer need linking. -->
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\WslKeepAlivePolicy.cs" Link="Services\WslKeepAlivePolicy.cs" />
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\GatewayConnectorInterfaces.cs" Link="Services\GatewayConnectorInterfaces.cs" />
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\ActivityStreamService.cs" Link="Services\ActivityStreamService.cs" />
Expand Down
23 changes: 22 additions & 1 deletion tests/OpenClaw.Tray.Tests/WslGatewayControllerTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
using OpenClaw.Connection;
using OpenClaw.Shared;
using OpenClawTray.Services;

namespace OpenClaw.Tray.Tests;

Expand Down Expand Up @@ -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<WslDistroInfo> Distros { get; init; } = [];
Expand Down
Loading