diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index 11b055d0c..26ed96f5a 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -10,6 +10,25 @@ The Setup Engine is a **config-driven system** for provisioning an OpenClaw WSL The bundled `default-config.json` ships with the tray executable and provides secure defaults (loopback bind, WSL isolation, systemd enabled). Defaults can be overridden via config file or environment variables. +WSL wizard completion restores `gateway.reload.mode` before explicitly restarting +the Gateway. Gateway 2026.9.6 may refuse the guarded restart when it cannot verify +a live serving owner; the rejected owner-lease predicate is not exposed by the +public Gateway CLI. A reload-triggered supervisor transition is one possible +timing explanation, not an established cause of every refusal. Observed service +states differ: local diagnostics captured `activating/auto-restart` with no +MainPID, while hosted generic refusals captured an `active/running` unit and a +live PID. Neither snapshot establishes the admission-time owner-lease predicate. +`SetupWizardRunner` recognizes only the exact +serving-owner refusal, waits for verified managed endpoint ownership using the +existing bounded provenance probe, and retries the normal CLI restart once. +The probe allows up to 30 one-second retry delays, plus probe duration, for +`NoListener` and `UnknownListener` tagged `ListenerSnapshotChanged`. Other +unknown/conflicting listeners, other restart errors, and a repeated refusal still +fail setup. Listener provenance does not prove owner-lease or coordinator +readiness; the retried CLI command retains those guards. Restart-intent recording +contention is a separate failure and is not retried here. There is no direct +systemd restart fallback or ownership bypass. + > **Status note (2026-07-06):** Current default setup includes `WindowsNodeBootstrapContextStep`, which injects Windows-node context into the WSL workspace `AGENTS.md` after onboarding. --- diff --git a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs index cc66d93b3..8fef4b403 100644 --- a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs +++ b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs @@ -19,6 +19,8 @@ public sealed class SetupWizardRunner TimeSpan.FromMilliseconds(500); internal const string StartupMigrationLeaseDiagnostic = "OpenClaw startup migrations are already running for this state directory;"; + internal const string RestartServingOwnerDiagnostic = + "GATEWAY_RESTART_PREPARATION_REFUSED: Cannot verify a live serving Gateway owner for the selected service. Gateway was not signaled."; private static readonly Regex s_normalizeKeyRegex = new("[^a-z0-9]+", RegexOptions.Compiled); // Progress steps can repeat while background work runs; keep bounded caps @@ -614,6 +616,22 @@ internal async Task RestoreReloadModeAsync() await StartGatewayStep.RestartAndWaitForHealthAsync( _ctx, CancellationToken.None); + if (!restartResult.IsSuccess && + restartResult.Message?.Contains(RestartServingOwnerDiagnostic, StringComparison.Ordinal) == true) + { + // Restoring hybrid reload can initiate a supervisor restart before the CLI + // records its intent. Never bypass that CLI ownership gate or adopt a listener. + _ctx.Logger.Warn( + "Gateway restart owner was unavailable after restoring reload. Rechecking managed ownership before one restart retry."); + var retryOwnership = await VerifyExpectedManagedGatewayAsync( + "before retrying gateway restart"); + if (!retryOwnership.IsSuccess) + return retryOwnership; + + restartResult = await StartGatewayStep.RestartAndWaitForHealthAsync( + _ctx, + CancellationToken.None); + } if (!restartResult.IsSuccess) { return StepResult.Fail( diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 3a568a07f..9c4c88803 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -4266,6 +4266,95 @@ public async Task SetupWizard_RestoreReloadModeRestartsAndVerifiesGateway() AssertReloadRestorationCompleted(commands); } + [Fact] + public async Task SetupWizard_RestartOwnerGapReverifiesOwnershipBeforeOneRetry() + { + var restarts = 0; + var inspections = 0; + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command switch + { + var value when value.Contains("config set gateway.reload.mode") => Ok(), + var value when value.Contains("openclaw gateway restart") => Restart(), + var value when value.Contains("curl -s") => Ok("200"), + _ => Fail($"Unexpected command: {command}"), + }); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + ctx.EndpointProvenanceProbe = (_, _) => + { + inspections++; + return Task.FromResult(new GatewayEndpointProvenance( + inspections == 1 + ? GatewayEndpointProvenanceKind.NoListener + : GatewayEndpointProvenanceKind.ExpectedManagedGateway, + ctx.Config.GatewayPort)); + }; + + var result = await new SetupWizardRunner(ctx).RestoreReloadModeAsync(); + + Assert.True(result.IsSuccess, result.Message); + Assert.Equal(2, restarts); + Assert.Equal(3, inspections); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("systemctl")); + return; + + CommandResult Restart() + { + if (++restarts == 1) + return Fail(SetupWizardRunner.RestartServingOwnerDiagnostic); + Assert.Equal(2, inspections); + return Ok(); + } + } + + [Theory] + [InlineData(GatewayEndpointProvenanceKind.UnknownListener)] + [InlineData(GatewayEndpointProvenanceKind.ConflictingOpenClawGateway)] + public async Task SetupWizard_RestartOwnerGapDoesNotRetryAnUntrustedListener( + GatewayEndpointProvenanceKind kind) + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command.Contains("config set gateway.reload.mode") + ? Ok() + : Fail(SetupWizardRunner.RestartServingOwnerDiagnostic)); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + ctx.EndpointProvenanceProbe = (_, _) => Task.FromResult( + new GatewayEndpointProvenance(kind, ctx.Config.GatewayPort)); + + var result = await new SetupWizardRunner(ctx).RestoreReloadModeAsync(); + + Assert.False(result.IsSuccess); + Assert.Contains("ownership verification failed", result.Message); + Assert.Single(commands.WslCalls, call => call.Command.Contains("openclaw gateway restart")); + } + + [Theory] + [InlineData(SetupWizardRunner.RestartServingOwnerDiagnostic, 2)] + [InlineData("GATEWAY_RESTART_PREPARATION_REFUSED: Cannot verify the selected service command.", 1)] + [InlineData("StateDatabaseCoordinatorContentionError: another OpenClaw process owns state-lifecycle. GATEWAY_RESTART_PREPARATION_REFUSED: Cannot record restart intent for the serving Gateway. Gateway was not signaled.", 1)] + [InlineData("Unrelated restart failure", 1)] + public async Task SetupWizard_RestartRetryRemainsBoundedAndSpecific(string error, int expectedRestarts) + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command.Contains("config set gateway.reload.mode") ? Ok() : Fail(error)); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + TrustManagedEndpoint(ctx); + + var result = await new SetupWizardRunner(ctx).RestoreReloadModeAsync(); + + Assert.False(result.IsSuccess); + Assert.Contains(error, result.Message); + Assert.Equal(expectedRestarts, + commands.WslCalls.Count(call => call.Command.Contains("openclaw gateway restart"))); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("systemctl")); + } + [Fact] public async Task SetupWizard_RestoreReloadModeRetriesExactStartupMigrationLeaseContention() {