Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 | - |
Expand Down
19 changes: 16 additions & 3 deletions docs/SETUP_ENGINE_REDESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

---

Expand Down Expand Up @@ -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 |
|---|-----------|-------------|
Expand Down
6 changes: 3 additions & 3 deletions docs/WSL_EXE_ARGV_PITFALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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

Expand Down
160 changes: 160 additions & 0 deletions src/OpenClaw.SetupEngine/CleanupStaleDistroStep.cs
Original file line number Diff line number Diff line change
@@ -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<StepResult> 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<StepResult> 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}"));
}
}
Loading
Loading