diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1b27138f1..1700080c3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -82,7 +82,7 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. | `src/OpenClaw.Shared/Models.cs` | per-domain model files + `*Mapper` classes | | `src/OpenClaw.Shared/Capabilities/SystemCapability.cs` | `ExecApprovalService` | | `src/OpenClaw.Connection/GatewayConnectionManager.cs` | `NodeConnectionCoordinator`, `BootstrapTokenLifecycle`, `DevicePairApprovalCoordinator` | -| `src/OpenClaw.SetupEngine/SetupSteps.cs` | one file per step; `WslShellClient`, `GatewayConfigScriptBuilder`, `KeepaliveProcessManager`. WSL/POSIX quoting is done — use `WslShellQuoting`, never a local `ShellEscape`. | +| `src/OpenClaw.SetupEngine/SetupSteps.cs` | one file per step (done for the steps still referencing this file); `WslShellClient`, `GatewayConfigScriptBuilder` still pending. WSL/POSIX quoting is done — use `WslShellQuoting`, never a local `ShellEscape`. Setup-time keepalive process ownership already extracted to `KeepaliveProcessManager` (see `setup-keepalive-process-manager`). | | Any test hand-rolling a temp dir / env save-restore / CLI capture | `OpenClaw.TestSupport` fixtures | ## Ledger @@ -117,7 +117,8 @@ leading and trailing pipe. Columns, in order: | test-settings-builder | authoritative | scattered test files | ad hoc SettingsData construction in migrated tests | OpenClaw.TestSupport.SettingsDataBuilder | pre-existing un-migrated tests until adopted | settings test data starts from production defaults | TestSupportFixtureTests.SettingsDataBuilder_StartsFromDefaults | behavioral | when settings tests adopt the builder | | json-read-helpers | authoritative | OpenClaw.Shared (multiple files) | duplicate non-nullable fallback-returning JsonElement getters | JsonReadHelpers | null-sentinel / non-negative / whitespace-absent / trimming variants stay separate | canonical non-nullable fallback JSON coercion; divergent-contract helpers are not blindly routed here | JsonReadHelpersTests.GetString_ReturnsNull_WhenPropertyMissing | behavioral | when the non-nullable fallback getters are all routed here | | wsl-posix-quoting | authoritative | OpenClaw.SetupEngine/SetupSteps.cs | ad hoc ShellEscape with divergent wrap semantics | WslShellQuoting | - | WSL command lines use POSIX single-quote quoting via WslShellQuoting not cmd/PowerShell quoting | WslShellQuotingTests.QuotePosixSingleQuote_WrapsAndEscapesEmbeddedQuote | behavioral | when no code builds WSL command lines outside WslShellQuoting | -| setup-shellescape-closed | closed | src/OpenClaw.SetupEngine/SetupSteps.cs | private ShellEscape helpers with divergent wrap semantics | WslShellQuoting | - | SetupSteps builds WSL command lines only via WslShellQuoting; no local ShellEscape helper | SetupStepsShellEscapeClosureTests.SetupSteps_DoesNotReintroduce_PrivateShellEscape | source-shape | when SetupSteps.cs no longer builds any WSL command strings | +| setup-shellescape-closed | closed | src/OpenClaw.SetupEngine/SetupSteps.cs | private ShellEscape helpers with divergent wrap semantics | WslShellQuoting | - | OpenClaw.SetupEngine builds WSL command lines only via WslShellQuoting; no local ShellEscape helper anywhere in the project | SetupStepsShellEscapeClosureTests.SetupEngine_DoesNotReintroduce_PrivateShellEscape | source-shape | when no file under src/OpenClaw.SetupEngine builds any WSL command strings | +| setup-keepalive-process-manager | authoritative | src/OpenClaw.SetupEngine/SetupSteps.cs (StartKeepaliveStep) | setup-time WSL keepalive process discovery, start, marker read/write, command-line identity, and rollback cleanup | KeepaliveProcessManager (raw OS calls delegated to internal IKeepaliveProcessRuntime seam; StartKeepaliveStep is the only caller that reads SetupContext) | StartKeepaliveStep keeps Id/DisplayName and thin ExecuteAsync/RollbackAsync orchestration only | setup-time keepalive never hard-fails the pipeline on start failure (null PID or thrown exception both soft-fail identically); its marker path/JSON are the intentional handoff consumed by the tray keepalive service; rollback kills only wsl/wsl.exe processes whose command line matches this distro via WslCommandLineMatcher, leaves wrong-distro/unmatched command lines untouched, and deletes only its own marker/empty directory | KeepaliveProcessManagerTests.RollbackAsync_KillsOnlyMatchingDistroProcesses_LeavesOthersUntouched | behavioral | when StartKeepaliveStep contains no process/marker logic of its own | | wsl-distro-install-path | authoritative | OpenClaw.SetupEngine/SetupSteps.cs | inline Path.Combine wsl distro install-path derivation | DistroInstallPathPolicy | - | new installs use the strict supported name grammar; teardown accepts only unambiguous single-segment names whose canonical path is an immediate child of LocalDataDir\wsl with no aliases, case or Unicode collisions, or reparse points at the root or child | SetupStepsTests.DistroInstallPathPolicy_ResolvesImmediateChild | behavioral | - | | managed-local-provenance | authoritative | scattered connection, setup, browser, and reconnect call sites | implicit loopback trust and duplicated strong-credential listener checks | ManagedLocalGatewayPortProvenanceService | callers request inspection, authorization, or conflict repair only | unknown or changed listener owners never receive strong credentials or destructive remediation | ManagedLocalGatewayPortProvenanceServiceTests.InteractiveCredentialGate_ExpectedCacheThenOwnerChanges_FailsClosed | behavioral | - | | managed-local-repair | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs and direct reconnect callbacks | repair eligibility, restart budgets, port remediation, and reconnect verification | ManagedLocalGatewayAutoRepairMonitor + ManagedLocalGatewayRepairCoordinator | App composition and dependency callbacks only | explicit disconnect and gateway switches abort repair before restart or reconnect | ManagedLocalGatewayRepairCoordinatorTests.UserDisconnectedIntent_AbortsBeforeProbeOrRestart | behavioral | - | diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index f759dbd6b..d79722af2 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -53,7 +53,13 @@ src/OpenClaw.SetupEngine/ ├── Program.cs # callable entry: --config, --headless, --dry-run, --rollback-on-failure ├── SetupPipeline.cs # Sequential step orchestrator (132 lines) ├── SetupContext.cs # Config model + shared state bag (217 lines) -├── SetupSteps.cs # All setup step implementations +├── SetupSteps.cs # Shared setup-engine helpers (WslConstants, WslInstallSupport, +│ # SetupOpenClawLogger, SetupPairingCredentialPolicy, +│ # WindowsGatewayReachability); one file per step class lives +│ # alongside it (e.g. CreateWslInstanceStep.cs, +│ # ConfigureGatewayStep.cs, StartKeepaliveStep.cs, ...) +├── KeepaliveProcessManager.cs # Setup-time WSL keepalive process/marker/rollback owner +├── TailscaleSetupSteps.cs # The 4 Tailscale setup steps, grouped ├── TransactionJournal.cs # Append-only JSONL journal (77 lines) ├── SetupLogger.cs # Structured JSONL logger (112 lines) ├── CommandRunner.cs # Concrete WSL/process command runner @@ -73,7 +79,9 @@ src/OpenClaw.SetupEngine.UI/ └── CompletePage.xaml / .cs # Mascot status badge, summary, startup toggle ``` -**Total engine code: ~1,882 lines across 8 files.** UI adds ~10 more files. +The pipeline runs 24 steps (see `SetupStepFactory.BuildDefaultSteps()` in `SetupPipeline.cs` for +the authoritative order — this doc's step table below predates the 4 Tailscale steps and is not +fully current). UI adds ~10 more files. --- @@ -181,7 +189,12 @@ rerun setup with a supported new name. ## Pipeline Steps (19 total) -Executed sequentially. Each step is a small class (30–120 lines) in `SetupSteps.cs`. +> Note: this table predates the 4 Tailscale setup steps; the current pipeline runs 24 steps +> total. See `SetupStepFactory.BuildDefaultSteps()` in `SetupPipeline.cs` for the authoritative, +> current order. Fixing this table fully is out of scope for the E0 file-split PR. + +Executed sequentially. Each step is a small class (30–120 lines) in its own file under +`src/OpenClaw.SetupEngine/` (e.g. `PreflightOsStep.cs`). | # | Step Class | What It Does | |---|-----------|-------------| diff --git a/docs/WSL_EXE_ARGV_PITFALL.md b/docs/WSL_EXE_ARGV_PITFALL.md index aee6d6977..7b529be4d 100644 --- a/docs/WSL_EXE_ARGV_PITFALL.md +++ b/docs/WSL_EXE_ARGV_PITFALL.md @@ -92,7 +92,7 @@ await commandRunner.RunInWslAsync( ### 2. C#-interpolate every value into the script string -Do not store values in Bash variables; bake the values into the script literally. This is the workaround used by `src/OpenClaw.SetupEngine/SetupSteps.cs:936-945` in `ValidateWslLockdownStep`. It is acceptable for short scripts with a small fixed value set and no spaces in values. +Do not store values in Bash variables; bake the values into the script literally. This is the workaround used by `src/OpenClaw.SetupEngine/ValidateWslLockdownStep.cs:55-59` in `ValidateWslLockdownStep`. It is acceptable for short scripts with a small fixed value set and no spaces in values. ```csharp var workspace = "/home/openclaw/.openclaw/workspace"; @@ -126,8 +126,8 @@ All of these failed workarounds were verified empirically: ## Where this matters in the codebase - `src/OpenClaw.SetupEngine/CommandRunner.cs` — `RunInWslAsync` exposes the opt-in `inputViaStdin` parameter. -- `src/OpenClaw.SetupEngine/SetupSteps.cs:936-945` — `ValidateWslLockdownStep` uses workaround #2, C# interpolation. -- `src/OpenClaw.SetupEngine/SetupSteps.cs` `WindowsNodeBootstrapContextStep` — uses workaround #1, stdin. +- `src/OpenClaw.SetupEngine/ValidateWslLockdownStep.cs:55-59` — `ValidateWslLockdownStep` uses workaround #2, C# interpolation. +- `src/OpenClaw.SetupEngine/WindowsNodeBootstrapContextStep.cs` — `WindowsNodeBootstrapContextStep` uses workaround #1, stdin. ## Related diff --git a/src/OpenClaw.SetupEngine/CleanupStaleDistroStep.cs b/src/OpenClaw.SetupEngine/CleanupStaleDistroStep.cs new file mode 100644 index 000000000..a1bbc2251 --- /dev/null +++ b/src/OpenClaw.SetupEngine/CleanupStaleDistroStep.cs @@ -0,0 +1,160 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text.Json; +using OpenClaw.Connection; +using OpenClaw.Shared; + +namespace OpenClaw.SetupEngine; + + +public sealed class CleanupStaleDistroStep : SetupStep +{ + public override string Id => "cleanup-distro"; + public override string DisplayName => "Clean up stale WSL distro"; + public override bool CanRetry => false; + + public override bool CanSkip(SetupContext ctx) => !ctx.Config.CleanBeforeRun; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + if (!DistroInstallPathPolicy.TryGetManagedInstallPath(ctx.LocalDataDir, distro, out var wslDir, out var pathError)) + return StepResult.Terminal(pathError); + + var list = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--list", "--quiet"], TimeSpan.FromSeconds(15), ct: ct); + if (list.ExitCode != 0) + return StepResult.Ok("WSL not available or no distros - nothing to clean"); + + var distros = WslInstallSupport.ParseQuietDistroList(list.Stdout); + + ctx.Logger.Debug($"Found WSL distros: [{string.Join(", ", distros)}]"); + + if (!distros.Any(d => d.Equals(distro, StringComparison.OrdinalIgnoreCase))) + { + // Distro not registered, but disk directory may still exist from prior crash + if (Directory.Exists(wslDir)) + { + ctx.Logger.Info($"Removing orphaned WSL directory: {wslDir}"); + var delete = await DeleteDistroDirectoryWithRetries(ctx, distro, wslDir, ct); + if (!delete.IsSuccess) + return delete; + } + ctx.Logger.Decision("No stale distro found", "skip cleanup"); + return StepResult.Ok("No stale distro to clean"); + } + + ctx.Logger.Decision($"Found existing distro '{distro}'", "terminating and unregistering"); + + // Stop only the app-owned distro. Global WSL shutdown would disrupt unrelated distros. + await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--terminate", distro], TimeSpan.FromSeconds(30), ct: ct); + await Task.Delay(2000, ct); // Let port release + + var unregister = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--unregister", distro], TimeSpan.FromSeconds(60), ct: ct); + if (unregister.ExitCode != 0) + { + ctx.Logger.Warn($"First unregister attempt failed (exit {unregister.ExitCode}); retrying targeted termination"); + await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--terminate", distro], TimeSpan.FromSeconds(30), ct: ct); + await Task.Delay(3000, ct); + unregister = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--unregister", distro], TimeSpan.FromSeconds(60), ct: ct); + } + + if (unregister.ExitCode == 0) + { + // Also remove the on-disk WSL vhdx directory (--import fails if it exists) + var delete = await DeleteDistroDirectoryWithRetries(ctx, distro, wslDir, ct); + if (!delete.IsSuccess) + return delete; + + // Wait for port to be released + ctx.Logger.Info("Waiting for port release after distro termination..."); + await PreflightPortStep.WaitForPortFreeAsync(ctx.Config.GatewayPort, ctx.Config.Gateway.Bind, ctx.Logger, ct); + return StepResult.Ok($"Unregistered stale distro '{distro}'"); + } + + return StepResult.Fail($"Failed to unregister distro: {unregister.Stderr}"); + } + + internal static async Task DeleteDistroDirectoryWithRetries( + SetupContext ctx, + string distroName, + string wslDir, + CancellationToken ct) + { + var deletePath = wslDir; + Exception? lastError = null; + + for (var attempt = 0; attempt < 4; attempt++) + { + if (!DistroInstallPathPolicy.TryValidateDeleteTarget( + ctx.LocalDataDir, + distroName, + wslDir, + out deletePath, + out var pathError)) + { + return StepResult.Terminal(pathError); + } + + try + { + if (File.Exists(deletePath)) + { + if (File.GetAttributes(deletePath).HasFlag(FileAttributes.ReparsePoint)) + return StepResult.Fail($"App-owned WSL path '{deletePath}' is a reparse point; remove it manually and retry setup."); + + ctx.Logger.Info($"Removing app-owned WSL file at install path: {deletePath}"); + File.Delete(deletePath); + } + else if (Directory.Exists(deletePath)) + { + if (new DirectoryInfo(deletePath).Attributes.HasFlag(FileAttributes.ReparsePoint)) + return StepResult.Fail($"App-owned WSL directory '{deletePath}' is a reparse point; remove it manually and retry setup."); + + ctx.Logger.Info($"Removing app-owned WSL directory: {deletePath}"); + Directory.Delete(deletePath, recursive: true); + } + + var parent = Path.GetDirectoryName(deletePath); + if (!string.IsNullOrWhiteSpace(parent) && + Directory.Exists(parent) && + !new DirectoryInfo(parent).Attributes.HasFlag(FileAttributes.ReparsePoint) && + !Directory.EnumerateFileSystemEntries(parent).Any()) + { + Directory.Delete(parent); + ctx.Logger.Info("Deleted empty wsl\\ parent directory"); + } + + return StepResult.Ok("WSL directory removed"); + } + catch (DirectoryNotFoundException) + { + return StepResult.Ok("WSL directory already absent"); + } + catch (IOException ex) + { + lastError = ex; + if (attempt >= 3) + break; + + ctx.Logger.Warn($"VHD directory still locked, retrying in {(attempt + 1) * 2}s..."); + await Task.Delay(TimeSpan.FromSeconds((attempt + 1) * 2), ct); + } + catch (UnauthorizedAccessException ex) + { + lastError = ex; + if (attempt >= 3) + break; + + ctx.Logger.Warn($"VHD directory access denied, retrying in {(attempt + 1) * 2}s..."); + await Task.Delay(TimeSpan.FromSeconds((attempt + 1) * 2), ct); + } + } + + return StepResult.Fail( + $"Failed to remove app-owned WSL directory '{deletePath}'. Close any process using the OpenClaw WSL distro and retry setup." + + (lastError is null ? "" : $" Last error: {lastError.Message}")); + } +} diff --git a/src/OpenClaw.SetupEngine/CleanupStaleGatewayStep.cs b/src/OpenClaw.SetupEngine/CleanupStaleGatewayStep.cs new file mode 100644 index 000000000..c9d260ab0 --- /dev/null +++ b/src/OpenClaw.SetupEngine/CleanupStaleGatewayStep.cs @@ -0,0 +1,89 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text.Json; +using OpenClaw.Connection; +using OpenClaw.Shared; + +namespace OpenClaw.SetupEngine; + + +public sealed class CleanupStaleGatewayStep : SetupStep +{ + public override string Id => "cleanup-gateway"; + public override string DisplayName => "Clean up stale gateway state"; + public override bool CanRetry => false; + + public override bool CanSkip(SetupContext ctx) => !ctx.Config.CleanBeforeRun; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + // Remove stale setup-state.json from AppData (legacy location) + var stateFile = Path.Combine(ctx.DataDir, "setup-state.json"); + if (File.Exists(stateFile)) + { + File.Delete(stateFile); + ctx.Logger.Info("Deleted stale setup-state.json (AppData)"); + } + + // Also remove from LocalAppData (current write location) + var localStateFile = Path.Combine(ctx.LocalDataDir, "setup-state.json"); + if (File.Exists(localStateFile)) + { + File.Delete(localStateFile); + ctx.Logger.Info("Deleted stale setup-state.json (LocalAppData)"); + } + + // Remove stale gateway record for our local URL if it exists + var registry = new GatewayRegistry(ctx.DataDir, logger: new SetupOpenClawLogger(ctx.Logger)); + registry.Load(); + var existing = registry.FindByUrl(ctx.GatewayUrl!); + if (existing != null) + { + // Preserve non-local records and SSH-tunneled gateways — they may be + // remote gateways that happen to use localhost as a forwarded port. + if (!PairOperatorStep.IsSetupManagedLocalRecord(existing, ctx)) + { + ctx.Logger.Warn($"Skipping cleanup of gateway record {existing.Id}: " + + "not a SetupEngine-managed local gateway"); + } + else + { + // Clean identity directory + var identityDir = registry.GetIdentityDirectory(existing.Id); + if (Directory.Exists(identityDir)) + { + Directory.Delete(identityDir, recursive: true); + ctx.Logger.Info($"Deleted stale identity directory: {identityDir}"); + } + registry.Remove(existing.Id); + registry.Save(); + ctx.Logger.Info($"Removed stale gateway record for {ctx.GatewayUrl}"); + } + } + + await Task.CompletedTask; + return StepResult.Ok("Gateway state cleaned"); + } + + public override Task RollbackAsync(SetupContext ctx, CancellationToken ct) + { + // Delete setup-state.json (written by VerifyEndToEndStep) + var localDataPath = ctx.LocalDataDir; + + var stateFile = Path.Combine(localDataPath, "setup-state.json"); + if (File.Exists(stateFile)) + { + File.Delete(stateFile); + ctx.Logger.Info("[Uninstall] Deleted setup-state.json"); + } + else + { + ctx.Logger.Info("[Uninstall] setup-state.json already absent"); + } + + return Task.CompletedTask; + } +} diff --git a/src/OpenClaw.SetupEngine/ConfigureGatewayStep.cs b/src/OpenClaw.SetupEngine/ConfigureGatewayStep.cs new file mode 100644 index 000000000..963e80f58 --- /dev/null +++ b/src/OpenClaw.SetupEngine/ConfigureGatewayStep.cs @@ -0,0 +1,186 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text.Json; +using OpenClaw.Connection; +using OpenClaw.Shared; + +namespace OpenClaw.SetupEngine; + + +public sealed class ConfigureGatewayStep : SetupStep +{ + internal const string DevicePairPublicUrlKey = "plugins.entries.device-pair.config.publicUrl"; + internal const string DevicePairEnabledKey = "plugins.entries.device-pair.enabled"; + // Each `openclaw config set` emitted below spawns the Node CLI fresh inside WSL; on a + // newly created distro with a cold cache that is ~4-5s apiece. Budget the step by how + // many config commands we actually emit -- BuildConfigCommands grows with the + // device-pair keys and every Gateway.ExtraConfig entry -- with a floor so the minimal + // path keeps generous headroom. A fixed cap silently regresses as the list grows. + internal static readonly TimeSpan ConfigBaseBudget = TimeSpan.FromSeconds(45); + internal static readonly TimeSpan PerConfigCommandBudget = TimeSpan.FromSeconds(15); + internal static readonly TimeSpan MinConfigurationTimeout = TimeSpan.FromSeconds(180); + + public override string Id => "configure-gateway"; + public override string DisplayName => "Configure gateway"; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + var port = ctx.Config.GatewayPort; + var gw = ctx.Config.Gateway; + + // Validate bind value — Tailscale Serve deliberately keeps the gateway loopback-bound. + if (gw.Bind is not ("loopback" or "lan")) + return StepResult.Terminal($"Invalid Gateway.Bind value '{gw.Bind}'. Must be 'loopback' or 'lan'."); + if (TailscaleSetupPolicy.ValidateConfig(ctx.Config) is { } tailscaleConfigError) + return StepResult.Terminal(tailscaleConfigError); + + // Generate a shared gateway token + var token = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N"); + ctx.SharedGatewayToken = token; + var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = token }; + + var allowedCommandsJson = JsonSerializer.Serialize(ctx.Config.Capabilities.GetEnabledCommandIds()); + var escapedAllowedCommands = WslShellQuoting.QuotePosixSingleQuote(allowedCommandsJson); + var extraConfigOverridesAllowCommands = gw.ExtraConfig?.ContainsKey("gateway.nodes.allowCommands") == true; + if (gw.ExtraConfig is { Count: > 0 }) + { + foreach (var key in gw.ExtraConfig.Keys) + { + if (!IsSafeExtraConfigKey(key)) + return StepResult.Fail($"Invalid Gateway.ExtraConfig key '{key}'. Keys may contain only letters, digits, '.', '_', and '-'."); + } + } + + var configCommands = BuildConfigCommands(gw, port, escapedAllowedCommands, ctx.Config.Tailscale); + + ctx.Logger.Info($"Gateway node allowCommands derived from setup capabilities: {allowedCommandsJson}"); + if (extraConfigOverridesAllowCommands) + ctx.Logger.Warn("Gateway.ExtraConfig overrides derived gateway.nodes.allowCommands"); + if (GetDefaultDevicePairPublicUrl(gw, port, ctx.Config.Tailscale.Enabled) is { } defaultPublicUrl && + gw.ExtraConfig?.ContainsKey(DevicePairPublicUrlKey) != true) + { + ctx.Logger.Info($"Configured device-pair public URL for loopback gateway: {defaultPublicUrl}"); + } + + var pathPrefix = ctx.WslPathPrefix; + var script = $""" + set -e + {pathPrefix} + + {configCommands} + + echo "GATEWAY_CONFIGURED" + """; + + var timeout = ComputeConfigurationTimeout(configCommands); + var result = await ctx.Commands.RunInWslAsync(distro, script, timeout, env, ct); + + if (result.ExitCode != 0 || !result.Stdout.Contains("GATEWAY_CONFIGURED")) + { + if (result.TimedOut) + return StepResult.Fail( + $"Gateway configuration timed out after {timeout.TotalSeconds:0}s while running openclaw config inside WSL."); + + return StepResult.Fail($"Gateway configuration failed (exit {result.ExitCode}): {result.Stderr}"); + } + + ctx.Logger.StateChange("shared_gateway_token", null, "[SET]"); + return StepResult.Ok("Gateway configured"); + } + + internal static string BuildConfigCommands( + GatewayConfig gw, + int port, + string escapedAllowedCommands, + TailscaleConfig? tailscale = null) + { + var configCommands = $""" + openclaw config set gateway.mode local + openclaw config set gateway.port {port} + openclaw config set gateway.bind {gw.Bind} + openclaw config set gateway.auth.mode {gw.AuthMode} + openclaw config set gateway.auth.token "$OPENCLAW_GATEWAY_TOKEN" + openclaw config set gateway.reload.mode {gw.ReloadMode} + openclaw config set gateway.nodes.allowCommands {escapedAllowedCommands} + """; + + if (tailscale?.Enabled == true) + { + var trustTailscaleAuth = tailscale.TrustTailscaleAuth ? "true" : "false"; + configCommands += $""" + + openclaw config set gateway.tailscale.mode off + openclaw config set gateway.auth.allowTailscale {trustTailscaleAuth} + """; + } + + if (GetDefaultDevicePairPublicUrl(gw, port, tailscale?.Enabled == true) is { } defaultPublicUrl && + gw.ExtraConfig?.ContainsKey(DevicePairPublicUrlKey) != true) + { + configCommands += $"\n openclaw config set {DevicePairPublicUrlKey} {WslShellQuoting.QuotePosixSingleQuote(defaultPublicUrl)}"; + } + + // The gateway ships the `device-pair` plugin bundled but DISABLED by default. + // Without it, every scope-upgrade / role-upgrade WS connect (how OAuth providers like + // Codex request the broader scopes needed to start their auth flow) hangs in + // "pending approval" forever. The provider CLI errors out before ever printing its + // verification URL, leaving the wizard stuck. Enable the plugin whenever we know how + // to reach it (i.e. we either wrote the default loopback URL above, or the user + // supplied their own publicUrl via ExtraConfig). + var hasDevicePairPublicUrl = + GetDefaultDevicePairPublicUrl(gw, port, tailscale?.Enabled == true) is not null || + gw.ExtraConfig?.ContainsKey(DevicePairPublicUrlKey) == true; + var devicePairExplicitlyConfigured = + gw.ExtraConfig?.ContainsKey(DevicePairEnabledKey) == true; + if (hasDevicePairPublicUrl && !devicePairExplicitlyConfigured) + { + configCommands += $"\n openclaw config set {DevicePairEnabledKey} true"; + } + + // Apply any extra config key/value pairs from config (shell-escape values) + if (gw.ExtraConfig is { Count: > 0 }) + { + foreach (var (key, value) in gw.ExtraConfig) + { + if (!IsSafeExtraConfigKey(key)) + throw new ArgumentException($"Invalid Gateway.ExtraConfig key '{key}'. Keys may contain only letters, digits, '.', '_', and '-'.", nameof(gw)); + + var escapedValue = WslShellQuoting.QuotePosixSingleQuote(value); + configCommands += $"\n openclaw config set {key} {escapedValue}"; + } + } + + return configCommands; + } + + // Budget = base + per-command, floored. Scales the WSL timeout with the number of + // `openclaw config set` invocations the step emits so it cannot silently regress as + // BuildConfigCommands grows. + internal static TimeSpan ComputeConfigurationTimeout(string configCommands) + { + var budget = ConfigBaseBudget + PerConfigCommandBudget * CountConfigSetCommands(configCommands); + return budget > MinConfigurationTimeout ? budget : MinConfigurationTimeout; + } + + private static int CountConfigSetCommands(string configCommands) + { + var count = 0; + foreach (var line in configCommands.Split('\n')) + { + if (line.Contains("openclaw config set", StringComparison.Ordinal)) + count++; + } + + return count; + } + + internal static string? GetDefaultDevicePairPublicUrl(GatewayConfig gw, int port, bool tailscaleEnabled = false) => + gw.Bind == "loopback" && !tailscaleEnabled ? $"http://127.0.0.1:{port}" : null; + + internal static bool IsSafeExtraConfigKey(string value) + => System.Text.RegularExpressions.Regex.IsMatch(value, "^[A-Za-z0-9._-]+$"); +} diff --git a/src/OpenClaw.SetupEngine/ConfigureWslInstanceStep.cs b/src/OpenClaw.SetupEngine/ConfigureWslInstanceStep.cs new file mode 100644 index 000000000..a96fc519a --- /dev/null +++ b/src/OpenClaw.SetupEngine/ConfigureWslInstanceStep.cs @@ -0,0 +1,86 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text.Json; +using OpenClaw.Connection; +using OpenClaw.Shared; + +namespace OpenClaw.SetupEngine; + + +public sealed class ConfigureWslInstanceStep : SetupStep +{ + public override string Id => "wsl-configure"; + public override string DisplayName => "Configure WSL instance"; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + var wsl = ctx.Config.Wsl; + + if (!WslConfig.IsValidLinuxUserName(wsl.User)) + return StepResult.Terminal($"Invalid WSL user '{wsl.User}'. Use a Linux username matching [a-z_][a-z0-9_-]{{0,31}}."); + + // Build wsl.conf from config + var wslConf = $""" +[boot] +systemd={wsl.Systemd.ToString().ToLower()} + +[automount] +enabled={wsl.Automount.ToString().ToLower()} +mountFsTab={wsl.MountFsTab.ToString().ToLower()} + +[interop] +enabled={wsl.Interop.ToString().ToLower()} +appendWindowsPath={wsl.AppendWindowsPath.ToString().ToLower()} + +[user] +default={wsl.User} + +[time] +useWindowsTimezone={wsl.UseWindowsTimezone.ToString().ToLower()} +"""; + + // Create user and directories + var script = $""" + set -e + + # Create user if not exists + if ! id -u {wsl.User} &>/dev/null; then + useradd -m -s /bin/bash {wsl.User} + fi + + # Create required directories + mkdir -p /home/{wsl.User}/.openclaw + mkdir -p /var/lib/openclaw + mkdir -p /var/log/openclaw + mkdir -p /opt/openclaw + + chown -R {wsl.User}:{wsl.User} /home/{wsl.User}/.openclaw + chown -R {wsl.User}:{wsl.User} /var/lib/openclaw + chown -R {wsl.User}:{wsl.User} /var/log/openclaw + chown -R {wsl.User}:{wsl.User} /opt/openclaw + + # Write wsl.conf + cat > /etc/wsl.conf << 'WSLCONF' + {wslConf} + WSLCONF + + echo "CONFIGURED_OK" + """; + + var result = await ctx.Commands.RunInWslAsync(distro, script, TimeSpan.FromSeconds(60), ct: ct, user: "root"); + + if (result.ExitCode != 0 || !result.Stdout.Contains("CONFIGURED_OK")) + return StepResult.Fail($"Configuration failed: {result.Stderr}"); + + // Restart WSL to apply wsl.conf (systemd) + ctx.Logger.Info("Restarting WSL to apply configuration (systemd)"); + await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--terminate", distro], TimeSpan.FromSeconds(30), ct: ct); + await Task.Delay(2000, ct); // Let WSL settle + + return StepResult.Ok("WSL instance configured"); + } +} diff --git a/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs b/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs new file mode 100644 index 000000000..8deef80bd --- /dev/null +++ b/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs @@ -0,0 +1,236 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text.Json; +using OpenClaw.Connection; +using OpenClaw.Shared; + +namespace OpenClaw.SetupEngine; + + +public sealed class CreateWslInstanceStep : SetupStep +{ + public override string Id => "wsl-create"; + public override string DisplayName => "Create WSL instance"; + public override bool CanRetry => false; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + var baseDistro = ctx.Config.BaseDistro.Trim(); + + if (string.IsNullOrWhiteSpace(baseDistro)) + return StepResult.Terminal("BaseDistro is required for fresh WSL gateway setup."); + + if (!DistroInstallPathPolicy.TryGetNewInstallPath(ctx.LocalDataDir, distro, out var installPath, out var pathError)) + return StepResult.Terminal(pathError); + + ctx.Logger.Info($"Creating clean app-owned WSL distro '{distro}' from '{baseDistro}' at '{installPath}'"); + + var existing = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--list", "--quiet"], TimeSpan.FromSeconds(15), ct: ct); + if (existing.ExitCode != 0) + return StepResult.Fail($"Failed to list WSL distros before creating '{distro}': {existing.Stderr}"); + + if (WslInstallSupport.ContainsDistro(existing.Stdout, distro)) + return StepResult.Fail($"Target WSL distro '{distro}' still exists after cleanup; refusing to create a new gateway over unknown state."); + + var pathCheck = EnsureInstallPathReady(installPath); + if (!pathCheck.IsSuccess) + return pathCheck; + + Directory.CreateDirectory(Path.GetDirectoryName(installPath)!); + + 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( + WslConstants.WslExePath, + installArgs, + TimeSpan.FromMinutes(15), + ct: ct); + + if (install.ExitCode != 0) + { + var cleanupError = await CleanupPartialInstall(ctx, distro, installPath, ct); + return StepResult.Fail( + $"Fresh WSL install failed for '{distro}' from '{baseDistro}' (exit {install.ExitCode}): {FirstNonEmpty(install.Stderr, install.Stdout)}{cleanupError}"); + } + + var verify = await VerifyFreshDistro(ctx, distro, installPath, ct); + if (!verify.IsSuccess) + { + var cleanupError = await CleanupPartialInstall(ctx, distro, installPath, ct); + return StepResult.Fail($"{verify.Message}{cleanupError}"); + } + + return verify; + } + + private static StepResult EnsureInstallPathReady(string installPath) + { + if (File.Exists(installPath)) + { + if (File.GetAttributes(installPath).HasFlag(FileAttributes.ReparsePoint)) + return StepResult.Fail($"App-owned WSL install path '{installPath}' is a reparse point; remove it manually and retry setup."); + + File.Delete(installPath); + return StepResult.Ok(); + } + + if (!Directory.Exists(installPath)) + return StepResult.Ok(); + + if (new DirectoryInfo(installPath).Attributes.HasFlag(FileAttributes.ReparsePoint)) + return StepResult.Fail($"App-owned WSL install directory '{installPath}' is a reparse point; remove it manually and retry setup."); + + if (Directory.EnumerateFileSystemEntries(installPath).Any()) + { + return StepResult.Fail( + $"App-owned WSL install directory '{installPath}' still contains files after cleanup; refusing to create a new gateway over unknown state."); + } + + Directory.Delete(installPath); + return StepResult.Ok(); + } + + private static async Task VerifyFreshDistro(SetupContext ctx, string distro, string installPath, CancellationToken ct) + { + var list = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--list", "--quiet"], TimeSpan.FromSeconds(15), ct: ct); + if (list.ExitCode != 0 || !WslInstallSupport.ContainsDistro(list.Stdout, distro)) + { + var environmentIssue = await PreflightWslStep.DetectEnvironmentIssueAsync(ctx, ct); + var baseMessage = $"Fresh WSL install did not register expected distro '{distro}'."; + return StepResult.Fail(environmentIssue != null ? $"{baseMessage} {environmentIssue}" : baseMessage); + } + + var verbose = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--list", "--verbose"], TimeSpan.FromSeconds(15), ct: ct); + if (verbose.ExitCode != 0 || !WslInstallSupport.TryGetDistroVersion(verbose.Stdout, distro, out var version)) + return StepResult.Fail($"Fresh WSL install registered '{distro}', but setup could not verify it is WSL2."); + + if (version != 2) + return StepResult.Fail($"Fresh WSL install registered '{distro}' as WSL{version}; WSL2 is required."); + + var probe = await ctx.Commands.RunAsync( + WslConstants.WslExePath, + ["-d", distro, "-u", "root", "--", "sh", "-lc", "id -u && test -d / && echo OPENCLAW_FRESH_WSL_READY"], + TimeSpan.FromSeconds(30), + ct: ct); + + if (probe.ExitCode != 0 || !probe.Stdout.Contains("OPENCLAW_FRESH_WSL_READY", StringComparison.Ordinal)) + return StepResult.Fail($"Fresh WSL distro '{distro}' could not run a root verification command: {FirstNonEmpty(probe.Stderr, probe.Stdout)}"); + + return StepResult.Ok($"Created clean WSL2 distro '{distro}' at '{installPath}'"); + } + + private static async Task CleanupPartialInstall(SetupContext ctx, string distro, string installPath, CancellationToken ct) + { + var cleanupErrors = new List(); + var installPathExists = Directory.Exists(installPath) || File.Exists(installPath); + var list = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--list", "--quiet"], TimeSpan.FromSeconds(15), ct: ct); + var registrationStateKnown = list.ExitCode == 0; + var distroExists = registrationStateKnown && WslInstallSupport.ContainsDistro(list.Stdout, distro); + var canDeleteInstallPath = registrationStateKnown && !distroExists; + + 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"); + canDeleteInstallPath = await TryUnregisterPartialInstall(ctx, distro, cleanupErrors, ct); + } + else if (distroExists) + { + canDeleteInstallPath = await TryUnregisterPartialInstall(ctx, distro, cleanupErrors, ct); + } + + if (!canDeleteInstallPath) + { + if (!registrationStateKnown) + { + cleanupErrors.Insert(0, + $"could not confirm whether distro '{distro}' is still registered: {FirstNonEmpty(list.Stderr, list.Stdout)}"); + } + + if (installPathExists) + { + cleanupErrors.Add( + $"skipped deleting app-owned install path '{installPath}' until distro '{distro}' is confirmed unregistered"); + } + } + else if (installPathExists) + { + var delete = await CleanupStaleDistroStep.DeleteDistroDirectoryWithRetries(ctx, distro, installPath, ct); + if (!delete.IsSuccess) + cleanupErrors.Add(delete.Message ?? "install directory cleanup failed"); + } + + return cleanupErrors.Count == 0 + ? "" + : $" Partial app-owned distro cleanup also failed: {string.Join("; ", cleanupErrors)}"; + } + + private static async Task TryUnregisterPartialInstall(SetupContext ctx, string distro, List cleanupErrors, CancellationToken ct) + { + var terminate = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--terminate", distro], TimeSpan.FromSeconds(30), ct: ct); + if (terminate.ExitCode != 0 && !IsMissingDistroResult(terminate)) + ctx.Logger.Warn($"Targeted terminate for '{distro}' failed before unregister (exit {terminate.ExitCode}): {FirstNonEmpty(terminate.Stderr, terminate.Stdout)}"); + + var unregister = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--unregister", distro], TimeSpan.FromSeconds(60), ct: ct); + if (unregister.ExitCode == 0 || IsMissingDistroResult(unregister)) + return true; + + ctx.Logger.Warn($"Partial install unregister failed (exit {unregister.ExitCode}); retrying targeted termination"); + terminate = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--terminate", distro], TimeSpan.FromSeconds(30), ct: ct); + if (terminate.ExitCode != 0 && !IsMissingDistroResult(terminate)) + ctx.Logger.Warn($"Targeted terminate retry for '{distro}' failed (exit {terminate.ExitCode}): {FirstNonEmpty(terminate.Stderr, terminate.Stdout)}"); + + unregister = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--unregister", distro], TimeSpan.FromSeconds(60), ct: ct); + if (unregister.ExitCode == 0 || IsMissingDistroResult(unregister)) + return true; + + cleanupErrors.Add($"unregister exit {unregister.ExitCode}: {FirstNonEmpty(unregister.Stderr, unregister.Stdout)}"); + return false; + } + + private static bool IsMissingDistroResult(CommandResult result) + { + if (result.ExitCode == 0) + return false; + + var output = FirstNonEmpty(result.Stderr, result.Stdout); + return output.Contains("There is no distribution with the supplied name", StringComparison.OrdinalIgnoreCase) || + output.Contains("WSL_E_DISTRO_NOT_FOUND", StringComparison.OrdinalIgnoreCase); + } + + private static string FirstNonEmpty(params string[] values) + => values.Select(v => v.Trim()).FirstOrDefault(v => v.Length > 0) ?? "no output"; + + public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + + if (!DistroInstallPathPolicy.TryGetManagedInstallPath(ctx.LocalDataDir, distro, out var vhdDir, out var pathError)) + throw new IOException($"[Uninstall] Refusing WSL rollback filesystem cleanup: {pathError}"); + + var cleanupError = await CleanupPartialInstall(ctx, distro, vhdDir, ct); + if (cleanupError.Length > 0) + throw new IOException($"[Uninstall] Refusing unsafe WSL rollback cleanup.{cleanupError}"); + + if (!DistroInstallPathPolicy.TryGetManagedInstallPath( + ctx.LocalDataDir, + distro, + out var revalidatedPath, + out pathError)) + { + throw new IOException($"[Uninstall] Refusing WSL parent cleanup: {pathError}"); + } + + var wslDir = Path.GetDirectoryName(revalidatedPath)!; + if (Directory.Exists(wslDir) && + !new DirectoryInfo(wslDir).Attributes.HasFlag(FileAttributes.ReparsePoint) && + !Directory.EnumerateFileSystemEntries(wslDir).Any()) + { + Directory.Delete(wslDir); + ctx.Logger.Info("[Uninstall] Deleted empty wsl\\ parent directory"); + } + } +} diff --git a/src/OpenClaw.SetupEngine/InstallCliStep.cs b/src/OpenClaw.SetupEngine/InstallCliStep.cs new file mode 100644 index 000000000..1cd497cdf --- /dev/null +++ b/src/OpenClaw.SetupEngine/InstallCliStep.cs @@ -0,0 +1,143 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text.Json; +using OpenClaw.Connection; +using OpenClaw.Shared; + +namespace OpenClaw.SetupEngine; + + +public sealed class InstallCliStep : SetupStep +{ + public override string Id => "install-cli"; + public override string DisplayName => "Install OpenClaw CLI"; + public override RetryPolicy Retry => new(MaxAttempts: 2, InitialDelay: TimeSpan.FromSeconds(5)); + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + var user = ctx.Config.Wsl.User; + + // Download and run install script (URL configurable) + var installUrl = ctx.Config.Gateway.InstallUrl ?? GatewayLkgVersion.DefaultInstallUrl; + + // Validate URL is HTTPS to prevent downgrade attacks + if (!Uri.TryCreate(installUrl, UriKind.Absolute, out var parsedUrl) || + !string.Equals(parsedUrl.Scheme, "https", StringComparison.OrdinalIgnoreCase)) + { + return StepResult.Fail($"Installer URL must be HTTPS: {installUrl}"); + } + + string installScript; + try + { + installScript = BuildInstallCommand(installUrl, ctx.Config.Gateway.Version); + } + catch (ArgumentException ex) + { + return StepResult.Fail(ex.Message); + } + + var result = await ctx.Commands.RunInWslAsync(distro, installScript, TimeSpan.FromMinutes(5), ct: ct); + + if (result.ExitCode != 0) + return StepResult.Fail($"CLI install failed (exit {result.ExitCode}): {result.Stderr}"); + + var verifyCommands = new (string Command, string? ExecutablePath)[] + { + ("openclaw --version", null), + ($"/home/{user}/.openclaw/bin/openclaw --version", $"/home/{user}/.openclaw/bin/openclaw"), + ("/opt/openclaw/bin/openclaw --version", "/opt/openclaw/bin/openclaw"), + ("/usr/local/bin/openclaw --version", "/usr/local/bin/openclaw") + }; + + foreach (var (cmd, executablePath) in verifyCommands) + { + var verify = await ctx.Commands.RunInWslAsync(distro, cmd, TimeSpan.FromSeconds(15), ct: ct); + if (verify.ExitCode == 0 && !string.IsNullOrWhiteSpace(verify.Stdout)) + { + if (executablePath != null) + { + var pathResult = await EnsureCliOnDefaultPathAsync(ctx, distro, executablePath, ct); + if (!pathResult.IsSuccess) + return pathResult; + } + + ctx.Logger.Info($"OpenClaw CLI version: {verify.Stdout.Trim()}"); + return StepResult.Ok($"CLI installed: {verify.Stdout.Trim()}"); + } + } + + return StepResult.Fail("CLI installed but not found in any known location"); + } + + internal static string BuildInstallCommand(string installUrl, string? requestedVersion) + { + var escapedUrl = WslShellQuoting.EscapePosixSingleQuoteInner(installUrl); + if (string.IsNullOrWhiteSpace(requestedVersion)) + return $"curl -fsSL --proto '=https' --tlsv1.2 '{escapedUrl}' | bash"; + + var trimmedVersion = requestedVersion.Trim(); + if (trimmedVersion.Contains('\n') || trimmedVersion.Contains('\r')) + throw new ArgumentException("Gateway version cannot contain newlines."); + + var escapedVersion = WslShellQuoting.EscapePosixSingleQuoteInner(trimmedVersion); + return $"curl -fsSL --proto '=https' --tlsv1.2 '{escapedUrl}' | bash -s -- --version '{escapedVersion}'"; + } + + private static async Task EnsureCliOnDefaultPathAsync( + SetupContext ctx, + string distro, + string executablePath, + CancellationToken ct) + { + var user = ctx.Config.Wsl.User; + + if (!executablePath.StartsWith("/", StringComparison.Ordinal) || + executablePath.Contains('\'') || + executablePath.Contains('\n')) + { + return StepResult.Fail($"Refusing to create openclaw PATH symlink for unexpected install path: {executablePath}"); + } + + if (!string.Equals(executablePath, "/usr/local/bin/openclaw", StringComparison.Ordinal)) + { + var linkCommand = $""" + set -e + ln -sfn {executablePath} /usr/local/bin/openclaw + echo OPENCLAW_PATH_READY + """; + + var link = await ctx.Commands.RunInWslAsync( + distro, + linkCommand, + TimeSpan.FromSeconds(15), + ct: ct, + user: "root"); + + if (link.ExitCode != 0 || !link.Stdout.Contains("OPENCLAW_PATH_READY", StringComparison.Ordinal)) + return StepResult.Fail($"Failed to make openclaw available on default PATH: {link.Stderr}"); + } + + var bareVerify = await ctx.Commands.RunInWslAsync( + distro, + $"env -i HOME=/home/{user} USER={user} PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin openclaw --version", + TimeSpan.FromSeconds(15), + ct: ct); + + if (bareVerify.ExitCode != 0 || string.IsNullOrWhiteSpace(bareVerify.Stdout)) + return StepResult.Fail($"openclaw PATH symlink verification failed: {bareVerify.Stderr}"); + + ctx.Logger.Info($"OpenClaw CLI available on default PATH: {bareVerify.Stdout.Trim()}"); + return StepResult.Ok(); + } + + public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) + { + var user = ctx.Config.Wsl.User; + await ctx.Commands.RunInWslAsync(ctx.DistroName!, $"rm -rf /opt/openclaw /home/{user}/.openclaw /usr/local/bin/openclaw", TimeSpan.FromSeconds(30), ct: ct, user: "root"); + } +} diff --git a/src/OpenClaw.SetupEngine/InstallGatewayServiceStep.cs b/src/OpenClaw.SetupEngine/InstallGatewayServiceStep.cs new file mode 100644 index 000000000..e7a098921 --- /dev/null +++ b/src/OpenClaw.SetupEngine/InstallGatewayServiceStep.cs @@ -0,0 +1,35 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text.Json; +using OpenClaw.Connection; +using OpenClaw.Shared; + +namespace OpenClaw.SetupEngine; + + +public sealed class InstallGatewayServiceStep : SetupStep +{ + public override string Id => "install-service"; + public override string DisplayName => "Install gateway service"; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + + var result = await ctx.Commands.RunInWslAsync( + distro, $"{ctx.WslPathPrefix} && openclaw gateway install --force", TimeSpan.FromSeconds(60), ct: ct); + + if (result.ExitCode != 0) + return StepResult.Fail($"Service install failed (exit {result.ExitCode}): {result.Stderr}"); + + return StepResult.Ok("Gateway service installed"); + } + + public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) + { + await ctx.Commands.RunInWslAsync(ctx.DistroName!, $"{ctx.WslPathPrefix} && openclaw gateway uninstall", TimeSpan.FromSeconds(30), ct: ct); + } +} diff --git a/src/OpenClaw.SetupEngine/KeepaliveProcessManager.cs b/src/OpenClaw.SetupEngine/KeepaliveProcessManager.cs new file mode 100644 index 000000000..cccb2c520 --- /dev/null +++ b/src/OpenClaw.SetupEngine/KeepaliveProcessManager.cs @@ -0,0 +1,263 @@ +using System.Diagnostics; +using System.Text.Json; +using OpenClaw.Shared; + +namespace OpenClaw.SetupEngine; + +internal abstract record KeepaliveStartResult +{ + private KeepaliveStartResult() { } + + internal sealed record AlreadyRunning(int Pid) : KeepaliveStartResult; + internal sealed record Started(int Pid) : KeepaliveStartResult; + internal sealed record FailedToStart : KeepaliveStartResult; +} + +/// +/// Owns the setup-time detached WSL keepalive process that keeps the app-owned WSL2 distro +/// alive between distro creation and the point the tray's own long-lived +/// WslGatewayKeepAliveService (OpenClaw.Tray.WinUI) takes over. Its marker path and JSON +/// shape are the intentional handoff contract consumed by that tray service; this setup owner +/// does not invoke the tray service itself. A failed start never hard-fails setup because the tray +/// will start its own keepalive on next launch. +/// +/// Takes explicit immutable inputs (distro name, local data dir, wsl.exe path) rather than a +/// — only reads SetupContext +/// and maps this type's results onto the step's /logging contract. +/// +/// Raw OS process interaction (PID liveness, command-line lookup, process enumeration, starting +/// the detached process, killing a process tree) is delegated to an +/// so tests can control those primitives deterministically; +/// this class owns all keepalive *policy* (identity matching via +/// , marker read/write, soft-fail contract). +/// +internal sealed class KeepaliveProcessManager +{ + private readonly string? _distroName; + private readonly string _localDataDir; + private readonly string _wslExePath; + private readonly SetupLogger _logger; + private readonly IKeepaliveProcessRuntime _runtime; + + internal KeepaliveProcessManager( + string? distroName, + string localDataDir, + string wslExePath, + SetupLogger logger) + : this(distroName, localDataDir, wslExePath, logger, new ProcessKeepaliveRuntime()) + { + } + + internal KeepaliveProcessManager( + string? distroName, + string localDataDir, + string wslExePath, + SetupLogger logger, + IKeepaliveProcessRuntime runtime) + { + _distroName = distroName; + _localDataDir = localDataDir; + _wslExePath = wslExePath; + _logger = logger; + _runtime = runtime; + } + + internal static string GetMarkerPath(string localDataDir, string distroName) + => Path.Combine(localDataDir, "wsl-keepalive", $"{distroName}.json"); + + internal bool TryGetExisting(string markerPath, string distro, out int pid) + { + pid = 0; + if (!File.Exists(markerPath)) + return false; + + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(markerPath)); + if (!doc.RootElement.TryGetProperty("Pid", out var pidElement) || !pidElement.TryGetInt32(out pid)) + { + pid = 0; + return false; + } + + if (!_runtime.IsProcessAlive(pid)) + { + pid = 0; + return false; + } + + if (!IsKeepaliveCommandLine(GetProcessCommandLine(pid), distro)) + { + pid = 0; + return false; + } + + return true; + } + catch (Exception ex) + { + // TryGetExisting returns false on any failure (file missing/unreadable, or a corrupt + // marker). Debug-level via Trace so the failure is still visible in dev diagnostics. + Trace.WriteLine($"[Keepalive] TryGetExistingKeepalive failed: {ex.Message}"); + pid = 0; + return false; + } + } + + /// + /// Ensures a setup-time keepalive process is running for the given distro. Never throws for + /// a failed process start — the caller (StartKeepaliveStep) treats that as a soft failure and + /// still succeeds, since the tray will start its own keepalive on next launch. + /// + internal KeepaliveStartResult EnsureStarted() + { + var distroName = _distroName!; + _logger.Info($"Launching persistent keepalive for distro: {distroName}"); + + var markerPath = GetMarkerPath(_localDataDir, distroName); + if (TryGetExisting(markerPath, distroName, out var existingPid)) + { + _logger.Info($"Keepalive already running for distro '{distroName}' (PID {existingPid})"); + return new KeepaliveStartResult.AlreadyRunning(existingPid); + } + + if (File.Exists(markerPath)) + { + try { File.Delete(markerPath); } + catch (Exception ex) { _logger.Debug($"[Keepalive] Stale marker delete failed: {ex.Message}"); } + } + + // Launch detached keepalive process — keeps the distro alive so port forwarding + // remains stable until the tray starts its own keepalive. + int? pid; + try + { + pid = _runtime.StartDetached(new KeepaliveProcessStartSpec( + _wslExePath, + ["-d", distroName, "--", "sleep", "infinity"])); + } + catch (Exception ex) + { + // A thrown exception here is treated identically to a null PID: a soft failure that + // never fails the setup pipeline — the tray will start its own keepalive on launch. + // The warning text matches the null-PID branch exactly; exception detail goes to + // Debug only, so callers observing the warning contract can't distinguish the two. + _logger.Debug($"[Keepalive] Process start threw: {ex.Message}"); + _logger.Warn("Failed to start keepalive process — tray will start its own"); + return new KeepaliveStartResult.FailedToStart(); + } + + if (pid is null) + { + _logger.Warn("Failed to start keepalive process — tray will start its own"); + return new KeepaliveStartResult.FailedToStart(); + } + + _logger.Info($"Keepalive process started (PID {pid}), distro will stay alive for tray launch"); + + WriteMarker(markerPath, distroName, pid.Value); + + return new KeepaliveStartResult.Started(pid.Value); + } + + private void WriteMarker(string markerPath, string distroName, int pid) + { + var marker = new + { + DistroName = distroName, + Pid = pid, + StartTimeUtc = DateTimeOffset.UtcNow, + ProcessName = "wsl" + }; + var json = JsonSerializer.Serialize(marker, SetupConfig.JsonWriteOptions); + AtomicFile.WriteAllText(markerPath, json); + _logger.Info($"Wrote keepalive marker: {markerPath}"); + } + + /// + /// Kills any detached keepalive process(es) for this distro and deletes the marker + /// file/directory. Best-effort and continues past individual failures — this is the primary + /// cleanup path during uninstall and must not assume EnsureStarted ran in this process. + /// + internal async Task RollbackAsync(CancellationToken ct) + { + var distroName = _distroName; + if (string.IsNullOrEmpty(distroName)) + { + _logger.Info("[Uninstall] No distro name — skipping keepalive cleanup"); + return; + } + + // Kill keepalive wsl.exe processes for this distro. + // Pattern: wsl.exe -d -- sleep infinity + try + { + var processIds = _runtime.EnumerateProcessIds("wsl") + .Concat(_runtime.EnumerateProcessIds("wsl.exe")); + foreach (var pid in processIds) + { + try + { + // Read command line via WMI/CIM (through the runtime seam) + var cmdLine = GetProcessCommandLine(pid); + if (IsKeepaliveCommandLine(cmdLine, distroName)) + { + _runtime.KillProcessTree(pid, TimeSpan.FromSeconds(5)); + _logger.Info($"[Uninstall] Killed keepalive process tree PID {pid}"); + } + } + catch (Exception ex) { _logger.Debug($"[Uninstall] Keepalive proc {pid} cleanup skipped (may have exited): {ex.Message}"); } + } + } + catch (Exception ex) + { + _logger.Warn($"[Uninstall] Error enumerating keepalive processes: {ex.Message}"); + } + + // Delete keepalive marker file + var markerPath = GetMarkerPath(_localDataDir, distroName); + var markerDir = Path.GetDirectoryName(markerPath)!; + + if (File.Exists(markerPath)) + { + File.Delete(markerPath); + _logger.Info($"[Uninstall] Deleted keepalive marker: {markerPath}"); + } + + // Clean up empty marker directory + if (Directory.Exists(markerDir) && !Directory.EnumerateFileSystemEntries(markerDir).Any()) + { + Directory.Delete(markerDir); + _logger.Info("[Uninstall] Deleted empty wsl-keepalive directory"); + } + + await Task.CompletedTask; + } + + private string? GetProcessCommandLine(int pid) + { + try + { + return _runtime.GetCommandLine(pid); + } + catch (Exception ex) + { + SetupDiagnostics.TryWriteStderrWarning( + $"Failed to query command line for process {pid}: {ex.Message}"); + return null; + } + } + + /// + /// Single canonical WSL keepalive command-line matcher lives in + /// ; this is a thin delegate kept for the + /// existing call sites/tests, not a second implementation. + /// + internal static bool IsKeepaliveCommandLine(string? commandLine, string distro) + { + if (string.IsNullOrWhiteSpace(commandLine) || string.IsNullOrWhiteSpace(distro)) + return false; + + return WslCommandLineMatcher.IsKeepaliveForDistro(commandLine, distro); + } +} diff --git a/src/OpenClaw.SetupEngine/KeepaliveProcessRuntime.cs b/src/OpenClaw.SetupEngine/KeepaliveProcessRuntime.cs new file mode 100644 index 000000000..9388d0163 --- /dev/null +++ b/src/OpenClaw.SetupEngine/KeepaliveProcessRuntime.cs @@ -0,0 +1,120 @@ +using System.Diagnostics; + +namespace OpenClaw.SetupEngine; + +/// +/// Narrow, setup-engine-internal seam over the raw OS primitives +/// needs: PID liveness, command-line lookup, WSL process enumeration, starting the detached +/// keepalive process, and killing a process tree. This is a runtime/mechanism seam only — it +/// carries no identity policy. Whether a command line "belongs" to a given distro's keepalive is +/// decided exclusively by via +/// , never by this interface or its +/// implementations. Deliberately setup-engine-scoped, not a generic cross-repo process +/// abstraction — do not reuse this outside . +/// +internal interface IKeepaliveProcessRuntime +{ + /// True if a process with this PID currently exists and has not exited. + bool IsProcessAlive(int pid); + + /// + /// Best-effort command-line lookup for a PID. Returns null if the process is gone or the + /// lookup otherwise fails to produce a value. May throw for exceptional OS failures — callers + /// in catch per-call so one failure doesn't abort a + /// broader scan. + /// + string? GetCommandLine(int pid); + + /// Enumerates the OS process IDs with the exact process name requested by the manager. + IReadOnlyList EnumerateProcessIds(string processName); + + /// + /// Starts the exact executable/argv selected by the manager and returns its PID, or null if the + /// OS returned no process handle. May throw; treats a + /// thrown exception identically to a null return. + /// + int? StartDetached(KeepaliveProcessStartSpec startSpec); + + /// + /// Kills the process tree rooted at and waits up to + /// for exit. May throw (e.g. the process already exited, or + /// access is denied) — callers catch per-process so one failure doesn't stop cleanup of the + /// remaining matches. + /// + void KillProcessTree(int pid, TimeSpan waitTimeout); +} + +internal sealed record KeepaliveProcessStartSpec(string FileName, IReadOnlyList Arguments); + +/// +/// Production backed by +/// and a WMI/CIM command-line lookup via a spawned powershell.exe helper (unchanged from the +/// pre-extraction inline implementation). Every wrapper obtained here is +/// disposed before the method returns. +/// +internal sealed class ProcessKeepaliveRuntime : IKeepaliveProcessRuntime +{ + public bool IsProcessAlive(int pid) + { + using var process = Process.GetProcessById(pid); + return !process.HasExited; + } + + public string? GetCommandLine(int pid) + { + var psi = new ProcessStartInfo("powershell.exe", + $"-NoProfile -Command \"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').CommandLine\"") + { + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using var p = Process.Start(psi); + if (p == null) return null; + var output = p.StandardOutput.ReadToEnd(); + p.WaitForExit(5000); + return output.Trim(); + } + + public IReadOnlyList EnumerateProcessIds(string processName) + { + var ids = new List(); + var processes = Process.GetProcessesByName(processName); + try + { + foreach (var proc in processes) + ids.Add(proc.Id); + } + finally + { + // Dispose every wrapper returned by GetProcessesByName, even if reading .Id on one of + // them throws partway through — otherwise the unvisited remainder of the array leaks + // process handles. + foreach (var proc in processes) + proc.Dispose(); + } + return ids; + } + + public int? StartDetached(KeepaliveProcessStartSpec startSpec) + { + var psi = new ProcessStartInfo + { + FileName = startSpec.FileName, + UseShellExecute = false, + CreateNoWindow = true + }; + foreach (var argument in startSpec.Arguments) + psi.ArgumentList.Add(argument); + + using var proc = Process.Start(psi); + return proc?.Id; + } + + public void KillProcessTree(int pid, TimeSpan waitTimeout) + { + using var proc = Process.GetProcessById(pid); + proc.Kill(entireProcessTree: true); + proc.WaitForExit((int)waitTimeout.TotalMilliseconds); + } +} diff --git a/src/OpenClaw.SetupEngine/MintBootstrapTokenStep.cs b/src/OpenClaw.SetupEngine/MintBootstrapTokenStep.cs new file mode 100644 index 000000000..8accceb5a --- /dev/null +++ b/src/OpenClaw.SetupEngine/MintBootstrapTokenStep.cs @@ -0,0 +1,76 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text.Json; +using OpenClaw.Connection; +using OpenClaw.Shared; + +namespace OpenClaw.SetupEngine; + + +public sealed class MintBootstrapTokenStep : SetupStep +{ + public override string Id => "mint-token"; + public override string DisplayName => "Mint bootstrap token"; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + + // Token was already set by ConfigureGatewayStep + if (string.IsNullOrWhiteSpace(ctx.SharedGatewayToken)) + return StepResult.Fail("No shared gateway token set by previous step"); + + // Mint a bootstrap/QR token + var env = new Dictionary + { + ["OPENCLAW_GATEWAY_TOKEN"] = ctx.SharedGatewayToken + }; + + var mint = await ctx.Commands.RunInWslAsync( + distro, $"{ctx.WslPathPrefix} && openclaw qr --json", TimeSpan.FromSeconds(30), env, ct); + + if (mint.ExitCode == 0 && !string.IsNullOrWhiteSpace(mint.Stdout)) + { + // Parse bootstrap token from JSON output + try + { + if (TryReadBootstrapToken(mint.Stdout.Trim(), out var bootstrapToken, out var source)) + { + ctx.BootstrapToken = bootstrapToken; + ctx.Logger.StateChange("bootstrap_token", null, "[SET]"); + return StepResult.Ok($"Bootstrap token minted from {source}"); + } + } + catch (JsonException ex) + { + ctx.Logger.Warn($"Failed to parse QR JSON: {ex.Message}"); + } + } + + ctx.Logger.Warn("QR/bootstrap token mint failed or did not return a bootstrapToken/setupCode"); + return StepResult.Fail("Could not mint bootstrap token; refusing to use the shared gateway token as bootstrap."); + } + + internal static bool TryReadBootstrapToken(string json, out string? token, out string? source) + { + using var doc = JsonDocument.Parse(json); + foreach (var propertyName in new[] { "bootstrapToken", "setupCode" }) + { + if (doc.RootElement.TryGetProperty(propertyName, out var property) && + property.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(property.GetString())) + { + token = property.GetString(); + source = propertyName; + return true; + } + } + + token = null; + source = null; + return false; + } +} diff --git a/src/OpenClaw.SetupEngine/PairNodeStep.cs b/src/OpenClaw.SetupEngine/PairNodeStep.cs new file mode 100644 index 000000000..1d25dd51e --- /dev/null +++ b/src/OpenClaw.SetupEngine/PairNodeStep.cs @@ -0,0 +1,377 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text.Json; +using OpenClaw.Connection; +using OpenClaw.Shared; + +namespace OpenClaw.SetupEngine; + + +public sealed class PairNodeStep : SetupStep +{ + public override string Id => "pair-node"; + public override string DisplayName => "Pair node connection"; + public override RetryPolicy Retry => new(MaxAttempts: 3, InitialDelay: TimeSpan.FromSeconds(3)); + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var gatewayUrl = ctx.GatewayUrl!; + var token = SetupPairingCredentialPolicy.ResolveInitialPairingToken(ctx); + + if (string.IsNullOrEmpty(token)) + return StepResult.Terminal("No credential available for node pairing"); + + var registry = new GatewayRegistry(ctx.DataDir, logger: new SetupOpenClawLogger(ctx.Logger)); + registry.Load(); + var record = registry.GetById(ctx.GatewayRecordId!); + if (record == null) + return StepResult.Fail("Gateway record not found in registry"); + + var identityPath = registry.GetIdentityDirectory(record.Id); + + var reachability = await WindowsGatewayReachability.VerifyAsync(ctx, "node", ct); + if (!reachability.IsSuccess) + return reachability; + var provenanceCheck = await PairOperatorStep.EnsurePairingEndpointTrustedAsync(ctx, ct); + if (provenanceCheck is not null) + return provenanceCheck; + + var drainResult = await VerifyEndToEndStep.DrainPendingDeviceApprovalsAsync(ctx, ct); + if (!drainResult.IsSuccess) + return drainResult; + provenanceCheck = await PairOperatorStep.EnsurePairingEndpointTrustedAsync(ctx, ct); + if (provenanceCheck is not null) + return provenanceCheck; + + var wsLogger = new SetupOpenClawLogger(ctx.Logger); + WindowsNodeClient? client = null; + + try + { + // Phase 1: Connect (may get PAIRING_REQUIRED) + client = new WindowsNodeClient(gatewayUrl, token, identityPath, logger: wsLogger); + PairOperatorStep.ApplyReconnectAuthorization(client, ctx); + client.UseV2Signature = true; + + // Register capabilities BEFORE connect — gateway stores them from hello message + RegisterCapabilitiesFromConfig(client, ctx); + + var outcome = await WaitForNodeConnection(client, ctx, TimeSpan.FromSeconds(15), ct); + + if (outcome.Outcome == NodeConnectionOutcome.Connected) + { + ctx.NodeDeviceId = client.ShortDeviceId; + ctx.Logger.Info($"Node connected directly: {ctx.NodeDeviceId}"); + return StepResult.Ok("Node connected and paired"); + } + + if (outcome.Outcome == NodeConnectionOutcome.PairingRequired) + { + if (!ctx.Config.AutoApprovePairing) + return StepResult.Fail("Node pairing required but auto-approve is disabled"); + + ctx.Logger.Info("Node pairing required — auto-approving via CLI"); + await client.DisconnectAsync(); + client.Dispose(); + client = null; + + var approveResult = await AutoApproveNodePairing(ctx, outcome.RequestId, ct); + if (!approveResult.IsSuccess) + return approveResult; + + await Task.Delay(2000, ct); + + // Phase 2: Reconnect after approval + provenanceCheck = await PairOperatorStep.EnsurePairingEndpointTrustedAsync(ctx, ct); + if (provenanceCheck is not null) + return provenanceCheck; + client = new WindowsNodeClient(gatewayUrl, token, identityPath, logger: wsLogger); + PairOperatorStep.ApplyReconnectAuthorization(client, ctx); + client.UseV2Signature = true; + RegisterCapabilitiesFromConfig(client, ctx); + + outcome = await WaitForNodeConnection(client, ctx, TimeSpan.FromSeconds(20), ct); + if (outcome.Outcome == NodeConnectionOutcome.Connected) + { + ctx.NodeDeviceId = client.ShortDeviceId; + ctx.Logger.Info($"Node paired after approval: {ctx.NodeDeviceId}"); + await client.DisconnectAsync(); + client.Dispose(); + client = null; + + // Skip node finalization — the operator finalization in VerifyEndToEndStep + // will be the last connect, ensuring operator metadata is "current". + // Node finalization would rotate tokens and potentially invalidate the operator token. + ctx.Logger.Info("Node paired — skipping node finalization (operator finalization is last)"); + return StepResult.Ok("Node paired successfully"); + } + + return StepResult.Fail($"Node reconnection after approval failed: {outcome.Outcome}"); + } + + return StepResult.Fail($"Node connection failed: {outcome.Outcome}"); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Let a caller-driven cancel propagate so the pipeline reports Cancelled, + // not a Failed step — the catch-all below would otherwise convert it back + // into StepResult.Fail (same idiom as the other steps' cancel rethrow). + throw; + } + catch (DeviceIdentityLoadException ex) + { + return SetupIdentityFailure.Terminal(ctx, "node pairing", ex); + } + catch (Exception ex) + { + return StepResult.Fail($"Node pairing failed: {ex.Message}", ex); + } + finally + { + if (client != null) + { + await client.DisconnectAsync(); + client.Dispose(); + } + } + } + + /// + /// After node pairing, finalize by connecting with the node device token to avoid + /// metadata-upgrade when the tray reconnects. + /// + private static async Task FinalizeNodeWithDeviceToken( + SetupContext ctx, string gatewayUrl, string identityPath, IOpenClawLogger wsLogger, CancellationToken ct) + { + ctx.Logger.Info("Finalizing node: reconnect with node device token"); + + var identity = new DeviceIdentity(identityPath); + try + { + identity.Initialize(); + } + catch (DeviceIdentityLoadException ex) + { + return SetupIdentityFailure.Terminal(ctx, "node finalization", ex); + } + var nodeToken = identity.NodeDeviceToken; + + if (string.IsNullOrEmpty(nodeToken)) + { + ctx.Logger.Warn("No node device token stored after pairing — skipping node finalization"); + return StepResult.Ok("Node paired (no finalization needed)"); + } + + // Wait for grace period (same as operator finalization) + ctx.Logger.Info("Waiting for gateway grace period before node finalization..."); + await Task.Delay(TimeSpan.FromSeconds(5), ct); + + var finalClient = new WindowsNodeClient(gatewayUrl, nodeToken, identityPath, logger: wsLogger); + PairOperatorStep.ApplyReconnectAuthorization(finalClient, ctx); + finalClient.UseV2Signature = true; + + try + { + var result = await WaitForNodeConnection(finalClient, ctx, TimeSpan.FromSeconds(15), ct); + + if (result.Outcome == NodeConnectionOutcome.Connected) + { + ctx.Logger.Info("Node finalization connected — tray will connect seamlessly"); + return StepResult.Ok("Node paired and finalized for tray"); + } + + if (result.Outcome == NodeConnectionOutcome.PairingRequired) + { + ctx.Logger.Info("Node metadata-upgrade detected — auto-approving"); + await finalClient.DisconnectAsync(); + finalClient.Dispose(); + finalClient = null; + + var approveResult = await AutoApproveNodePairing(ctx, result.RequestId, ct); + if (!approveResult.IsSuccess) + return StepResult.Fail($"Node finalization approval failed: {approveResult.Message}"); + + await Task.Delay(2000, ct); + + finalClient = new WindowsNodeClient(gatewayUrl, nodeToken, identityPath, logger: wsLogger); + PairOperatorStep.ApplyReconnectAuthorization(finalClient, ctx); + finalClient.UseV2Signature = true; + var finalResult = await WaitForNodeConnection(finalClient, ctx, TimeSpan.FromSeconds(15), ct); + + if (finalResult.Outcome == NodeConnectionOutcome.Connected) + { + ctx.Logger.Info("Node finalization approved — tray will connect seamlessly"); + return StepResult.Ok("Node paired and finalized for tray"); + } + + return StepResult.Fail($"Node finalization failed after approval: {finalResult.Outcome}"); + } + + return StepResult.Fail($"Node finalization failed: {result.Outcome}"); + } + finally + { + if (finalClient != null) + { + await finalClient.DisconnectAsync(); + finalClient.Dispose(); + } + } + } + + private enum NodeConnectionOutcome { Connected, PairingRequired, Error, Timeout } + + private sealed record NodeConnectionResult(NodeConnectionOutcome Outcome, string? RequestId = null); + + private static async Task WaitForNodeConnection( + WindowsNodeClient client, SetupContext ctx, TimeSpan timeout, CancellationToken ct) + { + var tcs = new TaskCompletionSource(); + string? pairingRequestId = null; + + void OnStatusChanged(object? sender, ConnectionStatus status) + { + ctx.Logger.Debug($"Node connection status: {status}"); + if (status == ConnectionStatus.Connected) + tcs.TrySetResult(new NodeConnectionResult(NodeConnectionOutcome.Connected)); + else if (status == ConnectionStatus.Error) + tcs.TrySetResult(new NodeConnectionResult(NodeConnectionOutcome.Error)); + else if (status == ConnectionStatus.Disconnected) + { + if (client.IsPendingApproval) + tcs.TrySetResult(new NodeConnectionResult(NodeConnectionOutcome.PairingRequired, pairingRequestId)); + else + tcs.TrySetResult(new NodeConnectionResult(NodeConnectionOutcome.Error)); + } + } + + void OnPairingStatusChanged(object? sender, PairingStatusEventArgs args) + { + if (args.Status == PairingStatus.Pending && ApprovalRequestHelper.IsSafeRequestId(args.RequestId)) + pairingRequestId = args.RequestId; + } + + client.StatusChanged += OnStatusChanged; + client.PairingStatusChanged += OnPairingStatusChanged; + + try + { + await client.ConnectAsync(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(timeout); + return await tcs.Task.WaitAsync(cts.Token); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + // Only the internal CancelAfter(timeout) firing is a Timeout; a caller + // (user aborting setup) cancelling `ct` must propagate so the pipeline + // reports Cancelled, rather than being misreported as a node timeout. + return new NodeConnectionResult(NodeConnectionOutcome.Timeout); + } + finally + { + client.StatusChanged -= OnStatusChanged; + client.PairingStatusChanged -= OnPairingStatusChanged; + } + } + + internal static async Task AutoApproveNodePairing(SetupContext ctx, string? requestId, CancellationToken ct) + { + var distro = ctx.DistroName!; + var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken ?? throw new InvalidOperationException("No gateway token available for auto-approve"); + + var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = token }; + var approvalKind = ApprovalRequestKind.Device; + + if (string.IsNullOrWhiteSpace(requestId)) + { + approvalKind = ApprovalRequestKind.Node; + var pending = await ctx.Commands.RunInWslAsync( + distro, + $"""{ctx.WslPathPrefix} && openclaw nodes list --json""", + TimeSpan.FromSeconds(30), env, ct); + + ctx.Logger.Info($"Node pending list: exit={pending.ExitCode}"); + + if (pending.ExitCode != 0) + { + var pendingOutput = pending.Stdout.Trim(); + if (ApprovalRequestHelper.IsPluginNotFoundError(pendingOutput)) + return StepResult.Terminal(ApprovalRequestHelper.PluginNotFoundMessage); + return StepResult.Fail($"Could not list pending node pairing requests (exit {pending.ExitCode}): {pendingOutput}"); + } + + var parsed = ApprovalRequestHelper.TryReadSinglePendingRequestId(pending.Stdout.Trim()); + if (!parsed.Success) + { + ctx.Logger.Warn($"Could not select node pairing request: {parsed.Error}"); + return StepResult.Fail(parsed.Error ?? "Could not find a safe pending node pairing request"); + } + + requestId = parsed.RequestId; + } + + if (!ApprovalRequestHelper.IsSafeRequestId(requestId)) + return StepResult.Fail("Node pairing request ID contained unsafe characters"); + + ctx.Logger.Info($"Approving node pairing request: {requestId}"); + var approvalEnv = ApprovalRequestHelper.AddRequestIdEnvironment(env, requestId!); + + var approve = await ctx.Commands.RunInWslAsync( + distro, + $"""{ctx.WslPathPrefix} && {ApprovalRequestHelper.ApprovalCommand(approvalKind)}""", + TimeSpan.FromSeconds(30), approvalEnv, ct); + + ctx.Logger.Info($"Node approve result: exit={approve.ExitCode}"); + + return approve.ExitCode == 0 + ? StepResult.Ok($"Node approved: {requestId}") + : ApprovalRequestHelper.IsPluginNotFoundError(approve.Stdout.Trim()) + ? StepResult.Terminal(ApprovalRequestHelper.PluginNotFoundMessage) + : StepResult.Fail($"Node approval failed (exit {approve.ExitCode}): {approve.Stdout.Trim()}"); + } + + private static void RegisterCapabilitiesFromConfig(WindowsNodeClient client, SetupContext ctx) + { + var capabilities = ctx.Config.Capabilities.GetEnabledCapabilities(); + foreach (var (category, commands) in capabilities) + { + client.RegisterCapability(new StubNodeCapability(category, commands)); + } + if (ctx.Config.Settings.NodeCameraEnabled && ctx.Config.Capabilities.Camera) + client.SetPermission("camera.capture", true); + if (ctx.Config.Settings.NodeScreenEnabled && ctx.Config.Capabilities.Screen) + client.SetPermission("screen.record", true); + + ctx.Logger.Info($"Registered {capabilities.Count} capability categories with {capabilities.Sum(c => c.Commands.Length)} total commands"); + } + + public override Task RollbackAsync(SetupContext ctx, CancellationToken ct) + { + // Null node device token (mirrors old uninstall step 7 for node role) + // Only clear if no external gateways remain (same logic as PairOperatorStep) + var registry = new GatewayRegistry(ctx.DataDir, logger: new SetupOpenClawLogger(ctx.Logger)); + registry.Load(); + var hasExternalGateways = registry.GetAll().Any(r => + !r.IsLocal && !(r.SshTunnel is null && LocalGatewayUrlClassifier.IsLocalGatewayUrl(r.Url))); + + if (hasExternalGateways) + { + ctx.Logger.Info("[Uninstall] Preserving node device token — external gateway records remain"); + } + else + { + var nodeCleared = DeviceIdentity.TryClearDeviceTokenForRole(ctx.DataDir, "node"); + ctx.Logger.Info(nodeCleared + ? "[Uninstall] Cleared node device token" + : "[Uninstall] Node device token already absent"); + } + + return Task.CompletedTask; + } +} + +internal sealed record WindowsNodeContextTarget(string DistroName, string User, string WorkspacePath); diff --git a/src/OpenClaw.SetupEngine/PairOperatorStep.cs b/src/OpenClaw.SetupEngine/PairOperatorStep.cs new file mode 100644 index 000000000..b2acdb0c3 --- /dev/null +++ b/src/OpenClaw.SetupEngine/PairOperatorStep.cs @@ -0,0 +1,515 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text.Json; +using OpenClaw.Connection; +using OpenClaw.Shared; + +namespace OpenClaw.SetupEngine; + + +public sealed class PairOperatorStep : SetupStep +{ + public override string Id => "pair-operator"; + public override string DisplayName => "Pair operator connection"; + public override RetryPolicy Retry => new(MaxAttempts: 3, InitialDelay: TimeSpan.FromSeconds(3)); + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var gatewayUrl = ctx.GatewayUrl!; + var token = SetupPairingCredentialPolicy.ResolveInitialPairingToken(ctx); + + if (string.IsNullOrEmpty(token)) + return StepResult.Terminal("No credential available for operator pairing"); + + // Register gateway in registry (only once — reuse across retries) + var registry = new GatewayRegistry(ctx.DataDir, logger: new SetupOpenClawLogger(ctx.Logger)); + registry.Load(); + + string identityPath; + if (!string.IsNullOrEmpty(ctx.GatewayRecordId)) + { + var existing = registry.GetById(ctx.GatewayRecordId); + if (existing == null) + return StepResult.Fail($"Gateway record {ctx.GatewayRecordId} not found"); + identityPath = registry.GetIdentityDirectory(existing.Id); + ctx.Logger.Info($"Reusing existing gateway record: id={existing.Id}"); + } + else + { + var record = new GatewayRecord + { + Id = Guid.NewGuid().ToString("N")[..16], + Url = gatewayUrl, + FriendlyName = ctx.Config.Tailscale.Enabled + ? $"Tailscale ({ctx.DistroName})" + : $"Local ({ctx.DistroName})", + SharedGatewayToken = ctx.SharedGatewayToken, + BootstrapToken = ctx.BootstrapToken, + IsLocal = true, + SetupManagedDistroName = ctx.DistroName, + LastConnected = DateTime.UtcNow + }; + + record = registry.AddOrUpdate(record); + registry.SetActive(record.Id); + registry.Save(); + ctx.GatewayRecordId = record.Id; + identityPath = registry.GetIdentityDirectory(record.Id); + ctx.Logger.Info($"Gateway record created: id={record.Id}"); + } + + // Initialize device identity + Directory.CreateDirectory(identityPath); + var identity = new DeviceIdentity(identityPath); + try + { + identity.Initialize(); + } + catch (DeviceIdentityLoadException ex) + { + return SetupIdentityFailure.Terminal(ctx, "operator pairing", ex); + } + ctx.Logger.Info($"Device identity initialized: {identity.DeviceId[..16]}..."); + ctx.OperatorDeviceId = identity.DeviceId; + + var reachability = await WindowsGatewayReachability.VerifyAsync(ctx, "operator", ct); + if (!reachability.IsSuccess) + return reachability; + var provenanceCheck = await EnsurePairingEndpointTrustedAsync(ctx, ct); + if (provenanceCheck is not null) + return provenanceCheck; + + // Connect operator WebSocket — handle pairing-required flow + var wsLogger = new SetupOpenClawLogger(ctx.Logger); + OpenClawGatewayClient? client = null; + + try + { + // Phase 1: Initial connect (may get PAIRING_REQUIRED) + client = new OpenClawGatewayClient(gatewayUrl, token, logger: wsLogger, identityPath: identityPath); + ApplyReconnectAuthorization(client, ctx); + client.UseV2Signature = true; // Local gateway uses v2 signature format + var phase1Result = await WaitForConnectionOrPairing(client, ctx, TimeSpan.FromSeconds(15), ct); + + if (phase1Result == ConnectionOutcome.Connected) + { + ctx.Logger.Info("Operator connected directly (no pairing needed)"); + return StepResult.Ok("Operator connected and paired"); + } + + if (phase1Result == ConnectionOutcome.PairingRequired) + { + if (!ctx.Config.AutoApprovePairing) + return StepResult.Fail("Pairing required but auto-approve is disabled"); + + ctx.Logger.Info("Pairing required — auto-approving via CLI"); + var requestId = client.PairingRequiredRequestId; + await client.DisconnectAsync(); + client.Dispose(); + client = null; + + // Auto-approve the pending pairing request + var approveResult = await AutoApprovePairing(ctx, requestId, ct); + if (!approveResult.IsSuccess) + return approveResult; + + // Wait for gateway to process the approval + await Task.Delay(2000, ct); + + // Phase 2: Reconnect — the device should now be approved + provenanceCheck = await EnsurePairingEndpointTrustedAsync(ctx, ct); + if (provenanceCheck is not null) + return provenanceCheck; + client = new OpenClawGatewayClient(gatewayUrl, token, logger: wsLogger, identityPath: identityPath); + ApplyReconnectAuthorization(client, ctx); + client.UseV2Signature = true; + var phase2Result = await WaitForConnectionOrPairing(client, ctx, TimeSpan.FromSeconds(20), ct); + + if (phase2Result == ConnectionOutcome.Connected) + { + ctx.Logger.Info("Operator paired successfully after approval"); + // Disconnect before finalization + await client.DisconnectAsync(); + client.Dispose(); + client = null; + + // Phase 3: Skip operator finalization here — it must happen AFTER node pairing. + // The node pairing changes the device's "current metadata" to node/node-host, + // so operator finalization (as cli/cli) must come last to match what the tray sends. + ctx.Logger.Info("Operator paired — finalization deferred to after node pairing"); + return StepResult.Ok("Operator paired (finalization deferred)"); + } + + return StepResult.Fail($"Reconnection after approval failed: {phase2Result}"); + } + + return StepResult.Fail($"Operator connection failed: {phase1Result}"); + } + catch (DeviceIdentityLoadException ex) + { + return SetupIdentityFailure.Terminal(ctx, "operator pairing", ex); + } + catch (Exception ex) + { + return StepResult.Fail($"Operator pairing failed: {ex.Message}", ex); + } + finally + { + if (client != null) + { + await client.DisconnectAsync(); + client.Dispose(); + } + } + } + + internal static async Task EnsurePairingEndpointTrustedAsync( + SetupContext ctx, + CancellationToken cancellationToken) + { + var record = new GatewayRecord + { + Id = ctx.GatewayRecordId ?? "setup-managed-gateway", + Url = ctx.GatewayUrl ?? ctx.Config.EffectiveGatewayUrl, + IsLocal = true, + SetupManagedDistroName = ctx.DistroName, + }; + var probe = ctx.EndpointProvenanceProbe ?? + new ManagedLocalGatewayPortProvenanceService( + new SetupOpenClawLogger(ctx.Logger)).InspectAsync; + var provenance = await probe(record, cancellationToken).ConfigureAwait(false); + return provenance.Kind switch + { + GatewayEndpointProvenanceKind.ExpectedManagedGateway or + GatewayEndpointProvenanceKind.NotApplicable => null, + GatewayEndpointProvenanceKind.NoListener => + StepResult.Fail("The managed WSL gateway is not listening; no pairing credential was sent."), + _ => StepResult.Terminal( + provenance.Detail ?? + "The managed gateway address is owned by an unverified process; no pairing credential was sent."), + }; + } + + internal static void ApplyReconnectAuthorization( + WebSocketClientBase client, + SetupContext ctx) + { + client.ReconnectAuthorizationAsync = async cancellationToken => + { + var failure = await EnsurePairingEndpointTrustedAsync(ctx, cancellationToken).ConfigureAwait(false); + return failure is null + ? ReconnectAuthorizationResult.AllowedResult + : new ReconnectAuthorizationResult( + false, + GatewayErrorKind.LocalPortConflict, + failure.Message); + }; + } + + /// + /// After initial pairing, the gateway knows us via auth.token (shared gateway token). + /// The tray will connect using auth.deviceToken (the token we just received). + /// This "finalizes" the transition so the gateway doesn't flag it as metadata-upgrade. + /// + private static async Task FinalizeWithDeviceToken( + SetupContext ctx, string gatewayUrl, string identityPath, IOpenClawLogger wsLogger, CancellationToken ct) + { + ctx.Logger.Info("Finalizing: reconnect with device token (like tray will)"); + + // Read the device token we just stored + var identity = new DeviceIdentity(identityPath); + try + { + identity.Initialize(); + } + catch (DeviceIdentityLoadException ex) + { + return SetupIdentityFailure.Terminal(ctx, "operator finalization", ex); + } + var deviceToken = identity.DeviceToken; + + if (string.IsNullOrEmpty(deviceToken)) + { + ctx.Logger.Warn("No device token stored after pairing — skipping finalization"); + return StepResult.Ok("Operator paired (no finalization needed)"); + } + + // Wait for the gateway's internal session grace period to expire. + // Without this delay, the gateway accepts the deviceToken connect within grace + // but would later reject the tray's identical connect as "metadata-upgrade". + ctx.Logger.Info("Waiting for gateway grace period to expire before finalization..."); + await Task.Delay(TimeSpan.FromSeconds(5), ct); + + // Connect exactly as the tray would: pass deviceToken as the credential + var finalClient = new OpenClawGatewayClient(gatewayUrl, deviceToken, logger: wsLogger, identityPath: identityPath); + ApplyReconnectAuthorization(finalClient, ctx); + finalClient.UseV2Signature = true; + + try + { + var result = await WaitForConnectionOrPairing(finalClient, ctx, TimeSpan.FromSeconds(15), ct); + + if (result == ConnectionOutcome.Connected) + { + ctx.Logger.Info("Finalization connected — tray will connect seamlessly"); + return StepResult.Ok("Operator paired and finalized for tray"); + } + + if (result == ConnectionOutcome.PairingRequired) + { + ctx.Logger.Info("Metadata-upgrade detected during finalization — auto-approving"); + var requestId = finalClient.PairingRequiredRequestId; + await finalClient.DisconnectAsync(); + finalClient.Dispose(); + finalClient = null; + + // Approve the metadata-upgrade + var approveResult = await AutoApprovePairing(ctx, requestId, ct); + if (!approveResult.IsSuccess) + return StepResult.Fail($"Finalization approval failed: {approveResult.Message}"); + + await Task.Delay(2000, ct); + + // One more connect to confirm + finalClient = new OpenClawGatewayClient(gatewayUrl, deviceToken, logger: wsLogger, identityPath: identityPath); + ApplyReconnectAuthorization(finalClient, ctx); + finalClient.UseV2Signature = true; + var finalResult = await WaitForConnectionOrPairing(finalClient, ctx, TimeSpan.FromSeconds(15), ct); + + if (finalResult == ConnectionOutcome.Connected) + { + ctx.Logger.Info("Finalization approved — tray will connect seamlessly"); + return StepResult.Ok("Operator paired and finalized for tray"); + } + + return StepResult.Fail($"Finalization failed after approval: {finalResult}"); + } + + return StepResult.Fail($"Finalization connect failed: {result}"); + } + finally + { + if (finalClient != null) + { + await finalClient.DisconnectAsync(); + finalClient.Dispose(); + } + } + } + + internal static async Task AutoApprovePairing(SetupContext ctx, CancellationToken ct) + => await AutoApprovePairing(ctx, requestId: null, ct); + + internal static async Task AutoApprovePairing(SetupContext ctx, string? requestId, CancellationToken ct) + { + var distro = ctx.DistroName!; + var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken ?? throw new InvalidOperationException("No gateway token available for auto-approve"); + + var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = token }; + + if (string.IsNullOrWhiteSpace(requestId)) + { + var preview = await ctx.Commands.RunInWslAsync( + distro, + $"""{ctx.WslPathPrefix} && openclaw devices approve --latest --json""", + TimeSpan.FromSeconds(30), env, ct); + + ctx.Logger.Info($"Approve preview: exit={preview.ExitCode}"); + + var parsed = ApprovalRequestHelper.TryReadSelectedRequestId(preview.Stdout.Trim()); + if (!parsed.Success) + { + ctx.Logger.Warn($"Could not select pairing request: {parsed.Error}"); + return StepResult.Fail("Could not find a safe pending pairing request to approve"); + } + + requestId = parsed.RequestId; + } + + if (!ApprovalRequestHelper.IsSafeRequestId(requestId)) + { + ctx.Logger.Warn("Refusing to approve pairing request with unsafe request ID"); + return StepResult.Fail("Pairing request ID contained unsafe characters"); + } + + ctx.Logger.Info($"Approving pairing request: {requestId}"); + var approvalEnv = ApprovalRequestHelper.AddRequestIdEnvironment(env, requestId!); + + var approve = await ctx.Commands.RunInWslAsync( + distro, + $"""{ctx.WslPathPrefix} && {ApprovalRequestHelper.ApprovalCommand(ApprovalRequestKind.Device)}""", + TimeSpan.FromSeconds(30), approvalEnv, ct); + + ctx.Logger.Info($"Approve result: exit={approve.ExitCode}"); + + if (approve.ExitCode != 0) + { + var approveOutput = approve.Stdout.Trim(); + if (ApprovalRequestHelper.IsPluginNotFoundError(approveOutput)) + return StepResult.Terminal(ApprovalRequestHelper.PluginNotFoundMessage); + return StepResult.Fail($"Device approval failed (exit {approve.ExitCode}): {approveOutput}"); + } + + return StepResult.Ok($"Approved request {requestId}"); + } + + internal enum ConnectionOutcome { Connected, PairingRequired, Error, Timeout } + + internal static async Task WaitForConnectionOrPairing( + OpenClawGatewayClient client, SetupContext ctx, TimeSpan timeout, CancellationToken ct) + { + var tcs = new TaskCompletionSource(); + + void OnStatusChanged(object? sender, ConnectionStatus status) + { + ctx.Logger.Debug($"Operator connection status: {status}"); + if (status == ConnectionStatus.Connected) + tcs.TrySetResult(ConnectionOutcome.Connected); + else if (status == ConnectionStatus.Error) + tcs.TrySetResult(ConnectionOutcome.Error); + else if (status == ConnectionStatus.Disconnected) + { + // Check if pairing was required — client sets IsPairingRequired before disconnect + if (client.IsPairingRequired) + tcs.TrySetResult(ConnectionOutcome.PairingRequired); + else + tcs.TrySetResult(ConnectionOutcome.Error); + } + } + + client.StatusChanged += OnStatusChanged; + EventHandler onDeviceToken = (_, _) => ctx.Logger.Info("Device token received from gateway"); + client.DeviceTokenReceived += onDeviceToken; + + try + { + await client.ConnectAsync(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(timeout); + return await tcs.Task.WaitAsync(cts.Token); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException) + { + return ConnectionOutcome.Timeout; + } + catch (Exception ex) + { + ctx.Logger.Warn($"Operator connection failed: {ex.Message}"); + return ConnectionOutcome.Error; + } + finally + { + client.StatusChanged -= OnStatusChanged; + client.DeviceTokenReceived -= onDeviceToken; + } + } + + public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) + { + var registry = new GatewayRegistry(ctx.DataDir, logger: new SetupOpenClawLogger(ctx.Logger)); + registry.Load(); + + // Find all local gateway records to remove (mirrors old uninstall step 6a) + var localRecords = registry.GetAll() + .Where(r => IsSetupManagedLocalRecord(r, ctx)) + .ToList(); + + if (localRecords.Count > 0) + { + foreach (var record in localRecords) + { + // Remove identity directory + var identityDir = registry.GetIdentityDirectory(record.Id); + if (Directory.Exists(identityDir)) + { + Directory.Delete(identityDir, recursive: true); + ctx.Logger.Info($"[Uninstall] Deleted identity directory: {identityDir}"); + } + registry.Remove(record.Id); + } + registry.Save(); + ctx.Logger.Info($"[Uninstall] Removed {localRecords.Count} local gateway record(s)"); + } + else + { + ctx.Logger.Info("[Uninstall] No local gateway records found"); + } + + // Null operator device token (mirrors old uninstall step 7) + // Check if external gateways remain — if so, preserve root device tokens + var hasExternalGateways = registry.GetAll().Any(r => + !r.IsLocal && !(r.SshTunnel is null && LocalGatewayUrlClassifier.IsLocalGatewayUrl(r.Url))); + + if (hasExternalGateways) + { + ctx.Logger.Info("[Uninstall] Preserving root device tokens — external gateway records remain"); + } + else + { + var operatorCleared = DeviceIdentity.TryClearDeviceTokenForRole(ctx.DataDir, "operator"); + ctx.Logger.Info(operatorCleared + ? "[Uninstall] Cleared operator device token" + : "[Uninstall] Operator device token already absent"); + } + + // Best-effort revoke operator token via gateway HTTP endpoint (mirrors old step 4) + await TryRevokeOperatorTokenAsync(ctx, ct); + } + + internal static bool IsSetupManagedLocalRecord(GatewayRecord record, SetupContext ctx) + { + if (!record.IsLocal || record.SshTunnel != null) + return false; + + if (string.Equals(record.SetupManagedDistroName, ctx.DistroName, StringComparison.Ordinal)) + return true; + + return string.IsNullOrWhiteSpace(record.SetupManagedDistroName) + && string.Equals(record.Url, ctx.GatewayUrl, StringComparison.OrdinalIgnoreCase) + && string.Equals(record.FriendlyName, $"Local ({ctx.DistroName})", StringComparison.Ordinal); + } + + private static async Task TryRevokeOperatorTokenAsync(SetupContext ctx, CancellationToken ct) + { + try + { + // Read settings.json for legacy token if available + var settingsPath = Path.Combine(ctx.DataDir, "settings.json"); + if (!File.Exists(settingsPath)) return; + + var settingsJson = await File.ReadAllTextAsync(settingsPath, ct); + using var doc = JsonDocument.Parse(settingsJson); + + string? token = null; + if (doc.RootElement.TryGetProperty("Token", out var tokenProp)) + token = tokenProp.GetString(); + + if (string.IsNullOrWhiteSpace(token)) return; + + var gatewayUrl = ctx.GatewayUrl ?? "ws://localhost:18789"; + var httpBase = gatewayUrl + .Replace("ws://", "http://", StringComparison.OrdinalIgnoreCase) + .Replace("wss://", "https://", StringComparison.OrdinalIgnoreCase) + .TrimEnd('/'); + + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + http.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}"); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(5)); + var response = await http.PostAsync($"{httpBase}/api/v1/operator/disconnect", content: null, cts.Token); + ctx.Logger.Info($"[Uninstall] Revoke operator token: HTTP {(int)response.StatusCode}"); + } + catch (Exception ex) + { + ctx.Logger.Info($"[Uninstall] Best-effort token revoke failed ({ex.GetType().Name}); gateway may be down"); + } + } +} diff --git a/src/OpenClaw.SetupEngine/PreflightOsStep.cs b/src/OpenClaw.SetupEngine/PreflightOsStep.cs new file mode 100644 index 000000000..365b328de --- /dev/null +++ b/src/OpenClaw.SetupEngine/PreflightOsStep.cs @@ -0,0 +1,32 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text.Json; +using OpenClaw.Connection; +using OpenClaw.Shared; + +namespace OpenClaw.SetupEngine; + + +public sealed class PreflightOsStep : SetupStep +{ + public override string Id => "preflight-os"; + public override string DisplayName => "Verify Windows OS"; + public override bool CanRetry => false; + + public override Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + if (!Environment.Is64BitOperatingSystem) + return Task.FromResult(StepResult.Terminal("64-bit Windows required")); + + if (!OperatingSystem.IsWindows()) + return Task.FromResult(StepResult.Terminal("Windows OS required")); + + var version = Environment.OSVersion.Version; + ctx.Logger.Info($"OS: Windows {version} (64-bit)"); + + return Task.FromResult(StepResult.Ok($"Windows {version}")); + } +} diff --git a/src/OpenClaw.SetupEngine/PreflightPortStep.cs b/src/OpenClaw.SetupEngine/PreflightPortStep.cs new file mode 100644 index 000000000..18874b6b0 --- /dev/null +++ b/src/OpenClaw.SetupEngine/PreflightPortStep.cs @@ -0,0 +1,101 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text.Json; +using OpenClaw.Connection; +using OpenClaw.Shared; + +namespace OpenClaw.SetupEngine; + + +public sealed class PreflightPortStep : SetupStep +{ + public override string Id => "preflight-port"; + public override string DisplayName => "Check gateway port available"; + public override bool CanRetry => false; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var port = ctx.Config.GatewayPort; + var addresses = ctx.Config.Gateway.Bind.Equals("lan", StringComparison.OrdinalIgnoreCase) + ? new[] { IPAddress.Any, IPAddress.IPv6Any } + : [IPAddress.Loopback]; + + // Poll briefly in case WSL port forwarding proxy hasn't fully released the + // port yet after targeted distro termination in a prior cleanup step. + await WaitForPortFreeAsync(port, ctx.Config.Gateway.Bind, ctx.Logger, ct, maxWaitSeconds: 10); + + foreach (var address in addresses) + { + if (!CanBind(address, port, out var error)) + return StepResult.Fail($"Port {port} is already in use for {DescribeBind(address)} ({error.SocketErrorCode})"); + } + + return StepResult.Ok($"Port {port} is available"); + } + + /// + /// Polls until all required addresses for can be bound, + /// or until elapses. Silently returns if the + /// port never frees — will still hard-fail in that case. + /// + internal static async Task WaitForPortFreeAsync( + int port, string bind, SetupLogger logger, CancellationToken ct, + int maxWaitSeconds = 20) + { + var addresses = bind.Equals("lan", StringComparison.OrdinalIgnoreCase) + ? new[] { IPAddress.Any, IPAddress.IPv6Any } + : [IPAddress.Loopback]; + + var deadline = DateTime.UtcNow.AddSeconds(maxWaitSeconds); + var attempt = 0; + + while (DateTime.UtcNow < deadline) + { + ct.ThrowIfCancellationRequested(); + + if (addresses.All(a => CanBind(a, port, out _))) + { + if (attempt > 0) + logger.Info($"Port {port} became free after {attempt * 500}ms"); + return; + } + + attempt++; + await Task.Delay(500, ct); + } + + logger.Warn($"Port {port} still in use after {maxWaitSeconds}s poll — proceeding to hard check"); + } + + internal static bool CanBind(IPAddress address, int port, out SocketException error) + { + var listener = new TcpListener(address, port) + { + ExclusiveAddressUse = true + }; + + try + { + listener.Start(); + error = null!; + return true; + } + catch (SocketException ex) + { + error = ex; + return false; + } + finally + { + listener.Stop(); + } + } + + private static string DescribeBind(IPAddress address) + => address.Equals(IPAddress.Any) ? "LAN IPv4 bind" : + address.Equals(IPAddress.IPv6Any) ? "LAN IPv6 bind" : + "loopback bind"; +} diff --git a/src/OpenClaw.SetupEngine/PreflightWslStep.cs b/src/OpenClaw.SetupEngine/PreflightWslStep.cs new file mode 100644 index 000000000..d0673eb9c --- /dev/null +++ b/src/OpenClaw.SetupEngine/PreflightWslStep.cs @@ -0,0 +1,149 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text.Json; +using OpenClaw.Connection; +using OpenClaw.Shared; + +namespace OpenClaw.SetupEngine; + + +public sealed class PreflightWslStep : SetupStep +{ + public override string Id => "preflight-wsl"; + public override string DisplayName => "Verify WSL available"; + public override bool CanRetry => false; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var versionResult = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--version"], TimeSpan.FromSeconds(5), ct: ct); + if (versionResult.ExitCode != 0 && LooksUnavailable(versionResult)) + { + var installResult = await InstallWslPlatformAsync(ctx, ct); + if (!installResult.IsSuccess) + return installResult; + + versionResult = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--version"], TimeSpan.FromSeconds(5), ct: ct); + } + + if (versionResult.ExitCode != 0) + { + if (LooksTooOldForVersionCommand(versionResult)) + return StepResult.Terminal($"WSL is installed but too old for clean app-owned gateway setup. {WslInstallSupport.UpdateInstructions}"); + + return StepResult.Terminal($"WSL is not available. {FirstUsefulLine(versionResult)}"); + } + + var versionOutput = NormalizeWslOutput($"{versionResult.Stdout}\n{versionResult.Stderr}"); + if (!WslInstallSupport.TryParseWslVersion(versionOutput, out var wslVersion)) + return StepResult.Terminal($"WSL version output did not include a parseable WSL version. {WslInstallSupport.UpdateInstructions}"); + + if (!WslInstallSupport.SupportsDirectNamedInstall(wslVersion)) + return StepResult.Terminal($"WSL {wslVersion} cannot create a clean app-owned OpenClaw gateway distro. {WslInstallSupport.UpdateInstructions}"); + + ctx.Logger.Info($"WSL version output: {NormalizeWslOutput(versionResult.Stdout).Trim()}"); + ctx.Logger.Info($"WSL direct named install is supported (version {wslVersion})"); + + // wsl --version can succeed even when the WSL2 platform itself is + // unusable (Virtual Machine Platform component disabled, hardware + // virtualization off in firmware, Hyper-V missing, ...). Surface + // that diagnostic now so the user gets an actionable message + // before pipeline reaches the actual `wsl --install` step. + var statusIssue = await DetectEnvironmentIssueAsync(ctx, ct); + if (statusIssue != null) + return StepResult.Terminal(statusIssue); + + return StepResult.Ok("WSL available"); + } + + internal static async Task DetectEnvironmentIssueAsync(SetupContext ctx, CancellationToken ct) + { + var status = await ctx.Commands.RunAsync( + WslConstants.WslExePath, + ["--status"], + TimeSpan.FromSeconds(10), + ct: ct); + + var combined = $"{status.Stdout}\n{status.Stderr}"; + if (WslInstallSupport.TryGetEnvironmentIssue(combined, out var message)) + { + ctx.Logger.Warn($"WSL environment issue detected: {NormalizeWslOutput(combined).Trim()}"); + return message; + } + + return null; + } + + private static async Task InstallWslPlatformAsync(SetupContext ctx, CancellationToken ct) + { + ctx.Logger.Warn("WSL platform appears to be missing; launching elevated WSL platform install"); + try + { + var psi = new ProcessStartInfo + { + FileName = WslConstants.WslExePath, + UseShellExecute = true, + Verb = "runas", + CreateNoWindow = true, + WorkingDirectory = WslConstants.SafeWindowsWorkingDirectory + }; + psi.ArgumentList.Add("--install"); + psi.ArgumentList.Add("--no-distribution"); + + using var process = Process.Start(psi); + if (process == null) + return StepResult.Fail("Could not start elevated WSL platform installer."); + + await process.WaitForExitAsync(ct); + + if (process.ExitCode == 3010) + 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}."); + + var probe = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--version"], TimeSpan.FromSeconds(5), ct: ct); + if (probe.ExitCode != 0 || LooksUnavailable(probe)) + return StepResult.Terminal("WSL platform install completed, but Windows still reports WSL unavailable. Reboot Windows, then run setup again."); + + return StepResult.Ok("WSL platform installed"); + } + catch (System.ComponentModel.Win32Exception ex) when ((uint)ex.NativeErrorCode == 1223) + { + return StepResult.Fail("WSL platform install was cancelled at the elevation prompt."); + } + catch (Exception ex) + { + return StepResult.Fail($"WSL platform install failed: {ex.Message}", ex); + } + } + + private static bool LooksUnavailable(CommandResult result) + { + var text = NormalizeWslOutput($"{result.Stdout}\n{result.Stderr}"); + return text.Contains("aka.ms/wslinstall", StringComparison.OrdinalIgnoreCase) + || text.Contains("Windows Subsystem for Linux has no installed distributions", StringComparison.OrdinalIgnoreCase) + || text.Contains("not recognized", StringComparison.OrdinalIgnoreCase) + || text.Contains("not installed", StringComparison.OrdinalIgnoreCase); + } + + private static bool LooksTooOldForVersionCommand(CommandResult result) + { + var text = NormalizeWslOutput($"{result.Stdout}\n{result.Stderr}"); + return text.Contains("Invalid command line option", StringComparison.OrdinalIgnoreCase) + || text.Contains("unrecognized option", StringComparison.OrdinalIgnoreCase) + || text.Contains("unknown option", StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeWslOutput(string value) + => WslInstallSupport.Normalize(value); + + private static string FirstUsefulLine(CommandResult result) + { + var text = NormalizeWslOutput($"{result.Stderr}\n{result.Stdout}"); + return text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim() + ?? "Run wsl --install from an elevated terminal and retry setup."; + } +} diff --git a/src/OpenClaw.SetupEngine/RunGatewayWizardStep.cs b/src/OpenClaw.SetupEngine/RunGatewayWizardStep.cs new file mode 100644 index 000000000..1a8356061 --- /dev/null +++ b/src/OpenClaw.SetupEngine/RunGatewayWizardStep.cs @@ -0,0 +1,26 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text.Json; +using OpenClaw.Connection; +using OpenClaw.Shared; + +namespace OpenClaw.SetupEngine; + + +public sealed class RunGatewayWizardStep : SetupStep +{ + public override string Id => "run-wizard"; + public override string DisplayName => "Run gateway wizard"; + public override bool CanRetry => false; + + public override bool CanSkip(SetupContext ctx) => ctx.Config.SkipWizard; + + public override Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var runner = new SetupWizardRunner(ctx); + return runner.RunAsync(ct); + } +} diff --git a/src/OpenClaw.SetupEngine/SetupSteps.cs b/src/OpenClaw.SetupEngine/SetupSteps.cs index 14d9bb89c..d4ab9cdd9 100644 --- a/src/OpenClaw.SetupEngine/SetupSteps.cs +++ b/src/OpenClaw.SetupEngine/SetupSteps.cs @@ -9,7 +9,7 @@ namespace OpenClaw.SetupEngine; -// PATH prefix for all openclaw CLI commands in WSL + internal static class WslConstants { public static string GetPathPrefix(string user) => @@ -213,6 +213,7 @@ public static string Normalize(string value) } // Adapter to bridge SetupLogger → IOpenClawLogger for WebSocket clients + internal sealed class SetupOpenClawLogger(SetupLogger logger) : IOpenClawLogger { public void Info(string message) => logger.Info($"[WS] {message}"); @@ -226,3635 +227,42 @@ public void Trace(string message) { } public void Error(string message, Exception? ex = null) => logger.Error($"[WS] {message}{(ex != null ? $": {ex}" : "")}"); } -// ═══════════════════════════════════════════════════════════════════ -// CLEANUP STEPS -// ═══════════════════════════════════════════════════════════════════ - -public sealed class ValidateDistroInstallPathStep : SetupStep -{ - public const string StepId = "validate-distro-path"; - - public override string Id => StepId; - public override string DisplayName => "Validate WSL distro install path"; - public override bool CanRetry => false; - - public override Task ExecuteAsync(SetupContext ctx, CancellationToken ct) - { - if (DistroInstallPathPolicy.TryGetNewInstallPath( - ctx.LocalDataDir, - ctx.DistroName, - out _, - out var error)) - { - return Task.FromResult(StepResult.Ok()); - } - - return Task.FromResult(StepResult.Terminal( - DistroInstallPathPolicy.WithLegacyReplacementGuidance(ctx.DistroName, error))); - } -} - -public sealed class CleanupStaleDistroStep : SetupStep -{ - public override string Id => "cleanup-distro"; - public override string DisplayName => "Clean up stale WSL distro"; - public override bool CanRetry => false; - - public override bool CanSkip(SetupContext ctx) => !ctx.Config.CleanBeforeRun; - - public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) - { - var distro = ctx.DistroName!; - if (!DistroInstallPathPolicy.TryGetManagedInstallPath(ctx.LocalDataDir, distro, out var wslDir, out var pathError)) - return StepResult.Terminal(pathError); - - var list = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--list", "--quiet"], TimeSpan.FromSeconds(15), ct: ct); - if (list.ExitCode != 0) - return StepResult.Ok("WSL not available or no distros - nothing to clean"); - - var distros = WslInstallSupport.ParseQuietDistroList(list.Stdout); - - ctx.Logger.Debug($"Found WSL distros: [{string.Join(", ", distros)}]"); - - if (!distros.Any(d => d.Equals(distro, StringComparison.OrdinalIgnoreCase))) - { - // Distro not registered, but disk directory may still exist from prior crash - if (Directory.Exists(wslDir)) - { - ctx.Logger.Info($"Removing orphaned WSL directory: {wslDir}"); - var delete = await DeleteDistroDirectoryWithRetries(ctx, distro, wslDir, ct); - if (!delete.IsSuccess) - return delete; - } - ctx.Logger.Decision("No stale distro found", "skip cleanup"); - return StepResult.Ok("No stale distro to clean"); - } - - ctx.Logger.Decision($"Found existing distro '{distro}'", "terminating and unregistering"); - - // Stop only the app-owned distro. Global WSL shutdown would disrupt unrelated distros. - await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--terminate", distro], TimeSpan.FromSeconds(30), ct: ct); - await Task.Delay(2000, ct); // Let port release - - var unregister = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--unregister", distro], TimeSpan.FromSeconds(60), ct: ct); - if (unregister.ExitCode != 0) - { - ctx.Logger.Warn($"First unregister attempt failed (exit {unregister.ExitCode}); retrying targeted termination"); - await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--terminate", distro], TimeSpan.FromSeconds(30), ct: ct); - await Task.Delay(3000, ct); - unregister = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--unregister", distro], TimeSpan.FromSeconds(60), ct: ct); - } - - if (unregister.ExitCode == 0) - { - // Also remove the on-disk WSL vhdx directory (--import fails if it exists) - var delete = await DeleteDistroDirectoryWithRetries(ctx, distro, wslDir, ct); - if (!delete.IsSuccess) - return delete; - - // Wait for port to be released - ctx.Logger.Info("Waiting for port release after distro termination..."); - await PreflightPortStep.WaitForPortFreeAsync(ctx.Config.GatewayPort, ctx.Config.Gateway.Bind, ctx.Logger, ct); - return StepResult.Ok($"Unregistered stale distro '{distro}'"); - } - - return StepResult.Fail($"Failed to unregister distro: {unregister.Stderr}"); - } - - internal static async Task DeleteDistroDirectoryWithRetries( - SetupContext ctx, - string distroName, - string wslDir, - CancellationToken ct) - { - var deletePath = wslDir; - Exception? lastError = null; - - for (var attempt = 0; attempt < 4; attempt++) - { - if (!DistroInstallPathPolicy.TryValidateDeleteTarget( - ctx.LocalDataDir, - distroName, - wslDir, - out deletePath, - out var pathError)) - { - return StepResult.Terminal(pathError); - } - - try - { - if (File.Exists(deletePath)) - { - if (File.GetAttributes(deletePath).HasFlag(FileAttributes.ReparsePoint)) - return StepResult.Fail($"App-owned WSL path '{deletePath}' is a reparse point; remove it manually and retry setup."); - - ctx.Logger.Info($"Removing app-owned WSL file at install path: {deletePath}"); - File.Delete(deletePath); - } - else if (Directory.Exists(deletePath)) - { - if (new DirectoryInfo(deletePath).Attributes.HasFlag(FileAttributes.ReparsePoint)) - return StepResult.Fail($"App-owned WSL directory '{deletePath}' is a reparse point; remove it manually and retry setup."); - - ctx.Logger.Info($"Removing app-owned WSL directory: {deletePath}"); - Directory.Delete(deletePath, recursive: true); - } - - var parent = Path.GetDirectoryName(deletePath); - if (!string.IsNullOrWhiteSpace(parent) && - Directory.Exists(parent) && - !new DirectoryInfo(parent).Attributes.HasFlag(FileAttributes.ReparsePoint) && - !Directory.EnumerateFileSystemEntries(parent).Any()) - { - Directory.Delete(parent); - ctx.Logger.Info("Deleted empty wsl\\ parent directory"); - } - - return StepResult.Ok("WSL directory removed"); - } - catch (DirectoryNotFoundException) - { - return StepResult.Ok("WSL directory already absent"); - } - catch (IOException ex) - { - lastError = ex; - if (attempt >= 3) - break; - - ctx.Logger.Warn($"VHD directory still locked, retrying in {(attempt + 1) * 2}s..."); - await Task.Delay(TimeSpan.FromSeconds((attempt + 1) * 2), ct); - } - catch (UnauthorizedAccessException ex) - { - lastError = ex; - if (attempt >= 3) - break; - - ctx.Logger.Warn($"VHD directory access denied, retrying in {(attempt + 1) * 2}s..."); - await Task.Delay(TimeSpan.FromSeconds((attempt + 1) * 2), ct); - } - } - - return StepResult.Fail( - $"Failed to remove app-owned WSL directory '{deletePath}'. Close any process using the OpenClaw WSL distro and retry setup." - + (lastError is null ? "" : $" Last error: {lastError.Message}")); - } -} - -public sealed class CleanupStaleGatewayStep : SetupStep -{ - public override string Id => "cleanup-gateway"; - public override string DisplayName => "Clean up stale gateway state"; - public override bool CanRetry => false; - - public override bool CanSkip(SetupContext ctx) => !ctx.Config.CleanBeforeRun; - - public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) - { - // Remove stale setup-state.json from AppData (legacy location) - var stateFile = Path.Combine(ctx.DataDir, "setup-state.json"); - if (File.Exists(stateFile)) - { - File.Delete(stateFile); - ctx.Logger.Info("Deleted stale setup-state.json (AppData)"); - } - - // Also remove from LocalAppData (current write location) - var localStateFile = Path.Combine(ctx.LocalDataDir, "setup-state.json"); - if (File.Exists(localStateFile)) - { - File.Delete(localStateFile); - ctx.Logger.Info("Deleted stale setup-state.json (LocalAppData)"); - } - - // Remove stale gateway record for our local URL if it exists - var registry = new GatewayRegistry(ctx.DataDir, logger: new SetupOpenClawLogger(ctx.Logger)); - registry.Load(); - var existing = registry.FindByUrl(ctx.GatewayUrl!); - if (existing != null) - { - // Preserve non-local records and SSH-tunneled gateways — they may be - // remote gateways that happen to use localhost as a forwarded port. - if (!PairOperatorStep.IsSetupManagedLocalRecord(existing, ctx)) - { - ctx.Logger.Warn($"Skipping cleanup of gateway record {existing.Id}: " + - "not a SetupEngine-managed local gateway"); - } - else - { - // Clean identity directory - var identityDir = registry.GetIdentityDirectory(existing.Id); - if (Directory.Exists(identityDir)) - { - Directory.Delete(identityDir, recursive: true); - ctx.Logger.Info($"Deleted stale identity directory: {identityDir}"); - } - registry.Remove(existing.Id); - registry.Save(); - ctx.Logger.Info($"Removed stale gateway record for {ctx.GatewayUrl}"); - } - } - - await Task.CompletedTask; - return StepResult.Ok("Gateway state cleaned"); - } - - public override Task RollbackAsync(SetupContext ctx, CancellationToken ct) - { - // Delete setup-state.json (written by VerifyEndToEndStep) - var localDataPath = ctx.LocalDataDir; - - var stateFile = Path.Combine(localDataPath, "setup-state.json"); - if (File.Exists(stateFile)) - { - File.Delete(stateFile); - ctx.Logger.Info("[Uninstall] Deleted setup-state.json"); - } - else - { - ctx.Logger.Info("[Uninstall] setup-state.json already absent"); - } - - return Task.CompletedTask; - } -} - -// ═══════════════════════════════════════════════════════════════════ -// PREFLIGHT STEPS -// ═══════════════════════════════════════════════════════════════════ - -public sealed class PreflightOsStep : SetupStep -{ - public override string Id => "preflight-os"; - public override string DisplayName => "Verify Windows OS"; - public override bool CanRetry => false; - - public override Task ExecuteAsync(SetupContext ctx, CancellationToken ct) - { - if (!Environment.Is64BitOperatingSystem) - return Task.FromResult(StepResult.Terminal("64-bit Windows required")); - - if (!OperatingSystem.IsWindows()) - return Task.FromResult(StepResult.Terminal("Windows OS required")); - - var version = Environment.OSVersion.Version; - ctx.Logger.Info($"OS: Windows {version} (64-bit)"); - - return Task.FromResult(StepResult.Ok($"Windows {version}")); - } -} - -public sealed class PreflightWslStep : SetupStep +// SetupPairingCredentialPolicy and WindowsGatewayReachability are used by both PairOperatorStep +// and PairNodeStep (2 call sites each — grep-confirmed), not just one, so per the file-split +// plan's co-location rule they stay here as shared helpers rather than moving into either step's +// own file. +internal static class SetupPairingCredentialPolicy { - public override string Id => "preflight-wsl"; - public override string DisplayName => "Verify WSL available"; - public override bool CanRetry => false; - - public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) - { - var versionResult = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--version"], TimeSpan.FromSeconds(5), ct: ct); - if (versionResult.ExitCode != 0 && LooksUnavailable(versionResult)) - { - var installResult = await InstallWslPlatformAsync(ctx, ct); - if (!installResult.IsSuccess) - return installResult; - - versionResult = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--version"], TimeSpan.FromSeconds(5), ct: ct); - } - - if (versionResult.ExitCode != 0) - { - if (LooksTooOldForVersionCommand(versionResult)) - return StepResult.Terminal($"WSL is installed but too old for clean app-owned gateway setup. {WslInstallSupport.UpdateInstructions}"); - - return StepResult.Terminal($"WSL is not available. {FirstUsefulLine(versionResult)}"); - } - - var versionOutput = NormalizeWslOutput($"{versionResult.Stdout}\n{versionResult.Stderr}"); - if (!WslInstallSupport.TryParseWslVersion(versionOutput, out var wslVersion)) - return StepResult.Terminal($"WSL version output did not include a parseable WSL version. {WslInstallSupport.UpdateInstructions}"); - - if (!WslInstallSupport.SupportsDirectNamedInstall(wslVersion)) - return StepResult.Terminal($"WSL {wslVersion} cannot create a clean app-owned OpenClaw gateway distro. {WslInstallSupport.UpdateInstructions}"); - - ctx.Logger.Info($"WSL version output: {NormalizeWslOutput(versionResult.Stdout).Trim()}"); - ctx.Logger.Info($"WSL direct named install is supported (version {wslVersion})"); - - // wsl --version can succeed even when the WSL2 platform itself is - // unusable (Virtual Machine Platform component disabled, hardware - // virtualization off in firmware, Hyper-V missing, ...). Surface - // that diagnostic now so the user gets an actionable message - // before pipeline reaches the actual `wsl --install` step. - var statusIssue = await DetectEnvironmentIssueAsync(ctx, ct); - if (statusIssue != null) - return StepResult.Terminal(statusIssue); - - return StepResult.Ok("WSL available"); - } - - internal static async Task DetectEnvironmentIssueAsync(SetupContext ctx, CancellationToken ct) - { - var status = await ctx.Commands.RunAsync( - WslConstants.WslExePath, - ["--status"], - TimeSpan.FromSeconds(10), - ct: ct); - - var combined = $"{status.Stdout}\n{status.Stderr}"; - if (WslInstallSupport.TryGetEnvironmentIssue(combined, out var message)) - { - ctx.Logger.Warn($"WSL environment issue detected: {NormalizeWslOutput(combined).Trim()}"); - return message; - } - - return null; - } - - private static async Task InstallWslPlatformAsync(SetupContext ctx, CancellationToken ct) - { - ctx.Logger.Warn("WSL platform appears to be missing; launching elevated WSL platform install"); - try - { - var psi = new ProcessStartInfo - { - FileName = WslConstants.WslExePath, - UseShellExecute = true, - Verb = "runas", - CreateNoWindow = true, - WorkingDirectory = WslConstants.SafeWindowsWorkingDirectory - }; - psi.ArgumentList.Add("--install"); - psi.ArgumentList.Add("--no-distribution"); - - using var process = Process.Start(psi); - if (process == null) - return StepResult.Fail("Could not start elevated WSL platform installer."); - - await process.WaitForExitAsync(ct); - - if (process.ExitCode == 3010) - 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}."); - - var probe = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--version"], TimeSpan.FromSeconds(5), ct: ct); - if (probe.ExitCode != 0 || LooksUnavailable(probe)) - return StepResult.Terminal("WSL platform install completed, but Windows still reports WSL unavailable. Reboot Windows, then run setup again."); - - return StepResult.Ok("WSL platform installed"); - } - catch (System.ComponentModel.Win32Exception ex) when ((uint)ex.NativeErrorCode == 1223) - { - return StepResult.Fail("WSL platform install was cancelled at the elevation prompt."); - } - catch (Exception ex) - { - return StepResult.Fail($"WSL platform install failed: {ex.Message}", ex); - } - } - - private static bool LooksUnavailable(CommandResult result) - { - var text = NormalizeWslOutput($"{result.Stdout}\n{result.Stderr}"); - return text.Contains("aka.ms/wslinstall", StringComparison.OrdinalIgnoreCase) - || text.Contains("Windows Subsystem for Linux has no installed distributions", StringComparison.OrdinalIgnoreCase) - || text.Contains("not recognized", StringComparison.OrdinalIgnoreCase) - || text.Contains("not installed", StringComparison.OrdinalIgnoreCase); - } - - private static bool LooksTooOldForVersionCommand(CommandResult result) - { - var text = NormalizeWslOutput($"{result.Stdout}\n{result.Stderr}"); - return text.Contains("Invalid command line option", StringComparison.OrdinalIgnoreCase) - || text.Contains("unrecognized option", StringComparison.OrdinalIgnoreCase) - || text.Contains("unknown option", StringComparison.OrdinalIgnoreCase); - } - - private static string NormalizeWslOutput(string value) - => WslInstallSupport.Normalize(value); - - private static string FirstUsefulLine(CommandResult result) - { - var text = NormalizeWslOutput($"{result.Stderr}\n{result.Stdout}"); - return text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim() - ?? "Run wsl --install from an elevated terminal and retry setup."; - } + // A durable device token does not exist until pairing completes. Initial + // operator and node pairing must therefore use the shared token first, + // with the one-time bootstrap credential as the fallback. + public static string? ResolveInitialPairingToken(SetupContext ctx) => + ctx.SharedGatewayToken ?? ctx.BootstrapToken; } -public sealed class PreflightPortStep : SetupStep +internal static class WindowsGatewayReachability { - public override string Id => "preflight-port"; - public override string DisplayName => "Check gateway port available"; - public override bool CanRetry => false; - - public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) - { - var port = ctx.Config.GatewayPort; - var addresses = ctx.Config.Gateway.Bind.Equals("lan", StringComparison.OrdinalIgnoreCase) - ? new[] { IPAddress.Any, IPAddress.IPv6Any } - : [IPAddress.Loopback]; - - // Poll briefly in case WSL port forwarding proxy hasn't fully released the - // port yet after targeted distro termination in a prior cleanup step. - await WaitForPortFreeAsync(port, ctx.Config.Gateway.Bind, ctx.Logger, ct, maxWaitSeconds: 10); - - foreach (var address in addresses) - { - if (!CanBind(address, port, out var error)) - return StepResult.Fail($"Port {port} is already in use for {DescribeBind(address)} ({error.SocketErrorCode})"); - } - - return StepResult.Ok($"Port {port} is available"); - } - - /// - /// Polls until all required addresses for can be bound, - /// or until elapses. Silently returns if the - /// port never frees — will still hard-fail in that case. - /// - internal static async Task WaitForPortFreeAsync( - int port, string bind, SetupLogger logger, CancellationToken ct, - int maxWaitSeconds = 20) - { - var addresses = bind.Equals("lan", StringComparison.OrdinalIgnoreCase) - ? new[] { IPAddress.Any, IPAddress.IPv6Any } - : [IPAddress.Loopback]; - - var deadline = DateTime.UtcNow.AddSeconds(maxWaitSeconds); - var attempt = 0; - - while (DateTime.UtcNow < deadline) - { - ct.ThrowIfCancellationRequested(); - - if (addresses.All(a => CanBind(a, port, out _))) - { - if (attempt > 0) - logger.Info($"Port {port} became free after {attempt * 500}ms"); - return; - } - - attempt++; - await Task.Delay(500, ct); - } - - logger.Warn($"Port {port} still in use after {maxWaitSeconds}s poll — proceeding to hard check"); - } - - internal static bool CanBind(IPAddress address, int port, out SocketException error) + public static async Task VerifyAsync(SetupContext ctx, string pairingRole, CancellationToken ct) { - var listener = new TcpListener(address, port) - { - ExclusiveAddressUse = true - }; - try { - listener.Start(); - error = null!; - return true; - } - catch (SocketException ex) - { - error = ex; - return false; - } - finally - { - listener.Stop(); - } - } - - private static string DescribeBind(IPAddress address) - => address.Equals(IPAddress.Any) ? "LAN IPv4 bind" : - address.Equals(IPAddress.IPv6Any) ? "LAN IPv6 bind" : - "loopback bind"; -} - -// ═══════════════════════════════════════════════════════════════════ -// WSL STEPS -// ═══════════════════════════════════════════════════════════════════ - -public sealed class CreateWslInstanceStep : SetupStep -{ - public override string Id => "wsl-create"; - public override string DisplayName => "Create WSL instance"; - public override bool CanRetry => false; - - public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) - { - var distro = ctx.DistroName!; - var baseDistro = ctx.Config.BaseDistro.Trim(); - - if (string.IsNullOrWhiteSpace(baseDistro)) - return StepResult.Terminal("BaseDistro is required for fresh WSL gateway setup."); - - if (!DistroInstallPathPolicy.TryGetNewInstallPath(ctx.LocalDataDir, distro, out var installPath, out var pathError)) - return StepResult.Terminal(pathError); - - ctx.Logger.Info($"Creating clean app-owned WSL distro '{distro}' from '{baseDistro}' at '{installPath}'"); - - var existing = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--list", "--quiet"], TimeSpan.FromSeconds(15), ct: ct); - if (existing.ExitCode != 0) - return StepResult.Fail($"Failed to list WSL distros before creating '{distro}': {existing.Stderr}"); - - if (WslInstallSupport.ContainsDistro(existing.Stdout, distro)) - return StepResult.Fail($"Target WSL distro '{distro}' still exists after cleanup; refusing to create a new gateway over unknown state."); - - var pathCheck = EnsureInstallPathReady(installPath); - if (!pathCheck.IsSuccess) - return pathCheck; - - Directory.CreateDirectory(Path.GetDirectoryName(installPath)!); - - 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( - WslConstants.WslExePath, - installArgs, - TimeSpan.FromMinutes(15), - ct: ct); - - if (install.ExitCode != 0) - { - var cleanupError = await CleanupPartialInstall(ctx, distro, installPath, ct); - return StepResult.Fail( - $"Fresh WSL install failed for '{distro}' from '{baseDistro}' (exit {install.ExitCode}): {FirstNonEmpty(install.Stderr, install.Stdout)}{cleanupError}"); - } - - var verify = await VerifyFreshDistro(ctx, distro, installPath, ct); - if (!verify.IsSuccess) - { - var cleanupError = await CleanupPartialInstall(ctx, distro, installPath, ct); - return StepResult.Fail($"{verify.Message}{cleanupError}"); - } - - return verify; - } - - private static StepResult EnsureInstallPathReady(string installPath) - { - if (File.Exists(installPath)) - { - if (File.GetAttributes(installPath).HasFlag(FileAttributes.ReparsePoint)) - return StepResult.Fail($"App-owned WSL install path '{installPath}' is a reparse point; remove it manually and retry setup."); - - File.Delete(installPath); - return StepResult.Ok(); - } - - if (!Directory.Exists(installPath)) + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + var gatewayUri = new Uri(ctx.GatewayUrl!); + var scheme = gatewayUri.Scheme.Equals("wss", StringComparison.OrdinalIgnoreCase) + ? Uri.UriSchemeHttps + : Uri.UriSchemeHttp; + var healthUri = new UriBuilder(gatewayUri) { Scheme = scheme, Port = gatewayUri.Port }.Uri; + var resp = await http.GetAsync(healthUri, ct); + ctx.Logger.Debug($"Gateway health check: HTTP {(int)resp.StatusCode}"); return StepResult.Ok(); - - if (new DirectoryInfo(installPath).Attributes.HasFlag(FileAttributes.ReparsePoint)) - return StepResult.Fail($"App-owned WSL install directory '{installPath}' is a reparse point; remove it manually and retry setup."); - - if (Directory.EnumerateFileSystemEntries(installPath).Any()) - { - return StepResult.Fail( - $"App-owned WSL install directory '{installPath}' still contains files after cleanup; refusing to create a new gateway over unknown state."); - } - - Directory.Delete(installPath); - return StepResult.Ok(); - } - - private static async Task VerifyFreshDistro(SetupContext ctx, string distro, string installPath, CancellationToken ct) - { - var list = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--list", "--quiet"], TimeSpan.FromSeconds(15), ct: ct); - if (list.ExitCode != 0 || !WslInstallSupport.ContainsDistro(list.Stdout, distro)) - { - var environmentIssue = await PreflightWslStep.DetectEnvironmentIssueAsync(ctx, ct); - var baseMessage = $"Fresh WSL install did not register expected distro '{distro}'."; - return StepResult.Fail(environmentIssue != null ? $"{baseMessage} {environmentIssue}" : baseMessage); - } - - var verbose = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--list", "--verbose"], TimeSpan.FromSeconds(15), ct: ct); - if (verbose.ExitCode != 0 || !WslInstallSupport.TryGetDistroVersion(verbose.Stdout, distro, out var version)) - return StepResult.Fail($"Fresh WSL install registered '{distro}', but setup could not verify it is WSL2."); - - if (version != 2) - return StepResult.Fail($"Fresh WSL install registered '{distro}' as WSL{version}; WSL2 is required."); - - var probe = await ctx.Commands.RunAsync( - WslConstants.WslExePath, - ["-d", distro, "-u", "root", "--", "sh", "-lc", "id -u && test -d / && echo OPENCLAW_FRESH_WSL_READY"], - TimeSpan.FromSeconds(30), - ct: ct); - - if (probe.ExitCode != 0 || !probe.Stdout.Contains("OPENCLAW_FRESH_WSL_READY", StringComparison.Ordinal)) - return StepResult.Fail($"Fresh WSL distro '{distro}' could not run a root verification command: {FirstNonEmpty(probe.Stderr, probe.Stdout)}"); - - return StepResult.Ok($"Created clean WSL2 distro '{distro}' at '{installPath}'"); - } - - private static async Task CleanupPartialInstall(SetupContext ctx, string distro, string installPath, CancellationToken ct) - { - var cleanupErrors = new List(); - var installPathExists = Directory.Exists(installPath) || File.Exists(installPath); - var list = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--list", "--quiet"], TimeSpan.FromSeconds(15), ct: ct); - var registrationStateKnown = list.ExitCode == 0; - var distroExists = registrationStateKnown && WslInstallSupport.ContainsDistro(list.Stdout, distro); - var canDeleteInstallPath = registrationStateKnown && !distroExists; - - 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"); - canDeleteInstallPath = await TryUnregisterPartialInstall(ctx, distro, cleanupErrors, ct); } - else if (distroExists) - { - canDeleteInstallPath = await TryUnregisterPartialInstall(ctx, distro, cleanupErrors, ct); - } - - if (!canDeleteInstallPath) + catch (OperationCanceledException) when (ct.IsCancellationRequested) { - if (!registrationStateKnown) - { - cleanupErrors.Insert(0, - $"could not confirm whether distro '{distro}' is still registered: {FirstNonEmpty(list.Stderr, list.Stdout)}"); - } - - if (installPathExists) - { - cleanupErrors.Add( - $"skipped deleting app-owned install path '{installPath}' until distro '{distro}' is confirmed unregistered"); - } + throw; } - else if (installPathExists) + catch (Exception ex) { - var delete = await CleanupStaleDistroStep.DeleteDistroDirectoryWithRetries(ctx, distro, installPath, ct); - if (!delete.IsSuccess) - cleanupErrors.Add(delete.Message ?? "install directory cleanup failed"); + return StepResult.Fail($"Gateway not reachable before {pairingRole} pairing: {ex.Message}"); } - - return cleanupErrors.Count == 0 - ? "" - : $" Partial app-owned distro cleanup also failed: {string.Join("; ", cleanupErrors)}"; - } - - private static async Task TryUnregisterPartialInstall(SetupContext ctx, string distro, List cleanupErrors, CancellationToken ct) - { - var terminate = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--terminate", distro], TimeSpan.FromSeconds(30), ct: ct); - if (terminate.ExitCode != 0 && !IsMissingDistroResult(terminate)) - ctx.Logger.Warn($"Targeted terminate for '{distro}' failed before unregister (exit {terminate.ExitCode}): {FirstNonEmpty(terminate.Stderr, terminate.Stdout)}"); - - var unregister = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--unregister", distro], TimeSpan.FromSeconds(60), ct: ct); - if (unregister.ExitCode == 0 || IsMissingDistroResult(unregister)) - return true; - - ctx.Logger.Warn($"Partial install unregister failed (exit {unregister.ExitCode}); retrying targeted termination"); - terminate = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--terminate", distro], TimeSpan.FromSeconds(30), ct: ct); - if (terminate.ExitCode != 0 && !IsMissingDistroResult(terminate)) - ctx.Logger.Warn($"Targeted terminate retry for '{distro}' failed (exit {terminate.ExitCode}): {FirstNonEmpty(terminate.Stderr, terminate.Stdout)}"); - - unregister = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--unregister", distro], TimeSpan.FromSeconds(60), ct: ct); - if (unregister.ExitCode == 0 || IsMissingDistroResult(unregister)) - return true; - - cleanupErrors.Add($"unregister exit {unregister.ExitCode}: {FirstNonEmpty(unregister.Stderr, unregister.Stdout)}"); - return false; - } - - private static bool IsMissingDistroResult(CommandResult result) - { - if (result.ExitCode == 0) - return false; - - var output = FirstNonEmpty(result.Stderr, result.Stdout); - return output.Contains("There is no distribution with the supplied name", StringComparison.OrdinalIgnoreCase) || - output.Contains("WSL_E_DISTRO_NOT_FOUND", StringComparison.OrdinalIgnoreCase); - } - - private static string FirstNonEmpty(params string[] values) - => values.Select(v => v.Trim()).FirstOrDefault(v => v.Length > 0) ?? "no output"; - - public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) - { - var distro = ctx.DistroName!; - - if (!DistroInstallPathPolicy.TryGetManagedInstallPath(ctx.LocalDataDir, distro, out var vhdDir, out var pathError)) - throw new IOException($"[Uninstall] Refusing WSL rollback filesystem cleanup: {pathError}"); - - var cleanupError = await CleanupPartialInstall(ctx, distro, vhdDir, ct); - if (cleanupError.Length > 0) - throw new IOException($"[Uninstall] Refusing unsafe WSL rollback cleanup.{cleanupError}"); - - if (!DistroInstallPathPolicy.TryGetManagedInstallPath( - ctx.LocalDataDir, - distro, - out var revalidatedPath, - out pathError)) - { - throw new IOException($"[Uninstall] Refusing WSL parent cleanup: {pathError}"); - } - - var wslDir = Path.GetDirectoryName(revalidatedPath)!; - if (Directory.Exists(wslDir) && - !new DirectoryInfo(wslDir).Attributes.HasFlag(FileAttributes.ReparsePoint) && - !Directory.EnumerateFileSystemEntries(wslDir).Any()) - { - Directory.Delete(wslDir); - ctx.Logger.Info("[Uninstall] Deleted empty wsl\\ parent directory"); - } - } -} - -public sealed class ConfigureWslInstanceStep : SetupStep -{ - public override string Id => "wsl-configure"; - public override string DisplayName => "Configure WSL instance"; - - public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) - { - var distro = ctx.DistroName!; - var wsl = ctx.Config.Wsl; - - if (!WslConfig.IsValidLinuxUserName(wsl.User)) - return StepResult.Terminal($"Invalid WSL user '{wsl.User}'. Use a Linux username matching [a-z_][a-z0-9_-]{{0,31}}."); - - // Build wsl.conf from config - var wslConf = $""" -[boot] -systemd={wsl.Systemd.ToString().ToLower()} - -[automount] -enabled={wsl.Automount.ToString().ToLower()} -mountFsTab={wsl.MountFsTab.ToString().ToLower()} - -[interop] -enabled={wsl.Interop.ToString().ToLower()} -appendWindowsPath={wsl.AppendWindowsPath.ToString().ToLower()} - -[user] -default={wsl.User} - -[time] -useWindowsTimezone={wsl.UseWindowsTimezone.ToString().ToLower()} -"""; - - // Create user and directories - var script = $""" - set -e - - # Create user if not exists - if ! id -u {wsl.User} &>/dev/null; then - useradd -m -s /bin/bash {wsl.User} - fi - - # Create required directories - mkdir -p /home/{wsl.User}/.openclaw - mkdir -p /var/lib/openclaw - mkdir -p /var/log/openclaw - mkdir -p /opt/openclaw - - chown -R {wsl.User}:{wsl.User} /home/{wsl.User}/.openclaw - chown -R {wsl.User}:{wsl.User} /var/lib/openclaw - chown -R {wsl.User}:{wsl.User} /var/log/openclaw - chown -R {wsl.User}:{wsl.User} /opt/openclaw - - # Write wsl.conf - cat > /etc/wsl.conf << 'WSLCONF' - {wslConf} - WSLCONF - - echo "CONFIGURED_OK" - """; - - var result = await ctx.Commands.RunInWslAsync(distro, script, TimeSpan.FromSeconds(60), ct: ct, user: "root"); - - if (result.ExitCode != 0 || !result.Stdout.Contains("CONFIGURED_OK")) - return StepResult.Fail($"Configuration failed: {result.Stderr}"); - - // Restart WSL to apply wsl.conf (systemd) - ctx.Logger.Info("Restarting WSL to apply configuration (systemd)"); - await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--terminate", distro], TimeSpan.FromSeconds(30), ct: ct); - await Task.Delay(2000, ct); // Let WSL settle - - return StepResult.Ok("WSL instance configured"); - } -} - -public sealed class ValidateWslLockdownStep : SetupStep -{ - private const int MaxWslConfReadAttempts = 3; - - public override string Id => "validate-wsl-lockdown"; - public override string DisplayName => "Validate WSL lockdown"; - public override bool CanRetry => false; - - public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) - { - var distro = ctx.DistroName!; - var wsl = ctx.Config.Wsl; - - var readConf = await ReadWslConfWithStartupRetryAsync(ctx, distro, ct); - if (readConf.ExitCode != 0) - return StepResult.Terminal("Cannot read /etc/wsl.conf - WSL configuration may not have been applied"); - - var errors = ValidateWslConf(readConf.Stdout, wsl); - if (errors.Count > 0) - { - var msg = "WSL lockdown validation failed:\n" + string.Join("\n", errors.Select(e => $" - {e}")); - return StepResult.Terminal(msg); - } - - var requiredDirs = new[] - { - $"/home/{wsl.User}/.openclaw", - "/var/lib/openclaw", - "/var/log/openclaw", - "/opt/openclaw" - }; - - // Generate per-directory checks inline (no bash variables). - // wsl.exe argv variable-expansion pitfall: see docs/WSL_EXE_ARGV_PITFALL.md. - // `wsl.exe -- bash -c