From 95911b849220ef840c46a76e7d76a320efa860cf Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 4 May 2026 12:21:40 -0700 Subject: [PATCH 01/56] feat(shared): port DeviceIdentity with role-specific operator/node tokens (Phase 1) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Shared/DeviceIdentity.cs | 90 ++++++++++++++++++- .../DeviceIdentityTests.cs | 55 ++++++++++++ 2 files changed, 143 insertions(+), 2 deletions(-) diff --git a/src/OpenClaw.Shared/DeviceIdentity.cs b/src/OpenClaw.Shared/DeviceIdentity.cs index e3c496e5..b0471520 100644 --- a/src/OpenClaw.Shared/DeviceIdentity.cs +++ b/src/OpenClaw.Shared/DeviceIdentity.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -18,14 +20,23 @@ public class DeviceIdentity private PublicKey? _publicKey; private string? _deviceId; private string? _deviceToken; + private string[]? _deviceTokenScopes; + private string? _nodeDeviceToken; + private string[]? _nodeDeviceTokenScopes; private static readonly SignatureAlgorithm Ed25519Algorithm = SignatureAlgorithm.Ed25519; public string DeviceId => _deviceId ?? throw new InvalidOperationException("Device not initialized"); public string PublicKeyBase64Url => _publicKey != null ? Base64UrlEncode(_publicKey.Export(KeyBlobFormat.RawPublicKey)) : throw new InvalidOperationException("Device not initialized"); public string? DeviceToken => _deviceToken; + public IReadOnlyList? DeviceTokenScopes => _deviceTokenScopes; + public string? NodeDeviceToken => _nodeDeviceToken; + public IReadOnlyList? NodeDeviceTokenScopes => _nodeDeviceTokenScopes; - public static string? TryReadStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null) + public static string? TryReadStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null) => + TryReadStoredDeviceTokenForRole(dataPath, "operator", logger); + + public static string? TryReadStoredDeviceTokenForRole(string dataPath, string role, IOpenClawLogger? logger = null) { var keyPath = Path.Combine(dataPath, "device-key-ed25519.json"); if (!File.Exists(keyPath)) @@ -36,7 +47,11 @@ public class DeviceIdentity try { using var doc = JsonDocument.Parse(File.ReadAllText(keyPath)); - if (doc.RootElement.TryGetProperty(nameof(DeviceKeyData.DeviceToken), out var deviceToken) && + var tokenPropertyName = string.Equals(role, "node", StringComparison.OrdinalIgnoreCase) + ? nameof(DeviceKeyData.NodeDeviceToken) + : nameof(DeviceKeyData.DeviceToken); + + if (doc.RootElement.TryGetProperty(tokenPropertyName, out var deviceToken) && deviceToken.ValueKind == JsonValueKind.String) { var value = deviceToken.GetString(); @@ -61,6 +76,9 @@ public class DeviceIdentity public static bool HasStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null) => !string.IsNullOrWhiteSpace(TryReadStoredDeviceToken(dataPath, logger)); + + public static bool HasStoredDeviceTokenForRole(string dataPath, string role, IOpenClawLogger? logger = null) => + !string.IsNullOrWhiteSpace(TryReadStoredDeviceTokenForRole(dataPath, role, logger)); public DeviceIdentity(string dataPath, IOpenClawLogger? logger = null) { @@ -102,6 +120,9 @@ private void LoadExisting() _publicKey = _privateKey.PublicKey; _deviceId = data.DeviceId; _deviceToken = data.DeviceToken; + _deviceTokenScopes = NormalizeScopes(data.DeviceTokenScopes); + _nodeDeviceToken = data.NodeDeviceToken; + _nodeDeviceTokenScopes = NormalizeScopes(data.NodeDeviceTokenScopes); _logger.Info($"Loaded Ed25519 device identity: {_deviceId?[..16]}..."); } @@ -306,8 +327,30 @@ public string BuildDebugPayload(string nonce, long signedAtMs, string clientId, /// Store the device token received after pairing approval /// public void StoreDeviceToken(string token) + { + StoreDeviceTokenCore(token, null); + } + + public void StoreDeviceTokenWithScopes(string token, IEnumerable? scopes) + { + StoreDeviceTokenCore(token, NormalizeScopes(scopes)); + } + + public void StoreDeviceTokenForRole(string role, string token, IEnumerable? scopes = null) + { + if (string.Equals(role, "node", StringComparison.OrdinalIgnoreCase)) + { + StoreNodeDeviceTokenCore(token, NormalizeScopes(scopes)); + return; + } + + StoreDeviceTokenCore(token, NormalizeScopes(scopes)); + } + + private void StoreDeviceTokenCore(string token, string[]? scopes) { _deviceToken = token; + _deviceTokenScopes = scopes; // Update the key file with the token try @@ -319,6 +362,7 @@ public void StoreDeviceToken(string token) if (data != null) { data.DeviceToken = token; + data.DeviceTokenScopes = scopes; File.WriteAllText(_keyPath, JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); _logger.Info("Device token stored"); } @@ -329,6 +373,45 @@ public void StoreDeviceToken(string token) _logger.Error($"Failed to store device token: {ex.Message}"); } } + + private void StoreNodeDeviceTokenCore(string token, string[]? scopes) + { + _nodeDeviceToken = token; + _nodeDeviceTokenScopes = scopes; + + try + { + if (File.Exists(_keyPath)) + { + var json = File.ReadAllText(_keyPath); + var data = JsonSerializer.Deserialize(json); + if (data != null) + { + data.NodeDeviceToken = token; + data.NodeDeviceTokenScopes = scopes; + File.WriteAllText(_keyPath, JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); + _logger.Info("Node device token stored"); + } + } + } + catch (Exception ex) + { + _logger.Error($"Failed to store node device token: {ex.Message}"); + } + } + + private static string[]? NormalizeScopes(IEnumerable? scopes) + { + if (scopes == null) + return null; + + var normalized = scopes + .Where(scope => !string.IsNullOrWhiteSpace(scope)) + .Select(scope => scope.Trim()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + return normalized.Length == 0 ? null : normalized; + } private static string Base64UrlEncode(byte[] data) { @@ -344,6 +427,9 @@ private class DeviceKeyData public string? PublicKeyBase64 { get; set; } public string? DeviceId { get; set; } public string? DeviceToken { get; set; } + public string[]? DeviceTokenScopes { get; set; } + public string? NodeDeviceToken { get; set; } + public string[]? NodeDeviceTokenScopes { get; set; } public string? Algorithm { get; set; } public long CreatedAt { get; set; } } diff --git a/tests/OpenClaw.Shared.Tests/DeviceIdentityTests.cs b/tests/OpenClaw.Shared.Tests/DeviceIdentityTests.cs index 5332331b..78fc0af0 100644 --- a/tests/OpenClaw.Shared.Tests/DeviceIdentityTests.cs +++ b/tests/OpenClaw.Shared.Tests/DeviceIdentityTests.cs @@ -192,11 +192,66 @@ public void StoreDeviceToken_PersistsAcrossReload() id1.StoreDeviceToken("secret-device-token"); Assert.Equal("secret-device-token", id1.DeviceToken); + Assert.Null(id1.DeviceTokenScopes); // Reload var id2 = new DeviceIdentity(dir); id2.Initialize(); Assert.Equal("secret-device-token", id2.DeviceToken); + Assert.Null(id2.DeviceTokenScopes); + } + finally { Directory.Delete(dir, true); } + } + + [IntegrationFact] + public void StoreDeviceTokenWithScopes_PersistsScopesAcrossReload() + { + var dir = CreateTempDir(); + try + { + var id1 = new DeviceIdentity(dir); + id1.Initialize(); + + id1.StoreDeviceTokenWithScopes( + "secret-device-token", + ["operator.read", "operator.write", "operator.read"]); + + Assert.Equal("secret-device-token", id1.DeviceToken); + Assert.Equal(["operator.read", "operator.write"], id1.DeviceTokenScopes); + + var id2 = new DeviceIdentity(dir); + id2.Initialize(); + Assert.Equal("secret-device-token", id2.DeviceToken); + Assert.Equal(["operator.read", "operator.write"], id2.DeviceTokenScopes); + } + finally { Directory.Delete(dir, true); } + } + + [IntegrationFact] + public void StoreDeviceTokenForRole_Node_PreservesOperatorToken() + { + var dir = CreateTempDir(); + try + { + var id1 = new DeviceIdentity(dir); + id1.Initialize(); + id1.StoreDeviceTokenWithScopes("operator-token", ["operator.read"]); + id1.StoreDeviceTokenForRole("node", "node-token", []); + + Assert.Equal("operator-token", id1.DeviceToken); + Assert.Equal(["operator.read"], id1.DeviceTokenScopes); + Assert.Equal("node-token", id1.NodeDeviceToken); + Assert.Null(id1.NodeDeviceTokenScopes); + + var id2 = new DeviceIdentity(dir); + id2.Initialize(); + Assert.Equal("operator-token", id2.DeviceToken); + Assert.Equal(["operator.read"], id2.DeviceTokenScopes); + Assert.Equal("node-token", id2.NodeDeviceToken); + Assert.Null(id2.NodeDeviceTokenScopes); + Assert.Equal("operator-token", DeviceIdentity.TryReadStoredDeviceToken(dir)); + Assert.Equal("node-token", DeviceIdentity.TryReadStoredDeviceTokenForRole(dir, "node")); + Assert.True(DeviceIdentity.HasStoredDeviceTokenForRole(dir, "node")); } finally { Directory.Delete(dir, true); } } From 3ae03d37d7486e40eba1cf838c4366814a6d6302 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 4 May 2026 12:28:59 -0700 Subject: [PATCH 02/56] =?UTF-8?q?fix(shared):=20close=20Phase=201=20punch?= =?UTF-8?q?=20list=20=E2=80=94=20scope=20persistence=20+=20role=20validati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Shared/DeviceIdentity.cs | 19 ++++++++- .../DeviceIdentityTests.cs | 39 +++++++++++++++++-- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/OpenClaw.Shared/DeviceIdentity.cs b/src/OpenClaw.Shared/DeviceIdentity.cs index b0471520..8e316a3e 100644 --- a/src/OpenClaw.Shared/DeviceIdentity.cs +++ b/src/OpenClaw.Shared/DeviceIdentity.cs @@ -38,6 +38,7 @@ public class DeviceIdentity public static string? TryReadStoredDeviceTokenForRole(string dataPath, string role, IOpenClawLogger? logger = null) { + var tokenRole = ParseDeviceTokenRole(role); var keyPath = Path.Combine(dataPath, "device-key-ed25519.json"); if (!File.Exists(keyPath)) { @@ -47,7 +48,7 @@ public class DeviceIdentity try { using var doc = JsonDocument.Parse(File.ReadAllText(keyPath)); - var tokenPropertyName = string.Equals(role, "node", StringComparison.OrdinalIgnoreCase) + var tokenPropertyName = tokenRole == DeviceTokenRole.Node ? nameof(DeviceKeyData.NodeDeviceToken) : nameof(DeviceKeyData.DeviceToken); @@ -338,7 +339,8 @@ public void StoreDeviceTokenWithScopes(string token, IEnumerable? scopes public void StoreDeviceTokenForRole(string role, string token, IEnumerable? scopes = null) { - if (string.Equals(role, "node", StringComparison.OrdinalIgnoreCase)) + var tokenRole = ParseDeviceTokenRole(role); + if (tokenRole == DeviceTokenRole.Node) { StoreNodeDeviceTokenCore(token, NormalizeScopes(scopes)); return; @@ -347,6 +349,13 @@ public void StoreDeviceTokenForRole(string role, string token, IEnumerable role switch + { + "operator" => DeviceTokenRole.Operator, + "node" => DeviceTokenRole.Node, + _ => throw new ArgumentOutOfRangeException(nameof(role), "Device token role must be 'operator' or 'node'.") + }; + private void StoreDeviceTokenCore(string token, string[]? scopes) { _deviceToken = token; @@ -421,6 +430,12 @@ private static string Base64UrlEncode(byte[] data) .TrimEnd('='); } + private enum DeviceTokenRole + { + Operator, + Node + } + private class DeviceKeyData { public string? PrivateKeyBase64 { get; set; } diff --git a/tests/OpenClaw.Shared.Tests/DeviceIdentityTests.cs b/tests/OpenClaw.Shared.Tests/DeviceIdentityTests.cs index 78fc0af0..1152ef1b 100644 --- a/tests/OpenClaw.Shared.Tests/DeviceIdentityTests.cs +++ b/tests/OpenClaw.Shared.Tests/DeviceIdentityTests.cs @@ -236,19 +236,19 @@ public void StoreDeviceTokenForRole_Node_PreservesOperatorToken() var id1 = new DeviceIdentity(dir); id1.Initialize(); id1.StoreDeviceTokenWithScopes("operator-token", ["operator.read"]); - id1.StoreDeviceTokenForRole("node", "node-token", []); + id1.StoreDeviceTokenForRole("node", "node-token", ["node.connect", "node.connect", "node.reconnect"]); Assert.Equal("operator-token", id1.DeviceToken); Assert.Equal(["operator.read"], id1.DeviceTokenScopes); Assert.Equal("node-token", id1.NodeDeviceToken); - Assert.Null(id1.NodeDeviceTokenScopes); + Assert.Equal(["node.connect", "node.reconnect"], id1.NodeDeviceTokenScopes); var id2 = new DeviceIdentity(dir); id2.Initialize(); Assert.Equal("operator-token", id2.DeviceToken); Assert.Equal(["operator.read"], id2.DeviceTokenScopes); Assert.Equal("node-token", id2.NodeDeviceToken); - Assert.Null(id2.NodeDeviceTokenScopes); + Assert.Equal(["node.connect", "node.reconnect"], id2.NodeDeviceTokenScopes); Assert.Equal("operator-token", DeviceIdentity.TryReadStoredDeviceToken(dir)); Assert.Equal("node-token", DeviceIdentity.TryReadStoredDeviceTokenForRole(dir, "node")); Assert.True(DeviceIdentity.HasStoredDeviceTokenForRole(dir, "node")); @@ -256,6 +256,39 @@ public void StoreDeviceTokenForRole_Node_PreservesOperatorToken() finally { Directory.Delete(dir, true); } } + [Theory] + [InlineData("")] + [InlineData("OPERATOR")] + [InlineData("adminstrator")] + public void StoreDeviceTokenForRole_InvalidRole_Throws(string role) + { + var dir = CreateTempDir(); + try + { + var identity = new DeviceIdentity(dir); + identity.Initialize(); + + Assert.Throws(() => + identity.StoreDeviceTokenForRole(role, "token")); + } + finally { Directory.Delete(dir, true); } + } + + [Theory] + [InlineData("")] + [InlineData("OPERATOR")] + [InlineData("adminstrator")] + public void TryReadStoredDeviceTokenForRole_InvalidRole_Throws(string role) + { + var dir = CreateTempDir(); + try + { + Assert.Throws(() => + DeviceIdentity.TryReadStoredDeviceTokenForRole(dir, role)); + } + finally { Directory.Delete(dir, true); } + } + [IntegrationFact] public void DifferentDirs_ProduceDifferentIdentities() { From b20b5cec8b67c5a26472883498756261f2467ed5 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 4 May 2026 12:31:13 -0700 Subject: [PATCH 03/56] =?UTF-8?q?feat(shared):=20port=20OpenClawGatewayCli?= =?UTF-8?q?ent=20=E2=80=94=20bootstrap=20+=20role-specific=20reconnect=20(?= =?UTF-8?q?Phase=202.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Shared/OpenClawGatewayClient.cs | 222 ++++++++++++++---- .../OpenClawGatewayClientTests.cs | 150 ++++++++---- 2 files changed, 284 insertions(+), 88 deletions(-) diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index ae4f807f..efe0fc1a 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -2,6 +2,7 @@ using System.Collections.Frozen; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using System.Text.Json; using System.Threading; @@ -66,7 +67,8 @@ private enum SignatureTokenMode private bool _operatorReadScopeUnavailable; private bool _pairingRequiredAwaitingApproval; private bool _authFailed; - private readonly bool _useBootstrapHandoffAuth; + private readonly bool _tokenIsBootstrapToken; + private readonly bool _bootstrapPairAsNode; /// True when the gateway reported "pairing required" for this device. public bool IsPairingRequired => _pairingRequiredAwaitingApproval; @@ -74,6 +76,8 @@ private enum SignatureTokenMode /// True when the device signature was rejected in all supported modes. public bool IsAuthFailed => _authFailed; + /// The gateway auth token used for this connection. + public string ConnectAuthToken => _connectAuthToken; private IReadOnlyList? _userRules; private bool _preferStructuredCategories = true; private readonly System.Collections.Concurrent.ConcurrentDictionary> _pendingWizardResponses = new(); @@ -150,21 +154,19 @@ protected override void OnDisposing() public IReadOnlyList GrantedOperatorScopes => _grantedOperatorScopes; public bool IsConnectedToGateway => IsConnected; - public OpenClawGatewayClient( - string gatewayUrl, - string token, - IOpenClawLogger? logger = null, - bool useBootstrapHandoffAuth = false) + public OpenClawGatewayClient(string gatewayUrl, string token, IOpenClawLogger? logger = null, bool tokenIsBootstrapToken = false, bool bootstrapPairAsNode = false) : base(gatewayUrl, token, logger) { - _useBootstrapHandoffAuth = useBootstrapHandoffAuth; + _tokenIsBootstrapToken = tokenIsBootstrapToken; + _bootstrapPairAsNode = bootstrapPairAsNode; var dataPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + Environment.GetEnvironmentVariable("OPENCLAW_TRAY_APPDATA_DIR") + ?? Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OpenClawTray"); _deviceIdentity = new DeviceIdentity(dataPath, _logger); _deviceIdentity.Initialize(); - _connectAuthToken = _deviceIdentity.DeviceToken ?? _token; + _connectAuthToken = _deviceIdentity.DeviceToken ?? (_tokenIsBootstrapToken ? string.Empty : _token); } public async Task DisconnectAsync() @@ -440,13 +442,14 @@ private async Task SendConnectMessageAsync(string? nonce = null) { var requestId = Guid.NewGuid().ToString(); TrackPendingRequest(requestId, "connect"); - var requestedScopes = GetRequestedOperatorScopes(); + var role = GetConnectRole(); + var requestedScopes = GetRequestedScopes(role); var signedAt = _challengeTimestampMs ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); var connectNonce = nonce ?? string.Empty; var signatureToken = _signatureTokenMode is SignatureTokenMode.V3EmptyToken or SignatureTokenMode.V2EmptyToken ? string.Empty - : _connectAuthToken; + : GetSignatureToken(); var signature = _signatureTokenMode is SignatureTokenMode.V2AuthToken or SignatureTokenMode.V2EmptyToken ? _deviceIdentity.SignConnectPayloadV2( @@ -454,7 +457,7 @@ private async Task SendConnectMessageAsync(string? nonce = null) signedAt, OperatorClientId, OperatorClientMode, - OperatorRole, + role, requestedScopes, signatureToken) : _deviceIdentity.SignConnectPayloadV3( @@ -462,7 +465,7 @@ private async Task SendConnectMessageAsync(string? nonce = null) signedAt, OperatorClientId, OperatorClientMode, - OperatorRole, + role, requestedScopes, signatureToken, OperatorPlatform, @@ -486,7 +489,7 @@ private async Task SendConnectMessageAsync(string? nonce = null) mode = OperatorClientMode, displayName = OperatorClientDisplayName }, - role = OperatorRole, + role, scopes = requestedScopes, caps = Array.Empty(), commands = Array.Empty(), @@ -516,10 +519,25 @@ private async Task SendConnectMessageAsync(string? nonce = null) } } - private string[] GetRequestedOperatorScopes() => - _useBootstrapHandoffAuth && string.IsNullOrEmpty(_deviceIdentity.DeviceToken) - ? s_operatorBootstrapScopes + private string GetConnectRole() + { + return _bootstrapPairAsNode && _tokenIsBootstrapToken && string.IsNullOrEmpty(_deviceIdentity.DeviceToken) + ? "node" + : OperatorRole; + } + + private string[] GetRequestedScopes(string role) + { + if (role == "node") + return []; + + if (string.IsNullOrEmpty(_deviceIdentity.DeviceToken)) + return s_operatorBootstrapScopes; + + return _deviceIdentity.DeviceTokenScopes is { Count: > 0 } scopes + ? scopes.ToArray() : s_operatorScopes; + } /// /// Builds the auth payload for the connect handshake, matching the gateway's @@ -529,27 +547,34 @@ private string[] GetRequestedOperatorScopes() => /// private Dictionary BuildAuthPayload() { - var auth = new Dictionary { ["token"] = _connectAuthToken }; - - if (!_useBootstrapHandoffAuth) - { - return auth; - } + var auth = new Dictionary(); if (!string.IsNullOrEmpty(_deviceIdentity.DeviceToken)) { - // Paired device: send explicit device token for cleaner auth path auth["deviceToken"] = _deviceIdentity.DeviceToken; } - else + else if (_tokenIsBootstrapToken) { - // Fresh device: send bootstrap token for initial pairing + // Fresh QR/setup-code device: do not also send auth.token, which upstream treats + // as an explicit gateway token and therefore suppresses bootstrap pairing. auth["bootstrapToken"] = _token; } + else + { + auth["token"] = _connectAuthToken; + } return auth; } + private string GetSignatureToken() + { + if (!string.IsNullOrEmpty(_deviceIdentity.DeviceToken)) + return _deviceIdentity.DeviceToken; + + return _tokenIsBootstrapToken ? _token : _connectAuthToken; + } + private async Task SendTrackedRequestAsync(string method, object? parameters = null) { if (!IsConnected) return; @@ -751,7 +776,8 @@ private void HandleResponse(JsonElement root) else if (root.TryGetProperty("payload", out var wizPayload)) { // Log the payload kind for debugging - _logger.Info($"Wizard response payload kind={wizPayload.ValueKind}, raw={wizPayload.ToString()?.Substring(0, Math.Min(200, wizPayload.ToString()?.Length ?? 0))}"); + var wizardPayloadText = TokenSanitizer.Sanitize(wizPayload.ToString()); + _logger.Info($"Wizard response payload kind={wizPayload.ValueKind}, raw={wizardPayloadText[..Math.Min(200, wizardPayloadText.Length)]}"); wizardCompletion.TrySetResult(wizPayload.Clone()); } else @@ -785,10 +811,26 @@ private void HandleResponse(JsonElement root) _grantedOperatorScopes = TryGetHandshakeScopes(payload); _mainSessionKey = TryGetHandshakeMainSessionKey(payload) ?? "main"; PublishGatewaySelf(GatewaySelfInfo.FromHelloOk(payload)); - var newDeviceToken = TryGetHandshakeDeviceToken(payload); + if (_bootstrapPairAsNode) + { + var nodeDeviceToken = TryGetHandshakeDeviceTokenCore(payload, "node", allowDirectDeviceTokenFallback: true); + if (!string.IsNullOrWhiteSpace(nodeDeviceToken)) + { + var nodeDeviceTokenScopes = TryGetHandshakeDeviceTokenScopesCore(payload, "node", allowDirectDeviceTokenFallback: true); + _deviceIdentity.StoreDeviceTokenForRole("node", nodeDeviceToken, nodeDeviceTokenScopes); + _logger.Info("Node device token stored for Windows tray node reconnect"); + } + } + + var newDeviceToken = _bootstrapPairAsNode + ? TryGetHandshakeDeviceTokenCore(payload, OperatorRole, allowDirectDeviceTokenFallback: false) + : TryGetHandshakeDeviceTokenCore(payload, preferredRole: null); if (!string.IsNullOrWhiteSpace(newDeviceToken)) { - _deviceIdentity.StoreDeviceToken(newDeviceToken); + var deviceTokenScopes = _bootstrapPairAsNode + ? TryGetHandshakeDeviceTokenScopesCore(payload, OperatorRole, allowDirectDeviceTokenFallback: false) + : TryGetHandshakeDeviceTokenScopesCore(payload, preferredRole: null); + _deviceIdentity.StoreDeviceTokenWithScopes(newDeviceToken, deviceTokenScopes); _connectAuthToken = newDeviceToken; _logger.Info("Operator device token stored for reconnect"); } @@ -1083,25 +1125,38 @@ private static bool IsSessionCommandMethod(string method) private static string[] TryGetHandshakeScopes(JsonElement payload) { + if (payload.TryGetProperty("auth", out var authPayload) && + authPayload.ValueKind == JsonValueKind.Object && + authPayload.TryGetProperty("scopes", out var authScopes) && + authScopes.ValueKind == JsonValueKind.Array) + { + return ReadStringArray(authScopes); + } + if (payload.TryGetProperty("scopes", out var scopesProp) && scopesProp.ValueKind == JsonValueKind.Array) { - var buffer = new string[scopesProp.GetArrayLength()]; - var count = 0; - foreach (var scope in scopesProp.EnumerateArray()) + return ReadStringArray(scopesProp); + } + + return []; + } + + private static string[] ReadStringArray(JsonElement array) + { + var buffer = new string[array.GetArrayLength()]; + var count = 0; + foreach (var item in array.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) { - if (scope.ValueKind == JsonValueKind.String) - { - var value = scope.GetString(); - if (!string.IsNullOrWhiteSpace(value)) - buffer[count++] = value; - } + var value = item.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + buffer[count++] = value; } - - return buffer[..count]; } - return []; + return buffer[..count]; } private static string? TryGetHandshakeMainSessionKey(JsonElement payload) @@ -1126,12 +1181,49 @@ private static string[] TryGetHandshakeScopes(JsonElement payload) } private static string? TryGetHandshakeDeviceToken(JsonElement payload) + { + return TryGetHandshakeDeviceTokenCore(payload, preferredRole: null); + } + + private static string? TryGetHandshakeDeviceTokenCore(JsonElement payload, string? preferredRole) + { + return TryGetHandshakeDeviceTokenCore(payload, preferredRole, allowDirectDeviceTokenFallback: true); + } + + private static string? TryGetHandshakeDeviceTokenCore(JsonElement payload, string? preferredRole, bool allowDirectDeviceTokenFallback) { if (!payload.TryGetProperty("auth", out var authPayload) || authPayload.ValueKind != JsonValueKind.Object) { return null; } + if (!string.IsNullOrWhiteSpace(preferredRole) && + authPayload.TryGetProperty("deviceTokens", out var deviceTokens) && + deviceTokens.ValueKind == JsonValueKind.Array) + { + foreach (var entry in deviceTokens.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) + continue; + + if (entry.TryGetProperty("role", out var role) && + role.ValueKind == JsonValueKind.String && + string.Equals(role.GetString(), preferredRole, StringComparison.OrdinalIgnoreCase) && + entry.TryGetProperty("deviceToken", out var roleToken) && + roleToken.ValueKind == JsonValueKind.String) + { + var roleTokenValue = roleToken.GetString(); + if (!string.IsNullOrWhiteSpace(roleTokenValue)) + return roleTokenValue; + } + } + + if (!allowDirectDeviceTokenFallback) + { + return null; + } + } + if (!authPayload.TryGetProperty("deviceToken", out var deviceToken) || deviceToken.ValueKind != JsonValueKind.String) { return null; @@ -1141,6 +1233,54 @@ private static string[] TryGetHandshakeScopes(JsonElement payload) return string.IsNullOrWhiteSpace(value) ? null : value; } + private static string[]? TryGetHandshakeDeviceTokenScopesCore(JsonElement payload, string? preferredRole) + { + return TryGetHandshakeDeviceTokenScopesCore(payload, preferredRole, allowDirectDeviceTokenFallback: true); + } + + private static string[]? TryGetHandshakeDeviceTokenScopesCore(JsonElement payload, string? preferredRole, bool allowDirectDeviceTokenFallback) + { + if (!payload.TryGetProperty("auth", out var authPayload) || authPayload.ValueKind != JsonValueKind.Object) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(preferredRole) && + authPayload.TryGetProperty("deviceTokens", out var deviceTokens) && + deviceTokens.ValueKind == JsonValueKind.Array) + { + foreach (var entry in deviceTokens.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) + continue; + + if (entry.TryGetProperty("role", out var role) && + role.ValueKind == JsonValueKind.String && + string.Equals(role.GetString(), preferredRole, StringComparison.OrdinalIgnoreCase)) + { + return entry.TryGetProperty("scopes", out var roleScopes) && roleScopes.ValueKind == JsonValueKind.Array + ? ReadStringArray(roleScopes) + : []; + } + } + + if (!allowDirectDeviceTokenFallback) + { + return null; + } + } + + if (authPayload.TryGetProperty("deviceToken", out var deviceToken) && + deviceToken.ValueKind == JsonValueKind.String && + authPayload.TryGetProperty("scopes", out var scopes) && + scopes.ValueKind == JsonValueKind.Array) + { + return ReadStringArray(scopes); + } + + return null; + } + public string BuildMissingScopeFixCommands(string missingScope) { var scope = string.IsNullOrWhiteSpace(missingScope) ? "operator.write" : missingScope.Trim(); diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs index d5e4d6eb..4ab045e8 100644 --- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs @@ -14,22 +14,19 @@ private class GatewayClientTestHelper { private readonly OpenClawGatewayClient _client; - public GatewayClientTestHelper(bool useBootstrapHandoffAuth = false) + public GatewayClientTestHelper(bool tokenIsBootstrapToken = false, bool bootstrapPairAsNode = false) { _client = new OpenClawGatewayClient( "ws://localhost:18789", "test-token", new TestLogger(), - useBootstrapHandoffAuth); + tokenIsBootstrapToken, + bootstrapPairAsNode); } - public GatewayClientTestHelper(IOpenClawLogger logger, bool useBootstrapHandoffAuth = false) + public GatewayClientTestHelper(IOpenClawLogger logger) { - _client = new OpenClawGatewayClient( - "ws://localhost:18789", - "test-token", - logger, - useBootstrapHandoffAuth); + _client = new OpenClawGatewayClient("ws://localhost:18789", "test-token", logger); } public string ClassifyNotification(string text) @@ -258,11 +255,44 @@ public string CallBuildPairingApprovalFixCommands() => _client.BuildPairingApprovalFixCommands(); public string[] GetRequestedOperatorScopes() + { + var role = GetConnectRole(); + var method = typeof(OpenClawGatewayClient).GetMethod( + "GetRequestedScopes", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + return (string[])method!.Invoke(_client, new object[] { role })!; + } + + public string GetConnectRole() { var method = typeof(OpenClawGatewayClient).GetMethod( - "GetRequestedOperatorScopes", + "GetConnectRole", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - return (string[])method!.Invoke(_client, null)!; + return (string)method!.Invoke(_client, null)!; + } + + public string? TryGetHandshakeDeviceToken(string payloadJson, string? preferredRole = null) + { + using var document = JsonDocument.Parse(payloadJson); + var method = typeof(OpenClawGatewayClient).GetMethod( + "TryGetHandshakeDeviceTokenCore", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static, + binder: null, + types: [typeof(JsonElement), typeof(string)], + modifiers: null); + return (string?)method!.Invoke(null, new object?[] { document.RootElement, preferredRole }); + } + + public string[]? TryGetHandshakeDeviceTokenScopes(string payloadJson, string? preferredRole = null) + { + using var document = JsonDocument.Parse(payloadJson); + var method = typeof(OpenClawGatewayClient).GetMethod( + "TryGetHandshakeDeviceTokenScopesCore", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static, + binder: null, + types: [typeof(JsonElement), typeof(string)], + modifiers: null); + return (string[]?)method!.Invoke(null, new object?[] { document.RootElement, preferredRole }); } public Dictionary BuildAuthPayload() @@ -273,7 +303,7 @@ public Dictionary BuildAuthPayload() return (Dictionary)method!.Invoke(_client, null)!; } - public void SetDeviceTokenForTest(string? token) + public void SetDeviceTokenForTest(string? token, string[]? scopes = null) { var identityField = typeof(OpenClawGatewayClient).GetField( "_deviceIdentity", @@ -283,6 +313,10 @@ public void SetDeviceTokenForTest(string? token) "_deviceToken", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); tokenField!.SetValue(identity, token); + var scopesField = identity.GetType().GetField( + "_deviceTokenScopes", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + scopesField!.SetValue(identity, scopes); SetPrivateField("_connectAuthToken", token ?? "test-token"); } @@ -338,78 +372,100 @@ public List CaptureAuthenticationFailedEvents() } [Fact] - public void OperatorConnect_FreshDevice_DefaultPathKeepsLegacyAuthShape() + public void OperatorConnect_FreshDevice_RequestsBootstrapHandoffScopes() { - var helper = new GatewayClientTestHelper(); + var helper = new GatewayClientTestHelper(tokenIsBootstrapToken: true); helper.SetDeviceTokenForTest(null); var scopes = helper.GetRequestedOperatorScopes(); var auth = helper.BuildAuthPayload(); Assert.Equal( - ["operator.admin", "operator.read", "operator.write", "operator.approvals", "operator.pairing"], + ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], scopes); + Assert.DoesNotContain("operator.admin", scopes); + Assert.DoesNotContain("operator.pairing", scopes); + Assert.Equal("test-token", auth["bootstrapToken"]); + Assert.False(auth.ContainsKey("token")); + Assert.False(auth.ContainsKey("deviceToken")); + } + + [Fact] + public void OperatorConnect_PairedDevice_RequestsFullOperatorScopes() + { + var helper = new GatewayClientTestHelper(); + helper.SetDeviceTokenForTest("paired-device-token"); + + var scopes = helper.GetRequestedOperatorScopes(); + var auth = helper.BuildAuthPayload(); + Assert.Contains("operator.admin", scopes); Assert.Contains("operator.pairing", scopes); Assert.DoesNotContain("operator.talk.secrets", scopes); - Assert.Equal("test-token", auth["token"]); + Assert.Equal("paired-device-token", auth["deviceToken"]); + Assert.False(auth.ContainsKey("token")); Assert.False(auth.ContainsKey("bootstrapToken")); - Assert.False(auth.ContainsKey("deviceToken")); } [Fact] - public void OperatorConnect_SetupCodeBootstrapPath_RequestsBootstrapHandoffScopes() + public void OperatorConnect_PairedDeviceWithStoredScopes_RequestsStoredScopes() { - var helper = new GatewayClientTestHelper(useBootstrapHandoffAuth: true); - helper.SetDeviceTokenForTest(null); + var helper = new GatewayClientTestHelper(); + helper.SetDeviceTokenForTest( + "paired-device-token", + ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"]); var scopes = helper.GetRequestedOperatorScopes(); - var auth = helper.BuildAuthPayload(); Assert.Equal( ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], scopes); - Assert.DoesNotContain("operator.admin", scopes); - Assert.DoesNotContain("operator.pairing", scopes); - Assert.Equal("test-token", auth["bootstrapToken"]); - Assert.False(auth.ContainsKey("deviceToken")); } [Fact] - public void OperatorConnect_SetupCodeBootstrapPath_WithPairedDeviceUsesFullScopesAndDeviceToken() + public void BootstrapNodeHandoff_FreshDevice_RequestsNodeRoleWithoutScopes() { - var helper = new GatewayClientTestHelper(useBootstrapHandoffAuth: true); - helper.SetDeviceTokenForTest("paired-device-token"); + var helper = new GatewayClientTestHelper(tokenIsBootstrapToken: true, bootstrapPairAsNode: true); + helper.SetDeviceTokenForTest(null); - var scopes = helper.GetRequestedOperatorScopes(); var auth = helper.BuildAuthPayload(); - Assert.Equal( - ["operator.admin", "operator.read", "operator.write", "operator.approvals", "operator.pairing"], - scopes); - Assert.Equal("paired-device-token", auth["token"]); - Assert.Equal("paired-device-token", auth["deviceToken"]); - Assert.False(auth.ContainsKey("bootstrapToken")); + Assert.Equal("node", helper.GetConnectRole()); + Assert.Empty(helper.GetRequestedOperatorScopes()); + Assert.Equal("test-token", auth["bootstrapToken"]); + Assert.False(auth.ContainsKey("token")); + Assert.False(auth.ContainsKey("deviceToken")); } [Fact] - public void OperatorConnect_PairedDevice_DefaultPathKeepsLegacyAuthShape() + public void BootstrapNodeHandoff_PrefersOperatorTokenFromAdditionalDeviceTokens() { var helper = new GatewayClientTestHelper(); - helper.SetDeviceTokenForTest("paired-device-token"); - var scopes = helper.GetRequestedOperatorScopes(); - var auth = helper.BuildAuthPayload(); + var payload = + """ + { + "type": "hello-ok", + "auth": { + "deviceToken": "node-token", + "role": "node", + "scopes": [], + "deviceTokens": [ + { + "deviceToken": "operator-token", + "role": "operator", + "scopes": ["operator.read"] + } + ] + } + } + """; + var token = helper.TryGetHandshakeDeviceToken(payload, "operator"); + var scopes = helper.TryGetHandshakeDeviceTokenScopes(payload, "operator"); - Assert.Equal( - ["operator.admin", "operator.read", "operator.write", "operator.approvals", "operator.pairing"], - scopes); - Assert.Contains("operator.admin", scopes); - Assert.Contains("operator.pairing", scopes); - Assert.DoesNotContain("operator.talk.secrets", scopes); - Assert.Equal("paired-device-token", auth["token"]); - Assert.False(auth.ContainsKey("deviceToken")); - Assert.False(auth.ContainsKey("bootstrapToken")); + Assert.Equal("operator-token", token); + Assert.NotNull(scopes); + Assert.Equal(["operator.read"], scopes!); } private class TestLogger : IOpenClawLogger From b69202da466584c55200a947369e077e8e2b6c86 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 4 May 2026 12:32:37 -0700 Subject: [PATCH 04/56] =?UTF-8?q?feat(shared):=20port=20WindowsNodeClient?= =?UTF-8?q?=20=E2=80=94=20auth.deviceToken=20reconnect=20(Phase=202.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Shared/WindowsNodeClient.cs | 85 +++++++++++++------ .../WindowsNodeClientTests.cs | 42 ++------- 2 files changed, 66 insertions(+), 61 deletions(-) diff --git a/src/OpenClaw.Shared/WindowsNodeClient.cs b/src/OpenClaw.Shared/WindowsNodeClient.cs index a9d4acdc..e29d6b51 100644 --- a/src/OpenClaw.Shared/WindowsNodeClient.cs +++ b/src/OpenClaw.Shared/WindowsNodeClient.cs @@ -57,7 +57,7 @@ public class WindowsNodeClient : WebSocketClientBase public bool IsPendingApproval => _isPendingApproval; /// True if device is paired via a stored token or an explicit gateway approval event. - public bool IsPaired => _isPaired || !string.IsNullOrEmpty(_deviceIdentity.DeviceToken); + public bool IsPaired => _isPaired || !string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken); /// Device ID for display/approval (first 16 chars of full ID) public string ShortDeviceId => _deviceIdentity.DeviceId.Length > 16 @@ -74,7 +74,7 @@ public class WindowsNodeClient : WebSocketClientBase protected override string ClientRole => "node"; public WindowsNodeClient(string gatewayUrl, string token, string dataPath, IOpenClawLogger? logger = null, string? bootstrapToken = null) - : base(gatewayUrl, ResolveRequiredCredential(token, bootstrapToken, dataPath), logger) + : base(gatewayUrl, ResolveRequiredCredential(token, bootstrapToken, dataPath, logger), logger) { _gatewayToken = NormalizeOptionalCredential(token); _bootstrapToken = NormalizeOptionalCredential(bootstrapToken); @@ -98,8 +98,14 @@ private static string NormalizeOptionalCredential(string? credential) return string.IsNullOrWhiteSpace(credential) ? string.Empty : credential; } - private static string ResolveRequiredCredential(string? token, string? bootstrapToken, string dataPath) + private static string ResolveRequiredCredential(string? token, string? bootstrapToken, string dataPath, IOpenClawLogger? logger) { + var storedNodeToken = TryLoadStoredNodeToken(dataPath, logger); + if (!string.IsNullOrEmpty(storedNodeToken)) + { + return storedNodeToken; + } + var gatewayToken = NormalizeOptionalCredential(token); if (!string.IsNullOrEmpty(gatewayToken)) { @@ -112,13 +118,26 @@ private static string ResolveRequiredCredential(string? token, string? bootstrap return bootstrap; } - var storedDeviceToken = DeviceIdentity.TryReadStoredDeviceToken(dataPath); - if (!string.IsNullOrEmpty(storedDeviceToken)) + throw new ArgumentException("Token or bootstrap token is required.", nameof(token)); + } + + public static bool HasStoredNodeDeviceToken(string dataPath, IOpenClawLogger? logger = null) + { + return !string.IsNullOrWhiteSpace(TryLoadStoredNodeToken(dataPath, logger)); + } + + private static string? TryLoadStoredNodeToken(string dataPath, IOpenClawLogger? logger) + { + try { - return storedDeviceToken; + var identity = new DeviceIdentity(dataPath, logger); + identity.Initialize(); + return string.IsNullOrWhiteSpace(identity.NodeDeviceToken) ? null : identity.NodeDeviceToken; + } + catch + { + return null; } - - throw new ArgumentException("Token or bootstrap token is required.", nameof(token)); } /// @@ -186,7 +205,7 @@ protected override async Task ProcessMessageAsync(string json) try { // Log raw messages at debug level (visible in dbgview, not in log file noise) - _logger.Debug($"[NODE RX] {json}"); + _logger.Debug($"[NODE RX] {TokenSanitizer.Sanitize(json)}"); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; @@ -499,7 +518,7 @@ private async Task HandleConnectChallengeAsync(JsonElement root) private async Task SendNodeConnectAsync(string? nonce, long ts) { - var isPaired = !string.IsNullOrEmpty(_deviceIdentity.DeviceToken); + var isPaired = !string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken); var usingBootstrap = !isPaired && !string.IsNullOrEmpty(_bootstrapToken); _logger.Info($"Connecting with Ed25519 device identity (paired: {isPaired}, bootstrap: {usingBootstrap})"); @@ -569,9 +588,9 @@ private string BuildNodeConnectMessage(string? nonce, long ts) private (Dictionary Auth, string TokenForSignature) BuildConnectAuth() { - if (!string.IsNullOrEmpty(_deviceIdentity.DeviceToken)) + if (!string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken)) { - return (new Dictionary { ["token"] = _deviceIdentity.DeviceToken }, _deviceIdentity.DeviceToken); + return (new Dictionary { ["deviceToken"] = _deviceIdentity.NodeDeviceToken }, _deviceIdentity.NodeDeviceToken); } if (!string.IsNullOrEmpty(_bootstrapToken)) @@ -627,7 +646,7 @@ private void HandleResponse(JsonElement root) _isPaired = true; _pairingApprovedAwaitingReconnect = false; _logger.Info("Received device token - we are now paired!"); - _deviceIdentity.StoreDeviceToken(deviceToken); + _deviceIdentity.StoreDeviceTokenForRole("node", deviceToken, TryGetAuthScopes(authPayload)); PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( PairingStatus.Paired, _deviceIdentity.DeviceId, @@ -641,7 +660,7 @@ private void HandleResponse(JsonElement root) // Skip this block if we already fired PairingStatusChanged above via gotNewToken. if (!gotNewToken) { - if (string.IsNullOrEmpty(_deviceIdentity.DeviceToken)) + if (string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken)) { if (reconnectingAfterApproval) { @@ -731,7 +750,7 @@ private void HandleRequestError(JsonElement root) return; } - _logger.Error($"Node registration failed: {error} (code: {errorCode})"); + _logger.Error($"Node registration failed: {TokenSanitizer.Sanitize(error)} (code: {errorCode})"); RaiseStatusChanged(ConnectionStatus.Error); } @@ -779,6 +798,27 @@ private static bool TryGetString(JsonElement element, string propertyName, out s value = prop.GetString(); return !string.IsNullOrWhiteSpace(value); } + + private static string[]? TryGetAuthScopes(JsonElement authPayload) + { + if (!authPayload.TryGetProperty("scopes", out var scopes) || scopes.ValueKind != JsonValueKind.Array) + { + return null; + } + + var values = new List(); + foreach (var item in scopes.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + var value = item.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + values.Add(value); + } + } + + return values.Count == 0 ? null : values.Distinct(StringComparer.Ordinal).ToArray(); + } private async Task HandleRequestAsync(JsonElement root) { @@ -930,16 +970,8 @@ private async Task SendErrorResponseAsync(string requestId, string error) } /// - /// Send a generic node-event to the gateway. Mirrors the Android - /// GatewaySession.sendNodeEvent wire shape: a JSON-RPC request with - /// method node.event and params { event, payloadJSON }, - /// where payloadJSON is the inner payload as a *string*, not a - /// nested object. The gateway's node-event dispatcher - /// (server-node-events.ts) then re-parses it. - /// - /// Returns false when not connected so callers can surface a status to the - /// renderer (e.g. clear a button-loading spinner with an error). Throws on - /// argument problems but swallows transport-layer errors as false. + /// Sends a node.event request with JSON payload. + /// Returns false when not connected or when the transport send fails. /// public async Task SendNodeEventAsync(string eventName, System.Text.Json.Nodes.JsonObject payload) { @@ -947,9 +979,6 @@ public async Task SendNodeEventAsync(string eventName, System.Text.Json.No if (payload is null) throw new ArgumentNullException(nameof(payload)); if (!_isConnected) return false; - // payloadJSON is a STRING containing JSON, matching the Android wire - // shape and the gateway's parser at server-node-events.ts:380 which - // does JSON.parse(evt.payloadJSON). var msg = new { type = "req", diff --git a/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs b/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs index e69c676e..e02c130e 100644 --- a/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs @@ -41,31 +41,6 @@ public void Constructor_NormalizesGatewayUrl(string inputUrl, string expectedUrl } } - [Fact] - public void Constructor_AllowsStoredDeviceTokenWithoutGatewayOrBootstrapToken() - { - var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}"); - Directory.CreateDirectory(dataPath); - - try - { - var identity = new DeviceIdentity(dataPath); - identity.Initialize(); - identity.StoreDeviceToken("stored-device-token"); - - using var client = new WindowsNodeClient("ws://localhost:18789", "", dataPath); - - Assert.True(client.IsPaired); - } - finally - { - if (Directory.Exists(dataPath)) - { - Directory.Delete(dataPath, true); - } - } - } - /// /// Regression test: when hello-ok includes auth.deviceToken, PairingStatusChanged must /// fire exactly once — not twice (once from the token block and again from the DeviceToken @@ -185,13 +160,13 @@ public void HandleResponse_HelloOkWithStoredToken_FiresPairedOnceNotPending() { using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath); - // Pre-store a device token so the client is already paired + // Pre-store a node device token so the node client is already paired. var identityField = typeof(WindowsNodeClient).GetField( "_deviceIdentity", BindingFlags.NonPublic | BindingFlags.Instance); var identity = identityField!.GetValue(client)!; - var storeMethod = identity.GetType().GetMethod("StoreDeviceToken"); - storeMethod!.Invoke(identity, ["stored-device-token-xyz"]); + var storeMethod = identity.GetType().GetMethod("StoreDeviceTokenForRole"); + storeMethod!.Invoke(identity, ["node", "stored-device-token-xyz", null]); var pairingEvents = new List(); client.PairingStatusChanged += (_, e) => pairingEvents.Add(e); @@ -733,8 +708,8 @@ public void IsPaired_ReturnsTrue_AfterDeviceTokenStored() "_deviceIdentity", BindingFlags.NonPublic | BindingFlags.Instance); var identity = identityField!.GetValue(client)!; - var storeMethod = identity.GetType().GetMethod("StoreDeviceToken"); - storeMethod!.Invoke(identity, ["my-device-token"]); + var storeMethod = identity.GetType().GetMethod("StoreDeviceTokenForRole"); + storeMethod!.Invoke(identity, ["node", "my-device-token", null]); Assert.True(client.IsPaired); } @@ -793,15 +768,16 @@ public void BuildNodeConnectMessage_UsesStoredDeviceToken_OverBootstrapToken() "_deviceIdentity", BindingFlags.NonPublic | BindingFlags.Instance); var identity = identityField!.GetValue(client)!; - var storeMethod = identity.GetType().GetMethod("StoreDeviceToken"); - storeMethod!.Invoke(identity, ["stored-device-token"]); + var storeMethod = identity.GetType().GetMethod("StoreDeviceTokenForRole"); + storeMethod!.Invoke(identity, ["node", "stored-device-token", null]); var json = InvokeBuildNodeConnectMessage(client); using var doc = JsonDocument.Parse(json); var auth = doc.RootElement.GetProperty("params").GetProperty("auth"); var (_, tokenForSignature) = InvokeBuildConnectAuth(client); - Assert.Equal("stored-device-token", auth.GetProperty("token").GetString()); + Assert.Equal("stored-device-token", auth.GetProperty("deviceToken").GetString()); + Assert.False(auth.TryGetProperty("token", out _)); Assert.False(auth.TryGetProperty("bootstrapToken", out _)); Assert.Equal("stored-device-token", tokenForSignature); } From 98bdf77efea04c3ebdf16bed5db7691b5dbc2608 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 4 May 2026 12:59:01 -0700 Subject: [PATCH 05/56] feat(tray): port LocalGatewaySetup with loopback-only WSL setup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../LocalGatewaySetup/LocalGatewaySetup.cs | 2159 +++++++++++++++++ .../LocalGatewaySetup}/SetupCodeDecoder.cs | 49 +- .../LocalGatewaySetupTests.cs | 502 ++++ .../OpenClaw.Tray.Tests.csproj | 3 +- .../SetupCodeDecoderTests.cs | 14 + 5 files changed, 2700 insertions(+), 27 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs rename src/OpenClaw.Tray.WinUI/{Onboarding/Services => Services/LocalGatewaySetup}/SetupCodeDecoder.cs (51%) create mode 100644 tests/OpenClaw.Tray.Tests/LocalGatewaySetupTests.cs diff --git a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs new file mode 100644 index 00000000..a1b1a02a --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs @@ -0,0 +1,2159 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using OpenClaw.Shared; +using OpenClawTray.Onboarding.Services; +#if !OPENCLAW_TRAY_TESTS +using OpenClawTray.Services; +#endif + +namespace OpenClawTray.Services.LocalGatewaySetup; + +public enum LocalGatewaySetupPhase +{ + NotStarted, + Preflight, + ElevationCheck, + EnsureWslEnabled, + CreateWslInstance, + ConfigureWslInstance, + InstallOpenClawCli, + PrepareGatewayConfig, + InstallGatewayService, + StartGateway, + WaitForGateway, + MintBootstrapToken, + PairOperator, + CheckWindowsNodeReadiness, + PairWindowsTrayNode, + VerifyEndToEnd, + Complete, + Failed, + Cancelled +} + +public enum LocalGatewaySetupStatus +{ + Pending, + Running, + RequiresAdmin, + RequiresRestart, + Blocked, + FailedRetryable, + FailedTerminal, + Complete, + Cancelled +} + +public enum LocalGatewaySetupSeverity +{ + Info, + Warning, + Blocking +} + +public sealed record LocalGatewaySetupOptions +{ + public string DistroName { get; init; } = "OpenClawGateway"; + public string GatewayUrl { get; init; } = "ws://localhost:18789"; + public int GatewayPort { get; init; } = 18789; + public string GatewayServiceName { get; init; } = "openclaw-gateway"; + public string BaseDistroName { get; init; } = "Ubuntu-24.04"; + public string? InstanceInstallLocation { get; init; } + public string OpenClawInstallPrefix { get; init; } = "/opt/openclaw"; + public string OpenClawInstallVersion { get; init; } = "latest"; + public string OpenClawInstallMethod { get; init; } = "npm"; + public string OpenClawInstallerUrl { get; init; } = "https://openclaw.ai/install-cli.sh"; + public bool AllowExistingDistro { get; init; } + public bool EnableWindowsTrayNodeByDefault { get; init; } = true; +} + +public interface ILocalGatewaySetupEnvironment +{ + string? GetVariable(string name); +} + +public sealed class ProcessLocalGatewaySetupEnvironment : ILocalGatewaySetupEnvironment +{ + public string? GetVariable(string name) => Environment.GetEnvironmentVariable(name); +} + +public sealed record LocalGatewaySetupRuntimeConfiguration( + string? DistroName, + string? InstanceInstallLocation, + bool AllowExistingDistro) +{ + public const string DistroNameVariable = "OPENCLAW_WSL_DISTRO_NAME"; + public const string InstanceInstallLocationVariable = "OPENCLAW_WSL_INSTALL_LOCATION"; + public const string AllowExistingDistroVariable = "OPENCLAW_WSL_ALLOW_EXISTING_DISTRO"; + + public static LocalGatewaySetupRuntimeConfiguration FromEnvironment(ILocalGatewaySetupEnvironment? environment = null) + { + environment ??= new ProcessLocalGatewaySetupEnvironment(); + return new LocalGatewaySetupRuntimeConfiguration( + NullIfWhiteSpace(environment.GetVariable(DistroNameVariable)), + NullIfWhiteSpace(environment.GetVariable(InstanceInstallLocationVariable)), + IsTruthy(environment.GetVariable(AllowExistingDistroVariable))); + } + + private static bool IsTruthy(string? value) + { + return value is not null + && (value.Equals("1", StringComparison.OrdinalIgnoreCase) + || value.Equals("true", StringComparison.OrdinalIgnoreCase) + || value.Equals("yes", StringComparison.OrdinalIgnoreCase)); + } + + private static string? NullIfWhiteSpace(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value; +} + +public sealed record LocalGatewaySetupIssue( + string Code, + string Message, + LocalGatewaySetupSeverity Severity, + string? Detail = null); + +public sealed record LocalGatewaySetupPhaseRecord( + LocalGatewaySetupPhase Phase, + LocalGatewaySetupStatus Status, + DateTimeOffset StartedAtUtc, + DateTimeOffset? FinishedAtUtc = null, + string? Message = null); + +public sealed class LocalGatewaySetupState +{ + public int SchemaVersion { get; set; } = 1; + public string RunId { get; set; } = Guid.NewGuid().ToString("N"); + public LocalGatewaySetupPhase Phase { get; set; } = LocalGatewaySetupPhase.NotStarted; + public LocalGatewaySetupStatus Status { get; set; } = LocalGatewaySetupStatus.Pending; + public string DistroName { get; set; } = "OpenClawGateway"; + public string GatewayUrl { get; set; } = "ws://localhost:18789"; + public bool IsLocalOnly { get; set; } + public string? FailureCode { get; set; } + public string? UserMessage { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List Issues { get; set; } = new(); + public List History { get; set; } = new(); + + public static LocalGatewaySetupState Create(LocalGatewaySetupOptions options) + { + return new LocalGatewaySetupState + { + DistroName = options.DistroName, + GatewayUrl = LocalGatewayEndpointResolver.BuildLoopbackGatewayUrl(options) + }; + } + + public void StartPhase(LocalGatewaySetupPhase phase, string? message = null) + { + Phase = phase; + Status = LocalGatewaySetupStatus.Running; + UserMessage = message; + UpdatedAtUtc = DateTimeOffset.UtcNow; + History.Add(new LocalGatewaySetupPhaseRecord(phase, Status, UpdatedAtUtc, Message: message)); + } + + public void CompletePhase(LocalGatewaySetupPhase phase, string? message = null) + { + Phase = phase; + Status = phase == LocalGatewaySetupPhase.Complete + ? LocalGatewaySetupStatus.Complete + : LocalGatewaySetupStatus.Running; + UserMessage = message; + UpdatedAtUtc = DateTimeOffset.UtcNow; + + var index = History.FindLastIndex(x => x.Phase == phase && x.FinishedAtUtc is null); + if (index >= 0) + { + var record = History[index]; + History[index] = record with { Status = Status, FinishedAtUtc = UpdatedAtUtc, Message = message ?? record.Message }; + } + } + + public void Block(string code, string message, bool retryable = false, string? detail = null) + { + Phase = LocalGatewaySetupPhase.Failed; + Status = retryable ? LocalGatewaySetupStatus.FailedRetryable : LocalGatewaySetupStatus.FailedTerminal; + FailureCode = code; + UserMessage = message; + UpdatedAtUtc = DateTimeOffset.UtcNow; + Issues.Add(new LocalGatewaySetupIssue(code, message, retryable ? LocalGatewaySetupSeverity.Warning : LocalGatewaySetupSeverity.Blocking, detail)); + } +} + +public interface ILocalGatewaySetupStateStore +{ + Task LoadAsync(CancellationToken cancellationToken = default); + Task SaveAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default); +} + +public sealed class LocalGatewaySetupStateStore : ILocalGatewaySetupStateStore +{ + private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; + private readonly string _statePath; + + public LocalGatewaySetupStateStore(string? statePath = null) + { + _statePath = statePath ?? Path.Combine( + Environment.GetEnvironmentVariable("OPENCLAW_TRAY_LOCALAPPDATA_DIR") + ?? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OpenClawTray", + "setup-state.json"); + } + + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + if (!File.Exists(_statePath)) + return null; + + await using var stream = File.OpenRead(_statePath); + return await JsonSerializer.DeserializeAsync(stream, s_jsonOptions, cancellationToken); + } + + public async Task SaveAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) + { + var directory = Path.GetDirectoryName(_statePath); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + await using var stream = File.Create(_statePath); + await JsonSerializer.SerializeAsync(stream, state, s_jsonOptions, cancellationToken); + } +} + +public sealed record WslCommandResult(int ExitCode, string StandardOutput, string StandardError) +{ + public bool Success => ExitCode == 0; +} + +public sealed record WslDistroInfo(string Name, string State, int Version); +public sealed record WslStatusInfo(int? DefaultVersion, string? WslVersion, string? KernelVersion); + +public interface IWslCommandRunner +{ + Task RunAsync(IReadOnlyList arguments, CancellationToken cancellationToken = default); + Task> ListDistrosAsync(CancellationToken cancellationToken = default); + Task TerminateDistroAsync(string name, CancellationToken cancellationToken = default); + Task UnregisterDistroAsync(string name, CancellationToken cancellationToken = default); + Task RunInDistroAsync(string name, IReadOnlyList command, CancellationToken cancellationToken = default); +} + +public sealed class WslExeCommandRunner : IWslCommandRunner +{ + private readonly IOpenClawLogger _logger; + private readonly TimeSpan _defaultTimeout; + + public WslExeCommandRunner(IOpenClawLogger? logger = null, TimeSpan? defaultTimeout = null) + { + _logger = logger ?? NullLogger.Instance; + _defaultTimeout = defaultTimeout ?? TimeSpan.FromSeconds(30); + } + + public async Task> ListDistrosAsync(CancellationToken cancellationToken = default) + { + var result = await RunAsync(["--list", "--verbose"], cancellationToken); + return result.Success ? ParseDistroList(result.StandardOutput) : []; + } + + public Task RunAsync(IReadOnlyList arguments, CancellationToken cancellationToken = default) => + RunProcessAsync("wsl.exe", arguments, cancellationToken); + + public Task RunInDistroAsync(string name, IReadOnlyList command, CancellationToken cancellationToken = default) + { + var args = new List { "-d", name, "--" }; + args.AddRange(command); + return RunAsync(args, cancellationToken); + } + + public Task TerminateDistroAsync(string name, CancellationToken cancellationToken = default) => + RunAsync(["--terminate", name], cancellationToken); + + public Task UnregisterDistroAsync(string name, CancellationToken cancellationToken = default) => + RunAsync(["--unregister", name], cancellationToken); + + public static IReadOnlyList ParseDistroList(string output) + { + var distros = new List(); + foreach (var rawLine in output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + var line = rawLine.Replace("\0", string.Empty).Trim(); + if (line.Length == 0 || line.StartsWith("NAME", StringComparison.OrdinalIgnoreCase)) + continue; + + if (line[0] == '*') + line = line[1..].TrimStart(); + + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 3) + continue; + + if (!int.TryParse(parts[^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var version)) + continue; + + var state = parts[^2]; + var name = string.Join(" ", parts.Take(parts.Length - 2)); + if (!string.IsNullOrWhiteSpace(name)) + distros.Add(new WslDistroInfo(name, state, version)); + } + + return distros; + } + + public static WslStatusInfo ParseStatus(string output) + { + int? defaultVersion = null; + string? wslVersion = null; + string? kernelVersion = null; + + foreach (var rawLine in output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + var line = rawLine.Replace("\0", string.Empty).Trim(); + var separator = line.IndexOf(':'); + if (separator <= 0) + continue; + + var key = line[..separator].Trim(); + var value = line[(separator + 1)..].Trim(); + if (key.Equals("Default Version", StringComparison.OrdinalIgnoreCase) + && int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedDefaultVersion)) + { + defaultVersion = parsedDefaultVersion; + } + else if (key.Equals("WSL version", StringComparison.OrdinalIgnoreCase)) + { + wslVersion = value; + } + else if (key.Equals("Kernel version", StringComparison.OrdinalIgnoreCase)) + { + kernelVersion = value; + } + } + + return new WslStatusInfo(defaultVersion, wslVersion, kernelVersion); + } + + private async Task RunProcessAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken) + { + var psi = new ProcessStartInfo + { + FileName = fileName, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 + }; + + foreach (var argument in arguments) + psi.ArgumentList.Add(argument); + + _logger.Info($"[WSL] {fileName} {string.Join(" ", arguments.Select(RedactArgument))}"); + + using var process = new Process { StartInfo = psi }; + try + { + process.Start(); + } + catch (Exception ex) + { + return new WslCommandResult(-1, string.Empty, $"Failed to start wsl.exe: {ex.Message}"); + } + + var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_defaultTimeout); + try + { + await process.WaitForExitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (Exception ex) + { + _logger.Warn($"[WSL] Failed to kill timed-out process: {ex.Message}"); + } + + return new WslCommandResult(-1, await SafeReadAsync(stdoutTask), "wsl.exe timed out"); + } + + return new WslCommandResult( + process.ExitCode, + await SafeReadAsync(stdoutTask), + await SafeReadAsync(stderrTask)); + } + + private static async Task SafeReadAsync(Task task) + { + try + { + return await task; + } + catch (OperationCanceledException) + { + return string.Empty; + } + } + + private static string RedactArgument(string argument) => + SecretRedactor.Redact(argument.Contains("token", StringComparison.OrdinalIgnoreCase) + || argument.Contains("private", StringComparison.OrdinalIgnoreCase) + || argument.Contains("setupCode", StringComparison.OrdinalIgnoreCase) + ? "" + : argument); +} + +public sealed record LocalGatewayPreflightResult( + bool CanContinue, + bool RequiresAdmin, + bool RequiresRestart, + IReadOnlyList Issues); + +public enum SetupElevationOperation +{ + EnableWindowsSubsystemForLinux, + EnableVirtualMachinePlatform, + UpdateWsl +} + +public sealed record SetupElevationRequest( + SetupElevationOperation Operation, + string Reason, + IReadOnlyDictionary? Parameters = null); + +public sealed record SetupElevationResult( + bool Success, + bool RequiresRestart = false, + string? ErrorCode = null, + string? ErrorMessage = null); + +public interface ISetupElevationBroker +{ + IReadOnlySet SupportedOperations { get; } + Task ExecuteAsync(SetupElevationRequest request, CancellationToken cancellationToken = default); +} + +public sealed class UnavailableSetupElevationBroker : ISetupElevationBroker +{ + public IReadOnlySet SupportedOperations { get; } = new HashSet(); + + public Task ExecuteAsync(SetupElevationRequest request, CancellationToken cancellationToken = default) + { + return Task.FromResult(new SetupElevationResult( + false, + ErrorCode: "elevation_broker_unavailable", + ErrorMessage: "The OpenClaw setup elevation broker is not available.")); + } +} + +public interface IPortProbe +{ + bool IsPortAvailable(int port); +} + +public sealed class TcpPortProbe : IPortProbe +{ + public bool IsPortAvailable(int port) + { + try + { + using var listener = new TcpListener(System.Net.IPAddress.Loopback, port); + listener.Start(); + listener.Stop(); + return true; + } + catch (SocketException) + { + return false; + } + } +} + +public interface ILocalGatewayPreflightProbe +{ + Task RunAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default); +} + +public sealed class LocalGatewayPreflightProbe : ILocalGatewayPreflightProbe +{ + private readonly IWslCommandRunner _wsl; + private readonly IPortProbe _portProbe; + + public LocalGatewayPreflightProbe(IWslCommandRunner wsl, IPortProbe? portProbe = null) + { + _wsl = wsl; + _portProbe = portProbe ?? new TcpPortProbe(); + } + + public async Task RunAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default) + { + var issues = new List(); + + if (!OperatingSystem.IsWindows()) + issues.Add(new LocalGatewaySetupIssue("unsupported_os", "OpenClaw local WSL gateway setup requires Windows.", LocalGatewaySetupSeverity.Blocking)); + + if (Environment.Is64BitOperatingSystem is false) + issues.Add(new LocalGatewaySetupIssue("unsupported_architecture", "OpenClaw local WSL gateway setup requires a 64-bit Windows installation.", LocalGatewaySetupSeverity.Blocking)); + + var wslStatus = await _wsl.RunAsync(["--status"], cancellationToken); + if (!wslStatus.Success) + { + issues.Add(new LocalGatewaySetupIssue("wsl_unavailable", WslLogsHelp("WSL is not available or is blocked by policy."), LocalGatewaySetupSeverity.Blocking)); + } + else + { + var status = WslExeCommandRunner.ParseStatus(wslStatus.StandardOutput); + if (status.DefaultVersion == 1) + issues.Add(new LocalGatewaySetupIssue("wsl_default_version_1", "The host default WSL version is WSL1. OpenClaw creates its dedicated gateway instance as WSL2.", LocalGatewaySetupSeverity.Warning)); + } + + var distros = await _wsl.ListDistrosAsync(cancellationToken); + if (!options.AllowExistingDistro && distros.Any(d => string.Equals(d.Name, options.DistroName, StringComparison.OrdinalIgnoreCase))) + issues.Add(new LocalGatewaySetupIssue("distro_exists", $"A WSL distro named {options.DistroName} already exists.", LocalGatewaySetupSeverity.Blocking)); + + if (distros.Any(d => d.Version == 1)) + issues.Add(new LocalGatewaySetupIssue("wsl1_present", "WSL1 distros are present. OpenClaw uses WSL2 and does not modify existing distros.", LocalGatewaySetupSeverity.Warning)); + + if (!_portProbe.IsPortAvailable(options.GatewayPort)) + { + if (options.AllowExistingDistro && await IsExistingGatewayPortAsync(options, cancellationToken)) + { + issues.Add(new LocalGatewaySetupIssue( + "gateway_port_already_active", + $"Local gateway port {options.GatewayPort} is already served by the OpenClawGateway WSL instance; setup will resume against it.", + LocalGatewaySetupSeverity.Warning)); + } + else + { + issues.Add(new LocalGatewaySetupIssue("port_in_use", $"Local gateway port {options.GatewayPort} is already in use.", LocalGatewaySetupSeverity.Blocking)); + } + } + + var canContinue = issues.All(x => x.Severity != LocalGatewaySetupSeverity.Blocking); + return new LocalGatewayPreflightResult(canContinue, RequiresAdmin: false, RequiresRestart: false, issues); + } + + private async Task IsExistingGatewayPortAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken) + { + var script = string.Join("\n", new[] + { + "set -euo pipefail", + "if [ -s /var/lib/openclaw/gateway-token ]; then", + " xargs -r " + ShellQuote(options.OpenClawInstallPrefix + "/bin/openclaw") + " gateway status --json --require-rpc --url " + ShellQuote(LocalGatewayEndpointResolver.BuildLoopbackGatewayUrl(options)) + " --token message + " If WSL diagnostics are needed, follow aka.ms/wsllogs."; + + private static string ShellQuote(string value) => "'" + value.Replace("'", "'\"'\"'", StringComparison.Ordinal) + "'"; +} + +public sealed record WslInstanceInstallResult( + bool Success, + string? InstallLocation = null, + IReadOnlyList? Warnings = null, + string? ErrorCode = null, + string? ErrorMessage = null); + +public interface IWslInstanceInstaller +{ + Task EnsureInstalledAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default); +} + +public sealed class WslStoreInstanceInstaller : IWslInstanceInstaller +{ + private readonly IWslCommandRunner _wsl; + private readonly Action _createDirectory; + + public WslStoreInstanceInstaller(IWslCommandRunner wsl, Action? createDirectory = null) + { + _wsl = wsl; + _createDirectory = createDirectory ?? (path => Directory.CreateDirectory(path)); + } + + public async Task EnsureInstalledAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default) + { + var installLocation = ResolveInstallLocation(options); + var distros = await _wsl.ListDistrosAsync(cancellationToken); + if (distros.Any(d => string.Equals(d.Name, options.DistroName, StringComparison.OrdinalIgnoreCase) && d.Version == 2)) + { + return options.AllowExistingDistro + ? new WslInstanceInstallResult(true, installLocation, ["wsl_instance_already_exists"]) + : new WslInstanceInstallResult(false, installLocation, ErrorCode: "distro_exists", ErrorMessage: $"A WSL distro named {options.DistroName} already exists."); + } + + _createDirectory(installLocation); + var install = await _wsl.RunAsync([ + "--install", + options.BaseDistroName, + "--name", + options.DistroName, + "--location", + installLocation, + "--no-launch", + "--version", + "2"], cancellationToken); + + if (install.Success) + return new WslInstanceInstallResult(true, installLocation); + + var diagnostics = new List { $"wsl_install_exit_code={install.ExitCode}" }; + AddDiagnosticOutput(diagnostics, "wsl_install_stdout", install.StandardOutput); + AddDiagnosticOutput(diagnostics, "wsl_install_stderr", install.StandardError); + diagnostics.Add("wsl_logs=aka.ms/wsllogs"); + return new WslInstanceInstallResult( + false, + installLocation, + diagnostics, + "wsl_instance_install_failed", + WslLogsHelp("Creating the OpenClaw Gateway WSL instance failed.")); + } + + public static string ResolveInstallLocation(LocalGatewaySetupOptions options) + { + if (!string.IsNullOrWhiteSpace(options.InstanceInstallLocation)) + return options.InstanceInstallLocation; + + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OpenClawTray", + "wsl", + options.DistroName); + } + + private static void AddDiagnosticOutput(List diagnostics, string name, string value) + { + var sanitized = SanitizeForDiagnostic(value); + if (!string.IsNullOrWhiteSpace(sanitized)) + diagnostics.Add($"{name}={sanitized}"); + } + + private static string WslLogsHelp(string message) => message + " Follow aka.ms/wsllogs for WSL diagnostic collection instructions."; + + private static string SanitizeForDiagnostic(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var sanitized = SecretRedactor.Redact(value).Replace("\0", string.Empty).Trim(); + const int maxLength = 2000; + return sanitized.Length <= maxLength ? sanitized : sanitized[..maxLength] + "..."; + } +} + +public sealed record WslInstanceConfigurationResult( + bool Success, + IReadOnlyList? Warnings = null, + string? ErrorCode = null, + string? ErrorMessage = null); + +public interface IWslInstanceConfigurator +{ + Task ConfigureAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default); +} + +public sealed class WslFirstBootConfigurator : IWslInstanceConfigurator +{ + private readonly IWslCommandRunner _wsl; + + public WslFirstBootConfigurator(IWslCommandRunner wsl) + { + _wsl = wsl; + } + + public async Task ConfigureAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default) + { + if (options.AllowExistingDistro && await IsAlreadyConfiguredAsync(options, cancellationToken)) + return new WslInstanceConfigurationResult(true, ["wsl_instance_already_configured"]); + + var script = string.Join("\n", new[] + { + "set -euo pipefail", + "if ! id -u openclaw >/dev/null 2>&1; then useradd --create-home --shell /bin/bash openclaw; fi", + "install -d -m 0755 -o openclaw -g openclaw /home/openclaw/.openclaw", + "install -d -m 0755 -o openclaw -g openclaw " + ShellQuote(options.OpenClawInstallPrefix), + "install -d -m 0755 -o openclaw -g openclaw /var/lib/openclaw", + "install -d -m 0755 -o openclaw -g openclaw /var/log/openclaw", + "cat >/etc/wsl.conf <<'EOF'", + "[boot]", + "systemd=true", + "", + "[automount]", + "enabled=false", + "", + "[interop]", + "enabled=false", + "appendWindowsPath=false", + "", + "[user]", + "default=openclaw", + "EOF", + "cat >/etc/wsl-distribution.conf <<'EOF'", + "[oobe]", + "defaultName=openclaw", + "EOF", + "loginctl enable-linger openclaw || true", + "chown -R openclaw:openclaw /home/openclaw/.openclaw " + ShellQuote(options.OpenClawInstallPrefix) + " /var/lib/openclaw /var/log/openclaw" + }); + + var configure = await _wsl.RunAsync(["-d", options.DistroName, "-u", "root", "--", "bash", "-lc", script], cancellationToken); + if (!configure.Success) + { + return new WslInstanceConfigurationResult( + false, + ErrorCode: "wsl_firstboot_config_failed", + ErrorMessage: WslLogsHelp("Failed to configure the OpenClaw WSL instance.")); + } + + var warnings = new List(); + var setDefaultUser = await _wsl.RunAsync(["--manage", options.DistroName, "--set-default-user", "openclaw"], cancellationToken); + if (!setDefaultUser.Success) + warnings.Add("wsl_manage_set_default_user_failed"); + + var terminate = await _wsl.TerminateDistroAsync(options.DistroName, cancellationToken); + if (!terminate.Success) + { + return new WslInstanceConfigurationResult( + false, + warnings, + "wsl_instance_restart_failed", + WslLogsHelp("Failed to restart the OpenClaw WSL instance after writing WSL configuration.")); + } + + return new WslInstanceConfigurationResult(true, warnings); + } + + private async Task IsAlreadyConfiguredAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken) + { + var script = string.Join("\n", new[] + { + "set -euo pipefail", + "id -u openclaw >/dev/null", + "test -d /home/openclaw/.openclaw", + "test -d " + ShellQuote(options.OpenClawInstallPrefix), + "grep -q '^systemd=true$' /etc/wsl.conf", + "grep -q '^enabled=false$' /etc/wsl.conf", + "grep -q '^appendWindowsPath=false$' /etc/wsl.conf", + "grep -q '^default=openclaw$' /etc/wsl.conf" + }); + + var probe = await _wsl.RunAsync(["-d", options.DistroName, "-u", "root", "--", "bash", "-lc", script], cancellationToken); + return probe.Success; + } + + private static string WslLogsHelp(string message) => message + " Follow aka.ms/wsllogs for WSL diagnostic collection instructions."; + private static string ShellQuote(string value) => "'" + value.Replace("'", "'\"'\"'", StringComparison.Ordinal) + "'"; +} + +public sealed record OpenClawLinuxInstallerEvent(string? Event, string? Phase, string? Message, string RawLine); + +public sealed record OpenClawLinuxInstallResult( + bool Success, + IReadOnlyList? Events = null, + string? ErrorCode = null, + string? ErrorMessage = null, + string? Detail = null); + +public interface IOpenClawLinuxInstaller +{ + Task InstallAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default); +} + +public sealed class OpenClawInstallCliLinuxInstaller : IOpenClawLinuxInstaller +{ + private readonly IWslCommandRunner _wsl; + + public OpenClawInstallCliLinuxInstaller(IWslCommandRunner wsl) + { + _wsl = wsl; + } + + public async Task InstallAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default) + { + await StopExistingGatewayServiceAsync(options, cancellationToken); + + var script = string.Join(" ", new[] + { + "set -euo pipefail;", + "curl -fsSL --proto '=https' --tlsv1.2", + ShellQuote(options.OpenClawInstallerUrl), + "|", + "OPENCLAW_PREFIX=" + ShellQuote(options.OpenClawInstallPrefix), + "OPENCLAW_INSTALL_METHOD=" + ShellQuote(options.OpenClawInstallMethod), + "OPENCLAW_VERSION=" + ShellQuote(options.OpenClawInstallVersion), + "SHARP_IGNORE_GLOBAL_LIBVIPS=1", + "bash -s -- --json --prefix", + ShellQuote(options.OpenClawInstallPrefix), + "--version", + ShellQuote(options.OpenClawInstallVersion), + "--no-onboard" + }); + + var install = await _wsl.RunAsync(["-d", options.DistroName, "-u", "openclaw", "--", "bash", "-lc", script], cancellationToken); + var events = ParseInstallerEvents(install.StandardOutput); + if (!install.Success) + { + var detail = BuildCommandDiagnostic("openclaw_install", install); + return new OpenClawLinuxInstallResult(false, events, "openclaw_linux_install_failed", "The upstream OpenClaw Linux installer failed.", detail); + } + + var version = await _wsl.RunAsync(["-d", options.DistroName, "-u", "openclaw", "--", options.OpenClawInstallPrefix + "/bin/openclaw", "--version"], cancellationToken); + if (!version.Success) + { + var detail = BuildCommandDiagnostic("openclaw_cli_verify", version); + return new OpenClawLinuxInstallResult(false, events, "openclaw_cli_verify_failed", "The OpenClaw CLI was installed, but the installed binary could not be verified.", detail); + } + + return new OpenClawLinuxInstallResult(true, events); + } + + public static IReadOnlyList ParseInstallerEvents(string output) + { + var events = new List(); + foreach (var line in output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + var redactedLine = SecretRedactor.Redact(line); + try + { + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + events.Add(new OpenClawLinuxInstallerEvent( + TryGetString(root, "event"), + TryGetString(root, "phase"), + SecretRedactor.Redact(TryGetString(root, "message") ?? string.Empty), + redactedLine)); + } + catch (JsonException) + { + events.Add(new OpenClawLinuxInstallerEvent(null, null, redactedLine, redactedLine)); + } + } + + return events; + } + + private Task StopExistingGatewayServiceAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken) + { + const string serviceName = "openclaw-gateway.service"; + var script = string.Join("\n", new[] + { + "set +e", + "systemctl --user stop " + serviceName + " >/dev/null 2>&1", + "systemctl --user reset-failed " + serviceName + " >/dev/null 2>&1" + }); + return _wsl.RunAsync(["-d", options.DistroName, "-u", "openclaw", "--", "bash", "-lc", script], cancellationToken); + } + + private static string? TryGetString(JsonElement root, string propertyName) + { + if (root.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String) + return property.GetString(); + return null; + } + + private static string ShellQuote(string value) => "'" + value.Replace("'", "'\"'\"'", StringComparison.Ordinal) + "'"; + private static string BuildCommandDiagnostic(string prefix, WslCommandResult result) => DiagnosticFormatter.Build(prefix, result); +} + +public sealed record GatewayServiceOperationResult( + bool Success, + string? ErrorCode = null, + string? ErrorMessage = null, + string? Detail = null); + +public sealed record GatewayConfigurationResult( + bool Success, + string? ErrorCode = null, + string? ErrorMessage = null, + string? Detail = null); + +public interface IGatewayConfigurationPreparer +{ + Task PrepareAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default); +} + +public sealed class OpenClawCliGatewayConfigurationPreparer : IGatewayConfigurationPreparer +{ + private readonly IWslCommandRunner _wsl; + + public OpenClawCliGatewayConfigurationPreparer(IWslCommandRunner wsl) + { + _wsl = wsl; + } + + public async Task PrepareAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default) + { + var openClaw = ShellQuote(options.OpenClawInstallPrefix + "/bin/openclaw"); + var script = string.Join("\n", new[] + { + "set -euo pipefail", + "umask 077", + "if [ ! -s /var/lib/openclaw/gateway-token ]; then", + " od -An -N32 -tx1 /dev/urandom | tr -d '[:space:]' >/var/lib/openclaw/gateway-token", + "fi", + openClaw + " config set gateway.mode local", + openClaw + " config set gateway.port " + options.GatewayPort.ToString(CultureInfo.InvariantCulture) + " --strict-json", + openClaw + " config set gateway.auth.mode token", + "xargs -r " + openClaw + " config set gateway.auth.token "'" + value.Replace("'", "'\"'\"'", StringComparison.Ordinal) + "'"; +} + +public interface IGatewayServiceManager +{ + Task InstallAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default); + Task StartAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default); +} + +public sealed class OpenClawCliGatewayServiceManager : IGatewayServiceManager +{ + private readonly IWslCommandRunner _wsl; + + public OpenClawCliGatewayServiceManager(IWslCommandRunner wsl) + { + _wsl = wsl; + } + + public async Task InstallAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default) + { + await ResetFailedServiceStateAsync(options, cancellationToken); + var result = await RunOpenClawAsync(options, ["gateway", "install", "--force", "--port", options.GatewayPort.ToString(CultureInfo.InvariantCulture)], cancellationToken); + if (result.Success) + return new GatewayServiceOperationResult(true); + + var firstFailure = DiagnosticFormatter.Build("gateway_service_install", result); + if (IsSystemdStartLimitFailure(result)) + { + await ResetFailedServiceStateAsync(options, cancellationToken); + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + + var retry = await RunOpenClawAsync(options, ["gateway", "install", "--force", "--port", options.GatewayPort.ToString(CultureInfo.InvariantCulture)], cancellationToken); + if (retry.Success) + return new GatewayServiceOperationResult(true); + + return new GatewayServiceOperationResult(false, "gateway_service_install_failed", "Failed to install the upstream OpenClaw gateway service.", firstFailure + Environment.NewLine + DiagnosticFormatter.Build("gateway_service_install_retry", retry)); + } + + return new GatewayServiceOperationResult(false, "gateway_service_install_failed", "Failed to install the upstream OpenClaw gateway service.", firstFailure); + } + + public async Task StartAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default) + { + var start = await RunOpenClawAsync(options, ["gateway", "start"], cancellationToken); + if (!start.Success) + return new GatewayServiceOperationResult(false, "gateway_service_start_failed", WslLogsHelp("Failed to start the upstream OpenClaw gateway service.")); + + WslCommandResult? lastStatus = null; + var deadline = DateTimeOffset.UtcNow.AddSeconds(90); + while (DateTimeOffset.UtcNow < deadline) + { + lastStatus = await RunStatusWithTokenAsync(options, cancellationToken); + if (lastStatus.Success) + return new GatewayServiceOperationResult(true); + + await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); + } + + return new GatewayServiceOperationResult( + false, + "gateway_service_status_failed", + WslLogsHelp("The OpenClaw gateway service started, but did not report ready status."), + lastStatus is null ? null : DiagnosticFormatter.Build("gateway_service_status", lastStatus)); + } + + private Task RunOpenClawAsync(LocalGatewaySetupOptions options, IReadOnlyList arguments, CancellationToken cancellationToken) + { + var args = new List { "-d", options.DistroName, "-u", "openclaw", "--", options.OpenClawInstallPrefix + "/bin/openclaw" }; + args.AddRange(arguments); + return _wsl.RunAsync(args, cancellationToken); + } + + private Task RunStatusWithTokenAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken) + { + var script = string.Join("\n", new[] + { + "set -euo pipefail", + "xargs -r " + ShellQuote(options.OpenClawInstallPrefix + "/bin/openclaw") + + " gateway status --json --require-rpc --url " + + ShellQuote(LocalGatewayEndpointResolver.BuildLoopbackGatewayUrl(options)) + + " --token ResetFailedServiceStateAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken) + { + const string serviceName = "openclaw-gateway.service"; + var script = string.Join("\n", new[] + { + "set +e", + "systemctl --user reset-failed " + serviceName + " >/dev/null 2>&1" + }); + return _wsl.RunAsync(["-d", options.DistroName, "-u", "openclaw", "--", "bash", "-lc", script], cancellationToken); + } + + private static bool IsSystemdStartLimitFailure(WslCommandResult result) + { + var output = (result.StandardOutput ?? string.Empty) + "\n" + (result.StandardError ?? string.Empty); + return output.Contains("start-limit-hit", StringComparison.OrdinalIgnoreCase) + || output.Contains("Start request repeated too quickly", StringComparison.OrdinalIgnoreCase) + || output.Contains("systemctl restart failed", StringComparison.OrdinalIgnoreCase); + } + + private static string WslLogsHelp(string message) => message + " Follow aka.ms/wsllogs for WSL diagnostic collection instructions."; + private static string ShellQuote(string value) => "'" + value.Replace("'", "'\"'\"'", StringComparison.Ordinal) + "'"; +} + +internal static partial class SecretRedactor +{ + [GeneratedRegex("(?i)(setup[_-]?code|bootstrap[_-]?token|device[_-]?token|gateway[_-]?token|auth[_-]?token|private[_-]?key(?:base64)?|public[_-]?key(?:base64)?|secret)(['\\\"\\s:=]+)([^\\s,'\\\"}]+)")] + private static partial Regex SecretValueRegex(); + + public static string Redact(string value) + { + if (string.IsNullOrEmpty(value)) + return value; + + return SecretValueRegex().Replace(value, "$1$2"); + } +} + +internal static class DiagnosticFormatter +{ + public static string Build(string prefix, WslCommandResult result) + { + var diagnostics = new List { $"{prefix}_exit_code={result.ExitCode}" }; + AddOutput(diagnostics, $"{prefix}_stdout", result.StandardOutput); + AddOutput(diagnostics, $"{prefix}_stderr", result.StandardError); + return string.Join(Environment.NewLine, diagnostics); + } + + private static void AddOutput(List diagnostics, string name, string value) + { + var sanitized = SanitizeForDiagnostic(value); + if (!string.IsNullOrWhiteSpace(sanitized)) + diagnostics.Add($"{name}={sanitized}"); + } + + private static string SanitizeForDiagnostic(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var sanitized = SecretRedactor.Redact(value).Replace("\0", string.Empty).Trim(); + const int maxLength = 2000; + return sanitized.Length <= maxLength ? sanitized : sanitized[..maxLength] + "..."; + } +} + +public sealed record LocalGatewayHealthResult(bool Success, string? Error = null); + +public sealed record LocalGatewayEndpointResolutionResult( + bool Success, + string GatewayUrl, + string? Error = null); + +public interface ILocalGatewayHealthProbe +{ + Task WaitForHealthyAsync(string gatewayUrl, CancellationToken cancellationToken = default); +} + +public sealed class LocalGatewayHealthProbe : ILocalGatewayHealthProbe +{ + public async Task WaitForHealthyAsync(string gatewayUrl, CancellationToken cancellationToken = default) + { + for (var attempt = 0; attempt < 10; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + var result = await GatewayHealthCheck.TestAsync(gatewayUrl, token: null); + if (result.Success) + return new LocalGatewayHealthResult(true); + + await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken); + } + + return new LocalGatewayHealthResult(false, WslLogsHelp("Gateway did not become healthy.")); + } + + private static string WslLogsHelp(string message) => message + " Follow aka.ms/wsllogs for WSL diagnostic collection instructions."; +} + +public interface ILocalGatewayEndpointResolver +{ + Task ResolveAsync( + LocalGatewaySetupOptions options, + string currentGatewayUrl, + ILocalGatewayHealthProbe healthProbe, + IWslCommandRunner wsl, + CancellationToken cancellationToken = default); +} + +public sealed class LocalGatewayEndpointResolver : ILocalGatewayEndpointResolver +{ + public Task ResolveAsync( + LocalGatewaySetupOptions options, + string currentGatewayUrl, + ILocalGatewayHealthProbe healthProbe, + IWslCommandRunner wsl, + CancellationToken cancellationToken = default) + { + return ResolveLoopbackAsync(options, healthProbe, cancellationToken); + } + + public static string BuildLoopbackGatewayUrl(LocalGatewaySetupOptions options) + { + var scheme = Uri.TryCreate(options.GatewayUrl, UriKind.Absolute, out var uri) ? uri.Scheme : "ws"; + return $"{scheme}://localhost:{options.GatewayPort}"; + } + + private static async Task ResolveLoopbackAsync(LocalGatewaySetupOptions options, ILocalGatewayHealthProbe healthProbe, CancellationToken cancellationToken) + { + var gatewayUrl = BuildLoopbackGatewayUrl(options); + var result = await healthProbe.WaitForHealthyAsync(gatewayUrl, cancellationToken); + return result.Success + ? new LocalGatewayEndpointResolutionResult(true, gatewayUrl) + : new LocalGatewayEndpointResolutionResult(false, gatewayUrl, result.Error ?? WslLogsHelp("Gateway did not become healthy.")); + } + + private static string WslLogsHelp(string message) => message + " Follow aka.ms/wsllogs for WSL diagnostic collection instructions."; +} + +public sealed record ProvisioningResult(bool Success, string? ErrorCode = null, string? ErrorMessage = null); + +public interface IOperatorPairingService +{ + Task PairAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default); +} + +public sealed record BootstrapTokenResult( + bool Success, + string? BootstrapToken = null, + DateTimeOffset? ExpiresAtUtc = null, + string? ErrorCode = null, + string? ErrorMessage = null); + +public interface IBootstrapTokenProvider +{ + Task MintAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default); +} + +public interface IBootstrapTokenProvisioner +{ + Task MintAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default); +} + +public interface IWindowsTrayNodeProvisioner +{ + Task CheckReadinessAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default); + Task PairAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default); +} + +public interface ILocalGatewaySetupSettings +{ + string GatewayUrl { get; set; } + string Token { get; set; } + string BootstrapToken { get; set; } + bool UseSshTunnel { get; set; } + bool EnableNodeMode { get; set; } + void Save(); +} + +public sealed class SettingsManagerLocalGatewaySetupSettings : ILocalGatewaySetupSettings +{ + private readonly SettingsManager _settings; + + public SettingsManagerLocalGatewaySetupSettings(SettingsManager settings) + { + _settings = settings; + } + + public string GatewayUrl { get => _settings.GatewayUrl; set => _settings.GatewayUrl = value; } + public string Token { get => _settings.Token; set => _settings.Token = value; } + public string BootstrapToken { get => _settings.BootstrapToken; set => _settings.BootstrapToken = value; } + public bool UseSshTunnel { get => _settings.UseSshTunnel; set => _settings.UseSshTunnel = value; } + public bool EnableNodeMode { get => _settings.EnableNodeMode; set => _settings.EnableNodeMode = value; } + public void Save() => _settings.Save(); +} + +public sealed class DeferredBootstrapTokenProvisioner : IBootstrapTokenProvisioner +{ + public Task MintAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) => + Task.FromResult(new ProvisioningResult(true)); +} + +public interface IWindowsNodeConnector +{ + Task ConnectAsync(string gatewayUrl, string token, string? bootstrapToken, CancellationToken cancellationToken = default); +} + +#if !OPENCLAW_TRAY_TESTS +public sealed class NodeServiceWindowsNodeConnector : IWindowsNodeConnector +{ + private readonly NodeService _nodeService; + + public NodeServiceWindowsNodeConnector(NodeService nodeService) + { + _nodeService = nodeService; + } + + public async Task ConnectAsync(string gatewayUrl, string token, string? bootstrapToken, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + await _nodeService.ConnectAsync(gatewayUrl, token, bootstrapToken); + var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(35); + while (DateTimeOffset.UtcNow < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_nodeService.IsConnected && _nodeService.IsPaired) + return; + + await Task.Delay(250, cancellationToken); + } + + throw new TimeoutException("Timed out waiting for the Windows tray node to pair with the gateway."); + } +} +#endif + +public static class WslDistroKeepAlive +{ + private static readonly object s_lock = new(); + private static readonly Dictionary s_processes = new(StringComparer.OrdinalIgnoreCase); + private static bool s_processExitRegistered; + + public static void EnsureStarted(string distroName, IOpenClawLogger? logger = null) + { + if (string.IsNullOrWhiteSpace(distroName)) + return; + + lock (s_lock) + { + if (s_processes.TryGetValue(distroName, out var existing) && !existing.HasExited) + return; + + try + { + var process = Process.Start(new ProcessStartInfo + { + FileName = "wsl.exe", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + ArgumentList = { "-d", distroName, "-u", "openclaw", "--", "sleep", "2147483647" } + }); + + if (process == null) + { + logger?.Warn("Failed to start WSL keepalive process."); + return; + } + + s_processes[distroName] = process; + logger?.Info($"Started WSL keepalive process for {distroName} (PID {process.Id})."); + + if (!s_processExitRegistered) + { + AppDomain.CurrentDomain.ProcessExit += (_, _) => StopAll(); + s_processExitRegistered = true; + } + } + catch (Exception ex) + { + logger?.Warn($"Failed to start WSL keepalive process: {ex.Message}"); + } + } + } + + private static void StopAll() + { + lock (s_lock) + { + foreach (var process in s_processes.Values) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch + { + // Process exit cleanup is best-effort only. + } + } + + s_processes.Clear(); + } + } +} + +public enum GatewayOperatorConnectionStatus +{ + Connected, + PairingRequired, + AuthFailed, + Timeout, + Failed +} + +public sealed record GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus Status, string? ErrorMessage = null); + +public interface IGatewayOperatorConnector +{ + Task ConnectAsync(string gatewayUrl, string token, bool tokenIsBootstrapToken = false, CancellationToken cancellationToken = default); + Task ConnectWithStoredDeviceTokenAsync(string gatewayUrl, CancellationToken cancellationToken = default); +} + +public sealed class OpenClawGatewayOperatorConnector : IGatewayOperatorConnector +{ + private readonly IOpenClawLogger _logger; + private readonly TimeSpan _timeout; + + public OpenClawGatewayOperatorConnector(IOpenClawLogger? logger = null, TimeSpan? timeout = null) + { + _logger = logger ?? NullLogger.Instance; + _timeout = timeout ?? TimeSpan.FromSeconds(35); + } + + public async Task ConnectAsync(string gatewayUrl, string token, bool tokenIsBootstrapToken = false, CancellationToken cancellationToken = default) + { + using var client = new OpenClawGatewayClient(gatewayUrl, token, _logger, tokenIsBootstrapToken, bootstrapPairAsNode: false); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + client.StatusChanged += (_, status) => + { + if (status == ConnectionStatus.Connected) + completion.TrySetResult(new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.Connected)); + else if (status == ConnectionStatus.Error) + { + if (client.IsPairingRequired) + completion.TrySetResult(new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.PairingRequired, "Gateway requires pairing approval.")); + else if (client.IsAuthFailed) + completion.TrySetResult(new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.AuthFailed, "Gateway rejected operator authentication.")); + } + }; + + try + { + await client.ConnectAsync(); + var completed = await Task.WhenAny(completion.Task, Task.Delay(_timeout, cancellationToken)); + return completed == completion.Task + ? await completion.Task + : new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.Timeout, "Timed out waiting for operator handshake."); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.Failed, ex.Message); + } + finally + { + await client.DisconnectAsync(); + } + } + + public async Task ConnectWithStoredDeviceTokenAsync(string gatewayUrl, CancellationToken cancellationToken = default) + { + var dataPath = Path.Combine( + Environment.GetEnvironmentVariable("OPENCLAW_TRAY_APPDATA_DIR") + ?? Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenClawTray"); + var identity = new DeviceIdentity(dataPath, _logger); + identity.Initialize(); + + if (string.IsNullOrWhiteSpace(identity.DeviceToken)) + return new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.AuthFailed, "Gateway did not return a stored device token after bootstrap pairing."); + + return await ConnectAsync(gatewayUrl, identity.DeviceToken, tokenIsBootstrapToken: false, cancellationToken); + } +} + +public sealed class SettingsBootstrapTokenProvisioner : IBootstrapTokenProvisioner +{ + private readonly ILocalGatewaySetupSettings _settings; + private readonly IBootstrapTokenProvider _bootstrapTokenProvider; + + public SettingsBootstrapTokenProvisioner(ILocalGatewaySetupSettings settings, IBootstrapTokenProvider bootstrapTokenProvider) + { + _settings = settings; + _bootstrapTokenProvider = bootstrapTokenProvider; + } + + public async Task MintAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) + { + if (!string.IsNullOrWhiteSpace(_settings.Token) || !string.IsNullOrWhiteSpace(_settings.BootstrapToken)) + return new ProvisioningResult(true); + + var minted = await _bootstrapTokenProvider.MintAsync(state, cancellationToken); + if (!minted.Success || string.IsNullOrWhiteSpace(minted.BootstrapToken)) + { + return new ProvisioningResult( + false, + minted.ErrorCode ?? "bootstrap_token_missing", + minted.ErrorMessage ?? "Gateway did not return a bootstrap token."); + } + + _settings.BootstrapToken = minted.BootstrapToken; + _settings.Save(); + return new ProvisioningResult(true); + } +} + +public sealed class SettingsOperatorPairingService : IOperatorPairingService +{ + private readonly ILocalGatewaySetupSettings _settings; + private readonly IGatewayOperatorConnector? _connector; + + public SettingsOperatorPairingService(SettingsManager settings, IGatewayOperatorConnector? connector = null) + : this(new SettingsManagerLocalGatewaySetupSettings(settings), connector) + { + } + + public SettingsOperatorPairingService(ILocalGatewaySetupSettings settings, IGatewayOperatorConnector? connector = null) + { + _settings = settings; + _connector = connector; + } + + public async Task PairAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) + { + var credential = ResolveCredential(); + if (credential is null) + { + return new ProvisioningResult( + false, + "operator_credential_missing", + "A gateway token or bootstrap token is required before the tray can pair as an operator."); + } + + _settings.GatewayUrl = state.GatewayUrl; + _settings.UseSshTunnel = false; + _settings.Save(); + + if (_connector == null) + return new ProvisioningResult(true); + + var result = await _connector.ConnectAsync(state.GatewayUrl, credential.Value, credential.IsBootstrapToken, cancellationToken); + if (result.Status != GatewayOperatorConnectionStatus.Connected) + { + return result.Status switch + { + GatewayOperatorConnectionStatus.PairingRequired => new ProvisioningResult(false, "operator_pairing_required", result.ErrorMessage), + GatewayOperatorConnectionStatus.AuthFailed => new ProvisioningResult(false, "operator_auth_failed", result.ErrorMessage), + GatewayOperatorConnectionStatus.Timeout => new ProvisioningResult(false, "operator_pairing_timeout", result.ErrorMessage), + _ => new ProvisioningResult(false, "operator_pairing_failed", result.ErrorMessage ?? "Operator pairing failed.") + }; + } + + if (credential.IsBootstrapToken) + { + var reconnectResult = await _connector.ConnectWithStoredDeviceTokenAsync(state.GatewayUrl, cancellationToken); + if (reconnectResult.Status != GatewayOperatorConnectionStatus.Connected) + { + return reconnectResult.Status switch + { + GatewayOperatorConnectionStatus.PairingRequired => new ProvisioningResult(false, "operator_reconnect_pairing_required", reconnectResult.ErrorMessage), + GatewayOperatorConnectionStatus.AuthFailed => new ProvisioningResult(false, "operator_reconnect_auth_failed", reconnectResult.ErrorMessage), + GatewayOperatorConnectionStatus.Timeout => new ProvisioningResult(false, "operator_reconnect_timeout", reconnectResult.ErrorMessage), + _ => new ProvisioningResult(false, "operator_reconnect_failed", reconnectResult.ErrorMessage ?? "Operator reconnect with stored device token failed.") + }; + } + + _settings.Save(); + } + + return new ProvisioningResult(true); + } + + private ResolvedOperatorCredential? ResolveCredential() + { + if (!string.IsNullOrWhiteSpace(_settings.Token)) + return new ResolvedOperatorCredential(_settings.Token, false); + + if (!string.IsNullOrWhiteSpace(_settings.BootstrapToken)) + return new ResolvedOperatorCredential(_settings.BootstrapToken, true); + + return null; + } + + private sealed record ResolvedOperatorCredential(string Value, bool IsBootstrapToken); +} + +public sealed class WslGatewayCliBootstrapTokenProvider : IBootstrapTokenProvider +{ + private readonly IWslCommandRunner _wsl; + private readonly string _commandName; + + public WslGatewayCliBootstrapTokenProvider(IWslCommandRunner wsl, string commandName = "openclaw") + { + _wsl = wsl; + _commandName = commandName; + } + + public async Task MintAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) + { + var script = string.Join(" ", new[] + { + "set -euo pipefail;", + "if [ -f /var/lib/openclaw/gateway.env ]; then set -a; . /var/lib/openclaw/gateway.env; set +a; fi;", + "exec", + ShellQuote(_commandName), + "qr", + "--json", + "--url", + ShellQuote(state.GatewayUrl) + }); + var result = await _wsl.RunInDistroAsync(state.DistroName, ["bash", "-lc", script], cancellationToken); + if (!result.Success) + return new BootstrapTokenResult(false, ErrorCode: "bootstrap_token_command_failed", ErrorMessage: "Gateway bootstrap-token command failed."); + + return ParseQrJson(result.StandardOutput); + } + + public static BootstrapTokenResult ParseQrJson(string output) + { + try + { + using var doc = JsonDocument.Parse(output); + var root = doc.RootElement; + if (!TryGetString(root, "bootstrapToken", out var token) + && !TryGetString(root, "bootstrap_token", out token) + && !TryGetString(root, "token", out token)) + { + if (!TryGetString(root, "setupCode", out var setupCode) + && !TryGetString(root, "setup_code", out setupCode)) + { + return new BootstrapTokenResult(false, ErrorCode: "bootstrap_token_missing", ErrorMessage: "Gateway QR output did not include a bootstrap token or setup code."); + } + + var decoded = SetupCodeDecoder.Decode(setupCode); + if (!decoded.Success) + return new BootstrapTokenResult(false, ErrorCode: "setup_code_invalid", ErrorMessage: decoded.Error ?? "Gateway setup code could not be decoded."); + + if (string.IsNullOrWhiteSpace(decoded.Token)) + return new BootstrapTokenResult(false, ErrorCode: "bootstrap_token_missing", ErrorMessage: "Gateway setup code did not include a bootstrap token."); + + token = decoded.Token; + } + + return new BootstrapTokenResult(true, token, TryGetExpiry(root)); + } + catch (JsonException ex) + { + return new BootstrapTokenResult(false, ErrorCode: "bootstrap_token_json_invalid", ErrorMessage: ex.Message); + } + } + + private static bool TryGetString(JsonElement root, string propertyName, out string value) + { + if (root.TryGetProperty(propertyName, out var property) + && property.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(property.GetString())) + { + value = property.GetString()!; + return true; + } + + value = string.Empty; + return false; + } + + private static DateTimeOffset? TryGetExpiry(JsonElement root) + { + foreach (var name in new[] { "expiresAtMs", "expires_at_ms" }) + { + if (root.TryGetProperty(name, out var property) && property.TryGetInt64(out var ms)) + return DateTimeOffset.FromUnixTimeMilliseconds(ms); + } + + foreach (var name in new[] { "expiresAt", "expires_at", "expires", "expiry" }) + { + if (root.TryGetProperty(name, out var property) + && property.ValueKind == JsonValueKind.String + && DateTimeOffset.TryParse(property.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)) + return parsed; + } + + return null; + } + + private static string ShellQuote(string value) => "'" + value.Replace("'", "'\"'\"'", StringComparison.Ordinal) + "'"; +} + +public sealed class SettingsWindowsTrayNodeProvisioner : IWindowsTrayNodeProvisioner +{ + private readonly ILocalGatewaySetupSettings _settings; + private readonly IWindowsNodeConnector? _connector; + + public SettingsWindowsTrayNodeProvisioner(SettingsManager settings, IWindowsNodeConnector? connector = null) + : this(new SettingsManagerLocalGatewaySetupSettings(settings), connector) + { + } + + public SettingsWindowsTrayNodeProvisioner(ILocalGatewaySetupSettings settings, IWindowsNodeConnector? connector = null) + { + _settings = settings; + _connector = connector; + } + + public Task CheckReadinessAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(_settings.Token) + && string.IsNullOrWhiteSpace(_settings.BootstrapToken) + && !HasStoredNodeDeviceToken()) + { + return Task.FromResult(new ProvisioningResult( + false, + "windows_node_credential_missing", + "A gateway token, bootstrap token, or stored node device token is required before enabling the Windows tray node.")); + } + + return Task.FromResult(new ProvisioningResult(true)); + } + + public async Task PairAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) + { + var readiness = await CheckReadinessAsync(state, cancellationToken); + if (!readiness.Success) + return readiness; + + _settings.GatewayUrl = state.GatewayUrl; + _settings.UseSshTunnel = false; + _settings.EnableNodeMode = true; + _settings.Save(); + + if (_connector != null) + { + try + { + await _connector.ConnectAsync(state.GatewayUrl, _settings.Token, _settings.BootstrapToken, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return new ProvisioningResult(false, "windows_node_pairing_failed", ex.Message); + } + } + + return new ProvisioningResult(true); + } + + private static bool HasStoredNodeDeviceToken() + { + var dataPath = Path.Combine( + Environment.GetEnvironmentVariable("OPENCLAW_TRAY_APPDATA_DIR") + ?? Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenClawTray"); + + return WindowsNodeClient.HasStoredNodeDeviceToken(dataPath); + } +} + +public sealed class LocalGatewaySetupEngine +{ + private readonly LocalGatewaySetupOptions _options; + private readonly ILocalGatewaySetupStateStore _stateStore; + private readonly ILocalGatewayPreflightProbe _preflight; + private readonly IWslCommandRunner _wsl; + private readonly IWslInstanceInstaller _wslInstanceInstaller; + private readonly IWslInstanceConfigurator _wslInstanceConfigurator; + private readonly IOpenClawLinuxInstaller _openClawLinuxInstaller; + private readonly IGatewayConfigurationPreparer _gatewayConfigurationPreparer; + private readonly IGatewayServiceManager _gatewayServiceManager; + private readonly ILocalGatewayHealthProbe _healthProbe; + private readonly ILocalGatewayEndpointResolver _endpointResolver; + private readonly IBootstrapTokenProvisioner _bootstrapTokenProvisioner; + private readonly IOperatorPairingService _operatorPairing; + private readonly IWindowsTrayNodeProvisioner _windowsTrayNode; + private readonly IOpenClawLogger _logger; + + public event Action? StateChanged; + + public LocalGatewaySetupEngine( + LocalGatewaySetupOptions options, + ILocalGatewaySetupStateStore stateStore, + ILocalGatewayPreflightProbe preflight, + IWslCommandRunner wsl, + ILocalGatewayHealthProbe healthProbe, + IBootstrapTokenProvisioner bootstrapTokenProvisioner, + IOperatorPairingService operatorPairing, + IWindowsTrayNodeProvisioner windowsTrayNode, + IOpenClawLogger? logger = null, + IWslInstanceInstaller? wslInstanceInstaller = null, + IWslInstanceConfigurator? wslInstanceConfigurator = null, + IOpenClawLinuxInstaller? openClawLinuxInstaller = null, + IGatewayConfigurationPreparer? gatewayConfigurationPreparer = null, + IGatewayServiceManager? gatewayServiceManager = null, + ILocalGatewayEndpointResolver? endpointResolver = null) + { + _options = options; + _stateStore = stateStore; + _preflight = preflight; + _wsl = wsl; + _wslInstanceInstaller = wslInstanceInstaller ?? new WslStoreInstanceInstaller(wsl); + _wslInstanceConfigurator = wslInstanceConfigurator ?? new WslFirstBootConfigurator(wsl); + _openClawLinuxInstaller = openClawLinuxInstaller ?? new OpenClawInstallCliLinuxInstaller(wsl); + _gatewayConfigurationPreparer = gatewayConfigurationPreparer ?? new OpenClawCliGatewayConfigurationPreparer(wsl); + _gatewayServiceManager = gatewayServiceManager ?? new OpenClawCliGatewayServiceManager(wsl); + _healthProbe = healthProbe; + _endpointResolver = endpointResolver ?? new LocalGatewayEndpointResolver(); + _bootstrapTokenProvisioner = bootstrapTokenProvisioner; + _operatorPairing = operatorPairing; + _windowsTrayNode = windowsTrayNode; + _logger = logger ?? NullLogger.Instance; + } + + public async Task RunLocalOnlyAsync(CancellationToken cancellationToken = default) + { + var state = await _stateStore.LoadAsync(cancellationToken) ?? LocalGatewaySetupState.Create(_options); + state.DistroName = _options.DistroName; + state.GatewayUrl = LocalGatewayEndpointResolver.BuildLoopbackGatewayUrl(_options); + var distroExists = await HasDistroAsync(cancellationToken); + var resumingAfterInstanceStarted = IsCreateOrLater(state.Phase) && distroExists; + var preflightOptions = _options with { AllowExistingDistro = _options.AllowExistingDistro || resumingAfterInstanceStarted }; + + await RunPhaseAsync(state, LocalGatewaySetupPhase.Preflight, "Checking your PC", async () => + { + var result = await _preflight.RunAsync(preflightOptions, cancellationToken); + state.Issues = result.Issues.ToList(); + if (!result.CanContinue) + { + state.Block("preflight_blocked", "This PC is not ready for local WSL gateway setup."); + return false; + } + + if (result.RequiresRestart) + { + state.Status = LocalGatewaySetupStatus.RequiresRestart; + state.UserMessage = "Restart required before OpenClaw local gateway setup can continue."; + return false; + } + + if (result.RequiresAdmin) + { + state.Status = LocalGatewaySetupStatus.RequiresAdmin; + state.UserMessage = "Administrator approval is required before setup can continue."; + return false; + } + + return true; + }, cancellationToken); + + await RunPhaseAsync(state, LocalGatewaySetupPhase.EnsureWslEnabled, "Checking WSL support", () => Task.FromResult(state.Status == LocalGatewaySetupStatus.Running), cancellationToken); + + await RunPhaseAsync(state, LocalGatewaySetupPhase.CreateWslInstance, "Creating OpenClaw Gateway WSL instance", async () => + { + var installOptions = _options with { AllowExistingDistro = _options.AllowExistingDistro || resumingAfterInstanceStarted }; + var result = await _wslInstanceInstaller.EnsureInstalledAsync(installOptions, cancellationToken); + if (!result.Success) + { + var detail = string.Join(Environment.NewLine, result.Warnings ?? Array.Empty()); + if (!string.IsNullOrWhiteSpace(detail)) + _logger.Warn($"WSL instance install diagnostics: {SecretRedactor.Redact(detail)}"); + state.Block(result.ErrorCode ?? "wsl_instance_install_failed", result.ErrorMessage ?? "Failed to create the OpenClaw Gateway WSL instance.", retryable: true, detail: detail); + return false; + } + + foreach (var warning in result.Warnings ?? Array.Empty()) + _logger.Warn($"WSL instance install warning: {SecretRedactor.Redact(warning)}"); + + return true; + }, cancellationToken); + + await RunPhaseAsync(state, LocalGatewaySetupPhase.ConfigureWslInstance, "Configuring OpenClaw Gateway WSL instance", async () => + { + var result = await _wslInstanceConfigurator.ConfigureAsync(_options, cancellationToken); + if (!result.Success) + { + state.Block(result.ErrorCode ?? "wsl_instance_config_failed", result.ErrorMessage ?? "Failed to configure the OpenClaw Gateway WSL instance.", retryable: true); + return false; + } + + foreach (var warning in result.Warnings ?? Array.Empty()) + _logger.Warn($"WSL instance configuration warning: {SecretRedactor.Redact(warning)}"); + + return true; + }, cancellationToken); + + await RunPhaseAsync(state, LocalGatewaySetupPhase.InstallOpenClawCli, "Installing OpenClaw inside WSL", async () => + { + var result = await _openClawLinuxInstaller.InstallAsync(_options, cancellationToken); + if (!result.Success) + { + if (!string.IsNullOrWhiteSpace(result.Detail)) + _logger.Warn($"OpenClaw Linux installer diagnostics: {SecretRedactor.Redact(result.Detail)}"); + state.Block(result.ErrorCode ?? "openclaw_linux_install_failed", result.ErrorMessage ?? "The upstream OpenClaw Linux installer failed.", retryable: true, detail: result.Detail); + return false; + } + + return true; + }, cancellationToken); + + await RunPhaseAsync(state, LocalGatewaySetupPhase.PrepareGatewayConfig, "Preparing OpenClaw Gateway configuration", async () => + { + var result = await _gatewayConfigurationPreparer.PrepareAsync(_options, cancellationToken); + if (!result.Success) + { + if (!string.IsNullOrWhiteSpace(result.Detail)) + _logger.Warn($"Gateway configuration diagnostics: {SecretRedactor.Redact(result.Detail)}"); + state.Block(result.ErrorCode ?? "gateway_config_prepare_failed", result.ErrorMessage ?? "Failed to prepare OpenClaw Gateway configuration.", retryable: true, detail: result.Detail); + return false; + } + + return true; + }, cancellationToken); + + await RunPhaseAsync(state, LocalGatewaySetupPhase.InstallGatewayService, "Installing OpenClaw Gateway service", async () => + { + var result = await _gatewayServiceManager.InstallAsync(_options, cancellationToken); + if (!result.Success) + { + if (!string.IsNullOrWhiteSpace(result.Detail)) + _logger.Warn($"Gateway service install diagnostics: {SecretRedactor.Redact(result.Detail)}"); + state.Block(result.ErrorCode ?? "gateway_service_install_failed", result.ErrorMessage ?? "Failed to install the OpenClaw Gateway service.", retryable: true, detail: result.Detail); + return false; + } + + return true; + }, cancellationToken); + + await RunGatewayCliStartPhaseAsync(state, cancellationToken); + + await RunPhaseAsync(state, LocalGatewaySetupPhase.WaitForGateway, "Waiting for OpenClaw Gateway", async () => + { + var result = await _endpointResolver.ResolveAsync(_options, state.GatewayUrl, _healthProbe, _wsl, cancellationToken); + if (!result.Success) + { + state.Block("gateway_unhealthy", result.Error ?? WslLogsHelp("Gateway did not become healthy."), retryable: true); + return false; + } + + state.GatewayUrl = result.GatewayUrl; + return true; + }, cancellationToken); + + await RunProvisioningPhaseAsync(state, LocalGatewaySetupPhase.MintBootstrapToken, "Generating setup code", () => _bootstrapTokenProvisioner.MintAsync(state, cancellationToken), cancellationToken); + await RunProvisioningPhaseAsync(state, LocalGatewaySetupPhase.PairOperator, "Pairing tray operator", () => _operatorPairing.PairAsync(state, cancellationToken), cancellationToken); + + if (_options.EnableWindowsTrayNodeByDefault) + { + await RunProvisioningPhaseAsync(state, LocalGatewaySetupPhase.CheckWindowsNodeReadiness, "Checking Windows node readiness", () => _windowsTrayNode.CheckReadinessAsync(state, cancellationToken), cancellationToken); + await RunProvisioningPhaseAsync(state, LocalGatewaySetupPhase.PairWindowsTrayNode, "Pairing Windows tray node", () => _windowsTrayNode.PairAsync(state, cancellationToken), cancellationToken); + } + + await RunPhaseAsync(state, LocalGatewaySetupPhase.VerifyEndToEnd, "Verifying local gateway", () => Task.FromResult(state.Status == LocalGatewaySetupStatus.Running), cancellationToken); + + if (state.Status == LocalGatewaySetupStatus.Running) + { + state.IsLocalOnly = true; + state.CompletePhase(LocalGatewaySetupPhase.Complete, "Local OpenClaw gateway is ready."); + await SaveAndPublishAsync(state, cancellationToken); + } + + return state; + } + + private async Task RunGatewayCliStartPhaseAsync(LocalGatewaySetupState state, CancellationToken cancellationToken) + { + await RunPhaseAsync(state, LocalGatewaySetupPhase.StartGateway, "Starting OpenClaw Gateway", async () => + { + var result = await _gatewayServiceManager.StartAsync(_options, cancellationToken); + if (!result.Success) + { + if (!string.IsNullOrWhiteSpace(result.Detail)) + _logger.Warn($"Gateway service start diagnostics: {SecretRedactor.Redact(result.Detail)}"); + state.Block(result.ErrorCode ?? "gateway_service_start_failed", result.ErrorMessage ?? WslLogsHelp("Failed to start OpenClaw Gateway."), retryable: true, detail: result.Detail); + return false; + } + + WslDistroKeepAlive.EnsureStarted(_options.DistroName, _logger); + return true; + }, cancellationToken); + } + + private async Task RunProvisioningPhaseAsync(LocalGatewaySetupState state, LocalGatewaySetupPhase phase, string message, Func> action, CancellationToken cancellationToken) + { + await RunPhaseAsync(state, phase, message, async () => + { + var result = await action(); + if (!result.Success) + { + state.Block(result.ErrorCode ?? "provisioning_failed", result.ErrorMessage ?? message, retryable: true); + return false; + } + + return true; + }, cancellationToken); + } + + private async Task RunPhaseAsync(LocalGatewaySetupState state, LocalGatewaySetupPhase phase, string message, Func> action, CancellationToken cancellationToken) + { + if (state.Status is not LocalGatewaySetupStatus.Pending and not LocalGatewaySetupStatus.Running) + return; + + state.StartPhase(phase, message); + await SaveAndPublishAsync(state, cancellationToken); + bool completed; + try + { + completed = await action(); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.Error($"Local gateway setup phase {phase} failed.", ex); + state.Block($"{phase.ToString().ToLowerInvariant()}_failed", ex.Message, retryable: true, detail: SecretRedactor.Redact(ex.ToString())); + await SaveAndPublishAsync(state, cancellationToken); + return; + } + + if (completed && state.Status == LocalGatewaySetupStatus.Running) + { + state.CompletePhase(phase, message); + await SaveAndPublishAsync(state, cancellationToken); + } + else if (!completed) + { + await SaveAndPublishAsync(state, cancellationToken); + } + } + + private async Task SaveAndPublishAsync(LocalGatewaySetupState state, CancellationToken cancellationToken) + { + await _stateStore.SaveAsync(state, cancellationToken); + StateChanged?.Invoke(state); + } + + private async Task HasDistroAsync(CancellationToken cancellationToken) + { + var distros = await _wsl.ListDistrosAsync(cancellationToken); + return distros.Any(d => string.Equals(d.Name, _options.DistroName, StringComparison.OrdinalIgnoreCase) && d.Version == 2); + } + + private static bool IsCreateOrLater(LocalGatewaySetupPhase phase) + { + return phase is LocalGatewaySetupPhase.CreateWslInstance + or LocalGatewaySetupPhase.ConfigureWslInstance + or LocalGatewaySetupPhase.InstallOpenClawCli + or LocalGatewaySetupPhase.PrepareGatewayConfig + or LocalGatewaySetupPhase.InstallGatewayService + or LocalGatewaySetupPhase.StartGateway + or LocalGatewaySetupPhase.WaitForGateway + or LocalGatewaySetupPhase.MintBootstrapToken + or LocalGatewaySetupPhase.PairOperator + or LocalGatewaySetupPhase.CheckWindowsNodeReadiness + or LocalGatewaySetupPhase.PairWindowsTrayNode + or LocalGatewaySetupPhase.VerifyEndToEnd + or LocalGatewaySetupPhase.Complete; + } + + private static string WslLogsHelp(string message) => message + " Follow aka.ms/wsllogs for WSL diagnostic collection instructions."; +} + +public sealed record LocalGatewayLifecycleResult( + bool Success, + string? ErrorCode = null, + string? ErrorMessage = null, + IReadOnlyList? Steps = null); + +public sealed record LocalGatewayRemoveRequest( + bool ConfirmRemove, + bool ClearLocalCredentials, + bool PreserveRelayRegistration = true, + bool PreserveWorkerData = true); + +public interface ILocalGatewayLifecycleManager +{ + Task RepairAsync(CancellationToken cancellationToken = default); + Task RemoveAsync(LocalGatewayRemoveRequest request, CancellationToken cancellationToken = default); +} + +public sealed class LocalGatewayLifecycleManager : ILocalGatewayLifecycleManager +{ + private readonly LocalGatewaySetupOptions _options; + private readonly IWslCommandRunner _wsl; + private readonly ILocalGatewayHealthProbe _healthProbe; + private readonly ILocalGatewaySetupSettings? _settings; + + public LocalGatewayLifecycleManager(LocalGatewaySetupOptions options, IWslCommandRunner wsl, ILocalGatewayHealthProbe healthProbe, ILocalGatewaySetupSettings? settings = null) + { + _options = options; + _wsl = wsl; + _healthProbe = healthProbe; + _settings = settings; + } + + public async Task RepairAsync(CancellationToken cancellationToken = default) + { + var steps = new List(); + var distros = await _wsl.ListDistrosAsync(cancellationToken); + if (!distros.Any(d => d.Name.Equals(_options.DistroName, StringComparison.OrdinalIgnoreCase) && d.Version == 2)) + return Fail("distro_missing", $"The OpenClaw WSL distro '{_options.DistroName}' was not found.", steps); + + await _wsl.TerminateDistroAsync(_options.DistroName, cancellationToken); + steps.Add("distro_terminated"); + + var daemonReload = await RunInDistroAsRootAsync(["systemctl", "daemon-reload"], cancellationToken); + steps.Add("daemon_reloaded"); + if (!daemonReload.Success) + return Fail("daemon_reload_failed", "Failed to reload OpenClaw Gateway systemd units.", steps); + + var gateway = await RestartGatewayServiceAsync(steps, cancellationToken); + if (!gateway.Success) + return gateway; + + var health = await _healthProbe.WaitForHealthyAsync(LocalGatewayEndpointResolver.BuildLoopbackGatewayUrl(_options), cancellationToken); + steps.Add("gateway_health_checked"); + if (!health.Success) + return Fail("gateway_unhealthy", health.Error ?? WslLogsHelp("Gateway did not become healthy after repair."), steps); + + return new LocalGatewayLifecycleResult(true, Steps: steps); + } + + public async Task RemoveAsync(LocalGatewayRemoveRequest request, CancellationToken cancellationToken = default) + { + var steps = new List(); + if (!request.ConfirmRemove) + return Fail("confirmation_required", "Removing the local OpenClaw Gateway requires explicit confirmation.", steps); + + await _wsl.TerminateDistroAsync(_options.DistroName, cancellationToken); + steps.Add("distro_terminated"); + var unregister = await _wsl.UnregisterDistroAsync(_options.DistroName, cancellationToken); + steps.Add("distro_unregistered"); + if (!unregister.Success) + return Fail("distro_unregister_failed", $"Failed to unregister WSL distro '{_options.DistroName}'.", steps); + + if (request.ClearLocalCredentials && _settings is not null) + { + _settings.Token = string.Empty; + _settings.BootstrapToken = string.Empty; + _settings.EnableNodeMode = false; + _settings.UseSshTunnel = false; + _settings.Save(); + steps.Add("local_credentials_cleared"); + } + + if (request.PreserveRelayRegistration) + steps.Add("relay_registration_preserved"); + if (request.PreserveWorkerData) + steps.Add("worker_data_preserved"); + + return new LocalGatewayLifecycleResult(true, Steps: steps); + } + + private async Task RestartGatewayServiceAsync(List steps, CancellationToken cancellationToken) + { + var serviceName = _options.GatewayServiceName; + var enable = await RunInDistroAsRootAsync(["systemctl", "enable", "--now", $"{serviceName}.service"], cancellationToken); + steps.Add($"{serviceName}_enabled"); + if (!enable.Success) + return Fail("service_enable_failed", $"Failed to enable {serviceName}.service.", steps); + + var restart = await RunInDistroAsRootAsync(["systemctl", "restart", $"{serviceName}.service"], cancellationToken); + steps.Add($"{serviceName}_restarted"); + if (!restart.Success) + return Fail("service_restart_failed", $"Failed to restart {serviceName}.service.", steps); + + var active = await RunInDistroAsRootAsync(["systemctl", "is-active", "--quiet", $"{serviceName}.service"], cancellationToken); + steps.Add($"{serviceName}_active_checked"); + if (!active.Success) + return Fail("service_inactive", $"{serviceName}.service is not active after repair.", steps); + + return new LocalGatewayLifecycleResult(true, Steps: steps); + } + + private Task RunInDistroAsRootAsync(IReadOnlyList command, CancellationToken cancellationToken) + { + var args = new List { "-d", _options.DistroName, "-u", "root", "--" }; + args.AddRange(command); + return _wsl.RunAsync(args, cancellationToken); + } + + private static string WslLogsHelp(string message) => message + " Follow aka.ms/wsllogs for WSL diagnostic collection instructions."; + private static LocalGatewayLifecycleResult Fail(string errorCode, string errorMessage, IReadOnlyList steps) => new(false, errorCode, errorMessage, steps); +} + +public static class LocalGatewaySetupEngineFactory +{ + public static LocalGatewaySetupEngine CreateLocalOnly( + SettingsManager settings, + IOpenClawLogger? logger = null, +#if !OPENCLAW_TRAY_TESTS + NodeService? nodeService = null, +#endif + string? distroName = null, + string? instanceInstallLocation = null, + bool allowExistingDistro = false) + { + var runtime = LocalGatewaySetupRuntimeConfiguration.FromEnvironment(); + var options = new LocalGatewaySetupOptions + { + GatewayUrl = settings.GetEffectiveGatewayUrl(), + DistroName = string.IsNullOrWhiteSpace(distroName) ? runtime.DistroName ?? "OpenClawGateway" : distroName, + InstanceInstallLocation = string.IsNullOrWhiteSpace(instanceInstallLocation) ? runtime.InstanceInstallLocation : instanceInstallLocation, + AllowExistingDistro = allowExistingDistro || runtime.AllowExistingDistro, +#if OPENCLAW_TRAY_TESTS + EnableWindowsTrayNodeByDefault = settings.EnableNodeMode +#else + EnableWindowsTrayNodeByDefault = settings.EnableNodeMode || nodeService != null +#endif + }; + + var wsl = new WslExeCommandRunner(logger, TimeSpan.FromMinutes(30)); + var settingsAdapter = new SettingsManagerLocalGatewaySetupSettings(settings); + var operatorConnector = new OpenClawGatewayOperatorConnector(logger); + var bootstrapTokenProvider = new WslGatewayCliBootstrapTokenProvider(wsl, options.OpenClawInstallPrefix + "/bin/openclaw"); +#if OPENCLAW_TRAY_TESTS + IWindowsNodeConnector? windowsNodeConnector = null; +#else + IWindowsNodeConnector? windowsNodeConnector = nodeService == null ? null : new NodeServiceWindowsNodeConnector(nodeService); +#endif + + return new LocalGatewaySetupEngine( + options, + new LocalGatewaySetupStateStore(), + new LocalGatewayPreflightProbe(wsl), + wsl, + new LocalGatewayHealthProbe(), + new SettingsBootstrapTokenProvisioner(settingsAdapter, bootstrapTokenProvider), + new SettingsOperatorPairingService(settingsAdapter, operatorConnector), + new SettingsWindowsTrayNodeProvisioner(settingsAdapter, windowsNodeConnector), + logger); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/SetupCodeDecoder.cs b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/SetupCodeDecoder.cs similarity index 51% rename from src/OpenClaw.Tray.WinUI/Onboarding/Services/SetupCodeDecoder.cs rename to src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/SetupCodeDecoder.cs index d9985f5d..5f2bcdf0 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Services/SetupCodeDecoder.cs +++ b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/SetupCodeDecoder.cs @@ -6,8 +6,7 @@ namespace OpenClawTray.Onboarding.Services; /// -/// Decodes base64url-encoded setup codes into gateway URL and token. -/// Extracted from ConnectionPage for testability. +/// Decodes upstream OpenClaw setup codes into gateway URL and bootstrap token fields. /// public static class SetupCodeDecoder { @@ -24,10 +23,10 @@ public static DecodeResult Decode(string setupCode) string json; try { - // Base64url decode: replace URL-safe chars, add padding var b64 = setupCode.Trim().Replace('-', '+').Replace('_', '/'); var pad = b64.Length % 4; - if (pad > 0) b64 += new string('=', 4 - pad); + if (pad > 0) + b64 += new string('=', 4 - pad); json = Encoding.UTF8.GetString(Convert.FromBase64String(b64)); } @@ -41,34 +40,32 @@ public static DecodeResult Decode(string setupCode) try { - var doc = JsonDocument.Parse(json); - string? url = null; - string? token = null; + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var url = TryReadString(root, "url"); + if (!string.IsNullOrEmpty(url) && !GatewayUrlHelper.IsValidGatewayUrl(url)) + return new DecodeResult(false, Error: "Invalid gateway URL in setup code"); - if (doc.RootElement.TryGetProperty("url", out var urlProp)) - { - var decoded = urlProp.GetString() ?? ""; - if (!string.IsNullOrEmpty(decoded)) - { - if (!GatewayUrlHelper.IsValidGatewayUrl(decoded)) - return new DecodeResult(false, Error: "Invalid gateway URL in setup code"); - url = decoded; - } - } + var token = TryReadString(root, "bootstrapToken") + ?? TryReadString(root, "bootstrap_token") + ?? TryReadString(root, "token"); + if (token?.Length > 512) + token = null; - if (doc.RootElement.TryGetProperty("bootstrapToken", out var tokenProp)) - { - var decoded = tokenProp.GetString() ?? ""; - if (decoded.Length <= 512) - token = decoded; - // Token exceeding 512 chars is silently ignored (not set) - } - - return new DecodeResult(true, Url: url, Token: token); + return new DecodeResult(true, Url: string.IsNullOrEmpty(url) ? null : url, Token: token); } catch (JsonException ex) { return new DecodeResult(false, Error: $"Invalid JSON: {ex.Message}"); } } + + private static string? TryReadString(JsonElement root, string propertyName) + { + if (!root.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.String) + return null; + + var value = property.GetString(); + return string.IsNullOrEmpty(value) ? null : value; + } } diff --git a/tests/OpenClaw.Tray.Tests/LocalGatewaySetupTests.cs b/tests/OpenClaw.Tray.Tests/LocalGatewaySetupTests.cs new file mode 100644 index 00000000..7cc51b6f --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/LocalGatewaySetupTests.cs @@ -0,0 +1,502 @@ +using System.Text; +using OpenClawTray.Services.LocalGatewaySetup; + +namespace OpenClaw.Tray.Tests; + +public class LocalGatewaySetupTests +{ + [Fact] + public void ParseDistroList_ParsesVerboseWslOutput() + { + const string output = """ + NAME STATE VERSION + * Ubuntu Running 2 + OpenClawGateway Stopped 2 + Legacy Stopped 1 + """; + + var distros = WslExeCommandRunner.ParseDistroList(output); + + Assert.Equal(3, distros.Count); + Assert.Contains(distros, d => d.Name == "OpenClawGateway" && d.State == "Stopped" && d.Version == 2); + Assert.Contains(distros, d => d.Name == "Legacy" && d.Version == 1); + } + + [Fact] + public void ParseStatus_ReadsDefaultAndWslVersions() + { + const string output = """ + Default Version: 1 + WSL version: 2.1.5.0 + Kernel version: 5.15.146.1-2 + """; + + var status = WslExeCommandRunner.ParseStatus(output); + + Assert.Equal(1, status.DefaultVersion); + Assert.Equal("2.1.5.0", status.WslVersion); + Assert.Equal("5.15.146.1-2", status.KernelVersion); + } + + [Fact] + public void RuntimeConfiguration_ReadsOnlyCleanWslEnvironment() + { + var environment = new FakeSetupEnvironment(new Dictionary + { + [LocalGatewaySetupRuntimeConfiguration.DistroNameVariable] = "OpenClawGatewayE2E", + [LocalGatewaySetupRuntimeConfiguration.InstanceInstallLocationVariable] = @"C:\openclaw\wsl", + [LocalGatewaySetupRuntimeConfiguration.AllowExistingDistroVariable] = "1" + }); + + var config = LocalGatewaySetupRuntimeConfiguration.FromEnvironment(environment); + + Assert.Equal("OpenClawGatewayE2E", config.DistroName); + Assert.Equal(@"C:\openclaw\wsl", config.InstanceInstallLocation); + Assert.True(config.AllowExistingDistro); + } + + [Fact] + public async Task Preflight_BlocksExistingOpenClawDistro() + { + var runner = new FakeWslCommandRunner { Distros = [new WslDistroInfo("OpenClawGateway", "Stopped", 2)] }; + var preflight = new LocalGatewayPreflightProbe(runner, new FixedPortProbe(available: true)); + + var result = await preflight.RunAsync(new LocalGatewaySetupOptions()); + + Assert.False(result.CanContinue); + Assert.Contains(result.Issues, issue => issue.Code == "distro_exists" && issue.Severity == LocalGatewaySetupSeverity.Blocking); + } + + [Fact] + public async Task Preflight_WslStatusFailure_IncludesWslLogsHelp() + { + var runner = new FakeWslCommandRunner { WslStatusExitCode = 1 }; + var preflight = new LocalGatewayPreflightProbe(runner, new FixedPortProbe(available: true)); + + var result = await preflight.RunAsync(new LocalGatewaySetupOptions()); + + Assert.False(result.CanContinue); + Assert.Contains(result.Issues, issue => issue.Code == "wsl_unavailable" && issue.Message.Contains("aka.ms/wsllogs", StringComparison.Ordinal)); + } + + [Fact] + public async Task Preflight_AllowsExistingGatewayOwnedLoopbackPort_WhenExistingDistroAllowed() + { + var runner = new FakeWslCommandRunner + { + Distros = [new WslDistroInfo("OpenClawGateway", "Running", 2)], + CommandOutputByContains = { ["gateway status"] = "{\"ok\":true}" } + }; + var preflight = new LocalGatewayPreflightProbe(runner, new FixedPortProbe(available: false)); + + var result = await preflight.RunAsync(new LocalGatewaySetupOptions { AllowExistingDistro = true }); + + Assert.True(result.CanContinue); + Assert.Contains(result.Issues, issue => issue.Code == "gateway_port_already_active" && issue.Severity == LocalGatewaySetupSeverity.Warning); + Assert.Contains(runner.Commands, command => command.Count == 8 && command[7].Contains("--url 'ws://localhost:18789'", StringComparison.Ordinal)); + } + + [Fact] + public async Task WslStoreInstanceInstaller_UsesCraigApprovedInstallCommand_AndTrustsExitCode() + { + using var temp = new TempDirectory(); + var installLocation = System.IO.Path.Combine(temp.Path, "OpenClawGateway"); + var wsl = new FakeWslCommandRunner(); + var installer = new WslStoreInstanceInstaller(wsl, createDirectory: _ => { }); + + var result = await installer.EnsureInstalledAsync(new LocalGatewaySetupOptions { InstanceInstallLocation = installLocation }); + + Assert.True(result.Success); + Assert.Contains(wsl.Commands, command => command.SequenceEqual([ + "--install", + "Ubuntu-24.04", + "--name", + "OpenClawGateway", + "--location", + installLocation, + "--no-launch", + "--version", + "2"])); + Assert.DoesNotContain(wsl.Commands, command => command.Contains("--web-download")); + Assert.DoesNotContain(wsl.Commands, command => command.Contains("--from-file")); + } + + [Fact] + public async Task WslStoreInstanceInstaller_FailedInstall_IncludesWslLogsHelpWithoutPostconditionRecovery() + { + using var temp = new TempDirectory(); + var wsl = new FakeWslCommandRunner { InstallExitCode = 42 }; + var installer = new WslStoreInstanceInstaller(wsl, createDirectory: _ => { }); + + var result = await installer.EnsureInstalledAsync(new LocalGatewaySetupOptions { InstanceInstallLocation = temp.Path }); + + Assert.False(result.Success); + Assert.Equal("wsl_instance_install_failed", result.ErrorCode); + Assert.Contains("aka.ms/wsllogs", result.ErrorMessage!); + Assert.Single(wsl.Commands, command => command.Count > 0 && command[0] == "--install"); + Assert.DoesNotContain(wsl.Commands, command => command.SequenceEqual(["-d", "OpenClawGateway", "-u", "root", "--", "true"])); + } + + [Fact] + public async Task WslFirstBootConfigurator_WritesCraigWslConfigurationThroughWslExe() + { + var wsl = new FakeWslCommandRunner(); + var configurator = new WslFirstBootConfigurator(wsl); + + var result = await configurator.ConfigureAsync(new LocalGatewaySetupOptions()); + + Assert.True(result.Success); + var command = Assert.Single(wsl.Commands, command => command.Count == 8 && command[5] == "bash" && command[6] == "-lc"); + Assert.Contains("cat >/etc/wsl.conf", command[7]); + Assert.Contains("[automount]", command[7]); + Assert.Contains("enabled=false", command[7]); + Assert.Contains("appendWindowsPath=false", command[7]); + Assert.Contains("cat >/etc/wsl-distribution.conf", command[7]); + Assert.Contains("loginctl enable-linger openclaw", command[7]); + Assert.DoesNotContain("machine-id", command[7], StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("resolv.conf", command[7], StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(@"\\wsl", command[7], StringComparison.OrdinalIgnoreCase); + Assert.Contains(wsl.Commands, command => command.SequenceEqual(["--manage", "OpenClawGateway", "--set-default-user", "openclaw"])); + Assert.Contains(wsl.Commands, command => command.SequenceEqual(["--terminate", "OpenClawGateway"])); + } + + [Fact] + public async Task OpenClawInstallCliLinuxInstaller_UsesUpstreamInstallerAndRedactsEvents() + { + var wsl = new FakeWslCommandRunner + { + CommandOutputByContains = { ["install-cli.sh"] = "{\"event\":\"progress\",\"message\":\"bootstrapToken: secret-token\"}" } + }; + var installer = new OpenClawInstallCliLinuxInstaller(wsl); + + var result = await installer.InstallAsync(new LocalGatewaySetupOptions { OpenClawInstallVersion = "next" }); + + Assert.True(result.Success); + Assert.Contains(wsl.Commands, command => command.Count == 8 && command[7].Contains("https://openclaw.ai/install-cli.sh", StringComparison.Ordinal)); + Assert.Contains(wsl.Commands, command => command.SequenceEqual(["-d", "OpenClawGateway", "-u", "openclaw", "--", "/opt/openclaw/bin/openclaw", "--version"])); + Assert.DoesNotContain("secret-token", result.Events![0].RawLine); + Assert.Contains("", result.Events![0].RawLine); + } + + [Fact] + public async Task GatewayConfigurationPreparer_WritesLoopbackOnlyConfigWithoutBindOrTokenValue() + { + var wsl = new FakeWslCommandRunner(); + var preparer = new OpenClawCliGatewayConfigurationPreparer(wsl); + + var result = await preparer.PrepareAsync(new LocalGatewaySetupOptions()); + + Assert.True(result.Success); + Assert.Contains(wsl.Commands, command => + command.Count == 8 + && command[7].Contains("config set gateway.mode local", StringComparison.Ordinal) + && command[7].Contains("config set gateway.port 18789 --strict-json", StringComparison.Ordinal) + && command[7].Contains("config set gateway.auth.mode token", StringComparison.Ordinal) + && command[7].Contains("config set gateway.auth.token", StringComparison.Ordinal) + && !command[7].Contains("gateway.bind", StringComparison.Ordinal) + && !command[7].Contains("lan", StringComparison.Ordinal)); + } + + [Fact] + public async Task EndpointResolver_UsesOnlyLocalhostCandidate() + { + var wsl = new FakeWslCommandRunner { CommandOutputByContains = { ["hostname -I"] = "172.30.138.183" } }; + var health = new ReachableOnlyHealthProbe("ws://localhost:18789"); + var resolver = new LocalGatewayEndpointResolver(); + + var result = await resolver.ResolveAsync(new LocalGatewaySetupOptions { GatewayUrl = "ws://127.0.0.1:18789" }, "ws://127.0.0.1:18789", health, wsl); + + Assert.True(result.Success); + Assert.Equal("ws://localhost:18789", result.GatewayUrl); + Assert.Equal(["ws://localhost:18789"], health.Attempts); + Assert.DoesNotContain(wsl.Commands, command => string.Join(" ", command).Contains("hostname -I", StringComparison.Ordinal)); + } + + [Fact] + public async Task BootstrapTokenProvider_RunsGatewayQrCommandAndDecodesSetupCode() + { + var setupPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"url\":\"ws://localhost:18789\",\"bootstrapToken\":\"minted-token\"}")) + .Replace('+', '-') + .Replace('/', '_') + .TrimEnd('='); + var runner = new FakeWslCommandRunner { RunInDistroOutput = $"{{\"setupCode\":\"{setupPayload}\",\"expiresAtMs\":1893456000000}}" }; + var provider = new WslGatewayCliBootstrapTokenProvider(runner, "/opt/openclaw/bin/openclaw"); + + var result = await provider.MintAsync(new LocalGatewaySetupState { DistroName = "OpenClawGateway", GatewayUrl = "ws://localhost:18789" }); + + Assert.True(result.Success); + Assert.Equal("minted-token", result.BootstrapToken); + Assert.Equal(DateTimeOffset.FromUnixTimeMilliseconds(1893456000000), result.ExpiresAtUtc); + Assert.Contains(runner.Commands, command => command.Count == 3 && command[2].Contains("'/opt/openclaw/bin/openclaw' qr --json --url 'ws://localhost:18789'", StringComparison.Ordinal)); + } + + [Fact] + public async Task Engine_RunsCleanPhaseListThroughWindowsTrayNode() + { + using var temp = new TempDirectory(); + var statePath = System.IO.Path.Combine(temp.Path, "setup-state.json"); + var installLocation = System.IO.Path.Combine(temp.Path, "OpenClawGateway"); + var wsl = new FakeWslCommandRunner(); + var provisioning = new FakeProvisioner(); + var engine = new LocalGatewaySetupEngine( + new LocalGatewaySetupOptions { InstanceInstallLocation = installLocation }, + new LocalGatewaySetupStateStore(statePath), + new LocalGatewayPreflightProbe(wsl, new FixedPortProbe(available: true)), + wsl, + new SuccessfulHealthProbe(), + provisioning, + provisioning, + provisioning, + wslInstanceInstaller: new WslStoreInstanceInstaller(wsl, createDirectory: _ => { }), + wslInstanceConfigurator: new FakeWslInstanceConfigurator(), + openClawLinuxInstaller: new FakeOpenClawLinuxInstaller(), + gatewayConfigurationPreparer: new FakeGatewayConfigurationPreparer(), + gatewayServiceManager: new FakeGatewayServiceManager()); + + var state = await engine.RunLocalOnlyAsync(); + + Assert.Equal(LocalGatewaySetupStatus.Complete, state.Status); + Assert.True(state.IsLocalOnly); + Assert.Equal("ws://localhost:18789", state.GatewayUrl); + Assert.Contains(state.History, h => h.Phase == LocalGatewaySetupPhase.CreateWslInstance); + Assert.Contains(state.History, h => h.Phase == LocalGatewaySetupPhase.MintBootstrapToken); + Assert.Contains(state.History, h => h.Phase == LocalGatewaySetupPhase.PairOperator); + Assert.Contains(state.History, h => h.Phase == LocalGatewaySetupPhase.PairWindowsTrayNode); + Assert.DoesNotContain(state.History, h => h.Phase.ToString().Contains("Worker", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(state.History, h => h.Phase.ToString().Contains("Import", StringComparison.OrdinalIgnoreCase)); + Assert.Equal(1, provisioning.BootstrapMintCalls); + Assert.Equal(1, provisioning.OperatorPairCalls); + Assert.Equal(1, provisioning.WindowsNodeReadinessCalls); + Assert.Equal(1, provisioning.WindowsNodePairCalls); + } + + [Fact] + public async Task Engine_StopsBeforeInstall_WhenPreflightBlocks() + { + using var temp = new TempDirectory(); + var wsl = new FakeWslCommandRunner { Distros = [new WslDistroInfo("OpenClawGateway", "Stopped", 2)] }; + var provisioning = new FakeProvisioner(); + var engine = new LocalGatewaySetupEngine( + new LocalGatewaySetupOptions(), + new LocalGatewaySetupStateStore(System.IO.Path.Combine(temp.Path, "setup-state.json")), + new LocalGatewayPreflightProbe(wsl, new FixedPortProbe(available: true)), + wsl, + new SuccessfulHealthProbe(), + provisioning, + provisioning, + provisioning); + + var state = await engine.RunLocalOnlyAsync(); + + Assert.Equal(LocalGatewaySetupStatus.FailedTerminal, state.Status); + Assert.Equal("preflight_blocked", state.FailureCode); + Assert.DoesNotContain(wsl.Commands, command => command.Count > 0 && command[0] == "--install"); + } + + [Fact] + public async Task LifecycleManager_RepairTerminatesOnlyGatewayDistroAndRestartsGatewayService() + { + var wsl = new FakeWslCommandRunner { Distros = [new WslDistroInfo("OpenClawGateway", "Running", 2)] }; + var manager = new LocalGatewayLifecycleManager(new LocalGatewaySetupOptions(), wsl, new SuccessfulHealthProbe()); + + var result = await manager.RepairAsync(); + + Assert.True(result.Success); + Assert.Contains("distro_terminated", result.Steps!); + Assert.Contains(wsl.Commands, command => command.SequenceEqual(["--terminate", "OpenClawGateway"])); + Assert.Contains(wsl.Commands, command => command.SequenceEqual(["-d", "OpenClawGateway", "-u", "root", "--", "systemctl", "restart", "openclaw-gateway.service"])); + Assert.DoesNotContain(wsl.Commands, command => command.SequenceEqual(["--shutdown"])); + Assert.DoesNotContain(wsl.Commands, command => string.Join(" ", command).Contains("openclaw-worker", StringComparison.Ordinal)); + } + + [Fact] + public async Task LifecycleManager_RemoveUnregistersDistroAndClearsLocalCredentials() + { + var settings = new FakeSetupSettings { Token = "token", BootstrapToken = "bootstrap", EnableNodeMode = true }; + var wsl = new FakeWslCommandRunner { Distros = [new WslDistroInfo("OpenClawGateway", "Stopped", 2)] }; + var manager = new LocalGatewayLifecycleManager(new LocalGatewaySetupOptions(), wsl, new SuccessfulHealthProbe(), settings); + + var result = await manager.RemoveAsync(new LocalGatewayRemoveRequest(ConfirmRemove: true, ClearLocalCredentials: true)); + + Assert.True(result.Success); + Assert.Contains("OpenClawGateway", wsl.UnregisteredDistros); + Assert.Equal("", settings.Token); + Assert.Equal("", settings.BootstrapToken); + Assert.False(settings.EnableNodeMode); + Assert.True(settings.SaveCalled); + } + + private sealed class FakeWslCommandRunner : IWslCommandRunner + { + public List Distros { get; set; } = []; + public List UnregisteredDistros { get; } = []; + public List> Commands { get; } = []; + public int WslStatusExitCode { get; set; } + public string WslStatusOutput { get; set; } = ""; + public string RunInDistroOutput { get; set; } = ""; + public int InstallExitCode { get; set; } + public Dictionary CommandOutputByContains { get; } = new(); + + public Task RunAsync(IReadOnlyList arguments, CancellationToken cancellationToken = default) + { + Commands.Add(arguments); + if (arguments.SequenceEqual(["--status"])) + return Task.FromResult(new WslCommandResult(WslStatusExitCode, WslStatusOutput, "")); + + if (arguments.Count > 0 && arguments[0] == "--install") + return Task.FromResult(new WslCommandResult(InstallExitCode, "", InstallExitCode == 0 ? "" : "install failed")); + + var joined = string.Join(" ", arguments); + foreach (var pair in CommandOutputByContains) + { + if (joined.Contains(pair.Key, StringComparison.Ordinal)) + return Task.FromResult(new WslCommandResult(0, pair.Value, "")); + } + + return Task.FromResult(new WslCommandResult(0, "", "")); + } + + public Task> ListDistrosAsync(CancellationToken cancellationToken = default) => + Task.FromResult>(Distros.ToArray()); + + public Task TerminateDistroAsync(string name, CancellationToken cancellationToken = default) + { + Commands.Add(["--terminate", name]); + return Task.FromResult(new WslCommandResult(0, "", "")); + } + + public Task UnregisterDistroAsync(string name, CancellationToken cancellationToken = default) + { + UnregisteredDistros.Add(name); + Distros.RemoveAll(d => d.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + return Task.FromResult(new WslCommandResult(0, "", "")); + } + + public Task RunInDistroAsync(string name, IReadOnlyList command, CancellationToken cancellationToken = default) + { + Commands.Add(command); + return Task.FromResult(new WslCommandResult(0, RunInDistroOutput, "")); + } + } + + private sealed class FakeSetupEnvironment : ILocalGatewaySetupEnvironment + { + private readonly IReadOnlyDictionary _values; + public FakeSetupEnvironment(IReadOnlyDictionary values) => _values = values; + public string? GetVariable(string name) => _values.TryGetValue(name, out var value) ? value : null; + } + + private sealed class FixedPortProbe : IPortProbe + { + private readonly bool _available; + public FixedPortProbe(bool available) => _available = available; + public bool IsPortAvailable(int port) => _available; + } + + private sealed class SuccessfulHealthProbe : ILocalGatewayHealthProbe + { + public Task WaitForHealthyAsync(string gatewayUrl, CancellationToken cancellationToken = default) => + Task.FromResult(new LocalGatewayHealthResult(true)); + } + + private sealed class ReachableOnlyHealthProbe : ILocalGatewayHealthProbe + { + private readonly string _reachableGatewayUrl; + public ReachableOnlyHealthProbe(string reachableGatewayUrl) => _reachableGatewayUrl = reachableGatewayUrl; + public List Attempts { get; } = []; + public Task WaitForHealthyAsync(string gatewayUrl, CancellationToken cancellationToken = default) + { + Attempts.Add(gatewayUrl); + return Task.FromResult(gatewayUrl.Equals(_reachableGatewayUrl, StringComparison.OrdinalIgnoreCase) + ? new LocalGatewayHealthResult(true) + : new LocalGatewayHealthResult(false, "unreachable")); + } + } + + private sealed class FakeProvisioner : IBootstrapTokenProvisioner, IOperatorPairingService, IWindowsTrayNodeProvisioner + { + public int BootstrapMintCalls { get; private set; } + public int OperatorPairCalls { get; private set; } + public int WindowsNodeReadinessCalls { get; private set; } + public int WindowsNodePairCalls { get; private set; } + + public Task MintAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) + { + BootstrapMintCalls++; + return Task.FromResult(new ProvisioningResult(true)); + } + + Task IOperatorPairingService.PairAsync(LocalGatewaySetupState state, CancellationToken cancellationToken) + { + OperatorPairCalls++; + return Task.FromResult(new ProvisioningResult(true)); + } + + Task IWindowsTrayNodeProvisioner.CheckReadinessAsync(LocalGatewaySetupState state, CancellationToken cancellationToken) + { + WindowsNodeReadinessCalls++; + return Task.FromResult(new ProvisioningResult(true)); + } + + Task IWindowsTrayNodeProvisioner.PairAsync(LocalGatewaySetupState state, CancellationToken cancellationToken) + { + WindowsNodePairCalls++; + return Task.FromResult(new ProvisioningResult(true)); + } + } + + private sealed class FakeSetupSettings : ILocalGatewaySetupSettings + { + public string GatewayUrl { get; set; } = ""; + public string Token { get; set; } = ""; + public string BootstrapToken { get; set; } = ""; + public bool UseSshTunnel { get; set; } = true; + public bool EnableNodeMode { get; set; } + public bool SaveCalled { get; private set; } + public void Save() => SaveCalled = true; + } + + private sealed class FakeWslInstanceConfigurator : IWslInstanceConfigurator + { + public Task ConfigureAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default) => + Task.FromResult(new WslInstanceConfigurationResult(true)); + } + + private sealed class FakeOpenClawLinuxInstaller : IOpenClawLinuxInstaller + { + public Task InstallAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default) => + Task.FromResult(new OpenClawLinuxInstallResult(true)); + } + + private sealed class FakeGatewayConfigurationPreparer : IGatewayConfigurationPreparer + { + public Task PrepareAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default) => + Task.FromResult(new GatewayConfigurationResult(true)); + } + + private sealed class FakeGatewayServiceManager : IGatewayServiceManager + { + public Task InstallAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default) => + Task.FromResult(new GatewayServiceOperationResult(true)); + + public Task StartAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default) => + Task.FromResult(new GatewayServiceOperationResult(true)); + } + + private sealed class TempDirectory : IDisposable + { + public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "openclaw-tests-" + Guid.NewGuid().ToString("N")); + public TempDirectory() => Directory.CreateDirectory(Path); + public void Dispose() + { + try + { + Directory.Delete(Path, recursive: true); + } + catch + { + // Test cleanup best effort. + } + } + } +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 6f653ad2..36eb23da 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -35,7 +35,8 @@ - + + diff --git a/tests/OpenClaw.Tray.Tests/SetupCodeDecoderTests.cs b/tests/OpenClaw.Tray.Tests/SetupCodeDecoderTests.cs index 9191c671..adfda23e 100644 --- a/tests/OpenClaw.Tray.Tests/SetupCodeDecoderTests.cs +++ b/tests/OpenClaw.Tray.Tests/SetupCodeDecoderTests.cs @@ -69,6 +69,20 @@ public void Decode_TokenOnly_SucceedsWithNullUrl() Assert.Equal("abc123", result.Token); } + [Theory] + [InlineData("bootstrap_token")] + [InlineData("token")] + public void Decode_AlternateTokenPropertyNames_ReturnsToken(string propertyName) + { + var json = $$"""{"url":"ws://localhost:18789","{{propertyName}}":"abc123"}"""; + var code = ToBase64Url(json); + + var result = SetupCodeDecoder.Decode(code); + + Assert.True(result.Success); + Assert.Equal("abc123", result.Token); + } + [Fact] public void Decode_HttpUrl_AlsoValid() { From 4ab1ec69846383945566b5f1e5eed89c04195a85 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 4 May 2026 13:14:55 -0700 Subject: [PATCH 06/56] =?UTF-8?q?fix(tray):=20close=20Phase=203=20punch=20?= =?UTF-8?q?list=20=E2=80=94=20strip=20worker=20vocabulary,=20gate=20distro?= =?UTF-8?q?=20override?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove residual PreserveWorkerData property and worker_data_preserved step from LocalGatewayRemoveRequest/LocalGatewayLifecycleManager. Windows tray is the node; no WSL-worker vocabulary remains in product APIs. - Gate OPENCLAW_WSL_DISTRO_NAME env override and explicit distroName parameter behind #if DEBUG || OPENCLAW_TRAY_TESTS via ResolveDistroName helper. Production builds are now hard-locked to OpenClawGateway regardless of caller input. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../LocalGatewaySetup/LocalGatewaySetup.cs | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs index a1b1a02a..087482c9 100644 --- a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs +++ b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs @@ -100,7 +100,11 @@ public static LocalGatewaySetupRuntimeConfiguration FromEnvironment(ILocalGatewa { environment ??= new ProcessLocalGatewaySetupEnvironment(); return new LocalGatewaySetupRuntimeConfiguration( +#if DEBUG || OPENCLAW_TRAY_TESTS NullIfWhiteSpace(environment.GetVariable(DistroNameVariable)), +#else + null, +#endif NullIfWhiteSpace(environment.GetVariable(InstanceInstallLocationVariable)), IsTruthy(environment.GetVariable(AllowExistingDistroVariable))); } @@ -1995,8 +1999,7 @@ public sealed record LocalGatewayLifecycleResult( public sealed record LocalGatewayRemoveRequest( bool ConfirmRemove, bool ClearLocalCredentials, - bool PreserveRelayRegistration = true, - bool PreserveWorkerData = true); + bool PreserveRelayRegistration = true); public interface ILocalGatewayLifecycleManager { @@ -2071,8 +2074,6 @@ public async Task RemoveAsync(LocalGatewayRemoveReq if (request.PreserveRelayRegistration) steps.Add("relay_registration_preserved"); - if (request.PreserveWorkerData) - steps.Add("worker_data_preserved"); return new LocalGatewayLifecycleResult(true, Steps: steps); } @@ -2125,7 +2126,7 @@ public static LocalGatewaySetupEngine CreateLocalOnly( var options = new LocalGatewaySetupOptions { GatewayUrl = settings.GetEffectiveGatewayUrl(), - DistroName = string.IsNullOrWhiteSpace(distroName) ? runtime.DistroName ?? "OpenClawGateway" : distroName, + DistroName = ResolveDistroName(runtime, distroName), InstanceInstallLocation = string.IsNullOrWhiteSpace(instanceInstallLocation) ? runtime.InstanceInstallLocation : instanceInstallLocation, AllowExistingDistro = allowExistingDistro || runtime.AllowExistingDistro, #if OPENCLAW_TRAY_TESTS @@ -2156,4 +2157,14 @@ public static LocalGatewaySetupEngine CreateLocalOnly( new SettingsWindowsTrayNodeProvisioner(settingsAdapter, windowsNodeConnector), logger); } + + private static string ResolveDistroName(LocalGatewaySetupRuntimeConfiguration runtime, string? explicitDistroName) + { +#if DEBUG || OPENCLAW_TRAY_TESTS + // Test/dev seam only: shipping builds are locked to the Craig-approved OpenClawGateway instance name. + return string.IsNullOrWhiteSpace(explicitDistroName) ? runtime.DistroName ?? "OpenClawGateway" : explicitDistroName; +#else + return "OpenClawGateway"; +#endif + } } From 8cc32c62a50ecf0b62183702bede21eeb5877db7 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 4 May 2026 13:20:47 -0700 Subject: [PATCH 07/56] feat(tray): wire setup engine + shared identity path in App startup (Phase 4) - App.App now exposes CreateLocalGatewaySetupEngine() backed by LocalGatewaySetupEngineFactory.CreateLocalOnly. Onboarding pages (Phase 5) can request the engine; NodeService is materialized eagerly so the engine can pair the Windows tray node into the gateway it installs. - Add IdentityDataPath alongside DataPath (operator/node DeviceIdentity store at %APPDATA%\\OpenClawTray, OPENCLAW_TRAY_APPDATA_DIR override for tests). NodeService now accepts identityDataPath; WindowsNodeClient is constructed with it so node device tokens land in the same role-aware DeviceIdentity store as operator tokens (Phase 1 model: shared location, role distinction inside). - StartupSetupState.CanStartNodeGateway / RequiresSetup callsites now use IdentityDataPath so stored node device tokens are detected at the same path WindowsNodeClient writes them. - No prototype env-var rootfs/manifest overrides, dev-shim auto-accept, or worker-in-WSL wiring ported (Phase 3 already pruned those phases; nothing to strip in App). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 66 ++++++++++++++++++- .../Services/NodeService.cs | 13 +++- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 21f2bc4b..0b35c8af 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -8,6 +8,7 @@ using OpenClawTray.Services; using OpenClawTray.Windows; using OpenClawTray.Onboarding; +using OpenClawTray.Services.LocalGatewaySetup; using System; using System.Collections.Frozen; using System.Collections.Generic; @@ -46,6 +47,22 @@ public partial class App : Application /// public void EnsureSshTunnelStarted() => _sshTunnelService?.EnsureStarted(_settings); + /// + /// Creates the WSL local gateway setup engine using the current tray settings. + /// Onboarding pages (Phase 5) call this to drive the local-WSL setup flow; + /// the engine pairs the operator + Windows tray node into the gateway it + /// installs, so we eagerly materialize the NodeService when needed. + /// + public LocalGatewaySetupEngine CreateLocalGatewaySetupEngine() + { + var settings = _settings ?? new SettingsManager(); + var nodeService = EnsureNodeServiceForLocalGatewaySetup(settings); + return LocalGatewaySetupEngineFactory.CreateLocalOnly( + settings, + new AppLogger(), + nodeService); + } + /// /// Returns the HWND of the active onboarding window, or IntPtr.Zero if none. /// Used by onboarding pages that need to host file pickers / dialogs. @@ -135,6 +152,14 @@ public void ReinitializeGatewayClient(bool useBootstrapHandoffAuth = false) => ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OpenClawTray"); + // Operator/node identity store (DeviceIdentity). Lives at %APPDATA%\OpenClawTray + // by convention so it follows the user across machines via roaming profile. + // OPENCLAW_TRAY_APPDATA_DIR isolates a test/E2E identity store the same way + // OPENCLAW_TRAY_DATA_DIR isolates the per-machine data directory. + private static readonly string IdentityDataPath = Path.Combine( + Environment.GetEnvironmentVariable("OPENCLAW_TRAY_APPDATA_DIR") + ?? Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenClawTray"); private static readonly string CrashLogPath = Path.Combine(DataPath, "crash.log"); private static readonly string RunMarkerPath = Path.Combine(DataPath, "run.marker"); @@ -1054,7 +1079,7 @@ private void InitializeNodeService() if (!enableNode && !enableMcp) return; // Gateway connection requires auth (operator token, bootstrap token, or stored device token); MCP doesn't. - var canRunGateway = StartupSetupState.CanStartNodeGateway(_settings, DataPath); + var canRunGateway = StartupSetupState.CanStartNodeGateway(_settings, IdentityDataPath); if (enableNode && !canRunGateway && !enableMcp) { @@ -1077,7 +1102,8 @@ private void InitializeNodeService() DataPath, () => _keepAliveWindow?.Content as FrameworkElement, _settings, - enableMcpServer: enableMcp); + enableMcpServer: enableMcp, + identityDataPath: IdentityDataPath); _nodeService.StatusChanged += OnNodeStatusChanged; _nodeService.NotificationRequested += OnNodeNotificationRequested; _nodeService.PairingStatusChanged += OnPairingStatusChanged; @@ -1102,9 +1128,43 @@ private void InitializeNodeService() } } + private NodeService? EnsureNodeServiceForLocalGatewaySetup(SettingsManager settings) + { + if (_nodeService != null) + return _nodeService; + + if (_dispatcherQueue == null) + return null; + + try + { + _nodeService = new NodeService( + new AppLogger(), + _dispatcherQueue, + DataPath, + () => _keepAliveWindow?.Content as FrameworkElement, + settings, + enableMcpServer: settings.EnableMcpServer, + identityDataPath: IdentityDataPath); + _nodeService.StatusChanged += OnNodeStatusChanged; + _nodeService.NotificationRequested += OnNodeNotificationRequested; + _nodeService.PairingStatusChanged += OnPairingStatusChanged; + _nodeService.ChannelHealthUpdated += OnChannelHealthUpdated; + _nodeService.InvokeCompleted += OnNodeInvokeCompleted; + _nodeService.GatewaySelfUpdated += OnGatewaySelfUpdated; + return _nodeService; + } + catch (Exception ex) + { + Logger.Error($"Failed to initialize node service for local gateway setup: {ex}"); + _nodeService = null; + return null; + } + } + private static bool RequiresSetup(SettingsManager settings) { - return StartupSetupState.RequiresSetup(settings, DataPath); + return StartupSetupState.RequiresSetup(settings, IdentityDataPath); } private void OnNodeStatusChanged(object? sender, ConnectionStatus status) diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs index 7c5478cb..a9751de2 100644 --- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs @@ -73,6 +73,13 @@ public sealed class NodeService : IDisposable private TtsCapability? _ttsCapability; private TextToSpeechService? _textToSpeechService; private readonly string _dataPath; + // Identity store location for the role-aware DeviceIdentity. Defaults to + // _dataPath when no separate path is supplied (preserves existing test + // behavior that hands a single temp directory to NodeService). The Tray + // app supplies %APPDATA%\OpenClawTray here so node device tokens land in + // the same DeviceIdentity store as operator tokens (Phase 1 model: + // single shared location, role distinction inside). + private readonly string _identityDataPath; private string? _token; // Authoritative capability list — populated by RegisterCapabilities and @@ -141,11 +148,13 @@ public NodeService( string dataPath, Func? rootProvider = null, SettingsManager? settings = null, - bool enableMcpServer = false) + bool enableMcpServer = false, + string? identityDataPath = null) { _logger = logger; _dispatcherQueue = dispatcherQueue; _dataPath = dataPath; + _identityDataPath = string.IsNullOrWhiteSpace(identityDataPath) ? dataPath : identityDataPath; _rootProvider = rootProvider ?? (() => null); _settings = settings; _enableMcpServer = enableMcpServer; @@ -167,7 +176,7 @@ public async Task ConnectAsync(string gatewayUrl, string token, string? bootstra _logger.Info($"Starting Windows Node connection to {GatewayUrlHelper.SanitizeForDisplay(gatewayUrl)}"); _token = token; - _nodeClient = new WindowsNodeClient(gatewayUrl, token, _dataPath, _logger, bootstrapToken); + _nodeClient = new WindowsNodeClient(gatewayUrl, token, _identityDataPath, _logger, bootstrapToken); _nodeClient.StatusChanged += OnNodeStatusChanged; _nodeClient.PairingStatusChanged += OnPairingStatusChanged; _nodeClient.HealthReceived += OnNodeHealthReceived; From 43035cab3a4c74bb65cbd51ed755c4c36f5fbaab Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 4 May 2026 13:46:22 -0700 Subject: [PATCH 08/56] feat(onboarding): add SetupWarning + LocalSetupProgress routes and SetupPath state (Phase 5.1) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Onboarding/Services/OnboardingState.cs | 76 ++++++--- .../ConnectionPageTopologyTests.cs | 2 + .../OnboardingStateTests.cs | 152 +++++++++++++++--- 3 files changed, 193 insertions(+), 37 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs index 8bc1f595..4ada214e 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs @@ -16,7 +16,7 @@ public sealed class OnboardingState : IDisposable /// /// The currently displayed route. Updated by OnboardingApp on navigation. /// - public OnboardingRoute CurrentRoute { get; set; } = OnboardingRoute.Welcome; + public OnboardingRoute CurrentRoute { get; set; } = OnboardingRoute.SetupWarning; /// /// Raised when the current route changes to or from the Chat page. @@ -31,6 +31,22 @@ public sealed class OnboardingState : IDisposable /// public ConnectionMode Mode { get; set; } = ConnectionMode.Local; + /// + /// Forked-onboarding setup path (Phase 5). Null until the user picks a path + /// on . While null, the nav-bar + /// "Next" button is disabled on the SetupWarning page. + /// + public SetupPath? SetupPath { get; set; } + + /// + /// Raised by pages that want to advance the OnboardingApp programmatically + /// (e.g., the SetupWarning page's "Set up locally" / "Advanced setup" buttons, + /// the LocalSetupProgress page on auto-advance after success). + /// + public event EventHandler? AdvanceRequested; + + public void RequestAdvance() => AdvanceRequested?.Invoke(this, EventArgs.Empty); + /// /// Whether the onboarding chat page should be shown. /// @@ -68,32 +84,42 @@ public OnboardingState(SettingsManager settings) } /// - /// Returns the page order based on the selected mode and chat preference, - /// matching the macOS onboarding flow. + /// Returns the page order for the forked Phase-5 onboarding flow. + /// SetupWarning is page 0 in every flow; the user's choice on that page + /// () determines whether page 1 is the local-setup + /// progress page or the legacy advanced Connection page. /// public OnboardingRoute[] GetPageOrder() { - // Node mode: skip Wizard and Chat — node clients can't use operator RPCs + // Treat null SetupPath as Local for page-count purposes; the nav-bar + // Next button is disabled on SetupWarning until the user picks a path. + var path = SetupPath ?? Onboarding.Services.SetupPath.Local; + + // Node mode: skip Wizard and Chat — node clients can't use operator RPCs. if (Settings.EnableNodeMode) { - return Mode switch - { - ConnectionMode.Local or ConnectionMode.Wsl or ConnectionMode.Remote or ConnectionMode.Ssh => - [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Ready], - _ => // Later or unknown - [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Ready], - }; + return path == Onboarding.Services.SetupPath.Local + ? [OnboardingRoute.SetupWarning, OnboardingRoute.LocalSetupProgress, OnboardingRoute.Permissions, OnboardingRoute.Ready] + : [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Ready]; } + if (path == Onboarding.Services.SetupPath.Local) + { + // Local setup always runs the wizard locally after the gateway is up. + return ShowChat + ? [OnboardingRoute.SetupWarning, OnboardingRoute.LocalSetupProgress, OnboardingRoute.Wizard, OnboardingRoute.Permissions, OnboardingRoute.Chat, OnboardingRoute.Ready] + : [OnboardingRoute.SetupWarning, OnboardingRoute.LocalSetupProgress, OnboardingRoute.Wizard, OnboardingRoute.Permissions, OnboardingRoute.Ready]; + } + + // Advanced path: keep the legacy ConnectionMode-aware ordering. return (Mode, ShowChat) switch { - // Local-style flows (Local, WSL, SSH tunnel) all run wizard locally - (ConnectionMode.Local or ConnectionMode.Wsl or ConnectionMode.Ssh, true) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Wizard, OnboardingRoute.Permissions, OnboardingRoute.Chat, OnboardingRoute.Ready], - (ConnectionMode.Local or ConnectionMode.Wsl or ConnectionMode.Ssh, false) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Wizard, OnboardingRoute.Permissions, OnboardingRoute.Ready], - (ConnectionMode.Remote, true) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Chat, OnboardingRoute.Ready], - (ConnectionMode.Remote, false) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Ready], - (ConnectionMode.Later, _) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Ready], - _ => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Ready], + (ConnectionMode.Local or ConnectionMode.Wsl or ConnectionMode.Ssh, true) => [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Wizard, OnboardingRoute.Permissions, OnboardingRoute.Chat, OnboardingRoute.Ready], + (ConnectionMode.Local or ConnectionMode.Wsl or ConnectionMode.Ssh, false) => [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Wizard, OnboardingRoute.Permissions, OnboardingRoute.Ready], + (ConnectionMode.Remote, true) => [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Chat, OnboardingRoute.Ready], + (ConnectionMode.Remote, false) => [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Ready], + (ConnectionMode.Later, _) => [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Ready], + _ => [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Ready], }; } @@ -130,10 +156,22 @@ public enum ConnectionMode public enum OnboardingRoute { - Welcome, + SetupWarning, + LocalSetupProgress, Connection, Wizard, Permissions, Chat, Ready, } + +/// +/// Forked-onboarding setup path picked on . +/// +public enum SetupPath +{ + /// User chose "Set up locally" — run the WSL gateway setup engine. + Local, + /// User chose "Advanced setup" — fall through to the legacy ConnectionPage. + Advanced, +} diff --git a/tests/OpenClaw.Tray.Tests/ConnectionPageTopologyTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionPageTopologyTests.cs index 5745f1e3..ed5309cd 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionPageTopologyTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionPageTopologyTests.cs @@ -30,6 +30,7 @@ private static string CreateTempSettingsDirectory() public void GetPageOrder_WslMode_BehavesLikeLocal() { var s = CreateState(ConnectionMode.Wsl); + s.SetupPath = SetupPath.Advanced; // Wsl mode only reachable via Advanced fork (Phase 5) s.ShowChat = true; var pages = s.GetPageOrder(); Assert.Contains(OnboardingRoute.Wizard, pages); @@ -40,6 +41,7 @@ public void GetPageOrder_WslMode_BehavesLikeLocal() public void GetPageOrder_SshMode_BehavesLikeLocal() { var s = CreateState(ConnectionMode.Ssh); + s.SetupPath = SetupPath.Advanced; s.ShowChat = true; var pages = s.GetPageOrder(); Assert.Contains(OnboardingRoute.Wizard, pages); diff --git a/tests/OpenClaw.Tray.Tests/OnboardingStateTests.cs b/tests/OpenClaw.Tray.Tests/OnboardingStateTests.cs index da95fcb8..88daa7fd 100644 --- a/tests/OpenClaw.Tray.Tests/OnboardingStateTests.cs +++ b/tests/OpenClaw.Tray.Tests/OnboardingStateTests.cs @@ -25,24 +25,56 @@ private static string CreateTempSettingsDirectory() #region GetPageOrder [Fact] - public void GetPageOrder_LocalMode_IncludesWizard() + public void GetPageOrder_LocalPath_IncludesLocalSetupProgressAndWizard() { var state = CreateState(); + state.SetupPath = SetupPath.Local; state.Mode = ConnectionMode.Local; state.ShowChat = true; var pages = state.GetPageOrder(); Assert.Equal( - [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Wizard, + [OnboardingRoute.SetupWarning, OnboardingRoute.LocalSetupProgress, OnboardingRoute.Wizard, OnboardingRoute.Permissions, OnboardingRoute.Chat, OnboardingRoute.Ready], pages); } [Fact] - public void GetPageOrder_RemoteMode_ExcludesWizard() + public void GetPageOrder_AdvancedPath_IncludesConnectionPage() { var state = CreateState(); + state.SetupPath = SetupPath.Advanced; + state.Mode = ConnectionMode.Local; + state.ShowChat = true; + + var pages = state.GetPageOrder(); + + Assert.Equal( + [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Wizard, + OnboardingRoute.Permissions, OnboardingRoute.Chat, OnboardingRoute.Ready], + pages); + } + + [Fact] + public void GetPageOrder_NullSetupPath_DefaultsToLocalFlow() + { + var state = CreateState(); + Assert.Null(state.SetupPath); + state.ShowChat = true; + + var pages = state.GetPageOrder(); + + Assert.Contains(OnboardingRoute.SetupWarning, pages); + Assert.Contains(OnboardingRoute.LocalSetupProgress, pages); + Assert.DoesNotContain(OnboardingRoute.Connection, pages); + } + + [Fact] + public void GetPageOrder_AdvancedPath_RemoteMode_ExcludesWizard() + { + var state = CreateState(); + state.SetupPath = SetupPath.Advanced; state.Mode = ConnectionMode.Remote; state.ShowChat = true; @@ -54,9 +86,10 @@ public void GetPageOrder_RemoteMode_ExcludesWizard() } [Fact] - public void GetPageOrder_LaterMode_MinimalPages() + public void GetPageOrder_AdvancedPath_LaterMode_MinimalPages() { var state = CreateState(); + state.SetupPath = SetupPath.Advanced; state.Mode = ConnectionMode.Later; state.ShowChat = true; @@ -66,32 +99,48 @@ public void GetPageOrder_LaterMode_MinimalPages() Assert.DoesNotContain(OnboardingRoute.Permissions, pages); Assert.DoesNotContain(OnboardingRoute.Chat, pages); Assert.Equal( - [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Ready], + [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Ready], + pages); + } + + [Fact] + public void GetPageOrder_LocalPath_NodeMode_SkipsWizardAndChat() + { + var state = new OnboardingState(CreateSettings(enableNodeMode: true)); + state.SetupPath = SetupPath.Local; + state.Mode = ConnectionMode.Local; + state.ShowChat = true; + + var pages = state.GetPageOrder(); + + Assert.Equal( + [OnboardingRoute.SetupWarning, OnboardingRoute.LocalSetupProgress, OnboardingRoute.Permissions, OnboardingRoute.Ready], pages); } [Fact] - public void GetPageOrder_NodeMode_SkipsOperatorPages() + public void GetPageOrder_AdvancedPath_NodeMode_UsesConnectionPage() { var state = new OnboardingState(CreateSettings(enableNodeMode: true)); + state.SetupPath = SetupPath.Advanced; state.Mode = ConnectionMode.Local; state.ShowChat = true; var pages = state.GetPageOrder(); Assert.Equal( - [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Ready], + [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Ready], pages); } [Theory] - [InlineData(ConnectionMode.Local)] - [InlineData(ConnectionMode.Remote)] - [InlineData(ConnectionMode.Later)] - public void GetPageOrder_NoChatMode_ExcludesChat(ConnectionMode mode) + [InlineData(SetupPath.Local)] + [InlineData(SetupPath.Advanced)] + public void GetPageOrder_NoChat_ExcludesChat(SetupPath path) { var state = CreateState(); - state.Mode = mode; + state.SetupPath = path; + state.Mode = ConnectionMode.Local; state.ShowChat = false; var pages = state.GetPageOrder(); @@ -100,20 +149,87 @@ public void GetPageOrder_NoChatMode_ExcludesChat(ConnectionMode mode) } [Theory] - [InlineData(ConnectionMode.Local)] - [InlineData(ConnectionMode.Remote)] - [InlineData(ConnectionMode.Later)] - public void GetPageOrder_AlwaysStartsWithWelcomeAndEndsWithReady(ConnectionMode mode) + [InlineData(SetupPath.Local)] + [InlineData(SetupPath.Advanced)] + public void GetPageOrder_AlwaysStartsWithSetupWarningAndEndsWithReady(SetupPath path) { var state = CreateState(); - state.Mode = mode; + state.SetupPath = path; var pages = state.GetPageOrder(); - Assert.Equal(OnboardingRoute.Welcome, pages.First()); + Assert.Equal(OnboardingRoute.SetupWarning, pages.First()); Assert.Equal(OnboardingRoute.Ready, pages.Last()); } + [Fact] + public void GetPageOrder_NeverContainsRemovedWelcomeRoute() + { + // Welcome route was removed in Phase 5 and folded into SetupWarning. + var routes = Enum.GetValues().Select(r => r.ToString()).ToArray(); + Assert.DoesNotContain("Welcome", routes); + } + + #endregion + + #region SetupPath + + [Fact] + public void SetupPath_DefaultsToNull() + { + Assert.Null(CreateState().SetupPath); + } + + [Fact] + public void SetupPath_CanBeSetToLocal() + { + var state = CreateState(); + state.SetupPath = SetupPath.Local; + Assert.Equal(SetupPath.Local, state.SetupPath); + } + + [Fact] + public void SetupPath_CanBeSetToAdvanced() + { + var state = CreateState(); + state.SetupPath = SetupPath.Advanced; + Assert.Equal(SetupPath.Advanced, state.SetupPath); + } + + #endregion + + #region AdvanceRequested + + [Fact] + public void RequestAdvance_FiresAdvanceRequestedEvent() + { + var state = CreateState(); + var fired = false; + state.AdvanceRequested += (_, _) => fired = true; + + state.RequestAdvance(); + + Assert.True(fired); + } + + [Fact] + public void RequestAdvance_DoesNotThrow_WithoutHandler() + { + var state = CreateState(); + var ex = Record.Exception(() => state.RequestAdvance()); + Assert.Null(ex); + } + + #endregion + + #region CurrentRoute defaults + + [Fact] + public void CurrentRoute_DefaultsToSetupWarning() + { + Assert.Equal(OnboardingRoute.SetupWarning, CreateState().CurrentRoute); + } + #endregion #region Defaults From 6a5783a2f52ec71fe3f695a91f86bf342d592f1b Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 4 May 2026 13:46:29 -0700 Subject: [PATCH 09/56] feat(onboarding): SetupWarningPage with folded security notice (Phase 5.2) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Onboarding/Pages/SetupWarningPage.cs | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs new file mode 100644 index 00000000..5453c6c1 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs @@ -0,0 +1,104 @@ +using OpenClawTray.FunctionalUI; +using OpenClawTray.FunctionalUI.Core; +using OpenClawTray.Onboarding.Services; +using static OpenClawTray.FunctionalUI.Factories; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace OpenClawTray.Onboarding.Pages; + +/// +/// Page 0 of the forked Phase-5 onboarding flow. +/// +/// Layout contract (Mattingly Phase 5, decisions/inbox/mattingly-warning-page-layout.md): +/// +/// Grid +/// Rows: Auto (title), 1* (body+spacer), Auto (primary), Auto (hyperlink) +/// Columns: 1* +/// HAlign Center / VAlign Center / MaxWidth 460 +/// Row 0: TextBlock title — bold 22pt, centered +/// Row 1: TextBlock body — 14pt, 0.65 opacity, wrapping; security notice folded in +/// Row 2: Button "Set up locally" — accent fill, MinWidth 200, Height 44, centered +/// Row 3: Button "Advanced setup" styled as TextBlockButton (hyperlink), 8px top margin +/// +/// Picking either path sets and fires +/// ; +/// catches the event and navigates to the next page in the (re-derived) order. +/// The nav-bar Next button is disabled on this page until a path is chosen. +/// +public sealed class SetupWarningPage : Component +{ + public override Element Render() + { + const string TitleText = "Set up OpenClaw"; + // Body folds in the ⚠️ security notice (Mike's decision — WelcomePage removed). + const string BodyText = + "OpenClaw lets agents run commands, read and write files, and capture screenshots " + + "on this PC. Only set it up on a computer you trust.\n\n" + + "⚠️ The local setup installs a small WSL Linux instance dedicated to OpenClaw. " + + "If you'd rather connect to an existing or remote gateway, choose Advanced setup."; + + void ChooseLocal() + { + Props.SetupPath = Onboarding.Services.SetupPath.Local; + Props.Mode = ConnectionMode.Local; + Props.RequestAdvance(); + } + + void ChooseAdvanced() + { + Props.SetupPath = Onboarding.Services.SetupPath.Advanced; + Props.RequestAdvance(); + } + + return Grid( + columns: ["1*"], + rows: ["Auto", "1*", "Auto", "Auto"], + + TextBlock(TitleText) + .FontSize(22) + .FontWeight(new global::Windows.UI.Text.FontWeight(700)) + .HAlign(HorizontalAlignment.Center) + .TextWrapping() + .Grid(row: 0, column: 0), + + TextBlock(BodyText) + .FontSize(14) + .Opacity(0.65) + .HAlign(HorizontalAlignment.Center) + .VAlign(VerticalAlignment.Top) + .TextWrapping() + .Margin(0, 12, 0, 12) + .Grid(row: 1, column: 0), + + Button("Set up locally", ChooseLocal) + .MinWidth(200) + .Height(44) + .HAlign(HorizontalAlignment.Center) + .Set(b => + { + b.Style = (Style)Application.Current.Resources["AccentButtonStyle"]; + Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingSetupLocal"); + }) + .Grid(row: 2, column: 0), + + Button("Advanced setup", ChooseAdvanced) + .HAlign(HorizontalAlignment.Center) + .Margin(0, 8, 0, 0) + .Set(b => + { + if (Application.Current.Resources.TryGetValue("TextBlockButtonStyle", out var hyperStyle) && + hyperStyle is Style s) + { + b.Style = s; + } + Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingSetupAdvanced"); + }) + .Grid(row: 3, column: 0) + ) + .HAlign(HorizontalAlignment.Center) + .VAlign(VerticalAlignment.Center) + .MaxWidth(460) + .Padding(0, 8, 0, 0); + } +} From c2ad1e5acc9bd5cfd17f1932a213092f5d4ef15b Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 4 May 2026 13:46:38 -0700 Subject: [PATCH 10/56] feat(onboarding): LocalSetupProgressPage bound to LocalGatewaySetup engine (Phase 5.3) Wires AdvanceRequested into OnboardingApp, supports OPENCLAW_ONBOARDING_START_SETUP_PATH and OPENCLAW_VISUAL_TEST_LOCAL_SETUP for screenshot capture without running the real WSL engine. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Onboarding/OnboardingApp.cs | 28 +- .../Onboarding/OnboardingWindow.cs | 10 + .../Pages/LocalSetupProgressPage.cs | 338 ++++++++++++++++++ 3 files changed, 370 insertions(+), 6 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingApp.cs b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingApp.cs index 027ced77..a723e9eb 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingApp.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingApp.cs @@ -20,14 +20,14 @@ public sealed class OnboardingApp : Component public override Element Render() { // Seed navigation + page index from Props.CurrentRoute (used by visual tests via - // OPENCLAW_ONBOARDING_START_ROUTE; defaults to Welcome on normal launches). + // OPENCLAW_ONBOARDING_START_ROUTE; defaults to SetupWarning on normal launches). var pagesInit = Props.GetPageOrder(); var initialIdx = Math.Max(0, Array.IndexOf(pagesInit, Props.CurrentRoute)); var nav = UseNavigation(pagesInit[initialIdx]); var (pageIndex, setPageIndex) = UseState(initialIdx); var pages = Props.GetPageOrder(); - // Clamp pageIndex if page order changed (e.g., node mode toggled) + // Clamp pageIndex if page order changed (e.g., node mode toggled, SetupPath changed). if (pageIndex >= pages.Length) { setPageIndex(pages.Length - 1); @@ -35,12 +35,14 @@ public override Element Render() void GoNext() { - if (pageIndex < pages.Length - 1) + // Re-derive pages on each call so SetupPath changes (Local vs Advanced) take effect. + var current = Props.GetPageOrder(); + if (pageIndex < current.Length - 1) { setPageIndex(pageIndex + 1); - nav.Navigate(pages[pageIndex + 1]); + nav.Navigate(current[pageIndex + 1]); Props.NotifyPageChanged(); - Props.NotifyRouteChanged(pages[pageIndex + 1]); + Props.NotifyRouteChanged(current[pageIndex + 1]); } } @@ -55,7 +57,19 @@ void GoBack() } } + // Subscribe to programmatic advance requests (SetupWarningPage buttons, + // LocalSetupProgressPage auto-advance after success). + UseEffect(() => + { + EventHandler handler = (_, _) => GoNext(); + Props.AdvanceRequested += handler; + return () => Props.AdvanceRequested -= handler; + }, pageIndex); + var isLastPage = pageIndex >= pages.Length - 1; + var currentRoute = pages[pageIndex]; + // Requirement 8: nav-bar Next disabled on SetupWarning until path chosen. + var nextDisabled = currentRoute == OnboardingRoute.SetupWarning && Props.SetupPath == null; // VStack for functional UI content (icon + pages only). // The nav bar is rendered natively in OnboardingWindow for reliable bottom pinning. @@ -67,7 +81,8 @@ void GoBack() // Page content — fixed height prevents nav bar from jumping between pages (NavigationHost(nav, route => route switch { - OnboardingRoute.Welcome => Component(), + OnboardingRoute.SetupWarning => Component(Props), + OnboardingRoute.LocalSetupProgress => Component(Props), OnboardingRoute.Connection => Component(Props), OnboardingRoute.Ready => Component(Props), OnboardingRoute.Wizard => Component(Props), @@ -94,6 +109,7 @@ void GoBack() : Helpers.LocalizationHelper.GetString("Onboarding_Next"), isLastPage ? Props.Complete : GoNext) .Width(100) + .Disabled(nextDisabled) .Set(b => { Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingNext"); diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs index 81696713..b882ef4a 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs @@ -76,8 +76,18 @@ public OnboardingWindow(SettingsManager settings) if (!string.IsNullOrWhiteSpace(startRoute) && Enum.TryParse(startRoute, ignoreCase: true, out var parsed)) { + // Ensure SetupPath is consistent with the requested route so GetPageOrder + // produces the expected step indicator. Defaults can be overridden below. + if (parsed == OnboardingRoute.LocalSetupProgress) _state.SetupPath = SetupPath.Local; + else if (parsed == OnboardingRoute.Connection) _state.SetupPath = SetupPath.Advanced; _state.CurrentRoute = parsed; } + var startSetupPath = Environment.GetEnvironmentVariable("OPENCLAW_ONBOARDING_START_SETUP_PATH"); + if (!string.IsNullOrWhiteSpace(startSetupPath) && + Enum.TryParse(startSetupPath, ignoreCase: true, out var parsedPath)) + { + _state.SetupPath = parsedPath; + } // Optional override for visual tests: pre-select a connection mode (Local/Wsl/Remote/Ssh/Later). var startMode = Environment.GetEnvironmentVariable("OPENCLAW_ONBOARDING_START_MODE"); if (!string.IsNullOrWhiteSpace(startMode) && diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs new file mode 100644 index 00000000..39237afb --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs @@ -0,0 +1,338 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.UI.Xaml; +using OpenClawTray.FunctionalUI; +using OpenClawTray.FunctionalUI.Core; +using OpenClawTray.Onboarding.Services; +using OpenClawTray.Services.LocalGatewaySetup; +using static OpenClawTray.FunctionalUI.Factories; + +namespace OpenClawTray.Onboarding.Pages; + +/// +/// Page 1 of the Local fork (Phase 5). +/// +/// Drives via , +/// surfaces a small whitelist of user-meaningful stages, and auto-advances after a +/// 1-second pause once is reached. +/// On a Try again button restarts +/// the engine; on we surface the +/// message with an aka.ms/wsllogs hint and leave the user to back out. +/// +/// Layout contract (Mattingly Phase 5): +/// +/// Grid +/// Rows: Auto (title), Auto (subtitle), 1* (scrollable stages), Auto (error/retry) +/// Columns: 1* +/// Row 0: TextBlock — 22pt bold, centered +/// Row 1: TextBlock — 13pt, 0.65 opacity, wrapping, centered +/// Row 2: ScrollView wrapping VStack of per-stage Grid rows +/// Per stage: Grid columns Auto / 1* / Auto = icon | label | spinner-or-checkmark +/// States: Pending (0.4 opacity) / Active (spinner) / Complete (✅) / Failed (❌, red) +/// Row 3: Error/retry Grid (collapsed unless Failed*) — error TextBlock | Try again Button +/// +/// Hidden phases that emit subtitle only (per Mike's decision): ElevationCheck, +/// PairOperator, CheckWindowsNodeReadiness, PairWindowsTrayNode, VerifyEndToEnd. +/// +public sealed class LocalSetupProgressPage : Component +{ + // Engine lives across page navigations so back/forward doesn't cancel an in-flight setup. + private static LocalGatewaySetupEngine? s_engine; + private static Task? s_runTask; + private static bool s_advanceFiredForCompletion; + + private static readonly (string Label, LocalGatewaySetupPhase[] Phases)[] s_visibleStages = new[] + { + ("Checking system", new[] { LocalGatewaySetupPhase.Preflight, LocalGatewaySetupPhase.EnsureWslEnabled }), + ("Installing Ubuntu", new[] { LocalGatewaySetupPhase.CreateWslInstance }), + ("Configuring instance", new[] { LocalGatewaySetupPhase.ConfigureWslInstance }), + ("Installing OpenClaw", new[] { LocalGatewaySetupPhase.InstallOpenClawCli }), + ("Preparing gateway", new[] { LocalGatewaySetupPhase.PrepareGatewayConfig, LocalGatewaySetupPhase.InstallGatewayService }), + ("Starting gateway", new[] { LocalGatewaySetupPhase.StartGateway, LocalGatewaySetupPhase.WaitForGateway }), + ("Generating setup code", new[] { LocalGatewaySetupPhase.MintBootstrapToken }), + }; + + public override Element Render() + { + var (snapshot, setSnapshot) = UseState(null); + var (retryCount, setRetryCount) = UseState(0); + var dispatcher = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); + var advanceRef = Props; // capture for closure + + // Visual-test override: render a synthetic state so screenshot capture doesn't + // kick off a real WSL install on the test machine. + var visualState = TryReadVisualTestState(); + + UseEffect(() => + { + if (visualState != null) + { + setSnapshot(visualState); + return () => { }; + } + + if (s_engine == null) + { + try + { + var app = (App)Application.Current; + s_engine = app.CreateLocalGatewaySetupEngine(); + } + catch (Exception ex) + { + var failState = LocalGatewaySetupState.Create(new LocalGatewaySetupOptions()); + failState.Block("engine_construct_failed", ex.Message, retryable: false, detail: ex.ToString()); + setSnapshot(failState); + return () => { }; + } + } + + void Handler(LocalGatewaySetupState st) + { + dispatcher?.TryEnqueue(() => + { + setSnapshot(st); + + if (st.Status == LocalGatewaySetupStatus.Complete && !s_advanceFiredForCompletion) + { + s_advanceFiredForCompletion = true; + // 1-second pause on success per Mike's decision. + Task.Delay(TimeSpan.FromSeconds(1)).ContinueWith(_ => + dispatcher.TryEnqueue(() => advanceRef.RequestAdvance()), + TaskScheduler.Default); + } + }); + } + + s_engine.StateChanged += Handler; + + if (s_runTask == null || s_runTask.IsCompleted || retryCount > 0) + { + s_advanceFiredForCompletion = false; + s_runTask = s_engine.RunLocalOnlyAsync(); + } + + return () => + { + if (s_engine != null) + s_engine.StateChanged -= Handler; + }; + }, retryCount); + + var phase = snapshot?.Phase ?? LocalGatewaySetupPhase.NotStarted; + var status = snapshot?.Status ?? LocalGatewaySetupStatus.Pending; + var subtitle = !string.IsNullOrWhiteSpace(snapshot?.UserMessage) + ? snapshot!.UserMessage! + : "Setting up your local OpenClaw gateway. This usually takes a few minutes."; + + var stageRows = s_visibleStages.Select(stage => RenderStage(stage.Label, stage.Phases, phase, status)).ToArray(); + + var isFailed = status == LocalGatewaySetupStatus.FailedRetryable || status == LocalGatewaySetupStatus.FailedTerminal; + var canRetry = status == LocalGatewaySetupStatus.FailedRetryable; + + Element errorRow; + if (isFailed) + { + var msg = snapshot?.UserMessage ?? "Setup did not complete."; + if (status == LocalGatewaySetupStatus.FailedTerminal) + msg += "\nDiagnostics: aka.ms/wsllogs"; + + var children = new System.Collections.Generic.List + { + TextBlock(msg) + .FontSize(12) + .Opacity(0.85) + .TextWrapping() + .VAlign(VerticalAlignment.Center) + .Grid(row: 0, column: 0) + }; + if (canRetry) + { + children.Add( + Button("Try again", () => setRetryCount(retryCount + 1)) + .MinWidth(120) + .HAlign(HorizontalAlignment.Right) + .VAlign(VerticalAlignment.Center) + .Set(b => Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingLocalSetupRetry")) + .Grid(row: 0, column: 1) + ); + } + errorRow = Border( + Grid(["1*", "Auto"], ["Auto"], children.ToArray()) + .Padding(12, 10, 12, 10) + ) + .CornerRadius(8) + .BackgroundResource("SystemFillColorCriticalBackgroundBrush") + .Margin(0, 12, 0, 0); + } + else + { + errorRow = TextBlock("").Height(0); // collapsed + } + + return Grid( + columns: ["1*"], + rows: ["Auto", "Auto", "1*", "Auto"], + + TextBlock("Setting up locally") + .FontSize(22) + .FontWeight(new global::Windows.UI.Text.FontWeight(700)) + .HAlign(HorizontalAlignment.Center) + .TextWrapping() + .Grid(row: 0, column: 0), + + TextBlock(subtitle) + .FontSize(13) + .Opacity(0.65) + .HAlign(HorizontalAlignment.Center) + .TextWrapping() + .Margin(0, 6, 0, 12) + .Grid(row: 1, column: 0), + + ScrollView( + VStack(8, stageRows) + .Padding(8, 4, 8, 4) + ) + .Grid(row: 2, column: 0), + + errorRow.Grid(row: 3, column: 0) + ) + .HAlign(HorizontalAlignment.Stretch) + .VAlign(VerticalAlignment.Stretch) + .MaxWidth(520) + .Padding(0, 8, 0, 0); + } + + private static Element RenderStage(string label, LocalGatewaySetupPhase[] stagePhases, LocalGatewaySetupPhase currentPhase, LocalGatewaySetupStatus currentStatus) + { + var stageState = ComputeStageState(stagePhases, currentPhase, currentStatus); + string icon; + Element trailing; + double opacity; + switch (stageState) + { + case StageState.Complete: + icon = "✅"; + trailing = TextBlock("").Width(20); + opacity = 1.0; + break; + case StageState.Active: + icon = "•"; + trailing = ProgressRing().Width(18).Height(18); + opacity = 1.0; + break; + case StageState.Failed: + icon = "❌"; + trailing = TextBlock("").Width(20); + opacity = 1.0; + break; + case StageState.Pending: + default: + icon = "○"; + trailing = TextBlock("").Width(20); + opacity = 0.4; + break; + } + + var labelBlock = TextBlock(label) + .FontSize(13) + .VAlign(VerticalAlignment.Center) + .Grid(row: 0, column: 1); + + if (stageState == StageState.Failed) + labelBlock = labelBlock.Set(t => t.Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.IndianRed)); + + return Grid( + columns: ["Auto", "1*", "Auto"], + rows: ["Auto"], + + TextBlock(icon) + .FontSize(14) + .Margin(0, 0, 10, 0) + .VAlign(VerticalAlignment.Center) + .Grid(row: 0, column: 0), + + labelBlock, + + trailing.Grid(row: 0, column: 2) + ) + .Opacity(opacity) + .Padding(4, 4, 4, 4); + } + + private enum StageState { Pending, Active, Complete, Failed } + + /// + /// Visual-test hook: when OPENCLAW_VISUAL_TEST=1 and OPENCLAW_VISUAL_TEST_LOCAL_SETUP is set, + /// render a synthetic state without starting the real WSL setup engine. Accepted values: + /// "active:<phase>" (e.g. "active:CreateWslInstance"), + /// "complete", + /// "retryable:<message>", + /// "terminal:<message>". + /// + private static LocalGatewaySetupState? TryReadVisualTestState() + { + if (Environment.GetEnvironmentVariable("OPENCLAW_VISUAL_TEST") != "1") return null; + var raw = Environment.GetEnvironmentVariable("OPENCLAW_VISUAL_TEST_LOCAL_SETUP"); + if (string.IsNullOrWhiteSpace(raw)) return null; + + var state = LocalGatewaySetupState.Create(new LocalGatewaySetupOptions()); + var parts = raw.Split(':', 2); + var kind = parts[0].Trim().ToLowerInvariant(); + var arg = parts.Length > 1 ? parts[1] : ""; + + switch (kind) + { + case "active": + if (Enum.TryParse(arg, ignoreCase: true, out var p)) + { + state.StartPhase(p, "Setting up your local OpenClaw gateway."); + } + break; + case "complete": + state.CompletePhase(LocalGatewaySetupPhase.Complete, "Local gateway is ready."); + break; + case "retryable": + state.Block("visual_test_retryable", string.IsNullOrWhiteSpace(arg) ? "Setup hit a snag." : arg, retryable: true); + break; + case "terminal": + state.Block("visual_test_terminal", string.IsNullOrWhiteSpace(arg) ? "Setup cannot continue." : arg, retryable: false); + break; + } + return state; + } + + private static StageState ComputeStageState(LocalGatewaySetupPhase[] stagePhases, LocalGatewaySetupPhase currentPhase, LocalGatewaySetupStatus currentStatus) + { + // Failure pins the *current* stage to Failed; later stages remain Pending; earlier stages keep Complete. + var stageOrdinals = stagePhases.Select(p => (int)p).ToArray(); + var currentOrdinal = (int)currentPhase; + + var maxOrdinalInStage = stageOrdinals.Max(); + var minOrdinalInStage = stageOrdinals.Min(); + + if (currentStatus == LocalGatewaySetupStatus.Complete) + return StageState.Complete; + + if (currentPhase == LocalGatewaySetupPhase.Failed || currentStatus == LocalGatewaySetupStatus.FailedRetryable || currentStatus == LocalGatewaySetupStatus.FailedTerminal) + { + // Find the most recent non-terminal phase from snapshot.History? We don't have history here. + // Conservative: mark stage failed if the current phase ordinal falls within the stage's range + // *or* if no later visible stage has started. Otherwise pending. + // Simpler: only the stage matching the LAST visible-or-hidden phase before Failed is Failed. + // Without history, treat all stages with maxOrdinalInStage <= last-running-ordinal as Complete, + // current as Failed, rest as Pending. Approximate by using Phase==Failed and treating stages + // whose ordinals are all <= some threshold as complete based on the user-message phase hint. + // Pragmatic fallback: mark first stage with currentOrdinal in range as Failed; stages after as Pending; stages before as Complete. + // Since on Failed the engine sets Phase=Failed (highest ordinal) we can't distinguish — so we just mark the LAST visible stage as Failed. + return maxOrdinalInStage == s_visibleStages.Last().Phases.Max(p => (int)p) ? StageState.Failed : StageState.Pending; + } + + if (currentOrdinal > maxOrdinalInStage) + return StageState.Complete; + if (currentOrdinal >= minOrdinalInStage && currentOrdinal <= maxOrdinalInStage) + return StageState.Active; + return StageState.Pending; + } +} From 99f510760b69a1b4c3e610629293fdae008d7070 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 4 May 2026 13:46:46 -0700 Subject: [PATCH 11/56] chore(onboarding): remove WelcomePage (folded into SetupWarning) (Phase 5.4) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Onboarding/Pages/WelcomePage.cs | 78 ------------------- 1 file changed, 78 deletions(-) delete mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Pages/WelcomePage.cs diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/WelcomePage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/WelcomePage.cs deleted file mode 100644 index dcc9d7bc..00000000 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/WelcomePage.cs +++ /dev/null @@ -1,78 +0,0 @@ -using OpenClawTray.FunctionalUI; -using OpenClawTray.FunctionalUI.Core; -using OpenClawTray.Helpers; -using static OpenClawTray.FunctionalUI.Factories; -using Microsoft.UI.Xaml; - -namespace OpenClawTray.Onboarding.Pages; - -/// -/// Page 0: Welcome & Security Notice. -/// Matches macOS welcomePage() — title, subtitle, security warning card. -/// -public sealed class WelcomePage : Component -{ - public override Element Render() - { - return VStack(10, - TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_Title")) - .FontSize(22) - .FontWeight(new global::Windows.UI.Text.FontWeight(700)) - .HAlign(HorizontalAlignment.Center) - .TextWrapping(), - - TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_Subtitle")) - .FontSize(14) - .Opacity(0.6) - .HAlign(HorizontalAlignment.Center) - .TextWrapping(), - - TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_GetConnected")) - .FontSize(13) - .Opacity(0.5) - .HAlign(HorizontalAlignment.Center) - .TextWrapping() - .Margin(0, 4, 0, 0), - - // Combined security notice + trust card - Border( - VStack(8, - HStack(6, - TextBlock("⚠️").FontSize(14), - TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_SecurityTitle")) - .FontSize(13) - .FontWeight(new global::Windows.UI.Text.FontWeight(600)) - ), - TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_SecurityBody")) - .FontSize(12) - .Opacity(0.85) - .TextWrapping(), - TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_TrustTitle")) - .FontSize(13) - .FontWeight(new global::Windows.UI.Text.FontWeight(600)) - .Margin(0, 4, 0, 0), - BulletItem("Onboarding_Welcome_Trust_Commands", "Run commands on your computer"), - BulletItem("Onboarding_Welcome_Trust_Files", "Read and write files"), - BulletItem("Onboarding_Welcome_Trust_Screen", "Capture screenshots") - ).Padding(14) - ) - .CornerRadius(8) - .BackgroundResource("SystemFillColorCautionBackgroundBrush") - .Margin(0, 12, 0, 0) - ) - .HAlign(HorizontalAlignment.Center) - .VAlign(VerticalAlignment.Center) - .MaxWidth(460) - .Padding(0, 8, 0, 0); - } - - private static Element BulletItem(string key, string fallback) - { - var text = LocalizationHelper.GetString(key); - if (text == key) text = fallback; - return HStack(6, - TextBlock("•").FontSize(12).Opacity(0.6), - TextBlock(text).FontSize(12).Opacity(0.7) - ); - } -} From 8060ae97f92d0356dc8ba7f30a62cc1c507df515 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 4 May 2026 13:59:27 -0700 Subject: [PATCH 12/56] feat(scripts): port validate-wsl-gateway.ps1 - 4 scenarios, loopback-only, no rootfs (Phase 6) Clean port of prototype validate-wsl-gateway.ps1 reduced to four scenarios: PreflightOnly, UpstreamInstall, FreshMachine, Recreate. Kept: UI automation (drives SetupWarningPage 'Set up locally' button [OnboardingSetupLocal] -> LocalSetupProgressPage), loopback-only endpoint diagnostics, real upstream setup-code/bootstrap proof, operator pairing proof, Windows tray node proof, separated validation/cleanup status, token/setup-code redaction, aka.ms/wsllogs link on failure. Stripped: BuildRootfs/InstallOnly/Smoke/Loop scenarios, all rootfs/ manifest/signing parameters, worker-in-WSL pairing, WSL-IP/lan/auto fallback diagnostics, AllowNonStandardDistroNameForDestructiveClean. Recreate uses 'wsl --unregister OpenClawGateway' (NEVER --shutdown) per Craig. Network probes are loopback only. Validation: PreflightOnly run PASS (status=Passed, validation=Passed). build.ps1 PASS. Shared.Tests 1180/1180. Tray.Tests 434/434. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/validate-wsl-gateway.ps1 | 940 +++++++++++++++++++++++++++++++ 1 file changed, 940 insertions(+) create mode 100644 scripts/validate-wsl-gateway.ps1 diff --git a/scripts/validate-wsl-gateway.ps1 b/scripts/validate-wsl-gateway.ps1 new file mode 100644 index 00000000..54a62fcc --- /dev/null +++ b/scripts/validate-wsl-gateway.ps1 @@ -0,0 +1,940 @@ +<# +.SYNOPSIS + Validate the OpenClaw WSL gateway local-setup product code path end-to-end. + +.DESCRIPTION + Phase 6 clean port. Drives the WinUI3 tray app from launch through the + forked onboarding (SetupWarningPage -> "Set up locally" -> LocalSetupProgressPage) + so the *product* code path that runs + + wsl --install Ubuntu-24.04 --name OpenClawGateway --location --no-launch --version 2 + + is exercised end-to-end. The script does NOT install WSL itself and does NOT + invoke `wsl --install` directly: it expects the tray engine to do that and + only verifies the postcondition. + + Networking diagnostics are loopback-only. There is no WSL-IP / lan / auto + fallback. Token / setup-code / private-key material is redacted in artifacts. + +.PARAMETER Scenario + PreflightOnly - Repo layout + WSL host status + relay probe (safe; no install). + UpstreamInstall - Build/test, drive tray onboarding to install OpenClawGateway, + run smoke + pairing proofs. Reuses an existing distro if present. + FreshMachine - Like UpstreamInstall, but unregisters any existing + OpenClawGateway distro first (simulates a clean machine). + Recreate - Iterated FreshMachine (unregister between runs). Use `-Iterations`. + +.NOTES + Diagnostics on networking/lifecycle health failures point operators at + https://aka.ms/wsllogs (per Craig). + + File I/O against WSL is via `wsl bash -c` only. NEVER \\wsl$ / \\wsl.localhost. +#> +[CmdletBinding()] +param( + [ValidateSet("PreflightOnly", "UpstreamInstall", "FreshMachine", "Recreate")] + [string]$Scenario = "PreflightOnly", + [string]$OutputDir = (Join-Path (Get-Location) "artifacts\wsl-gateway-validation"), + [int]$Iterations = 1, + [switch]$ConfirmDestructiveClean, + [switch]$KeepFailedDistro, + [bool]$CleanupAfterSuccess = $true, + [switch]$ContinueOnCleanupFailure, + [switch]$NoBuild, + [int]$TimeoutSeconds = 600, + [string]$DistroName = "OpenClawGateway", + [string]$GatewayUrl = "ws://127.0.0.1:18789", + [string]$RelayProbeUri, + [switch]$RequireRelayProbe, + [switch]$RequireRealGatewayBootstrap, + [switch]$RequireOperatorPairing, + [switch]$RequireWindowsNodePairing, + [switch]$ContinueOnFailure +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$runStamp = Get-Date -Format "yyyyMMdd-HHmmss" +$runRoot = Join-Path $OutputDir $runStamp +$commandsRoot = Join-Path $runRoot "commands" +$screenshotsRoot = Join-Path $runRoot "screenshots" +$summaryPath = Join-Path $runRoot "summary.json" +$summaryMarkdownPath = Join-Path $runRoot "summary.md" +$trayProject = Join-Path $repoRoot "src\OpenClaw.Tray.WinUI\OpenClaw.Tray.WinUI.csproj" +$trayExe = Join-Path $repoRoot "src\OpenClaw.Tray.WinUI\bin\x64\Debug\net10.0-windows10.0.19041.0\win-x64\OpenClaw.Tray.WinUI.exe" +$cliProject = Join-Path $repoRoot "src\OpenClaw.Cli\OpenClaw.Cli.csproj" + +# Always isolate AppData under run root for non-Preflight scenarios so we never +# trample the operator's real Windows tray identity. +$validationAppDataRoot = if ($Scenario -eq "PreflightOnly") { $env:APPDATA } else { Join-Path $runRoot "isolated\appdata" } +$validationLocalAppDataRoot = if ($Scenario -eq "PreflightOnly") { $env:LOCALAPPDATA } else { Join-Path $runRoot "isolated\localappdata" } +$setupStatePath = Join-Path $validationLocalAppDataRoot "OpenClawTray\setup-state.json" +$settingsPath = Join-Path $validationAppDataRoot "OpenClawTray\settings.json" +$wslInstallLocation = Join-Path $runRoot "wsl\$DistroName" + +$script:summary = [ordered]@{ + script = "validate-wsl-gateway" + scenario = $Scenario + startedAt = (Get-Date).ToString("o") + finishedAt = $null + status = "Running" + validationStatus = "Running" + cleanupStatus = "NotStarted" + repository = $repoRoot.Path + outputDir = $runRoot + networkingMode = "LocalhostOnly" + activeDistroName = $DistroName + activeInstallLocation = $wslInstallLocation + selectedGatewayUrl = $GatewayUrl + pairingValidation = [ordered]@{ + gatewayImplementation = "Unknown" + bootstrapQrShape = "Unknown" + realUpstreamBootstrapHandoff = $false + operatorPaired = $false + windowsNodePaired = $false + } + setupPhases = @() + iterations = @() + steps = @() + error = $null +} + +function Add-Step { + param([string]$Name, [string]$Status, [string]$Message, [hashtable]$Data = @{}) + $script:summary.steps += [ordered]@{ + name = $Name + status = $Status + message = $Message + data = $Data + timestamp = (Get-Date).ToString("o") + } +} + +function Test-IsOpenClawOwnedDistroName { + param([string]$Name) + return $Name -eq "OpenClawGateway" -or $Name.StartsWith("OpenClawGateway", [System.StringComparison]::Ordinal) +} + +function Assert-DestructiveSafety { + if ($Scenario -in @("FreshMachine", "Recreate") -and -not $ConfirmDestructiveClean) { + throw "-ConfirmDestructiveClean is required when -Scenario is $Scenario (will unregister WSL distro '$DistroName')." + } + if ($Scenario -in @("FreshMachine", "Recreate") -and -not (Test-IsOpenClawOwnedDistroName -Name $DistroName)) { + throw "Refusing destructive action for non-OpenClaw distro '$DistroName'. Distro name must start with 'OpenClawGateway'." + } +} + +function Get-SafeUriDisplay { + param([string]$Uri) + try { + $b = [System.UriBuilder]::new($Uri) + $b.Query = $null; $b.Fragment = $null + return $b.Uri.AbsoluteUri + } catch { + return "" + } +} + +function Write-Summary { + New-Item -ItemType Directory -Force -Path $runRoot | Out-Null + $script:summary.finishedAt = (Get-Date).ToString("o") + $script:summary | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $summaryPath -Encoding UTF8 + + $lines = @( + "# OpenClaw WSL gateway validation", + "", + "- Scenario: $Scenario", + "- Status: $($script:summary.status)", + "- Validation: $($script:summary.validationStatus)", + "- Cleanup: $($script:summary.cleanupStatus)", + "- Networking mode: LocalhostOnly (loopback only)", + "- Started: $($script:summary.startedAt)", + "- Finished: $($script:summary.finishedAt)", + "- Output: $runRoot", + "", + "## Steps" + ) + foreach ($step in $script:summary.steps) { + $lines += "- $($step.status): $($step.name) - $($step.message)" + } + if ($script:summary.error) { + $lines += "", "## Error", $script:summary.error + $lines += "", "Diagnostics: see https://aka.ms/wsllogs for WSL networking/lifecycle logs." + } + $lines | Set-Content -LiteralPath $summaryMarkdownPath -Encoding UTF8 +} + +function Redact-SensitiveGatewayOutput { + param([string]$Content) + if ([string]::IsNullOrEmpty($Content)) { return $Content } + $r = $Content -replace '("(?:bootstrapToken|bootstrap_token|deviceToken|device_token|token|setupCode|setup_code|PrivateKeyBase64|PublicKeyBase64)"\s*:\s*")[^"]+(")', '$1$2' + $r = $r -replace '(?i)((?:bootstrap|device|gateway|auth)[_-]?token\s*[:=]\s*)[^\s,"''}]+', '$1' + return $r +} + +function Read-TextFileWithRetry { + param([string]$Path, [int]$Attempts = 10, [int]$DelayMilliseconds = 200) + for ($i = 1; $i -le $Attempts; $i++) { + try { return Get-Content -LiteralPath $Path -Raw -ErrorAction Stop } + catch [System.IO.IOException] { if ($i -eq $Attempts) { throw } ; Start-Sleep -Milliseconds $DelayMilliseconds } + } +} + +function Write-TextFileWithRetry { + param([string]$Path, [string]$Content, [int]$Attempts = 10, [int]$DelayMilliseconds = 200) + for ($i = 1; $i -le $Attempts; $i++) { + try { $Content | Set-Content -LiteralPath $Path -Encoding UTF8 -ErrorAction Stop ; return } + catch [System.IO.IOException] { if ($i -eq $Attempts) { throw } ; Start-Sleep -Milliseconds $DelayMilliseconds } + } +} + +function Copy-RedactedFileIfExists { + param([string]$SourcePath, [string]$DestinationPath) + if (-not (Test-Path -LiteralPath $SourcePath)) { return $false } + $content = Read-TextFileWithRetry -Path $SourcePath + Write-TextFileWithRetry -Path $DestinationPath -Content (Redact-SensitiveGatewayOutput $content) + return $true +} + +function Invoke-LoggedProcess { + param( + [string]$Name, + [string]$FilePath, + [string[]]$ArgumentList, + [string]$WorkingDirectory = $repoRoot.Path, + [hashtable]$Environment = @{}, + [switch]$IgnoreExitCode, + [switch]$SensitiveOutput + ) + + New-Item -ItemType Directory -Force -Path $commandsRoot | Out-Null + $safe = $Name -replace "[^a-zA-Z0-9_.-]", "-" + $stdout = Join-Path $commandsRoot "$safe.stdout.txt" + $stderr = Join-Path $commandsRoot "$safe.stderr.txt" + $saved = @{} + foreach ($k in $Environment.Keys) { + $saved[$k] = [Environment]::GetEnvironmentVariable($k, "Process") + [Environment]::SetEnvironmentVariable($k, [string]$Environment[$k], "Process") + } + Push-Location $WorkingDirectory + try { + & $FilePath @ArgumentList > $stdout 2> $stderr + $exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE } + } finally { + Pop-Location + foreach ($k in $Environment.Keys) { + [Environment]::SetEnvironmentVariable($k, $saved[$k], "Process") + } + } + + if ($SensitiveOutput) { + foreach ($p in @($stdout, $stderr)) { + if (Test-Path -LiteralPath $p) { + $c = Read-TextFileWithRetry -Path $p -Attempts 20 -DelayMilliseconds 250 + Write-TextFileWithRetry -Path $p -Content (Redact-SensitiveGatewayOutput $c) -Attempts 20 -DelayMilliseconds 250 + } + } + } + + Add-Step $Name "Completed" "Command completed with exit code $exitCode." @{ + file = $FilePath; arguments = ($ArgumentList -join " "); exitCode = $exitCode; stdout = $stdout; stderr = $stderr + } + + if ($exitCode -ne 0 -and -not $IgnoreExitCode) { + throw "$Name failed with exit code $exitCode. See $stdout and $stderr." + } +} + +function Invoke-LoggedPowerShellScript { + param([string]$Name, [string]$ScriptPath, [string[]]$ArgumentList = @()) + $hostExe = if ($PSHOME -and (Test-Path (Join-Path $PSHOME "pwsh.exe"))) { Join-Path $PSHOME "pwsh.exe" } else { "powershell.exe" } + $args = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $ScriptPath) + $ArgumentList + Invoke-LoggedProcess -Name $Name -FilePath $hostExe -ArgumentList $args +} + +function Invoke-RepositoryValidation { + if ($NoBuild) { + Add-Step "repository-validation" "Skipped" "Skipped build and tests because -NoBuild was set." + return + } + Invoke-LoggedPowerShellScript "build" (Join-Path $repoRoot "build.ps1") + Invoke-LoggedProcess "test-shared" "dotnet" @("test", ".\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj", "--no-restore") + Invoke-LoggedProcess "test-tray" "dotnet" @("test", ".\tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj", "--no-restore") +} + +function Invoke-Preflight { + Invoke-LoggedProcess "dotnet-info" "dotnet" @("--info") -IgnoreExitCode + Invoke-LoggedProcess "wsl-status" "wsl.exe" @("--status") -IgnoreExitCode + Invoke-LoggedProcess "wsl-list-before" "wsl.exe" @("--list", "--verbose") -IgnoreExitCode + + if (-not (Test-Path -LiteralPath $trayProject)) { throw "Tray project not found: $trayProject" } + if (-not (Test-Path -LiteralPath $cliProject)) { throw "CLI project not found: $cliProject" } + Add-Step "repo-layout" "Passed" "Required projects are present." + + Invoke-RelayPrototypeProbe +} + +function Invoke-RelayPrototypeProbe { + $probeUri = if (-not [string]::IsNullOrWhiteSpace($RelayProbeUri)) { $RelayProbeUri } else { [Environment]::GetEnvironmentVariable("OPENCLAW_RELAY_PROBE_URI", "Process") } + if ([string]::IsNullOrWhiteSpace($probeUri)) { + $msg = "No relay probe endpoint was supplied. Set -RelayProbeUri or OPENCLAW_RELAY_PROBE_URI." + if ($RequireRelayProbe) { throw "RelayProbeMissing: $msg" } + Add-Step "relay-prototype-probe" "NotAvailable" $msg + return + } + $relayPath = Join-Path $commandsRoot "relay-prototype-probe.txt" + New-Item -ItemType Directory -Force -Path $commandsRoot | Out-Null + try { + $r = Invoke-WebRequest -Uri $probeUri -TimeoutSec 15 -UseBasicParsing + $body = if ($null -ne $r.Content) { $r.Content } else { "" } + $body = $body -replace '(?i)(token=)[^&\s]+', '$1' + $body | Set-Content -LiteralPath $relayPath -Encoding UTF8 + Add-Step "relay-prototype-probe" "Passed" "Relay probe endpoint responded." @{ + uri = (Get-SafeUriDisplay $probeUri); statusCode = [int]$r.StatusCode; path = $relayPath + } + } catch { + throw "RelayProbeFailed: relay probe failed for $(Get-SafeUriDisplay $probeUri): $($_.Exception.Message)" + } +} + +function Get-LatestScreenshotPath { + if (-not (Test-Path -LiteralPath $screenshotsRoot)) { return $null } + $latest = Get-ChildItem -LiteralPath $screenshotsRoot -Filter "*.png" -File -Recurse | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if ($null -eq $latest) { return $null } + return $latest.FullName +} + +function Save-DiagnosticsSnapshot { + param([string]$Reason) + $diag = Join-Path $runRoot "diagnostics" + New-Item -ItemType Directory -Force -Path $diag | Out-Null + + if (Test-Path -LiteralPath $setupStatePath) { + Copy-RedactedFileIfExists -SourcePath $setupStatePath -DestinationPath (Join-Path $diag "setup-state.redacted.json") | Out-Null + } + if (Test-Path -LiteralPath $settingsPath) { + Copy-RedactedFileIfExists -SourcePath $settingsPath -DestinationPath (Join-Path $diag "settings.redacted.json") | Out-Null + } + $identityPath = Join-Path $validationAppDataRoot "OpenClawTray\device-key-ed25519.json" + if (Test-Path -LiteralPath $identityPath) { + Copy-RedactedFileIfExists -SourcePath $identityPath -DestinationPath (Join-Path $diag "device-key.shape.redacted.json") | Out-Null + } + + Add-Step "diagnostics-snapshot" "Completed" "Saved diagnostics snapshot for $Reason. See https://aka.ms/wsllogs for WSL networking/lifecycle logs." @{ + path = $diag + latestScreenshot = (Get-LatestScreenshotPath) + wslLogsHelp = "https://aka.ms/wsllogs" + } +} + +function Get-ValidationAppEnvironment { + return @{ + OPENCLAW_TRAY_APPDATA_DIR = $validationAppDataRoot + OPENCLAW_TRAY_LOCALAPPDATA_DIR = $validationLocalAppDataRoot + } +} + +function Convert-SetupStatus { + param([object]$Status) + $v = [string]$Status + if ($v -match '^\d+$') { + # Aligned with LocalGatewaySetupStatus enum + $names = @("Pending", "Running", "RequiresAdmin", "RequiresRestart", "Blocked", + "FailedRetryable", "FailedTerminal", "Complete", "Cancelled") + $i = [int]$v + if ($i -ge 0 -and $i -lt $names.Count) { return $names[$i] } + } + return $v +} + +function Convert-SetupPhase { + param([object]$Phase) + $v = [string]$Phase + if ($v -match '^\d+$') { + # Aligned with the clean LocalGatewaySetupPhase enum (worker / rootfs phases removed). + $names = @( + "NotStarted", "Preflight", "ElevationCheck", + "EnsureWslEnabled", "CreateWslInstance", "ConfigureWslInstance", + "InstallOpenClawCli", "PrepareGatewayConfig", "InstallGatewayService", + "StartGateway", "WaitForGateway", + "MintBootstrapToken", "PairOperator", + "CheckWindowsNodeReadiness", "PairWindowsTrayNode", + "VerifyEndToEnd", "Complete", "Failed", "Cancelled" + ) + $i = [int]$v + if ($i -ge 0 -and $i -lt $names.Count) { return $names[$i] } + } + return $v +} + +function Wait-ForUiAutomationElement { + param([string]$AutomationId, [int]$TimeoutSeconds) + Add-Type -AssemblyName UIAutomationClient + Add-Type -AssemblyName UIAutomationTypes + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $cond = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::AutomationIdProperty, $AutomationId) + while ((Get-Date) -lt $deadline) { + $el = [System.Windows.Automation.AutomationElement]::RootElement.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, $cond) + if ($null -ne $el) { return $el } + Start-Sleep -Milliseconds 500 + } + return $null +} + +function Invoke-UiAutomationClick { + param([string]$AutomationId, [int]$TimeoutSeconds) + $el = Wait-ForUiAutomationElement -AutomationId $AutomationId -TimeoutSeconds $TimeoutSeconds + if ($null -ne $el) { + $p = $el.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) + $p.Invoke() + Add-Step "ui-click-$AutomationId" "Completed" "Clicked UI element with AutomationId '$AutomationId'." + return + } + Save-DiagnosticsSnapshot -Reason "missing-ui-target-$AutomationId" + throw "UI element with AutomationId '$AutomationId' was not found within $TimeoutSeconds seconds." +} + +function Stop-ExistingTrayProcesses { + param([string]$Reason) + $repoPrefix = [string]$repoRoot.Path + $procs = Get-Process -Name "OpenClaw.Tray.WinUI" -ErrorAction SilentlyContinue | + Where-Object { + try { -not [string]::IsNullOrWhiteSpace($_.Path) -and $_.Path.StartsWith($repoPrefix, [System.StringComparison]::OrdinalIgnoreCase) } + catch { $false } + } + foreach ($p in $procs) { + $procId = $p.Id + try { + Stop-Process -Id $procId -Force -ErrorAction Stop + Add-Step "stop-existing-tray" "Completed" "Stopped existing repo tray process by PID before validation." @{ pid = $procId; reason = $Reason } + } catch [Microsoft.PowerShell.Commands.ProcessCommandException] { + Add-Step "stop-existing-tray" "Skipped" "Repo tray process had already exited before cleanup." @{ pid = $procId; reason = $Reason } + } + } +} + +function Stop-WslKeepAliveProcesses { + $target = $DistroName + $procs = Get-CimInstance Win32_Process -Filter "Name = 'wsl.exe'" -ErrorAction SilentlyContinue | + Where-Object { + $_.CommandLine -and + $_.CommandLine.Contains($target, [System.StringComparison]::OrdinalIgnoreCase) -and + $_.CommandLine.Contains("sleep", [System.StringComparison]::OrdinalIgnoreCase) -and + $_.CommandLine.Contains("2147483647", [System.StringComparison]::OrdinalIgnoreCase) + } + foreach ($p in $procs) { + try { + Stop-Process -Id $p.ProcessId -Force -ErrorAction Stop + Add-Step "stop-wsl-keepalive" "Completed" "Stopped $target keepalive process by PID." @{ pid = $p.ProcessId; distroName = $target } + } catch [Microsoft.PowerShell.Commands.ProcessCommandException] { + Add-Step "stop-wsl-keepalive" "Skipped" "$target keepalive process had already exited." @{ pid = $p.ProcessId; distroName = $target } + } + } +} + +function Start-TrayForLocalSetup { + Stop-ExistingTrayProcesses -Reason "pre-launch" + + # Forked onboarding entry point is SetupWarning by default; we just force + # onboarding mode and let the script click "Set up locally". + $env = @{ + OPENCLAW_SKIP_UPDATE_CHECK = "1" + OPENCLAW_FORCE_ONBOARDING = "1" + OPENCLAW_WSL_DISTRO_NAME = $DistroName + OPENCLAW_WSL_INSTALL_LOCATION = $wslInstallLocation + OPENCLAW_WSL_ALLOW_EXISTING_DISTRO = if ($Scenario -eq "UpstreamInstall") { "1" } else { "0" } + OPENCLAW_TRAY_APPDATA_DIR = $validationAppDataRoot + OPENCLAW_TRAY_LOCALAPPDATA_DIR = $validationLocalAppDataRoot + OPENCLAW_VISUAL_TEST = "1" + OPENCLAW_VISUAL_TEST_DIR = $screenshotsRoot + } + + $saved = @{} + foreach ($k in $env.Keys) { + $saved[$k] = [Environment]::GetEnvironmentVariable($k, "Process") + [Environment]::SetEnvironmentVariable($k, [string]$env[$k], "Process") + } + + try { + New-Item -ItemType Directory -Force -Path $screenshotsRoot | Out-Null + if (Test-Path -LiteralPath $trayExe) { + $proc = Start-Process -FilePath $trayExe -WorkingDirectory $repoRoot -PassThru + } else { + $args = @("run", "--project", $trayProject, "-p:Platform=x64", "--no-build") + $proc = Start-Process -FilePath "dotnet" -ArgumentList $args -WorkingDirectory $repoRoot -PassThru + } + Add-Step "launch-tray" "Completed" "Launched tray onboarding for WSL local setup." @{ + pid = $proc.Id; screenshots = $screenshotsRoot; file = $proc.StartInfo.FileName + } + return $proc + } finally { + foreach ($k in $env.Keys) { + [Environment]::SetEnvironmentVariable($k, $saved[$k], "Process") + } + } +} + +function Wait-ForSetupCompletion { + param([int]$TimeoutSeconds) + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastPhase = ""; $lastStatus = "" + while ((Get-Date) -lt $deadline) { + if (Test-Path -LiteralPath $setupStatePath) { + $text = Read-TextFileWithRetry -Path $setupStatePath + $state = $text | ConvertFrom-Json + $copy = Join-Path $runRoot "setup-state.json" + $text | Set-Content -LiteralPath $copy -Encoding UTF8 + + $phase = Convert-SetupPhase $state.Phase + $status = Convert-SetupStatus $state.Status + if ($phase -ne $lastPhase -or $status -ne $lastStatus) { + $lastPhase = $phase; $lastStatus = $status + $script:summary.setupPhases += [ordered]@{ + phase = $phase; status = $status; message = [string]$state.UserMessage; timestamp = (Get-Date).ToString("o") + } + Add-Step "setup-phase-$phase" $status ([string]$state.UserMessage) @{ phase = $phase; status = $status } + } + + if ($status -eq "Complete") { + if ($state.PSObject.Properties.Name -contains "GatewayUrl" -and -not [string]::IsNullOrWhiteSpace([string]$state.GatewayUrl)) { + $script:GatewayUrl = [string]$state.GatewayUrl + $script:summary.selectedGatewayUrl = $script:GatewayUrl + } + Add-Step "setup-state" "Passed" "Setup reached $status." @{ + status = $status; phase = $phase; path = $copy + gatewayUrl = (Get-SafeUriDisplay $script:GatewayUrl) + } + return + } + if ($status -in @("FailedRetryable", "FailedTerminal", "Blocked", "Cancelled")) { + Save-DiagnosticsSnapshot -Reason "setup-failed-$phase" + throw "Setup failed with status $status, phase $phase, code $($state.FailureCode): $($state.UserMessage). Diagnostics: https://aka.ms/wsllogs." + } + } + Start-Sleep -Seconds 2 + } + Save-DiagnosticsSnapshot -Reason "setup-timeout" + throw "Setup did not reach Complete within $TimeoutSeconds seconds. Diagnostics: https://aka.ms/wsllogs." +} + +function Invoke-TrayLocalSetup { + $proc = Start-TrayForLocalSetup + Start-Sleep -Seconds 5 + + # SetupWarningPage hosts the "Set up locally" primary button. + if ($null -eq (Wait-ForUiAutomationElement -AutomationId "OnboardingSetupLocal" -TimeoutSeconds 60)) { + Save-DiagnosticsSnapshot -Reason "setup-local-button-not-found" + throw "UI automation target OnboardingSetupLocal was not found on SetupWarningPage." + } + Invoke-UiAutomationClick -AutomationId "OnboardingSetupLocal" -TimeoutSeconds 5 + + # LocalSetupProgressPage starts the engine on appearance; just wait for state. + Wait-ForSetupCompletion -TimeoutSeconds $TimeoutSeconds + return $proc +} + +function Stop-TrayProcess { + param([object]$Process) + if ($null -ne $Process) { + $procId = $Process.Id + $live = Get-Process -Id $procId -ErrorAction SilentlyContinue + if ($null -ne $live) { + Stop-Process -Id $procId -Force + Add-Step "stop-tray" "Completed" "Stopped tray process by PID after setup validation." @{ pid = $procId } + } else { + Add-Step "stop-tray" "Skipped" "Tray process had already exited before cleanup." @{ pid = $procId } + } + } + Stop-ExistingTrayProcesses -Reason "post-validation" + Stop-WslKeepAliveProcesses +} + +function Convert-GatewayUrlToHealthUri { + param([string]$Url) + $b = [System.UriBuilder]::new($Url) + if ($b.Scheme -eq "ws") { $b.Scheme = "http" } + elseif ($b.Scheme -eq "wss") { $b.Scheme = "https" } + $b.Path = ($b.Path.TrimEnd("/") + "/health") + return $b.Uri.AbsoluteUri +} + +function Save-LoopbackNetworkDiagnostics { + param([string]$Reason) + # Loopback only - no WSL IP, no `hostname -I`, no lan probes. + $safe = $Reason -replace "[^a-zA-Z0-9_.-]", "-" + $tcpPath = Join-Path $commandsRoot "network-$safe-windows-tcp-18789.json" + try { + $cs = @(Get-NetTCPConnection -LocalPort 18789 -ErrorAction Stop | ForEach-Object { + [ordered]@{ + localAddress = $_.LocalAddress; localPort = $_.LocalPort + state = $_.State.ToString(); owningProcess = $_.OwningProcess + } + }) + $cs | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $tcpPath -Encoding UTF8 + Add-Step "network-$safe-windows-tcp" "Completed" "Captured Windows TCP listener state for loopback gateway port." @{ path = $tcpPath } + } catch { + $_.Exception.Message | Set-Content -LiteralPath $tcpPath -Encoding UTF8 + Add-Step "network-$safe-windows-tcp" "Skipped" "Could not capture Windows TCP listener state. See https://aka.ms/wsllogs." @{ path = $tcpPath } + } +} + +function Save-RedactedSettings { + if (-not (Test-Path -LiteralPath $settingsPath)) { + Add-Step "settings-redacted" "Skipped" "Tray settings file was not found." + return + } + $copy = Join-Path $runRoot "settings.redacted.json" + $c = Read-TextFileWithRetry -Path $settingsPath + $c = $c -replace '("(?:Token|token|GatewayToken|BootstrapToken|bootstrapToken|bootstrap_token|NodeToken|nodeToken)"\s*:\s*")[^"]*(")', '$1$2' + $c | Set-Content -LiteralPath $copy -Encoding UTF8 + Add-Step "settings-redacted" "Completed" "Saved redacted tray settings." @{ path = $copy } +} + +function Test-SetupHistoryPhase { + param([string]$Phase) + if (-not (Test-Path -LiteralPath $setupStatePath)) { return $false } + $state = Read-TextFileWithRetry -Path $setupStatePath | ConvertFrom-Json + if (-not ($state.PSObject.Properties.Name -contains "History")) { return $false } + foreach ($e in @($state.History)) { + if ((Convert-SetupPhase $e.Phase) -eq $Phase -and (Convert-SetupStatus $e.Status) -in @("Running", "Complete")) { + return $true + } + } + return (Convert-SetupPhase $state.Phase) -eq $Phase +} + +function Save-RedactedDeviceIdentityShape { + $idp = Join-Path $validationAppDataRoot "OpenClawTray\device-key-ed25519.json" + if (-not (Test-Path -LiteralPath $idp)) { + Add-Step "device-identity" "Failed" "Device identity file was not found." @{ path = $idp } + return $false + } + $copy = Join-Path $runRoot "device-key.shape.redacted.json" + Copy-RedactedFileIfExists -SourcePath $idp -DestinationPath $copy | Out-Null + try { + $id = Get-Content -LiteralPath $idp -Raw | ConvertFrom-Json + $hasOperatorToken = ($id.PSObject.Properties.Name -contains "DeviceToken" -and -not [string]::IsNullOrWhiteSpace([string]$id.DeviceToken)) -or + ($id.PSObject.Properties.Name -contains "OperatorDeviceToken" -and -not [string]::IsNullOrWhiteSpace([string]$id.OperatorDeviceToken)) + Add-Step "device-identity" ($(if ($hasOperatorToken) { "Passed" } else { "Failed" })) "Checked stored device identity token shape." @{ + path = $copy; hasOperatorToken = $hasOperatorToken + } + return $hasOperatorToken + } catch { + Add-Step "device-identity" "Failed" "Device identity JSON could not be parsed." @{ path = $copy } + return $false + } +} + +function Test-JsonStringProperty { + param([object]$Json, [string[]]$Names) + foreach ($n in $Names) { + if ($Json.PSObject.Properties.Name -contains $n) { + $v = [string]$Json.$n + if (-not [string]::IsNullOrWhiteSpace($v)) { return $true } + } + } + return $false +} + +function Get-JsonStringProperty { + param([object]$Json, [string]$Name) + if ($Json -and $Json.PSObject.Properties.Name -contains $Name) { return [string]$Json.$Name } + return "" +} + +function Invoke-BootstrapHandoffProbe { + # Real upstream setup-code / bootstrap proof. + $stdout = Join-Path $commandsRoot "wsl-bootstrap-token.stdout.txt" + $stderr = Join-Path $commandsRoot "wsl-bootstrap-token.stderr.txt" + $args = @("-d", $DistroName, "--", "/opt/openclaw/bin/openclaw", "qr", "--json", "--url", $GatewayUrl) + & wsl.exe @args > $stdout 2> $stderr + $exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE } + $raw = if (Test-Path -LiteralPath $stdout) { Read-TextFileWithRetry -Path $stdout -Attempts 20 -DelayMilliseconds 250 } else { "" } + Write-TextFileWithRetry -Path $stdout -Content (Redact-SensitiveGatewayOutput $raw) -Attempts 20 -DelayMilliseconds 250 + + if ($exitCode -ne 0) { + Add-Step "wsl-bootstrap-token" "Failed" "Gateway QR command failed with exit code $exitCode." @{ + arguments = ($args -join " "); exitCode = $exitCode; stdout = $stdout; stderr = $stderr + } + throw "BootstrapTokenCommandFailed: openclaw qr --json failed. See $stdout and $stderr." + } + + $hasSetupCode = $false; $hasDirectToken = $false + try { + $qr = $raw | ConvertFrom-Json + $hasSetupCode = Test-JsonStringProperty $qr @("setupCode", "setup_code") + $hasDirectToken = Test-JsonStringProperty $qr @("bootstrapToken", "bootstrap_token", "token") + } catch { + throw "BootstrapTokenJsonInvalid: openclaw qr --json did not produce valid JSON: $($_.Exception.Message)" + } + + $shape = if ($hasSetupCode) { "UpstreamSetupCode" } elseif ($hasDirectToken) { "DirectBootstrapToken" } else { "Unknown" } + $script:summary.pairingValidation["bootstrapQrShape"] = $shape + $script:summary.pairingValidation["realUpstreamBootstrapHandoff"] = $hasSetupCode + + Add-Step "wsl-bootstrap-token" "Completed" "Gateway QR command completed; bootstrap shape is $shape." @{ + arguments = ($args -join " "); exitCode = $exitCode; stdout = $stdout; stderr = $stderr; bootstrapQrShape = $shape; realUpstreamBootstrapHandoff = $hasSetupCode + } + + if ($RequireRealGatewayBootstrap -and -not $hasSetupCode) { + throw "RealGatewayBootstrapRequired: expected upstream setupCode bootstrap handoff, but openclaw qr --json returned $shape." + } +} + +function Invoke-OperatorPairingProof { + if (-not $RequireOperatorPairing) { + Add-Step "operator-pairing-proof" "Skipped" "Operator pairing proof was not required." + return + } + if (-not (Test-SetupHistoryPhase -Phase "PairOperator")) { + Save-DiagnosticsSnapshot -Reason "operator-pair-phase-missing" + throw "OperatorPairingProofFailed: setup state did not record PairOperator." + } + if (-not (Save-RedactedDeviceIdentityShape)) { + Save-DiagnosticsSnapshot -Reason "operator-device-token-missing" + throw "OperatorPairingProofFailed: stored operator device token is missing." + } + Invoke-LoggedProcess "operator-stored-token-reconnect" "dotnet" @( + "run", "--project", $cliProject, "--", + "--probe-read", "--skip-chat", "--require-stored-device-token", + "--connect-timeout-ms", "15000" + ) -Environment (Get-ValidationAppEnvironment) -SensitiveOutput + + $script:summary.pairingValidation["operatorPaired"] = $true + Add-Step "operator-pairing-proof" "Passed" "Stored operator device token reconnect succeeded." +} + +function Invoke-WindowsNodePairingProof { + # Windows tray IS the node (per Mike). Confirm the PairWindowsTrayNode phase + # ran and that gateway node.list returns the tray node. + if (-not $RequireWindowsNodePairing) { + Add-Step "windows-node-pairing-proof" "Skipped" "Windows tray node pairing proof was not required." + return + } + if (-not (Test-SetupHistoryPhase -Phase "PairWindowsTrayNode")) { + Save-DiagnosticsSnapshot -Reason "windows-node-pair-phase-missing" + throw "WindowsNodePairingProofFailed: setup state did not record PairWindowsTrayNode." + } + Invoke-LoggedProcess "windows-node-list-proof" "dotnet" @( + "run", "--project", $cliProject, "--", + "--probe-read", "--skip-chat", "--require-stored-device-token", "--require-node", + "--connect-timeout-ms", "90000" + ) -Environment (Get-ValidationAppEnvironment) -SensitiveOutput + + $script:summary.pairingValidation["windowsNodePaired"] = $true + Add-Step "windows-node-pairing-proof" "Passed" "Gateway node.list returned the Windows tray node." +} + +function Invoke-SmokeChecks { + Invoke-LoggedProcess "wsl-list-after" "wsl.exe" @("--list", "--verbose") -IgnoreExitCode + Save-LoopbackNetworkDiagnostics -Reason "post-install" + + # Gateway in WSL via systemd user unit (UpstreamInstall layout). + Invoke-LoggedProcess "wsl-openclaw-version" "wsl.exe" @( + "-d", $DistroName, "-u", "openclaw", "--", "/opt/openclaw/bin/openclaw", "--version") + Invoke-LoggedProcess "wsl-openclaw-config-validate" "wsl.exe" @( + "-d", $DistroName, "-u", "openclaw", "--", "/opt/openclaw/bin/openclaw", "config", "validate") + Invoke-LoggedProcess "wsl-gateway-journal" "wsl.exe" @( + "-d", $DistroName, "-u", "root", "--", "journalctl", "--user", "-u", "openclaw-gateway", + "--no-pager", "-n", "200") -IgnoreExitCode -SensitiveOutput + + # Loopback-only health probe. + $healthUri = Convert-GatewayUrlToHealthUri -Url $GatewayUrl + $healthPath = Join-Path $commandsRoot "gateway-health.json" + try { + $h = Invoke-RestMethod -Uri $healthUri -TimeoutSec 10 + $h | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $healthPath -Encoding UTF8 + if (-not $h.ok) { throw "Gateway health response did not contain ok=true." } + $gw = if ($h.PSObject.Properties.Name -contains "gateway") { $h.gateway } else { $null } + $version = Get-JsonStringProperty $gw "version" + $displayName = Get-JsonStringProperty $gw "displayName" + $isDev = $version -like "*-dev*" -or $displayName -like "Dev OpenClaw*" + $script:summary.pairingValidation["gatewayImplementation"] = if ($isDev) { "DevShim" } else { "ProductionCandidate" } + Add-Step "gateway-health" "Passed" "Gateway health endpoint returned ok=true." @{ uri = $healthUri; path = $healthPath } + } catch { + throw "Gateway health check failed for ${healthUri}: $($_.Exception.Message). Diagnostics: https://aka.ms/wsllogs." + } + + Invoke-BootstrapHandoffProbe + Save-RedactedSettings + Invoke-OperatorPairingProof + Invoke-WindowsNodePairingProof + + $args = @( + "run", "--project", $cliProject, "--", + "--probe-read", "--skip-chat", + "--message", "openclaw validation ping", + "--connect-timeout-ms", "15000" + ) + if ($RequireOperatorPairing) { $args += "--require-stored-device-token" } + Invoke-LoggedProcess "openclaw-cli-probe" "dotnet" $args -Environment (Get-ValidationAppEnvironment) -SensitiveOutput +} + +function Invoke-DistroUnregisterIfPresent { + param([string]$Reason) + Stop-WslKeepAliveProcesses + # Authoritative repair primitive: `wsl --unregister`. NEVER `wsl --shutdown`. + Invoke-LoggedProcess "wsl-unregister-$Reason" "wsl.exe" @("--unregister", $DistroName) -IgnoreExitCode + + if (Test-Path -LiteralPath $wslInstallLocation) { + try { + Remove-Item -LiteralPath $wslInstallLocation -Recurse -Force -ErrorAction Stop + Add-Step "remove-install-location-$Reason" "Completed" "Removed install location directory." @{ path = $wslInstallLocation } + } catch { + Add-Step "remove-install-location-$Reason" "Skipped" "Could not remove install location: $($_.Exception.Message)" @{ path = $wslInstallLocation } + } + } +} + +function Invoke-PreIterationCleanup { + param([int]$Index) + if ($Scenario -in @("FreshMachine", "Recreate")) { + Invoke-DistroUnregisterIfPresent -Reason "iteration-$Index-pre" + # Wipe isolated AppData so identity store starts empty. + foreach ($p in @($validationAppDataRoot, $validationLocalAppDataRoot)) { + if (Test-Path -LiteralPath $p) { + try { Remove-Item -LiteralPath $p -Recurse -Force -ErrorAction Stop } catch { } + } + } + } else { + Stop-WslKeepAliveProcesses + } +} + +function Invoke-PostIterationCleanup { + param([int]$Index, [bool]$IterationFailed) + if ($Scenario -ne "Recreate") { + $script:summary.cleanupStatus = if ($script:summary.cleanupStatus -eq "Failed") { "Failed" } else { "Skipped" } + Add-Step "iteration-$Index-cleanup" "Skipped" "Post-iteration distro cleanup is only required in Recreate scenario." + return "Skipped" + } + if ($IterationFailed -and $KeepFailedDistro) { + $script:summary.cleanupStatus = if ($script:summary.cleanupStatus -eq "Failed") { "Failed" } else { "Skipped" } + Add-Step "iteration-$Index-cleanup" "Skipped" "Keeping failed WSL distro for inspection (-KeepFailedDistro)." @{ distroName = $DistroName } + return "Skipped" + } + if (-not $IterationFailed -and -not $CleanupAfterSuccess) { + $script:summary.cleanupStatus = if ($script:summary.cleanupStatus -eq "Failed") { "Failed" } else { "Skipped" } + Add-Step "iteration-$Index-cleanup" "Skipped" "Leaving successful distro (-CleanupAfterSuccess:`$false)." @{ distroName = $DistroName } + return "Skipped" + } + try { + $script:summary.cleanupStatus = "Running" + Invoke-DistroUnregisterIfPresent -Reason "iteration-$Index-post" + $script:summary.cleanupStatus = "Passed" + Add-Step "iteration-$Index-cleanup" "Passed" "Cleaned recreated WSL distro after validation iteration." @{ distroName = $DistroName } + return "Passed" + } catch { + $script:summary.cleanupStatus = "Failed" + Add-Step "iteration-$Index-cleanup" "Failed" $_.Exception.Message + if (-not $ContinueOnCleanupFailure) { throw } + return "Failed" + } +} + +function New-IterationRecord { + param([int]$Index) + return [ordered]@{ + index = $Index + distroName = $DistroName + installLocation = $wslInstallLocation + validationStatus = "Running" + cleanupStatus = "NotStarted" + error = $null + cleanupError = $null + startedAt = (Get-Date).ToString("o") + finishedAt = $null + } +} + +function Invoke-ValidationIteration { + param([int]$Index) + $iteration = New-IterationRecord -Index $Index + $script:summary.iterations += $iteration + Add-Step "iteration-$Index" "Started" "Starting validation iteration $Index." + $trayProcess = $null + $iterationFailed = $false + + try { + Invoke-RepositoryValidation + Invoke-PreIterationCleanup -Index $Index + $trayProcess = Invoke-TrayLocalSetup + Invoke-SmokeChecks + + Add-Step "iteration-$Index" "Passed" "Validation iteration $Index passed." + $iteration.validationStatus = "Passed" + $script:summary.validationStatus = "Passed" + } catch { + $iterationFailed = $true + $iteration.validationStatus = "Failed" + $iteration.error = $_.Exception.Message + $script:summary.validationStatus = "Failed" + Save-DiagnosticsSnapshot -Reason "iteration-$Index-failed" + throw + } finally { + try { + Stop-TrayProcess -Process $trayProcess + $iteration.cleanupStatus = Invoke-PostIterationCleanup -Index $Index -IterationFailed $iterationFailed + } catch { + $iteration.cleanupStatus = "Failed" + $iteration.cleanupError = $_.Exception.Message + throw + } finally { + $iteration.finishedAt = (Get-Date).ToString("o") + } + } +} + +New-Item -ItemType Directory -Force -Path $runRoot, $commandsRoot, $screenshotsRoot | Out-Null + +$exitCode = 0 +try { + Assert-DestructiveSafety + Invoke-Preflight + + if ($Scenario -eq "PreflightOnly") { + Add-Step "scenario" "Passed" "Preflight completed." + $script:summary.validationStatus = "Passed" + $script:summary.cleanupStatus = "Skipped" + } elseif ($Scenario -eq "Recreate" -or $Iterations -gt 1) { + if ($Iterations -lt 1) { throw "-Iterations must be at least 1." } + for ($i = 1; $i -le $Iterations; $i++) { + try { Invoke-ValidationIteration -Index $i } + catch { + Add-Step "iteration-$i" "Failed" $_.Exception.Message + if (-not $ContinueOnFailure) { throw } + } + } + } else { + # UpstreamInstall or FreshMachine, single shot. + Invoke-ValidationIteration -Index 1 + } + + if ($script:summary.validationStatus -eq "Running") { $script:summary.validationStatus = "Passed" } + if ($script:summary.cleanupStatus -in @("Running", "NotStarted")) { $script:summary.cleanupStatus = "Skipped" } + if ($script:summary.validationStatus -eq "Failed") { + $script:summary.status = "Failed"; $exitCode = 1 + } else { + $script:summary.status = if ($script:summary.cleanupStatus -eq "Failed") { "PassedWithCleanupFailure" } else { "Passed" } + } +} catch { + $script:summary.status = "Failed" + if ($script:summary.validationStatus -eq "Running") { $script:summary.validationStatus = "Failed" } + if ($script:summary.cleanupStatus -eq "Running") { $script:summary.cleanupStatus = "Failed" } + $script:summary.error = $_.Exception.Message + Add-Step "validation" "Failed" $_.Exception.Message + $exitCode = 1 +} finally { + Write-Summary +} + +Write-Host "Validation summary: $summaryPath" +if ($script:summary.status -eq "Failed") { + Write-Host "Diagnostics: see https://aka.ms/wsllogs for WSL networking/lifecycle logs." +} +exit $exitCode From dbd7708085cef148f13a0f2f0e6797294f79412a Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 4 May 2026 14:07:58 -0700 Subject: [PATCH 13/56] feat(scripts): port reset-openclaw-wsl-validation-state.ps1 -- exact-target gated cleanup (Phase 7) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../reset-openclaw-wsl-validation-state.ps1 | 388 ++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 scripts/reset-openclaw-wsl-validation-state.ps1 diff --git a/scripts/reset-openclaw-wsl-validation-state.ps1 b/scripts/reset-openclaw-wsl-validation-state.ps1 new file mode 100644 index 00000000..04bf9fdc --- /dev/null +++ b/scripts/reset-openclaw-wsl-validation-state.ps1 @@ -0,0 +1,388 @@ +# reset-openclaw-wsl-validation-state.ps1 +# +# Exact-target destructive cleanup for OpenClaw-owned WSL validation state. +# +# Safety guarantees enforced by this script: +# 1. Without -ConfirmDestructiveClean, the script runs in DRY-RUN mode and +# reports what it WOULD do; it never mutates state. +# 2. The only WSL distro this script will ever touch is the production +# constant "OpenClawGateway". Any other distro name is rejected. +# 3. Destructive operations are preceded by a copy of the user's +# %APPDATA%\OpenClawTray and %LOCALAPPDATA%\OpenClawTray identity +# directories to a timestamped backup location (printed to console). +# 4. The script never calls `wsl --shutdown`. It uses +# `wsl --terminate OpenClawGateway` only. +# 5. The script never reads or writes \\wsl$ / \\wsl.localhost paths. + +[CmdletBinding()] +param( + [string]$OutputDir = (Join-Path (Get-Location) "artifacts\wsl-gateway-validation\reset"), + [string]$BackupRoot, + [string]$AppDataRoot, + [string]$LocalAppDataRoot, + [string]$InstallLocation, + [switch]$CleanInstallLocation, + [switch]$ConfirmDestructiveClean, + [switch]$KeepRunningProcesses, + [switch]$PassThruJson +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# Production-locked WSL distro name (Phase 3 constant). This script will +# refuse to act on any other distro, even via -DistroName overrides +# (which are intentionally absent). +$script:OpenClawDistroName = "OpenClawGateway" + +$startedAt = Get-Date +$timestamp = $startedAt.ToString("yyyyMMddHHmmss") + +if ([string]::IsNullOrWhiteSpace($BackupRoot)) { + $BackupRoot = Join-Path (Get-Location) "artifacts\reset-backups\$timestamp" +} + +$result = [ordered]@{ + script = "reset-openclaw-wsl-validation-state" + startedAt = $startedAt.ToString("o") + finishedAt = $null + outputDir = $OutputDir + backupRoot = $BackupRoot + distroName = $script:OpenClawDistroName + installLocation = $InstallLocation + appDataRoot = $AppDataRoot + localAppDataRoot = $LocalAppDataRoot + destructiveConfirmed = [bool]$ConfirmDestructiveClean + dryRun = -not $ConfirmDestructiveClean + targets = [ordered]@{} + steps = @() +} + +function Add-ResetStep { + param( + [string]$Name, + [string]$Status, + [string]$Message, + [hashtable]$Data = @{} + ) + + $script:result.steps += [ordered]@{ + name = $Name + status = $Status + message = $Message + data = $Data + timestamp = (Get-Date).ToString("o") + } +} + +function Invoke-CapturedCommand { + param( + [string]$Name, + [string]$FilePath, + [string[]]$ArgumentList, + [string]$WorkingDirectory = (Get-Location).Path, + [switch]$IgnoreExitCode + ) + + $stepDir = Join-Path $OutputDir "commands" + New-Item -ItemType Directory -Force -Path $stepDir | Out-Null + $safeName = $Name -replace "[^a-zA-Z0-9_.-]", "-" + $stdout = Join-Path $stepDir "$safeName.stdout.txt" + $stderr = Join-Path $stepDir "$safeName.stderr.txt" + + Push-Location $WorkingDirectory + try { + & $FilePath @ArgumentList > $stdout 2> $stderr + $exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE } + } + finally { + Pop-Location + } + + Add-ResetStep $Name "Completed" "Command completed with exit code $exitCode." @{ + file = $FilePath + arguments = ($ArgumentList -join " ") + exitCode = $exitCode + stdout = $stdout + stderr = $stderr + } + + if ($exitCode -ne 0 -and -not $IgnoreExitCode) { + throw "$Name failed with exit code $exitCode. See $stdout and $stderr." + } +} + +function Backup-Directory { + param( + [string]$Path, + [string]$Label + ) + + if (-not (Test-Path -LiteralPath $Path)) { + Add-ResetStep "backup-$Label" "Skipped" "$Path does not exist." + return + } + + New-Item -ItemType Directory -Force -Path $BackupRoot | Out-Null + $leaf = Split-Path -Leaf $Path + $destination = Join-Path $BackupRoot "$Label-$leaf" + + if ($result.dryRun) { + Add-ResetStep "backup-$Label" "DryRun" "Would copy $Path to $destination, then remove the original." @{ + source = $Path + destination = $destination + } + return + } + + if (Test-Path -LiteralPath $destination) { + $destination = Join-Path $BackupRoot ("{0}-{1:yyyyMMddHHmmss}" -f "$Label-$leaf", (Get-Date)) + } + + # Copy first so the user can recover even if removal fails partway. + Copy-Item -LiteralPath $Path -Destination $destination -Recurse -Force + Remove-Item -LiteralPath $Path -Recurse -Force + Add-ResetStep "backup-$Label" "Completed" "Backed up $Path to $destination, then removed the original." @{ + source = $Path + destination = $destination + } +} + +function Assert-DestructiveTargetIsAllowed { + # Hard-lock: this script will only ever touch the production OpenClawGateway distro. + # No override flag exists. If $script:OpenClawDistroName is ever something else, + # the script must refuse to run regardless of dry-run mode. + if ($script:OpenClawDistroName -ne "OpenClawGateway") { + throw "Refusing to run: distro name is locked to 'OpenClawGateway' but resolved to '$($script:OpenClawDistroName)'." + } +} + +function Get-PortOwnerSnapshot { + param([string]$Label) + + $port = 18789 + try { + $connections = @(Get-NetTCPConnection -LocalPort $port -ErrorAction Stop) + $snapshot = @($connections | ForEach-Object { + [ordered]@{ + localAddress = $_.LocalAddress + localPort = $_.LocalPort + state = $_.State.ToString() + owningProcess = $_.OwningProcess + } + }) + } + catch { + $snapshot = @() + } + + $snapshotPath = Join-Path $OutputDir "port-18789-$Label.json" + $snapshot | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $snapshotPath -Encoding UTF8 + Add-ResetStep "port-snapshot-$Label" "Completed" "Captured TCP listener snapshot for port 18789." @{ + path = $snapshotPath + ownerCount = @($snapshot).Count + } + return $snapshot +} + +function Get-WslDistros { + $output = & wsl.exe --list --quiet 2>$null + if ($LASTEXITCODE -ne 0 -or $null -eq $output) { + return @() + } + + return @($output | ForEach-Object { ($_ -replace "`0", "").Trim() } | Where-Object { $_ }) +} + +function Get-OpenClawProcesses { + return @(Get-Process | Where-Object { $_.ProcessName -like "OpenClaw*" }) +} + +function Add-TargetSummary { + param( + [object[]]$Processes, + [string[]]$Distros, + [string]$AppDataPath, + [string]$LocalAppDataPath, + [string]$InstallLocationPath, + [object[]]$PortOwners + ) + + $script:result.targets = [ordered]@{ + processes = @($Processes | ForEach-Object { + [ordered]@{ + pid = $_.Id + name = $_.ProcessName + path = $_.Path + } + }) + distroExists = ($Distros -contains $script:OpenClawDistroName) + distroName = $script:OpenClawDistroName + appDataPath = $AppDataPath + appDataExists = Test-Path -LiteralPath $AppDataPath + localAppDataPath = $LocalAppDataPath + localAppDataExists = Test-Path -LiteralPath $LocalAppDataPath + installLocationPath = $InstallLocationPath + installLocationExists = (-not [string]::IsNullOrWhiteSpace($InstallLocationPath)) -and (Test-Path -LiteralPath $InstallLocationPath) + installLocationCleanupRequested = [bool]$CleanInstallLocation + port18789OwnersBefore = @($PortOwners) + outputDir = $OutputDir + backupRoot = $BackupRoot + } + + Add-ResetStep "target-summary" "Completed" "Captured OpenClaw-owned reset targets." @{ + processCount = @($Processes).Count + distroExists = [bool]$script:result.targets.distroExists + appDataExists = [bool]$script:result.targets.appDataExists + localAppDataExists = [bool]$script:result.targets.localAppDataExists + installLocationExists = [bool]$script:result.targets.installLocationExists + } +} + +function Assert-CleanPostCondition { + param( + [string]$AppDataPath, + [string]$LocalAppDataPath, + [string]$InstallLocationPath + ) + + if ($result.dryRun) { + Add-ResetStep "postconditions" "Skipped" "Postconditions are skipped during dry-run." + return + } + + $remainingProcesses = @(Get-OpenClawProcesses) + if (-not $KeepRunningProcesses -and $remainingProcesses.Count -gt 0) { + throw "OpenClaw processes are still running after reset: $(@($remainingProcesses | ForEach-Object { $_.Id }) -join ', ')" + } + + $remainingDistros = @(Get-WslDistros) + if ($remainingDistros -contains $script:OpenClawDistroName) { + throw "WSL distro '$($script:OpenClawDistroName)' is still registered after reset." + } + + if (Test-Path -LiteralPath $AppDataPath) { + throw "AppData path still exists after reset: $AppDataPath" + } + + if (Test-Path -LiteralPath $LocalAppDataPath) { + throw "LocalAppData path still exists after reset: $LocalAppDataPath" + } + + if ($CleanInstallLocation -and -not [string]::IsNullOrWhiteSpace($InstallLocationPath) -and (Test-Path -LiteralPath $InstallLocationPath)) { + throw "Install location still exists after reset: $InstallLocationPath" + } + + $wslListAfterPath = Join-Path $OutputDir "wsl-list-after.txt" + & wsl.exe --list --verbose > $wslListAfterPath 2>&1 + $script:result.targets.port18789OwnersAfter = @(Get-PortOwnerSnapshot -Label "after") + Add-ResetStep "postconditions" "Passed" "OpenClaw-owned state reset postconditions passed." @{ + wslListAfter = $wslListAfterPath + } +} + +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +try { + Assert-DestructiveTargetIsAllowed + + if ([string]::IsNullOrWhiteSpace($AppDataRoot)) { + $AppDataRoot = $env:APPDATA + $result.appDataRoot = $AppDataRoot + } + if ([string]::IsNullOrWhiteSpace($LocalAppDataRoot)) { + $LocalAppDataRoot = $env:LOCALAPPDATA + $result.localAppDataRoot = $LocalAppDataRoot + } + + $appData = Join-Path $AppDataRoot "OpenClawTray" + $localAppData = Join-Path $LocalAppDataRoot "OpenClawTray" + $processes = @(Get-OpenClawProcesses) + $distros = @(Get-WslDistros) + $portOwnersBefore = @(Get-PortOwnerSnapshot -Label "before") + Add-TargetSummary -Processes $processes -Distros $distros -AppDataPath $appData -LocalAppDataPath $localAppData -InstallLocationPath $InstallLocation -PortOwners $portOwnersBefore + + if ($result.dryRun) { + Add-ResetStep "mode" "DryRun" "No state will be changed. Pass -ConfirmDestructiveClean to reset OpenClaw-owned state." + Write-Host "DRY-RUN: pass -ConfirmDestructiveClean to actually reset OpenClaw-owned state." + } + else { + Add-ResetStep "mode" "Confirmed" "OpenClaw-owned state reset is enabled for this run." + Write-Host "Backups will be written under: $BackupRoot" + } + + if ($processes.Count -eq 0) { + Add-ResetStep "stop-openclaw-processes" "Skipped" "No OpenClaw processes are running." + } + elseif ($KeepRunningProcesses) { + Add-ResetStep "stop-openclaw-processes" "Skipped" "Keeping running OpenClaw processes because -KeepRunningProcesses was set." @{ + pids = @($processes | ForEach-Object { $_.Id }) + } + } + elseif ($result.dryRun) { + Add-ResetStep "stop-openclaw-processes" "DryRun" "Would stop running OpenClaw processes by PID." @{ + pids = @($processes | ForEach-Object { $_.Id }) + } + } + else { + foreach ($process in $processes) { + Stop-Process -Id $process.Id -Force + } + Add-ResetStep "stop-openclaw-processes" "Completed" "Stopped running OpenClaw processes by PID." @{ + pids = @($processes | ForEach-Object { $_.Id }) + } + } + + $hasGatewayDistro = $distros -contains $script:OpenClawDistroName + $wslListPath = Join-Path $OutputDir "wsl-list-before.txt" + & wsl.exe --list --verbose > $wslListPath 2>&1 + Add-ResetStep "capture-wsl-list" "Completed" "Captured WSL distro list." @{ path = $wslListPath } + + if (-not $hasGatewayDistro) { + Add-ResetStep "unregister-$($script:OpenClawDistroName)" "Skipped" "WSL distro '$($script:OpenClawDistroName)' is not registered." + } + elseif ($result.dryRun) { + Add-ResetStep "unregister-$($script:OpenClawDistroName)" "DryRun" "Would terminate and unregister only the '$($script:OpenClawDistroName)' WSL distro." @{ distroName = $script:OpenClawDistroName } + } + else { + # Exact-target only: --terminate , never --shutdown. + Invoke-CapturedCommand "wsl-terminate-$($script:OpenClawDistroName)" "wsl.exe" @("--terminate", $script:OpenClawDistroName) -IgnoreExitCode + Invoke-CapturedCommand "wsl-unregister-$($script:OpenClawDistroName)" "wsl.exe" @("--unregister", $script:OpenClawDistroName) + } + + Backup-Directory -Path $appData -Label "appdata" + Backup-Directory -Path $localAppData -Label "localappdata" + if ($CleanInstallLocation) { + if ([string]::IsNullOrWhiteSpace($InstallLocation)) { + Add-ResetStep "backup-install-location" "Skipped" "No install location was supplied." + } + else { + Backup-Directory -Path $InstallLocation -Label "install-location" + } + } + else { + Add-ResetStep "backup-install-location" "Skipped" "Install location cleanup was not requested." + } + Assert-CleanPostCondition -AppDataPath $appData -LocalAppDataPath $localAppData -InstallLocationPath $InstallLocation + + $result.finishedAt = (Get-Date).ToString("o") + $summaryPath = Join-Path $OutputDir "reset-summary.json" + $result | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $summaryPath -Encoding UTF8 + if ($PassThruJson) { + $result | ConvertTo-Json -Depth 10 + } + else { + Write-Host "Reset summary: $summaryPath" + if (-not $result.dryRun) { + Write-Host "Backup root: $BackupRoot" + } + } +} +catch { + $result.finishedAt = (Get-Date).ToString("o") + Add-ResetStep "reset" "Failed" $_.Exception.Message + $summaryPath = Join-Path $OutputDir "reset-summary.json" + $result | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $summaryPath -Encoding UTF8 + Write-Error $_.Exception.Message + exit 1 +} From 32cbeae8d1e918c752f8f64ce96f051b5a5b816f Mon Sep 17 00:00:00 2001 From: Mattingly Date: Mon, 4 May 2026 14:10:32 -0700 Subject: [PATCH 14/56] fix(onboarding): drop time estimate + clean orphan Welcome resw entries (Phase 5 fast-follow) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Pages/LocalSetupProgressPage.cs | 2 +- .../Strings/en-us/Resources.resw | 29 +---- .../Strings/fr-fr/Resources.resw | 29 +---- .../Strings/nl-nl/Resources.resw | 117 +++++++----------- .../Strings/zh-cn/Resources.resw | 29 +---- .../Strings/zh-tw/Resources.resw | 29 +---- 6 files changed, 50 insertions(+), 185 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs index 39237afb..41997e1a 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs @@ -124,7 +124,7 @@ void Handler(LocalGatewaySetupState st) var status = snapshot?.Status ?? LocalGatewaySetupStatus.Pending; var subtitle = !string.IsNullOrWhiteSpace(snapshot?.UserMessage) ? snapshot!.UserMessage! - : "Setting up your local OpenClaw gateway. This usually takes a few minutes."; + : "Setting up your local OpenClaw gateway."; var stageRows = s_visibleStages.Select(stage => RenderStage(stage.Label, stage.Phases, phase, status)).ToArray(); diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 59accc1b..195dad95 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -1,4 +1,4 @@ - + @@ -1089,30 +1089,6 @@ On your gateway host (Mac/Linux), run: Finish - - Welcome to OpenClaw - - - Your AI assistant, right from the system tray - - - Security Notice - - - OpenClaw runs an AI agent that can execute commands, read and write files, and interact with your system on your behalf. - - - Your agent can: - - - Run commands on your computer - - - Read and write files - - - Capture screenshots - Choose your Gateway @@ -1158,9 +1134,6 @@ On your gateway host (Mac/Linux), run: Make sure your remote gateway is running and accessible. - - Let's get you connected in just a few steps. - Setup Code diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index c592a252..99d87267 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -1,4 +1,4 @@ - + @@ -1088,30 +1088,6 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : Terminer - - Bienvenue dans OpenClaw - - - Votre assistant IA, directement depuis la barre des tâches - - - Avis de sécurité - - - OpenClaw exécute un agent IA capable d'exécuter des commandes, de lire et écrire des fichiers, et d'interagir avec votre système en votre nom. - - - Votre agent peut : - - - Exécuter des commandes sur votre ordinateur - - - Lire et écrire des fichiers - - - Capturer des captures d'écran - Choisissez votre passerelle @@ -1157,9 +1133,6 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : Assurez-vous que votre passerelle distante est en cours d'exécution et accessible. - - Connectons-nous en quelques étapes. - Code de configuration diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 443c34b9..8c34fe43 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -1,4 +1,4 @@ - + @@ -266,7 +266,7 @@ - ⚡ Activiteitenstroom + ⚡ Activiteitenstroom @@ -306,7 +306,7 @@ - 📋 Meldingsgeschiedenis + 📋 Meldingsgeschiedenis @@ -326,19 +326,19 @@ - Instellingen — OpenClaw Tray + Instellingen — OpenClaw Tray - Status — OpenClaw Tray + Status — OpenClaw Tray - Activiteitenstroom — OpenClaw Tray + Activiteitenstroom — OpenClaw Tray - Meldingsgeschiedenis — OpenClaw Tray + Meldingsgeschiedenis — OpenClaw Tray - Snel verzenden — OpenClaw + Snel verzenden — OpenClaw OpenClaw Chat @@ -355,15 +355,15 @@ Testen... - ✅ Verbonden! + ✅ Verbonden! - ❌ Verbinding mislukt + ❌ Verbinding mislukt - 📤 Snel verzenden + 📤 Snel verzenden Typ je bericht... @@ -378,7 +378,7 @@ Verzenden... - ❌ Mislukt + ❌ Mislukt Bericht verzonden @@ -398,13 +398,13 @@ Om te beginnen heb je het volgende nodig: - • Een actieve OpenClaw-gateway + • Een actieve OpenClaw-gateway - • Je API-token van het dashboard + • Je API-token van het dashboard - 📚 Documentatie bekijken + 📚 Documentatie bekijken Later @@ -415,7 +415,7 @@ - 🎉 Versie {0} is beschikbaar! + 🎉 Versie {0} is beschikbaar! Huidige versie: {0} @@ -459,7 +459,7 @@ Automatisch starten - Automatisch starten ✓ + Automatisch starten ✓ Logbestand openen @@ -542,25 +542,25 @@ Geen gebruiksgegevens - ↳ Sessie resetten + ↳ Sessie resetten - ↳ Log comprimeren + ↳ Log comprimeren - ↳ Sessie verwijderen + ↳ Sessie verwijderen - ⏳ Wachten op goedkeuring... + ⏳ Wachten op goedkeuring... - ✅ Gekoppeld en verbonden + ✅ Gekoppeld en verbonden - 🔄 Verbinden... + 🔄 Verbinden... - ⚪ Niet verbonden + ⚪ Niet verbonden @@ -622,13 +622,13 @@ Canvas - ❌ Canvasfout + ❌ Canvasfout Opnieuw proberen - 🎨 Canvas gereed + 🎨 Canvas gereed Wachten op inhoud... @@ -667,17 +667,17 @@ De gateway op {0} reageert niet. Om verbinding te maken: -• Zorg dat je OpenClaw-gateway actief is -• Als deze extern is, maak verbinding via VPN met je thuisnetwerk -• Of gebruik een SSH-tunnel: ssh -N -L 18789:localhost:18789 jouw-server +• Zorg dat je OpenClaw-gateway actief is +• Als deze extern is, maak verbinding via VPN met je thuisnetwerk +• Of gebruik een SSH-tunnel: ssh -N -L 18789:localhost:18789 jouw-server Het HTTPS-certificaat van de gateway wordt niet vertrouwd. Om een beveiligde verbinding te maken: -• Gebruik een HTTPS-gateway-URL (bijvoorbeeld: https://host.tailnet.ts.net) -• Bij een zelfondertekend certificaat: importeer het in Vertrouwde basiscertificeringsinstanties van Windows -• Of gebruik een SSH-tunnel naar localhost en blijf localhost-URL's gebruiken +• Gebruik een HTTPS-gateway-URL (bijvoorbeeld: https://host.tailnet.ts.net) +• Bij een zelfondertekend certificaat: importeer het in Vertrouwde basiscertificeringsinstanties van Windows +• Of gebruik een SSH-tunnel naar localhost en blijf localhost-URL's gebruiken Ongeldige gateway-URL: {0} @@ -688,9 +688,9 @@ Om een beveiligde verbinding te maken: Er is geen veilige omweg voor extern onbeveiligd HTTP: browsers en WebView dwingen dit af. Gebruik een van deze opties: -• Gebruik een vertrouwd HTTPS/WSS-eindpunt (Let's Encrypt, Tailscale Serve, Caddy) -• Bij een zelfondertekend certificaat: importeer je gateway-CA/certificaat in Vertrouwde basiscertificeringsinstanties van Windows (certmgr.msc) -• Of tunnel naar localhost: ssh -N -L 18789:localhost:18789 <server> +• Gebruik een vertrouwd HTTPS/WSS-eindpunt (Let's Encrypt, Tailscale Serve, Caddy) +• Bij een zelfondertekend certificaat: importeer je gateway-CA/certificaat in Vertrouwde basiscertificeringsinstanties van Windows (certmgr.msc) +• Of tunnel naar localhost: ssh -N -L 18789:localhost:18789 <server> @@ -702,13 +702,13 @@ Gebruik een van deze opties: - 📋 Apparaat-ID gekopieerd + 📋 Apparaat-ID gekopieerd Voer uit: openclaw devices approve {0}... - 📋 Node-overzicht gekopieerd + 📋 Node-overzicht gekopieerd {0} node(s) gekopieerd naar klembord @@ -717,7 +717,7 @@ Gebruik een van deze opties: - ❌ Sessieactie mislukt + ❌ Sessieactie mislukt Kan verzoek niet naar gateway verzenden. @@ -726,13 +726,13 @@ Gebruik een van deze opties: - 🔌 Node-modus actief + 🔌 Node-modus actief Deze pc kan nu opdrachten ontvangen van de agent (canvas, schermafbeeldingen) - ⏳ Wachten op koppelingsgoedkeuring + ⏳ Wachten op koppelingsgoedkeuring Voer uit op gateway: openclaw devices approve {0}... @@ -744,13 +744,13 @@ Gebruik een van deze opties: Koppelingsopdracht gekopieerd - ✅ Node gekoppeld! + ✅ Node gekoppeld! Deze pc kan nu opdrachten ontvangen van de agent - ❌ Koppeling geweigerd + ❌ Koppeling geweigerd De gateway heeft het koppelingsverzoek voor dit apparaat geweigerd. @@ -774,13 +774,13 @@ Gebruik een van deze opties: - 📸 Schermafbeelding gemaakt + 📸 Schermafbeelding gemaakt OpenClaw-agent heeft je scherm vastgelegd - 📷 Cameratoegang geblokkeerd + 📷 Cameratoegang geblokkeerd Schakel cameratoegang in via Windows-privacyinstellingen voor OpenClaw Tray @@ -789,7 +789,7 @@ Gebruik een van deze opties: - ⚡ Nieuw: Activiteitenstroom + ⚡ Nieuw: Activiteitenstroom Open het systeemvakmenu om live sessies, gebruik en node-activiteit in één overzicht te bekijken. @@ -1089,30 +1089,6 @@ Voer op uw gateway-host (Mac/Linux) uit: Voltooien - - Welkom bij OpenClaw - - - Uw AI-assistent, direct vanuit het systeemvak - - - Beveiligingsmelding - - - OpenClaw draait een AI-agent die opdrachten kan uitvoeren, bestanden kan lezen en schrijven, en namens u met uw systeem kan communiceren. - - - Uw agent kan: - - - Opdrachten uitvoeren op uw computer - - - Bestanden lezen en schrijven - - - Schermafbeeldingen maken - Kies uw gateway @@ -1158,9 +1134,6 @@ Voer op uw gateway-host (Mac/Linux) uit: Zorg ervoor dat uw externe gateway actief en bereikbaar is. - - Laten we u in enkele stappen verbinden. - Configuratiecode diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 3bd92a3e..e41259a2 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -1,4 +1,4 @@ - + @@ -1089,30 +1089,6 @@ 完成 - - 欢迎使用 OpenClaw - - - 您的 AI 助手,就在系统托盘中 - - - 安全提示 - - - OpenClaw 运行的 AI 代理可以执行命令、读写文件,并代表您与系统进行交互。 - - - 您的代理可以: - - - 在您的计算机上运行命令 - - - 读取和写入文件 - - - 捕获屏幕截图 - 选择网关 @@ -1158,9 +1134,6 @@ 请确保您的远程网关正在运行且可以访问。 - - 只需几步即可完成连接。 - 配置码 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index dd76c719..d43e00dd 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -1,4 +1,4 @@ - + @@ -1089,30 +1089,6 @@ 完成 - - 歡迎使用 OpenClaw - - - 您的 AI 助手,就在系統匣中 - - - 安全性通知 - - - OpenClaw 執行的 AI 代理程式可以執行命令、讀寫檔案,並代表您與系統進行互動。 - - - 您的代理程式可以: - - - 在您的電腦上執行命令 - - - 讀取和寫入檔案 - - - 擷取螢幕截圖 - 選擇閘道 @@ -1158,9 +1134,6 @@ 請確認您的遠端閘道正在執行且可以存取。 - - 只需幾個步驟即可完成連線。 - 設定碼 From 13009816cf576867c07ba7a7ad662d7d16f216bd Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 4 May 2026 14:18:03 -0700 Subject: [PATCH 15/56] docs(wsl): port wsl-owner-validation + wsl-owner-open-issues with Craig's answers (Phase 8) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/wsl-owner-open-issues.md | 369 +++++++++++++++++++++++++++++++++ docs/wsl-owner-validation.md | 375 ++++++++++++++++++++++++++++++++++ 2 files changed, 744 insertions(+) create mode 100644 docs/wsl-owner-open-issues.md create mode 100644 docs/wsl-owner-validation.md diff --git a/docs/wsl-owner-open-issues.md b/docs/wsl-owner-open-issues.md new file mode 100644 index 00000000..eaa607f7 --- /dev/null +++ b/docs/wsl-owner-open-issues.md @@ -0,0 +1,369 @@ +# OpenClaw Windows local gateway: WSL-owner Q&A + +This document is the structured record of the questions we asked Craig Loewen +(WSL) about the Windows OpenClaw local-gateway design, and Craig's answers. +It is the canonical "why does the architecture look like this?" reference +for the Windows local-gateway PR. + +Companion: [`docs/wsl-owner-validation.md`](wsl-owner-validation.md) +describes the resulting design as it ships. + +**Status legend:** ✅ Answered (verbatim or paraphrased Craig answer +recorded). 🟡 Open. + +**Source:** Craig Loewen's review of the prototype `wsl-owner-open-issues.md` +(2026-05-04). His answers are summarized authoritatively in +`.squad/decisions.md` under "Decision: Craig Loewen's WSL Answers +(Authoritative)" and underpinned the Phase 3 plan revision in +`.squad/decisions-archive.md`. The architecture statements below are +paraphrased; Mike's relayed verbatim Q&A lives in the squad decisions thread, +not in the public PR. + +The design is built on three coupled choices: + +1. **Distribution model:** create a dedicated `OpenClawGateway` instance from + the Store Ubuntu-24.04 package and configure it post-install — no custom + OpenClaw rootfs. +2. **Networking model:** loopback only between the Windows tray and the + gateway in WSL — no WSL-IP fallback, no `lan`/`auto` bind. +3. **Lifecycle model:** instance-scoped `wsl --terminate OpenClawGateway` for + repair; user-systemd plus a tray-owned keepalive for liveness; no global + `wsl --shutdown` and no global `.wslconfig` mutation. + +The goal remains a low-maintenance implementation that uses the public +OpenClaw Linux installer unchanged and does not maintain a custom OpenClaw +Linux distribution. + +## Final shape + +1. The Windows tray verifies WSL/WSL2 availability. +2. The tray creates a dedicated WSL2 instance named `OpenClawGateway` from + the Store Ubuntu-24.04 package: + ```powershell + wsl.exe --install Ubuntu-24.04 ` + --name OpenClawGateway ` + --location "$env:LOCALAPPDATA\OpenClawTray\wsl" ` + --no-launch ` + --version 2 + ``` +3. The tray launches the instance as root and applies OpenClaw-owned + configuration: + - create the `openclaw` user; + - create `/home/openclaw/.openclaw`, `/opt/openclaw`, + `/var/lib/openclaw`, and `/var/log/openclaw`; + - write `/etc/wsl.conf` and `/etc/wsl-distribution.conf`; + - set the default user to `openclaw` via + `wsl --manage OpenClawGateway --set-default-user openclaw`; + - terminate only `OpenClawGateway` so WSL config takes effect. +4. The tray runs the public OpenClaw Linux installer inside the instance: + `https://openclaw.ai/install-cli.sh` with prefix `/opt/openclaw`. No + forked or patched gateway installer. +5. The tray uses upstream OpenClaw CLI/service commands to configure and + start the gateway. +6. The tray calls upstream `openclaw qr --json`, consumes the upstream + setup-code/bootstrap-token handoff, and pairs Windows tray operator and + Windows tray node sessions; both device tokens land in + `%APPDATA%\OpenClawTray\device-key-ed25519.json`. + +## Issue 1: Ubuntu Store package + post-install configuration + +### Q1.1 — Is `wsl --install Ubuntu-24.04 --name OpenClawGateway --location ... --no-launch --version 2` a supported primitive for a Windows app creating a dedicated app-owned WSL instance? + +**Status:** ✅ Answered. + +**Craig:** Yes — supportable. This is the canonical primitive for an +app-owned WSL instance. + +**Implication:** `LocalGatewaySetup.cs` issues exactly this command. The +clean port removed `--web-download`, `--from-file`, and any rootfs-import +fallback. + +### Q1.2 — Is it acceptable to treat the install as successful when post-conditions pass, even if the `wsl --install` process itself hangs or exits unclearly? + +**Status:** ✅ Answered. + +**Craig:** **Trust the exit code.** The hang-fallback pattern from the +prototype is not needed. + +**Implication:** The clean engine treats `wsl --install` exit 0 as the +success signal, and additionally confirms `OpenClawGateway` appears in +`wsl --list --quiet` to defend against the "winget-style" failure mode where +exit 0 reports success without registering a distro (see Q1.3). Non-zero +exit ⇒ install failure; no postcondition-on-hang path. + +### Q1.3 — Should we prefer generic `Ubuntu`, explicit `Ubuntu-24.04`, `--web-download`, `--from-file`, or another source for the default path? + +**Status:** ✅ Answered. + +**Craig:** Use **explicit `Ubuntu-24.04`**, not generic `Ubuntu`. No +`--web-download` and no `--from-file` are needed. + +**Implication:** The clean install command is pinned to `Ubuntu-24.04`. The +prototype's "generic `Ubuntu` channel was more reliable on this dev machine" +observation is not a basis for a final product default. + +Empirical confirmation (2026-05-04, 20-iter harness on Windows 10.0.26200, +WSL 2.6.3.0): `wsl --install Ubuntu-24.04 --name --location +--no-launch --version 2` succeeded **10/10**; `winget install --id +Canonical.Ubuntu.2404 -e --silent --accept-source-agreements +--accept-package-agreements --disable-interactivity` succeeded **0/10** +(stages the launcher APPX but never registers a WSL distro under +`--silent --disable-interactivity`). Raw artifacts: +`artifacts/wsl-install-vs-winget/run-20260504-131837/summary.json`. + +### Q1.4 — What is the recommended enterprise/offline fallback when Store access is blocked? + +**Status:** ✅ Answered. + +**Craig:** Modern WSL distributions are no longer Store-gated; an offline +fallback is **not needed** for this PR. + +**Implication:** No offline fallback path ships in this PR. If a future +enterprise scenario surfaces a real blocker, that decision can be revisited +separately. + +### Q1.5 — Are `automount=false`, `interop=false`, and `appendWindowsPath=false` appropriate for this managed instance? + +**Status:** ✅ Answered. + +**Craig:** Yes — all three settings are appropriate for an app-owned +appliance. + +**Implication:** `/etc/wsl.conf` ships with all three disabled (see +`docs/wsl-owner-validation.md`). + +### Q1.6 — Are there WSL/systemd/machine-id/DNS/timezone details we should explicitly repair or validate after cloning/configuring an Ubuntu instance? + +**Status:** ✅ Answered. + +**Craig:** **No post-clone repairs needed** — machine-id / DNS / timezone +work as delivered. + +**Implication:** The setup engine does not regenerate `/etc/machine-id`, +does not rewrite `/etc/resolv.conf`, and does not touch timezone state. It +relies on `useWindowsTimezone=true` in `/etc/wsl.conf` for clock alignment. + +### Q1.7 — Should OpenClaw avoid writing `/etc/wsl-distribution.conf`, or is it appropriate to suppress shortcuts/terminal profile for the dedicated instance? + +**Status:** ✅ Answered. + +**Craig:** Use both `wsl.conf` and `wsl-distribution.conf`. Suppressing +shortcut/terminal entries is the correct application of +`wsl-distribution.conf` for a privately managed instance. + +**Implication:** The setup engine writes `/etc/wsl-distribution.conf` with +`shortcut.enabled=false` and `terminal.enabled=false`. + +## Issue 2: Local networking between Windows and the WSL gateway + +### Q2.1 — Is Windows localhost forwarding to a WSL2 service reliable enough to make `loopback` the final default? + +**Status:** ✅ Answered. + +**Craig:** **Yes — loopback only.** Windows localhost forwarding to a WSL2 +service is a reliable core WSL promise. + +**Implication:** Gateway binds to loopback inside WSL on `:18789`. Windows +tray connects via `http://localhost:18789` / `ws://localhost:18789`. The +prototype's earlier observations of localhost-forwarding flakiness were +attributed to other lifecycle issues (see Issue 3) and not to the forwarding +contract itself. + +### Q2.2 — If localhost forwarding fails, is WSL-IP fallback a supported/recommended pattern for a Windows app-owned WSL instance? + +**Status:** ✅ Answered. + +**Craig:** **No.** WSL-IP fallback is not the recommended pattern. + +**Implication:** The clean port has **no** WSL-IP fallback. The endpoint +resolver does not enumerate WSL interface addresses, does not run +`hostname -I` / `ip -4 addr` / `ip route` / `ss -ltnp` inside WSL, and +returns exactly one candidate: `http://localhost:18789`. + +### Q2.3 — Is `gateway.bind=lan` inside the WSL instance acceptable for the fallback path, assuming the Windows tray still only advertises/selects local endpoints by default? + +**Status:** ✅ Answered. + +**Craig:** **No** — loopback only. + +**Implication:** The setup engine never writes `gateway.bind=lan`. The +runtime configuration surface for `gateway.bind` was removed. + +### Q2.4 — Should we implement `auto` bind promotion instead of defaulting to `lan`? + +**Status:** ✅ Answered. + +**Craig:** **No.** Loopback only; no `auto` promotion. + +**Implication:** No promotion logic exists in the clean port. There is one +bind mode, and it is loopback. + +### Q2.5 — Are there WSL NAT, mirrored networking, firewall, or portproxy recommendations we should follow while still avoiding global `.wslconfig` changes? + +**Status:** ✅ Answered. + +**Craig:** No — loopback forwarding works without any of those +modifications. + +**Implication:** The tray does not write to `.wslconfig`, does not configure +mirrored networking, does not add Windows firewall rules, and does not run +`netsh interface portproxy` for normal local-gateway operation. + +### Q2.6 — What diagnostics should we capture before asking users/maintainers to file WSL networking bugs? + +**Status:** ✅ Answered. + +**Craig:** Point at ****. Do not scrape WSL internal +log files from the product. + +**Implication:** On any setup or networking failure, the +`LocalSetupProgressPage` shows an aka.ms/wsllogs hint, the validation +script's `Save-DiagnosticsSnapshot` records `wslLogsHelp = +https://aka.ms/wsllogs`, and the run summary appends a "Diagnostics: see +https://aka.ms/wsllogs..." note. The product captures only its own state +(Windows-side `:18789` listener snapshot, loopback `/health` probe, +redacted setup-state.json) and a generated repro guide. + +## Issue 3: WSL gateway lifecycle and service ownership + +### Q3.1 — For an app-owned WSL appliance, should the gateway be a user-systemd service, a root/system service wrapper, or something else? + +**Status:** ✅ Answered. + +**Craig:** Both **user-systemd** and a **tray-owned keepalive** are +acceptable for this shape. + +**Implication:** The clean port uses upstream OpenClaw service primitives +under the `openclaw` user, plus a tray-owned WSL keepalive +(`wsl.exe -d OpenClawGateway -u openclaw -- sleep 2147483647`) while +local-gateway mode is active. Readiness still requires Windows-side +`/health` to succeed — `systemctl active` alone does not imply Windows +reachability. + +### Q3.2 — Is `loginctl enable-linger openclaw` expected to be reliable in this WSL shape, or should we avoid depending on it? + +**Status:** ✅ Answered. + +**Craig:** Linger is acceptable for this shape (alongside the tray +keepalive). + +**Implication:** Setup runs `loginctl enable-linger openclaw`. The tray +keepalive remains as belt-and-suspenders for the active local-gateway +window. + +### Q3.3 — Is a tray-owned keepalive process acceptable, or should it be treated as validation-only? + +**Status:** ✅ Answered. + +**Craig:** Acceptable as a product primitive (see Q3.1). It is not +validation-only. + +**Implication:** The keepalive ships as part of the runtime, not just as a +test scaffold. + +### Q3.4 — Is instance-scoped `wsl --terminate OpenClawGateway` the right repair/restart primitive? + +**Status:** ✅ Answered. + +**Craig:** **Yes.** Use `wsl --terminate OpenClawGateway` only. **Never** +global `wsl --shutdown`. + +**Implication:** Setup, repair, validation, and removal paths all use +`wsl --terminate OpenClawGateway`. `git grep 'wsl --shutdown'` over the +clean worktree returns no product or validation hits. + +### Q3.5 — Are there cases where global `wsl --shutdown` is recommended or unavoidable, despite our desire to avoid it? + +**Status:** ✅ Answered. + +**Craig:** **No.** Do not issue `wsl --shutdown` from this product. + +**Implication:** Recreate / FreshMachine validation scenarios use +`wsl --unregister OpenClawGateway` for destructive cleanup. They never +issue a global shutdown. + +### Q3.6 — What lifecycle diagnostics should the tray collect when WSL reports the service active but Windows cannot connect? + +**Status:** ✅ Answered. + +**Craig:** Same answer as Q2.6 — point at ; the +product should not scrape WSL logs. + +**Implication:** The product collects only its own state and points at the +WSL-team-owned diagnostics page. See Q2.6. + +## Mac app comparison: operator vs node + +The macOS app runs operator/UI and a local Mac node from the same app +binary/process via separate gateway sessions: + +- `GatewayConnection.shared` owns one `GatewayChannelActor` for + operator/UI scopes (`role: "operator"`, `clientMode: "ui"`). +- `MacNodeModeCoordinator.shared.start()` owns a separate + `GatewayNodeSession` and `MacNodeRuntime` (`role: "node"`, + `clientId: "openclaw-macos"`, capabilities for canvas / screen / browser + / etc.), connecting to the same gateway URL over a distinct WebSocket. +- In local mode, `GatewayProcessManager` manages the local gateway via + launchd / OpenClaw CLI behavior; in remote mode, + `ConnectionModeCoordinator` stops the local gateway and uses + `NodeServiceManager.start()` against the remote gateway. + +**Implication for Windows (decided by Mike):** The Windows tray pairs as +**both operator and node** against the local gateway, mirroring the macOS +in-app node model. There is **no separate WSL-internal worker** in this +PR. `StartWorker` / `PairWorker` phases were dropped; the +`PreserveWorkerData` parameter and `worker_data_preserved` lifecycle step +were removed in Phase 3 cleanup. + +If a future scope adds a Linux worker inside the WSL gateway instance, it +will require a separate upstream-supported install/start/list proof and a +new owner decision — not a re-litigation of the current PR. + +## Architectural decisions captured + +For traceability, the high-order decisions implied by Craig's answers are: + +1. **Distribution model** — Store Ubuntu-24.04 + post-install configuration; + no custom rootfs; no offline fallback. (Q1.1, Q1.3, Q1.4) +2. **Configuration** — `wsl.conf` (systemd, automount/interop/appendPath + off, default user `openclaw`, `useWindowsTimezone=true`) + + `wsl-distribution.conf` (no shortcut, no terminal). No post-clone + repairs. (Q1.5, Q1.6, Q1.7) +3. **Networking** — Loopback only, port 18789. No WSL-IP fallback. No + `lan`/`auto` bind. No `.wslconfig` / portproxy / firewall mutation. + (Q2.1–Q2.5) +4. **Lifecycle** — User-systemd + tray keepalive. Linger acceptable. + `wsl --terminate OpenClawGateway` for repair. **Never** global + `wsl --shutdown`. (Q3.1–Q3.5) +5. **Diagnostics** — `https://aka.ms/wsllogs`. No internal log scraping. + (Q2.6, Q3.6) +6. **Roles in scope** — Windows tray operator + Windows tray node. + Worker-in-WSL out of scope. (Mac app comparison + Mike's Phase-0 + decision.) + +These decisions are reflected one-for-one in: + +- `src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs` +- `src/OpenClaw.Tray.WinUI/App.xaml.cs` (factory + identity-path wiring) +- `src/OpenClaw.Tray.WinUI/Services/NodeService.cs` +- `src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs` +- `src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs` +- `scripts/validate-wsl-gateway.ps1` (4 scenarios) +- `scripts/reset-openclaw-wsl-validation-state.ps1` (exact-target gated + cleanup) + +## Open follow-ups + +These are not open architecture questions for Craig — they are tracked +work items that intentionally fall outside this PR: + +- **Off-box / LAN / phone reachability via OpenClaw relay.** Blocked on + relay ownership / protocol clarity. Not addressed in this PR. +- **`winget install Microsoft.WSL` as a platform repair fallback.** Deeper + research in flight; does not change the Phase 3 decision to use + `wsl --install` for distro creation in this PR. +- **Onboarding copy localization.** `Onboarding_SetupWarning_*` / + `Onboarding_LocalSetupProgress_*` resw entries to be added across + supported locales after Mike signs off final copy. + +No open questions for Craig remain that block this PR. diff --git a/docs/wsl-owner-validation.md b/docs/wsl-owner-validation.md new file mode 100644 index 00000000..eb8cf5fd --- /dev/null +++ b/docs/wsl-owner-validation.md @@ -0,0 +1,375 @@ +# OpenClaw Windows local gateway: WSL design validation + +This document describes the WSL design that ships in this PR. It reflects Craig +Loewen's authoritative review of `docs/wsl-owner-open-issues.md` (verbatim Q&A +reproduced inline in that companion doc). Where the prototype enumerated +options, this version states the chosen design. + +The current scope is: + +- A dedicated app-owned **Ubuntu-24.04** WSL2 instance named `OpenClawGateway`, + created from the standard Ubuntu Store package and then configured by the + Windows tray. +- The public OpenClaw Linux installer (`https://openclaw.ai/install-cli.sh`) + runs unchanged inside that instance with prefix `/opt/openclaw`. +- **Loopback-only** local networking (`http://localhost:18789`) between the + Windows tray and the gateway. +- Repair / restart via instance-scoped `wsl --terminate OpenClawGateway`. +- Diagnostics on failure pointed at . +- The Windows tray pairs as both **operator** and **node** against the local + gateway (matching the macOS app's in-app node model). No worker-in-WSL is + installed by the Windows tray in this PR. + +Out of scope for this PR (explicitly): + +- No custom OpenClaw rootfs / OpenClaw-distributed Linux image. +- No `--web-download` / `--from-file` / signed offline-base-artifact fallback. +- No WSL-IP / `lan` / `auto`-bind fallback. No `gateway.bind` overrides. +- No global `.wslconfig` mutation. No global `wsl --shutdown` from any product + or validation path. +- No `\\wsl$` or `\\wsl.localhost` file I/O. All WSL file operations go through + `wsl.exe -d OpenClawGateway -- ...`. + +## High-level user experience + +1. User installs or opens the Windows tray app. +2. The first onboarding page (`SetupWarningPage`) offers **Set up locally** + (default) or **Advanced setup**. +3. **Set up locally** opens `LocalSetupProgressPage`, which drives + `LocalGatewaySetupEngine` to: + - preflight the WSL host; + - create the `OpenClawGateway` instance from Ubuntu-24.04; + - apply OpenClaw-owned WSL configuration (`/etc/wsl.conf`, + `/etc/wsl-distribution.conf`, `openclaw` user, state directories); + - install OpenClaw via the public installer; + - prepare and start the gateway service; + - mint a bootstrap setup-code via `openclaw qr --json`; + - pair the Windows tray operator and Windows tray node; + - verify end-to-end reachability over loopback. +4. On terminal failure, the page surfaces a link to ; + no internal log scraping is attempted. + +## End-state architecture + +```mermaid +flowchart LR + subgraph Windows["Windows user session"] + Tray["OpenClaw Tray app"] + Identity["%APPDATA%\OpenClawTray\
device-key-ed25519.json (operator + node)"] + Engine["LocalGatewaySetupEngine"] + WslFeature["Windows WSL platform"] + end + + subgraph WSL["WSL2: OpenClawGateway"] + Ubuntu["Ubuntu-24.04 (Store)"] + WslConf["/etc/wsl.conf
systemd=true
automount=false
interop=false
appendWindowsPath=false
default user=openclaw"] + DistroConf["/etc/wsl-distribution.conf
shortcut=false
terminal=false"] + Systemd["systemd"] + Installer["public installer
install-cli.sh
--prefix /opt/openclaw"] + GatewaySvc["openclaw gateway
bind=loopback :18789"] + State["/var/lib/openclaw"] + end + + Tray --> Engine + Engine -->|"wsl --install Ubuntu-24.04 --name OpenClawGateway --location \OpenClawTray\wsl --no-launch --version 2"| WslFeature + WslFeature --> Ubuntu + Ubuntu --> WslConf + Ubuntu --> DistroConf + WslConf --> Systemd + Engine -->|"wsl -d OpenClawGateway -u root -- bash install-cli.sh"| Installer + Installer --> GatewaySvc + Systemd --> GatewaySvc + GatewaySvc --> State + Tray -->|"http://localhost:18789 (operator + node WebSocket sessions)"| GatewaySvc + Tray --> Identity +``` + +## WSL touch points + +### Dedicated WSL instance lifecycle + +The tray treats WSL as an application-owned runtime boundary and uses a single +dedicated WSL2 instance named `OpenClawGateway`. The base is **Ubuntu-24.04** +from the Store; the OpenClaw-owned configuration is applied after the instance +is laid down. + +| Operation | WSL command | Scope | +| --- | --- | --- | +| Preflight | `wsl.exe --status`, `wsl.exe --list --verbose` | Read-only WSL capability checks | +| Instance creation | `wsl.exe --install Ubuntu-24.04 --name OpenClawGateway --location <%LOCALAPPDATA%>\OpenClawTray\wsl --no-launch --version 2` | Creates only the dedicated OpenClaw instance | +| In-instance configuration | `wsl.exe -d OpenClawGateway -u root -- ...` | Writes `/etc/wsl.conf`, `/etc/wsl-distribution.conf`, creates `openclaw` user and state dirs | +| Default user | `wsl.exe --manage OpenClawGateway --set-default-user openclaw` | Locks default user to `openclaw` | +| Apply config | `wsl.exe --terminate OpenClawGateway` (then implicit restart on next command) | Picks up `wsl.conf` changes | +| Public OpenClaw install | `wsl.exe -d OpenClawGateway -u root -- bash -c "curl -fsSL https://openclaw.ai/install-cli.sh \| bash -s -- --prefix /opt/openclaw"` | Runs the public installer unchanged | +| Service start/check | `wsl.exe -d OpenClawGateway -u root -- systemctl ...` | Starts/checks OpenClaw gateway | +| Repair | `wsl.exe --terminate OpenClawGateway` | Instance-scoped restart only | +| Remove | `wsl.exe --terminate OpenClawGateway`, `wsl.exe --unregister OpenClawGateway` | Requires explicit user confirmation | + +Guarantees: + +- **WSL2 only** for the OpenClaw instance. +- The tray never modifies the user's default WSL instance. +- The tray never modifies global `.wslconfig`. +- The tray never calls global `wsl.exe --shutdown` in any product, validation, + repair, or removal path. +- The tray never unregisters arbitrary WSL instances; only the exact + `OpenClawGateway` name is eligible, and destructive cleanup requires explicit + confirmation in scripts. + +### Install command and success criterion + +The single canonical install primitive is: + +```powershell +wsl.exe --install Ubuntu-24.04 ` + --name OpenClawGateway ` + --location "$env:LOCALAPPDATA\OpenClawTray\wsl" ` + --no-launch ` + --version 2 +``` + +Success criterion (per Craig): **trust the `wsl --install` exit code**. +There is no postcondition-on-hang fallback. After exit, the engine confirms +that `OpenClawGateway` appears in `wsl --list --quiet`; failure of that +post-condition is treated as install failure regardless of stdout. + +`Ubuntu-24.04` is used explicitly (not the generic `Ubuntu` channel). No +`--web-download` and no `--from-file` are used; there is no offline base +fallback in this PR. + +#### Empirical evidence + +The literature recommendation (`wsl --install` over `winget install +Canonical.Ubuntu.2404`) was confirmed empirically on 2026-05-04 with a 20-iter +harness: + +| Path | success | failure | strict success rate | +|---|---:|---:|---| +| `wsl --install Ubuntu-24.04 --name --location --no-launch --version 2` | 10 | 0 | **10/10** | +| `winget install --id Canonical.Ubuntu.2404 -e --silent --accept-source-agreements --accept-package-agreements --disable-interactivity` | 0 | 10 | **0/10** | + +Success ≡ exit 0 AND target distro registered in `wsl --list --quiet`. + +Root cause for winget 0/10: `Canonical.Ubuntu.2404` is the launcher APPX, not +a WSL distro creator; with `--silent --disable-interactivity` the launcher is +never invoked, so the APPX stages but no distro registers. winget cannot pass +`--name` or `--location` to the launcher. + +Harness, raw timings, exit codes, and per-iteration `detail.json`: +`artifacts/wsl-install-vs-winget/run-20260504-131837/summary.json`. (The +`artifacts/` tree is gitignored; the summary will be present on any host that +runs `scripts/experiments/wsl-install-vs-winget-empirical-2026-05-04.ps1`.) + +A deeper winget research thread is in flight (Aaron-9, prototype worktree). +That work may broaden the picture for `winget install Microsoft.WSL` as a +**platform** repair fallback — it does not change the Phase 3 decision to use +`wsl --install` for distro creation in this PR. + +### `/etc/wsl.conf` + +```ini +[boot] +systemd=true + +[automount] +enabled=false +mountFsTab=false + +[interop] +enabled=false +appendWindowsPath=false + +[user] +default=openclaw + +[time] +useWindowsTimezone=true +``` + +Rationale (Craig confirmed all settings appropriate for an app-owned +appliance): + +- `systemd=true` — gateway is a systemd-managed service. +- `automount.enabled=false` / `mountFsTab=false` — the gateway does not need + Windows drive mounts. +- `interop.enabled=false` / `appendWindowsPath=false` — the appliance does not + shell out to Windows binaries. +- `default=openclaw` — non-root default user; root only via explicit + `wsl.exe -d OpenClawGateway -u root -- ...`. +- `useWindowsTimezone=true` — gateway timestamps align with the user's + Windows session. + +Per Craig: no post-clone repairs needed (machine-id / DNS / timezone work as +delivered by Ubuntu-24.04). + +### `/etc/wsl-distribution.conf` + +```ini +[oobe] +defaultName=OpenClawGateway + +[shortcut] +enabled=false + +[terminal] +enabled=false +``` + +Rationale: the OpenClaw instance is an implementation detail; users should not +see a Start menu shortcut or Windows Terminal profile for it. Craig confirmed +this is the correct use of `wsl-distribution.conf` for a privately managed +instance. + +### Networking — loopback only + +The gateway binds to **loopback inside WSL on port 18789**. The Windows tray +connects via `http://localhost:18789` / `ws://localhost:18789`. + +Per Craig: Windows localhost forwarding to a WSL2 service is a reliable core +WSL promise. **No** WSL-IP fallback. **No** `lan` or `auto` bind. **No** +`gateway.bind` overrides written by the tray. **No** Windows portproxy or +firewall mutation. + +The endpoint resolver and validation runner do not enumerate WSL interface +addresses, do not run `hostname -I` / `ip -4 addr` / `ip route` / `ss -ltnp` +inside WSL, and do not promote between bind modes. There is one Windows-side +TCP listener snapshot of port 18789 plus a loopback `/health` probe. + +Off-box / LAN / phone reachability is out of scope for this PR and will be +handled separately when relay ownership and protocol are clear. + +### Lifecycle and service ownership + +- The gateway is started/managed via upstream OpenClaw CLI commands invoked + through `wsl.exe -d OpenClawGateway -u root -- ...`. +- `loginctl enable-linger openclaw` plus a tray-owned WSL keepalive + (`wsl.exe -d OpenClawGateway -u openclaw -- sleep 2147483647`) keep the + instance reachable while local-gateway mode is active. Both patterns are + acceptable per Craig. +- Repair primitive: `wsl.exe --terminate OpenClawGateway`. Global + `wsl --shutdown` is **never** issued. +- Removal: `wsl.exe --unregister OpenClawGateway` only (after explicit user + confirmation), preceded by `wsl.exe --terminate OpenClawGateway`. Cleanup + also removes the install-location directory. + +Product readiness for the gateway requires all of: + +1. service start/restart command returns; +2. WSL listener exists on `:18789`; +3. Windows-side `http://localhost:18789/health` probe succeeds; +4. gateway status / RPC succeeds with the device token; +5. setup-code mint succeeds. + +`systemctl active` alone is not treated as readiness. + +### Diagnostics + +On any setup failure, the engine and validation script surface the link + for the user/maintainer to collect WSL logs. The +product does **not** scrape WSL internal log files or invoke +`wsl --shutdown` to collect them. The validation script's +`Save-DiagnosticsSnapshot` records `wslLogsHelp = https://aka.ms/wsllogs` and +`Write-Summary` appends a "Diagnostics: see https://aka.ms/wsllogs..." note +to `summary.md` on failure. + +### Host filesystem and file I/O + +All WSL file operations from Windows go through `wsl.exe -d OpenClawGateway +-- ...` subprocess calls. `\\wsl$` and `\\wsl.localhost` are forbidden in +product code, validation scripts, tests, and ad-hoc PowerShell. The instance +does not depend on any Windows drive mount after setup. + +### Pairing and protocol boundary + +OpenClaw pairing is implemented entirely through the upstream OpenClaw +protocol. The tray never edits gateway pairing stores directly. + +1. Gateway starts with local token auth from + `/var/lib/openclaw/gateway.env`. +2. Tray invokes `wsl.exe -d OpenClawGateway -- openclaw qr --json` and + decodes the upstream setup-code payload (with short-lived bootstrap + token). +3. Tray (operator) connects over WebSocket using its Ed25519 device identity + and `auth.bootstrapToken`; gateway returns `hello-ok.auth.deviceToken`, + stored in `%APPDATA%\OpenClawTray\device-key-ed25519.json` (operator + token field). +4. Tray (node) opens a separate WebSocket session with role `node` and + pairs through the same setup-code/bootstrap-token flow; the resulting + device token is stored in the same identity file under the **node** + field. +5. Subsequent reconnects use `auth.deviceToken`. Node tokens are never + reused as `auth.token` and vice versa. + +Identity-path invariant: operator and node device tokens share +`%APPDATA%\OpenClawTray\device-key-ed25519.json` (`OPENCLAW_TRAY_APPDATA_DIR` +override honored), with role distinction inside the file. The +prototype-era split between `%APPDATA%` (operator) and `%LOCALAPPDATA%` +(node) was closed in Phase 4. + +The Windows tray node parallels the macOS app's in-app node model +(`MacNodeModeCoordinator` with role `node`, separate session, capabilities +declared). No WSL-internal worker is paired by the Windows tray in this PR. + +## Validation + +`scripts/validate-wsl-gateway.ps1` provides four scenarios. Each writes a +JSON+markdown summary under `artifacts/validate-wsl-gateway//`. + +| Scenario | What it does | When to use | Destructive | +|---|---|---|---| +| `PreflightOnly` | Repo-layout sanity, WSL host status (`wsl --status`, `wsl --list --verbose`), relay-prototype probe (NotAvailable when no probe URI). No build, no install, no WSL state mutation. | Cheap CI / local sanity check. Safe on dev box. | No | +| `UpstreamInstall` | Build + tests, then drives the tray onboarding so the product itself runs the canonical `wsl --install Ubuntu-24.04 --name OpenClawGateway --location --no-launch --version 2` path. Smoke + bootstrap-token + operator+node pairing proofs over loopback. Reuses an existing `OpenClawGateway` instance if present. | Lab / dedicated machine. End-to-end product path. | Reuses existing distro state | +| `FreshMachine` | `UpstreamInstall` after a fresh-machine reset: `wsl --unregister OpenClawGateway` + AppData wipe (single shot). | Lab. Fresh install proof. | Yes, scoped to `OpenClawGateway` | +| `Recreate` | Iterated `FreshMachine`. Supports `-Iterations`. Uses `wsl --unregister` only — **never** `wsl --shutdown`. | Lab / repeatability harness. | Yes, scoped to `OpenClawGateway` | + +Scenarios deliberately removed from the prototype: `BuildRootfs`, +`InstallOnly`, `Smoke`, `Full`, `Loop`. Parameters deliberately removed: +`-BuildDevRootfs`, `-BaseRootfsPath`, `-GatewayPackagePath`, +`-UseExistingManifest`, `-RootfsPath`, `-AllowUnsignedDevArtifact`, +`-SigningKeyId`, `-PublicKeyPath`, +`-AllowNonStandardDistroNameForDestructiveClean`, `-NetworkingMode`, +`-LoopMode`, `-RequireWorkerPairing`, `-CleanOpenClawState`, +`-GoSkillProofCommand`, `-RequireGoSkillProof`. + +The validation script: + +- Drives onboarding via the `SetupWarningPage` "Set up locally" button + (`OnboardingSetupLocal` automation ID); `LocalSetupProgressPage` autostarts + the engine on appearance. +- Polls `setup-state.json` for `Complete` (terminal status). Worker / rootfs + phases are gone; terminal status is `Complete` only. +- Snapshots loopback diagnostics on failure (Windows-side `:18789` listener + state; loopback `/health` probe). Does **not** run any networking probes + inside WSL. +- Redacts sensitive output: `Redact-SensitiveGatewayOutput` over + `openclaw qr --json` stdout, `Save-RedactedSettings` strips `Token`, + `GatewayToken`, `BootstrapToken`, `bootstrap_token`, `NodeToken`, + `nodeToken`; relay probe body strips `token=...`. + +Scope guarantees from the validation script: + +- Only `OpenClawGateway` is ever the target of `wsl --unregister`. +- Global `wsl --shutdown` is never issued. +- No `\\wsl$` or `\\wsl.localhost` paths are read or written. + +Companion script: +`scripts/reset-openclaw-wsl-validation-state.ps1` — exact-target gated +cleanup for `OpenClawGateway` plus the `%APPDATA%\OpenClawTray` and +`%LOCALAPPDATA%\OpenClawTray` directories. Refuses to act on any other distro +name. + +## Outstanding follow-ups + +Tracked but outside the scope of this PR: + +- Off-box / LAN / phone reachability via OpenClaw relay (blocked on relay + ownership / protocol clarity). +- Optional `winget install Microsoft.WSL` as a **platform** repair fallback + (deeper research in flight). Distro creation stays on `wsl --install` + regardless. +- Internationalization of the onboarding copy (`Onboarding_SetupWarning_*` + / `Onboarding_LocalSetupProgress_*` resw entries across the supported + locales). + +See `docs/wsl-owner-open-issues.md` for the structured Q&A explaining **why** +this design is what it is, with Craig's verbatim answers. From ce89251fc4a4b4860fecc3323b4555f1a6308abf Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 4 May 2026 18:32:56 -0700 Subject: [PATCH 16/56] feat(onboarding): localize SetupWarning + LocalSetupProgress strings (fr-fr/nl-nl/zh-cn/zh-tw) Extract 17 hard-coded English strings from SetupWarningPage and LocalSetupProgressPage into Resources.resw and add translations for all four non-en-us locales. Adds OPENCLAW_TEST_LOCALE env hook on OnboardingWindow for visual-test locale forcing. Keys added (per locale): - Onboarding_SetupWarning_{Title,Body,SetupLocally,Advanced} (4) - Onboarding_LocalSetup_{Title,SubtitleIdle,SubtitleSuccess,Retry,TerminalFailure,DiagnosticsHint} (6) - Onboarding_LocalSetup_Phase_{Preflight,CreateInstance,Configure,InstallCli,PrepareConfig,StartGateway,MintToken} (7) Validation: build PASS, Tray 434/434, Shared 1180/1180, LocalizationValidationTests green. Screenshot verified for fr-FR at visual-test-output/phase5-localization/fr-fr/page-02.png; no truncation, no English fallback, layout contract intact (MaxWidth 460, centered). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Onboarding/OnboardingWindow.cs | 9 +++ .../Pages/LocalSetupProgressPage.cs | 33 +++++------ .../Onboarding/Pages/SetupWarningPage.cs | 13 ++--- .../Strings/en-us/Resources.resw | 55 ++++++++++++++++++- .../Strings/fr-fr/Resources.resw | 55 ++++++++++++++++++- .../Strings/nl-nl/Resources.resw | 55 ++++++++++++++++++- .../Strings/zh-cn/Resources.resw | 55 ++++++++++++++++++- .../Strings/zh-tw/Resources.resw | 55 ++++++++++++++++++- 8 files changed, 301 insertions(+), 29 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs index b882ef4a..d744127c 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs @@ -54,6 +54,15 @@ public OnboardingWindow(SettingsManager settings) ?? Path.Combine(Path.GetTempPath(), "openclaw-visual-test")) : null; + // Optional override for visual tests: render the onboarding UI in a specific locale + // (e.g. "fr-FR", "zh-CN") regardless of system language. Must be set BEFORE the first + // LocalizationHelper.GetString call so the resource context picks it up. + var testLocale = Environment.GetEnvironmentVariable("OPENCLAW_TEST_LOCALE"); + if (!string.IsNullOrWhiteSpace(testLocale)) + { + LocalizationHelper.SetLanguageOverride(testLocale); + } + Title = LocalizationHelper.GetString("Onboarding_Title"); this.SetWindowSize(720, 900); this.CenterOnScreen(); diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs index 41997e1a..23c07f53 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs @@ -4,6 +4,7 @@ using Microsoft.UI.Xaml; using OpenClawTray.FunctionalUI; using OpenClawTray.FunctionalUI.Core; +using OpenClawTray.Helpers; using OpenClawTray.Onboarding.Services; using OpenClawTray.Services.LocalGatewaySetup; using static OpenClawTray.FunctionalUI.Factories; @@ -42,15 +43,15 @@ public sealed class LocalSetupProgressPage : Component private static Task? s_runTask; private static bool s_advanceFiredForCompletion; - private static readonly (string Label, LocalGatewaySetupPhase[] Phases)[] s_visibleStages = new[] + private static readonly (string LabelKey, LocalGatewaySetupPhase[] Phases)[] s_visibleStages = new[] { - ("Checking system", new[] { LocalGatewaySetupPhase.Preflight, LocalGatewaySetupPhase.EnsureWslEnabled }), - ("Installing Ubuntu", new[] { LocalGatewaySetupPhase.CreateWslInstance }), - ("Configuring instance", new[] { LocalGatewaySetupPhase.ConfigureWslInstance }), - ("Installing OpenClaw", new[] { LocalGatewaySetupPhase.InstallOpenClawCli }), - ("Preparing gateway", new[] { LocalGatewaySetupPhase.PrepareGatewayConfig, LocalGatewaySetupPhase.InstallGatewayService }), - ("Starting gateway", new[] { LocalGatewaySetupPhase.StartGateway, LocalGatewaySetupPhase.WaitForGateway }), - ("Generating setup code", new[] { LocalGatewaySetupPhase.MintBootstrapToken }), + ("Onboarding_LocalSetup_Phase_Preflight", new[] { LocalGatewaySetupPhase.Preflight, LocalGatewaySetupPhase.EnsureWslEnabled }), + ("Onboarding_LocalSetup_Phase_CreateInstance", new[] { LocalGatewaySetupPhase.CreateWslInstance }), + ("Onboarding_LocalSetup_Phase_Configure", new[] { LocalGatewaySetupPhase.ConfigureWslInstance }), + ("Onboarding_LocalSetup_Phase_InstallCli", new[] { LocalGatewaySetupPhase.InstallOpenClawCli }), + ("Onboarding_LocalSetup_Phase_PrepareConfig", new[] { LocalGatewaySetupPhase.PrepareGatewayConfig, LocalGatewaySetupPhase.InstallGatewayService }), + ("Onboarding_LocalSetup_Phase_StartGateway", new[] { LocalGatewaySetupPhase.StartGateway, LocalGatewaySetupPhase.WaitForGateway }), + ("Onboarding_LocalSetup_Phase_MintToken", new[] { LocalGatewaySetupPhase.MintBootstrapToken }), }; public override Element Render() @@ -124,9 +125,9 @@ void Handler(LocalGatewaySetupState st) var status = snapshot?.Status ?? LocalGatewaySetupStatus.Pending; var subtitle = !string.IsNullOrWhiteSpace(snapshot?.UserMessage) ? snapshot!.UserMessage! - : "Setting up your local OpenClaw gateway."; + : LocalizationHelper.GetString("Onboarding_LocalSetup_SubtitleIdle"); - var stageRows = s_visibleStages.Select(stage => RenderStage(stage.Label, stage.Phases, phase, status)).ToArray(); + var stageRows = s_visibleStages.Select(stage => RenderStage(LocalizationHelper.GetString(stage.LabelKey), stage.Phases, phase, status)).ToArray(); var isFailed = status == LocalGatewaySetupStatus.FailedRetryable || status == LocalGatewaySetupStatus.FailedTerminal; var canRetry = status == LocalGatewaySetupStatus.FailedRetryable; @@ -134,9 +135,9 @@ void Handler(LocalGatewaySetupState st) Element errorRow; if (isFailed) { - var msg = snapshot?.UserMessage ?? "Setup did not complete."; + var msg = snapshot?.UserMessage ?? LocalizationHelper.GetString("Onboarding_LocalSetup_TerminalFailure"); if (status == LocalGatewaySetupStatus.FailedTerminal) - msg += "\nDiagnostics: aka.ms/wsllogs"; + msg += "\n" + LocalizationHelper.GetString("Onboarding_LocalSetup_DiagnosticsHint"); var children = new System.Collections.Generic.List { @@ -150,7 +151,7 @@ void Handler(LocalGatewaySetupState st) if (canRetry) { children.Add( - Button("Try again", () => setRetryCount(retryCount + 1)) + Button(LocalizationHelper.GetString("Onboarding_LocalSetup_Retry"), () => setRetryCount(retryCount + 1)) .MinWidth(120) .HAlign(HorizontalAlignment.Right) .VAlign(VerticalAlignment.Center) @@ -175,7 +176,7 @@ void Handler(LocalGatewaySetupState st) columns: ["1*"], rows: ["Auto", "Auto", "1*", "Auto"], - TextBlock("Setting up locally") + TextBlock(LocalizationHelper.GetString("Onboarding_LocalSetup_Title")) .FontSize(22) .FontWeight(new global::Windows.UI.Text.FontWeight(700)) .HAlign(HorizontalAlignment.Center) @@ -287,11 +288,11 @@ private enum StageState { Pending, Active, Complete, Failed } case "active": if (Enum.TryParse(arg, ignoreCase: true, out var p)) { - state.StartPhase(p, "Setting up your local OpenClaw gateway."); + state.StartPhase(p, LocalizationHelper.GetString("Onboarding_LocalSetup_SubtitleIdle")); } break; case "complete": - state.CompletePhase(LocalGatewaySetupPhase.Complete, "Local gateway is ready."); + state.CompletePhase(LocalGatewaySetupPhase.Complete, LocalizationHelper.GetString("Onboarding_LocalSetup_SubtitleSuccess")); break; case "retryable": state.Block("visual_test_retryable", string.IsNullOrWhiteSpace(arg) ? "Setup hit a snag." : arg, retryable: true); diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs index 5453c6c1..0c283034 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs @@ -1,5 +1,6 @@ using OpenClawTray.FunctionalUI; using OpenClawTray.FunctionalUI.Core; +using OpenClawTray.Helpers; using OpenClawTray.Onboarding.Services; using static OpenClawTray.FunctionalUI.Factories; using Microsoft.UI.Xaml; @@ -30,13 +31,9 @@ public sealed class SetupWarningPage : Component { public override Element Render() { - const string TitleText = "Set up OpenClaw"; + string TitleText = LocalizationHelper.GetString("Onboarding_SetupWarning_Title"); // Body folds in the ⚠️ security notice (Mike's decision — WelcomePage removed). - const string BodyText = - "OpenClaw lets agents run commands, read and write files, and capture screenshots " + - "on this PC. Only set it up on a computer you trust.\n\n" + - "⚠️ The local setup installs a small WSL Linux instance dedicated to OpenClaw. " + - "If you'd rather connect to an existing or remote gateway, choose Advanced setup."; + string BodyText = LocalizationHelper.GetString("Onboarding_SetupWarning_Body"); void ChooseLocal() { @@ -71,7 +68,7 @@ void ChooseAdvanced() .Margin(0, 12, 0, 12) .Grid(row: 1, column: 0), - Button("Set up locally", ChooseLocal) + Button(LocalizationHelper.GetString("Onboarding_SetupWarning_SetupLocally"), ChooseLocal) .MinWidth(200) .Height(44) .HAlign(HorizontalAlignment.Center) @@ -82,7 +79,7 @@ void ChooseAdvanced() }) .Grid(row: 2, column: 0), - Button("Advanced setup", ChooseAdvanced) + Button(LocalizationHelper.GetString("Onboarding_SetupWarning_Advanced"), ChooseAdvanced) .HAlign(HorizontalAlignment.Center) .Margin(0, 8, 0, 0) .Set(b => diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 195dad95..13b5d577 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -1,4 +1,4 @@ - + @@ -1387,4 +1387,57 @@ On your gateway host (Mac/Linux), run: You can configure the gateway later in Settings.
+ + Set up OpenClaw + + + OpenClaw lets agents run commands, read and write files, and capture screenshots on this PC. Only set it up on a computer you trust. + +⚠️ The local setup installs a small WSL Linux instance dedicated to OpenClaw. If you'd rather connect to an existing or remote gateway, choose Advanced setup. + + + Set up locally + + + Advanced setup + + + Setting up locally + + + Setting up your local OpenClaw gateway. + + + Local gateway is ready. + + + Try again + + + Setup did not complete. + + + Diagnostics: aka.ms/wsllogs + + + Checking system + + + Installing Ubuntu + + + Configuring instance + + + Installing OpenClaw + + + Preparing gateway + + + Starting gateway + + + Generating setup code +
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 99d87267..64bf35f5 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -1,4 +1,4 @@ - + @@ -1386,4 +1386,57 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : Vous pouvez configurer la passerelle plus tard dans les paramètres. + + Configurer OpenClaw + + + OpenClaw permet aux agents d'exécuter des commandes, de lire et d'écrire des fichiers, et de capturer des captures d'écran sur ce PC. Ne le configurez que sur un ordinateur de confiance. + +⚠️ La configuration locale installe une petite instance WSL Linux dédiée à OpenClaw. Si vous préférez vous connecter à une passerelle existante ou distante, choisissez Configuration avancée. + + + Configurer localement + + + Configuration avancée + + + Configuration locale en cours + + + Configuration de votre passerelle OpenClaw locale. + + + La passerelle locale est prête. + + + Réessayer + + + La configuration n'a pas abouti. + + + Diagnostics : aka.ms/wsllogs + + + Vérification du système + + + Installation d'Ubuntu + + + Configuration de l'instance + + + Installation d'OpenClaw + + + Préparation de la passerelle + + + Démarrage de la passerelle + + + Génération du code de configuration + diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 8c34fe43..211f65b0 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -1,4 +1,4 @@ - + @@ -1387,4 +1387,57 @@ Voer op uw gateway-host (Mac/Linux) uit: U kunt de gateway later configureren in Instellingen. + + OpenClaw instellen + + + OpenClaw laat agents opdrachten uitvoeren, bestanden lezen en schrijven en schermafbeeldingen maken op deze pc. Stel het alleen in op een computer die u vertrouwt. + +⚠️ De lokale installatie plaatst een kleine WSL Linux-instantie speciaal voor OpenClaw. Als u liever verbinding maakt met een bestaande of externe gateway, kies dan Geavanceerde installatie. + + + Lokaal instellen + + + Geavanceerde installatie + + + Bezig met lokaal instellen + + + Uw lokale OpenClaw-gateway wordt ingesteld. + + + De lokale gateway is gereed. + + + Opnieuw proberen + + + Installatie is niet voltooid. + + + Diagnose: aka.ms/wsllogs + + + Systeem controleren + + + Ubuntu installeren + + + Instantie configureren + + + OpenClaw installeren + + + Gateway voorbereiden + + + Gateway starten + + + Installatiecode genereren + diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index e41259a2..a676c7a7 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -1,4 +1,4 @@ - + @@ -1387,4 +1387,57 @@ 您可以稍后在“设置”中配置网关。 + + 设置 OpenClaw + + + OpenClaw 允许代理在此电脑上运行命令、读写文件以及捕获屏幕截图。仅在您信任的计算机上进行设置。 + +⚠️ 本地设置将安装一个专用于 OpenClaw 的小型 WSL Linux 实例。如果您希望连接到现有或远程网关,请选择“高级设置”。 + + + 本地设置 + + + 高级设置 + + + 正在本地设置 + + + 正在设置您的本地 OpenClaw 网关。 + + + 本地网关已就绪。 + + + 重试 + + + 设置未完成。 + + + 诊断:aka.ms/wsllogs + + + 正在检查系统 + + + 正在安装 Ubuntu + + + 正在配置实例 + + + 正在安装 OpenClaw + + + 正在准备网关 + + + 正在启动网关 + + + 正在生成设置代码 + diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index d43e00dd..6731cfb5 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -1,4 +1,4 @@ - + @@ -1387,4 +1387,57 @@ 您可以稍後在「設定」中設定閘道。 + + 設定 OpenClaw + + + OpenClaw 允許代理在此電腦上執行命令、讀寫檔案以及擷取螢幕畫面。僅在您信任的電腦上進行設定。 + +⚠️ 本機設定將安裝一個專用於 OpenClaw 的小型 WSL Linux 執行個體。如果您希望連線到現有或遠端閘道,請選擇「進階設定」。 + + + 本機設定 + + + 進階設定 + + + 正在本機設定 + + + 正在設定您的本機 OpenClaw 閘道。 + + + 本機閘道已就緒。 + + + 重試 + + + 設定未完成。 + + + 診斷:aka.ms/wsllogs + + + 正在檢查系統 + + + 正在安裝 Ubuntu + + + 正在設定執行個體 + + + 正在安裝 OpenClaw + + + 正在準備閘道 + + + 正在啟動閘道 + + + 正在產生設定碼 + From 73767c5a71313c59c34ec007266bc218141968ee Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 4 May 2026 18:59:06 -0700 Subject: [PATCH 17/56] feat(onboarding): nav-bar Next/Back policy on LocalSetupProgressPage per state (Phase 5 final) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements industry-standard onboarding-progress button policy on LocalSetupProgressPage per the autopilot defaults captured in .squad/decisions.md (round 11): Idle (Pending) Next=Hidden, Back=Enabled Running Next=VisibleDisabled, Back=Enabled Complete Next=VisibleEnabled, Back=Enabled (1s pre-auto-advance; tap-to-skip) FailedRetryable Next=VisibleDisabled, Back=Enabled (in-page Try again) FailedTerminal Next=VisibleDisabled, Back=Enabled (force back-out) Contract extension (minimal): - OnboardingState gains NextButtonState property (Default/Hidden/VisibleDisabled/VisibleEnabled), SetNextButtonState() setter, and NavBarStateChanged event. - OnboardingApp consults NextButtonState only when currentRoute == LocalSetupProgress; legacy behavior preserved everywhere else. - Mapping logic extracted to OnboardingTray.Onboarding.Services.LocalSetupProgressPolicy (no WinUI deps) so it is unit-testable from OpenClaw.Tray.Tests. Bonus fix: gate the Complete-state 1s auto-advance timer on still being on LocalSetupProgress so an early Next-tap doesn't over-advance a later page. Tests: Tray 447/447 (+13: 3 OnboardingState NextButtonState/NavBarStateChanged + 10 LocalSetupProgressPolicy mapping cases). Shared 1180/1180. Build PASS. Screenshots: visual-test-output/next-button-impl-2026-05-04/{s1-running,s2-success,s3-failed-terminal,s4-failed-retryable}/page-02.png — all four states verified visually. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Onboarding/OnboardingApp.cs | 51 ++++++++++++- .../Pages/LocalSetupProgressPage.cs | 20 ++++- .../Services/LocalSetupProgressPolicy.cs | 40 ++++++++++ .../Onboarding/Services/OnboardingState.cs | 46 ++++++++++++ .../LocalSetupProgressPageNextButtonTests.cs | 75 +++++++++++++++++++ .../OnboardingStateTests.cs | 42 +++++++++++ .../OpenClaw.Tray.Tests.csproj | 1 + 7 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalSetupProgressPolicy.cs create mode 100644 tests/OpenClaw.Tray.Tests/LocalSetupProgressPageNextButtonTests.cs diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingApp.cs b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingApp.cs index a723e9eb..4bb83bd0 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingApp.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingApp.cs @@ -66,10 +66,56 @@ void GoBack() return () => Props.AdvanceRequested -= handler; }, pageIndex); + // Re-render when a page pushes a new nav-bar Next button state + // (LocalSetupProgressPage uses this to map engine status → button). + var (navBarTick, setNavBarTick) = UseState(0); + UseEffect(() => + { + EventHandler handler = (_, _) => setNavBarTick(navBarTick + 1); + Props.NavBarStateChanged += handler; + return () => Props.NavBarStateChanged -= handler; + }, navBarTick); + var isLastPage = pageIndex >= pages.Length - 1; var currentRoute = pages[pageIndex]; - // Requirement 8: nav-bar Next disabled on SetupWarning until path chosen. - var nextDisabled = currentRoute == OnboardingRoute.SetupWarning && Props.SetupPath == null; + // Compute Next button visibility/disabled per page contract. + // - SetupWarning: visible, disabled until SetupPath chosen (legacy). + // - LocalSetupProgress: defer to Props.NextButtonState (set by the page in + // response to engine state changes; see Phase 5 Next/Back-button policy). + // - All other routes: visible, enabled (legacy default). + bool nextHidden = false; + bool nextDisabled; + if (currentRoute == OnboardingRoute.SetupWarning) + { + nextDisabled = Props.SetupPath == null; + } + else if (currentRoute == OnboardingRoute.LocalSetupProgress) + { + switch (Props.NextButtonState) + { + case OnboardingNextButtonState.Hidden: + nextHidden = true; + nextDisabled = true; + break; + case OnboardingNextButtonState.VisibleDisabled: + nextDisabled = true; + break; + case OnboardingNextButtonState.VisibleEnabled: + nextDisabled = false; + break; + case OnboardingNextButtonState.Default: + default: + // Conservative default before the page has pushed a state: + // visible+disabled (treat as Running/Idle equivalent — never + // let the user advance past a not-yet-complete local setup). + nextDisabled = true; + break; + } + } + else + { + nextDisabled = false; + } // VStack for functional UI content (icon + pages only). // The nav bar is rendered natively in OnboardingWindow for reliable bottom pinning. @@ -113,6 +159,7 @@ void GoBack() .Set(b => { Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingNext"); + b.Visibility = nextHidden ? Visibility.Collapsed : Visibility.Visible; b.Resources["ButtonBackground"] = new Microsoft.UI.Xaml.Media.SolidColorBrush( Microsoft.UI.ColorHelper.FromArgb(255, 211, 47, 47)); // #D32F2F b.Resources["ButtonBackgroundPointerOver"] = new Microsoft.UI.Xaml.Media.SolidColorBrush( diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs index 23c07f53..8a9d36b3 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs @@ -98,9 +98,16 @@ void Handler(LocalGatewaySetupState st) if (st.Status == LocalGatewaySetupStatus.Complete && !s_advanceFiredForCompletion) { s_advanceFiredForCompletion = true; - // 1-second pause on success per Mike's decision. + // 1-second pause on success per Mike's decision. Tap-to-skip: + // user can tap the (now visible+enabled) Next button to advance + // immediately; gate this timer on still being on LocalSetupProgress + // so an early tap doesn't over-advance a later page. Task.Delay(TimeSpan.FromSeconds(1)).ContinueWith(_ => - dispatcher.TryEnqueue(() => advanceRef.RequestAdvance()), + dispatcher.TryEnqueue(() => + { + if (advanceRef.CurrentRoute == OnboardingRoute.LocalSetupProgress) + advanceRef.RequestAdvance(); + }), TaskScheduler.Default); } }); @@ -127,6 +134,15 @@ void Handler(LocalGatewaySetupState st) ? snapshot!.UserMessage! : LocalizationHelper.GetString("Onboarding_LocalSetup_SubtitleIdle"); + // Push the nav-bar Next button state for this snapshot. Mapping (Phase 5 final policy): + // Idle/Pending (engine not started) → Hidden + // Running / RequiresAdmin / RequiresRestart / Blocked → VisibleDisabled + // Complete → VisibleEnabled (1s before auto-advance; tap to skip) + // FailedRetryable / FailedTerminal → VisibleDisabled (in-page Try Again or Back-out) + // Cancelled → VisibleDisabled + // Back is always enabled by the OnboardingApp default (pageIndex > 0). + Props.SetNextButtonState(LocalSetupProgressPolicy.MapStatusToNextButtonState(snapshot, status)); + var stageRows = s_visibleStages.Select(stage => RenderStage(LocalizationHelper.GetString(stage.LabelKey), stage.Phases, phase, status)).ToArray(); var isFailed = status == LocalGatewaySetupStatus.FailedRetryable || status == LocalGatewaySetupStatus.FailedTerminal; diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalSetupProgressPolicy.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalSetupProgressPolicy.cs new file mode 100644 index 00000000..08ef5179 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalSetupProgressPolicy.cs @@ -0,0 +1,40 @@ +using OpenClawTray.Services.LocalGatewaySetup; + +namespace OpenClawTray.Onboarding.Services; + +/// +/// Pure mapping helpers for LocalSetupProgressPage nav-bar policy +/// (Phase 5 final). Lives in the Services namespace (no WinUI / FunctionalUI +/// dependencies) so unit tests in OpenClaw.Tray.Tests can import it +/// directly via the project's selective <Compile Include> list. +/// +public static class LocalSetupProgressPolicy +{ + /// + /// Maps a snapshot to the nav-bar + /// Next button state per the Phase 5 final Next/Back-button policy. + /// + /// Mapping: + /// null / Pending → Hidden (engine not started; Idle) + /// Running → VisibleDisabled (engine progressing) + /// Complete → VisibleEnabled (1s pre-auto-advance; tap to skip) + /// FailedRetryable → VisibleDisabled (in-page Try Again is the action) + /// FailedTerminal → VisibleDisabled (force Back-out; no advancing past broken gateway) + /// RequiresAdmin / RequiresRestart / Blocked / Cancelled → VisibleDisabled + /// + /// Back is always enabled by the OnboardingApp default (pageIndex > 0 + /// on LocalSetupProgress because SetupWarning is page 0). + /// + public static OnboardingNextButtonState MapStatusToNextButtonState(LocalGatewaySetupState? snapshot, LocalGatewaySetupStatus status) + { + if (snapshot == null) + return OnboardingNextButtonState.Hidden; + + return status switch + { + LocalGatewaySetupStatus.Pending => OnboardingNextButtonState.Hidden, + LocalGatewaySetupStatus.Complete => OnboardingNextButtonState.VisibleEnabled, + _ => OnboardingNextButtonState.VisibleDisabled, + }; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs index 4ada214e..abe71081 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs @@ -47,6 +47,33 @@ public sealed class OnboardingState : IDisposable public void RequestAdvance() => AdvanceRequested?.Invoke(this, EventArgs.Empty); + /// + /// Per-page nav-bar Next button state override. Pages that want fine-grained + /// control over the nav-bar Next button (Hidden / Visible+Disabled / + /// Visible+Enabled) push a value here and raise ; + /// consults this for routes that opt in (currently + /// only ) and falls back to its + /// legacy logic everywhere else. + /// + public OnboardingNextButtonState NextButtonState { get; private set; } = OnboardingNextButtonState.Default; + + /// + /// Raised when changes so + /// can re-render the nav bar. + /// + public event EventHandler? NavBarStateChanged; + + /// + /// Sets and raises + /// if the value actually changed. + /// + public void SetNextButtonState(OnboardingNextButtonState state) + { + if (NextButtonState == state) return; + NextButtonState = state; + NavBarStateChanged?.Invoke(this, EventArgs.Empty); + } + /// /// Whether the onboarding chat page should be shown. /// @@ -175,3 +202,22 @@ public enum SetupPath /// User chose "Advanced setup" — fall through to the legacy ConnectionPage. Advanced, } + +/// +/// Per-page nav-bar Next button state override (Phase 5 final). Pages set this on +/// to opt out of the default +/// "always visible+enabled (Disabled only on SetupWarning until path chosen)" +/// behavior. consults this for routes that opt in +/// (currently only ). +/// +public enum OnboardingNextButtonState +{ + /// Use legacy nav-bar logic — visible+enabled unless route-specific defaults apply. + Default, + /// Next button collapsed entirely (e.g., LocalSetupProgress Idle state). + Hidden, + /// Next button visible but disabled (e.g., LocalSetupProgress Running / FailedRetryable / FailedTerminal). + VisibleDisabled, + /// Next button visible and enabled (e.g., LocalSetupProgress Complete during the 1s pre-auto-advance window). + VisibleEnabled, +} diff --git a/tests/OpenClaw.Tray.Tests/LocalSetupProgressPageNextButtonTests.cs b/tests/OpenClaw.Tray.Tests/LocalSetupProgressPageNextButtonTests.cs new file mode 100644 index 00000000..1bd46e33 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/LocalSetupProgressPageNextButtonTests.cs @@ -0,0 +1,75 @@ +using OpenClawTray.Onboarding.Services; +using OpenClawTray.Services.LocalGatewaySetup; + +namespace OpenClaw.Tray.Tests; + +/// +/// Locks down the per-engine-status mapping that +/// pushes to . See Phase 5 final +/// Next/Back-button policy in .squad/decisions.md. +/// +public class LocalSetupProgressPageNextButtonTests +{ + private static LocalGatewaySetupState CreateSnapshot() => + LocalGatewaySetupState.Create(new LocalGatewaySetupOptions()); + + [Fact] + public void NullSnapshot_MapsToHidden() + { + Assert.Equal( + OnboardingNextButtonState.Hidden, + LocalSetupProgressPolicy.MapStatusToNextButtonState(null, LocalGatewaySetupStatus.Pending)); + } + + [Fact] + public void Pending_MapsToHidden() + { + Assert.Equal( + OnboardingNextButtonState.Hidden, + LocalSetupProgressPolicy.MapStatusToNextButtonState(CreateSnapshot(), LocalGatewaySetupStatus.Pending)); + } + + [Fact] + public void Running_MapsToVisibleDisabled() + { + Assert.Equal( + OnboardingNextButtonState.VisibleDisabled, + LocalSetupProgressPolicy.MapStatusToNextButtonState(CreateSnapshot(), LocalGatewaySetupStatus.Running)); + } + + [Fact] + public void Complete_MapsToVisibleEnabled() + { + Assert.Equal( + OnboardingNextButtonState.VisibleEnabled, + LocalSetupProgressPolicy.MapStatusToNextButtonState(CreateSnapshot(), LocalGatewaySetupStatus.Complete)); + } + + [Fact] + public void FailedRetryable_MapsToVisibleDisabled() + { + Assert.Equal( + OnboardingNextButtonState.VisibleDisabled, + LocalSetupProgressPolicy.MapStatusToNextButtonState(CreateSnapshot(), LocalGatewaySetupStatus.FailedRetryable)); + } + + [Fact] + public void FailedTerminal_MapsToVisibleDisabled() + { + Assert.Equal( + OnboardingNextButtonState.VisibleDisabled, + LocalSetupProgressPolicy.MapStatusToNextButtonState(CreateSnapshot(), LocalGatewaySetupStatus.FailedTerminal)); + } + + [Theory] + [InlineData(LocalGatewaySetupStatus.RequiresAdmin)] + [InlineData(LocalGatewaySetupStatus.RequiresRestart)] + [InlineData(LocalGatewaySetupStatus.Blocked)] + [InlineData(LocalGatewaySetupStatus.Cancelled)] + public void OtherNonTerminalStatuses_MapToVisibleDisabled(LocalGatewaySetupStatus status) + { + Assert.Equal( + OnboardingNextButtonState.VisibleDisabled, + LocalSetupProgressPolicy.MapStatusToNextButtonState(CreateSnapshot(), status)); + } +} diff --git a/tests/OpenClaw.Tray.Tests/OnboardingStateTests.cs b/tests/OpenClaw.Tray.Tests/OnboardingStateTests.cs index 88daa7fd..e3a91988 100644 --- a/tests/OpenClaw.Tray.Tests/OnboardingStateTests.cs +++ b/tests/OpenClaw.Tray.Tests/OnboardingStateTests.cs @@ -380,4 +380,46 @@ public void Dispose_NullsOutGatewayClient() } #endregion + + #region NextButtonState (Phase 5 final policy) + + [Fact] + public void NextButtonState_DefaultsToDefault() + { + var state = CreateState(); + Assert.Equal(OnboardingNextButtonState.Default, state.NextButtonState); + } + + [Fact] + public void SetNextButtonState_RaisesNavBarStateChanged_WhenValueChanges() + { + var state = CreateState(); + var fired = 0; + state.NavBarStateChanged += (_, _) => fired++; + + state.SetNextButtonState(OnboardingNextButtonState.Hidden); + state.SetNextButtonState(OnboardingNextButtonState.VisibleDisabled); + state.SetNextButtonState(OnboardingNextButtonState.VisibleEnabled); + + Assert.Equal(3, fired); + Assert.Equal(OnboardingNextButtonState.VisibleEnabled, state.NextButtonState); + } + + [Fact] + public void SetNextButtonState_DoesNotRaise_WhenValueIsUnchanged() + { + var state = CreateState(); + state.SetNextButtonState(OnboardingNextButtonState.VisibleDisabled); + + var fired = 0; + state.NavBarStateChanged += (_, _) => fired++; + + state.SetNextButtonState(OnboardingNextButtonState.VisibleDisabled); + state.SetNextButtonState(OnboardingNextButtonState.VisibleDisabled); + + Assert.Equal(0, fired); + Assert.Equal(OnboardingNextButtonState.VisibleDisabled, state.NextButtonState); + } + + #endregion } diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 36eb23da..2f4dbc20 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -32,6 +32,7 @@ + From fe2de099e8c4a366c9c3a905ce90f7b41eb940d6 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 4 May 2026 19:49:28 -0700 Subject: [PATCH 18/56] fix(shared): bootstrap-token wire-format consistency between gateway mint and tray pair (Bug 1 from e2e drive) Bug 1 surfaced by the 2026-05-04 e2e drive: MintBootstrapToken correctly invokes `openclaw qr --json` and the tray sends the resulting token via `auth.bootstrapToken`, but the upstream gateway treats a fresh bootstrap-token connect as a *pending* operator pairing request and rejects the connect itself with `device-auth-invalid` then `pairing-required reason:not-paired`. The pending request is recorded server-side but never redeemed because nothing approves it. On a local-loopback gateway the user driving the tray is also the operator/approver, so SettingsOperatorPairingService now drives `openclaw devices approve --latest` through the gateway CLI and retries the bootstrap connect once. New IPendingDeviceApprover seam keeps it injectable (default null preserves remote-gateway behavior); WslGatewayCliPendingDeviceApprover authenticates with the locally-stored `/var/lib/openclaw/gateway-token` (read inside the shell so it never touches argv) and scopes the approval to `LocalGatewayApprover.IsLocalGateway` URLs only. Tests (10 new, all green): round-trip approve+retry, double-PairingRequired no-loop, approval-failure surfaces error code, remote-gateway opt-out, non-bootstrap-token opt-out, first-connect happy path, plus 4 ParseApproveJson cases. OPENCLAW_RUN_INTEGRATION=1, OPENCLAW_REPO_ROOT=: - OpenClaw.Shared.Tests: 1180/1180/0/0 - OpenClaw.Tray.Tests: 493/493/0/0 (+10 new) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../LocalGatewaySetup/LocalGatewaySetup.cs | 122 +++++++++- .../OperatorPairingApprovalTests.cs | 214 ++++++++++++++++++ 2 files changed, 332 insertions(+), 4 deletions(-) create mode 100644 tests/OpenClaw.Tray.Tests/OperatorPairingApprovalTests.cs diff --git a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs index 087482c9..c95a0934 100644 --- a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs +++ b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs @@ -1441,16 +1441,18 @@ public sealed class SettingsOperatorPairingService : IOperatorPairingService { private readonly ILocalGatewaySetupSettings _settings; private readonly IGatewayOperatorConnector? _connector; + private readonly IPendingDeviceApprover? _pendingApprover; - public SettingsOperatorPairingService(SettingsManager settings, IGatewayOperatorConnector? connector = null) - : this(new SettingsManagerLocalGatewaySetupSettings(settings), connector) + public SettingsOperatorPairingService(SettingsManager settings, IGatewayOperatorConnector? connector = null, IPendingDeviceApprover? pendingApprover = null) + : this(new SettingsManagerLocalGatewaySetupSettings(settings), connector, pendingApprover) { } - public SettingsOperatorPairingService(ILocalGatewaySetupSettings settings, IGatewayOperatorConnector? connector = null) + public SettingsOperatorPairingService(ILocalGatewaySetupSettings settings, IGatewayOperatorConnector? connector = null, IPendingDeviceApprover? pendingApprover = null) { _settings = settings; _connector = connector; + _pendingApprover = pendingApprover; } public async Task PairAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) @@ -1472,6 +1474,28 @@ public async Task PairAsync(LocalGatewaySetupState state, Ca return new ProvisioningResult(true); var result = await _connector.ConnectAsync(state.GatewayUrl, credential.Value, credential.IsBootstrapToken, cancellationToken); + + // Bug 1 fix: a fresh bootstrap-token connect creates a pending operator pairing request + // server-side but the connect itself is rejected with PairingRequired (operator-approval gate). + // On a local-loopback gateway the user driving the tray is also the approver, so we drive + // `openclaw devices approve --latest` via the gateway CLI and retry the bootstrap connect once. + if (result.Status == GatewayOperatorConnectionStatus.PairingRequired + && credential.IsBootstrapToken + && _pendingApprover != null + && LocalGatewayApprover.IsLocalGateway(state.GatewayUrl)) + { + var approval = await _pendingApprover.ApproveLatestAsync(state, cancellationToken); + if (!approval.Success) + { + return new ProvisioningResult( + false, + approval.ErrorCode ?? "operator_pending_approval_failed", + approval.ErrorMessage ?? "Local gateway pending pairing approval failed."); + } + + result = await _connector.ConnectAsync(state.GatewayUrl, credential.Value, credential.IsBootstrapToken, cancellationToken); + } + if (result.Status != GatewayOperatorConnectionStatus.Connected) { return result.Status switch @@ -1618,6 +1642,95 @@ private static bool TryGetString(JsonElement root, string propertyName, out stri private static string ShellQuote(string value) => "'" + value.Replace("'", "'\"'\"'", StringComparison.Ordinal) + "'"; } +public sealed record PendingDeviceApprovalResult(bool Success, string? ErrorCode = null, string? ErrorMessage = null); + +/// +/// Approves the most-recent pending device pairing request on a local-loopback gateway by +/// invoking openclaw devices approve --latest via the gateway CLI inside WSL. +/// Used during operator bootstrap pairing where the same user is both operator and approver. +/// +public interface IPendingDeviceApprover +{ + Task ApproveLatestAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default); +} + +public sealed class WslGatewayCliPendingDeviceApprover : IPendingDeviceApprover +{ + private readonly IWslCommandRunner _wsl; + private readonly string _commandName; + + public WslGatewayCliPendingDeviceApprover(IWslCommandRunner wsl, string commandName = "openclaw") + { + _wsl = wsl; + _commandName = commandName; + } + + public async Task ApproveLatestAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) + { + // Authenticate to the loopback gateway using the locally-stored gateway-token (mode 0600, + // owned by the openclaw user) and approve the latest pending request. The gateway token + // is read inside the shell so it never appears on the wsl.exe argv or in process listings. + var script = string.Join(" ", new[] + { + "set -euo pipefail;", + "if [ ! -s /var/lib/openclaw/gateway-token ]; then", + " echo 'gateway token file missing or empty' >&2; exit 64;", + "fi;", + "if [ -f /var/lib/openclaw/gateway.env ]; then set -a; . /var/lib/openclaw/gateway.env; set +a; fi;", + "exec", + ShellQuoteScalar(_commandName), + "devices", + "approve", + "--latest", + "--json", + "--url", + ShellQuoteScalar(state.GatewayUrl), + "--token", + "\"$(cat /var/lib/openclaw/gateway-token)\"" + }); + var result = await _wsl.RunInDistroAsync(state.DistroName, ["bash", "-lc", script], cancellationToken); + if (!result.Success) + { + return new PendingDeviceApprovalResult( + false, + "operator_pending_approval_failed", + "Local gateway pending pairing approval CLI failed."); + } + + return ParseApproveJson(result.StandardOutput); + } + + public static PendingDeviceApprovalResult ParseApproveJson(string output) + { + if (string.IsNullOrWhiteSpace(output)) + return new PendingDeviceApprovalResult(true); + + try + { + using var doc = JsonDocument.Parse(output); + var root = doc.RootElement; + if (root.ValueKind == JsonValueKind.Object + && root.TryGetProperty("ok", out var ok) + && ok.ValueKind == JsonValueKind.False) + { + var msg = root.TryGetProperty("error", out var err) && err.ValueKind == JsonValueKind.String + ? err.GetString() + : "Local gateway approval reported failure."; + return new PendingDeviceApprovalResult(false, "operator_pending_approval_failed", msg); + } + + return new PendingDeviceApprovalResult(true); + } + catch (JsonException) + { + // Plain-text success output from older CLI versions; treat exit-0 as success. + return new PendingDeviceApprovalResult(true); + } + } + + private static string ShellQuoteScalar(string value) => "'" + value.Replace("'", "'\"'\"'", StringComparison.Ordinal) + "'"; +} + public sealed class SettingsWindowsTrayNodeProvisioner : IWindowsTrayNodeProvisioner { private readonly ILocalGatewaySetupSettings _settings; @@ -2140,6 +2253,7 @@ public static LocalGatewaySetupEngine CreateLocalOnly( var settingsAdapter = new SettingsManagerLocalGatewaySetupSettings(settings); var operatorConnector = new OpenClawGatewayOperatorConnector(logger); var bootstrapTokenProvider = new WslGatewayCliBootstrapTokenProvider(wsl, options.OpenClawInstallPrefix + "/bin/openclaw"); + var pendingDeviceApprover = new WslGatewayCliPendingDeviceApprover(wsl, options.OpenClawInstallPrefix + "/bin/openclaw"); #if OPENCLAW_TRAY_TESTS IWindowsNodeConnector? windowsNodeConnector = null; #else @@ -2153,7 +2267,7 @@ public static LocalGatewaySetupEngine CreateLocalOnly( wsl, new LocalGatewayHealthProbe(), new SettingsBootstrapTokenProvisioner(settingsAdapter, bootstrapTokenProvider), - new SettingsOperatorPairingService(settingsAdapter, operatorConnector), + new SettingsOperatorPairingService(settingsAdapter, operatorConnector, pendingDeviceApprover), new SettingsWindowsTrayNodeProvisioner(settingsAdapter, windowsNodeConnector), logger); } diff --git a/tests/OpenClaw.Tray.Tests/OperatorPairingApprovalTests.cs b/tests/OpenClaw.Tray.Tests/OperatorPairingApprovalTests.cs new file mode 100644 index 00000000..a2c2d38a --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/OperatorPairingApprovalTests.cs @@ -0,0 +1,214 @@ +using OpenClawTray.Services.LocalGatewaySetup; + +namespace OpenClaw.Tray.Tests; + +/// +/// Bug 1 (e2e drive 2026-05-04): the bootstrap-token connect handshake correctly delivers +/// the upstream token, but the gateway records it as a pending operator pairing request and +/// rejects the same connect with PairingRequired. On a local-loopback gateway, the user +/// driving the tray is also the operator, so the setup engine must auto-approve the pending +/// request via the gateway CLI before retrying. These tests pin the auto-approve + retry +/// behavior of . +/// +public class OperatorPairingApprovalTests +{ + [Fact] + public async Task PairAsync_LocalLoopback_BootstrapToken_PairingRequired_ApprovesAndRetries() + { + var settings = new FakePairingSettings { BootstrapToken = "redacted-bootstrap-token" }; + var connector = new ScriptedConnector( + new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.PairingRequired, "pairing required"), + new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.Connected), + // ConnectWithStoredDeviceTokenAsync after redeem + new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.Connected)); + var approver = new RecordingApprover(new PendingDeviceApprovalResult(true)); + var service = new SettingsOperatorPairingService(settings, connector, approver); + var state = new LocalGatewaySetupState { GatewayUrl = "ws://127.0.0.1:18789", DistroName = "OpenClawGateway" }; + + var result = await service.PairAsync(state); + + Assert.True(result.Success); + Assert.Equal(2, connector.ConnectCalls); + Assert.Equal(1, connector.ConnectWithStoredDeviceTokenCalls); + Assert.Equal(1, approver.ApproveCalls); + Assert.Equal("ws://127.0.0.1:18789", approver.LastGatewayUrl); + Assert.Equal("OpenClawGateway", approver.LastDistroName); + } + + [Fact] + public async Task PairAsync_LocalLoopback_BootstrapToken_PairingRequiredTwice_FailsWithoutLooping() + { + var settings = new FakePairingSettings { BootstrapToken = "redacted-bootstrap-token" }; + var connector = new ScriptedConnector( + new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.PairingRequired, "pairing required"), + new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.PairingRequired, "still pending")); + var approver = new RecordingApprover(new PendingDeviceApprovalResult(true)); + var service = new SettingsOperatorPairingService(settings, connector, approver); + + var result = await service.PairAsync(new LocalGatewaySetupState { GatewayUrl = "ws://127.0.0.1:18789", DistroName = "OpenClawGateway" }); + + Assert.False(result.Success); + Assert.Equal("operator_pairing_required", result.ErrorCode); + Assert.Equal(2, connector.ConnectCalls); + Assert.Equal(1, approver.ApproveCalls); + } + + [Fact] + public async Task PairAsync_LocalLoopback_BootstrapToken_ApprovalFails_ReturnsApprovalError() + { + var settings = new FakePairingSettings { BootstrapToken = "redacted-bootstrap-token" }; + var connector = new ScriptedConnector( + new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.PairingRequired, "pairing required")); + var approver = new RecordingApprover(new PendingDeviceApprovalResult(false, "operator_pending_approval_failed", "no pending requests")); + var service = new SettingsOperatorPairingService(settings, connector, approver); + + var result = await service.PairAsync(new LocalGatewaySetupState { GatewayUrl = "ws://127.0.0.1:18789", DistroName = "OpenClawGateway" }); + + Assert.False(result.Success); + Assert.Equal("operator_pending_approval_failed", result.ErrorCode); + Assert.Equal(1, connector.ConnectCalls); + Assert.Equal(1, approver.ApproveCalls); + } + + [Fact] + public async Task PairAsync_RemoteGateway_PairingRequired_DoesNotApprove() + { + var settings = new FakePairingSettings { BootstrapToken = "redacted-bootstrap-token" }; + var connector = new ScriptedConnector( + new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.PairingRequired, "pairing required")); + var approver = new RecordingApprover(new PendingDeviceApprovalResult(true)); + var service = new SettingsOperatorPairingService(settings, connector, approver); + + var result = await service.PairAsync(new LocalGatewaySetupState { GatewayUrl = "ws://gateway.example.com:18789", DistroName = "OpenClawGateway" }); + + Assert.False(result.Success); + Assert.Equal("operator_pairing_required", result.ErrorCode); + Assert.Equal(0, approver.ApproveCalls); + } + + [Fact] + public async Task PairAsync_NonBootstrapToken_PairingRequired_DoesNotApprove() + { + // A previously-paired device whose deviceToken got revoked should NOT trigger an + // auto-approval — that path indicates a deeper problem and re-approving here would + // mask it. + var settings = new FakePairingSettings { Token = "redacted-explicit-gateway-token" }; + var connector = new ScriptedConnector( + new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.PairingRequired, "pairing required")); + var approver = new RecordingApprover(new PendingDeviceApprovalResult(true)); + var service = new SettingsOperatorPairingService(settings, connector, approver); + + var result = await service.PairAsync(new LocalGatewaySetupState { GatewayUrl = "ws://127.0.0.1:18789", DistroName = "OpenClawGateway" }); + + Assert.False(result.Success); + Assert.Equal("operator_pairing_required", result.ErrorCode); + Assert.Equal(0, approver.ApproveCalls); + } + + [Fact] + public async Task PairAsync_FirstConnectSucceeds_NoApprovalCall() + { + var settings = new FakePairingSettings { BootstrapToken = "redacted-bootstrap-token" }; + var connector = new ScriptedConnector( + new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.Connected), + new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.Connected)); + var approver = new RecordingApprover(new PendingDeviceApprovalResult(true)); + var service = new SettingsOperatorPairingService(settings, connector, approver); + + var result = await service.PairAsync(new LocalGatewaySetupState { GatewayUrl = "ws://127.0.0.1:18789", DistroName = "OpenClawGateway" }); + + Assert.True(result.Success); + Assert.Equal(0, approver.ApproveCalls); + } + + [Fact] + public void ParseApproveJson_OkResponse_ReturnsSuccess() + { + var result = WslGatewayCliPendingDeviceApprover.ParseApproveJson("{\"ok\":true,\"requestId\":\"abc\"}"); + + Assert.True(result.Success); + } + + [Fact] + public void ParseApproveJson_OkFalse_ReturnsFailure() + { + var result = WslGatewayCliPendingDeviceApprover.ParseApproveJson("{\"ok\":false,\"error\":\"no pending requests\"}"); + + Assert.False(result.Success); + Assert.Equal("operator_pending_approval_failed", result.ErrorCode); + Assert.Equal("no pending requests", result.ErrorMessage); + } + + [Fact] + public void ParseApproveJson_EmptyOutput_ReturnsSuccess() + { + var result = WslGatewayCliPendingDeviceApprover.ParseApproveJson(string.Empty); + + Assert.True(result.Success); + } + + [Fact] + public void ParseApproveJson_NonJsonOutput_ReturnsSuccess() + { + // Older CLI versions print plain text on success. Treat as success when exit was 0. + var result = WslGatewayCliPendingDeviceApprover.ParseApproveJson("approved request abc"); + + Assert.True(result.Success); + } + + private sealed class FakePairingSettings : ILocalGatewaySetupSettings + { + public string GatewayUrl { get; set; } = ""; + public string Token { get; set; } = ""; + public string BootstrapToken { get; set; } = ""; + public bool UseSshTunnel { get; set; } = true; + public bool EnableNodeMode { get; set; } + public void Save() { } + } + + private sealed class ScriptedConnector : IGatewayOperatorConnector + { + private readonly Queue _connectResults; + public int ConnectCalls { get; private set; } + public int ConnectWithStoredDeviceTokenCalls { get; private set; } + + public ScriptedConnector(params GatewayOperatorConnectionResult[] connectResults) + { + _connectResults = new Queue(connectResults); + } + + public Task ConnectAsync(string gatewayUrl, string token, bool tokenIsBootstrapToken = false, CancellationToken cancellationToken = default) + { + ConnectCalls++; + return Task.FromResult(_connectResults.Count > 0 + ? _connectResults.Dequeue() + : new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.Failed, "no scripted result")); + } + + public Task ConnectWithStoredDeviceTokenAsync(string gatewayUrl, CancellationToken cancellationToken = default) + { + ConnectWithStoredDeviceTokenCalls++; + return Task.FromResult(_connectResults.Count > 0 + ? _connectResults.Dequeue() + : new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.Connected)); + } + } + + private sealed class RecordingApprover : IPendingDeviceApprover + { + private readonly PendingDeviceApprovalResult _result; + public int ApproveCalls { get; private set; } + public string? LastGatewayUrl { get; private set; } + public string? LastDistroName { get; private set; } + + public RecordingApprover(PendingDeviceApprovalResult result) => _result = result; + + public Task ApproveLatestAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) + { + ApproveCalls++; + LastGatewayUrl = state.GatewayUrl; + LastDistroName = state.DistroName; + return Task.FromResult(_result); + } + } +} From 4af2581239f7544df3fc5da92788b3a458ca9042 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 4 May 2026 20:47:40 -0700 Subject: [PATCH 19/56] fix(onboarding): LocalSetupProgressPage stage advancement + FailedRetryable rendering (Bug 2 from e2e drive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 2 from Aaron's 2026-05-04 e2e drive: the LocalSetupProgressPage UI stayed on stage 1 ("Checking system" with spinner) for the entire 12-minute run even though the LocalGatewaySetupEngine progressed through 9+ phases on the gateway side and ultimately failed at PairOperator. The page never re-rendered past the first event and never transitioned to FailedRetryable. Root cause: reference-equality in UseState. The engine raises StateChanged with the same mutating LocalGatewaySetupState instance every call. The page's UseState compared previous and next with EqualityComparer.Default — which for a class without an Equals override falls through to ReferenceEquals. The first null -> state transition rendered once; every subsequent state -> state event was identified as "no change" and the framework swallowed the re-render request. Fix: - Introduce a private record RenderSnapshot(Phase, Status, LastRunningPhase, UserMessage, FailureCode) and store *that* in UseState. Records have value equality, so each engine event yields a fresh RenderSnapshot whose fields differ from the previous snapshot, reliably triggering re-renders. - Capture the snapshot off the dispatcher (before TryEnqueue) so values reflect the engine's state at the moment the event fired, not whatever the engine has further mutated to by the time the dispatcher dequeues. - Thread LastRunningPhase through to the stage-list math: previously the Failed-state rendering only knew Phase=Failed (the highest enum ordinal) which lost the position of the last running phase. The new helper consults History to pin the failure marker on the correct stage. Also extracted the stage-list math from the page into a pure helper (LocalSetupProgressStageMap) so it is unit-testable without WinUI deps: - VisibleStages array (now also folds PairOperator + later hidden phases into the MintToken stage, so a PairOperator failure pins correctly). - ComputeStageState(stagePhases, currentPhase, currentStatus, lastRunningPhase). - IndexOfStageForPhase, ShouldShowErrorRow, ShouldShowRetryButton. Tests added (LocalSetupProgressStageMapTests, +36 net): - Every running engine phase advances the active stage to the expected index (15 InlineData rows covering all 15 non-terminal phases). - NotStarted -> all stages Pending. - Complete -> all stages Complete. - Coverage guard: every declared LocalGatewaySetupPhase value is either terminal or covered by some VisibleStage (locks down future enum drift). - FailedRetryable @ PairOperator pins failure on the last visible stage (this is the concrete e2e-drive scenario). - FailedRetryable @ CreateWslInstance pins failure on stage 1. - FailedTerminal @ Preflight pins failure on stage 0. - ShouldShowErrorRow + ShouldShowRetryButton truth tables. Validation: - ./tests/OpenClaw.Shared.Tests: 1180 passed, 0 failed (anchor 1180/1180). - ./tests/OpenClaw.Tray.Tests: 493 passed, 0 failed (was 447/447, +46). - Env: OPENCLAW_REPO_ROOT=, OPENCLAW_RUN_INTEGRATION=1. - Full ./build.ps1 + screenshot verification BLOCKED in this session by the running tray app at PID 8240 holding a write-lock on the WinUI output directory (Mike is examining the broken state per the e2e-drive guardrail). Visual verification deferred until PID 8240 is released. Existing OPENCLAW_VISUAL_TEST_LOCAL_SETUP harness exercises the new retryable/terminal paths via the modified TryReadVisualTestState (which now seeds StartPhase before Block so LastRunningPhase pins correctly). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Pages/LocalSetupProgressPage.cs | 128 ++++++------ .../Services/LocalSetupProgressPolicy.cs | 12 +- .../Services/LocalSetupProgressStageMap.cs | 131 ++++++++++++ .../LocalSetupProgressStageMapTests.cs | 188 ++++++++++++++++++ .../OpenClaw.Tray.Tests.csproj | 1 + 5 files changed, 399 insertions(+), 61 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalSetupProgressStageMap.cs create mode 100644 tests/OpenClaw.Tray.Tests/LocalSetupProgressStageMapTests.cs diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs index 8a9d36b3..b6788298 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs @@ -43,20 +43,50 @@ public sealed class LocalSetupProgressPage : Component private static Task? s_runTask; private static bool s_advanceFiredForCompletion; - private static readonly (string LabelKey, LocalGatewaySetupPhase[] Phases)[] s_visibleStages = new[] + /// + /// Immutable snapshot captured per + /// invocation. Records have value-equality, so storing a fresh snapshot in + /// UseState on every event reliably triggers a re-render — unlike the + /// previous code which stored the live + /// reference (the engine mutates the same instance in place; reference-equal + /// previous/next values caused UseState to swallow every update past + /// the first, leaving the page stuck on stage 1 forever — Bug 2 / e2e drive). + /// + private sealed record RenderSnapshot( + LocalGatewaySetupPhase Phase, + LocalGatewaySetupStatus Status, + LocalGatewaySetupPhase LastRunningPhase, + string? UserMessage, + string? FailureCode); + + private static RenderSnapshot Capture(LocalGatewaySetupState st) { - ("Onboarding_LocalSetup_Phase_Preflight", new[] { LocalGatewaySetupPhase.Preflight, LocalGatewaySetupPhase.EnsureWslEnabled }), - ("Onboarding_LocalSetup_Phase_CreateInstance", new[] { LocalGatewaySetupPhase.CreateWslInstance }), - ("Onboarding_LocalSetup_Phase_Configure", new[] { LocalGatewaySetupPhase.ConfigureWslInstance }), - ("Onboarding_LocalSetup_Phase_InstallCli", new[] { LocalGatewaySetupPhase.InstallOpenClawCli }), - ("Onboarding_LocalSetup_Phase_PrepareConfig", new[] { LocalGatewaySetupPhase.PrepareGatewayConfig, LocalGatewaySetupPhase.InstallGatewayService }), - ("Onboarding_LocalSetup_Phase_StartGateway", new[] { LocalGatewaySetupPhase.StartGateway, LocalGatewaySetupPhase.WaitForGateway }), - ("Onboarding_LocalSetup_Phase_MintToken", new[] { LocalGatewaySetupPhase.MintBootstrapToken }), - }; + var lastRunning = LocalGatewaySetupPhase.NotStarted; + for (int i = st.History.Count - 1; i >= 0; i--) + { + var rec = st.History[i]; + if (rec.Phase != LocalGatewaySetupPhase.Failed + && rec.Phase != LocalGatewaySetupPhase.Cancelled + && rec.Phase != LocalGatewaySetupPhase.NotStarted) + { + lastRunning = rec.Phase; + break; + } + } + // While running, the last-running phase IS the current phase. + if (st.Status == LocalGatewaySetupStatus.Running + && st.Phase != LocalGatewaySetupPhase.Failed + && st.Phase != LocalGatewaySetupPhase.Cancelled + && st.Phase != LocalGatewaySetupPhase.NotStarted) + { + lastRunning = st.Phase; + } + return new RenderSnapshot(st.Phase, st.Status, lastRunning, st.UserMessage, st.FailureCode); + } public override Element Render() { - var (snapshot, setSnapshot) = UseState(null); + var (snapshot, setSnapshot) = UseState(null); var (retryCount, setRetryCount) = UseState(0); var dispatcher = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); var advanceRef = Props; // capture for closure @@ -69,7 +99,7 @@ public override Element Render() { if (visualState != null) { - setSnapshot(visualState); + setSnapshot(Capture(visualState)); return () => { }; } @@ -84,18 +114,23 @@ public override Element Render() { var failState = LocalGatewaySetupState.Create(new LocalGatewaySetupOptions()); failState.Block("engine_construct_failed", ex.Message, retryable: false, detail: ex.ToString()); - setSnapshot(failState); + setSnapshot(Capture(failState)); return () => { }; } } void Handler(LocalGatewaySetupState st) { + // Capture an immutable RenderSnapshot OFF the dispatcher so the + // values reflect the engine's state at the moment of the event, + // not whatever the engine has further mutated to by the time the + // dispatcher dequeues us. + var snap = Capture(st); dispatcher?.TryEnqueue(() => { - setSnapshot(st); + setSnapshot(snap); - if (st.Status == LocalGatewaySetupStatus.Complete && !s_advanceFiredForCompletion) + if (snap.Status == LocalGatewaySetupStatus.Complete && !s_advanceFiredForCompletion) { s_advanceFiredForCompletion = true; // 1-second pause on success per Mike's decision. Tap-to-skip: @@ -130,6 +165,7 @@ void Handler(LocalGatewaySetupState st) var phase = snapshot?.Phase ?? LocalGatewaySetupPhase.NotStarted; var status = snapshot?.Status ?? LocalGatewaySetupStatus.Pending; + var lastRunningPhase = snapshot?.LastRunningPhase ?? LocalGatewaySetupPhase.NotStarted; var subtitle = !string.IsNullOrWhiteSpace(snapshot?.UserMessage) ? snapshot!.UserMessage! : LocalizationHelper.GetString("Onboarding_LocalSetup_SubtitleIdle"); @@ -141,12 +177,14 @@ void Handler(LocalGatewaySetupState st) // FailedRetryable / FailedTerminal → VisibleDisabled (in-page Try Again or Back-out) // Cancelled → VisibleDisabled // Back is always enabled by the OnboardingApp default (pageIndex > 0). - Props.SetNextButtonState(LocalSetupProgressPolicy.MapStatusToNextButtonState(snapshot, status)); + Props.SetNextButtonState(LocalSetupProgressPolicy.MapStatusToNextButtonState(snapshot != null, status)); - var stageRows = s_visibleStages.Select(stage => RenderStage(LocalizationHelper.GetString(stage.LabelKey), stage.Phases, phase, status)).ToArray(); + var stageRows = LocalSetupProgressStageMap.VisibleStages + .Select(stage => RenderStage(LocalizationHelper.GetString(stage.LabelKey), stage.Phases, phase, status, lastRunningPhase)) + .ToArray(); - var isFailed = status == LocalGatewaySetupStatus.FailedRetryable || status == LocalGatewaySetupStatus.FailedTerminal; - var canRetry = status == LocalGatewaySetupStatus.FailedRetryable; + var isFailed = LocalSetupProgressStageMap.ShouldShowErrorRow(status); + var canRetry = LocalSetupProgressStageMap.ShouldShowRetryButton(status); Element errorRow; if (isFailed) @@ -221,30 +259,30 @@ void Handler(LocalGatewaySetupState st) .Padding(0, 8, 0, 0); } - private static Element RenderStage(string label, LocalGatewaySetupPhase[] stagePhases, LocalGatewaySetupPhase currentPhase, LocalGatewaySetupStatus currentStatus) + private static Element RenderStage(string label, LocalGatewaySetupPhase[] stagePhases, LocalGatewaySetupPhase currentPhase, LocalGatewaySetupStatus currentStatus, LocalGatewaySetupPhase lastRunningPhase) { - var stageState = ComputeStageState(stagePhases, currentPhase, currentStatus); + var stageState = LocalSetupProgressStageMap.ComputeStageState(stagePhases, currentPhase, currentStatus, lastRunningPhase); string icon; Element trailing; double opacity; switch (stageState) { - case StageState.Complete: + case LocalSetupProgressStageMap.StageState.Complete: icon = "✅"; trailing = TextBlock("").Width(20); opacity = 1.0; break; - case StageState.Active: + case LocalSetupProgressStageMap.StageState.Active: icon = "•"; trailing = ProgressRing().Width(18).Height(18); opacity = 1.0; break; - case StageState.Failed: + case LocalSetupProgressStageMap.StageState.Failed: icon = "❌"; trailing = TextBlock("").Width(20); opacity = 1.0; break; - case StageState.Pending: + case LocalSetupProgressStageMap.StageState.Pending: default: icon = "○"; trailing = TextBlock("").Width(20); @@ -257,7 +295,7 @@ private static Element RenderStage(string label, LocalGatewaySetupPhase[] stageP .VAlign(VerticalAlignment.Center) .Grid(row: 0, column: 1); - if (stageState == StageState.Failed) + if (stageState == LocalSetupProgressStageMap.StageState.Failed) labelBlock = labelBlock.Set(t => t.Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.IndianRed)); return Grid( @@ -278,8 +316,6 @@ private static Element RenderStage(string label, LocalGatewaySetupPhase[] stageP .Padding(4, 4, 4, 4); } - private enum StageState { Pending, Active, Complete, Failed } - /// /// Visual-test hook: when OPENCLAW_VISUAL_TEST=1 and OPENCLAW_VISUAL_TEST_LOCAL_SETUP is set, /// render a synthetic state without starting the real WSL setup engine. Accepted values: @@ -311,45 +347,17 @@ private enum StageState { Pending, Active, Complete, Failed } state.CompletePhase(LocalGatewaySetupPhase.Complete, LocalizationHelper.GetString("Onboarding_LocalSetup_SubtitleSuccess")); break; case "retryable": + // Walk the engine partway so RenderSnapshot.LastRunningPhase pins + // the failure marker on a stage instead of stage 0. + state.StartPhase(LocalGatewaySetupPhase.MintBootstrapToken, ""); state.Block("visual_test_retryable", string.IsNullOrWhiteSpace(arg) ? "Setup hit a snag." : arg, retryable: true); break; case "terminal": + state.StartPhase(LocalGatewaySetupPhase.MintBootstrapToken, ""); state.Block("visual_test_terminal", string.IsNullOrWhiteSpace(arg) ? "Setup cannot continue." : arg, retryable: false); break; } return state; } - - private static StageState ComputeStageState(LocalGatewaySetupPhase[] stagePhases, LocalGatewaySetupPhase currentPhase, LocalGatewaySetupStatus currentStatus) - { - // Failure pins the *current* stage to Failed; later stages remain Pending; earlier stages keep Complete. - var stageOrdinals = stagePhases.Select(p => (int)p).ToArray(); - var currentOrdinal = (int)currentPhase; - - var maxOrdinalInStage = stageOrdinals.Max(); - var minOrdinalInStage = stageOrdinals.Min(); - - if (currentStatus == LocalGatewaySetupStatus.Complete) - return StageState.Complete; - - if (currentPhase == LocalGatewaySetupPhase.Failed || currentStatus == LocalGatewaySetupStatus.FailedRetryable || currentStatus == LocalGatewaySetupStatus.FailedTerminal) - { - // Find the most recent non-terminal phase from snapshot.History? We don't have history here. - // Conservative: mark stage failed if the current phase ordinal falls within the stage's range - // *or* if no later visible stage has started. Otherwise pending. - // Simpler: only the stage matching the LAST visible-or-hidden phase before Failed is Failed. - // Without history, treat all stages with maxOrdinalInStage <= last-running-ordinal as Complete, - // current as Failed, rest as Pending. Approximate by using Phase==Failed and treating stages - // whose ordinals are all <= some threshold as complete based on the user-message phase hint. - // Pragmatic fallback: mark first stage with currentOrdinal in range as Failed; stages after as Pending; stages before as Complete. - // Since on Failed the engine sets Phase=Failed (highest ordinal) we can't distinguish — so we just mark the LAST visible stage as Failed. - return maxOrdinalInStage == s_visibleStages.Last().Phases.Max(p => (int)p) ? StageState.Failed : StageState.Pending; - } - - if (currentOrdinal > maxOrdinalInStage) - return StageState.Complete; - if (currentOrdinal >= minOrdinalInStage && currentOrdinal <= maxOrdinalInStage) - return StageState.Active; - return StageState.Pending; - } } + diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalSetupProgressPolicy.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalSetupProgressPolicy.cs index 08ef5179..b99c45db 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalSetupProgressPolicy.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalSetupProgressPolicy.cs @@ -26,8 +26,18 @@ public static class LocalSetupProgressPolicy /// on LocalSetupProgress because SetupWarning is page 0). /// public static OnboardingNextButtonState MapStatusToNextButtonState(LocalGatewaySetupState? snapshot, LocalGatewaySetupStatus status) + => MapStatusToNextButtonState(snapshot != null, status); + + /// + /// Snapshot-free overload used by the page after Bug 2 (e2e drive 2026-05-04). + /// The page now stores an immutable RenderSnapshot record (value equality) + /// instead of holding the live reference, + /// so it passes hasSnapshot + status directly. The original + /// reference-typed overload is preserved for back-compat with existing tests. + /// + public static OnboardingNextButtonState MapStatusToNextButtonState(bool hasSnapshot, LocalGatewaySetupStatus status) { - if (snapshot == null) + if (!hasSnapshot) return OnboardingNextButtonState.Hidden; return status switch diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalSetupProgressStageMap.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalSetupProgressStageMap.cs new file mode 100644 index 00000000..f2c50591 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalSetupProgressStageMap.cs @@ -0,0 +1,131 @@ +using System.Collections.Generic; +using System.Linq; +using OpenClawTray.Services.LocalGatewaySetup; + +namespace OpenClawTray.Onboarding.Services; + +/// +/// Pure helpers for LocalSetupProgressPage's stage-list rendering +/// (Phase 5). Lives in the Services namespace (no WinUI / FunctionalUI +/// dependencies) so unit tests in OpenClaw.Tray.Tests can import +/// it directly via the project's selective <Compile Include> list. +/// +/// Exists to fix Bug 2 from the e2e drive (2026-05-04) — the page render +/// previously inlined this logic AND took a reference-typed snapshot, which +/// hid two distinct defects: +/// 1. The engine raises +/// with the same mutating instance, +/// so reference-equality in UseState suppressed re-renders. +/// 2. The stage-state computation depended on 's +/// ordinal, but on failure the engine pins Phase = Failed (the highest +/// ordinal), losing the position of the last running phase. This helper +/// threads lastRunningPhase explicitly so failure rendering is +/// stable across the engine's full phase set. +/// +public static class LocalSetupProgressStageMap +{ + public enum StageState + { + Pending, + Active, + Complete, + Failed, + } + + public sealed record VisibleStage(string LabelKey, LocalGatewaySetupPhase[] Phases); + + /// + /// Whitelist of user-meaningful stages. Hidden phases (e.g. ElevationCheck, + /// PairOperator, CheckWindowsNodeReadiness, PairWindowsTrayNode, VerifyEndToEnd) + /// fold into a neighbouring visible stage or surface only as the subtitle line. + /// + public static readonly IReadOnlyList VisibleStages = new VisibleStage[] + { + new("Onboarding_LocalSetup_Phase_Preflight", new[] { LocalGatewaySetupPhase.Preflight, LocalGatewaySetupPhase.EnsureWslEnabled, LocalGatewaySetupPhase.ElevationCheck }), + new("Onboarding_LocalSetup_Phase_CreateInstance", new[] { LocalGatewaySetupPhase.CreateWslInstance }), + new("Onboarding_LocalSetup_Phase_Configure", new[] { LocalGatewaySetupPhase.ConfigureWslInstance }), + new("Onboarding_LocalSetup_Phase_InstallCli", new[] { LocalGatewaySetupPhase.InstallOpenClawCli }), + new("Onboarding_LocalSetup_Phase_PrepareConfig", new[] { LocalGatewaySetupPhase.PrepareGatewayConfig, LocalGatewaySetupPhase.InstallGatewayService }), + new("Onboarding_LocalSetup_Phase_StartGateway", new[] { LocalGatewaySetupPhase.StartGateway, LocalGatewaySetupPhase.WaitForGateway }), + new("Onboarding_LocalSetup_Phase_MintToken", new[] { LocalGatewaySetupPhase.MintBootstrapToken, LocalGatewaySetupPhase.PairOperator, LocalGatewaySetupPhase.CheckWindowsNodeReadiness, LocalGatewaySetupPhase.PairWindowsTrayNode, LocalGatewaySetupPhase.VerifyEndToEnd }), + }; + + /// + /// Compute the visual state for a single visible stage given the current + /// engine phase, status, and (when failed) the last running phase prior + /// to failure (read from ). + /// + public static StageState ComputeStageState( + LocalGatewaySetupPhase[] stagePhases, + LocalGatewaySetupPhase currentPhase, + LocalGatewaySetupStatus currentStatus, + LocalGatewaySetupPhase lastRunningPhase) + { + if (currentStatus == LocalGatewaySetupStatus.Complete) + return StageState.Complete; + + var stageOrdinals = stagePhases.Select(p => (int)p).ToArray(); + var minOrdinalInStage = stageOrdinals.Min(); + var maxOrdinalInStage = stageOrdinals.Max(); + + if (currentStatus == LocalGatewaySetupStatus.FailedRetryable + || currentStatus == LocalGatewaySetupStatus.FailedTerminal + || currentPhase == LocalGatewaySetupPhase.Failed) + { + // Use the last running phase to pin the failure marker on the + // stage where the engine actually broke. + var lastOrdinal = (int)lastRunningPhase; + if (lastOrdinal >= minOrdinalInStage && lastOrdinal <= maxOrdinalInStage) + return StageState.Failed; + if (lastOrdinal > maxOrdinalInStage) + return StageState.Complete; + return StageState.Pending; + } + + if (currentStatus == LocalGatewaySetupStatus.Cancelled) + { + var lastOrdinal = (int)lastRunningPhase; + if (lastOrdinal > maxOrdinalInStage) return StageState.Complete; + if (lastOrdinal >= minOrdinalInStage && lastOrdinal <= maxOrdinalInStage) return StageState.Pending; + return StageState.Pending; + } + + var currentOrdinal = (int)currentPhase; + if (currentOrdinal > maxOrdinalInStage) + return StageState.Complete; + if (currentOrdinal >= minOrdinalInStage && currentOrdinal <= maxOrdinalInStage) + return StageState.Active; + return StageState.Pending; + } + + /// + /// Find the index of the visible stage that should be highlighted Active + /// (or Failed) for the given engine phase. Returns -1 when no visible + /// stage covers the phase (e.g. + /// or ). + /// + public static int IndexOfStageForPhase(LocalGatewaySetupPhase phase) + { + for (int i = 0; i < VisibleStages.Count; i++) + { + if (VisibleStages[i].Phases.Contains(phase)) + return i; + } + return -1; + } + + /// + /// True when the page should render the inline error / retry row + /// (FailedRetryable or FailedTerminal). All other statuses collapse it. + /// + public static bool ShouldShowErrorRow(LocalGatewaySetupStatus status) + => status == LocalGatewaySetupStatus.FailedRetryable + || status == LocalGatewaySetupStatus.FailedTerminal; + + /// + /// True when the inline error row should expose a Try Again button — + /// only on FailedRetryable. FailedTerminal forces Back-out. + /// + public static bool ShouldShowRetryButton(LocalGatewaySetupStatus status) + => status == LocalGatewaySetupStatus.FailedRetryable; +} diff --git a/tests/OpenClaw.Tray.Tests/LocalSetupProgressStageMapTests.cs b/tests/OpenClaw.Tray.Tests/LocalSetupProgressStageMapTests.cs new file mode 100644 index 00000000..9b1dd10b --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/LocalSetupProgressStageMapTests.cs @@ -0,0 +1,188 @@ +using System; +using System.Linq; +using OpenClawTray.Onboarding.Services; +using OpenClawTray.Services.LocalGatewaySetup; +using SS = OpenClawTray.Onboarding.Services.LocalSetupProgressStageMap.StageState; + +namespace OpenClaw.Tray.Tests; + +/// +/// Locks down the stage-list mapping that +/// renders. Bug 2 (Aaron e2e drive 2026-05-04) showed two distinct symptoms +/// that traced to a single page-binding root cause: +/// 1. The stage list never advanced past the first stage even though the +/// engine progressed through 9+ phases. +/// 2. FailedRetryable never rendered the error/retry row. +/// +/// The root cause was reference-equality in UseState swallowing every +/// state change after the first (the engine raises StateChanged with +/// the same mutating instance). The page +/// now stores an immutable record snapshot, and the stage-list logic is +/// hosted in this pure helper so the mapping is exhaustively testable +/// without WinUI dependencies. +/// +public class LocalSetupProgressStageMapTests +{ + // ---------- Stage advancement: every engine phase resolves to a stage ---------- + + [Theory] + [InlineData(LocalGatewaySetupPhase.Preflight, 0)] + [InlineData(LocalGatewaySetupPhase.EnsureWslEnabled, 0)] + [InlineData(LocalGatewaySetupPhase.ElevationCheck, 0)] + [InlineData(LocalGatewaySetupPhase.CreateWslInstance, 1)] + [InlineData(LocalGatewaySetupPhase.ConfigureWslInstance, 2)] + [InlineData(LocalGatewaySetupPhase.InstallOpenClawCli, 3)] + [InlineData(LocalGatewaySetupPhase.PrepareGatewayConfig, 4)] + [InlineData(LocalGatewaySetupPhase.InstallGatewayService, 4)] + [InlineData(LocalGatewaySetupPhase.StartGateway, 5)] + [InlineData(LocalGatewaySetupPhase.WaitForGateway, 5)] + [InlineData(LocalGatewaySetupPhase.MintBootstrapToken, 6)] + [InlineData(LocalGatewaySetupPhase.PairOperator, 6)] + [InlineData(LocalGatewaySetupPhase.CheckWindowsNodeReadiness, 6)] + [InlineData(LocalGatewaySetupPhase.PairWindowsTrayNode, 6)] + [InlineData(LocalGatewaySetupPhase.VerifyEndToEnd, 6)] + public void EveryRunningEnginePhase_AdvancesActiveStage_ToExpectedIndex(LocalGatewaySetupPhase phase, int expectedActiveIndex) + { + var states = LocalSetupProgressStageMap.VisibleStages + .Select(s => LocalSetupProgressStageMap.ComputeStageState(s.Phases, phase, LocalGatewaySetupStatus.Running, phase)) + .ToArray(); + + Assert.Equal(SS.Active, states[expectedActiveIndex]); + for (int i = 0; i < expectedActiveIndex; i++) + Assert.Equal(SS.Complete, states[i]); + for (int i = expectedActiveIndex + 1; i < states.Length; i++) + Assert.Equal(SS.Pending, states[i]); + } + + [Fact] + public void NotStarted_RendersAllStagesPending() + { + foreach (var s in LocalSetupProgressStageMap.VisibleStages) + { + var state = LocalSetupProgressStageMap.ComputeStageState( + s.Phases, LocalGatewaySetupPhase.NotStarted, LocalGatewaySetupStatus.Pending, LocalGatewaySetupPhase.NotStarted); + Assert.Equal(SS.Pending, state); + } + } + + [Fact] + public void Complete_RendersAllStagesComplete() + { + foreach (var s in LocalSetupProgressStageMap.VisibleStages) + { + var state = LocalSetupProgressStageMap.ComputeStageState( + s.Phases, LocalGatewaySetupPhase.Complete, LocalGatewaySetupStatus.Complete, LocalGatewaySetupPhase.Complete); + Assert.Equal(SS.Complete, state); + } + } + + [Fact] + public void EveryDeclaredEnginePhase_IsCoveredBySomeVisibleStageOrIsTerminal() + { + // Guards against future enum additions silently dropping off the page. + var covered = LocalSetupProgressStageMap.VisibleStages.SelectMany(s => s.Phases).ToHashSet(); + var terminal = new[] + { + LocalGatewaySetupPhase.NotStarted, + LocalGatewaySetupPhase.Complete, + LocalGatewaySetupPhase.Failed, + LocalGatewaySetupPhase.Cancelled, + }; + foreach (LocalGatewaySetupPhase p in Enum.GetValues(typeof(LocalGatewaySetupPhase))) + { + Assert.True(covered.Contains(p) || terminal.Contains(p), + $"LocalGatewaySetupPhase.{p} is neither a terminal phase nor covered by any visible stage. Add it to LocalSetupProgressStageMap.VisibleStages."); + } + } + + // ---------- FailedRetryable: pin failure marker on the stage where engine broke ---------- + + [Fact] + public void FailedRetryable_AtPairOperator_PinsFailureOnLastVisibleStage() + { + // PairOperator is the failure mode Aaron's e2e drive hit (Bug 1). + // The engine sets Phase=Failed on Block(); LastRunningPhase tells us + // PairOperator was the last running phase, which lives in the MintToken + // visible stage (index 6). + var states = LocalSetupProgressStageMap.VisibleStages + .Select(s => LocalSetupProgressStageMap.ComputeStageState( + s.Phases, + LocalGatewaySetupPhase.Failed, + LocalGatewaySetupStatus.FailedRetryable, + LocalGatewaySetupPhase.PairOperator)) + .ToArray(); + + for (int i = 0; i < 6; i++) Assert.Equal(SS.Complete, states[i]); + Assert.Equal(SS.Failed, states[6]); + } + + [Fact] + public void FailedRetryable_AtCreateWslInstance_PinsFailureOnSecondStage() + { + var states = LocalSetupProgressStageMap.VisibleStages + .Select(s => LocalSetupProgressStageMap.ComputeStageState( + s.Phases, + LocalGatewaySetupPhase.Failed, + LocalGatewaySetupStatus.FailedRetryable, + LocalGatewaySetupPhase.CreateWslInstance)) + .ToArray(); + + Assert.Equal(SS.Complete, states[0]); + Assert.Equal(SS.Failed, states[1]); + for (int i = 2; i < states.Length; i++) Assert.Equal(SS.Pending, states[i]); + } + + [Fact] + public void FailedTerminal_AtPreflight_PinsFailureOnFirstStage() + { + var states = LocalSetupProgressStageMap.VisibleStages + .Select(s => LocalSetupProgressStageMap.ComputeStageState( + s.Phases, + LocalGatewaySetupPhase.Failed, + LocalGatewaySetupStatus.FailedTerminal, + LocalGatewaySetupPhase.Preflight)) + .ToArray(); + + Assert.Equal(SS.Failed, states[0]); + for (int i = 1; i < states.Length; i++) Assert.Equal(SS.Pending, states[i]); + } + + // ---------- Error row + retry button visibility ---------- + + [Theory] + [InlineData(LocalGatewaySetupStatus.FailedRetryable, true)] + [InlineData(LocalGatewaySetupStatus.FailedTerminal, true)] + [InlineData(LocalGatewaySetupStatus.Pending, false)] + [InlineData(LocalGatewaySetupStatus.Running, false)] + [InlineData(LocalGatewaySetupStatus.Complete, false)] + [InlineData(LocalGatewaySetupStatus.RequiresAdmin, false)] + [InlineData(LocalGatewaySetupStatus.RequiresRestart, false)] + [InlineData(LocalGatewaySetupStatus.Blocked, false)] + [InlineData(LocalGatewaySetupStatus.Cancelled, false)] + public void ShouldShowErrorRow_OnlyOnFailureStates(LocalGatewaySetupStatus status, bool expected) + { + Assert.Equal(expected, LocalSetupProgressStageMap.ShouldShowErrorRow(status)); + } + + [Theory] + [InlineData(LocalGatewaySetupStatus.FailedRetryable, true)] + [InlineData(LocalGatewaySetupStatus.FailedTerminal, false)] + [InlineData(LocalGatewaySetupStatus.Running, false)] + [InlineData(LocalGatewaySetupStatus.Complete, false)] + [InlineData(LocalGatewaySetupStatus.Pending, false)] + public void ShouldShowRetryButton_OnlyOnFailedRetryable(LocalGatewaySetupStatus status, bool expected) + { + Assert.Equal(expected, LocalSetupProgressStageMap.ShouldShowRetryButton(status)); + } + + // ---------- IndexOfStageForPhase ---------- + + [Fact] + public void IndexOfStageForPhase_ReturnsMinusOne_ForUncoveredPhases() + { + Assert.Equal(-1, LocalSetupProgressStageMap.IndexOfStageForPhase(LocalGatewaySetupPhase.NotStarted)); + Assert.Equal(-1, LocalSetupProgressStageMap.IndexOfStageForPhase(LocalGatewaySetupPhase.Complete)); + Assert.Equal(-1, LocalSetupProgressStageMap.IndexOfStageForPhase(LocalGatewaySetupPhase.Failed)); + Assert.Equal(-1, LocalSetupProgressStageMap.IndexOfStageForPhase(LocalGatewaySetupPhase.Cancelled)); + } +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 2f4dbc20..1f836ace 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -33,6 +33,7 @@ + From 392745114ff713616ffc3ab1d19ae8d90dc2e675 Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 4 May 2026 21:55:32 -0700 Subject: [PATCH 20/56] fix(setup): operator-pair approval against CLI v2026.5.3-1 ensureExplicitGatewayAuth (Bug 1 residual) Drop --url override from WslGatewayCliPendingDeviceApprover. The CLI runs inside the OpenClawGateway distro where openclaw.json pins gateway.mode=local + port 18789, so buildGatewayConnectionDetails resolves the loopback URL itself. Without --url, ensureExplicitGatewayAuth (src/gateway/call.ts) early-returns and shouldUseLocalPairingFallback becomes available, so the CLI silently falls back to local pairing-file approval if the WS hop trips. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../LocalGatewaySetup/LocalGatewaySetup.cs | 21 +++-- .../OperatorPairingApprovalTests.cs | 80 +++++++++++++++++++ 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs index c95a0934..80111bbf 100644 --- a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs +++ b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs @@ -1667,9 +1667,22 @@ public WslGatewayCliPendingDeviceApprover(IWslCommandRunner wsl, string commandN public async Task ApproveLatestAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) { - // Authenticate to the loopback gateway using the locally-stored gateway-token (mode 0600, - // owned by the openclaw user) and approve the latest pending request. The gateway token - // is read inside the shell so it never appears on the wsl.exe argv or in process listings. + // Bug 1 residual fix (CLI v2026.5.3-1, commit 2eae30e): the upstream `devices approve` + // command guards `--url` overrides via `ensureExplicitGatewayAuth` (src/gateway/call.ts) + // and rejects them when explicit credentials are not present in the exact shape it expects, + // surfacing "gateway url override requires explicit credentials" and a non-zero exit. + // + // Drop `--url` entirely. The CLI runs INSIDE the OpenClawGateway distro where + // `/home/openclaw/.openclaw/openclaw.json` already pins `gateway.mode=local` and the + // loopback port (18789); the CLI's `buildGatewayConnectionDetails` resolves the URL + // from that config when no override is supplied, so: + // 1. `ensureExplicitGatewayAuth` early-returns (no urlOverride to validate), and + // 2. `shouldUseLocalPairingFallback` becomes available — if the WS hop trips for any + // reason the CLI silently falls back to approving via local pairing files + // (`approveDevicePairing`), which is exactly the right thing on a single-machine + // local-loopback gateway. + // We still pass `--token` (read from the 0600 gateway-token file inside the shell so + // it never appears on `wsl.exe` argv) so the CLI authenticates if the WS path is taken. var script = string.Join(" ", new[] { "set -euo pipefail;", @@ -1683,8 +1696,6 @@ public async Task ApproveLatestAsync(LocalGatewaySe "approve", "--latest", "--json", - "--url", - ShellQuoteScalar(state.GatewayUrl), "--token", "\"$(cat /var/lib/openclaw/gateway-token)\"" }); diff --git a/tests/OpenClaw.Tray.Tests/OperatorPairingApprovalTests.cs b/tests/OpenClaw.Tray.Tests/OperatorPairingApprovalTests.cs index a2c2d38a..209dbf2a 100644 --- a/tests/OpenClaw.Tray.Tests/OperatorPairingApprovalTests.cs +++ b/tests/OpenClaw.Tray.Tests/OperatorPairingApprovalTests.cs @@ -156,6 +156,86 @@ public void ParseApproveJson_NonJsonOutput_ReturnsSuccess() Assert.True(result.Success); } + // --- Bug 1 residual fix (CLI v2026.5.3-1 / commit 2eae30e) regression tests --- + + [Fact] + public async Task WslGatewayCliPendingDeviceApprover_DoesNotPassUrlOverride_AvoidingEnsureExplicitGatewayAuthGuard() + { + // The upstream CLI v2026.5.3-1 (commit 2eae30e) `ensureExplicitGatewayAuth` + // (src/gateway/call.ts) rejects `--url` overrides with the error + // "gateway url override requires explicit credentials" unless explicit auth is supplied + // in the precise shape it expects. Drop `--url` so the CLI resolves the loopback URL + // from the in-distro `openclaw.json` (gateway.mode=local) instead. + var runner = new RecordingWslRunner(new WslCommandResult(0, "{\"ok\":true,\"requestId\":\"abc\"}", string.Empty)); + var approver = new WslGatewayCliPendingDeviceApprover(runner, "/opt/openclaw/bin/openclaw"); + var state = new LocalGatewaySetupState { GatewayUrl = "ws://127.0.0.1:18789", DistroName = "OpenClawGateway" }; + + var result = await approver.ApproveLatestAsync(state); + + Assert.True(result.Success); + Assert.Equal("OpenClawGateway", runner.LastDistroName); + Assert.NotNull(runner.LastCommand); + var script = string.Join(" ", runner.LastCommand!); + Assert.DoesNotContain("--url", script); + Assert.DoesNotContain("ws://127.0.0.1:18789", script); + Assert.Contains("devices", script); + Assert.Contains("approve", script); + Assert.Contains("--latest", script); + Assert.Contains("--json", script); + Assert.Contains("--token", script); + // Token value is dereferenced inside the shell so it never appears on argv. + Assert.Contains("$(cat /var/lib/openclaw/gateway-token)", script); + } + + [Fact] + public async Task WslGatewayCliPendingDeviceApprover_NonZeroExit_SurfacesStructuredFailureCode() + { + // Pin the failure surface for the v2026.5.3-1 stderr that originally regressed Bug 1. + var stderr = "[openclaw] Failed to start CLI: Error: gateway url override requires explicit credentials\n" + + "Fix: pass --token *** --password *** gatewayToken in tools).\n" + + " at ensureExplicitGatewayAuth (.../call-BCpe65RR.js:148:8)"; + var runner = new RecordingWslRunner(new WslCommandResult(1, string.Empty, stderr)); + var approver = new WslGatewayCliPendingDeviceApprover(runner, "/opt/openclaw/bin/openclaw"); + + var result = await approver.ApproveLatestAsync(new LocalGatewaySetupState + { + GatewayUrl = "ws://127.0.0.1:18789", + DistroName = "OpenClawGateway", + }); + + Assert.False(result.Success); + Assert.Equal("operator_pending_approval_failed", result.ErrorCode); + Assert.Equal("Local gateway pending pairing approval CLI failed.", result.ErrorMessage); + } + + private sealed class RecordingWslRunner : IWslCommandRunner + { + private readonly WslCommandResult _result; + public string? LastDistroName { get; private set; } + public IReadOnlyList? LastCommand { get; private set; } + + public RecordingWslRunner(WslCommandResult result) => _result = result; + + public Task RunAsync(IReadOnlyList arguments, CancellationToken cancellationToken = default) + => Task.FromResult(new WslCommandResult(0, string.Empty, string.Empty)); + + public Task> ListDistrosAsync(CancellationToken cancellationToken = default) + => Task.FromResult>(Array.Empty()); + + public Task TerminateDistroAsync(string name, CancellationToken cancellationToken = default) + => Task.FromResult(new WslCommandResult(0, string.Empty, string.Empty)); + + public Task UnregisterDistroAsync(string name, CancellationToken cancellationToken = default) + => Task.FromResult(new WslCommandResult(0, string.Empty, string.Empty)); + + public Task RunInDistroAsync(string name, IReadOnlyList command, CancellationToken cancellationToken = default) + { + LastDistroName = name; + LastCommand = command; + return Task.FromResult(_result); + } + } + private sealed class FakePairingSettings : ILocalGatewaySetupSettings { public string GatewayUrl { get; set; } = ""; From 6942a818d22f4eac5599898b7f33f4107cc201d6 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 4 May 2026 22:40:08 -0700 Subject: [PATCH 21/56] fix(setup): two-stage operator approve (preview + explicit requestId) against CLI v2026.5.3-1 (Bug 1 part 3) CLI v2026.5.3-1 (src/cli/devices-cli.ts, commit aef38de) makes `openclaw devices approve --latest --json` PREVIEW-ONLY: when --latest or no requestId is supplied, the action handler enters the usingImplicitSelection branch which writes a JSON preview ({ selected, approvalState, approveCommand, requiresAuthFlags }) and returns BEFORE invoking approvePairingWithFallback. Only an explicit requestId argument bypasses the preview gate and actually calls device.pair.approve / mutates paired.json. The previous fix (3927451) correctly removed the --url override that tripped ensureExplicitGatewayAuth, but the resulting invocation still only ran the preview, so the engine saw exit 0, retried the WS connect, got pairing-required again, and surfaced operator_pending_approval_failed. WslGatewayCliPendingDeviceApprover.ApproveLatestAsync now runs two stages: 1. Preview: openclaw devices approve --latest --json --token "\" parses selected.requestId from the v2026.5.3-1 preview JSON. 2. Commit: openclaw devices approve --json --token "\" actually approves and mutates paired.json. A new no_pending_entries error code distinguishes "stage 1 returned no selected.requestId" from a real approval failure so the engine does not infinite-loop. Stage 2 failures surface the underlying stderr. The requestId returned by stage 1 is validated against a safe charset before interpolation into the bash -lc commit script. Tests (tests/OpenClaw.Tray.Tests/OperatorPairingApprovalTests.cs): - TwoStage_PreviewThenCommit_Succeeds (argv shape pinned for both stages) - TwoStage_PreviewEmpty_NoPendingEntries (stage 2 must NOT run) - TwoStage_CommitFails_SurfacesStructuredFailure (surfaces stderr) - TwoStage_PreviewReturnsUnsafeRequestId_DoesNotRunCommit (defense in depth) - ParsePreviewJson_V20265_Shape_ReturnsRequestId - ParsePreviewJson_Empty_ReturnsNoPendingEntries - ParsePreviewJson_OkFalse_ReturnsApprovalFailure Existing DoesNotPassUrlOverride and NonZeroExit tests updated for the two-stage flow; all prior 12 approval tests remain green. Validation: ./build.ps1 ok dotnet test tests/OpenClaw.Tray.Tests --no-restore 502 / 502 passed dotnet test tests/OpenClaw.Shared.Tests --no-restore 1180 / 1180 passed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../LocalGatewaySetup/LocalGatewaySetup.cs | 202 +++++++++++++++--- .../OperatorPairingApprovalTests.cs | 183 ++++++++++++++-- 2 files changed, 334 insertions(+), 51 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs index 80111bbf..fda139c0 100644 --- a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs +++ b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs @@ -1667,48 +1667,164 @@ public WslGatewayCliPendingDeviceApprover(IWslCommandRunner wsl, string commandN public async Task ApproveLatestAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) { - // Bug 1 residual fix (CLI v2026.5.3-1, commit 2eae30e): the upstream `devices approve` - // command guards `--url` overrides via `ensureExplicitGatewayAuth` (src/gateway/call.ts) - // and rejects them when explicit credentials are not present in the exact shape it expects, - // surfacing "gateway url override requires explicit credentials" and a non-zero exit. + // Bug 1 part 3 (CLI v2026.5.3-1): `openclaw devices approve --latest --json` is a + // PREVIEW/inspection-only operation on this CLI version. It returns a JSON payload + // (`{ "selected": {...}, "approvalState": {...}, "approveCommand": "openclaw devices + // approve --json", "requiresAuthFlags": {...} }`) and exits 0 without + // ever invoking `device.pair.approve` or mutating `paired.json`. To actually + // approve, the CLI requires a follow-up call with the explicit requestId. // - // Drop `--url` entirely. The CLI runs INSIDE the OpenClawGateway distro where - // `/home/openclaw/.openclaw/openclaw.json` already pins `gateway.mode=local` and the - // loopback port (18789); the CLI's `buildGatewayConnectionDetails` resolves the URL - // from that config when no override is supplied, so: - // 1. `ensureExplicitGatewayAuth` early-returns (no urlOverride to validate), and - // 2. `shouldUseLocalPairingFallback` becomes available — if the WS hop trips for any - // reason the CLI silently falls back to approving via local pairing files - // (`approveDevicePairing`), which is exactly the right thing on a single-machine - // local-loopback gateway. - // We still pass `--token` (read from the 0600 gateway-token file inside the shell so - // it never appears on `wsl.exe` argv) so the CLI authenticates if the WS path is taken. - var script = string.Join(" ", new[] + // Source: src/cli/devices-cli.ts (commit aef38de) — the `usingImplicitSelection` + // branch (set when `--latest` or no requestId is supplied) writes the preview JSON + // and `return`s before reaching `approvePairingWithFallback`. Only an explicit + // requestId argument bypasses the preview gate. + // + // We therefore run two stages in the same approver call: + // Stage 1 — discover: `openclaw devices approve --latest --json --token "$TOKEN"` + // parses `selected.requestId` from the preview JSON. + // Stage 2 — commit: `openclaw devices approve --json --token "$TOKEN"` + // actually calls `device.pair.approve` (or the local pairing fallback) and + // mutates `paired.json`. + // + // We continue to drop `--url` (Bug 1 part 2 / CLI ensureExplicitGatewayAuth guard) + // and dereference the gateway token inside the shell so it never lands on argv. + var stage1 = await _wsl.RunInDistroAsync( + state.DistroName, + ["bash", "-lc", BuildPreviewScript()], + cancellationToken); + if (!stage1.Success) { - "set -euo pipefail;", - "if [ ! -s /var/lib/openclaw/gateway-token ]; then", - " echo 'gateway token file missing or empty' >&2; exit 64;", - "fi;", - "if [ -f /var/lib/openclaw/gateway.env ]; then set -a; . /var/lib/openclaw/gateway.env; set +a; fi;", - "exec", - ShellQuoteScalar(_commandName), - "devices", - "approve", - "--latest", - "--json", - "--token", - "\"$(cat /var/lib/openclaw/gateway-token)\"" - }); - var result = await _wsl.RunInDistroAsync(state.DistroName, ["bash", "-lc", script], cancellationToken); - if (!result.Success) + return new PendingDeviceApprovalResult( + false, + "operator_pending_approval_failed", + "Local gateway pending pairing approval CLI failed (preview stage)."); + } + + var preview = ParsePreviewJson(stage1.StandardOutput); + if (!preview.Success) + { + return new PendingDeviceApprovalResult(false, preview.ErrorCode, preview.ErrorMessage); + } + + var requestId = preview.RequestId!; + if (!IsSafeRequestId(requestId)) { return new PendingDeviceApprovalResult( false, "operator_pending_approval_failed", - "Local gateway pending pairing approval CLI failed."); + $"Local gateway preview returned an unsafe requestId: {requestId}"); } - return ParseApproveJson(result.StandardOutput); + var stage2 = await _wsl.RunInDistroAsync( + state.DistroName, + ["bash", "-lc", BuildCommitScript(requestId)], + cancellationToken); + if (!stage2.Success) + { + var stderr = string.IsNullOrWhiteSpace(stage2.StandardError) + ? "Local gateway pending pairing approval CLI failed (commit stage)." + : stage2.StandardError.Trim(); + return new PendingDeviceApprovalResult(false, "operator_pending_approval_failed", stderr); + } + + return ParseApproveJson(stage2.StandardOutput); + } + + private string BuildPreviewScript() => string.Join(" ", new[] + { + "set -euo pipefail;", + "if [ ! -s /var/lib/openclaw/gateway-token ]; then", + " echo 'gateway token file missing or empty' >&2; exit 64;", + "fi;", + "if [ -f /var/lib/openclaw/gateway.env ]; then set -a; . /var/lib/openclaw/gateway.env; set +a; fi;", + "exec", + ShellQuoteScalar(_commandName), + "devices", + "approve", + "--latest", + "--json", + "--token", + "\"$(cat /var/lib/openclaw/gateway-token)\"" + }); + + private string BuildCommitScript(string requestId) => string.Join(" ", new[] + { + "set -euo pipefail;", + "if [ ! -s /var/lib/openclaw/gateway-token ]; then", + " echo 'gateway token file missing or empty' >&2; exit 64;", + "fi;", + "if [ -f /var/lib/openclaw/gateway.env ]; then set -a; . /var/lib/openclaw/gateway.env; set +a; fi;", + "exec", + ShellQuoteScalar(_commandName), + "devices", + "approve", + ShellQuoteScalar(requestId), + "--json", + "--token", + "\"$(cat /var/lib/openclaw/gateway-token)\"" + }); + + /// + /// Parse the v2026.5.3-1 `devices approve --latest --json` preview payload and extract + /// the pending requestId for the stage-2 commit call. Returns a structured failure with + /// no_pending_entries when the preview indicates nothing approvable. + /// + public static PreviewParseResult ParsePreviewJson(string output) + { + if (string.IsNullOrWhiteSpace(output)) + { + return PreviewParseResult.Failure("no_pending_entries", "No pending device pairing requests to approve."); + } + + try + { + using var doc = JsonDocument.Parse(output); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return PreviewParseResult.Failure("no_pending_entries", "Preview JSON was not an object."); + } + + // Explicit `ok:false` legacy shape — surface as approval failure. + if (root.TryGetProperty("ok", out var ok) && ok.ValueKind == JsonValueKind.False) + { + var legacyMsg = root.TryGetProperty("error", out var legacyErr) && legacyErr.ValueKind == JsonValueKind.String + ? legacyErr.GetString() + : "Local gateway preview reported failure."; + return PreviewParseResult.Failure("operator_pending_approval_failed", legacyMsg); + } + + // v2026.5.3-1 preview shape: { "selected": { "requestId": "...", ... }, ... } + if (root.TryGetProperty("selected", out var selected) && selected.ValueKind == JsonValueKind.Object + && selected.TryGetProperty("requestId", out var reqId) && reqId.ValueKind == JsonValueKind.String) + { + var id = reqId.GetString(); + if (!string.IsNullOrWhiteSpace(id)) + { + return PreviewParseResult.SuccessWith(id!); + } + } + + // Tolerate an older flat shape some CLI builds may have used: { "requestId": "..." }. + if (root.TryGetProperty("requestId", out var rootReqId) && rootReqId.ValueKind == JsonValueKind.String) + { + var id = rootReqId.GetString(); + if (!string.IsNullOrWhiteSpace(id)) + { + return PreviewParseResult.SuccessWith(id!); + } + } + + return PreviewParseResult.Failure("no_pending_entries", "Preview JSON had no selected.requestId."); + } + catch (JsonException) + { + // Plain-text non-JSON output (e.g. older CLI / "No pending device pairing + // requests to approve" on stderr-but-stdout-empty edge cases). Treat as no + // pending entries so the engine surfaces a structured failure rather than + // silently succeeding. + return PreviewParseResult.Failure("no_pending_entries", "Preview output was not JSON; assuming no pending entries."); + } } public static PendingDeviceApprovalResult ParseApproveJson(string output) @@ -1739,9 +1855,27 @@ public static PendingDeviceApprovalResult ParseApproveJson(string output) } } + private static bool IsSafeRequestId(string value) + { + if (string.IsNullOrEmpty(value) || value.Length > 128) return false; + foreach (var c in value) + { + var ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') + || c == '-' || c == '_' || c == '.' || c == ':'; + if (!ok) return false; + } + return true; + } + private static string ShellQuoteScalar(string value) => "'" + value.Replace("'", "'\"'\"'", StringComparison.Ordinal) + "'"; } +public sealed record PreviewParseResult(bool Success, string? RequestId, string? ErrorCode, string? ErrorMessage) +{ + public static PreviewParseResult SuccessWith(string requestId) => new(true, requestId, null, null); + public static PreviewParseResult Failure(string code, string? message) => new(false, null, code, message); +} + public sealed class SettingsWindowsTrayNodeProvisioner : IWindowsTrayNodeProvisioner { private readonly ILocalGatewaySetupSettings _settings; diff --git a/tests/OpenClaw.Tray.Tests/OperatorPairingApprovalTests.cs b/tests/OpenClaw.Tray.Tests/OperatorPairingApprovalTests.cs index 209dbf2a..07ad3a6e 100644 --- a/tests/OpenClaw.Tray.Tests/OperatorPairingApprovalTests.cs +++ b/tests/OpenClaw.Tray.Tests/OperatorPairingApprovalTests.cs @@ -166,25 +166,31 @@ public async Task WslGatewayCliPendingDeviceApprover_DoesNotPassUrlOverride_Avoi // "gateway url override requires explicit credentials" unless explicit auth is supplied // in the precise shape it expects. Drop `--url` so the CLI resolves the loopback URL // from the in-distro `openclaw.json` (gateway.mode=local) instead. - var runner = new RecordingWslRunner(new WslCommandResult(0, "{\"ok\":true,\"requestId\":\"abc\"}", string.Empty)); + // + // Bug 1 part 3: with the two-stage approve, BOTH stages must omit `--url`. + var runner = new RecordingWslRunner( + new WslCommandResult(0, "{\"selected\":{\"requestId\":\"abc-123\"},\"approveCommand\":\"openclaw devices approve abc-123 --json\"}", string.Empty), + new WslCommandResult(0, "{\"requestId\":\"abc-123\",\"device\":{}}", string.Empty)); var approver = new WslGatewayCliPendingDeviceApprover(runner, "/opt/openclaw/bin/openclaw"); var state = new LocalGatewaySetupState { GatewayUrl = "ws://127.0.0.1:18789", DistroName = "OpenClawGateway" }; var result = await approver.ApproveLatestAsync(state); Assert.True(result.Success); - Assert.Equal("OpenClawGateway", runner.LastDistroName); - Assert.NotNull(runner.LastCommand); - var script = string.Join(" ", runner.LastCommand!); - Assert.DoesNotContain("--url", script); - Assert.DoesNotContain("ws://127.0.0.1:18789", script); - Assert.Contains("devices", script); - Assert.Contains("approve", script); - Assert.Contains("--latest", script); - Assert.Contains("--json", script); - Assert.Contains("--token", script); - // Token value is dereferenced inside the shell so it never appears on argv. - Assert.Contains("$(cat /var/lib/openclaw/gateway-token)", script); + Assert.Equal(2, runner.RunInDistroCommands.Count); + foreach (var cmd in runner.RunInDistroCommands) + { + Assert.Equal("OpenClawGateway", runner.LastDistroName); + var script = string.Join(" ", cmd); + Assert.DoesNotContain("--url", script); + Assert.DoesNotContain("ws://127.0.0.1:18789", script); + Assert.Contains("devices", script); + Assert.Contains("approve", script); + Assert.Contains("--json", script); + Assert.Contains("--token", script); + // Token value is dereferenced inside the shell so it never appears on argv. + Assert.Contains("$(cat /var/lib/openclaw/gateway-token)", script); + } } [Fact] @@ -205,16 +211,157 @@ public async Task WslGatewayCliPendingDeviceApprover_NonZeroExit_SurfacesStructu Assert.False(result.Success); Assert.Equal("operator_pending_approval_failed", result.ErrorCode); - Assert.Equal("Local gateway pending pairing approval CLI failed.", result.ErrorMessage); + Assert.Equal("Local gateway pending pairing approval CLI failed (preview stage).", result.ErrorMessage); + } + + // --- Bug 1 part 3 (two-stage approve, CLI v2026.5.3-1) regression tests --- + + [Fact] + public async Task WslGatewayCliPendingDeviceApprover_TwoStage_PreviewThenCommit_Succeeds() + { + // Stage 1 returns the v2026.5.3-1 preview shape with selected.requestId; stage 2 + // performs the actual approve and returns the gateway's mutation result. + var previewJson = "{\"selected\":{\"requestId\":\"57ccdbad-24a7-4750-8e5d-e92c5c497da0\"," + + "\"deviceId\":\"c5979c9c\"},\"approvalState\":{\"kind\":\"new-pairing\"," + + "\"requested\":{},\"approved\":null}," + + "\"approveCommand\":\"openclaw devices approve 57ccdbad-24a7-4750-8e5d-e92c5c497da0 --json\"," + + "\"requiresAuthFlags\":{\"token\":false,\"password\":false}}"; + var commitJson = "{\"requestId\":\"57ccdbad-24a7-4750-8e5d-e92c5c497da0\",\"device\":{\"deviceId\":\"c5979c9c\"}}"; + var runner = new RecordingWslRunner( + new WslCommandResult(0, previewJson, string.Empty), + new WslCommandResult(0, commitJson, string.Empty)); + var approver = new WslGatewayCliPendingDeviceApprover(runner, "/opt/openclaw/bin/openclaw"); + + var result = await approver.ApproveLatestAsync(new LocalGatewaySetupState + { + GatewayUrl = "ws://127.0.0.1:18789", + DistroName = "OpenClawGateway", + }); + + Assert.True(result.Success); + Assert.Equal(2, runner.RunInDistroCommands.Count); + + // Stage 1: preview (--latest, no requestId argv). + var stage1 = string.Join(" ", runner.RunInDistroCommands[0]); + Assert.Contains("--latest", stage1); + Assert.DoesNotContain("57ccdbad-24a7-4750-8e5d-e92c5c497da0", stage1); + + // Stage 2: commit (explicit requestId, no --latest). + var stage2 = string.Join(" ", runner.RunInDistroCommands[1]); + Assert.DoesNotContain("--latest", stage2); + Assert.Contains("'57ccdbad-24a7-4750-8e5d-e92c5c497da0'", stage2); + Assert.Contains("--json", stage2); + Assert.Contains("--token", stage2); + Assert.DoesNotContain("--url", stage2); + } + + [Fact] + public async Task WslGatewayCliPendingDeviceApprover_TwoStage_PreviewEmpty_NoPendingEntries() + { + // Stage 1 returns empty stdout (CLI prints "No pending device pairing requests" to + // stderr and exits — we observed exit-0 in the wild on v2026.5.3-1). Engine must + // see a distinct error code so it does not treat it as success and does not + // infinite-loop retrying the WS connect. + var runner = new RecordingWslRunner(new WslCommandResult(0, string.Empty, "No pending device pairing requests to approve")); + var approver = new WslGatewayCliPendingDeviceApprover(runner, "/opt/openclaw/bin/openclaw"); + + var result = await approver.ApproveLatestAsync(new LocalGatewaySetupState + { + GatewayUrl = "ws://127.0.0.1:18789", + DistroName = "OpenClawGateway", + }); + + Assert.False(result.Success); + Assert.Equal("no_pending_entries", result.ErrorCode); + Assert.Single(runner.RunInDistroCommands); // stage 2 must NOT have run. + } + + [Fact] + public async Task WslGatewayCliPendingDeviceApprover_TwoStage_CommitFails_SurfacesStructuredFailure() + { + var previewJson = "{\"selected\":{\"requestId\":\"abc-123\"}," + + "\"approveCommand\":\"openclaw devices approve abc-123 --json\"}"; + var runner = new RecordingWslRunner( + new WslCommandResult(0, previewJson, string.Empty), + new WslCommandResult(1, string.Empty, "unknown requestId")); + var approver = new WslGatewayCliPendingDeviceApprover(runner, "/opt/openclaw/bin/openclaw"); + + var result = await approver.ApproveLatestAsync(new LocalGatewaySetupState + { + GatewayUrl = "ws://127.0.0.1:18789", + DistroName = "OpenClawGateway", + }); + + Assert.False(result.Success); + Assert.Equal("operator_pending_approval_failed", result.ErrorCode); + Assert.Equal("unknown requestId", result.ErrorMessage); + Assert.Equal(2, runner.RunInDistroCommands.Count); + } + + [Fact] + public async Task WslGatewayCliPendingDeviceApprover_TwoStage_PreviewReturnsUnsafeRequestId_DoesNotRunCommit() + { + // Defense-in-depth: if the CLI ever returns a requestId containing shell + // metacharacters, refuse to interpolate it into a `bash -lc` script. + var previewJson = "{\"selected\":{\"requestId\":\"abc; rm -rf /\"}}"; + var runner = new RecordingWslRunner(new WslCommandResult(0, previewJson, string.Empty)); + var approver = new WslGatewayCliPendingDeviceApprover(runner, "/opt/openclaw/bin/openclaw"); + + var result = await approver.ApproveLatestAsync(new LocalGatewaySetupState + { + GatewayUrl = "ws://127.0.0.1:18789", + DistroName = "OpenClawGateway", + }); + + Assert.False(result.Success); + Assert.Equal("operator_pending_approval_failed", result.ErrorCode); + Assert.Single(runner.RunInDistroCommands); + } + + [Fact] + public void ParsePreviewJson_V20265_Shape_ReturnsRequestId() + { + var json = "{\"selected\":{\"requestId\":\"57ccdbad-24a7-4750-8e5d-e92c5c497da0\"}," + + "\"approveCommand\":\"openclaw devices approve 57ccdbad-24a7-4750-8e5d-e92c5c497da0 --json\"}"; + + var result = WslGatewayCliPendingDeviceApprover.ParsePreviewJson(json); + + Assert.True(result.Success); + Assert.Equal("57ccdbad-24a7-4750-8e5d-e92c5c497da0", result.RequestId); + } + + [Fact] + public void ParsePreviewJson_Empty_ReturnsNoPendingEntries() + { + var result = WslGatewayCliPendingDeviceApprover.ParsePreviewJson(string.Empty); + + Assert.False(result.Success); + Assert.Equal("no_pending_entries", result.ErrorCode); + } + + [Fact] + public void ParsePreviewJson_OkFalse_ReturnsApprovalFailure() + { + var result = WslGatewayCliPendingDeviceApprover.ParsePreviewJson("{\"ok\":false,\"error\":\"boom\"}"); + + Assert.False(result.Success); + Assert.Equal("operator_pending_approval_failed", result.ErrorCode); + Assert.Equal("boom", result.ErrorMessage); } private sealed class RecordingWslRunner : IWslCommandRunner { - private readonly WslCommandResult _result; + private readonly Queue _results; + private readonly WslCommandResult _fallback; public string? LastDistroName { get; private set; } public IReadOnlyList? LastCommand { get; private set; } + public List> RunInDistroCommands { get; } = new(); - public RecordingWslRunner(WslCommandResult result) => _result = result; + public RecordingWslRunner(params WslCommandResult[] results) + { + _results = new Queue(results); + _fallback = results.Length > 0 ? results[^1] : new WslCommandResult(0, string.Empty, string.Empty); + } public Task RunAsync(IReadOnlyList arguments, CancellationToken cancellationToken = default) => Task.FromResult(new WslCommandResult(0, string.Empty, string.Empty)); @@ -232,7 +379,9 @@ public Task RunInDistroAsync(string name, IReadOnlyList 0 ? _results.Dequeue() : _fallback; + return Task.FromResult(next); } } From 05f7be0ff5ea27ee934be2c8b590d75626df3101 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 4 May 2026 23:11:11 -0700 Subject: [PATCH 22/56] fix(setup): retry stage-1 approve preview on first-call race + surface stderr in failure (Bug 1 part 4) Bostick-11 Round-2 Path B drive surfaced a deterministic race: the engine's first `--token`-authenticated call into the in-distro CLI in Phase 12 triggers an internal Linux-operator auto-bootstrap inside the gateway. The bootstrap completes successfully (linux operator entry IS persisted to paired.json) but the CLI process that drove it exits non-zero; a fresh process invocation made hundreds of ms later succeeds because the internal operator is now pre-paired. Fix: - WslGatewayCliPendingDeviceApprover.ApproveLatestAsync retries stage 1 once on first failure with a 750ms backoff (configurable; tests use TimeSpan.Zero). - On final stage-1 failure, both attempts' stderr (each truncated to 1 KB) are surfaced in PendingDeviceApprovalResult.ErrorMessage so future regressions are diagnosable from setup-state.json without digging tray.log. Tests added/updated: - Stage1FailsThenSucceeds_OverallSuccess (retry path) - Stage1FailsTwice_SurfacesBothStderrs (structured failure with stderr) - TruncateStderr_RespectsCap_AndAppendsTruncationMarker - Existing NonZeroExit_SurfacesStructuredFailureCode updated to assert stderr surfacing Validation: build.ps1 green; Tray tests 505/505 passed; Shared tests 1158 passed + 22 skipped = 1180 baseline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../decisions/inbox/aaron-uninstall-plan.md | 300 ++++++++++++++++++ .../LocalGatewaySetup/LocalGatewaySetup.cs | 101 +++++- .../OperatorPairingApprovalTests.cs | 85 ++++- 3 files changed, 474 insertions(+), 12 deletions(-) create mode 100644 .squad/decisions/inbox/aaron-uninstall-plan.md diff --git a/.squad/decisions/inbox/aaron-uninstall-plan.md b/.squad/decisions/inbox/aaron-uninstall-plan.md new file mode 100644 index 00000000..f0ebd6f5 --- /dev/null +++ b/.squad/decisions/inbox/aaron-uninstall-plan.md @@ -0,0 +1,300 @@ +# Robust End-User Uninstall Plan — OpenClaw Windows + WSL Gateway + +**Author:** Aaron (Backend / Infra) — planning only, no code in this doc +**Date:** 2026-05-04T19:05:00-07:00 +**Worktree:** `..\openclaw-wsl-gateway-clean` @ `feat/wsl-gateway-clean` (16 commits since `871b959`) +**Requested by:** Mike Harsh — quote: *"We need to have a robust uninstall plan."* + +> Phase 7 reset script (`scripts/reset-openclaw-wsl-validation-state.ps1`) is a +> **dev/test** cleanup that nukes `OpenClawGateway` for re-validation. This +> document designs the **end-user-facing** uninstall: what happens when a user +> installs OpenClaw on Windows and later wants it gone cleanly. + +--- + +## 1. Scope — Tiers of "uninstall" + +Recommend supporting **two tiers** with a single user choice point: + +| Tier | What it removes | Default? | +|------|-----------------|----------| +| **App-only (soft)** | Tray app binaries + Start Menu + Add/Remove Programs entry + autostart registry value. **Leaves** WSL distro + identity dirs intact, so a reinstall picks up where the user left off. | No — opt-in for "I'm reinstalling" | +| **Full uninstall** | App-only **plus** `OpenClawGateway` WSL distro **plus** `%APPDATA%\OpenClawTray` and `%LOCALAPPDATA%\OpenClawTray`. | **Yes — default** | + +Explicitly **out of scope** ("nuclear"): + +- Uninstalling the WSL platform itself (`Microsoft.WSL` / `wsl --uninstall`). Other distros may depend on it. +- Removing Ubuntu-24.04 from Microsoft Store (`Canonical.Ubuntu.2404` APPX). It's a launcher; harmless to leave. +- Touching `%USERPROFILE%\.wslconfig` (per Craig — never written by us; never removed by us). +- Per-machine state for *other* users (we are per-user, see §6). + +**UX recommendation:** the Windows Add/Remove Programs flow always defaults to **Full**, and presents one checkbox: *"Keep my OpenClaw WSL data and identity in case I reinstall"* (unchecked by default). Power users / IT can pass a flag to the script for unattended app-only. + +--- + +## 2. Inventory — everything we install or touch + +Cited from the clean worktree. All paths assume **per-user** install (recommended — see §5 / open question Q1). + +### 2.1 Windows components + +| Artifact | Path / key | Created by | +|---|---|---| +| Tray app binaries | TBD by packaging — likely `%LOCALAPPDATA%\Programs\OpenClawTray\` (per-user MSIX/MSI) | Installer | +| Add/Remove Programs entry | `HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\OpenClawTray` (or MSIX package registration) | Installer | +| Start Menu shortcut | `%APPDATA%\Microsoft\Windows\Start Menu\Programs\OpenClaw\` | Installer | +| Per-user data dir (`DataPath`) | `%LOCALAPPDATA%\OpenClawTray\` — settings, logs, run.marker, crash.log, exec approvals, diagnostics jsonl | `App.xaml.cs:151-164` | +| Per-user identity dir (`IdentityDataPath`) | `%APPDATA%\OpenClawTray\` — operator + node `DeviceIdentity` (tokens), policy | `App.xaml.cs:159-162` | +| WSL instance VHD location | `%LOCALAPPDATA%\OpenClawTray\wsl\OpenClawGateway\` (default from `LocalGatewaySetup.ResolveInstallLocation`, line 644) | `wsl --install ... --location` | +| Autostart entry | `HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run\OpenClawTray` (REG_SZ → exe path) | `Services/AutoStartManager.cs:11-12` (only if user enabled it) | +| Single-instance mutex | Process-scoped, dies with process — no cleanup needed | +| Firewall rules | **None** — gateway is loopback-only (port 18789, Craig-confirmed) | +| Scheduled tasks | **None** in current design | +| `HKLM` entries | **None** in per-user model | + +### 2.2 WSL distro components + +| Artifact | What | How removed | +|---|---|---| +| Distro registration | `OpenClawGateway` in `wsl --list` | `wsl --unregister OpenClawGateway` | +| VHD on disk | `ext4.vhdx` under `%LOCALAPPDATA%\OpenClawTray\wsl\OpenClawGateway\` | `wsl --unregister` deletes it; we then verify the dir is empty and remove the empty parent dirs | +| `~\.wslconfig` global | **NEVER touched** by install — never touched by uninstall (Craig) | + +### 2.3 In-distro Linux components + +All disappear with `wsl --unregister OpenClawGateway` — no separate cleanup needed. For the record (so support can reason about state pre-uninstall): + +- `/opt/openclaw/...` — upstream Linux installer payload +- `/etc/systemd/user/openclaw-gateway.service` — gateway unit (`LocalGatewaySetup.cs:864`) +- `loginctl enable-linger openclaw` — set on `openclaw` user (`LocalGatewaySetup.cs:722`) +- `/etc/wsl.conf` — `systemd=true / interop=false / appendWindowsPath=false / default=openclaw` (`LocalGatewaySetup.cs:704, 761-764`) +- `/etc/wsl-distribution.conf` — shortcut/terminal disabled (`LocalGatewaySetup.cs:718`) +- `/home/openclaw/...` — gateway config, pairing store, logs + +### 2.4 Backup-worthy state (pre-delete) + +Mirroring Phase 7's `Backup-Directory` model — copy *before* remove: + +- `%APPDATA%\OpenClawTray\` (operator + node tokens, identity) +- `%LOCALAPPDATA%\OpenClawTray\` (settings, logs) +- **NOT** the WSL VHD — too large, and `wsl --unregister` is the only safe way to release it; users wanting that backup should `wsl --export OpenClawGateway ` themselves before uninstalling. (We may surface this as an optional pre-step in the script — see §5.) + +Tokens are not durable across reinstalls anyway: a fresh install re-pairs from a fresh setup-code, so the backup is mainly for support diagnostics, not for "restore in place." + +--- + +## 3. Order of operations (safe sequence) + +``` +1. Detect & stop tray process (release file locks on DataPath) + Stop-Process -Id # exact PID, never -Name + Wait for exit (<= 10s); if still alive → escalate to terminate. + +2. Stop in-distro service first (clean shutdown; release port 18789) + wsl -d OpenClawGateway -u openclaw -- systemctl --user stop openclaw-gateway.service + (best-effort; ignore failure — terminate handles hangs) + +3. Terminate the distro (release VHD lock, NEVER --shutdown) + wsl --terminate OpenClawGateway + +4. Backup identity + data (Phase-7-style copy before remove) + Copy %APPDATA%\OpenClawTray\ → %TEMP%\OpenClawUninstallBackup-\appdata-OpenClawTray\ + Copy %LOCALAPPDATA%\OpenClawTray\ → %TEMP%\OpenClawUninstallBackup-\localappdata-OpenClawTray\ + Print backup path to user / installer log. + +5. Unregister the distro (deletes the VHD) + wsl --unregister OpenClawGateway + Verify: wsl --list --quiet does NOT contain OpenClawGateway. + +6. Remove identity + data dirs (after process is dead) + Remove-Item -LiteralPath %APPDATA%\OpenClawTray\ -Recurse -Force + Remove-Item -LiteralPath %LOCALAPPDATA%\OpenClawTray\ -Recurse -Force + (LocalAppData includes the now-empty wsl\OpenClawGateway\ instance dir.) + +7. Remove autostart (registry value only — exact key, not delete-tree) + Reg DELETE HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run /v OpenClawTray /f + (See AutoStartManager.cs:11-12. Per-user only.) + +8. Remove Start Menu shortcut (single dir under %APPDATA%\...\Programs\OpenClaw\) + +9. Uninstall the package LAST (so the running uninstaller, if invoked + from MSIX/MSI, doesn't yank itself first) + MSIX: Remove-AppxPackage + MSI: msiexec /x /qn + This removes Add/Remove Programs entry implicitly. +``` + +Key dependency reasons: + +- **Step 1 before 6:** tray process holds file locks on `DataPath\diagnostics-*.jsonl` and `run.marker`. +- **Step 2 before 3:** systemd stop is graceful; terminate is forced. Even though terminate handles hangs, a clean stop avoids a "service died" entry in the last log. +- **Step 3 before 5:** `wsl --unregister` will fail if the distro is still mounted (rare, but possible). +- **Step 4 before 5/6:** backup *first*. If anything fails partway through, the user has recovery material. +- **Step 9 last:** if the package uninstaller is the orchestrator, it must finish bookkeeping after the per-user state is gone. + +--- + +## 4. Safety gates (mirroring Phase 7 model) + +These are non-negotiable. Lifted from `scripts/reset-openclaw-wsl-validation-state.ps1` (lines 36, 151-158, 287, 348): + +1. **Hard-locked distro name.** `$script:OpenClawDistroName = "OpenClawGateway"`. No flag, no env var, no override path. `Assert-DestructiveTargetIsAllowed` runs first; refuses if the constant ever drifts. **The uninstaller must never accept a `-DistroName` argument** — same hard-lock as Phase 7. +2. **Backup before remove.** Every directory deletion is preceded by `Copy-Item -Recurse` to `%TEMP%\OpenClawUninstallBackup-\` (or `$BackupRoot` override). Print the path on stdout / installer UI. Backup retention: user's responsibility (we don't auto-purge; we don't write `%TEMP%\` cleanup hooks). +3. **No `wsl --shutdown`, ever.** Only `wsl --terminate OpenClawGateway` (Craig-confirmed in `.squad/decisions.md`). Shutdown would impact every other distro on the box. +4. **No `\\wsl$\` / `\\wsl.localhost\` for file ops.** All in-distro reads/writes route through `wsl bash -c ...` (per global Copilot rule + Phase 7 line 15). For uninstall the simplest path is "let `wsl --unregister` delete everything" — we never touch the distro filesystem during uninstall. +5. **Postcondition assertions** (Phase 7 lines 242-282 model). On exit (non-dry-run), throw if: + - any `OpenClaw*` process is still running + - `wsl --list --quiet` still contains `OpenClawGateway` + - `%APPDATA%\OpenClawTray\` exists + - `%LOCALAPPDATA%\OpenClawTray\` exists + - autostart registry value still present + - **Diff of `wsl --list --verbose` before vs after must show only `OpenClawGateway` removed.** Any other distro count change → throw. +6. **Dry-run by default for the standalone script.** `-ConfirmDestructive` flag required to actually remove (mirrors `-ConfirmDestructiveClean` in Phase 7 line 25). The MSIX/MSI uninstaller path skips dry-run because the package manager already gated user consent. +7. **Token / private-key redaction in logs.** Reuse `SecretRedactor.Redact` from `LocalGatewaySetup.cs:665`. Specifically: any uninstall log written to `%TEMP%\OpenClawUninstallBackup-\uninstall.log` must run identity dir contents through redaction *before* logging filenames or contents (filenames are usually fine; full-file echo is forbidden — we never echo identity files). +8. **No `Stop-Process -Name`.** Always exact PID via `Get-Process | Where-Object { $_.ProcessName -like 'OpenClaw*' }` then `Stop-Process -Id`. Same rule as the global Copilot config. + +**Where uninstall differs from Phase 7 reset:** + +| Aspect | Phase 7 reset | End-user uninstall | +|---|---|---| +| Goal | Re-validate from clean slate | Permanent removal | +| Default | Dry-run | Confirmed (Add/Remove Programs has already confirmed) | +| Removes app binaries? | No (they stay; only state cleared) | **Yes** | +| Removes autostart reg key? | No | **Yes** | +| Removes Start Menu? | No | **Yes** | +| Backup? | Yes, to `artifacts\reset-backups\` | Yes, to `%TEMP%\OpenClawUninstallBackup-` | +| Touches WSL platform? | No | No (same — explicitly out of scope) | + +--- + +## 5. UX surfaces — how does the user trigger it? + +Recommendation for **first PR**: ship surface (a) and (c). Defer (b) and (d). + +| # | Surface | Ship in first PR? | Notes | +|---|---|---|---| +| (a) | **Windows Add/Remove Programs** (Settings → Apps → OpenClawTray → Uninstall) | **Yes** | Standard. The package manager invokes our uninstaller. This is what 95% of users will use. | +| (b) | Tray menu "Uninstall OpenClaw…" | **No — follow-up** | Convenient but redundant with (a) and (c). Adds a code path that has to handle "uninstall from inside the running app" (self-deletion). | +| (c) | Standalone script `scripts\uninstall-openclaw.ps1` | **Yes** | Power users / IT / repair. Same primitive the package uninstaller calls. Defaults to dry-run. | +| (d) | Group Policy / MDM unattended (`/quiet` MSI flag, MSIX `Remove-AppxPackage` for all users) | **No — follow-up** | Needed for enterprise. Depends on packaging decision (see Q2). | + +Confirmation prompt model for surface (c) (script): + +``` +.\scripts\uninstall-openclaw.ps1 # dry-run, prints plan +.\scripts\uninstall-openclaw.ps1 -KeepWslData # app-only tier, dry-run +.\scripts\uninstall-openclaw.ps1 -ConfirmDestructive # full uninstall +.\scripts\uninstall-openclaw.ps1 -ConfirmDestructive -KeepWslData # app-only, real +.\scripts\uninstall-openclaw.ps1 -ConfirmDestructive -ExportDistroTo C:\path\OpenClawGateway.tar + # optional pre-backup of distro +``` + +The MSIX/MSI uninstaller hook calls the same script with `-ConfirmDestructive` (and `-KeepWslData` if the user checked the "keep my data" box). + +--- + +## 6. Edge cases + +| # | Case | Mitigation | +|---|---|---| +| E1 | WSL platform uninstalled / disabled out from under us | `wsl --list --quiet` returns non-zero or empty. Skip the distro steps; log "wsl unavailable, skipping distro removal." Continue with Windows-side cleanup. Postcondition for distro = "not present" (which is satisfied by absence). | +| E2 | User renamed `OpenClawGateway` to something else manually | We do **not** chase. The hard-lock is the safety property (§4.1). Surface a warning: "No distro named `OpenClawGateway` found — if you renamed it, unregister it manually with `wsl --unregister `." | +| E3 | Two parallel installs (somehow) created shared state | Per-user model means each user's `%LOCALAPPDATA%\OpenClawTray\` is independent. Distro is per-machine but only one `OpenClawGateway` can exist at a time. Not really achievable in practice. | +| E4 | Uninstall runs mid-pairing (in-flight bootstrap token) | Stop tray process first (step 1) — bootstrap token in memory dies with the process. Backup captures the on-disk identity files (which may not yet contain the new token, harmless). | +| E5 | Another tool is using `wsl.exe` when we unregister | `wsl --unregister` will fail with `WSL_E_DISTRO_NOT_RUNNING` or busy. Retry once after 2s. If still failing, surface error with `aka.ms/wsllogs` link, and **stop** — do NOT proceed to delete identity dirs (user may want to retry without losing tokens). | +| E6 | systemd stop hangs in distro | `wsl --terminate OpenClawGateway` resolves it (kills the VM). We use a 5s timeout on the systemd stop call before falling through to terminate. | +| E7 | Per-user vs per-machine | **Recommend per-user.** Per-machine would require admin elevation and HKLM cleanup, and would force *every* user on the box to repair WSL state. See open question Q1. If we go per-machine: HKLM uninstall key + run uninstaller as SYSTEM, and **explicitly skip** per-user `%APPDATA%`/`%LOCALAPPDATA%` cleanup (we cannot reach other profiles safely). | +| E8 | User reinstalls after app-only uninstall | Tray app starts up, finds `IdentityDataPath` populated and distro registered → skips setup, reconnects via `auth.deviceToken` (per `.squad/decisions.md` "Store role-specific credentials"). This is the *whole point* of supporting tier 1. | +| E9 | Uninstall runs while gateway is still pairing a node | Same as E4 — stop tray first, kill systemd service, terminate distro. Pairing is idempotent: a fresh install re-pairs. | +| E10 | `%LOCALAPPDATA%\OpenClawTray\wsl\OpenClawGateway\` contains the VHD that's still locked | We never `Remove-Item` that subtree directly. `wsl --unregister` is the only deleter. After unregister, `Remove-Item %LOCALAPPDATA%\OpenClawTray\` is safe (the wsl subdir is empty). | +| E11 | User's `%TEMP%` is on a constrained drive (not enough space for backup) | Allow `-BackupRoot ` override on the script (mirrors Phase 7's `$BackupRoot` parameter at line 20-22). If backup fails, **abort before destructive steps** — never delete without backup unless user passed `-NoBackup` (advanced flag, undocumented in default UX). | + +--- + +## 7. Testing strategy + +Two kinds of tests: + +### 7.1 Unit / integration (in `tests/OpenClaw.Tray.Tests/`) + +- New filter `LocalUninstallTests`: pure logic for the order-of-operations planner (no shell calls). Mocks `IFileSystem` / `IWslRunner` / `IRegistry`. Verifies: + - distro name is exactly `OpenClawGateway` and never accepts override + - backup happens before remove for every directory + - postcondition checks throw on each missing condition + - `KeepWslData` skips steps 2-5 but keeps 1, 6-9 +- Reuse `SecretRedactor` tests for log-redaction surface. + +### 7.2 Validation script (analog to `validate-wsl-gateway.ps1`) + +Either: + +- **(a) New scenario** in `scripts\validate-wsl-gateway.ps1`: `-Scenario Uninstall`. Existing scenarios: `PreflightOnly` / `UpstreamInstall` / `FreshMachine` / `Recreate`. Add `Uninstall` that runs after `UpstreamInstall` succeeds and asserts postconditions. +- **(b) Separate script** `scripts\test-uninstall.ps1`. Cleaner separation, but duplicates harness boilerplate. + +**Recommend (a)** — keeps one harness, one report format, one set of redaction rules. + +Postconditions to verify (subset already enforced inside the uninstaller — re-checked in the test from the outside): + +- `wsl --list --quiet` does NOT contain `OpenClawGateway` +- `Test-Path %APPDATA%\OpenClawTray` → False +- `Test-Path %LOCALAPPDATA%\OpenClawTray` → False +- `(Get-Process | Where-Object ProcessName -like 'OpenClaw*').Count -eq 0` +- `Get-ItemProperty HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run -Name OpenClawTray` errors +- Add/Remove Programs query returns no `OpenClawTray` entry +- **Diff of `wsl --list --verbose` before-install vs after-uninstall is empty** (every other distro untouched — primary safety property) +- Backup dir exists at `%TEMP%\OpenClawUninstallBackup-\` and contains both `appdata-OpenClawTray\` and `localappdata-OpenClawTray\` subtrees + +--- + +## 8. Implementation scope — which PR? + +**Recommend (B): immediate follow-up PR, not on `feat/wsl-gateway-clean`.** + +Reasoning: + +- `feat/wsl-gateway-clean` is **plan-complete and reviewed** (Phase 8 APPROVED, 16 commits, +35 tests, 0 regressions). Mike's three blockers are closed. It is in PR-ready state. Adding uninstall now risks gating that PR on net-new design, packaging decisions (see open questions), and a fresh Kranz/Bostick round. +- Uninstall depends on **packaging decisions Mike has not made yet** (Q1, Q2 below). The shape of `scripts\uninstall-openclaw.ps1` differs materially between MSIX and MSI; the `Add/Remove Programs` hook differs even more. Building before deciding wastes work. +- The clean PR only adds the *install* path. Shipping uninstall as PR #2 mirrors how the team already works (Phase 7 reset shipped *after* Phase 6 validation). + +**However**, two things should land in PR #1 as a forward bridge: + +1. A short note in `docs/wsl-owner-validation.md` (or new `docs/wsl-owner-uninstall.md`) saying: "End-user uninstall is tracked as a follow-up PR; for now, validators use `scripts/reset-openclaw-wsl-validation-state.ps1` to clear state." +2. A `// TODO(uninstall): see .squad/decisions/inbox/aaron-uninstall-plan.md` comment in `LocalGatewaySetup.cs` near `ResolveInstallLocation` so we don't lose the design when it's time to implement. + +If Mike disagrees and wants (A), the new commits on `feat/wsl-gateway-clean` would be: + +- `feat(scripts): port uninstall-openclaw.ps1` (new, ~400 LOC mirroring reset script structure) +- `feat(tray): UninstallPlanner + IWslRunner shim` (new tests) +- `feat(scripts): add Uninstall scenario to validate-wsl-gateway.ps1` +- `docs(wsl): add wsl-owner-uninstall.md` + +That's 4 new commits and ~50 new tests. Doable, but bumps the PR from "ship now" to "ship in 1-2 days." + +--- + +## Open questions for Mike + +Please answer before implementation kicks off: + +| # | Question | Why it matters | +|---|---|---| +| Q1 | **Per-user or per-machine install?** Recommend per-user (HKCU, `%LOCALAPPDATA%`, no admin needed for install or uninstall). | Drives whether HKLM keys exist and whether uninstall needs elevation. Affects edge-case E7. | +| Q2 | **MSIX, MSI, or NSIS/Inno setup?** I assume MSIX (modern, per-user friendly, integrates with Add/Remove Programs and Microsoft Store path). | Drives the `Remove-AppxPackage` vs `msiexec /x` shape and whether we get free Add/Remove Programs registration. | +| Q3 | **Does uninstall offer "keep my WSL data for reinstall"?** Recommend **yes**, unchecked by default. | Tier 1 vs Tier 2. Affects UX dialog and script flag. | +| Q4 | **Tray menu "Uninstall…" item in v1?** Recommend **no** (defer). | Avoids self-uninstall complexity in first cut. | +| Q5 | **`wsl --export OpenClawGateway` pre-backup option?** Recommend **yes, opt-in via `-ExportDistroTo `** for power users. | Lets advanced users keep a portable distro snapshot before uninstall. Default off (large file, slow). | +| Q6 | **Telemetry on uninstall?** (e.g., a single ping "uninstall completed" before the binaries go away.) | Privacy / product question. Default off unless Mike says otherwise. | +| Q7 | **Where does the standalone script live post-install?** I assume `%LOCALAPPDATA%\Programs\OpenClawTray\uninstall.ps1` or invoked via `OpenClawTray.exe --uninstall`. | Affects discoverability and how IT scripts call it. | +| Q8 | **Backup retention?** I assume we never auto-purge `%TEMP%\OpenClawUninstallBackup-*\` (Windows handles `%TEMP%` cleanup). | Confirm — alternative is a 30-day self-clean. | + +--- + +## Cross-references + +- Inspiration: `scripts/reset-openclaw-wsl-validation-state.ps1` (Phase 7, commit `dbd7708`) — safety gate model, backup-before-remove, hard-locked distro constant. +- Decisions baseline: `.squad/decisions.md` — "Dedicated Ubuntu WSL instance", "Craig Loewen's WSL Answers", "winget Research Consolidated." +- Data path constants: `src/OpenClaw.Tray.WinUI/App.xaml.cs:151-164`. +- Autostart key: `src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs:11-12`. +- Distro install location: `src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs:639-649`. +- Service / linger: `LocalGatewaySetup.cs:704, 718, 722, 864`. +- Redactor: `LocalGatewaySetup.cs:665` (uses `SecretRedactor.Redact`). diff --git a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs index fda139c0..53fe5c5a 100644 --- a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs +++ b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs @@ -1656,13 +1656,32 @@ public interface IPendingDeviceApprover public sealed class WslGatewayCliPendingDeviceApprover : IPendingDeviceApprover { + // Bug 1 part 4 (CLI v2026.5.3-1): the engine's first `--token`-authenticated call into + // the in-distro CLI triggers an internal Linux-operator auto-bootstrap inside the + // gateway. The bootstrap completes successfully (the linux operator entry IS persisted + // to `paired.json`) but the CLI process that drove it exits non-zero — it can't recover + // its own current invocation. A fresh process invocation made ~hundreds of ms later + // succeeds because the internal operator is now pre-paired. We retry stage 1 ONCE with + // a small backoff to ride out that race. See Bostick-11 Round-2 Path B drive + // (`bostick-bug1-reverify.md` "Path B re-drive — Round 2") for the deterministic + // reproduction and gateway journal evidence. + public const int MaxStderrSurfaceLength = 1024; + private static readonly TimeSpan DefaultStage1RetryDelay = TimeSpan.FromMilliseconds(750); + private readonly IWslCommandRunner _wsl; private readonly string _commandName; + private readonly TimeSpan _stage1RetryDelay; public WslGatewayCliPendingDeviceApprover(IWslCommandRunner wsl, string commandName = "openclaw") + : this(wsl, commandName, DefaultStage1RetryDelay) + { + } + + public WslGatewayCliPendingDeviceApprover(IWslCommandRunner wsl, string commandName, TimeSpan stage1RetryDelay) { _wsl = wsl; _commandName = commandName; + _stage1RetryDelay = stage1RetryDelay; } public async Task ApproveLatestAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) @@ -1686,21 +1705,19 @@ public async Task ApproveLatestAsync(LocalGatewaySe // actually calls `device.pair.approve` (or the local pairing fallback) and // mutates `paired.json`. // + // Stage 1 is retried ONCE on first failure (Bug 1 part 4 race — see class-level + // comment) with a small backoff so the second attempt benefits from any internal + // operator pairing the failed first attempt provoked as a side effect. + // // We continue to drop `--url` (Bug 1 part 2 / CLI ensureExplicitGatewayAuth guard) // and dereference the gateway token inside the shell so it never lands on argv. - var stage1 = await _wsl.RunInDistroAsync( - state.DistroName, - ["bash", "-lc", BuildPreviewScript()], - cancellationToken); - if (!stage1.Success) + var stage1 = await RunStage1WithRetryAsync(state, cancellationToken); + if (!stage1.Result.Success) { - return new PendingDeviceApprovalResult( - false, - "operator_pending_approval_failed", - "Local gateway pending pairing approval CLI failed (preview stage)."); + return BuildStage1Failure(stage1.FirstStderr, stage1.Result.StandardError); } - var preview = ParsePreviewJson(stage1.StandardOutput); + var preview = ParsePreviewJson(stage1.Result.StandardOutput); if (!preview.Success) { return new PendingDeviceApprovalResult(false, preview.ErrorCode, preview.ErrorMessage); @@ -1730,6 +1747,70 @@ public async Task ApproveLatestAsync(LocalGatewaySe return ParseApproveJson(stage2.StandardOutput); } + private async Task RunStage1WithRetryAsync(LocalGatewaySetupState state, CancellationToken cancellationToken) + { + var first = await _wsl.RunInDistroAsync( + state.DistroName, + ["bash", "-lc", BuildPreviewScript()], + cancellationToken); + if (first.Success) + { + return new Stage1Outcome(first, FirstStderr: null); + } + + // Bug 1 part 4: the failed first call may itself have caused the gateway to + // auto-pair the in-distro internal Linux operator as a side effect. A second + // invocation made shortly after typically succeeds. Wait briefly, then retry once. + if (_stage1RetryDelay > TimeSpan.Zero) + { + try + { + await Task.Delay(_stage1RetryDelay, cancellationToken); + } + catch (TaskCanceledException) + { + return new Stage1Outcome(first, FirstStderr: first.StandardError); + } + } + + var second = await _wsl.RunInDistroAsync( + state.DistroName, + ["bash", "-lc", BuildPreviewScript()], + cancellationToken); + return new Stage1Outcome(second, FirstStderr: first.StandardError); + } + + private static PendingDeviceApprovalResult BuildStage1Failure(string? firstStderr, string? lastStderr) + { + // Bug 1 part 4 diagnosability: surface the captured stderr from BOTH attempts so + // future regressions in this race-prone area do not require digging into tray.log. + const string baseMessage = "Local gateway pending pairing approval CLI failed (preview stage)."; + var first = TruncateStderr(firstStderr); + var last = TruncateStderr(lastStderr); + + var sb = new StringBuilder(baseMessage); + if (!string.IsNullOrEmpty(first)) + { + sb.Append(" stage1.attempt1.stderr=").Append(first); + } + if (!string.IsNullOrEmpty(last) && !string.Equals(last, first, StringComparison.Ordinal)) + { + sb.Append(" stage1.attempt2.stderr=").Append(last); + } + + return new PendingDeviceApprovalResult(false, "operator_pending_approval_failed", sb.ToString()); + } + + public static string? TruncateStderr(string? stderr) + { + if (string.IsNullOrWhiteSpace(stderr)) return null; + var trimmed = stderr.Trim(); + if (trimmed.Length <= MaxStderrSurfaceLength) return trimmed; + return trimmed.Substring(0, MaxStderrSurfaceLength) + "…[truncated]"; + } + + private readonly record struct Stage1Outcome(WslCommandResult Result, string? FirstStderr); + private string BuildPreviewScript() => string.Join(" ", new[] { "set -euo pipefail;", diff --git a/tests/OpenClaw.Tray.Tests/OperatorPairingApprovalTests.cs b/tests/OpenClaw.Tray.Tests/OperatorPairingApprovalTests.cs index 07ad3a6e..dc7e9057 100644 --- a/tests/OpenClaw.Tray.Tests/OperatorPairingApprovalTests.cs +++ b/tests/OpenClaw.Tray.Tests/OperatorPairingApprovalTests.cs @@ -201,7 +201,7 @@ public async Task WslGatewayCliPendingDeviceApprover_NonZeroExit_SurfacesStructu + "Fix: pass --token *** --password *** gatewayToken in tools).\n" + " at ensureExplicitGatewayAuth (.../call-BCpe65RR.js:148:8)"; var runner = new RecordingWslRunner(new WslCommandResult(1, string.Empty, stderr)); - var approver = new WslGatewayCliPendingDeviceApprover(runner, "/opt/openclaw/bin/openclaw"); + var approver = new WslGatewayCliPendingDeviceApprover(runner, "/opt/openclaw/bin/openclaw", TimeSpan.Zero); var result = await approver.ApproveLatestAsync(new LocalGatewaySetupState { @@ -211,7 +211,13 @@ public async Task WslGatewayCliPendingDeviceApprover_NonZeroExit_SurfacesStructu Assert.False(result.Success); Assert.Equal("operator_pending_approval_failed", result.ErrorCode); - Assert.Equal("Local gateway pending pairing approval CLI failed (preview stage).", result.ErrorMessage); + // Bug 1 part 4: stage-1 stderr is now surfaced for diagnosability, AND stage 1 is + // retried once on first failure. With the same stderr returned on both attempts, + // we surface the prefix + attempt-1 stderr only (attempt-2 is suppressed when equal). + Assert.NotNull(result.ErrorMessage); + Assert.StartsWith("Local gateway pending pairing approval CLI failed (preview stage).", result.ErrorMessage); + Assert.Contains("stage1.attempt1.stderr=", result.ErrorMessage!); + Assert.Contains("ensureExplicitGatewayAuth", result.ErrorMessage!); } // --- Bug 1 part 3 (two-stage approve, CLI v2026.5.3-1) regression tests --- @@ -349,6 +355,81 @@ public void ParsePreviewJson_OkFalse_ReturnsApprovalFailure() Assert.Equal("boom", result.ErrorMessage); } + // --- Bug 1 part 4 (first-call race retry + stderr surfacing) regression tests --- + + [Fact] + public async Task WslGatewayCliPendingDeviceApprover_TwoStage_Stage1FailsThenSucceeds_OverallSuccess() + { + // Bug 1 part 4 race: the engine's first --token call into the in-distro CLI + // triggers an internal Linux-operator auto-bootstrap that exits the CLI process + // non-zero. A second invocation made shortly after succeeds because the internal + // operator is now pre-paired. Approver retries stage 1 once on first failure. + var previewJson = "{\"selected\":{\"requestId\":\"81ff1b4c-ff71-4432-99c2-54b6b214982d\"}}"; + var commitJson = "{\"requestId\":\"81ff1b4c-ff71-4432-99c2-54b6b214982d\",\"device\":{}}"; + var runner = new RecordingWslRunner( + new WslCommandResult(1, string.Empty, "auto-bootstrap pairing in progress"), + new WslCommandResult(0, previewJson, string.Empty), + new WslCommandResult(0, commitJson, string.Empty)); + var approver = new WslGatewayCliPendingDeviceApprover(runner, "/opt/openclaw/bin/openclaw", TimeSpan.Zero); + + var result = await approver.ApproveLatestAsync(new LocalGatewaySetupState + { + GatewayUrl = "ws://127.0.0.1:18789", + DistroName = "OpenClawGateway", + }); + + Assert.True(result.Success); + Assert.Equal(3, runner.RunInDistroCommands.Count); + // Both stage-1 attempts must use --latest; stage 2 must contain the requestId. + Assert.Contains("--latest", string.Join(" ", runner.RunInDistroCommands[0])); + Assert.Contains("--latest", string.Join(" ", runner.RunInDistroCommands[1])); + Assert.Contains("'81ff1b4c-ff71-4432-99c2-54b6b214982d'", string.Join(" ", runner.RunInDistroCommands[2])); + } + + [Fact] + public async Task WslGatewayCliPendingDeviceApprover_TwoStage_Stage1FailsTwice_SurfacesBothStderrs() + { + var firstStderr = "first attempt: bootstrap pairing in progress"; + var secondStderr = "second attempt: gateway returned 500"; + var runner = new RecordingWslRunner( + new WslCommandResult(1, string.Empty, firstStderr), + new WslCommandResult(2, string.Empty, secondStderr)); + var approver = new WslGatewayCliPendingDeviceApprover(runner, "/opt/openclaw/bin/openclaw", TimeSpan.Zero); + + var result = await approver.ApproveLatestAsync(new LocalGatewaySetupState + { + GatewayUrl = "ws://127.0.0.1:18789", + DistroName = "OpenClawGateway", + }); + + Assert.False(result.Success); + Assert.Equal("operator_pending_approval_failed", result.ErrorCode); + Assert.NotNull(result.ErrorMessage); + Assert.StartsWith("Local gateway pending pairing approval CLI failed (preview stage).", result.ErrorMessage); + Assert.Contains("stage1.attempt1.stderr=" + firstStderr, result.ErrorMessage!); + Assert.Contains("stage1.attempt2.stderr=" + secondStderr, result.ErrorMessage!); + // Stage 1 ran twice; stage 2 must NOT have run. + Assert.Equal(2, runner.RunInDistroCommands.Count); + } + + [Fact] + public void TruncateStderr_RespectsCap_AndAppendsTruncationMarker() + { + var huge = new string('x', WslGatewayCliPendingDeviceApprover.MaxStderrSurfaceLength + 200); + + var truncated = WslGatewayCliPendingDeviceApprover.TruncateStderr(huge); + + Assert.NotNull(truncated); + Assert.True(truncated!.Length <= WslGatewayCliPendingDeviceApprover.MaxStderrSurfaceLength + "…[truncated]".Length); + Assert.EndsWith("…[truncated]", truncated); + + var small = WslGatewayCliPendingDeviceApprover.TruncateStderr("short"); + Assert.Equal("short", small); + + Assert.Null(WslGatewayCliPendingDeviceApprover.TruncateStderr(null)); + Assert.Null(WslGatewayCliPendingDeviceApprover.TruncateStderr(" \r\n ")); + } + private sealed class RecordingWslRunner : IWslCommandRunner { private readonly Queue _results; From f2dec42c85b6c869f83a25727bb9e74bd33cf7e7 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 4 May 2026 23:42:19 -0700 Subject: [PATCH 23/56] fix(setup): read gateway token in C# and interpolate as shell literal; surface stdout (Bug 1 part 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bostick-11 Round-3 (commit 05f7be0) proved the part-4 retry IS firing but BOTH stage-1 attempts still exit non-zero with EMPTY stderr in the engine's invocation context. The IDENTICAL script run manually via wsl -- bash -lc