diff --git a/src/OpenClaw.SetupEngine/PreflightWslStep.cs b/src/OpenClaw.SetupEngine/PreflightWslStep.cs index 1be2a05ad..6bf4d6809 100644 --- a/src/OpenClaw.SetupEngine/PreflightWslStep.cs +++ b/src/OpenClaw.SetupEngine/PreflightWslStep.cs @@ -195,8 +195,28 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati return message; } - internal static async Task InstallWslPlatformAsync(SetupContext ctx, CancellationToken ct) + internal static Task InstallWslPlatformAsync(SetupContext ctx, CancellationToken ct) + => InstallWslPlatformAsync(ctx, WslPlatformInstallDiagnostics.QueryGitHubQuotaAsync, ct); + + internal static async Task InstallWslPlatformAsync( + SetupContext ctx, + Func> quotaProbe, + CancellationToken ct) { + ArgumentNullException.ThrowIfNull(quotaProbe); + + // wsl --install resolves its download through the GitHub API. When that + // quota is already spent the install cannot succeed, so fail before + // raising an administrator prompt the user would approve for nothing. + GitHubApiQuota? quota = await quotaProbe(ct); + if (quota is { IsExhausted: true }) + { + ctx.Logger.Warn( + $"GitHub API quota exhausted ({quota.Used}/{quota.Limit}, resets {quota.ResetsAt.ToLocalTime():HH:mm}); " + + "skipping the elevated WSL platform install because its download would be refused"); + return StepResult.Fail(WslPlatformInstallDiagnostics.DescribeUnavailableDownload(quota)); + } + ctx.Logger.Warn("WSL platform appears to be missing; launching elevated WSL platform install"); try { @@ -221,7 +241,19 @@ internal static async Task InstallWslPlatformAsync(SetupContext ctx, return StepResult.Terminal("WSL platform install requires a restart. Reboot Windows, then run setup again."); if (process.ExitCode != 0) - return StepResult.Fail($"WSL platform install failed with exit code {process.ExitCode}."); + { + // The installer runs elevated through ShellExecute, which cannot + // redirect its output, so wsl.exe's own error text is unreachable + // here. Re-read the quota to name the most common cause instead. + GitHubApiQuota? postFailureQuota = await quotaProbe(ct); + ctx.Logger.Warn( + $"Elevated WSL platform install exited with code {process.ExitCode}; " + + (postFailureQuota is null + ? "GitHub API quota could not be read" + : $"GitHub API quota {postFailureQuota.Used}/{postFailureQuota.Limit}")); + return StepResult.Fail( + WslPlatformInstallDiagnostics.DescribeFailure(process.ExitCode, postFailureQuota)); + } var probe = await ctx.Commands.RunAsync( WslConstants.WslExePath, @@ -263,7 +295,10 @@ internal EnsureWslPlatformStep( public override string Id => "ensure-wsl-platform"; public override string DisplayName => "Prepare WSL platform"; - public override bool CanRetry => false; + + // Inspection and wsl --install are both idempotent, and the common failures + // here (exhausted GitHub quota, a declined elevation prompt) are transient. + public override bool CanRetry => true; public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) { diff --git a/src/OpenClaw.SetupEngine/WslPlatformInstallDiagnostics.cs b/src/OpenClaw.SetupEngine/WslPlatformInstallDiagnostics.cs new file mode 100644 index 000000000..f65caf1d6 --- /dev/null +++ b/src/OpenClaw.SetupEngine/WslPlatformInstallDiagnostics.cs @@ -0,0 +1,134 @@ +using System.Net.Http; +using System.Text.Json; + +namespace OpenClaw.SetupEngine; + +/// +/// The unauthenticated GitHub API quota, which wsl --install depends on. +/// +internal sealed record GitHubApiQuota(int Limit, int Remaining, DateTimeOffset ResetsAt) +{ + public bool IsExhausted => Remaining <= 0; + + public int Used => Math.Max(0, Limit - Remaining); +} + +/// +/// Explains why an elevated wsl --install failed. +/// +/// +/// +/// C:\Windows\System32\wsl.exe is a stub; the real WSL ships out of band. +/// When WSL is absent, wsl --install resolves the package to download by +/// calling https://api.github.com/repos/Microsoft/WSL/releases/latest — that +/// URL is embedded in the stub, and no command-line option redirects it to the +/// Microsoft Store. Unauthenticated GitHub API calls are capped at 60/hour +/// per IP, so any machine behind shared egress (corporate NAT, VPN, cloud +/// hosts, CI, remote-accessed lab hardware) can find the quota already spent by +/// unrelated traffic. wsl.exe then prints "Forbidden (403)." and exits 1. +/// +/// +/// That output is unreachable to us: the installer must run elevated, elevation +/// requires ShellExecute, and ShellExecute cannot redirect stdout/stderr. So we +/// reconstruct the cause instead, from the same quota endpoint wsl.exe consumes. +/// /rate_limit is itself exempt from the quota, so probing is free. +/// +/// +internal static class WslPlatformInstallDiagnostics +{ + private const string RateLimitUrl = "https://api.github.com/rate_limit"; + + /// The Microsoft Store product id for Windows Subsystem for Linux. + private const string WslStoreProductId = "9P9TQF7MRM4R"; + + private static readonly TimeSpan s_probeTimeout = TimeSpan.FromSeconds(5); + + /// + /// Hands the user a way to install WSL that does not depend on OpenClaw + /// succeeding. The Store route is listed first because it does not touch the + /// GitHub API at all, so it works while the quota is still spent. + /// + /// + /// The failure surface renders this in a wrapping monospace card and turns the + /// first URL into a clickable link, so the line breaks and the bare commands + /// survive to the user. + /// + public static string SelfInstallInstructions => + "Install WSL yourself, then run setup again:" + Environment.NewLine + + $" 1. Microsoft Store (works even while the GitHub quota is spent): {WslInstallSupport.UpdateUrl}" + + Environment.NewLine + + $" or run: winget install --id {WslStoreProductId} --source msstore" + Environment.NewLine + + " 2. Or, in an elevated PowerShell: wsl --install --no-distribution" + Environment.NewLine + + "Reboot if Windows asks for one."; + + /// + /// Builds the operator-facing explanation for a failed platform install. + /// Pure, so the wording is covered by tests. + /// + public static string DescribeFailure(int exitCode, GitHubApiQuota? quota) + { + var reason = quota is { IsExhausted: true } + ? "wsl --install downloads WSL from GitHub, and this machine has already used its full " + + $"unauthenticated GitHub API quota ({quota.Used}/{quota.Limit}), which resets at " + + $"{quota.ResetsAt.ToLocalTime():HH:mm} local time. Shared networks reach that cap without " + + "any help from OpenClaw." + : "wsl --install downloads WSL from GitHub, and that download did not complete."; + + return $"WSL platform install failed with exit code {exitCode}. {reason}" + + Environment.NewLine + Environment.NewLine + SelfInstallInstructions; + } + + /// + /// Explains a pre-launch abort, used when the quota is already spent and the + /// install would only fail after prompting for administrator approval. + /// + public static string DescribeUnavailableDownload(GitHubApiQuota quota) => + "WSL is not installed, and OpenClaw cannot install it right now: wsl --install downloads WSL " + + $"from GitHub, and this machine has already used its full unauthenticated GitHub API quota " + + $"({quota.Used}/{quota.Limit}), which resets at {quota.ResetsAt.ToLocalTime():HH:mm} local time. " + + "Shared networks reach that cap without any help from OpenClaw." + + Environment.NewLine + Environment.NewLine + SelfInstallInstructions; + + /// + /// Reads the caller's GitHub API quota. Returns null when the quota cannot be + /// determined; a diagnostic must never turn a recoverable failure into a hard one. + /// + public static async Task QueryGitHubQuotaAsync(CancellationToken ct) + { + try + { + using var http = new HttpClient { Timeout = s_probeTimeout }; + // GitHub rejects requests without a User-Agent. + http.DefaultRequestHeaders.UserAgent.ParseAdd("OpenClawSetup"); + + using var response = await http.GetAsync(RateLimitUrl, ct); + if (!response.IsSuccessStatusCode) + return null; + + await using var body = await response.Content.ReadAsStreamAsync(ct); + using var json = await JsonDocument.ParseAsync(body, cancellationToken: ct); + + if (!json.RootElement.TryGetProperty("resources", out var resources) || + !resources.TryGetProperty("core", out var core)) + { + return null; + } + + if (!core.TryGetProperty("limit", out var limit) || + !core.TryGetProperty("remaining", out var remaining) || + !core.TryGetProperty("reset", out var reset)) + { + return null; + } + + return new GitHubApiQuota( + limit.GetInt32(), + remaining.GetInt32(), + DateTimeOffset.FromUnixTimeSeconds(reset.GetInt64())); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return null; + } + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/WslPlatformInstallDiagnosticsTests.cs b/tests/OpenClaw.SetupEngine.Tests/WslPlatformInstallDiagnosticsTests.cs new file mode 100644 index 000000000..4a3ae65bc --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/WslPlatformInstallDiagnosticsTests.cs @@ -0,0 +1,136 @@ +namespace OpenClaw.SetupEngine.Tests; + +/// +/// wsl --install downloads WSL through the GitHub API, whose unauthenticated +/// quota is 60/hour per IP. Machines behind shared egress hit that cap through +/// unrelated traffic and the install exits 1 with output we cannot capture, so +/// these tests pin the wording that has to stand in for the real error. +/// +public class WslPlatformInstallDiagnosticsTests +{ + private static GitHubApiQuota Exhausted() => + new(60, 0, DateTimeOffset.UtcNow.AddMinutes(14)); + + private static GitHubApiQuota Available() => + new(60, 41, DateTimeOffset.UtcNow.AddMinutes(14)); + + [Fact] + public void DescribeFailure_ExhaustedQuota_NamesQuotaAndStoreFallback() + { + var message = WslPlatformInstallDiagnostics.DescribeFailure(1, Exhausted()); + + Assert.Contains("exit code 1", message); + Assert.Contains("GitHub", message); + Assert.Contains("60/60", message); + Assert.Contains(WslInstallSupport.UpdateUrl, message); + } + + [Fact] + public void DescribeFailure_QuotaAvailable_StillOffersStoreFallback() + { + var message = WslPlatformInstallDiagnostics.DescribeFailure(5, Available()); + + Assert.Contains("exit code 5", message); + Assert.DoesNotContain("resets at", message); + Assert.Contains(WslInstallSupport.UpdateUrl, message); + } + + [Fact] + public void DescribeFailure_UnknownQuota_DoesNotClaimARateLimit() + { + var message = WslPlatformInstallDiagnostics.DescribeFailure(1, quota: null); + + Assert.Contains("exit code 1", message); + Assert.DoesNotContain("already used its full", message); + Assert.Contains(WslInstallSupport.UpdateUrl, message); + } + + [Fact] + public void DescribeUnavailableDownload_ExplainsWhyNoElevationPromptAppeared() + { + var message = WslPlatformInstallDiagnostics.DescribeUnavailableDownload(Exhausted()); + + Assert.Contains("60/60", message); + Assert.Contains("GitHub", message); + Assert.Contains(WslInstallSupport.UpdateUrl, message); + } + + [Fact] + public void SelfInstallInstructions_GiveARouteThatAvoidsTheGitHubApi() + { + var instructions = WslPlatformInstallDiagnostics.SelfInstallInstructions; + + // The Store route has to be reachable while the quota is still spent, + // so it must be present and must not be the wsl --install path. + Assert.Contains(WslInstallSupport.UpdateUrl, instructions); + Assert.Contains("winget install --id 9P9TQF7MRM4R --source msstore", instructions); + Assert.Contains("wsl --install --no-distribution", instructions); + Assert.Contains("elevated", instructions); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void EveryFailureMessage_TellsTheUserHowToInstallWslThemselves(bool quotaExhausted) + { + GitHubApiQuota quota = quotaExhausted ? Exhausted() : Available(); + + Assert.Contains(WslPlatformInstallDiagnostics.SelfInstallInstructions, + WslPlatformInstallDiagnostics.DescribeFailure(1, quota)); + Assert.Contains(WslPlatformInstallDiagnostics.SelfInstallInstructions, + WslPlatformInstallDiagnostics.DescribeFailure(1, quota: null)); + Assert.Contains(WslPlatformInstallDiagnostics.SelfInstallInstructions, + WslPlatformInstallDiagnostics.DescribeUnavailableDownload(Exhausted())); + } + + [Theory] + [InlineData(0, true)] + [InlineData(-1, true)] + [InlineData(1, false)] + [InlineData(60, false)] + public void IsExhausted_TracksRemainingCalls(int remaining, bool expected) => + Assert.Equal(expected, new GitHubApiQuota(60, remaining, DateTimeOffset.UtcNow).IsExhausted); + + [Fact] + public void Used_NeverReportsNegativeConsumption() => + Assert.Equal(0, new GitHubApiQuota(60, 75, DateTimeOffset.UtcNow).Used); + + [Fact] + public async Task InstallWslPlatform_ExhaustedQuota_FailsWithoutRaisingAnElevationPrompt() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"openclaw-wsl-diag-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + try + { + var logger = new SetupLogger(filePath: null, LogLevel.Trace); + var ctx = new SetupContext( + new SetupConfig(), + logger, + new TransactionJournal(filePath: null), + new CommandRunner(logger), + CancellationToken.None, + dataDir: tempDir, + localDataDir: tempDir); + + var probeCalls = 0; + StepResult result = await PreflightWslStep.InstallWslPlatformAsync( + ctx, + _ => + { + probeCalls++; + return Task.FromResult(Exhausted()); + }, + CancellationToken.None); + + // One probe, no second probe: the installer was never launched, so no + // administrator prompt was raised for an install that cannot succeed. + Assert.Equal(1, probeCalls); + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("60/60", result.Message); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } +}