From c7d80b4f5a73594d7237f6993077730a9b40bf61 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 22 Sep 2026 19:07:46 -0700 Subject: [PATCH 1/5] fix(setup): unregister only a distro this install created Signed-off-by: Sebastien Tardif --- .../CreateWslInstanceStep.cs | 8 ++- .../SetupStepsTests.cs | 71 ++++++++++++++++--- 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs b/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs index 68dd1c0ba0..6ac6ef96d2 100644 --- a/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs +++ b/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs @@ -198,15 +198,19 @@ private static async Task CleanupPartialInstall(SetupContext ctx, string var registrationStateKnown = list.ExitCode == 0; var distroExists = registrationStateKnown && WslInstallSupport.ContainsDistro(list.Stdout, distro); var canDeleteInstallPath = registrationStateKnown && !distroExists; + var ownsThisInstall = ManagedDistroOwnership.HasPathBoundMarkerEvidence(ctx.LocalDataDir, distro); if (!registrationStateKnown) { - ctx.Logger.Warn($"Partial install cleanup could not list WSL distros (exit {list.ExitCode}); attempting best-effort unregister for '{distro}' before deleting app-owned files"); + ctx.Logger.Warn($"Partial install cleanup could not list WSL distros (exit {list.ExitCode}); refusing to unregister '{distro}'"); + } + else if (distroExists && (ownsThisInstall || ctx.IsUninstalling)) + { canDeleteInstallPath = await TryUnregisterPartialInstall(ctx, distro, cleanupErrors, ct); } else if (distroExists) { - canDeleteInstallPath = await TryUnregisterPartialInstall(ctx, distro, cleanupErrors, ct); + ctx.Logger.Warn($"Refusing to unregister '{distro}' because this install did not create it"); } if (!canDeleteInstallPath) diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 3a568a07f7..166104e828 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -484,7 +484,7 @@ public async Task CreateWslInstanceRollback_PreservesVhdWhenUnregisterFails() Assert.Contains("Refusing unsafe WSL rollback cleanup", error.Message); Assert.True(File.Exists(sentinel)); - Assert.Equal(2, commands.Calls.Count(c => c.Arguments.SequenceEqual(["--unregister", legacyName]))); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--unregister")); } [Fact] @@ -3455,7 +3455,7 @@ public async Task CreateWslInstance_PartialCleanupSkipsInstallPathDeleteWhenDist } [Fact] - public async Task CreateWslInstance_PartialCleanupDeletesInstallPathWhenListFailsButDistroIsAlreadyGone() + public async Task CreateWslInstance_PartialCleanupKeepsInstallPathWhenListFails() { var listCalls = 0; var installPath = ""; @@ -3472,11 +3472,6 @@ public async Task CreateWslInstance_PartialCleanupDeletesInstallPathWhenListFail File.WriteAllText(Path.Combine(installPath, "ext4.vhdx"), "partial"); return Fail("download failed"); } - if (args.SequenceEqual(["--terminate", "OpenClawGateway"]) || - args.SequenceEqual(["--unregister", "OpenClawGateway"])) - { - return Fail("There is no distribution with the supplied name."); - } return Fail($"unexpected args: {string.Join(' ', args)}"); }); @@ -3487,8 +3482,10 @@ public async Task CreateWslInstance_PartialCleanupDeletesInstallPathWhenListFail Assert.Equal(StepOutcome.Failed, result.Outcome); Assert.Contains("download failed", result.Message); - Assert.DoesNotContain("Partial app-owned distro cleanup also failed", result.Message); - Assert.False(Directory.Exists(installPath)); + Assert.Contains("could not confirm whether distro 'OpenClawGateway' is still registered", result.Message); + Assert.Contains("skipped deleting app-owned install path", result.Message); + Assert.True(File.Exists(Path.Combine(installPath, "ext4.vhdx"))); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--unregister")); Assert.DoesNotContain(commands.Calls, c => c.Arguments.SequenceEqual(["--shutdown"])); } @@ -3508,6 +3505,62 @@ public async Task CreateWslInstance_FailsWhenTargetDistroStillExists() Assert.DoesNotContain(commands.Calls, c => c.Arguments.Contains("--install")); } + [Fact] + public async Task CreateWslInstance_RollbackDoesNotUnregisterDistroThatAlreadyExisted() + { + var commands = new FakeCommandRunner(args => + args.SequenceEqual(["--list", "--quiet"]) + ? Ok("OpenClawGateway\n") + : args.SequenceEqual(["--terminate", "OpenClawGateway"]) || + args.SequenceEqual(["--unregister", "OpenClawGateway"]) + ? Ok() + : Fail($"unexpected args: {string.Join(' ', args)}")); + var ctx = CreateContext( + new SetupConfig { RollbackOnFailure = true }, + commands); + var pipeline = new SetupPipeline([new CreateWslInstanceStep()]); + + var result = await pipeline.RunAsync(ctx); + + Assert.Equal(PipelineOutcome.Failed, result.Outcome); + Assert.Contains("still exists after cleanup", result.Message); + Assert.False(ManagedDistroOwnership.HasPathBoundMarkerEvidence( + ctx.LocalDataDir, + "OpenClawGateway")); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--unregister")); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--terminate")); + } + + [Fact] + public async Task CreateWslInstance_PartialCleanupDoesNotUnregisterWhenListFails() + { + var listCalls = 0; + var commands = new FakeCommandRunner(args => + { + if (args.SequenceEqual(["--list", "--quiet"])) + { + listCalls++; + return listCalls == 1 ? Ok("") : Fail("list failed"); + } + + if (args.Contains("--install")) + return Fail("download failed"); + if (args.SequenceEqual(["--terminate", "OpenClawGateway"]) || + args.SequenceEqual(["--unregister", "OpenClawGateway"])) + return Ok(); + + return Fail($"unexpected args: {string.Join(' ', args)}"); + }); + var ctx = CreateContext(commands: commands); + + var result = await new CreateWslInstanceStep().ExecuteAsync(ctx, CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("download failed", result.Message); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--unregister")); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--terminate")); + } + [Fact] public async Task CreateWslInstance_FailsWhenInstallDirectoryIsDirty() { From f826db4f5d5aebdf5a13ec01667d052bcc127286 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 23 Sep 2026 11:28:35 -0700 Subject: [PATCH 2/5] fix(setup): unregister a distro only when its live registration matches this install A path marker from an earlier attempt is not enough. Failed setup cleanup now requires the current WSL registration base path before terminate or unregister. Consented uninstall is unchanged. Signed-off-by: Sebastien Tardif --- .../CreateWslInstanceStep.cs | 34 ++++++++++++++++-- .../SetupStepsTests.cs | 36 ++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs b/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs index 6ac6ef96d2..38c51b4206 100644 --- a/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs +++ b/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs @@ -24,10 +24,22 @@ public sealed class CreateWslInstanceStep : SetupStep TimeSpan.FromSeconds(90), ]; + private readonly IWslRegistrationInspector _registrationInspector; + public override string Id => "wsl-create"; public override string DisplayName => "Create WSL instance"; public override bool CanRetry => false; + public CreateWslInstanceStep() + : this(new WindowsWslRegistrationInspector()) + { + } + + internal CreateWslInstanceStep(IWslRegistrationInspector registrationInspector) + { + _registrationInspector = registrationInspector ?? throw new ArgumentNullException(nameof(registrationInspector)); + } + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) { var distro = ctx.DistroName!; @@ -186,7 +198,7 @@ private static async Task VerifyFreshDistro(SetupContext ctx, string return StepResult.Ok($"Created clean WSL2 distro '{distro}' at '{installPath}'"); } - private static async Task CleanupPartialInstall(SetupContext ctx, string distro, string installPath, CancellationToken ct) + private async Task CleanupPartialInstall(SetupContext ctx, string distro, string installPath, CancellationToken ct) { var cleanupErrors = new List(); var installPathExists = Directory.Exists(installPath) || File.Exists(installPath); @@ -204,10 +216,28 @@ private static async Task CleanupPartialInstall(SetupContext ctx, string { ctx.Logger.Warn($"Partial install cleanup could not list WSL distros (exit {list.ExitCode}); refusing to unregister '{distro}'"); } - else if (distroExists && (ownsThisInstall || ctx.IsUninstalling)) + else if (distroExists && ctx.IsUninstalling) { canDeleteInstallPath = await TryUnregisterPartialInstall(ctx, distro, cleanupErrors, ct); } + else if (distroExists && ownsThisInstall) + { + if (!ManagedDistroOwnership.HasRegisteredDistroEvidence( + ctx.DataDir, + ctx.LocalDataDir, + distro, + installPath, + _registrationInspector, + out var registrationFailure)) + { + ctx.Logger.Warn( + $"Refusing to unregister '{distro}' because the live WSL registration is not this install: {registrationFailure}"); + } + else + { + canDeleteInstallPath = await TryUnregisterPartialInstall(ctx, distro, cleanupErrors, ct); + } + } else if (distroExists) { ctx.Logger.Warn($"Refusing to unregister '{distro}' because this install did not create it"); diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 166104e828..a91f9991dd 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -3409,14 +3409,48 @@ public async Task CreateWslInstance_PartialCleanupAvoidsGlobalShutdownWhenUnregi return Fail($"unexpected args: {string.Join(' ', args)}"); }); var ctx = CreateContext(commands: commands); + var installPath = Path.Combine(ctx.LocalDataDir, "wsl", "OpenClawGateway"); + var step = new CreateWslInstanceStep(FakeWslRegistrationInspector.Found(installPath)); - var result = await new CreateWslInstanceStep().ExecuteAsync(ctx, CancellationToken.None); + var result = await step.ExecuteAsync(ctx, CancellationToken.None); Assert.Equal(StepOutcome.Failed, result.Outcome); Assert.Contains("download failed", result.Message); + Assert.Contains(commands.Calls, c => c.Arguments.SequenceEqual(["--unregister", "OpenClawGateway"])); Assert.DoesNotContain(commands.Calls, c => c.Arguments.SequenceEqual(["--shutdown"])); } + [Fact] + public async Task CreateWslInstance_PartialCleanupDoesNotUnregisterWhenLiveRegistrationPathDiffers() + { + var listCalls = 0; + var commands = new FakeCommandRunner(args => + { + if (args.SequenceEqual(["--list", "--quiet"])) + { + listCalls++; + return Ok(listCalls == 1 ? "" : "OpenClawGateway\n"); + } + if (args.Contains("--install")) + return Fail("download failed"); + if (args.SequenceEqual(["--terminate", "OpenClawGateway"]) || + args.SequenceEqual(["--unregister", "OpenClawGateway"])) + return Ok(); + + return Fail($"unexpected args: {string.Join(' ', args)}"); + }); + var ctx = CreateContext(commands: commands); + var step = new CreateWslInstanceStep( + FakeWslRegistrationInspector.Found(@"C:\other-distro")); + + var result = await step.ExecuteAsync(ctx, CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("download failed", result.Message); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--unregister")); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--terminate")); + } + [Fact] public async Task CreateWslInstance_PartialCleanupSkipsInstallPathDeleteWhenDistroStateIsUnknown() { From 08e10063f5c3ed9ce1d6fd197f0da9c62a534a2c Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 23 Sep 2026 12:13:30 -0700 Subject: [PATCH 3/5] fix(setup): limit automatic unregister to this create attempt Consented uninstall still unregisters when wsl --list fails. Automatic rollback unregisters only after this run started the install and the live base path matches. Signed-off-by: Sebastien Tardif --- .../CreateWslInstanceStep.cs | 13 ++--- .../SetupStepsTests.cs | 50 +++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs b/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs index 38c51b4206..6b868e6ec2 100644 --- a/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs +++ b/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs @@ -25,6 +25,7 @@ public sealed class CreateWslInstanceStep : SetupStep ]; private readonly IWslRegistrationInspector _registrationInspector; + private bool _createdRegistrationThisAttempt; public override string Id => "wsl-create"; public override string DisplayName => "Create WSL instance"; @@ -88,6 +89,7 @@ or ArgumentException Directory.CreateDirectory(Path.GetDirectoryName(installPath)!); + _createdRegistrationThisAttempt = true; var installArgs = WslInstallSupport.BuildDirectInstallArgs(baseDistro, distro, installPath); ctx.Logger.Info($"Installing fresh WSL distro with arguments: {string.Join(' ', installArgs)}"); var install = await ctx.Commands.RunAsync( @@ -210,17 +212,16 @@ private async Task CleanupPartialInstall(SetupContext ctx, string distro var registrationStateKnown = list.ExitCode == 0; var distroExists = registrationStateKnown && WslInstallSupport.ContainsDistro(list.Stdout, distro); var canDeleteInstallPath = registrationStateKnown && !distroExists; - var ownsThisInstall = ManagedDistroOwnership.HasPathBoundMarkerEvidence(ctx.LocalDataDir, distro); - if (!registrationStateKnown) + if (ctx.IsUninstalling) { - ctx.Logger.Warn($"Partial install cleanup could not list WSL distros (exit {list.ExitCode}); refusing to unregister '{distro}'"); + canDeleteInstallPath = await TryUnregisterPartialInstall(ctx, distro, cleanupErrors, ct); } - else if (distroExists && ctx.IsUninstalling) + else if (!registrationStateKnown) { - canDeleteInstallPath = await TryUnregisterPartialInstall(ctx, distro, cleanupErrors, ct); + ctx.Logger.Warn($"Partial install cleanup could not list WSL distros (exit {list.ExitCode}); refusing to unregister '{distro}'"); } - else if (distroExists && ownsThisInstall) + else if (distroExists && _createdRegistrationThisAttempt) { if (!ManagedDistroOwnership.HasRegisteredDistroEvidence( ctx.DataDir, diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index a91f9991dd..cbc9738904 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -3451,6 +3451,56 @@ public async Task CreateWslInstance_PartialCleanupDoesNotUnregisterWhenLiveRegis Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--terminate")); } + [Fact] + public async Task CreateWslInstance_RollbackKeepsPreexistingDistroWhenMarkerMatchesLivePath() + { + var commands = new FakeCommandRunner(args => + args.SequenceEqual(["--list", "--quiet"]) + ? Ok("OpenClawGateway\n") + : args.SequenceEqual(["--terminate", "OpenClawGateway"]) || + args.SequenceEqual(["--unregister", "OpenClawGateway"]) + ? Ok() + : Fail($"unexpected args: {string.Join(' ', args)}")); + var ctx = CreateContext(commands: commands); + var installPath = Path.Combine(ctx.LocalDataDir, "wsl", "OpenClawGateway"); + await ManagedDistroOwnership.WriteMarkerAsync( + ctx.LocalDataDir, + "OpenClawGateway", + installPath, + CancellationToken.None); + var step = new CreateWslInstanceStep(FakeWslRegistrationInspector.Found(installPath)); + + var result = await step.ExecuteAsync(ctx, CancellationToken.None); + await step.RollbackAsync(ctx, CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("still exists after cleanup", result.Message); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--unregister")); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--terminate")); + } + + [Fact] + public async Task CreateWslInstance_UninstallUnregistersWhenDistroListFails() + { + var commands = new FakeCommandRunner(args => + { + if (args.SequenceEqual(["--list", "--quiet"])) + return Fail("list failed"); + if (args.SequenceEqual(["--terminate", "OpenClawGateway"]) || + args.SequenceEqual(["--unregister", "OpenClawGateway"])) + return Ok(); + return Fail($"unexpected args: {string.Join(' ', args)}"); + }); + var ctx = CreateContext(commands: commands); + ctx.IsUninstalling = true; + var step = new CreateWslInstanceStep( + FakeWslRegistrationInspector.Found(@"C:\other-distro")); + + await step.RollbackAsync(ctx, CancellationToken.None); + + Assert.Contains(commands.Calls, call => call.Arguments.SequenceEqual(["--unregister", "OpenClawGateway"])); + } + [Fact] public async Task CreateWslInstance_PartialCleanupSkipsInstallPathDeleteWhenDistroStateIsUnknown() { From e1abdb5dc2abc33114bb19964a407886f43684de Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Thu, 24 Sep 2026 01:59:29 +0200 Subject: [PATCH 4/5] fix(setup): preserve refused state during automatic rollback Guard rollback before this execution starts WSL creation, preserving preexisting directories, markers, and empty parents. Reset attempt authority on reuse while retaining explicit uninstall and live-registration cleanup checks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bdf13a3c-c9a5-4844-8c6f-c2253c403c2e --- docs/SETUP_ENGINE_REDESIGN.md | 8 + .../CreateWslInstanceStep.cs | 10 + .../SetupStepsTests.cs | 181 ++++++++++++++++-- 3 files changed, 179 insertions(+), 20 deletions(-) diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index 11b055d0cd..262990b8c2 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -79,6 +79,14 @@ absent or unregister succeeds. To replace such a legacy distro, uninstall it first, using `--uninstall --confirm-destructive` and the same distro name, then rerun setup with a supported new name. +Automatic rollback of the WSL-create step has cleanup authority only after that +execution starts creating the distro. If creation refuses pre-existing state, +rollback preserves the distro, install directory, ownership marker, and empty +parent directory. A reused step starts with no authority from an earlier attempt. +Once creation starts, the existing live-registration ownership and +unregister-failure preservation gates still apply. Explicit uninstall remains a +separate, destructive-consent path. + ```json { "DistroName": "OpenClawGateway", diff --git a/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs b/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs index 6b868e6ec2..cdc73bfbc5 100644 --- a/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs +++ b/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs @@ -43,6 +43,7 @@ internal CreateWslInstanceStep(IWslRegistrationInspector registrationInspector) public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) { + _createdRegistrationThisAttempt = false; var distro = ctx.DistroName!; var baseDistro = ctx.Config.BaseDistro.Trim(); @@ -320,6 +321,15 @@ public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) if (!DistroInstallPathPolicy.TryGetManagedInstallPath(ctx.LocalDataDir, distro, out var vhdDir, out var pathError)) throw new IOException($"[Uninstall] Refusing WSL rollback filesystem cleanup: {pathError}"); + // Execute's partial cleanup is reached only after creation starts; pipeline + // rollback also runs when Execute refuses pre-existing state. + if (!ctx.IsUninstalling && !_createdRegistrationThisAttempt) + { + ctx.Logger.Warn( + $"Preserving WSL distro '{distro}', install path, ownership marker, and parent directory because this attempt did not start creating it."); + return; + } + var cleanupError = await CleanupPartialInstall(ctx, distro, vhdDir, ct); if (cleanupError.Length > 0) throw new IOException($"[Uninstall] Refusing unsafe WSL rollback cleanup.{cleanupError}"); diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index cbc9738904..4365957395 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -460,49 +460,62 @@ public async Task CreateWslInstanceRollback_RejectsTraversalBeforeCommandsOrDele [Fact] public async Task CreateWslInstanceRollback_PreservesVhdWhenUnregisterFails() { - const string legacyName = "OpenClaw Gateway"; - var legacyPath = Path.Combine(_localTempDir, "wsl", legacyName); - Directory.CreateDirectory(legacyPath); - var sentinel = Path.Combine(legacyPath, "legacy.vhdx"); - File.WriteAllText(sentinel, "legacy"); + const string distroName = "OpenClawGateway"; + var installPath = Path.Combine(_localTempDir, "wsl", distroName); + var sentinel = Path.Combine(installPath, "partial.vhdx"); + var installed = false; var commands = new FakeCommandRunner(args => { if (args.SequenceEqual(["--list", "--quiet"])) - return Ok($"{legacyName}\n"); - if (args.SequenceEqual(["--terminate", legacyName])) + return Ok(installed ? $"{distroName}\n" : ""); + if (args.Contains("--install")) + { + Directory.CreateDirectory(installPath); + File.WriteAllText(sentinel, "partial"); + installed = true; + throw new IOException("install interrupted"); + } + if (args.SequenceEqual(["--terminate", distroName])) return Ok(); - if (args.SequenceEqual(["--unregister", legacyName])) + if (args.SequenceEqual(["--unregister", distroName])) return Fail("unregister failed"); return Fail($"unexpected args: {string.Join(' ', args)}"); }); - var ctx = CreateContext( - new SetupConfig { DistroName = legacyName }, - commands); + var ctx = CreateContext(commands: commands); + var step = new CreateWslInstanceStep(FakeWslRegistrationInspector.Found(installPath)); + await Assert.ThrowsAsync(() => step.ExecuteAsync(ctx, CancellationToken.None)); var error = await Assert.ThrowsAsync( - () => new CreateWslInstanceStep().RollbackAsync(ctx, CancellationToken.None)); + () => step.RollbackAsync(ctx, CancellationToken.None)); Assert.Contains("Refusing unsafe WSL rollback cleanup", error.Message); - Assert.True(File.Exists(sentinel)); - Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--unregister")); + Assert.Equal("partial", File.ReadAllText(sentinel)); + Assert.True(ManagedDistroOwnership.HasPathBoundMarkerEvidence(ctx.LocalDataDir, distroName)); + Assert.Equal(2, commands.Calls.Count(call => call.Arguments.SequenceEqual(["--unregister", distroName]))); } [Fact] public async Task CreateWslInstanceRollback_RemovesEmptyWslRootWhenDistroIsAlreadyAbsent() { var wslRoot = Path.Combine(_localTempDir, "wsl"); - Directory.CreateDirectory(wslRoot); var commands = new FakeCommandRunner(args => args.SequenceEqual(["--list", "--quiet"]) ? Ok("") : Fail($"unexpected args: {string.Join(' ', args)}")); var ctx = CreateContext(commands: commands); + var step = new CreateWslInstanceStep(); + var result = await step.ExecuteAsync(ctx, CancellationToken.None); + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.True(Directory.Exists(wslRoot)); - await new CreateWslInstanceStep().RollbackAsync(ctx, CancellationToken.None); + await step.RollbackAsync(ctx, CancellationToken.None); Assert.False(Directory.Exists(wslRoot)); Assert.Collection( commands.Calls, + call => Assert.Equal(["--list", "--quiet"], call.Arguments), + call => Assert.Contains("--install", call.Arguments), + call => Assert.Equal(["--list", "--quiet"], call.Arguments), call => Assert.Equal(["--list", "--quiet"], call.Arguments)); } @@ -3392,6 +3405,7 @@ public async Task CreateWslInstance_AllowsWslServiceToSettleBeforeVersionVerific public async Task CreateWslInstance_PartialCleanupAvoidsGlobalShutdownWhenUnregisterSucceeds() { var listCalls = 0; + var installPath = Path.Combine(_localTempDir, "wsl", "OpenClawGateway"); var commands = new FakeCommandRunner(args => { if (args.SequenceEqual(["--list", "--quiet"])) @@ -3400,7 +3414,11 @@ public async Task CreateWslInstance_PartialCleanupAvoidsGlobalShutdownWhenUnregi return Ok(listCalls == 1 ? "" : "OpenClawGateway\n"); } if (args.Contains("--install")) + { + Directory.CreateDirectory(installPath); + File.WriteAllText(Path.Combine(installPath, "ext4.vhdx"), "partial"); return Fail("download failed"); + } if (args.SequenceEqual(["--terminate", "OpenClawGateway"])) return Ok(); if (args.SequenceEqual(["--unregister", "OpenClawGateway"])) @@ -3409,7 +3427,6 @@ public async Task CreateWslInstance_PartialCleanupAvoidsGlobalShutdownWhenUnregi return Fail($"unexpected args: {string.Join(' ', args)}"); }); var ctx = CreateContext(commands: commands); - var installPath = Path.Combine(ctx.LocalDataDir, "wsl", "OpenClawGateway"); var step = new CreateWslInstanceStep(FakeWslRegistrationInspector.Found(installPath)); var result = await step.ExecuteAsync(ctx, CancellationToken.None); @@ -3418,12 +3435,21 @@ public async Task CreateWslInstance_PartialCleanupAvoidsGlobalShutdownWhenUnregi Assert.Contains("download failed", result.Message); Assert.Contains(commands.Calls, c => c.Arguments.SequenceEqual(["--unregister", "OpenClawGateway"])); Assert.DoesNotContain(commands.Calls, c => c.Arguments.SequenceEqual(["--shutdown"])); + Assert.False(Directory.Exists(installPath)); + Assert.False(File.Exists(Path.Combine(ctx.LocalDataDir, "setup-managed-distro.json"))); } - [Fact] - public async Task CreateWslInstance_PartialCleanupDoesNotUnregisterWhenLiveRegistrationPathDiffers() + [Theory] + [InlineData(nameof(WslRegistrationInspectionStatus.Found))] + [InlineData(nameof(WslRegistrationInspectionStatus.NotFound))] + [InlineData(nameof(WslRegistrationInspectionStatus.Unavailable))] + [InlineData(nameof(WslRegistrationInspectionStatus.Duplicate))] + [InlineData(nameof(WslRegistrationInspectionStatus.Malformed))] + public async Task CreateWslInstance_PartialCleanupPreservesFilesWhenLiveRegistrationIsNotOwned(string statusName) { var listCalls = 0; + var installPath = Path.Combine(_localTempDir, "wsl", "OpenClawGateway"); + var sentinel = Path.Combine(installPath, "ext4.vhdx"); var commands = new FakeCommandRunner(args => { if (args.SequenceEqual(["--list", "--quiet"])) @@ -3432,7 +3458,11 @@ public async Task CreateWslInstance_PartialCleanupDoesNotUnregisterWhenLiveRegis return Ok(listCalls == 1 ? "" : "OpenClawGateway\n"); } if (args.Contains("--install")) + { + Directory.CreateDirectory(installPath); + File.WriteAllText(sentinel, "partial"); return Fail("download failed"); + } if (args.SequenceEqual(["--terminate", "OpenClawGateway"]) || args.SequenceEqual(["--unregister", "OpenClawGateway"])) return Ok(); @@ -3441,14 +3471,19 @@ public async Task CreateWslInstance_PartialCleanupDoesNotUnregisterWhenLiveRegis }); var ctx = CreateContext(commands: commands); var step = new CreateWslInstanceStep( - FakeWslRegistrationInspector.Found(@"C:\other-distro")); + new FakeWslRegistrationInspector(new WslRegistrationInspection( + Enum.Parse(statusName), + BasePath: @"C:\other-distro"))); var result = await step.ExecuteAsync(ctx, CancellationToken.None); + await Assert.ThrowsAsync(() => step.RollbackAsync(ctx, CancellationToken.None)); Assert.Equal(StepOutcome.Failed, result.Outcome); Assert.Contains("download failed", result.Message); Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--unregister")); Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--terminate")); + Assert.Equal("partial", File.ReadAllText(sentinel)); + Assert.True(ManagedDistroOwnership.HasPathBoundMarkerEvidence(ctx.LocalDataDir, ctx.DistroName!)); } [Fact] @@ -3664,6 +3699,112 @@ public async Task CreateWslInstance_FailsWhenInstallDirectoryIsDirty() Assert.DoesNotContain(commands.Calls, c => c.Arguments.Contains("--install")); } + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task CreateWslInstance_RollbackPreservesRefusedDirectory(bool usePipeline, bool hasMarker) + { + var commands = new FakeCommandRunner(args => + args.SequenceEqual(["--list", "--quiet"]) + ? Ok("") + : Fail($"unexpected args: {string.Join(' ', args)}")); + var ctx = CreateContext(new SetupConfig { RollbackOnFailure = true }, commands); + var installPath = Path.Combine(ctx.LocalDataDir, "wsl", "OpenClawGateway"); + Directory.CreateDirectory(installPath); + var sentinel = Path.Combine(installPath, "keep.vhdx"); + await File.WriteAllTextAsync(sentinel, "preexisting data"); + var markerPath = Path.Combine(ctx.LocalDataDir, "setup-managed-distro.json"); + if (hasMarker) + await ManagedDistroOwnership.WriteMarkerAsync(ctx.LocalDataDir, ctx.DistroName!, installPath, CancellationToken.None); + var originalMarker = hasMarker ? await File.ReadAllTextAsync(markerPath) : null; + var logs = new List(); + ctx.Logger.LogEmitted += (_, entry) => logs.Add(entry); + var step = new CreateWslInstanceStep(); + + if (usePipeline) + { + var result = await new SetupPipeline([step]).RunAsync(ctx); + Assert.Equal(PipelineOutcome.Failed, result.Outcome); + Assert.Contains("still contains files after cleanup", result.Message); + } + else + { + var result = await step.ExecuteAsync(ctx, CancellationToken.None); + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("still contains files after cleanup", result.Message); + await step.RollbackAsync(ctx, CancellationToken.None); + } + + Assert.True(File.Exists(sentinel)); + Assert.Equal("preexisting data", await File.ReadAllTextAsync(sentinel)); + Assert.Equal(hasMarker, File.Exists(markerPath)); + if (hasMarker) + Assert.Equal(originalMarker, await File.ReadAllTextAsync(markerPath)); + Assert.Collection(commands.Calls, call => Assert.Equal(["--list", "--quiet"], call.Arguments)); + Assert.Empty(commands.WslCalls); + Assert.Contains(logs, entry => entry.Level == LogLevel.Warn && entry.Message.Contains("Preserving")); + _output.WriteLine($"Refused directory and marker preserved; pipeline={usePipeline}, marker={hasMarker}; only fake --list ran."); + } + + [Fact] + public async Task CreateWslInstanceRollback_BeforeExecutePreservesMarkerAndEmptyParent() + { + var commands = new FakeCommandRunner(_ => Ok("")); + var ctx = CreateContext(commands: commands); + var wslRoot = Path.Combine(ctx.LocalDataDir, "wsl"); + var installPath = Path.Combine(wslRoot, ctx.DistroName!); + Directory.CreateDirectory(wslRoot); + await ManagedDistroOwnership.WriteMarkerAsync(ctx.LocalDataDir, ctx.DistroName!, installPath, CancellationToken.None); + var markerPath = Path.Combine(ctx.LocalDataDir, "setup-managed-distro.json"); + var originalMarker = await File.ReadAllTextAsync(markerPath); + + await new CreateWslInstanceStep().RollbackAsync(ctx, CancellationToken.None); + + Assert.True(Directory.Exists(wslRoot)); + Assert.Equal(originalMarker, await File.ReadAllTextAsync(markerPath)); + Assert.Empty(commands.Calls); + Assert.Empty(commands.WslCalls); + } + + [Fact] + public async Task CreateWslInstance_ReusedStepDoesNotInheritCleanupAuthority() + { + var installPath = Path.Combine(_localTempDir, "wsl", "OpenClawGateway"); + var sentinel = Path.Combine(installPath, "ext4.vhdx"); + var listCalls = 0; + var commands = new FakeCommandRunner(args => + { + if (args.SequenceEqual(["--list", "--quiet"])) + return ++listCalls == 2 ? Fail("list failed") : Ok(""); + if (args.Contains("--install")) + { + Directory.CreateDirectory(installPath); + File.WriteAllText(sentinel, "previous attempt"); + return Fail("download failed"); + } + return Fail($"unexpected args: {string.Join(' ', args)}"); + }); + var ctx = CreateContext(commands: commands); + var step = new CreateWslInstanceStep(); + var first = await step.ExecuteAsync(ctx, CancellationToken.None); + Assert.Equal(StepOutcome.Failed, first.Outcome); + Assert.Contains("could not confirm", first.Message); + var markerPath = Path.Combine(ctx.LocalDataDir, "setup-managed-distro.json"); + var originalMarker = await File.ReadAllTextAsync(markerPath); + + var second = await step.ExecuteAsync(ctx, CancellationToken.None); + var callsBeforeRollback = commands.Calls.Count; + await step.RollbackAsync(ctx, CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, second.Outcome); + Assert.Contains("still contains files after cleanup", second.Message); + Assert.Equal("previous attempt", await File.ReadAllTextAsync(sentinel)); + Assert.Equal(originalMarker, await File.ReadAllTextAsync(markerPath)); + Assert.Equal(callsBeforeRollback, commands.Calls.Count); + } + [Fact] public async Task CreateWslInstance_RemovesStaleFileAtInstallPathBeforeInstalling() { From 4422ba5357007afbc9ff1c159fa614d45db25ad1 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sun, 27 Sep 2026 12:49:31 -0700 Subject: [PATCH 5/5] fix(setup): skip unregister when the distro is already gone Uninstall called terminate and unregister even after --list --quiet proved the distro was absent. A refused unregister also deleted the ownership marker when the install directory was already gone, so a surviving same-name registration lost its recovery record. Known-absent uninstall now skips unregister. A refusal while the registration still exists keeps the marker. Unknown and present registrations still use the existing unregister attempt. Tests: CreateWslInstance cleanup filter 9 passed. ./build.ps1 exit 0. Shared 4099 passed, 32 skipped. Tray 3064 passed. Five source contract tests still fail on an LF checkout because they expect CRLF snippets. Signed-off-by: Sebastien Tardif --- .../CreateWslInstanceStep.cs | 6 ++- .../SetupStepsTests.cs | 43 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs b/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs index cdc73bfbc5..dd1a099e9a 100644 --- a/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs +++ b/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs @@ -216,7 +216,8 @@ private async Task CleanupPartialInstall(SetupContext ctx, string distro if (ctx.IsUninstalling) { - canDeleteInstallPath = await TryUnregisterPartialInstall(ctx, distro, cleanupErrors, ct); + if (!(registrationStateKnown && !distroExists)) + canDeleteInstallPath = await TryUnregisterPartialInstall(ctx, distro, cleanupErrors, ct); } else if (!registrationStateKnown) { @@ -234,6 +235,8 @@ private async Task CleanupPartialInstall(SetupContext ctx, string distro { ctx.Logger.Warn( $"Refusing to unregister '{distro}' because the live WSL registration is not this install: {registrationFailure}"); + cleanupErrors.Add( + $"refusing to unregister '{distro}' because the live WSL registration is not this install: {registrationFailure}"); } else { @@ -243,6 +246,7 @@ private async Task CleanupPartialInstall(SetupContext ctx, string distro else if (distroExists) { ctx.Logger.Warn($"Refusing to unregister '{distro}' because this install did not create it"); + cleanupErrors.Add($"refusing to unregister '{distro}' because this install did not create it"); } if (!canDeleteInstallPath) diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 4365957395..aac2111589 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -3514,6 +3514,49 @@ await ManagedDistroOwnership.WriteMarkerAsync( Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--terminate")); } + [Fact] + public async Task CreateWslInstance_UninstallSkipsUnregisterWhenListProvesAbsent() + { + var commands = new FakeCommandRunner(args => + args.SequenceEqual(["--list", "--quiet"]) + ? Ok("") + : Fail($"unexpected args: {string.Join(' ', args)}")); + var ctx = CreateContext(commands: commands); + ctx.IsUninstalling = true; + + await new CreateWslInstanceStep().RollbackAsync(ctx, CancellationToken.None); + + Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--unregister")); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--terminate")); + } + + [Fact] + public async Task CreateWslInstance_KeepsMarkerWhenRefusedRegistrationSurvivesWithoutInstallPath() + { + var listCalls = 0; + var commands = new FakeCommandRunner(args => + { + if (args.SequenceEqual(["--list", "--quiet"])) + return Ok(++listCalls == 1 ? "" : "OpenClawGateway\n"); + if (args.Contains("--install")) + return Fail("download failed"); + if (args.SequenceEqual(["--terminate", "OpenClawGateway"]) || + args.SequenceEqual(["--unregister", "OpenClawGateway"])) + return Ok(); + return Fail($"unexpected args: {string.Join(' ', args)}"); + }); + var ctx = CreateContext(commands: commands); + var step = new CreateWslInstanceStep( + FakeWslRegistrationInspector.Found(@"C:\other-distro")); + + var result = await step.ExecuteAsync(ctx, CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("live WSL registration is not this install", result.Message); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--unregister")); + Assert.True(ManagedDistroOwnership.HasPathBoundMarkerEvidence(ctx.LocalDataDir, ctx.DistroName!)); + } + [Fact] public async Task CreateWslInstance_UninstallUnregistersWhenDistroListFails() {