Skip to content
Closed
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
41 changes: 38 additions & 3 deletions src/OpenClaw.SetupEngine/PreflightWslStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,28 @@ public override async Task<StepResult> ExecuteAsync(SetupContext ctx, Cancellati
return message;
}

internal static async Task<StepResult> InstallWslPlatformAsync(SetupContext ctx, CancellationToken ct)
internal static Task<StepResult> InstallWslPlatformAsync(SetupContext ctx, CancellationToken ct)
=> InstallWslPlatformAsync(ctx, WslPlatformInstallDiagnostics.QueryGitHubQuotaAsync, ct);

internal static async Task<StepResult> InstallWslPlatformAsync(
SetupContext ctx,
Func<CancellationToken, Task<GitHubApiQuota?>> 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
{
Expand All @@ -221,7 +241,19 @@ internal static async Task<StepResult> 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,
Expand Down Expand Up @@ -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<StepResult> ExecuteAsync(SetupContext ctx, CancellationToken ct)
{
Expand Down
134 changes: 134 additions & 0 deletions src/OpenClaw.SetupEngine/WslPlatformInstallDiagnostics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
using System.Net.Http;
using System.Text.Json;

namespace OpenClaw.SetupEngine;

/// <summary>
/// The unauthenticated GitHub API quota, which <c>wsl --install</c> depends on.
/// </summary>
internal sealed record GitHubApiQuota(int Limit, int Remaining, DateTimeOffset ResetsAt)
{
public bool IsExhausted => Remaining <= 0;

public int Used => Math.Max(0, Limit - Remaining);
}

/// <summary>
/// Explains why an elevated <c>wsl --install</c> failed.
/// </summary>
/// <remarks>
/// <para>
/// <c>C:\Windows\System32\wsl.exe</c> is a stub; the real WSL ships out of band.
/// When WSL is absent, <c>wsl --install</c> resolves the package to download by
/// calling <c>https://api.github.com/repos/Microsoft/WSL/releases/latest</c> — 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
/// <em>per IP</em>, 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.
/// </para>
/// <para>
/// 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.
/// <c>/rate_limit</c> is itself exempt from the quota, so probing is free.
/// </para>
/// </remarks>
internal static class WslPlatformInstallDiagnostics
{
private const string RateLimitUrl = "https://api.github.com/rate_limit";

/// <summary>The Microsoft Store product id for Windows Subsystem for Linux.</summary>
private const string WslStoreProductId = "9P9TQF7MRM4R";

private static readonly TimeSpan s_probeTimeout = TimeSpan.FromSeconds(5);

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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.";

/// <summary>
/// Builds the operator-facing explanation for a failed platform install.
/// Pure, so the wording is covered by tests.
/// </summary>
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;
}

/// <summary>
/// Explains a pre-launch abort, used when the quota is already spent and the
/// install would only fail after prompting for administrator approval.
/// </summary>
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;

/// <summary>
/// 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.
/// </summary>
public static async Task<GitHubApiQuota?> 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;
}
}
}
136 changes: 136 additions & 0 deletions tests/OpenClaw.SetupEngine.Tests/WslPlatformInstallDiagnosticsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
namespace OpenClaw.SetupEngine.Tests;

/// <summary>
/// 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.
/// </summary>
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<GitHubApiQuota?>(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);
}
}
}