From bd8ab21e0a31af98b8fcd3e45dc38bb4d65ef4e7 Mon Sep 17 00:00:00 2001 From: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:23:19 +0100 Subject: [PATCH 1/6] fix(companion): require live Tailscale readiness for dashboard auth --- .../GatewayConnectionManager.cs | 59 ++ src/OpenClaw.Connection/GatewayRecord.cs | 8 + .../GatewayTailscaleAuthUpgrade.cs | 539 ++++++++++++ .../IGatewayConnectionManager.cs | 9 + src/OpenClaw.SetupEngine/PairOperatorStep.cs | 12 + .../TailscaleSetupSteps.cs | 18 +- .../Capabilities/AppCapability.cs | 11 +- src/OpenClaw.Shared/ChannelsSnapshot.cs | 7 + src/OpenClaw.Shared/IOperatorGatewayClient.cs | 2 + src/OpenClaw.Shared/OpenClawGatewayClient.cs | 16 +- .../TailscaleServeStatusPolicy.cs | 209 +++++ .../App.CapabilityHandlers.cs | 31 +- src/OpenClaw.Tray.WinUI/App.xaml.cs | 70 +- .../Helpers/GatewayDashboardUrlBuilder.cs | 13 +- .../Pages/ConnectionPage.xaml.cs | 81 +- .../GatewayTailscaleAuthLiveVerifierTests.cs | 431 ++++++++++ .../GatewayTailscaleAuthUpgradeTests.cs | 780 ++++++++++++++++++ .../Setup/SetupAndConnectTests.cs | 11 +- .../SetupStepsTests.cs | 69 ++ .../AppCapabilityTests.cs | 39 + .../OpenClawGatewayClientTests.cs | 86 +- .../AppRefactorContractTests.cs | 27 + .../ConnectionPageTailscaleRecoveryTests.cs | 47 ++ .../GatewayDashboardUrlBuilderTests.cs | 17 + 24 files changed, 2563 insertions(+), 29 deletions(-) create mode 100644 src/OpenClaw.Connection/GatewayTailscaleAuthUpgrade.cs create mode 100644 src/OpenClaw.Shared/TailscaleServeStatusPolicy.cs create mode 100644 tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthLiveVerifierTests.cs create mode 100644 tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthUpgradeTests.cs diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs index f2c57556a..8f49e95fd 100644 --- a/src/OpenClaw.Connection/GatewayConnectionManager.cs +++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs @@ -56,6 +56,7 @@ public sealed class GatewayConnectionManager : private readonly ICredentialResolver _credentialResolver; private readonly IGatewayClientFactory _clientFactory; private readonly GatewayRegistry _registry; + private readonly IGatewayTailscaleAuthLiveVerifier _tailscaleAuthLiveVerifier; private readonly IOpenClawLogger _logger; private readonly IDeviceIdentityStore? _identityStore; private readonly INodeConnector? _nodeConnector; @@ -132,6 +133,8 @@ public GatewayConnectionManager( _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); _registry = registry ?? throw new ArgumentNullException(nameof(registry)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _tailscaleAuthLiveVerifier = new GatewayTailscaleAuthLiveVerifier( + new WslExeCommandRunner(_logger)); _identityStore = identityStore; _nodeConnector = nodeConnector; _tunnelManager = tunnelManager; @@ -203,6 +206,62 @@ public GatewayConnectionManager( public IOperatorGatewayClient? OperatorClient => _activeLifecycle?.DataClient; /// Internal access to the concrete client for auto-approve and other manager-internal operations. internal OpenClawGatewayClient? ConcreteOperatorClient => _activeLifecycle?.DataClient; + + public async Task EnableTailscaleDashboardAuthAsync( + string gatewayId, + CancellationToken cancellationToken = default) + { + await _transitionSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!string.Equals(_activeGatewayRecordId, gatewayId, StringComparison.Ordinal)) + return new(GatewayTailscaleAuthUpgradeOutcome.NotActive); + + var client = OperatorClient; + if (client is null) + return new(GatewayTailscaleAuthUpgradeOutcome.NotConnected); + + var service = new GatewayTailscaleAuthUpgradeService(_registry); + return await service.EnableAsync( + gatewayId, + new GatewayTailscaleAuthConfigClientAdapter(client), + cancellationToken) + .ConfigureAwait(false); + } + finally + { + _transitionSemaphore.Release(); + } + } + + public async Task RevalidateTailscaleDashboardAuthAsync( + string gatewayId, + CancellationToken cancellationToken = default) + { + await _transitionSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!string.Equals(_activeGatewayRecordId, gatewayId, StringComparison.Ordinal)) + return false; + + var client = OperatorClient; + if (client is null) + return false; + + var service = new GatewayTailscaleAuthUpgradeService( + _registry, + _tailscaleAuthLiveVerifier); + return await service.RevalidateAsync( + gatewayId, + new GatewayTailscaleAuthConfigClientAdapter(client), + cancellationToken) + .ConfigureAwait(false); + } + finally + { + _transitionSemaphore.Release(); + } + } public ConnectionDiagnostics Diagnostics => _diagnostics; // ─── Lifecycle ─── diff --git a/src/OpenClaw.Connection/GatewayRecord.cs b/src/OpenClaw.Connection/GatewayRecord.cs index 379bd3424..5be315505 100644 --- a/src/OpenClaw.Connection/GatewayRecord.cs +++ b/src/OpenClaw.Connection/GatewayRecord.cs @@ -33,6 +33,12 @@ public sealed record GatewayRecord /// WSL distro name for gateway records provisioned by SetupEngine. public string? SetupManagedDistroName { get; init; } + /// + /// True when setup or an explicit upgrade granted this managed gateway's + /// Dashboard access to verified Tailscale identity authentication. + /// + public bool TrustTailscaleAuth { get; init; } + /// Per-gateway SSH tunnel configuration. Null if no tunnel needed. public SshTunnelConfig? SshTunnel { get; init; } @@ -95,6 +101,7 @@ rebuilt.SshTunnel is null && // Migrate legacy "Local ()" ownership to the explicit durable marker. SetupManagedDistroName = managedDistroName, RequiresV2Signature = rebuilt.RequiresV2Signature || existing.RequiresV2Signature, + TrustTailscaleAuth = rebuilt.TrustTailscaleAuth || existing.TrustTailscaleAuth, }; } else if (existingManagedDistroName is not null) @@ -104,6 +111,7 @@ rebuilt.SshTunnel is null && IsLocal = OpenClaw.Shared.LocalGatewayUrlClassifier.IsLocalGatewayUrl(rebuilt.Url), SetupManagedDistroName = null, RequiresV2Signature = false, + TrustTailscaleAuth = false, FriendlyName = ParseLegacyManagedDistroName(result.FriendlyName) is not null ? null : result.FriendlyName, diff --git a/src/OpenClaw.Connection/GatewayTailscaleAuthUpgrade.cs b/src/OpenClaw.Connection/GatewayTailscaleAuthUpgrade.cs new file mode 100644 index 000000000..283af7b49 --- /dev/null +++ b/src/OpenClaw.Connection/GatewayTailscaleAuthUpgrade.cs @@ -0,0 +1,539 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using OpenClaw.Shared; + +namespace OpenClaw.Connection; + +public enum GatewayTailscaleAuthUpgradeOutcome +{ + Succeeded, + AlreadyEnabled, + Ineligible, + NotActive, + NotConnected, + MissingConfigScope, + ConfigUnavailable, + PatchRejected, + PersistenceFailed, +} + +public sealed record GatewayTailscaleAuthUpgradeResult( + GatewayTailscaleAuthUpgradeOutcome Outcome, + string? Error = null) +{ + public bool IsSuccess => Outcome is + GatewayTailscaleAuthUpgradeOutcome.Succeeded or + GatewayTailscaleAuthUpgradeOutcome.AlreadyEnabled; +} + +public static class GatewayTailscaleAuthUpgradePolicy +{ + public static bool IsEligible(GatewayRecord? record) + { + if (record is null || + !record.IsLocal || + record.SshTunnel is not null || + GatewayRecordEditing.ResolveManagedDistroName(record) is null) + { + return false; + } + + // Topology only limits where the opt-in is offered; confirmation and a successful Core patch grant trust. + return GatewayTopologyClassifier.Classify(record.Url, useSshTunnel: false).DetectedKind == + GatewayKind.Tailscale; + } + + public static bool CanOffer(GatewayRecord? record) => + record?.TrustTailscaleAuth != true && IsEligible(record); +} + +internal interface IGatewayTailscaleAuthConfigClient +{ + IReadOnlyList GrantedOperatorScopes { get; } + bool IsConnectedToGateway { get; } + Task RequestConfigDetailedAsync(int timeoutMs = 15000); + Task PatchConfigDetailedAsync( + JsonElement fullConfig, + string? baseHash, + int timeoutMs = 15000); +} + +internal sealed class GatewayTailscaleAuthConfigClientAdapter(IOperatorGatewayClient client) + : IGatewayTailscaleAuthConfigClient +{ + public IReadOnlyList GrantedOperatorScopes => client.GrantedOperatorScopes; + public bool IsConnectedToGateway => client.IsConnectedToGateway; + public Task RequestConfigDetailedAsync(int timeoutMs = 15000) => + client.RequestConfigDetailedAsync(timeoutMs); + public Task PatchConfigDetailedAsync( + JsonElement fullConfig, + string? baseHash, + int timeoutMs = 15000) => + client.PatchConfigDetailedAsync(fullConfig, baseHash, timeoutMs); +} + +internal enum GatewayTailscaleAuthLiveState +{ + Ready, + NotReady, + Unavailable, +} + +internal interface IGatewayTailscaleAuthLiveVerifier +{ + Task VerifyAsync( + GatewayRecord record, + int gatewayPort, + CancellationToken cancellationToken); +} + +internal sealed class GatewayTailscaleAuthLiveVerifier : IGatewayTailscaleAuthLiveVerifier +{ + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5); + private static readonly IReadOnlyList StatusCommand = + ["/usr/bin/tailscale", "status", "--json"]; + private static readonly IReadOnlyList ServeStatusCommand = + ["/usr/bin/tailscale", "serve", "status", "--json"]; + + private readonly IWslCommandRunner _commandRunner; + private readonly TimeSpan _timeout; + + public GatewayTailscaleAuthLiveVerifier( + IWslCommandRunner commandRunner, + TimeSpan? timeout = null) + { + _commandRunner = commandRunner ?? throw new ArgumentNullException(nameof(commandRunner)); + _timeout = timeout ?? DefaultTimeout; + } + + public async Task VerifyAsync( + GatewayRecord record, + int gatewayPort, + CancellationToken cancellationToken) + { + if (GatewayRecordEditing.ResolveManagedDistroName(record) is not { } distroName || + !Uri.TryCreate(record.Url, UriKind.Absolute, out var gatewayUri) || + gatewayPort is <= 0 or > 65535) + { + return GatewayTailscaleAuthLiveState.Unavailable; + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(_timeout); + + WslCommandResult statusResult; + try + { + statusResult = await _commandRunner.RunAsync( + BuildRootProbeArguments(distroName, StatusCommand), + timeout.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return GatewayTailscaleAuthLiveState.Unavailable; + } + + if (!statusResult.Success) + return GatewayTailscaleAuthLiveState.Unavailable; + + string dnsName; + try + { + using var status = JsonDocument.Parse(statusResult.StandardOutput); + var root = status.RootElement; + var backendRunning = + root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("BackendState", out var backendState) && + backendState.ValueKind == JsonValueKind.String && + string.Equals(backendState.GetString(), "Running", StringComparison.Ordinal); + dnsName = + root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("Self", out var self) && + self.ValueKind == JsonValueKind.Object && + self.TryGetProperty("DNSName", out var dnsNameElement) && + dnsNameElement.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(dnsNameElement.GetString()) + ? dnsNameElement.GetString()!.Trim().TrimEnd('.') + : string.Empty; + + if (!backendRunning || + !string.Equals(dnsName, gatewayUri.Host.TrimEnd('.'), StringComparison.OrdinalIgnoreCase)) + { + return GatewayTailscaleAuthLiveState.NotReady; + } + } + catch (JsonException) + { + return GatewayTailscaleAuthLiveState.Unavailable; + } + + WslCommandResult serveResult; + try + { + serveResult = await _commandRunner.RunAsync( + BuildRootProbeArguments(distroName, ServeStatusCommand), + timeout.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return GatewayTailscaleAuthLiveState.Unavailable; + } + + if (!serveResult.Success || + !TailscaleServeStatusPolicy.TryParse( + serveResult.StandardOutput, + gatewayPort, + gatewayUri, + out var serveStatus)) + { + return GatewayTailscaleAuthLiveState.Unavailable; + } + + return serveStatus.RoutesToGateway && !serveStatus.FunnelEnabled + ? GatewayTailscaleAuthLiveState.Ready + : GatewayTailscaleAuthLiveState.NotReady; + } + + private static IReadOnlyList BuildRootProbeArguments( + string distroName, + IReadOnlyList command) => + ["-d", distroName, "--user", "root", "--", .. command]; +} + +internal sealed class GatewayTailscaleAuthUpgradeService +{ + private static readonly TimeSpan ConfigTimeout = TimeSpan.FromSeconds(15); + private readonly GatewayRegistry _registry; + private readonly IGatewayTailscaleAuthLiveVerifier? _liveVerifier; + + public GatewayTailscaleAuthUpgradeService(GatewayRegistry registry) + : this(registry, liveVerifier: null) + { + } + + public GatewayTailscaleAuthUpgradeService( + GatewayRegistry registry, + IGatewayTailscaleAuthLiveVerifier? liveVerifier) + { + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _liveVerifier = liveVerifier; + } + + public async Task EnableAsync( + string gatewayId, + IGatewayTailscaleAuthConfigClient client, + CancellationToken cancellationToken) + { + var record = _registry.GetById(gatewayId); + if (!GatewayTailscaleAuthUpgradePolicy.IsEligible(record)) + return new(GatewayTailscaleAuthUpgradeOutcome.Ineligible); + + if (!string.Equals(_registry.ActiveGatewayId, gatewayId, StringComparison.Ordinal)) + return new(GatewayTailscaleAuthUpgradeOutcome.NotActive); + + if (!client.IsConnectedToGateway) + return new(GatewayTailscaleAuthUpgradeOutcome.NotConnected); + + if (!OperatorScopeHelper.CanReadConfig(client.GrantedOperatorScopes) || + !OperatorScopeHelper.CanWriteConfig(client.GrantedOperatorScopes)) + { + return new(GatewayTailscaleAuthUpgradeOutcome.MissingConfigScope); + } + + ConfigSnapshot snapshot; + try + { + snapshot = await ReadConfigAsync(client, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return new(GatewayTailscaleAuthUpgradeOutcome.ConfigUnavailable, ex.Message); + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (record!.TrustTailscaleAuth && AllowsTailscaleAuth(snapshot.Root)) + return new(GatewayTailscaleAuthUpgradeOutcome.AlreadyEnabled); + + if (string.IsNullOrWhiteSpace(snapshot.BaseHash)) + { + return new(GatewayTailscaleAuthUpgradeOutcome.ConfigUnavailable, "Config base hash is unavailable."); + } + + var previousValue = false; + var changed = false; + if (!record.TrustTailscaleAuth) + { + _registry.Update(gatewayId, current => + { + if (!GatewayTailscaleAuthUpgradePolicy.CanOffer(current)) + return current; + + previousValue = current.TrustTailscaleAuth; + changed = true; + return current with { TrustTailscaleAuth = true }; + }); + + if (!changed) + return new(GatewayTailscaleAuthUpgradeOutcome.NotActive); + + try + { + _registry.Save(); + } + catch (Exception ex) + { + _registry.Update(gatewayId, current => current with { TrustTailscaleAuth = previousValue }); + return new(GatewayTailscaleAuthUpgradeOutcome.PersistenceFailed, ex.Message); + } + } + + if (cancellationToken.IsCancellationRequested) + { + var rollback = RollBackMarkerAfterRejectedPatch( + gatewayId, + changed, + previousValue, + patchError: null); + if (rollback.Outcome == GatewayTailscaleAuthUpgradeOutcome.PersistenceFailed) + return rollback; + cancellationToken.ThrowIfCancellationRequested(); + } + + // Core beta.7 and current main define config.patch as JSON Merge Patch. + // Send only the intended leaf: replaying the full snapshot would turn + // unrelated null values into deletion markers. + var configPatch = CreateAllowTailscalePatch(); + ConfigPatchResult patch; + Task? patchTask = null; + try + { + patchTask = client.PatchConfigDetailedAsync(configPatch, snapshot.BaseHash); + patch = await patchTask.WaitAsync(cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Dispatch cannot be canceled at the client boundary. Observe its bounded + // result before returning so callers never proceed beside an in-flight + // mutation. Roll back only a definitive gateway rejection; otherwise the + // marker remains for the next trust-aware live revalidation. + try + { + var completedPatch = await patchTask!.ConfigureAwait(false); + if (!completedPatch.Ok && completedPatch.IsGatewayRejection) + { + var rollback = RollBackMarkerAfterRejectedPatch( + gatewayId, + changed, + previousValue, + completedPatch.Error); + if (rollback.Outcome == GatewayTailscaleAuthUpgradeOutcome.PersistenceFailed) + return rollback; + } + } + catch + { + // A transport failure is ambiguous: Core may have committed. + } + throw; + } + catch (Exception ex) + { + // The response can be lost after Core commits the patch. Keep the marker so + // the next trust-aware launch revalidates the authoritative Core state. + return new(GatewayTailscaleAuthUpgradeOutcome.PatchRejected, ex.Message); + } + + if (!patch.Ok && patch.IsGatewayRejection) + return RollBackMarkerAfterRejectedPatch(gatewayId, changed, previousValue, patch.Error); + + if (!patch.Ok) + { + // The request may have committed before its response was lost. Preserve + // the marker so the next trust-aware launch revalidates Core state. + return new(GatewayTailscaleAuthUpgradeOutcome.PatchRejected, patch.Error); + } + + return new(GatewayTailscaleAuthUpgradeOutcome.Succeeded); + } + + public async Task RevalidateAsync( + string gatewayId, + IGatewayTailscaleAuthConfigClient client, + CancellationToken cancellationToken) + { + var record = _registry.GetById(gatewayId); + if (record?.TrustTailscaleAuth != true || + !GatewayTailscaleAuthUpgradePolicy.IsEligible(record) || + !string.Equals(_registry.ActiveGatewayId, gatewayId, StringComparison.Ordinal) || + !client.IsConnectedToGateway || + !OperatorScopeHelper.CanReadConfig(client.GrantedOperatorScopes)) + { + return false; + } + + ConfigSnapshot snapshot; + try + { + snapshot = await ReadConfigAsync(client, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return false; + } + + if (AllowsTailscaleAuth(snapshot.Root)) + { + if (_liveVerifier is null || !TryGetGatewayPort(snapshot.Root, out var gatewayPort)) + return false; + + try + { + return await _liveVerifier.VerifyAsync(record, gatewayPort, cancellationToken).ConfigureAwait(false) == + GatewayTailscaleAuthLiveState.Ready; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return false; + } + } + + _registry.Update(gatewayId, current => current with { TrustTailscaleAuth = false }); + try + { + _registry.Save(); + } + catch + { + _registry.Update(gatewayId, current => current with { TrustTailscaleAuth = true }); + } + return false; + } + + private static async Task ReadConfigAsync( + IGatewayTailscaleAuthConfigClient client, + CancellationToken cancellationToken) + { + var timeoutMs = checked((int)ConfigTimeout.TotalMilliseconds); + var response = await client.RequestConfigDetailedAsync(timeoutMs) + .WaitAsync(cancellationToken) + .ConfigureAwait(false); + return CaptureSnapshot(response); + } + + private static ConfigSnapshot CaptureSnapshot(JsonElement response) + { + var root = response.TryGetProperty("parsed", out var parsed) + ? parsed + : response.TryGetProperty("config", out var config) + ? config + : response; + var baseHash = response.TryGetProperty("baseHash", out var baseHashElement) && + baseHashElement.ValueKind == JsonValueKind.String + ? baseHashElement.GetString() + : response.TryGetProperty("hash", out var hashElement) && + hashElement.ValueKind == JsonValueKind.String + ? hashElement.GetString() + : null; + if (baseHash is null && + response.TryGetProperty("raw", out var rawElement) && + rawElement.ValueKind == JsonValueKind.String && + rawElement.GetString() is { } raw) + { + baseHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(raw))); + } + + return new(root.Clone(), baseHash); + } + + private static JsonElement CreateAllowTailscalePatch() + { + using var document = JsonDocument.Parse(""" + { "gateway": { "auth": { "allowTailscale": true } } } + """); + return document.RootElement.Clone(); + } + + private GatewayTailscaleAuthUpgradeResult RollBackMarkerAfterRejectedPatch( + string gatewayId, + bool changed, + bool previousValue, + string? patchError) + { + if (!changed) + return new(GatewayTailscaleAuthUpgradeOutcome.PatchRejected, patchError); + + _registry.Update(gatewayId, current => current with { TrustTailscaleAuth = previousValue }); + try + { + _registry.Save(); + return new(GatewayTailscaleAuthUpgradeOutcome.PatchRejected, patchError); + } + catch (Exception ex) + { + return new( + GatewayTailscaleAuthUpgradeOutcome.PersistenceFailed, + $"Local Tailscale trust-marker rollback failed: {ex.Message}"); + } + } + + // A true marker is issued only after this service or setup explicitly writes + // allowTailscale=true. Omission therefore means that grant drifted or was revoked; + // do not duplicate Core's environment-sensitive implicit auth resolver here. + private static bool AllowsTailscaleAuth(JsonElement config) => + config.ValueKind == JsonValueKind.Object && + config.TryGetProperty("gateway", out var gateway) && + gateway.ValueKind == JsonValueKind.Object && + gateway.TryGetProperty("auth", out var auth) && + auth.ValueKind == JsonValueKind.Object && + auth.TryGetProperty("allowTailscale", out var allowTailscale) && + allowTailscale.ValueKind is JsonValueKind.True; + + private static bool TryGetGatewayPort(JsonElement config, out int port) + { + const int defaultGatewayPort = 18789; + port = 0; + if (config.ValueKind != JsonValueKind.Object || + !config.TryGetProperty("gateway", out var gateway) || + gateway.ValueKind != JsonValueKind.Object) + { + return false; + } + + if (!gateway.TryGetProperty("port", out var gatewayPort)) + { + port = defaultGatewayPort; + return true; + } + + return gatewayPort.ValueKind == JsonValueKind.Number && + gatewayPort.TryGetInt32(out port) && + port is > 0 and <= 65535; + } + + private sealed record ConfigSnapshot(JsonElement Root, string? BaseHash); +} diff --git a/src/OpenClaw.Connection/IGatewayConnectionManager.cs b/src/OpenClaw.Connection/IGatewayConnectionManager.cs index 0dc94a65f..eab1c1632 100644 --- a/src/OpenClaw.Connection/IGatewayConnectionManager.cs +++ b/src/OpenClaw.Connection/IGatewayConnectionManager.cs @@ -29,6 +29,15 @@ public interface IGatewayConnectionManager : IDisposable, IAsyncDisposable Task RestartSshTunnelAsync(CancellationToken cancellationToken = default) => Task.FromResult(false); Task SwitchGatewayAsync(string gatewayId); + Task EnableTailscaleDashboardAuthAsync( + string gatewayId, + CancellationToken cancellationToken = default) => + Task.FromResult(new GatewayTailscaleAuthUpgradeResult( + GatewayTailscaleAuthUpgradeOutcome.NotConnected)); + Task RevalidateTailscaleDashboardAuthAsync( + string gatewayId, + CancellationToken cancellationToken = default) => + Task.FromResult(false); void SetGatewayConnectionIntent(string gatewayId, bool shouldBeConnected); bool IsAutomaticReconnectAllowed(string gatewayId); diff --git a/src/OpenClaw.SetupEngine/PairOperatorStep.cs b/src/OpenClaw.SetupEngine/PairOperatorStep.cs index 9a30951df..f2bf76840 100644 --- a/src/OpenClaw.SetupEngine/PairOperatorStep.cs +++ b/src/OpenClaw.SetupEngine/PairOperatorStep.cs @@ -12,6 +12,8 @@ namespace OpenClaw.SetupEngine; public sealed class PairOperatorStep : SetupStep { + private const string AllowTailscaleConfigKey = "gateway.auth.allowTailscale"; + public override string Id => "pair-operator"; public override string DisplayName => "Pair operator connection"; public override RetryPolicy Retry => new(MaxAttempts: 3, InitialDelay: TimeSpan.FromSeconds(3)); @@ -50,6 +52,7 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati BootstrapToken = ctx.BootstrapToken, IsLocal = true, SetupManagedDistroName = ctx.DistroName, + TrustTailscaleAuth = ResolveEffectiveTailscaleAuthTrust(ctx.Config), LastConnected = DateTime.UtcNow }; @@ -166,6 +169,15 @@ record = registry.AddOrUpdate(record); } } + private static bool ResolveEffectiveTailscaleAuthTrust(SetupConfig config) + { + if (!config.Tailscale.Enabled || !config.Tailscale.TrustTailscaleAuth) + return false; + + return config.Gateway.ExtraConfig?.TryGetValue(AllowTailscaleConfigKey, out var value) != true || + bool.TryParse(value, out var enabled) && enabled; + } + internal static async Task EnsurePairingEndpointTrustedAsync( SetupContext ctx, CancellationToken cancellationToken, diff --git a/src/OpenClaw.SetupEngine/TailscaleSetupSteps.cs b/src/OpenClaw.SetupEngine/TailscaleSetupSteps.cs index cf8c6eb99..9817f7e18 100644 --- a/src/OpenClaw.SetupEngine/TailscaleSetupSteps.cs +++ b/src/OpenClaw.SetupEngine/TailscaleSetupSteps.cs @@ -139,15 +139,21 @@ private static bool HasGatewayWebProxy(JsonElement root, int port) foreach (var webEndpoint in web.EnumerateObject()) { - if (!webEndpoint.Value.TryGetProperty("Handlers", out var handlers) || handlers.ValueKind != JsonValueKind.Object) + if (webEndpoint.Value.ValueKind != JsonValueKind.Object || + !webEndpoint.Value.TryGetProperty("Handlers", out var handlers) || + handlers.ValueKind != JsonValueKind.Object) + { continue; + } foreach (var handler in handlers.EnumerateObject()) { if (handler.Value.ValueKind != JsonValueKind.Object || !handler.Value.TryGetProperty("Proxy", out var proxy) || proxy.ValueKind != JsonValueKind.String) + { continue; + } if (IsLoopbackGatewayProxy(proxy.GetString(), port)) return true; @@ -157,9 +163,6 @@ private static bool HasGatewayWebProxy(JsonElement root, int port) return false; } - // Serve status represents Funnel as AllowFunnel on current Tailscale - // versions. Accept the legacy Funnel spelling too so a version change - // cannot silently turn a public endpoint into an accepted setup state. private static bool HasEnabledFunnel(JsonElement root) { foreach (var property in root.EnumerateObject()) @@ -176,11 +179,10 @@ private static bool HasEnabledFunnel(JsonElement root) JsonValueKind.True => true, JsonValueKind.False or JsonValueKind.Null or JsonValueKind.Undefined => false, JsonValueKind.Array => value.EnumerateArray().Any(ContainsEnabledFunnelValue), - JsonValueKind.Object => value.EnumerateObject().Any(property => ContainsEnabledFunnelValue(property.Value)), - // A non-empty string is a configured public endpoint in older status - // documents. Be conservative: setup must never accept it as private. + JsonValueKind.Object => value.EnumerateObject().Any(property => + ContainsEnabledFunnelValue(property.Value)), JsonValueKind.String => !string.IsNullOrWhiteSpace(value.GetString()), - _ => false + _ => false, }; private static bool IsLoopbackGatewayProxy(string? proxy, int port) => diff --git a/src/OpenClaw.Shared/Capabilities/AppCapability.cs b/src/OpenClaw.Shared/Capabilities/AppCapability.cs index 5faac7ccc..cbc4fe948 100644 --- a/src/OpenClaw.Shared/Capabilities/AppCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/AppCapability.cs @@ -46,7 +46,7 @@ public class AppCapability : NodeCapabilityBase public Func? SettingsSetHandler; public Func? MenuHandler; public Func? SearchHandler; - public Func? DashboardUrlHandler; + public Func>? DashboardUrlHandler; public Func>? ChatSnapshotHandler; public Func>? ChatSendHandler; public Func>? ChatResetHandler; @@ -69,7 +69,7 @@ public override async Task ExecuteAsync(NodeInvokeRequest re "app.settings.set" => HandleSettingsSet(request), "app.menu" => HandleMenu(), "app.search" => HandleSearch(request), - "app.dashboard.url" => HandleDashboardUrl(request), + "app.dashboard.url" => await HandleDashboardUrl(request), "app.chat.snapshot" => await HandleChatSnapshot(request), "app.chat.send" => await HandleChatSend(request), "app.chat.reset" => await HandleChatReset(request), @@ -197,11 +197,14 @@ private NodeInvokeResponse HandleSearch(NodeInvokeRequest request) return Success(SearchHandler(query)); } - private NodeInvokeResponse HandleDashboardUrl(NodeInvokeRequest request) + private async Task HandleDashboardUrl(NodeInvokeRequest request) { if (DashboardUrlHandler == null) return Error("Dashboard URL handler not registered"); - return Success(DashboardUrlHandler(GetStringArg(request.Args, "path"))); + var result = await DashboardUrlHandler(GetStringArg(request.Args, "path")); + if (TryGetErrorPayload(result, out var error)) + return Error(error); + return Success(result); } private async Task HandleChatSnapshot(NodeInvokeRequest request) diff --git a/src/OpenClaw.Shared/ChannelsSnapshot.cs b/src/OpenClaw.Shared/ChannelsSnapshot.cs index 123f8dffb..03c32027c 100644 --- a/src/OpenClaw.Shared/ChannelsSnapshot.cs +++ b/src/OpenClaw.Shared/ChannelsSnapshot.cs @@ -357,6 +357,13 @@ public sealed class ConfigPatchResult /// Raw JSON of the gateway response (or stringified exception). For diagnostic disclosure. public string? RawResponse { get; init; } + /// + /// True only when the gateway returned an explicit pre-commit rejection. + /// Transport failures, timeouts, and post-commit runtime errors leave the + /// commit outcome unknown and must not be treated as authoritative. + /// + public bool IsGatewayRejection { get; init; } + /// /// True when the gateway rejected the patch because our baseHash was /// stale (someone else changed the config out from under us). Pages diff --git a/src/OpenClaw.Shared/IOperatorGatewayClient.cs b/src/OpenClaw.Shared/IOperatorGatewayClient.cs index 6c93e99c2..1860b2267 100644 --- a/src/OpenClaw.Shared/IOperatorGatewayClient.cs +++ b/src/OpenClaw.Shared/IOperatorGatewayClient.cs @@ -115,6 +115,8 @@ async Task RunCronJobDetailedAsync(string jobId, bool forc Task InstallSkillAsync(string skillId); Task SetSkillEnabledAsync(string skillKey, bool enabled); Task RequestConfigAsync(); + Task RequestConfigDetailedAsync(int timeoutMs = 15000) => + Task.FromException(new NotSupportedException("Response-aware config.get is not supported by this client.")); Task RequestConfigSchemaAsync(); Task SetConfigAsync(string path, object value); Task PatchConfigAsync(JsonElement fullConfig, string? baseHash); diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index e929b0727..ed12a3536 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -12,6 +12,11 @@ namespace OpenClaw.Shared; +internal sealed class GatewayRequestRejectedException(string message, string? code) : InvalidOperationException(message) +{ + public string? Code { get; } = code; +} + public partial class OpenClawGatewayClient : WebSocketClientBase, IOperatorGatewayClient { private const string OperatorClientId = "cli"; @@ -1226,6 +1231,9 @@ public async Task RequestConfigAsync() await SendTrackedRequestAsync("config.get"); } + public Task RequestConfigDetailedAsync(int timeoutMs = 15000) => + SendWizardRequestAsync("config.get", timeoutMs: timeoutMs); + public async Task RequestConfigSchemaAsync() { await SendTrackedRequestAsync("config.schema"); @@ -1283,6 +1291,10 @@ public async Task PatchConfigDetailedAsync(JsonElement fullCo Ok = false, Error = ex.Message, RawResponse = ex.ToString(), + IsGatewayRejection = ex is GatewayRequestRejectedException + { + Code: "INVALID_REQUEST", + }, }; } } @@ -1988,7 +2000,9 @@ private void HandleResponseForConnection( okWiz.ValueKind == JsonValueKind.False) { var message = TryGetErrorMessage(root) ?? "wizard request failed"; - wizardCompletion.TryFault(new InvalidOperationException(message)); + wizardCompletion.TryFault(new GatewayRequestRejectedException( + message, + TryGetErrorTopLevelCode(root))); } else if (root.TryGetProperty("payload", out var wizPayload)) { diff --git a/src/OpenClaw.Shared/TailscaleServeStatusPolicy.cs b/src/OpenClaw.Shared/TailscaleServeStatusPolicy.cs new file mode 100644 index 000000000..0f5fedb8f --- /dev/null +++ b/src/OpenClaw.Shared/TailscaleServeStatusPolicy.cs @@ -0,0 +1,209 @@ +using System.Text.Json; + +namespace OpenClaw.Shared; + +public sealed record TailscaleServeStatusResult(bool RoutesToGateway, bool FunnelEnabled); + +public static class TailscaleServeStatusPolicy +{ + public static bool TryParse( + string status, + int port, + Uri? expectedEndpoint, + out TailscaleServeStatusResult parsed) + { + parsed = new TailscaleServeStatusResult(false, false); + if (port is <= 0 or > 65535) + return false; + + try + { + using var document = JsonDocument.Parse(status); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || + !HasValidWebShape(root) || + !TryReadFunnelState(root, out var funnelEnabled)) + { + return false; + } + + parsed = new TailscaleServeStatusResult( + RoutesToGateway: HasGatewayWebProxy(root, port, expectedEndpoint), + FunnelEnabled: funnelEnabled); + return true; + } + catch (JsonException) + { + return false; + } + } + + private static bool HasValidWebShape(JsonElement root) + { + if (!root.TryGetProperty("Web", out var web)) + return true; + if (web.ValueKind != JsonValueKind.Object) + return false; + + foreach (var endpoint in web.EnumerateObject()) + { + if (endpoint.Value.ValueKind != JsonValueKind.Object) + return false; + if (endpoint.Value.TryGetProperty("Handlers", out var handlers) && + handlers.ValueKind != JsonValueKind.Object) + { + return false; + } + + if (endpoint.Value.TryGetProperty("Handlers", out handlers)) + { + foreach (var handler in handlers.EnumerateObject()) + { + if (handler.Value.ValueKind != JsonValueKind.Object || + (handler.Value.TryGetProperty("Proxy", out var proxy) && + proxy.ValueKind != JsonValueKind.String)) + { + return false; + } + } + } + } + + return true; + } + + private static bool HasGatewayWebProxy(JsonElement root, int port, Uri? expectedEndpoint) + { + if (!root.TryGetProperty("Web", out var web) || web.ValueKind != JsonValueKind.Object) + return false; + + foreach (var webEndpoint in web.EnumerateObject()) + { + if (!EndpointMatches(webEndpoint.Name, expectedEndpoint) || + webEndpoint.Value.ValueKind != JsonValueKind.Object || + !webEndpoint.Value.TryGetProperty("Handlers", out var handlers) || + handlers.ValueKind != JsonValueKind.Object || + !handlers.TryGetProperty("/", out var rootHandler) || + rootHandler.ValueKind != JsonValueKind.Object || + !rootHandler.TryGetProperty("Proxy", out var proxy) || + proxy.ValueKind != JsonValueKind.String) + { + continue; + } + + if (IsLoopbackGatewayProxy(proxy.GetString(), port)) + return true; + } + + return false; + } + + private static bool EndpointMatches(string endpoint, Uri? expectedEndpoint) + { + if (expectedEndpoint is null) + return true; + + var candidate = endpoint.Contains("://", StringComparison.Ordinal) + ? endpoint + : $"https://{endpoint}"; + return Uri.TryCreate(candidate, UriKind.Absolute, out var uri) && + uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + !HasUserInfoDelimiter(candidate) && + string.IsNullOrEmpty(uri.UserInfo) && + HasCanonicalRootPathAndNoSuffix(candidate) && + string.IsNullOrEmpty(uri.Query) && + string.IsNullOrEmpty(uri.Fragment) && + string.Equals( + uri.Host.TrimEnd('.'), + expectedEndpoint.Host.TrimEnd('.'), + StringComparison.OrdinalIgnoreCase) && + uri.Port == expectedEndpoint.Port; + } + + private static bool TryReadFunnelState(JsonElement root, out bool enabled) + { + enabled = false; + foreach (var property in root.EnumerateObject()) + { + if (!property.NameEquals("AllowFunnel") && !property.NameEquals("Funnel")) + continue; + if (!TryContainsEnabledFunnelValue(property.Value, out var propertyEnabled)) + return false; + enabled |= propertyEnabled; + } + + return true; + } + + private static bool TryContainsEnabledFunnelValue(JsonElement value, out bool enabled) + { + enabled = false; + switch (value.ValueKind) + { + case JsonValueKind.True: + enabled = true; + return true; + case JsonValueKind.False: + case JsonValueKind.Null: + return true; + case JsonValueKind.String: + enabled = !string.IsNullOrWhiteSpace(value.GetString()); + return true; + case JsonValueKind.Array: + foreach (var item in value.EnumerateArray()) + { + if (!TryContainsEnabledFunnelValue(item, out var itemEnabled)) + return false; + enabled |= itemEnabled; + } + return true; + case JsonValueKind.Object: + foreach (var property in value.EnumerateObject()) + { + if (!TryContainsEnabledFunnelValue(property.Value, out var propertyEnabled)) + return false; + enabled |= propertyEnabled; + } + return true; + default: + return false; + } + } + + private static bool IsLoopbackGatewayProxy(string? proxy, int port) => + proxy is not null && + Uri.TryCreate(proxy, UriKind.Absolute, out var uri) && + uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + uri.Port == port && + HasCanonicalRootPathAndNoSuffix(proxy) && + !HasUserInfoDelimiter(proxy) && + string.IsNullOrEmpty(uri.UserInfo) && + string.IsNullOrEmpty(uri.Query) && + string.IsNullOrEmpty(uri.Fragment) && + (uri.Host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase) || + uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase)); + + private static bool HasUserInfoDelimiter(string value) + { + var authorityStart = value.IndexOf("://", StringComparison.Ordinal); + if (authorityStart < 0) + return false; + authorityStart += 3; + + var authorityEnd = value.IndexOfAny(['/', '?', '#'], authorityStart); + if (authorityEnd < 0) + authorityEnd = value.Length; + return value.AsSpan(authorityStart, authorityEnd - authorityStart).Contains('@'); + } + + private static bool HasCanonicalRootPathAndNoSuffix(string value) + { + var authorityStart = value.IndexOf("://", StringComparison.Ordinal); + if (authorityStart < 0) + return false; + authorityStart += 3; + + var suffixStart = value.IndexOfAny(['/', '\\', '?', '#'], authorityStart); + return suffixStart < 0 || value.AsSpan(suffixStart).SequenceEqual("/"); + } +} diff --git a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs index 8e93ebbe9..da0afc3dd 100644 --- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs +++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs @@ -224,16 +224,39 @@ private void WireAppCapabilityHandlers() return matches; }; - app.DashboardUrlHandler = (path) => + app.DashboardUrlHandler = async (path) => { - if (!TryResolveChatCredentials(out var gatewayUrl, out var token, out var credentialSource, out var isBootstrapToken)) + if (!TryResolveChatCredentials(out var gatewayUrl, out var dashboardCredential, out var credentialSource, out var isBootstrapToken)) return new { error = "Gateway URL or credential is not configured" }; + var active = _gatewayRegistry?.GetActive(); + var activeMatches = active is not null && + string.Equals(active.Url, gatewayUrl, StringComparison.OrdinalIgnoreCase); + var trustTailscaleAuth = false; + if (active?.TrustTailscaleAuth == true && + activeMatches && + _connectionManager is not null) + { + trustTailscaleAuth = await _connectionManager + .RevalidateTailscaleDashboardAuthAsync(active.Id); + } + + var usesSharedCredential = + !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken; + + if (!GatewayDashboardUrlBuilder.HasBrowserCompatibleCredential( + trustTailscaleAuth, + usesSharedCredential)) + { + return new { error = GatewayDashboardUrlBuilder.NoBrowserCompatibleCredentialError }; + } + var url = GatewayDashboardUrlBuilder.Build( gatewayUrl, path, - token, - !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken); + dashboardCredential, + usesSharedCredential, + trustTailscaleAuth); return new { diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index a57b2c8c1..a56ff2434 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -3812,11 +3812,77 @@ private void OpenDashboard(string? path = null) return; } - var url = GatewayDashboardUrlBuilder.Build( + var appendBrowserCredential = + !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken; + var active = _gatewayRegistry?.GetActive(); + if (active?.TrustTailscaleAuth == true && + string.Equals(active.Url, gatewayUrl, StringComparison.OrdinalIgnoreCase) && + _connectionManager is not null) + { + _ = OpenDashboardAfterTailscaleAuthRevalidationAsync( + active.Id, + gatewayUrl, + path, + token, + appendBrowserCredential); + return; + } + + if (!GatewayDashboardUrlBuilder.HasBrowserCompatibleCredential( + trustTailscaleAuth: false, + usesSharedGatewayToken: appendBrowserCredential)) + { + ShowConnectionSettingsForPairingIssue( + "Dashboard", + "Tailscale authentication is unavailable and no approved browser credential is available"); + return; + } + + LaunchDashboardUrl(GatewayDashboardUrlBuilder.Build( gatewayUrl, path, token, - !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken); + appendBrowserCredential)); + } + + private async Task OpenDashboardAfterTailscaleAuthRevalidationAsync( + string gatewayId, + string gatewayUrl, + string? path, + string? browserCredential, + bool appendBrowserCredential) + { + var trustTailscaleAuth = false; + try + { + trustTailscaleAuth = await _connectionManager! + .RevalidateTailscaleDashboardAuthAsync(gatewayId); + } + catch (Exception ex) + { + Logger.Warn($"Failed to revalidate Tailscale dashboard auth: {ex.Message}"); + } + + if (!GatewayDashboardUrlBuilder.HasBrowserCompatibleCredential( + trustTailscaleAuth, + appendBrowserCredential)) + { + ShowConnectionSettingsForPairingIssue( + "Dashboard", + "Tailscale authentication is unavailable and no approved browser credential is available"); + return; + } + + LaunchDashboardUrl(GatewayDashboardUrlBuilder.Build( + gatewayUrl, + path, + browserCredential, + appendBrowserCredential && !trustTailscaleAuth, + trustTailscaleAuth)); + } + + private static void LaunchDashboardUrl(string url) + { try { diff --git a/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs b/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs index 60391616a..61dff6425 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs @@ -2,11 +2,20 @@ namespace OpenClawTray.Helpers; public static class GatewayDashboardUrlBuilder { + public const string NoBrowserCompatibleCredentialError = + "No browser-compatible gateway credential is available"; + + public static bool HasBrowserCompatibleCredential( + bool trustTailscaleAuth, + bool usesSharedGatewayToken) => + trustTailscaleAuth || usesSharedGatewayToken; + public static string Build( string gatewayUrl, string? path, string? sharedGatewayToken, - bool appendSharedGatewayToken) + bool appendSharedGatewayToken, + bool trustTailscaleAuth = false) { ArgumentException.ThrowIfNullOrWhiteSpace(gatewayUrl); @@ -19,7 +28,7 @@ public static string Build( ? baseUrl : $"{baseUrl}/{path.TrimStart('/')}"; - if (appendSharedGatewayToken && !string.IsNullOrEmpty(sharedGatewayToken)) + if (appendSharedGatewayToken && !trustTailscaleAuth && !string.IsNullOrEmpty(sharedGatewayToken)) { var separator = url.Contains('#') ? "&" : "#"; url = $"{url}{separator}token={Uri.EscapeDataString(sharedGatewayToken)}"; diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index 7800cd709..b2845bcda 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -1797,6 +1797,17 @@ private Border BuildSavedGatewayRowControl(SavedGatewayRow row) var openDashboard = new MenuFlyoutItem { Text = LocalizationHelper.GetString("ConnectionPage_OpenDashboard"), Tag = row.Id }; openDashboard.Click += OnSavedRowOpenDashboard; flyout.Items.Add(openDashboard); + var gatewayRecord = _gatewayRegistry?.GetById(row.Id); + if (row.IsActive && GatewayTailscaleAuthUpgradePolicy.CanOffer(gatewayRecord)) + { + var enableTailscaleAuth = new MenuFlyoutItem + { + Text = "Use Tailscale identity for dashboard", + Tag = row.Id, + }; + enableTailscaleAuth.Click += OnEnableTailscaleDashboardAuth; + flyout.Items.Add(enableTailscaleAuth); + } if (row.HasHostTerminal) { var openTerminal = new MenuFlyoutItem { Text = row.HostTerminalLabel, Tag = row.Id }; @@ -2520,7 +2531,23 @@ private async Task OnSavedRowOpenDashboardAsync(object sender) if (rec == null) return; try { - if (!string.IsNullOrWhiteSpace(rec.SharedGatewayToken)) + var trustTailscaleAuth = rec.TrustTailscaleAuth && + _connectionManager is not null && + await _connectionManager.RevalidateTailscaleDashboardAuthAsync(rec.Id); + if (trustTailscaleAuth) + { + var tailscaleUrl = GatewayDashboardUrlBuilder.Build( + rec.Url, + path: null, + rec.SharedGatewayToken, + appendSharedGatewayToken: false, + trustTailscaleAuth: true); + await global::Windows.System.Launcher.LaunchUriAsync(new Uri(tailscaleUrl)); + return; + } + + var usesSharedGatewayToken = !string.IsNullOrWhiteSpace(rec.SharedGatewayToken); + if (usesSharedGatewayToken) { var provenanceService = CurrentApp.ManagedLocalPortProvenance; if (provenanceService is null) @@ -2538,11 +2565,20 @@ private async Task OnSavedRowOpenDashboardAsync(object sender) } } + if (!GatewayDashboardUrlBuilder.HasBrowserCompatibleCredential( + trustTailscaleAuth, + usesSharedGatewayToken)) + { + CurrentApp.ShowTransientConnectionError( + "Tailscale authentication is unavailable and no approved browser credential is available"); + return; + } + var url = GatewayDashboardUrlBuilder.Build( rec.Url, path: null, rec.SharedGatewayToken, - appendSharedGatewayToken: !string.IsNullOrWhiteSpace(rec.SharedGatewayToken)); + appendSharedGatewayToken: usesSharedGatewayToken); await global::Windows.System.Launcher.LaunchUriAsync(new Uri(url)); } catch (Exception ex) @@ -2551,6 +2587,47 @@ private async Task OnSavedRowOpenDashboardAsync(object sender) } } + private void OnEnableTailscaleDashboardAuth(object sender, RoutedEventArgs e) => + AsyncEventHandlerGuard.Run( + () => OnEnableTailscaleDashboardAuthAsync(sender), + new AppLogger(), + nameof(OnEnableTailscaleDashboardAuth)); + + private async Task OnEnableTailscaleDashboardAuthAsync(object sender) + { + if (sender is not MenuFlyoutItem item || + item.Tag is not string gatewayId || + _connectionManager is null) + { + return; + } + + var dialog = new ContentDialog + { + Title = "Use Tailscale identity for dashboard?", + Content = "This enables verified Tailscale identity authentication on this managed gateway. Your saved token, device identity, WSL distro, and pairing remain unchanged.", + PrimaryButtonText = "Enable", + CloseButtonText = LocalizationHelper.GetString("ConnectionPage_CancelAction"), + DefaultButton = ContentDialogButton.Close, + XamlRoot = XamlRoot, + }; + if (await dialog.ShowAsync() != ContentDialogResult.Primary) + return; + + var result = await _connectionManager.EnableTailscaleDashboardAuthAsync(gatewayId); + if (result.IsSuccess) + { + SetGatewayHostActionStatus("Tailscale dashboard identity enabled."); + LoadSavedGateways(); + return; + } + + var detail = string.IsNullOrWhiteSpace(result.Error) + ? result.Outcome.ToString() + : result.Error; + SetGatewayHostActionStatus($"Could not enable Tailscale dashboard identity: {detail}", isError: true); + } + private void OnSavedRowEdit(object sender, RoutedEventArgs e) { if (sender is not MenuFlyoutItem item || item.Tag is not string gwId) return; diff --git a/tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthLiveVerifierTests.cs b/tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthLiveVerifierTests.cs new file mode 100644 index 000000000..30637d6fa --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthLiveVerifierTests.cs @@ -0,0 +1,431 @@ +using System.Text.Json; + +namespace OpenClaw.Connection.Tests; + +public sealed class GatewayTailscaleAuthLiveVerifierTests +{ + [Fact] + public void ConnectionManager_WiresWslLiveVerifierIntoDashboardRevalidation() + { + var source = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", + "OpenClaw.Connection", + "GatewayConnectionManager.cs")); + var revalidationStart = source.IndexOf( + "public async Task RevalidateTailscaleDashboardAuthAsync(", + StringComparison.Ordinal); + var diagnosticsStart = source.IndexOf( + "public ConnectionDiagnostics Diagnostics", + revalidationStart, + StringComparison.Ordinal); + + Assert.True(revalidationStart >= 0); + Assert.True(diagnosticsStart > revalidationStart); + Assert.Contains( + "new GatewayTailscaleAuthLiveVerifier(\n new WslExeCommandRunner(_logger))", + source.Replace("\r\n", "\n", StringComparison.Ordinal)); + var revalidation = source[revalidationStart..diagnosticsStart] + .Replace("\r\n", "\n", StringComparison.Ordinal); + Assert.Contains( + "new GatewayTailscaleAuthUpgradeService(\n" + + " _registry,\n" + + " _tailscaleAuthLiveVerifier);", + revalidation); + } + + [Theory] + [MemberData(nameof(StatusResults))] + public async Task VerifyAsync_ClassifiesStatusResult( + WslCommandResult commandResult, + string expected) + { + var runner = new FakeWslCommandRunner((_, command, _) => Task.FromResult( + command.Contains("serve", StringComparer.Ordinal) + ? ServeStatus() + : commandResult)); + var verifier = new GatewayTailscaleAuthLiveVerifier(runner, TimeSpan.FromSeconds(1)); + + var result = await verifier.VerifyAsync(ManagedRecord(), 18789, CancellationToken.None); + + Assert.Equal(expected, result.ToString()); + Assert.Equal("OpenClawGateway", runner.DistroName); + Assert.Equal(["/usr/bin/tailscale", "status", "--json"], runner.Commands[0]); + Assert.Equal( + ["-d", "OpenClawGateway", "--user", "root", "--", "/usr/bin/tailscale", "status", "--json"], + runner.HostArguments[0]); + Assert.Equal(expected == "Ready" ? 2 : 1, runner.ProbeCalls); + if (expected == "Ready") + { + Assert.Equal(["/usr/bin/tailscale", "serve", "status", "--json"], runner.Commands[1]); + Assert.Equal( + ["-d", "OpenClawGateway", "--user", "root", "--", "/usr/bin/tailscale", "serve", "status", "--json"], + runner.HostArguments[1]); + } + } + + [Fact] + public async Task VerifyAsync_RecordWithoutManagedDistroIsUnavailableWithoutCommand() + { + var runner = new FakeWslCommandRunner((_, _, _) => + Task.FromResult(RunningStatus())); + var verifier = new GatewayTailscaleAuthLiveVerifier(runner, TimeSpan.FromSeconds(1)); + + var result = await verifier.VerifyAsync( + ManagedRecord() with { SetupManagedDistroName = null, FriendlyName = null }, + 18789, + CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthLiveState.Unavailable, result); + Assert.Equal(0, runner.ProbeCalls); + } + + [Fact] + public async Task VerifyAsync_RunnerFailureIsUnavailable() + { + var runner = new FakeWslCommandRunner((_, _, _) => + Task.FromException(new InvalidOperationException("wsl unavailable"))); + var verifier = new GatewayTailscaleAuthLiveVerifier(runner, TimeSpan.FromSeconds(1)); + + var result = await verifier.VerifyAsync(ManagedRecord(), 18789, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthLiveState.Unavailable, result); + } + + [Fact] + public async Task VerifyAsync_TimeoutIsUnavailable() + { + var runner = new FakeWslCommandRunner(async (_, _, cancellationToken) => + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + throw new InvalidOperationException("unreachable"); + }); + var verifier = new GatewayTailscaleAuthLiveVerifier(runner, TimeSpan.FromMilliseconds(20)); + + var result = await verifier.VerifyAsync(ManagedRecord(), 18789, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthLiveState.Unavailable, result); + } + + [Fact] + public async Task VerifyAsync_CallerCancellationPropagates() + { + var runner = new FakeWslCommandRunner(async (_, _, cancellationToken) => + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + throw new InvalidOperationException("unreachable"); + }); + var verifier = new GatewayTailscaleAuthLiveVerifier(runner, TimeSpan.FromSeconds(1)); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => + verifier.VerifyAsync(ManagedRecord(), 18789, cancellation.Token)); + } + + [Theory] + [InlineData("other.tail.example.", 18789, false, "NotReady")] + [InlineData("host.tail.example.", 19999, false, "NotReady")] + [InlineData("host.tail.example.", 18789, true, "NotReady")] + public async Task VerifyAsync_FailsClosedWhenEndpointOrServeRouteDoesNotMatch( + string dnsName, + int proxyPort, + bool funnelEnabled, + string expected) + { + var runner = new FakeWslCommandRunner((_, command, _) => Task.FromResult( + command.Contains("serve", StringComparer.Ordinal) + ? ServeStatus(proxyPort: proxyPort, funnelEnabled: funnelEnabled) + : Status("Running", dnsName))); + var verifier = new GatewayTailscaleAuthLiveVerifier(runner, TimeSpan.FromSeconds(1)); + + var result = await verifier.VerifyAsync(ManagedRecord(), 18789, CancellationToken.None); + + Assert.Equal(expected, result.ToString()); + } + + [Fact] + public async Task VerifyAsync_UnrelatedServeHostIsNotReady() + { + var runner = new FakeWslCommandRunner((_, command, _) => Task.FromResult( + command.Contains("serve", StringComparer.Ordinal) + ? ServeStatus(host: "other.tail.example") + : RunningStatus())); + var verifier = new GatewayTailscaleAuthLiveVerifier(runner, TimeSpan.FromSeconds(1)); + + var result = await verifier.VerifyAsync(ManagedRecord(), 18789, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthLiveState.NotReady, result); + } + + [Theory] + [InlineData(8443, "", "NotReady")] + [InlineData(443, "/unrelated", "NotReady")] + [InlineData(443, "/app/..", "NotReady")] + [InlineData(443, "\\backend", "NotReady")] + [InlineData(443, "?target=other", "NotReady")] + [InlineData(443, "#fragment", "NotReady")] + public async Task VerifyAsync_RequiresExactPublicEndpointAndCoreRootProxy( + int endpointPort, + string proxySuffix, + string expected) + { + var runner = new FakeWslCommandRunner((_, command, _) => Task.FromResult( + command.Contains("serve", StringComparer.Ordinal) + ? ServeStatus(endpointPort: endpointPort, proxySuffix: proxySuffix) + : RunningStatus())); + var verifier = new GatewayTailscaleAuthLiveVerifier(runner, TimeSpan.FromSeconds(1)); + + var result = await verifier.VerifyAsync(ManagedRecord(), 18789, CancellationToken.None); + + Assert.Equal(expected, result.ToString()); + } + + [Theory] + [InlineData("user:password@")] + [InlineData("@")] + public async Task VerifyAsync_RejectsProxyUserInfo(string proxyUserInfo) + { + var runner = new FakeWslCommandRunner((_, command, _) => Task.FromResult( + command.Contains("serve", StringComparer.Ordinal) + ? ServeStatus(proxyUserInfo: proxyUserInfo) + : RunningStatus())); + var verifier = new GatewayTailscaleAuthLiveVerifier(runner, TimeSpan.FromSeconds(1)); + + var result = await verifier.VerifyAsync(ManagedRecord(), 18789, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthLiveState.NotReady, result); + } + + [Theory] + [InlineData("http://host.tail.example:443")] + [InlineData("https://user@host.tail.example:443")] + [InlineData("https://@host.tail.example:443")] + [InlineData("https://host.tail.example:443/path")] + [InlineData("https://host.tail.example:443/app/..")] + [InlineData("https://host.tail.example:443\\app")] + [InlineData("https://host.tail.example:443?query=1")] + [InlineData("https://host.tail.example:443#fragment")] + public async Task VerifyAsync_RejectsNonCanonicalServeEndpoint(string endpoint) + { + var endpointJson = JsonSerializer.Serialize(endpoint); + var serveJson = $$""" + { + "Web": { + {{endpointJson}}: { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:18789" } } + } + } + } + """; + var runner = new FakeWslCommandRunner((_, command, _) => Task.FromResult( + command.Contains("serve", StringComparer.Ordinal) + ? new WslCommandResult(0, serveJson, "") + : RunningStatus())); + var verifier = new GatewayTailscaleAuthLiveVerifier(runner, TimeSpan.FromSeconds(1)); + + var result = await verifier.VerifyAsync(ManagedRecord(), 18789, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthLiveState.NotReady, result); + } + + [Theory] + [InlineData("[]")] + [InlineData("null")] + [InlineData("{\"Web\":[]}")] + [InlineData("{\"Web\":{\"host.tail.example:443\":[]}}")] + public async Task VerifyAsync_StructurallyUnexpectedServeStatusIsUnavailable(string serveJson) + { + var runner = new FakeWslCommandRunner((_, command, _) => Task.FromResult( + command.Contains("serve", StringComparer.Ordinal) + ? new WslCommandResult(0, serveJson, "") + : RunningStatus())); + var verifier = new GatewayTailscaleAuthLiveVerifier(runner, TimeSpan.FromSeconds(1)); + + var result = await verifier.VerifyAsync(ManagedRecord(), 18789, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthLiveState.Unavailable, result); + } + + [Theory] + [InlineData("[]")] + [InlineData("42")] + [InlineData("{\"Proxy\":42}")] + public async Task VerifyAsync_MalformedSiblingHandlerIsUnavailable(string malformedHandler) + { + var serveJson = $$""" + { + "Web": { + "host.tail.example:443": { + "Handlers": { + "/": { "Proxy": "http://127.0.0.1:18789" }, + "/bad": {{malformedHandler}} + } + } + } + } + """; + var runner = new FakeWslCommandRunner((_, command, _) => Task.FromResult( + command.Contains("serve", StringComparer.Ordinal) + ? new WslCommandResult(0, serveJson, "") + : RunningStatus())); + var verifier = new GatewayTailscaleAuthLiveVerifier(runner, TimeSpan.FromSeconds(1)); + + var result = await verifier.VerifyAsync(ManagedRecord(), 18789, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthLiveState.Unavailable, result); + } + + [Theory] + [InlineData("\"AllowFunnel\": false, \"Funnel\": true", "NotReady")] + [InlineData("\"AllowFunnel\": 42", "Unavailable")] + [InlineData("\"Funnel\": [false, {\"legacy\": true}]", "NotReady")] + public async Task VerifyAsync_EvaluatesEveryFunnelAliasAndRejectsUnknownShapes( + string funnelProperties, + string expected) + { + var serveJson = $$""" + { + "Web": { + "host.tail.example:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:18789" } } + } + }, + {{funnelProperties}} + } + """; + var runner = new FakeWslCommandRunner((_, command, _) => Task.FromResult( + command.Contains("serve", StringComparer.Ordinal) + ? new WslCommandResult(0, serveJson, "") + : RunningStatus())); + var verifier = new GatewayTailscaleAuthLiveVerifier(runner, TimeSpan.FromSeconds(1)); + + var result = await verifier.VerifyAsync(ManagedRecord(), 18789, CancellationToken.None); + + Assert.Equal(expected, result.ToString()); + } + + public static TheoryData StatusResults => new() + { + { RunningStatus(), "Ready" }, + { Status("NeedsLogin", "host.tail.example."), "NotReady" }, + { Status("Stopped", "host.tail.example."), "NotReady" }, + { Status("Running", ""), "NotReady" }, + { new WslCommandResult(0, "{}", ""), "NotReady" }, + { new WslCommandResult(0, "not-json", ""), "Unavailable" }, + { new WslCommandResult(1, "", "tailscaled unavailable"), "Unavailable" }, + }; + + private static WslCommandResult RunningStatus() => Status("Running", "host.tail.example."); + + private static WslCommandResult ServeStatus( + string host = "host.tail.example", + int endpointPort = 443, + int proxyPort = 18789, + string proxySuffix = "", + string proxyUserInfo = "", + bool funnelEnabled = false) + { + var proxyJson = JsonSerializer.Serialize( + $"http://{proxyUserInfo}127.0.0.1:{proxyPort}{proxySuffix}"); + return new( + 0, + $$""" + { + "Web": { + "{{host}}:{{endpointPort}}": { + "Handlers": { "/": { "Proxy": {{proxyJson}} } } + } + }, + "AllowFunnel": { "{{host}}:{{endpointPort}}": {{funnelEnabled.ToString().ToLowerInvariant()}} } + } + """, + ""); + } + + private static string GetRepositoryRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if ((Directory.Exists(Path.Combine(current.FullName, ".git")) || + File.Exists(Path.Combine(current.FullName, ".git"))) && + File.Exists(Path.Combine( + current.FullName, + "src", + "OpenClaw.Connection", + "GatewayConnectionManager.cs"))) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new InvalidOperationException("Could not find repository root."); + } + + private static WslCommandResult Status(string backendState, string dnsName) => + new( + 0, + $"{{\"BackendState\":\"{backendState}\",\"Self\":{{\"DNSName\":\"{dnsName}\"}}}}", + ""); + + private static GatewayRecord ManagedRecord() => new() + { + Id = "gateway-1", + Url = "wss://host.tail.example", + IsLocal = true, + SetupManagedDistroName = "OpenClawGateway", + TrustTailscaleAuth = true, + }; + + private sealed class FakeWslCommandRunner( + Func, CancellationToken, Task> runInDistro) + : IWslCommandRunner + { + public int ProbeCalls { get; private set; } + public string? DistroName { get; private set; } + public List> Commands { get; } = []; + public List> HostArguments { get; } = []; + + public Task RunInDistroAsync( + string name, + IReadOnlyList command, + CancellationToken cancellationToken = default, + IReadOnlyDictionary? environment = null) + => throw new NotSupportedException(); + + public Task RunAsync( + IReadOnlyList arguments, + CancellationToken cancellationToken = default, + IReadOnlyDictionary? environment = null) + { + HostArguments.Add(arguments); + Assert.True(arguments.Count >= 6); + Assert.Equal("-d", arguments[0]); + Assert.Equal("--user", arguments[2]); + Assert.Equal("root", arguments[3]); + Assert.Equal("--", arguments[4]); + ProbeCalls++; + DistroName = arguments[1]; + var command = arguments.Skip(5).ToArray(); + Commands.Add(command); + return runInDistro(DistroName, command, cancellationToken); + } + + public Task> ListDistrosAsync( + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task TerminateDistroAsync( + string name, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task UnregisterDistroAsync( + string name, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + } +} diff --git a/tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthUpgradeTests.cs b/tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthUpgradeTests.cs new file mode 100644 index 000000000..ad57425d9 --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthUpgradeTests.cs @@ -0,0 +1,780 @@ +using System.Text.Json; +using OpenClaw.Shared; +using OpenClaw.TestSupport; + +namespace OpenClaw.Connection.Tests; + +public sealed class GatewayTailscaleAuthUpgradeTests : IDisposable +{ + private readonly TempDirectory _temp = new(); + + [Fact] + public async Task EnableAsync_PersistsRecordBeforePatchingCore() + { + var registry = CreateRegistry(); + var client = new FakeConfigClient(Config(allowTailscale: false)); + client.BeforePatch = () => Assert.True(registry.GetActive()!.TrustTailscaleAuth); + var service = new GatewayTailscaleAuthUpgradeService(registry); + + var result = await service.EnableAsync("gateway-1", client, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthUpgradeOutcome.Succeeded, result.Outcome); + Assert.True(registry.GetActive()!.TrustTailscaleAuth); + Assert.Equal("test-token-placeholder", registry.GetActive()!.SharedGatewayToken); + Assert.Equal(1, client.PatchCalls); + Assert.Equal("base-1", client.PatchBaseHash); + Assert.True(client.PatchedConfig!.Value + .GetProperty("gateway") + .GetProperty("auth") + .GetProperty("allowTailscale") + .GetBoolean()); + Assert.False(client.PatchedConfig.Value.TryGetProperty("unrelated", out _)); + + var reloaded = new GatewayRegistry(_temp.Path); + reloaded.Load(); + Assert.True(reloaded.GetActive()!.TrustTailscaleAuth); + Assert.Equal("test-token-placeholder", reloaded.GetActive()!.SharedGatewayToken); + } + + [Fact] + public async Task EnableAsync_CoreAlreadyEnabled_RequiresAcceptedIdempotentPatch() + { + var registry = CreateRegistry(); + var client = new FakeConfigClient(Config(allowTailscale: true)); + var service = new GatewayTailscaleAuthUpgradeService(registry); + + var result = await service.EnableAsync("gateway-1", client, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthUpgradeOutcome.Succeeded, result.Outcome); + Assert.True(registry.GetActive()!.TrustTailscaleAuth); + Assert.Equal(1, client.PatchCalls); + } + + [Fact] + public async Task EnableAsync_PersistedMarkerRevalidatesLiveCoreState() + { + var registry = CreateRegistry(); + registry.Update("gateway-1", current => current with { TrustTailscaleAuth = true }); + registry.Save(); + var client = new FakeConfigClient(Config(allowTailscale: false)); + var service = new GatewayTailscaleAuthUpgradeService(registry); + + var result = await service.EnableAsync("gateway-1", client, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthUpgradeOutcome.Succeeded, result.Outcome); + Assert.True(registry.GetActive()!.TrustTailscaleAuth); + Assert.Equal(1, client.ConfigRequests); + Assert.Equal(1, client.PatchCalls); + } + + [Fact] + public async Task EnableAsync_PersistedMarkerAndLiveCoreStateReturnAlreadyEnabled() + { + var registry = CreateRegistry(); + registry.Update("gateway-1", current => current with { TrustTailscaleAuth = true }); + registry.Save(); + var client = new FakeConfigClient(Config(allowTailscale: true)); + var service = new GatewayTailscaleAuthUpgradeService(registry); + + var result = await service.EnableAsync("gateway-1", client, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthUpgradeOutcome.AlreadyEnabled, result.Outcome); + Assert.True(registry.GetActive()!.TrustTailscaleAuth); + Assert.Equal(1, client.ConfigRequests); + Assert.Equal(0, client.PatchCalls); + } + + [Fact] + public async Task EnableAsync_PersistedMarkerDoesNotRequirePatchHashWhenCoreAllowsTailscale() + { + var registry = CreateRegistry(); + registry.Update("gateway-1", current => current with { TrustTailscaleAuth = true }); + registry.Save(); + var client = new FakeConfigClient(Config(allowTailscale: true, includeBaseHash: false)); + var service = new GatewayTailscaleAuthUpgradeService(registry); + + var result = await service.EnableAsync("gateway-1", client, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthUpgradeOutcome.AlreadyEnabled, result.Outcome); + Assert.Equal(0, client.PatchCalls); + } + + [Fact] + public async Task EnableAsync_MissingBaseHash_FailsClosedBeforePatch() + { + var registry = CreateRegistry(); + var client = new FakeConfigClient(Config(allowTailscale: false, includeBaseHash: false)); + var service = new GatewayTailscaleAuthUpgradeService(registry); + + var result = await service.EnableAsync("gateway-1", client, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthUpgradeOutcome.ConfigUnavailable, result.Outcome); + Assert.Equal(0, client.PatchCalls); + Assert.False(registry.GetActive()!.TrustTailscaleAuth); + } + + [Fact] + public async Task EnableAsync_PatchRejected_LeavesRecordAndCredentialsUntouched() + { + var registry = CreateRegistry(); + var client = new FakeConfigClient(Config(allowTailscale: false)) + { + PatchResult = new ConfigPatchResult + { + Ok = false, + Error = "rejected", + IsGatewayRejection = true, + }, + }; + var service = new GatewayTailscaleAuthUpgradeService(registry); + + var result = await service.EnableAsync("gateway-1", client, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthUpgradeOutcome.PatchRejected, result.Outcome); + Assert.False(registry.GetActive()!.TrustTailscaleAuth); + Assert.Equal("test-token-placeholder", registry.GetActive()!.SharedGatewayToken); + } + + [Fact] + public async Task EnableAsync_AmbiguousPatchFailure_PreservesMarkerForRevalidation() + { + var registry = CreateRegistry(); + var client = new FakeConfigClient(Config(allowTailscale: false)) + { + PatchResult = new ConfigPatchResult { Ok = false, Error = "request timed out" }, + }; + var service = new GatewayTailscaleAuthUpgradeService(registry); + + var result = await service.EnableAsync("gateway-1", client, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthUpgradeOutcome.PatchRejected, result.Outcome); + Assert.True(registry.GetActive()!.TrustTailscaleAuth); + Assert.Equal("test-token-placeholder", registry.GetActive()!.SharedGatewayToken); + } + + [Fact] + public async Task EnableAsync_MissingScopes_DoesNotRequestOrMutateConfig() + { + var registry = CreateRegistry(); + var client = new FakeConfigClient(Config(allowTailscale: false)) + { + Scopes = ["operator.read"], + }; + var service = new GatewayTailscaleAuthUpgradeService(registry); + + var result = await service.EnableAsync("gateway-1", client, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthUpgradeOutcome.MissingConfigScope, result.Outcome); + Assert.Equal(0, client.ConfigRequests); + Assert.False(registry.GetActive()!.TrustTailscaleAuth); + } + + [Fact] + public async Task EnableAsync_SaveFailure_RestoresInMemoryMarker() + { + var registry = CreateRegistry(new FailingWriteFileSystem()); + var client = new FakeConfigClient(Config(allowTailscale: true)); + var service = new GatewayTailscaleAuthUpgradeService(registry); + + var result = await service.EnableAsync("gateway-1", client, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthUpgradeOutcome.PersistenceFailed, result.Outcome); + Assert.False(registry.GetActive()!.TrustTailscaleAuth); + Assert.Equal("test-token-placeholder", registry.GetActive()!.SharedGatewayToken); + Assert.Equal(0, client.PatchCalls); + } + + [Fact] + public async Task RevalidateAsync_CoreStillAllowsTailscale_KeepsMarkerAndReturnsTrue() + { + var registry = CreateRegistry(); + registry.Update("gateway-1", current => current with { TrustTailscaleAuth = true }); + registry.Save(); + var client = new FakeConfigClient(Config(allowTailscale: true)); + var liveVerifier = new FakeLiveVerifier(GatewayTailscaleAuthLiveState.Ready); + var service = new GatewayTailscaleAuthUpgradeService(registry, liveVerifier); + + var trusted = await service.RevalidateAsync("gateway-1", client, CancellationToken.None); + + Assert.True(trusted); + Assert.True(registry.GetActive()!.TrustTailscaleAuth); + Assert.Equal(1, client.ConfigRequests); + Assert.Equal(0, client.PatchCalls); + Assert.Equal(1, liveVerifier.Calls); + Assert.Equal(18789, liveVerifier.LastGatewayPort); + } + + [Fact] + public async Task RevalidateAsync_OmittedGatewayPort_UsesCoreDefault() + { + var registry = CreateRegistry(); + registry.Update("gateway-1", current => current with { TrustTailscaleAuth = true }); + registry.Save(); + var client = new FakeConfigClient(Config(allowTailscale: true, includeGatewayPort: false)); + var liveVerifier = new FakeLiveVerifier(GatewayTailscaleAuthLiveState.Ready); + var service = new GatewayTailscaleAuthUpgradeService(registry, liveVerifier); + + var trusted = await service.RevalidateAsync("gateway-1", client, CancellationToken.None); + + Assert.True(trusted); + Assert.Equal(18789, liveVerifier.LastGatewayPort); + } + + [Theory] + [InlineData("0")] + [InlineData("65536")] + [InlineData("\"18789\"")] + public async Task RevalidateAsync_InvalidExplicitGatewayPort_FailsClosed(string gatewayPort) + { + var registry = CreateRegistry(); + registry.Update("gateway-1", current => current with { TrustTailscaleAuth = true }); + registry.Save(); + var client = new FakeConfigClient(ConfigWithGatewayPort(gatewayPort)); + var liveVerifier = new FakeLiveVerifier(GatewayTailscaleAuthLiveState.Ready); + var service = new GatewayTailscaleAuthUpgradeService(registry, liveVerifier); + + var trusted = await service.RevalidateAsync("gateway-1", client, CancellationToken.None); + + Assert.False(trusted); + Assert.Equal(0, liveVerifier.Calls); + } + + [Fact] + public async Task RevalidateAsync_CoreAllowsWithoutLiveVerifier_PreservesMarkerAndTokenAndReturnsFalse() + { + var registry = CreateRegistry(); + registry.Update("gateway-1", current => current with { TrustTailscaleAuth = true }); + registry.Save(); + var client = new FakeConfigClient(Config(allowTailscale: true)); + var service = new GatewayTailscaleAuthUpgradeService(registry); + + var trusted = await service.RevalidateAsync("gateway-1", client, CancellationToken.None); + + Assert.False(trusted); + Assert.True(registry.GetActive()!.TrustTailscaleAuth); + Assert.Equal("test-token-placeholder", registry.GetActive()!.SharedGatewayToken); + Assert.Equal(1, client.ConfigRequests); + + var reloaded = new GatewayRegistry(_temp.Path); + reloaded.Load(); + Assert.True(reloaded.GetActive()!.TrustTailscaleAuth); + Assert.Equal("test-token-placeholder", reloaded.GetActive()!.SharedGatewayToken); + } + + [Fact] + public async Task RevalidateAsync_CoreAllowsButLiveForwardedIdentityIsUnavailable_KeepsMarkerAndReturnsFalse() + { + var registry = CreateRegistry(); + registry.Update("gateway-1", current => current with { TrustTailscaleAuth = true }); + registry.Save(); + var client = new FakeConfigClient(Config(allowTailscale: true)); + var liveVerifier = new FakeLiveVerifier(GatewayTailscaleAuthLiveState.NotReady); + var service = new GatewayTailscaleAuthUpgradeService(registry, liveVerifier); + + var trusted = await service.RevalidateAsync("gateway-1", client, CancellationToken.None); + + Assert.False(trusted); + Assert.True(registry.GetActive()!.TrustTailscaleAuth); + Assert.Equal(1, client.ConfigRequests); + Assert.Equal(1, liveVerifier.Calls); + + var reloaded = new GatewayRegistry(_temp.Path); + reloaded.Load(); + Assert.True(reloaded.GetActive()!.TrustTailscaleAuth); + } + + [Fact] + public async Task RevalidateAsync_LiveVerifierFailure_KeepsMarkerAndReturnsFalse() + { + var registry = CreateRegistry(); + registry.Update("gateway-1", current => current with { TrustTailscaleAuth = true }); + registry.Save(); + var client = new FakeConfigClient(Config(allowTailscale: true)); + var liveVerifier = new FakeLiveVerifier(new InvalidOperationException("live probe unavailable")); + var service = new GatewayTailscaleAuthUpgradeService(registry, liveVerifier); + + var trusted = await service.RevalidateAsync("gateway-1", client, CancellationToken.None); + + Assert.False(trusted); + Assert.True(registry.GetActive()!.TrustTailscaleAuth); + Assert.Equal(1, liveVerifier.Calls); + } + + [Fact] + public async Task RevalidateAsync_LiveVerifierRecovery_ReturnsTrueWithoutReissuingMarker() + { + var registry = CreateRegistry(); + registry.Update("gateway-1", current => current with { TrustTailscaleAuth = true }); + registry.Save(); + var client = new FakeConfigClient(Config(allowTailscale: true)); + var liveVerifier = new FakeLiveVerifier( + GatewayTailscaleAuthLiveState.Unavailable, + GatewayTailscaleAuthLiveState.Ready); + var service = new GatewayTailscaleAuthUpgradeService(registry, liveVerifier); + + var unavailable = await service.RevalidateAsync("gateway-1", client, CancellationToken.None); + var recovered = await service.RevalidateAsync("gateway-1", client, CancellationToken.None); + + Assert.False(unavailable); + Assert.True(recovered); + Assert.True(registry.GetActive()!.TrustTailscaleAuth); + Assert.Equal(2, client.ConfigRequests); + Assert.Equal(2, liveVerifier.Calls); + } + + [Fact] + public async Task RevalidateAsync_ConfigFailurePreservesMarkerAndSkipsLiveVerifier() + { + var registry = CreateRegistry(); + registry.Update("gateway-1", current => current with { TrustTailscaleAuth = true }); + registry.Save(); + var client = new FakeConfigClient(Config(allowTailscale: true)) + { + ConfigError = new InvalidOperationException("config unavailable"), + }; + var liveVerifier = new FakeLiveVerifier(GatewayTailscaleAuthLiveState.Ready); + var service = new GatewayTailscaleAuthUpgradeService(registry, liveVerifier); + + var trusted = await service.RevalidateAsync("gateway-1", client, CancellationToken.None); + + Assert.False(trusted); + Assert.True(registry.GetActive()!.TrustTailscaleAuth); + Assert.Equal(1, client.ConfigRequests); + Assert.Equal(0, liveVerifier.Calls); + } + + [Fact] + public async Task RevalidateAsync_OmittedExplicitGrantClearsMarker() + { + var registry = CreateRegistry(); + registry.Update("gateway-1", current => current with { TrustTailscaleAuth = true }); + registry.Save(); + var client = new FakeConfigClient(ImplicitServeConfig()); + var service = new GatewayTailscaleAuthUpgradeService(registry); + + var trusted = await service.RevalidateAsync("gateway-1", client, CancellationToken.None); + + Assert.False(trusted); + Assert.False(registry.GetActive()!.TrustTailscaleAuth); + Assert.Equal(1, client.ConfigRequests); + Assert.Equal(0, client.PatchCalls); + } + + [Fact] + public async Task RevalidateAsync_CoreDisablesTailscale_ClearsPersistedMarkerAndReturnsFalse() + { + var registry = CreateRegistry(); + registry.Update("gateway-1", current => current with { TrustTailscaleAuth = true }); + registry.Save(); + var client = new FakeConfigClient(Config(allowTailscale: false)); + var liveVerifier = new FakeLiveVerifier(GatewayTailscaleAuthLiveState.Ready); + var service = new GatewayTailscaleAuthUpgradeService(registry, liveVerifier); + + var trusted = await service.RevalidateAsync("gateway-1", client, CancellationToken.None); + + Assert.False(trusted); + Assert.False(registry.GetActive()!.TrustTailscaleAuth); + Assert.Equal("test-token-placeholder", registry.GetActive()!.SharedGatewayToken); + Assert.Equal(0, client.PatchCalls); + Assert.Equal(0, liveVerifier.Calls); + + var reloaded = new GatewayRegistry(_temp.Path); + reloaded.Load(); + Assert.False(reloaded.GetActive()!.TrustTailscaleAuth); + } + + [Fact] + public async Task RevalidateAsync_CallerCancellationRemainsCancellation() + { + var registry = CreateRegistry(); + registry.Update("gateway-1", current => current with { TrustTailscaleAuth = true }); + var client = new FakeConfigClient(Config(allowTailscale: true)) { EmitConfig = false }; + var service = new GatewayTailscaleAuthUpgradeService(registry); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => + service.RevalidateAsync("gateway-1", client, cancellation.Token)); + } + + [Fact] + public async Task EnableAsync_CancellationAfterConfigReadDoesNotMutateOrPatch() + { + using var cancellation = new CancellationTokenSource(); + var registry = CreateRegistry(); + var client = new FakeConfigClient(Config(allowTailscale: false)) + { + AfterConfig = cancellation.Cancel, + }; + var service = new GatewayTailscaleAuthUpgradeService(registry); + + await Assert.ThrowsAnyAsync(() => + service.EnableAsync("gateway-1", client, cancellation.Token)); + + Assert.False(registry.GetActive()!.TrustTailscaleAuth); + Assert.Equal(0, client.PatchCalls); + } + + [Fact] + public async Task EnableAsync_CancellationAfterMarkerPersistenceRollsBackBeforeDispatch() + { + using var cancellation = new CancellationTokenSource(); + var registry = CreateRegistry(new CancelOnFirstWriteFileSystem(cancellation)); + var client = new FakeConfigClient(Config(allowTailscale: false)); + var service = new GatewayTailscaleAuthUpgradeService(registry); + + await Assert.ThrowsAnyAsync(() => + service.EnableAsync("gateway-1", client, cancellation.Token)); + + Assert.False(registry.GetActive()!.TrustTailscaleAuth); + Assert.Equal(0, client.PatchCalls); + var reloaded = new GatewayRegistry(_temp.Path); + reloaded.Load(); + Assert.False(reloaded.GetActive()!.TrustTailscaleAuth); + } + + [Fact] + public async Task EnableAsync_CancellationBeforeDispatchSurfacesRollbackPersistenceFailure() + { + using var cancellation = new CancellationTokenSource(); + var registry = CreateRegistry(new CancelThenFailRollbackFileSystem(cancellation)); + var client = new FakeConfigClient(Config(allowTailscale: false)); + var service = new GatewayTailscaleAuthUpgradeService(registry); + + var result = await service.EnableAsync("gateway-1", client, cancellation.Token); + + Assert.Equal(GatewayTailscaleAuthUpgradeOutcome.PersistenceFailed, result.Outcome); + Assert.Contains("trust-marker rollback failed", result.Error); + Assert.DoesNotContain("Core rejected", result.Error); + Assert.Equal(0, client.PatchCalls); + var reloaded = new GatewayRegistry(_temp.Path); + reloaded.Load(); + Assert.True(reloaded.GetActive()!.TrustTailscaleAuth); + } + + [Fact] + public async Task EnableAsync_CancellationDuringPatchPreservesMarkerForRevalidation() + { + using var cancellation = new CancellationTokenSource(); + var registry = CreateRegistry(); + var patchCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var client = new FakeConfigClient(Config(allowTailscale: false)) + { + PatchCompletion = patchCompletion, + }; + var service = new GatewayTailscaleAuthUpgradeService(registry); + + var enableTask = service.EnableAsync("gateway-1", client, cancellation.Token); + await client.PatchStarted.Task.WaitAsync(TimeSpan.FromSeconds(1)); + cancellation.Cancel(); + + Assert.False(enableTask.IsCompleted); + patchCompletion.SetResult(new ConfigPatchResult { Ok = true }); + await Assert.ThrowsAnyAsync(() => enableTask); + Assert.True(registry.GetActive()!.TrustTailscaleAuth); + Assert.Equal(1, client.PatchCalls); + } + + [Fact] + public async Task EnableAsync_CancellationDuringPatchRollsBackDefinitiveGatewayRejection() + { + using var cancellation = new CancellationTokenSource(); + var registry = CreateRegistry(); + var patchCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var client = new FakeConfigClient(Config(allowTailscale: false)) + { + PatchCompletion = patchCompletion, + }; + var service = new GatewayTailscaleAuthUpgradeService(registry); + + var enableTask = service.EnableAsync("gateway-1", client, cancellation.Token); + await client.PatchStarted.Task.WaitAsync(TimeSpan.FromSeconds(1)); + cancellation.Cancel(); + patchCompletion.SetResult(new ConfigPatchResult + { + Ok = false, + Error = "base hash mismatch", + IsGatewayRejection = true, + }); + + await Assert.ThrowsAnyAsync(() => enableTask); + Assert.False(registry.GetActive()!.TrustTailscaleAuth); + } + + [Fact] + public async Task EnableAsync_CancellationDuringPatchSurfacesRollbackPersistenceFailure() + { + using var cancellation = new CancellationTokenSource(); + var registry = CreateRegistry(new FailOnSecondWriteFileSystem()); + var patchCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var client = new FakeConfigClient(Config(allowTailscale: false)) + { + PatchCompletion = patchCompletion, + }; + var service = new GatewayTailscaleAuthUpgradeService(registry); + + var enableTask = service.EnableAsync("gateway-1", client, cancellation.Token); + await client.PatchStarted.Task.WaitAsync(TimeSpan.FromSeconds(1)); + cancellation.Cancel(); + patchCompletion.SetResult(new ConfigPatchResult + { + Ok = false, + Error = "base hash mismatch", + IsGatewayRejection = true, + }); + + var result = await enableTask; + Assert.Equal(GatewayTailscaleAuthUpgradeOutcome.PersistenceFailed, result.Outcome); + var reloaded = new GatewayRegistry(_temp.Path); + reloaded.Load(); + Assert.True(reloaded.GetActive()!.TrustTailscaleAuth); + } + + [Fact] + public async Task RevalidateAsync_EditedNonTailscaleEndpointDoesNotTrustMarker() + { + var registry = CreateRegistry(); + registry.Update("gateway-1", current => current with + { + Url = "wss://gateway.example.test", + TrustTailscaleAuth = true, + }); + var client = new FakeConfigClient(Config(allowTailscale: true)); + var service = new GatewayTailscaleAuthUpgradeService(registry); + + var trusted = await service.RevalidateAsync("gateway-1", client, CancellationToken.None); + + Assert.False(trusted); + Assert.Equal(0, client.ConfigRequests); + } + + [Theory] + [MemberData(nameof(IneligibleRecords))] + public void CanOffer_RejectsRecordsOutsideManagedTailnetBoundary(GatewayRecord record) => + Assert.False(GatewayTailscaleAuthUpgradePolicy.CanOffer(record)); + + public static TheoryData IneligibleRecords => new() + { + ManagedRecord() with { IsLocal = false }, + ManagedRecord() with { SetupManagedDistroName = null }, + ManagedRecord() with { SshTunnel = new SshTunnelConfig("user", "host", 18789, 18789) }, + ManagedRecord() with { Url = "ws://127.0.0.1:18789" }, + ManagedRecord() with { Url = "wss://gateway.example.test" }, + }; + + public void Dispose() => _temp.Dispose(); + + private GatewayRegistry CreateRegistry(IFileSystem? fileSystem = null) + { + var registry = new GatewayRegistry(_temp.Path, fileSystem); + registry.AddOrUpdate(ManagedRecord()); + registry.SetActive("gateway-1"); + return registry; + } + + private static GatewayRecord ManagedRecord() => new() + { + Id = "gateway-1", + Url = "wss://openclaw-host.tail1234.ts.net", + IsLocal = true, + SetupManagedDistroName = "OpenClawGateway", + SharedGatewayToken = "test-token-placeholder", + }; + + private static JsonElement Config( + bool allowTailscale, + bool includeBaseHash = true, + bool includeGatewayPort = true) + { + using var document = JsonDocument.Parse($$""" + { + "parsed": { + "gateway": { + {{(includeGatewayPort ? "\"port\": 18789," : "")}} + "auth": { "allowTailscale": {{allowTailscale.ToString().ToLowerInvariant()}} } + }, + "unrelated": "keep" + }{{(includeBaseHash ? ",\n \"baseHash\": \"base-1\"" : "")}} + } + """); + return document.RootElement.Clone(); + } + + private static JsonElement ConfigWithGatewayPort(string gatewayPort) + { + using var document = JsonDocument.Parse($$""" + { + "parsed": { + "gateway": { + "port": {{gatewayPort}}, + "auth": { "allowTailscale": true } + } + } + } + """); + return document.RootElement.Clone(); + } + + private static JsonElement ImplicitServeConfig() + { + using var document = JsonDocument.Parse(""" + { + "parsed": { + "gateway": { + "auth": { "mode": "token" }, + "tailscale": { "mode": "serve" } + } + }, + "baseHash": "base-1" + } + """); + return document.RootElement.Clone(); + } + + private sealed class FakeConfigClient(JsonElement config) : IGatewayTailscaleAuthConfigClient + { + public IReadOnlyList Scopes { get; set; } = ["operator.read", "operator.write"]; + public IReadOnlyList GrantedOperatorScopes => Scopes; + public bool IsConnectedToGateway { get; set; } = true; + public int ConfigRequests { get; private set; } + public int PatchCalls { get; private set; } + public string? PatchBaseHash { get; private set; } + public JsonElement? PatchedConfig { get; private set; } + public ConfigPatchResult PatchResult { get; set; } = new() { Ok = true }; + public Exception? ConfigError { get; set; } + public bool EmitConfig { get; set; } = true; + public Action? AfterConfig { get; set; } + public Action? BeforePatch { get; set; } + public TaskCompletionSource? PatchCompletion { get; set; } + public TaskCompletionSource PatchStarted { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public Task RequestConfigDetailedAsync(int timeoutMs = 15000) + { + ConfigRequests++; + if (ConfigError is not null) + return Task.FromException(ConfigError); + + var result = EmitConfig + ? Task.FromResult(config.Clone()) + : new TaskCompletionSource().Task; + AfterConfig?.Invoke(); + return result; + } + + public Task PatchConfigDetailedAsync( + JsonElement fullConfig, + string? baseHash, + int timeoutMs = 15000) + { + BeforePatch?.Invoke(); + PatchCalls++; + PatchBaseHash = baseHash; + PatchedConfig = fullConfig.Clone(); + PatchStarted.TrySetResult(); + return PatchCompletion?.Task ?? Task.FromResult(PatchResult); + } + } + + private sealed class CancelOnFirstWriteFileSystem(CancellationTokenSource cancellation) : IFileSystem + { + private readonly RealFileSystem _inner = new(); + private int _writes; + + public bool FileExists(string path) => _inner.FileExists(path); + public string ReadAllText(string path) => _inner.ReadAllText(path); + public void WriteAllText(string path, string content) + { + _inner.WriteAllText(path, content); + if (Interlocked.Increment(ref _writes) == 1) + cancellation.Cancel(); + } + public void CreateDirectory(string path) => _inner.CreateDirectory(path); + public bool DirectoryExists(string path) => _inner.DirectoryExists(path); + public void CopyFile(string source, string destination, bool overwrite) => + _inner.CopyFile(source, destination, overwrite); + public void DeleteFile(string path) => _inner.DeleteFile(path); + } + + private sealed class CancelThenFailRollbackFileSystem(CancellationTokenSource cancellation) : IFileSystem + { + private readonly RealFileSystem _inner = new(); + private int _writes; + + public bool FileExists(string path) => _inner.FileExists(path); + public string ReadAllText(string path) => _inner.ReadAllText(path); + public void WriteAllText(string path, string content) + { + if (Interlocked.Increment(ref _writes) == 2) + throw new IOException("rollback write failed"); + _inner.WriteAllText(path, content); + cancellation.Cancel(); + } + public void CreateDirectory(string path) => _inner.CreateDirectory(path); + public bool DirectoryExists(string path) => _inner.DirectoryExists(path); + public void CopyFile(string source, string destination, bool overwrite) => + _inner.CopyFile(source, destination, overwrite); + public void DeleteFile(string path) => _inner.DeleteFile(path); + } + + private sealed class FailOnSecondWriteFileSystem : IFileSystem + { + private readonly RealFileSystem _inner = new(); + private int _writes; + + public bool FileExists(string path) => _inner.FileExists(path); + public string ReadAllText(string path) => _inner.ReadAllText(path); + public void WriteAllText(string path, string content) + { + if (Interlocked.Increment(ref _writes) == 2) + throw new IOException("rollback write failed"); + _inner.WriteAllText(path, content); + } + public void CreateDirectory(string path) => _inner.CreateDirectory(path); + public bool DirectoryExists(string path) => _inner.DirectoryExists(path); + public void CopyFile(string source, string destination, bool overwrite) => + _inner.CopyFile(source, destination, overwrite); + public void DeleteFile(string path) => _inner.DeleteFile(path); + } + + private sealed class FailingWriteFileSystem : IFileSystem + { + public bool FileExists(string path) => false; + public string ReadAllText(string path) => throw new NotSupportedException(); + public void WriteAllText(string path, string content) => throw new IOException("write failed"); + public void CreateDirectory(string path) { } + public bool DirectoryExists(string path) => true; + public void CopyFile(string source, string destination, bool overwrite) => throw new NotSupportedException(); + public void DeleteFile(string path) { } + } + + private sealed class FakeLiveVerifier : IGatewayTailscaleAuthLiveVerifier + { + private readonly Queue _results; + + public FakeLiveVerifier(params object[] results) => _results = new Queue(results); + + public int Calls { get; private set; } + public int? LastGatewayPort { get; private set; } + + public Task VerifyAsync( + GatewayRecord record, + int gatewayPort, + CancellationToken cancellationToken) + { + Calls++; + LastGatewayPort = gatewayPort; + cancellationToken.ThrowIfCancellationRequested(); + var result = _results.Count > 1 ? _results.Dequeue() : _results.Peek(); + return result switch + { + GatewayTailscaleAuthLiveState state => Task.FromResult(state), + Exception error => Task.FromException(error), + _ => throw new InvalidOperationException("Unsupported fake live-verifier result."), + }; + } + } +} diff --git a/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs b/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs index 55d5607c2..7b1b84fcf 100644 --- a/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs +++ b/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs @@ -439,11 +439,12 @@ public async Task ExternalLike_QrOnlyFreshTray_RequiresExplicitDeviceApproval() var requestId = await WaitForFirstPendingDeviceRequestIdAsync(pendingBefore); Assert.False(string.IsNullOrWhiteSpace(requestId)); - using var dashboardDoc = await externalTray.Client.CallToolExpectSuccessAsync("app.dashboard.url"); - var dashboard = dashboardDoc.RootElement; - Assert.Equal("record.BootstrapToken", dashboard.GetProperty("credentialSource").GetString()); - Assert.False(dashboard.GetProperty("usesSharedGatewayToken").GetBoolean()); - Assert.False(dashboard.GetProperty("hasTokenQuery").GetBoolean()); + using var dashboardDoc = await externalTray.Client.CallToolAsync("app.dashboard.url"); + var dashboardResult = dashboardDoc.RootElement.GetProperty("result"); + Assert.True(dashboardResult.GetProperty("isError").GetBoolean()); + Assert.Equal( + "No browser-compatible gateway credential is available", + dashboardResult.GetProperty("content")[0].GetProperty("text").GetString()); using var rejectDoc = await RejectDevicePairingFromConnectionPageAsync(requestId); handledDeviceRequestIds.Add(requestId); diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 299c85c36..1e3db3c50 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -2603,11 +2603,23 @@ public void TailscalePolicy_ParsesAuthorizationUrlsAndOnlyAcceptsGatewayServePro } } """; + const string legacyAnyHandlerStatus = """ + { + "Web": { + "openclaw.example.ts.net:443": { + "Handlers": { + "/app": { "Proxy": "http://127.0.0.1:18789/backend" } + } + } + } + } + """; Assert.Equal("https://login.tailscale.com/a/abc_123-now", url!.AbsoluteUri); Assert.True(TailscaleSetupPolicy.ServeStatusRoutesToPort(expectedServeStatus, 18789)); Assert.False(TailscaleSetupPolicy.ServeStatusRoutesToPort(wrongBackendStatus, 18789)); Assert.False(TailscaleSetupPolicy.ServeStatusRoutesToPort(unrelatedPortStatus, 18789)); + Assert.True(TailscaleSetupPolicy.ServeStatusRoutesToPort(legacyAnyHandlerStatus, 18789)); Assert.False(TailscaleSetupPolicy.ServeStatusEnablesFunnel(expectedServeStatus, 18789)); Assert.True(TailscaleSetupPolicy.ServeStatusEnablesFunnel(funnelStatus, 18789)); } @@ -3130,6 +3142,63 @@ public async Task PairOperatorStep_FailsWhenGatewayNotReachableFromWindows() Assert.Contains("not reachable", result.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task PairOperatorStep_PersistsExplicitTailscaleIdentityTrust() + { + var config = new SetupConfig + { + GatewayPort = GetFreeTcpPort(), + Tailscale = new TailscaleConfig + { + Enabled = true, + TrustTailscaleAuth = true, + }, + }; + var context = CreateContext(config); + context.GatewayUrl = "wss://gateway.tailnet.ts.net"; + context.SharedGatewayToken = "gateway-token"; + context.DistroName = "OpenClawGateway"; + + var result = await new PairOperatorStep().ExecuteAsync(context, CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + var registry = new GatewayRegistry(_tempDir); + registry.Load(); + Assert.True(registry.GetActive()!.TrustTailscaleAuth); + } + + [Fact] + public async Task PairOperatorStep_ExtraConfigOverrideDisablesTailscaleIdentityTrust() + { + var config = new SetupConfig + { + GatewayPort = GetFreeTcpPort(), + Tailscale = new TailscaleConfig + { + Enabled = true, + TrustTailscaleAuth = true, + }, + Gateway = new GatewayConfig + { + ExtraConfig = new Dictionary + { + ["gateway.auth.allowTailscale"] = "false", + }, + }, + }; + var context = CreateContext(config); + context.GatewayUrl = "wss://gateway.tailnet.ts.net"; + context.SharedGatewayToken = "gateway-token"; + context.DistroName = "OpenClawGateway"; + + var result = await new PairOperatorStep().ExecuteAsync(context, CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + var registry = new GatewayRegistry(_tempDir); + registry.Load(); + Assert.False(registry.GetActive()!.TrustTailscaleAuth); + } + [Fact] public async Task PairOperatorStep_WhenSavedIdentityIsCorrupt_ReturnsTerminalWithoutMutation() { diff --git a/tests/OpenClaw.Shared.Tests/AppCapabilityTests.cs b/tests/OpenClaw.Shared.Tests/AppCapabilityTests.cs index 1c71bf587..496be14f2 100644 --- a/tests/OpenClaw.Shared.Tests/AppCapabilityTests.cs +++ b/tests/OpenClaw.Shared.Tests/AppCapabilityTests.cs @@ -124,6 +124,45 @@ public async Task SettingsSet_WithHandlerSuccessPayload_ReturnsData() Assert.True(res.Ok); } + [Fact] + public async Task DashboardUrl_AwaitsAsyncHandler() + { + var cap = new AppCapability(NullLogger.Instance) + { + DashboardUrlHandler = path => Task.FromResult(new { url = $"https://gateway.test/{path}" }) + }; + var req = new NodeInvokeRequest + { + Id = "1", + Command = "app.dashboard.url", + Args = ParseArgs("{\"path\":\"settings\"}") + }; + + var res = await cap.ExecuteAsync(req); + + Assert.True(res.Ok); + } + + [Fact] + public async Task DashboardUrl_PropagatesHandlerError() + { + var cap = new AppCapability(NullLogger.Instance) + { + DashboardUrlHandler = _ => Task.FromResult(new { error = "credential unavailable" }) + }; + var req = new NodeInvokeRequest + { + Id = "1", + Command = "app.dashboard.url", + Args = ParseArgs("{}") + }; + + var res = await cap.ExecuteAsync(req); + + Assert.False(res.Ok); + Assert.Equal("credential unavailable", res.Error); + } + [Fact] public async Task UnknownCommand_ReturnsError() { diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs index e42f3591e..ce69f7354 100644 --- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs @@ -613,13 +613,97 @@ await server.SendTextAsync( error = new { message = "wizard rejected" } })); - var exception = await Assert.ThrowsAsync( + var exception = await Assert.ThrowsAsync( async () => await responseTask.WaitAsync(TimeSpan.FromSeconds(2))); Assert.Equal("wizard rejected", exception.Message); Assert.Equal(0, helper.GetPendingRequestCount()); } + [Fact] + public async Task PatchConfigDetailedAsync_ClassifiesGatewayRejectionAsAuthoritative() + { + using var server = new LoopbackWebSocketServer(); + using var identity = new TempDirectory("config-patch-rejection-"); + await server.StartAsync(); + var helper = new GatewayClientTestHelper( + gatewayUrl: server.WebSocketUrl, + identityPath: identity.Path); + using var client = helper.Client; + await client.ConnectAsync(); + using var config = JsonDocument.Parse("{}"); + + var responseTask = client.PatchConfigDetailedAsync(config.RootElement, "base-hash", 10_000); + var request = await server.ReceiveTextAsync().WaitAsync(TimeSpan.FromSeconds(2)); + var requestId = ReadRequestId(request); + await server.SendTextAsync(JsonSerializer.Serialize(new + { + type = "res", + id = requestId, + ok = false, + error = new { code = "INVALID_REQUEST", message = "patch rejected" }, + })); + + var result = await responseTask.WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.False(result.Ok); + Assert.True(result.IsGatewayRejection); + Assert.Equal("patch rejected", result.Error); + } + + [Fact] + public async Task PatchConfigDetailedAsync_DoesNotClassifyUnavailableAsAuthoritative() + { + using var server = new LoopbackWebSocketServer(); + using var identity = new TempDirectory("config-patch-unavailable-"); + await server.StartAsync(); + var helper = new GatewayClientTestHelper( + gatewayUrl: server.WebSocketUrl, + identityPath: identity.Path); + using var client = helper.Client; + await client.ConnectAsync(); + using var config = JsonDocument.Parse("{}"); + + var responseTask = client.PatchConfigDetailedAsync(config.RootElement, "base-hash", 10_000); + var request = await server.ReceiveTextAsync().WaitAsync(TimeSpan.FromSeconds(2)); + var requestId = ReadRequestId(request); + await server.SendTextAsync(JsonSerializer.Serialize(new + { + type = "res", + id = requestId, + ok = false, + error = new { code = "UNAVAILABLE", message = "runtime activation failed" }, + })); + + var result = await responseTask.WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.False(result.Ok); + Assert.False(result.IsGatewayRejection); + Assert.Equal("runtime activation failed", result.Error); + } + + [Fact] + public async Task PatchConfigDetailedAsync_DoesNotClassifyTimeoutAsGatewayRejection() + { + using var server = new LoopbackWebSocketServer(); + using var identity = new TempDirectory("config-patch-timeout-"); + await server.StartAsync(); + var helper = new GatewayClientTestHelper( + gatewayUrl: server.WebSocketUrl, + identityPath: identity.Path); + using var client = helper.Client; + await client.ConnectAsync(); + using var config = JsonDocument.Parse("{}"); + + var responseTask = client.PatchConfigDetailedAsync(config.RootElement, "base-hash", 250); + await server.ReceiveTextAsync().WaitAsync(TimeSpan.FromSeconds(2)); + var result = await responseTask.WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.False(result.Ok); + Assert.False(result.IsGatewayRejection); + Assert.Contains("Timed out waiting", result.Error); + } + [Fact] public async Task SendWizardRequestAsync_DeadlineExpires_ThrowsTimeoutAndCleansTracking() { diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index b442dc582..997809015 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -451,6 +451,33 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() Assert.Contains("Check SSH tunnel settings and logs.", method); } + [Fact] + public void DashboardLaunches_UseSharedBrowserCredentialPolicy() + { + var source = ReadAppSources(); + var direct = ExtractMethod(source, "OpenDashboard"); + var revalidated = ExtractMethod(source, "OpenDashboardAfterTailscaleAuthRevalidationAsync"); + + AssertInOrder( + direct, + "if (active?.TrustTailscaleAuth == true", + "OpenDashboardAfterTailscaleAuthRevalidationAsync(", + "return;", + "GatewayDashboardUrlBuilder.HasBrowserCompatibleCredential(", + "trustTailscaleAuth: false", + "usesSharedGatewayToken: appendBrowserCredential", + "return;", + "LaunchDashboardUrl("); + AssertInOrder( + revalidated, + "trustTailscaleAuth = await", + "GatewayDashboardUrlBuilder.HasBrowserCompatibleCredential(", + "trustTailscaleAuth,", + "appendBrowserCredential", + "return;", + "LaunchDashboardUrl("); + } + [Fact] public void SshTunnelExit_RecoversActiveRegistryGatewayThroughConnectionManager() { diff --git a/tests/OpenClaw.Tray.Tests/ConnectionPageTailscaleRecoveryTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionPageTailscaleRecoveryTests.cs index e791458de..5551fcde0 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionPageTailscaleRecoveryTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionPageTailscaleRecoveryTests.cs @@ -5,6 +5,42 @@ namespace OpenClaw.Tray.Tests; public sealed class ConnectionPageTailscaleRecoveryTests { + [Fact] + public void SavedDashboardLaunch_UsesSharedBrowserCredentialPolicy() + { + var source = File.ReadAllText(Path.Combine( + TestRepositoryPaths.GetRepositoryRoot(), + "src", + "OpenClaw.Tray.WinUI", + "Pages", + "ConnectionPage.xaml.cs")); + var methodStart = source.IndexOf( + "private async Task OnSavedRowOpenDashboardAsync(object sender)", + StringComparison.Ordinal); + var methodEnd = source.IndexOf( + "private void OnEnableTailscaleDashboardAuth(object sender, RoutedEventArgs e)", + methodStart, + StringComparison.Ordinal); + Assert.True(methodStart >= 0 && methodEnd > methodStart); + var method = source[methodStart..methodEnd]; + + AssertInOrder( + method, + "var trustTailscaleAuth = rec.TrustTailscaleAuth &&", + "await _connectionManager.RevalidateTailscaleDashboardAuthAsync(rec.Id)", + "if (trustTailscaleAuth)", + "trustTailscaleAuth: true", + "LaunchUriAsync(", + "return;", + "var usesSharedGatewayToken =", + "GatewayDashboardUrlBuilder.HasBrowserCompatibleCredential(", + "trustTailscaleAuth,", + "usesSharedGatewayToken", + "return;", + "appendSharedGatewayToken: usesSharedGatewayToken", + "LaunchUriAsync("); + } + [Fact] public void NetworkFailure_ForManagedTailscaleGateway_UsesDedicatedRecoveryPlan() { @@ -64,4 +100,15 @@ public void NetworkFailure_ForUnmanagedTailscaleGateway_UsesOrdinaryNetworkRecov Assert.Equal(RecoveryCategory.Network, plan.Recovery); Assert.NotEqual("Tailscale gateway unavailable", plan.StripHeadline); } + + private static void AssertInOrder(string source, params string[] markers) + { + var current = -1; + foreach (var marker in markers) + { + var next = source.IndexOf(marker, current + 1, StringComparison.Ordinal); + Assert.True(next >= 0, $"Could not find marker after index {current}: {marker}"); + current = next; + } + } } diff --git a/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs b/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs index 367c8c2ff..019b2bb59 100644 --- a/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs +++ b/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs @@ -4,6 +4,23 @@ namespace OpenClaw.Tray.Tests; public sealed class GatewayDashboardUrlBuilderTests { + [Theory] + [InlineData(false, false, false)] + [InlineData(false, true, true)] + [InlineData(true, false, true)] + [InlineData(true, true, true)] + public void HasBrowserCompatibleCredential_RequiresTailscaleOrSharedToken( + bool trustTailscaleAuth, + bool usesSharedGatewayToken, + bool expected) + { + Assert.Equal( + expected, + GatewayDashboardUrlBuilder.HasBrowserCompatibleCredential( + trustTailscaleAuth, + usesSharedGatewayToken)); + } + [Fact] public void Build_AppendsSharedTokenToDashboardRoot() { From bac278abc92033c02f2bbf827c231de68e607691 Mon Sep 17 00:00:00 2001 From: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:19:39 +0100 Subject: [PATCH 2/6] fix(tray): centralize dashboard link policy --- docs/ARCHITECTURE.md | 2 + .../App.AppShutdownCoordinator.cs | 1 + .../App.CapabilityHandlers.cs | 30 ++-- src/OpenClaw.Tray.WinUI/App.xaml.cs | 77 ++++------ .../Helpers/GatewayDashboardUrlBuilder.cs | 8 -- .../Pages/ConnectionPage.xaml.cs | 96 ++++++------- .../Services/GatewayDashboardLinkService.cs | 88 ++++++++++++ .../Strings/en-us/Resources.resw | 18 +++ .../Strings/fr-fr/Resources.resw | 18 +++ .../Strings/nl-nl/Resources.resw | 18 +++ .../Strings/zh-cn/Resources.resw | 18 +++ .../Strings/zh-tw/Resources.resw | 18 +++ .../AppRefactorContractTests.cs | 26 +--- .../ConnectionPageTailscaleRecoveryTests.cs | 65 +++++---- .../GatewayDashboardLinkServiceTests.cs | 135 ++++++++++++++++++ .../GatewayDashboardUrlBuilderTests.cs | 17 --- .../OpenClaw.Tray.Tests.csproj | 1 + 17 files changed, 447 insertions(+), 189 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLinkService.cs create mode 100644 tests/OpenClaw.Tray.Tests/GatewayDashboardLinkServiceTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fb18bbeda..b690e2a58 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -73,6 +73,7 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. | Hub navigation tags, page mapping, command catalog/search, and gateway-page classification | `HubPageRegistry` | authoritative | | Hub notification banner severity and action projection | `AppNotificationInfoBarPresenter` | authoritative | | Tray-menu semantic composition and connection-toggle state | `TrayMenuPresenter` + `ConnectionTogglePresenter` | authoritative | +| Dashboard link Tailscale revalidation, browser-credential fallback, and fail-closed URL policy | `GatewayDashboardLinkService` | authoritative | | App-owned non-tray window creation, reuse, focus, theme, and lifetime | `IWindowManager` + `WindowManager` | authoritative | | Tray icon, popup coordination, live status, and callback lifetime | `ITrayController` + `TrayController` | authoritative | | Deep-link/protocol/toast/forwarded activation normalization, current-user IPC, and semantic activation plans | `ActivationRouter` | authoritative | @@ -169,6 +170,7 @@ leading and trailing pipe. Columns, in order: | app-window-surface-ownership-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | concrete non-tray window fields, constructors, show/hide/focus/theme/close mechanics, and window event lifetime | IWindowManager + WindowManager | interface forwarding, immutable request construction, route and policy callbacks, and setup restart dialog policy only | App cannot regain a parallel Hub, Chat, status, setup, canvas-request, or runtime-anchor owner | AppSurfaceOwnershipContractTests.App_DelegatesConcreteTrayAndWindowOwnership | source-shape | when App is replaced as the WinUI composition root | | app-tray-controller | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | tray icon creation, tray popup coordination, click routing, tooltip and live-toggle refresh, theme, callbacks, and disposal | ITrayController + TrayController | App captures immutable snapshots, implements semantic action callbacks, triggers refresh from authoritative state, preserves startup construction order, and constructs shutdown-plan callbacks | one tray icon and root menu are reused; A1 presenters retain semantics; TrayMenuWindow retains native popup mechanics; callbacks detach and resources dispose once | TrayControllerTests.Dispose_UnsubscribesAndDisposesEachResourceOnce | source-shape | when the WinUI tray surface is replaced | | app-tray-surface-ownership-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | concrete tray icon, root menu, weak live-control state, event subscriptions, popup build coordination, and resource disposal | ITrayController + TrayController | immutable snapshot and action callbacks plus state-change triggers only | App cannot regain tray controls or popup lifetime and TrayController cannot duplicate A1 semantic projection or TrayMenuWindow native mechanics | AppSurfaceOwnershipContractTests.App_DelegatesConcreteTrayAndWindowOwnership | source-shape | when the WinUI tray surface is replaced | +| dashboard-link-policy | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs + App.CapabilityHandlers.cs + Pages/ConnectionPage.xaml.cs | duplicated Tailscale revalidation, browser-credential fallback, and dashboard URL policy | GatewayDashboardLinkService | callers retain UI launch, error, provenance, and MCP response side effects | trusted Tailscale links omit shared credentials; degraded revalidation falls back only to an approved shared browser credential; bootstrap-only and no-credential states fail closed | AppRefactorContractTests.DashboardLinkPolicy_StaysDelegatedToFocusedService | source-shape | when App, ConnectionPage, and local MCP no longer own dashboard-link entrypoints | | native-tool-projector | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | pure native tool identity, allowlisted display arguments, payload extraction, and flattened-history detection/classification/summary | NativeToolProjector | ChatEventMapper and ChatHistoryLoader call the projector; ChatConversationState supplies scoped correlation plans and ChatMetadataStore owns persistence | unknown identities remain truthful Tool; title aliases are strict; display arguments are allowlisted, redacted, and bounded; live/history projection stays consistent | NativeToolProjectorTests.ExtractToolIdentity_TitleRequiresExactTrustedAlias | behavioral | - | | provider-native-tool-projection-closed | closed | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | private static copies of native tool identity, display argument, payload, flattened-history projection, and scoped metadata upsert | NativeToolProjector + ChatEventMapper + ChatHistoryLoader + ChatConversationState + ChatMetadataStore | provider forwards typed tool metadata writes while retaining bridge IO, telemetry, and event publication only | provider does not regain native tool JSON projection, identity policy, timeline correlation, or metadata persistence | review-only: pure projection, atomic correlation, and persistence are delegated to focused owners while the provider remains the IO facade | review-only | when OpenClawChatDataProvider is retired | | chat-conversation-state | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | provider-owned runtime gate and cross-domain state transactions | ChatConversationState | sole lock, timeline and entry metadata, connection/disposal flags, and typed orchestration across lock-free substates; provider supplies bridge context and coordinates IO, telemetry, and events | one authoritative lock atomically commits reset, reconnect, dispose, queue, history, and event transitions without duplicate shared versions | ChatRuntimeOwnershipContractTests.Root_CoordinatesCrossDomainCommitsUnderSoleGate | source-shape | when the chat runtime is replaced by a different atomic transaction boundary | diff --git a/src/OpenClaw.Tray.WinUI/App.AppShutdownCoordinator.cs b/src/OpenClaw.Tray.WinUI/App.AppShutdownCoordinator.cs index 9f35e1fc6..192cf9808 100644 --- a/src/OpenClaw.Tray.WinUI/App.AppShutdownCoordinator.cs +++ b/src/OpenClaw.Tray.WinUI/App.AppShutdownCoordinator.cs @@ -81,6 +81,7 @@ private AppShutdownPlan BuildShutdownPlan() } var connectionManager = _connectionManager; + _gatewayDashboardLinkService = null; if (connectionManager is not null) { steps.Add(new AppShutdownStep("gateway client", async () => diff --git a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs index da0afc3dd..d3467f8b7 100644 --- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs +++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs @@ -232,31 +232,25 @@ private void WireAppCapabilityHandlers() var active = _gatewayRegistry?.GetActive(); var activeMatches = active is not null && string.Equals(active.Url, gatewayUrl, StringComparison.OrdinalIgnoreCase); - var trustTailscaleAuth = false; - if (active?.TrustTailscaleAuth == true && - activeMatches && - _connectionManager is not null) - { - trustTailscaleAuth = await _connectionManager - .RevalidateTailscaleDashboardAuthAsync(active.Id); - } - var usesSharedCredential = !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken; + var tailscaleGatewayId = active?.TrustTailscaleAuth == true && activeMatches + ? active.Id + : null; + var service = _gatewayDashboardLinkService; + if (service is null) + return new { error = "Connection manager is not initialized" }; - if (!GatewayDashboardUrlBuilder.HasBrowserCompatibleCredential( - trustTailscaleAuth, - usesSharedCredential)) - { - return new { error = GatewayDashboardUrlBuilder.NoBrowserCompatibleCredentialError }; - } - - var url = GatewayDashboardUrlBuilder.Build( + var result = await service.BuildAsync(new GatewayDashboardLinkRequest( gatewayUrl, path, dashboardCredential, usesSharedCredential, - trustTailscaleAuth); + tailscaleGatewayId)); + if (!result.Success) + return new { error = result.Error }; + + var url = result.Url!; return new { diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index a56ff2434..80bb12205 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -52,6 +52,7 @@ public partial class App : Application, OpenClawTray.Services.IAppCommands, IPer private IWindowManager? _windowManager; private GatewayConnectionManager? _connectionManager; private GatewayDirectConnectService? _gatewayDirectConnectService; + private GatewayDashboardLinkService? _gatewayDashboardLinkService; private GatewayRegistry? _gatewayRegistry; private OpenClawTray.Services.ManagedLocalGatewayAutoRepairMonitor? _managedLocalAutoRepairMonitor; private ManagedLocalGatewayPortProvenanceService? _managedLocalPortProvenance; @@ -799,6 +800,10 @@ _dispatcherQueue is null tunnelManager: _sshTunnelService, endpointProvenanceProbe: managedLocalPortProvenance.InspectAsync, validationTunnelFactory: () => new SshTunnelService(appLogger)); + _gatewayDashboardLinkService = new GatewayDashboardLinkService( + (gatewayId, cancellationToken) => _connectionManager.RevalidateTailscaleDashboardAuthAsync( + gatewayId, + cancellationToken)); _connectionManager.OperatorClientChanged += OnOperatorClientChanged; _connectionManager.StateChanged += OnManagerStateChanged; _gatewayDirectConnectService = new GatewayDirectConnectService( @@ -3815,70 +3820,50 @@ private void OpenDashboard(string? path = null) var appendBrowserCredential = !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken; var active = _gatewayRegistry?.GetActive(); - if (active?.TrustTailscaleAuth == true && - string.Equals(active.Url, gatewayUrl, StringComparison.OrdinalIgnoreCase) && - _connectionManager is not null) - { - _ = OpenDashboardAfterTailscaleAuthRevalidationAsync( - active.Id, - gatewayUrl, - path, - token, - appendBrowserCredential); - return; - } - - if (!GatewayDashboardUrlBuilder.HasBrowserCompatibleCredential( - trustTailscaleAuth: false, - usesSharedGatewayToken: appendBrowserCredential)) - { - ShowConnectionSettingsForPairingIssue( - "Dashboard", - "Tailscale authentication is unavailable and no approved browser credential is available"); - return; - } + var tailscaleGatewayId = active?.TrustTailscaleAuth == true && + string.Equals(active.Url, gatewayUrl, StringComparison.OrdinalIgnoreCase) + ? active.Id + : null; - LaunchDashboardUrl(GatewayDashboardUrlBuilder.Build( + _ = OpenDashboardFromLinkServiceAsync(new GatewayDashboardLinkRequest( gatewayUrl, path, token, - appendBrowserCredential)); + appendBrowserCredential, + tailscaleGatewayId)); } - private async Task OpenDashboardAfterTailscaleAuthRevalidationAsync( - string gatewayId, - string gatewayUrl, - string? path, - string? browserCredential, - bool appendBrowserCredential) + internal async Task OpenDashboardFromLinkServiceAsync( + GatewayDashboardLinkRequest request, + Func>? validateBeforeLaunch = null) { - var trustTailscaleAuth = false; - try + var service = _gatewayDashboardLinkService; + if (service is null) { - trustTailscaleAuth = await _connectionManager! - .RevalidateTailscaleDashboardAuthAsync(gatewayId); + ShowConnectionSettingsForPairingIssue("Dashboard", "Connection manager is not initialized"); + return; } - catch (Exception ex) + + var result = await service.BuildAsync(request); + if (result.RevalidationError is not null) { - Logger.Warn($"Failed to revalidate Tailscale dashboard auth: {ex.Message}"); + Logger.Warn(result.RevalidationError); } - if (!GatewayDashboardUrlBuilder.HasBrowserCompatibleCredential( - trustTailscaleAuth, - appendBrowserCredential)) + if (!result.Success) { ShowConnectionSettingsForPairingIssue( "Dashboard", - "Tailscale authentication is unavailable and no approved browser credential is available"); + result.Error ?? "Dashboard URL is unavailable"); return; } - LaunchDashboardUrl(GatewayDashboardUrlBuilder.Build( - gatewayUrl, - path, - browserCredential, - appendBrowserCredential && !trustTailscaleAuth, - trustTailscaleAuth)); + if (validateBeforeLaunch is not null && !await validateBeforeLaunch(result)) + { + return; + } + + LaunchDashboardUrl(result.Url!); } private static void LaunchDashboardUrl(string url) diff --git a/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs b/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs index 61dff6425..a3c43151d 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs @@ -2,14 +2,6 @@ namespace OpenClawTray.Helpers; public static class GatewayDashboardUrlBuilder { - public const string NoBrowserCompatibleCredentialError = - "No browser-compatible gateway credential is available"; - - public static bool HasBrowserCompatibleCredential( - bool trustTailscaleAuth, - bool usesSharedGatewayToken) => - trustTailscaleAuth || usesSharedGatewayToken; - public static string Build( string gatewayUrl, string? path, diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index b2845bcda..1e7fb995f 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -1802,7 +1802,7 @@ private Border BuildSavedGatewayRowControl(SavedGatewayRow row) { var enableTailscaleAuth = new MenuFlyoutItem { - Text = "Use Tailscale identity for dashboard", + Text = LocalizationHelper.GetString("ConnectionPage_TailscaleDashboardAuthMenu"), Tag = row.Id, }; enableTailscaleAuth.Click += OnEnableTailscaleDashboardAuth; @@ -2531,55 +2531,13 @@ private async Task OnSavedRowOpenDashboardAsync(object sender) if (rec == null) return; try { - var trustTailscaleAuth = rec.TrustTailscaleAuth && - _connectionManager is not null && - await _connectionManager.RevalidateTailscaleDashboardAuthAsync(rec.Id); - if (trustTailscaleAuth) - { - var tailscaleUrl = GatewayDashboardUrlBuilder.Build( - rec.Url, - path: null, - rec.SharedGatewayToken, - appendSharedGatewayToken: false, - trustTailscaleAuth: true); - await global::Windows.System.Launcher.LaunchUriAsync(new Uri(tailscaleUrl)); - return; - } - - var usesSharedGatewayToken = !string.IsNullOrWhiteSpace(rec.SharedGatewayToken); - if (usesSharedGatewayToken) - { - var provenanceService = CurrentApp.ManagedLocalPortProvenance; - if (provenanceService is null) - return; - _ = await provenanceService.InspectAsync(rec); - var candidate = new GatewayCredential( - rec.SharedGatewayToken!, - IsBootstrapToken: false, - CredentialResolver.SourceSharedGatewayToken); - if (!provenanceService.IsStrongCredentialAllowed(rec, candidate)) - { - CurrentApp.ShowTransientConnectionError( - "Dashboard blocked because the saved gateway address is not owned by the verified managed gateway."); - return; - } - } - - if (!GatewayDashboardUrlBuilder.HasBrowserCompatibleCredential( - trustTailscaleAuth, - usesSharedGatewayToken)) - { - CurrentApp.ShowTransientConnectionError( - "Tailscale authentication is unavailable and no approved browser credential is available"); - return; - } - - var url = GatewayDashboardUrlBuilder.Build( + await CurrentApp.OpenDashboardFromLinkServiceAsync(new GatewayDashboardLinkRequest( rec.Url, - path: null, + null, rec.SharedGatewayToken, - appendSharedGatewayToken: usesSharedGatewayToken); - await global::Windows.System.Launcher.LaunchUriAsync(new Uri(url)); + !string.IsNullOrWhiteSpace(rec.SharedGatewayToken), + rec.TrustTailscaleAuth ? rec.Id : null), + result => ValidateSavedDashboardFallbackAsync(rec, result)); } catch (Exception ex) { @@ -2587,6 +2545,36 @@ _connectionManager is not null && } } + private async Task ValidateSavedDashboardFallbackAsync( + GatewayRecord record, + GatewayDashboardLinkResult result) + { + if (result.TrustTailscaleAuth) + { + return true; + } + + var provenanceService = CurrentApp.ManagedLocalPortProvenance; + if (provenanceService is null || string.IsNullOrWhiteSpace(record.SharedGatewayToken)) + { + return false; + } + + _ = await provenanceService.InspectAsync(record); + var candidate = new GatewayCredential( + record.SharedGatewayToken, + IsBootstrapToken: false, + CredentialResolver.SourceSharedGatewayToken); + if (provenanceService.IsStrongCredentialAllowed(record, candidate)) + { + return true; + } + + CurrentApp.ShowTransientConnectionError( + "Dashboard blocked because the saved gateway address is not owned by the verified managed gateway."); + return false; + } + private void OnEnableTailscaleDashboardAuth(object sender, RoutedEventArgs e) => AsyncEventHandlerGuard.Run( () => OnEnableTailscaleDashboardAuthAsync(sender), @@ -2604,9 +2592,9 @@ item.Tag is not string gatewayId || var dialog = new ContentDialog { - Title = "Use Tailscale identity for dashboard?", - Content = "This enables verified Tailscale identity authentication on this managed gateway. Your saved token, device identity, WSL distro, and pairing remain unchanged.", - PrimaryButtonText = "Enable", + Title = LocalizationHelper.GetString("ConnectionPage_TailscaleDashboardAuthTitle"), + Content = LocalizationHelper.GetString("ConnectionPage_TailscaleDashboardAuthBody"), + PrimaryButtonText = LocalizationHelper.GetString("ConnectionPage_TailscaleDashboardAuthEnable"), CloseButtonText = LocalizationHelper.GetString("ConnectionPage_CancelAction"), DefaultButton = ContentDialogButton.Close, XamlRoot = XamlRoot, @@ -2617,7 +2605,7 @@ item.Tag is not string gatewayId || var result = await _connectionManager.EnableTailscaleDashboardAuthAsync(gatewayId); if (result.IsSuccess) { - SetGatewayHostActionStatus("Tailscale dashboard identity enabled."); + SetGatewayHostActionStatus(LocalizationHelper.GetString("ConnectionPage_TailscaleDashboardAuthEnabled")); LoadSavedGateways(); return; } @@ -2625,7 +2613,9 @@ item.Tag is not string gatewayId || var detail = string.IsNullOrWhiteSpace(result.Error) ? result.Outcome.ToString() : result.Error; - SetGatewayHostActionStatus($"Could not enable Tailscale dashboard identity: {detail}", isError: true); + SetGatewayHostActionStatus( + string.Format(LocalizationHelper.GetString("ConnectionPage_TailscaleDashboardAuthFailedFormat"), detail), + isError: true); } private void OnSavedRowEdit(object sender, RoutedEventArgs e) diff --git a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLinkService.cs b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLinkService.cs new file mode 100644 index 000000000..384826c67 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLinkService.cs @@ -0,0 +1,88 @@ +using OpenClawTray.Helpers; + +namespace OpenClawTray.Services; + +public sealed record GatewayDashboardLinkRequest( + string GatewayUrl, + string? Path, + string? BrowserCredential, + bool AppendBrowserCredential, + string? TailscaleGatewayId = null); + +public sealed record GatewayDashboardLinkResult( + string? Url, + bool TrustTailscaleAuth, + string? Error = null, + string? RevalidationError = null) +{ + public bool Success => Url is not null && Error is null; +} + +/// +/// Owns dashboard-link authentication policy while callers retain UI and MCP side effects. +/// +public sealed class GatewayDashboardLinkService +{ + internal const string NoBrowserCompatibleCredential = + "No browser-compatible gateway credential is available"; + internal const string RevalidationFailed = + "Tailscale dashboard authentication revalidation failed"; + + private readonly Func> _revalidateTailscaleAuth; + + public GatewayDashboardLinkService( + Func> revalidateTailscaleAuth) + { + _revalidateTailscaleAuth = revalidateTailscaleAuth + ?? throw new ArgumentNullException(nameof(revalidateTailscaleAuth)); + } + + public async Task BuildAsync( + GatewayDashboardLinkRequest request, + CancellationToken cancellationToken = default) + { + var trustTailscaleAuth = false; + string? revalidationError = null; + + if (!string.IsNullOrWhiteSpace(request.TailscaleGatewayId)) + { + try + { + trustTailscaleAuth = await _revalidateTailscaleAuth( + request.TailscaleGatewayId, + cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception) + { + revalidationError = RevalidationFailed; + } + } + + if (!trustTailscaleAuth && + (!request.AppendBrowserCredential || string.IsNullOrWhiteSpace(request.BrowserCredential))) + { + return new GatewayDashboardLinkResult( + Url: null, + TrustTailscaleAuth: false, + Error: NoBrowserCompatibleCredential, + RevalidationError: revalidationError); + } + + var url = GatewayDashboardUrlBuilder.Build( + request.GatewayUrl, + request.Path, + request.BrowserCredential, + request.AppendBrowserCredential && !trustTailscaleAuth, + trustTailscaleAuth); + + return new GatewayDashboardLinkResult( + url, + trustTailscaleAuth, + RevalidationError: revalidationError); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 02c292072..e8527a623 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -5467,6 +5467,24 @@ Make sure the gateway is running. Open dashboard + + Use Tailscale identity for dashboard + + + Use Tailscale identity for dashboard? + + + This enables verified Tailscale identity authentication on this managed gateway. Your saved token, device identity, WSL distro, and pairing remain unchanged. + + + Enable + + + Tailscale dashboard identity enabled. + + + Could not enable Tailscale dashboard identity: {0} + Options for {0} diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 60e556866..ab436d79f 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -5463,6 +5463,24 @@ Assurez-vous que la passerelle est en cours d'exécution. Ouvrir le tableau de bord + + Utiliser l’identité Tailscale pour le tableau de bord + + + Utiliser l’identité Tailscale pour le tableau de bord ? + + + Cela active l’authentification vérifiée par identité Tailscale sur cette passerelle gérée. Votre jeton enregistré, l’identité de l’appareil, la distribution WSL et l’association restent inchangés. + + + Activer + + + L’identité Tailscale est activée pour le tableau de bord. + + + Impossible d’activer l’identité Tailscale pour le tableau de bord : {0} + Options pour {0} diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index cbb9ea5f2..c1c064a02 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -5464,6 +5464,24 @@ Controleer of de gateway actief is. Dashboard openen + + Tailscale-identiteit gebruiken voor het dashboard + + + Tailscale-identiteit gebruiken voor het dashboard? + + + Hiermee wordt verificatie via Tailscale-identiteit ingeschakeld op deze beheerde gateway. Uw opgeslagen token, apparaatidentiteit, WSL-distributie en koppeling blijven ongewijzigd. + + + Inschakelen + + + Tailscale-identiteit voor het dashboard is ingeschakeld. + + + Kan Tailscale-identiteit voor het dashboard niet inschakelen: {0} + Opties voor {0} diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index bf824cb7a..d149a5955 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -5463,6 +5463,24 @@ 打开仪表板 + + 使用 Tailscale 身份访问仪表板 + + + 使用 Tailscale 身份访问仪表板? + + + 这将在此托管网关上启用经过验证的 Tailscale 身份认证。已保存的令牌、设备身份、WSL 发行版和配对信息均保持不变。 + + + 启用 + + + 已启用仪表板的 Tailscale 身份认证。 + + + 无法启用仪表板的 Tailscale 身份认证:{0} + {0} 的选项 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 58acfbc95..540db16fa 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -5463,6 +5463,24 @@ 開啟儀表板 + + 使用 Tailscale 身分存取儀表板 + + + 使用 Tailscale 身分存取儀表板? + + + 這會在此受管理的閘道上啟用經過驗證的 Tailscale 身分驗證。已儲存的權杖、裝置身分、WSL 發行版本和配對資訊均維持不變。 + + + 啟用 + + + 已啟用儀表板的 Tailscale 身分驗證。 + + + 無法啟用儀表板的 Tailscale 身分驗證:{0} + {0} 的選項 diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 997809015..5f7ed5a5c 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -452,30 +452,14 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() } [Fact] - public void DashboardLaunches_UseSharedBrowserCredentialPolicy() + public void DashboardLinkPolicy_StaysDelegatedToFocusedService() { var source = ReadAppSources(); - var direct = ExtractMethod(source, "OpenDashboard"); - var revalidated = ExtractMethod(source, "OpenDashboardAfterTailscaleAuthRevalidationAsync"); - AssertInOrder( - direct, - "if (active?.TrustTailscaleAuth == true", - "OpenDashboardAfterTailscaleAuthRevalidationAsync(", - "return;", - "GatewayDashboardUrlBuilder.HasBrowserCompatibleCredential(", - "trustTailscaleAuth: false", - "usesSharedGatewayToken: appendBrowserCredential", - "return;", - "LaunchDashboardUrl("); - AssertInOrder( - revalidated, - "trustTailscaleAuth = await", - "GatewayDashboardUrlBuilder.HasBrowserCompatibleCredential(", - "trustTailscaleAuth,", - "appendBrowserCredential", - "return;", - "LaunchDashboardUrl("); + Assert.Contains("GatewayDashboardLinkService", source); + Assert.DoesNotContain("OpenDashboardAfterTailscaleAuthRevalidationAsync", source); + Assert.DoesNotContain(".RevalidateTailscaleDashboardAuthAsync(active.Id)", source); + Assert.DoesNotContain("GatewayDashboardUrlBuilder.Build(", source); } [Fact] diff --git a/tests/OpenClaw.Tray.Tests/ConnectionPageTailscaleRecoveryTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionPageTailscaleRecoveryTests.cs index 5551fcde0..91dfa5451 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionPageTailscaleRecoveryTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionPageTailscaleRecoveryTests.cs @@ -6,7 +6,7 @@ namespace OpenClaw.Tray.Tests; public sealed class ConnectionPageTailscaleRecoveryTests { [Fact] - public void SavedDashboardLaunch_UsesSharedBrowserCredentialPolicy() + public void SavedDashboardLaunch_DelegatesToSharedDashboardLinkService() { var source = File.ReadAllText(Path.Combine( TestRepositoryPaths.GetRepositoryRoot(), @@ -24,21 +24,44 @@ public void SavedDashboardLaunch_UsesSharedBrowserCredentialPolicy() Assert.True(methodStart >= 0 && methodEnd > methodStart); var method = source[methodStart..methodEnd]; - AssertInOrder( - method, - "var trustTailscaleAuth = rec.TrustTailscaleAuth &&", - "await _connectionManager.RevalidateTailscaleDashboardAuthAsync(rec.Id)", - "if (trustTailscaleAuth)", - "trustTailscaleAuth: true", - "LaunchUriAsync(", - "return;", - "var usesSharedGatewayToken =", - "GatewayDashboardUrlBuilder.HasBrowserCompatibleCredential(", - "trustTailscaleAuth,", - "usesSharedGatewayToken", - "return;", - "appendSharedGatewayToken: usesSharedGatewayToken", - "LaunchUriAsync("); + Assert.Contains("OpenDashboardFromLinkServiceAsync", method); + Assert.Contains("new GatewayDashboardLinkRequest(", method); + Assert.Contains("ValidateSavedDashboardFallbackAsync", method); + Assert.DoesNotContain("RevalidateTailscaleDashboardAuthAsync", method); + Assert.DoesNotContain("GatewayDashboardUrlBuilder.Build(", method); + + var validationStart = source.IndexOf( + "private async Task ValidateSavedDashboardFallbackAsync(", + methodStart, + StringComparison.Ordinal); + Assert.True(validationStart > methodStart); + var validationEnd = source.IndexOf( + "private void OnEnableTailscaleDashboardAuth(object sender, RoutedEventArgs e)", + validationStart, + StringComparison.Ordinal); + Assert.True(validationEnd > validationStart); + var validation = source[validationStart..validationEnd]; + + var tailscaleBypass = validation.IndexOf( + "if (result.TrustTailscaleAuth)", + StringComparison.Ordinal); + var bypassReturn = validation.IndexOf( + "return true;", + tailscaleBypass, + StringComparison.Ordinal); + var provenanceLookup = validation.IndexOf( + "ManagedLocalPortProvenance", + bypassReturn, + StringComparison.Ordinal); + var credentialAuthorization = validation.IndexOf( + "IsStrongCredentialAllowed", + provenanceLookup, + StringComparison.Ordinal); + + Assert.True(tailscaleBypass >= 0); + Assert.True(bypassReturn > tailscaleBypass); + Assert.True(provenanceLookup > bypassReturn); + Assert.True(credentialAuthorization > provenanceLookup); } [Fact] @@ -101,14 +124,4 @@ public void NetworkFailure_ForUnmanagedTailscaleGateway_UsesOrdinaryNetworkRecov Assert.NotEqual("Tailscale gateway unavailable", plan.StripHeadline); } - private static void AssertInOrder(string source, params string[] markers) - { - var current = -1; - foreach (var marker in markers) - { - var next = source.IndexOf(marker, current + 1, StringComparison.Ordinal); - Assert.True(next >= 0, $"Could not find marker after index {current}: {marker}"); - current = next; - } - } } diff --git a/tests/OpenClaw.Tray.Tests/GatewayDashboardLinkServiceTests.cs b/tests/OpenClaw.Tray.Tests/GatewayDashboardLinkServiceTests.cs new file mode 100644 index 000000000..f732e538d --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/GatewayDashboardLinkServiceTests.cs @@ -0,0 +1,135 @@ +using OpenClawTray.Services; + +namespace OpenClaw.Tray.Tests; + +public sealed class GatewayDashboardLinkServiceTests +{ + [Fact] + public async Task BuildAsync_SharedCredential_AppendsCredentialWithoutRevalidation() + { + var revalidationCalls = 0; + var service = CreateService((_, _) => + { + revalidationCalls++; + return Task.FromResult(true); + }); + + var result = await service.BuildAsync(Request()); + + Assert.True(result.Success); + Assert.Contains("token=shared-token", result.Url); + Assert.Equal(0, revalidationCalls); + } + + [Fact] + public async Task BuildAsync_TrustedTailscaleGateway_OmitsSharedCredential() + { + var service = CreateService((_, _) => Task.FromResult(true)); + + var result = await service.BuildAsync(Request(tailscaleGatewayId: "gateway-1")); + + Assert.True(result.Success); + Assert.True(result.TrustTailscaleAuth); + Assert.DoesNotContain("token=", result.Url); + } + + [Fact] + public async Task BuildAsync_FailedRevalidation_FallsBackToSharedCredential() + { + var service = CreateService((_, _) => Task.FromResult(false)); + + var result = await service.BuildAsync(Request(tailscaleGatewayId: "gateway-1")); + + Assert.True(result.Success); + Assert.False(result.TrustTailscaleAuth); + Assert.Contains("token=shared-token", result.Url); + } + + [Theory] + [InlineData(null)] + [InlineData("gateway-1")] + public async Task BuildAsync_WithoutApprovedBrowserCredential_FailsClosed( + string? tailscaleGatewayId) + { + var service = CreateService((_, _) => Task.FromResult(false)); + + var result = await service.BuildAsync(Request( + appendBrowserCredential: false, + tailscaleGatewayId: tailscaleGatewayId)); + + Assert.False(result.Success); + Assert.Null(result.Url); + Assert.Equal(GatewayDashboardLinkService.NoBrowserCompatibleCredential, result.Error); + } + + [Fact] + public async Task BuildAsync_RevalidationException_PreservesFallbackAndSanitizesDiagnostic() + { + var service = CreateService((_, _) => + throw new InvalidOperationException("sensitive probe detail")); + + var result = await service.BuildAsync(Request(tailscaleGatewayId: "gateway-1")); + + Assert.True(result.Success); + Assert.Contains("token=shared-token", result.Url); + Assert.Equal(GatewayDashboardLinkService.RevalidationFailed, result.RevalidationError); + Assert.DoesNotContain("sensitive", result.RevalidationError); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task BuildAsync_AppendRequestedWithoutCredential_FailsClosed( + string? browserCredential) + { + var service = CreateService((_, _) => Task.FromResult(false)); + + var result = await service.BuildAsync(Request( + browserCredential: browserCredential, + tailscaleGatewayId: "gateway-1")); + + Assert.False(result.Success); + Assert.Null(result.Url); + Assert.Equal(GatewayDashboardLinkService.NoBrowserCompatibleCredential, result.Error); + } + + [Fact] + public async Task BuildAsync_CallerCancellation_PropagatesWithoutFallback() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var service = CreateService((_, cancellationToken) => + Task.FromCanceled(cancellationToken)); + + await Assert.ThrowsAnyAsync(() => + service.BuildAsync(Request(tailscaleGatewayId: "gateway-1"), cts.Token)); + } + + [Fact] + public async Task BuildAsync_NonCallerCancellation_PreservesFallbackAndSanitizesDiagnostic() + { + var service = CreateService((_, _) => + Task.FromException(new OperationCanceledException("sensitive probe detail"))); + + var result = await service.BuildAsync(Request(tailscaleGatewayId: "gateway-1")); + + Assert.True(result.Success); + Assert.Contains("token=shared-token", result.Url); + Assert.Equal(GatewayDashboardLinkService.RevalidationFailed, result.RevalidationError); + Assert.DoesNotContain("sensitive", result.RevalidationError); + } + + private static GatewayDashboardLinkService CreateService( + Func> revalidate) => new(revalidate); + + private static GatewayDashboardLinkRequest Request( + bool appendBrowserCredential = true, + string? browserCredential = "shared-token", + string? tailscaleGatewayId = null) => new( + "https://gateway.example.test", + "/settings/profile", + browserCredential, + appendBrowserCredential, + tailscaleGatewayId); +} diff --git a/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs b/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs index 019b2bb59..367c8c2ff 100644 --- a/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs +++ b/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs @@ -4,23 +4,6 @@ namespace OpenClaw.Tray.Tests; public sealed class GatewayDashboardUrlBuilderTests { - [Theory] - [InlineData(false, false, false)] - [InlineData(false, true, true)] - [InlineData(true, false, true)] - [InlineData(true, true, true)] - public void HasBrowserCompatibleCredential_RequiresTailscaleOrSharedToken( - bool trustTailscaleAuth, - bool usesSharedGatewayToken, - bool expected) - { - Assert.Equal( - expected, - GatewayDashboardUrlBuilder.HasBrowserCompatibleCredential( - trustTailscaleAuth, - usesSharedGatewayToken)); - } - [Fact] public void Build_AppendsSharedTokenToDashboardRoot() { diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index cacaa3045..36947cdbb 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -115,6 +115,7 @@ + From 1ebebfa81e3c547c5fe33030b8f210cfd8efc1ac Mon Sep 17 00:00:00 2001 From: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:57:04 +0100 Subject: [PATCH 3/6] fix(tray): preserve bootstrap dashboard links --- docs/ARCHITECTURE.md | 2 +- src/OpenClaw.Tray.WinUI/App.xaml.cs | 3 +- .../Services/GatewayDashboardLinkService.cs | 20 ++++++----- .../Strings/en-us/Resources.resw | 6 ++++ .../Strings/fr-fr/Resources.resw | 6 ++++ .../Strings/nl-nl/Resources.resw | 6 ++++ .../Strings/zh-cn/Resources.resw | 6 ++++ .../Strings/zh-tw/Resources.resw | 6 ++++ .../Setup/SetupAndConnectTests.cs | 13 +++---- .../GatewayDashboardLinkServiceTests.cs | 34 +++++++++++++------ 10 files changed, 75 insertions(+), 27 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b690e2a58..21da067dc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -170,7 +170,7 @@ leading and trailing pipe. Columns, in order: | app-window-surface-ownership-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | concrete non-tray window fields, constructors, show/hide/focus/theme/close mechanics, and window event lifetime | IWindowManager + WindowManager | interface forwarding, immutable request construction, route and policy callbacks, and setup restart dialog policy only | App cannot regain a parallel Hub, Chat, status, setup, canvas-request, or runtime-anchor owner | AppSurfaceOwnershipContractTests.App_DelegatesConcreteTrayAndWindowOwnership | source-shape | when App is replaced as the WinUI composition root | | app-tray-controller | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | tray icon creation, tray popup coordination, click routing, tooltip and live-toggle refresh, theme, callbacks, and disposal | ITrayController + TrayController | App captures immutable snapshots, implements semantic action callbacks, triggers refresh from authoritative state, preserves startup construction order, and constructs shutdown-plan callbacks | one tray icon and root menu are reused; A1 presenters retain semantics; TrayMenuWindow retains native popup mechanics; callbacks detach and resources dispose once | TrayControllerTests.Dispose_UnsubscribesAndDisposesEachResourceOnce | source-shape | when the WinUI tray surface is replaced | | app-tray-surface-ownership-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | concrete tray icon, root menu, weak live-control state, event subscriptions, popup build coordination, and resource disposal | ITrayController + TrayController | immutable snapshot and action callbacks plus state-change triggers only | App cannot regain tray controls or popup lifetime and TrayController cannot duplicate A1 semantic projection or TrayMenuWindow native mechanics | AppSurfaceOwnershipContractTests.App_DelegatesConcreteTrayAndWindowOwnership | source-shape | when the WinUI tray surface is replaced | -| dashboard-link-policy | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs + App.CapabilityHandlers.cs + Pages/ConnectionPage.xaml.cs | duplicated Tailscale revalidation, browser-credential fallback, and dashboard URL policy | GatewayDashboardLinkService | callers retain UI launch, error, provenance, and MCP response side effects | trusted Tailscale links omit shared credentials; degraded revalidation falls back only to an approved shared browser credential; bootstrap-only and no-credential states fail closed | AppRefactorContractTests.DashboardLinkPolicy_StaysDelegatedToFocusedService | source-shape | when App, ConnectionPage, and local MCP no longer own dashboard-link entrypoints | +| dashboard-link-policy | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs + App.CapabilityHandlers.cs + Pages/ConnectionPage.xaml.cs | duplicated Tailscale revalidation, browser-credential fallback, and dashboard URL policy | GatewayDashboardLinkService | callers retain UI launch, error, provenance, and MCP response side effects | trusted Tailscale links omit shared credentials; degraded Tailscale revalidation falls back only to an approved shared browser credential and otherwise fails closed; non-Tailscale QR/bootstrap requests preserve the existing token-free dashboard URL | AppRefactorContractTests.DashboardLinkPolicy_StaysDelegatedToFocusedService | source-shape | when App, ConnectionPage, and local MCP no longer own dashboard-link entrypoints | | native-tool-projector | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | pure native tool identity, allowlisted display arguments, payload extraction, and flattened-history detection/classification/summary | NativeToolProjector | ChatEventMapper and ChatHistoryLoader call the projector; ChatConversationState supplies scoped correlation plans and ChatMetadataStore owns persistence | unknown identities remain truthful Tool; title aliases are strict; display arguments are allowlisted, redacted, and bounded; live/history projection stays consistent | NativeToolProjectorTests.ExtractToolIdentity_TitleRequiresExactTrustedAlias | behavioral | - | | provider-native-tool-projection-closed | closed | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | private static copies of native tool identity, display argument, payload, flattened-history projection, and scoped metadata upsert | NativeToolProjector + ChatEventMapper + ChatHistoryLoader + ChatConversationState + ChatMetadataStore | provider forwards typed tool metadata writes while retaining bridge IO, telemetry, and event publication only | provider does not regain native tool JSON projection, identity policy, timeline correlation, or metadata persistence | review-only: pure projection, atomic correlation, and persistence are delegated to focused owners while the provider remains the IO facade | review-only | when OpenClawChatDataProvider is retired | | chat-conversation-state | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | provider-owned runtime gate and cross-domain state transactions | ChatConversationState | sole lock, timeline and entry metadata, connection/disposal flags, and typed orchestration across lock-free substates; provider supplies bridge context and coordinates IO, telemetry, and events | one authoritative lock atomically commits reset, reconnect, dispose, queue, history, and event transitions without duplicate shared versions | ChatRuntimeOwnershipContractTests.Root_CoordinatesCrossDomainCommitsUnderSoleGate | source-shape | when the chat runtime is replaced by a different atomic transaction boundary | diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 80bb12205..5b3d3cb2f 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -803,7 +803,8 @@ _dispatcherQueue is null _gatewayDashboardLinkService = new GatewayDashboardLinkService( (gatewayId, cancellationToken) => _connectionManager.RevalidateTailscaleDashboardAuthAsync( gatewayId, - cancellationToken)); + cancellationToken), + LocalizationHelper.GetString); _connectionManager.OperatorClientChanged += OnOperatorClientChanged; _connectionManager.StateChanged += OnManagerStateChanged; _gatewayDirectConnectService = new GatewayDirectConnectService( diff --git a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLinkService.cs b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLinkService.cs index 384826c67..e009614f9 100644 --- a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLinkService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLinkService.cs @@ -23,18 +23,21 @@ public sealed record GatewayDashboardLinkResult( /// public sealed class GatewayDashboardLinkService { - internal const string NoBrowserCompatibleCredential = - "No browser-compatible gateway credential is available"; - internal const string RevalidationFailed = - "Tailscale dashboard authentication revalidation failed"; + internal const string NoBrowserCompatibleCredentialKey = + "DashboardLink_NoBrowserCompatibleCredential"; + internal const string RevalidationFailedKey = + "DashboardLink_TailscaleRevalidationFailed"; private readonly Func> _revalidateTailscaleAuth; + private readonly Func _localize; public GatewayDashboardLinkService( - Func> revalidateTailscaleAuth) + Func> revalidateTailscaleAuth, + Func localize) { _revalidateTailscaleAuth = revalidateTailscaleAuth ?? throw new ArgumentNullException(nameof(revalidateTailscaleAuth)); + _localize = localize ?? throw new ArgumentNullException(nameof(localize)); } public async Task BuildAsync( @@ -59,17 +62,18 @@ public async Task BuildAsync( } catch (Exception) { - revalidationError = RevalidationFailed; + revalidationError = _localize(RevalidationFailedKey); } } - if (!trustTailscaleAuth && + if (!string.IsNullOrWhiteSpace(request.TailscaleGatewayId) && + !trustTailscaleAuth && (!request.AppendBrowserCredential || string.IsNullOrWhiteSpace(request.BrowserCredential))) { return new GatewayDashboardLinkResult( Url: null, TrustTailscaleAuth: false, - Error: NoBrowserCompatibleCredential, + Error: _localize(NoBrowserCompatibleCredentialKey), RevalidationError: revalidationError); } diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index e8527a623..1d5fd715c 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -5485,6 +5485,12 @@ Make sure the gateway is running. Could not enable Tailscale dashboard identity: {0} + + No browser-compatible gateway credential is available + + + Tailscale dashboard authentication revalidation failed + Options for {0} diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index ab436d79f..946dea0bf 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -5481,6 +5481,12 @@ Assurez-vous que la passerelle est en cours d'exécution. Impossible d’activer l’identité Tailscale pour le tableau de bord : {0} + + Aucun identifiant de passerelle compatible avec le navigateur n’est disponible + + + La revalidation de l’authentification Tailscale du tableau de bord a échoué + Options pour {0} diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index c1c064a02..44611089f 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -5482,6 +5482,12 @@ Controleer of de gateway actief is. Kan Tailscale-identiteit voor het dashboard niet inschakelen: {0} + + Er is geen gatewayreferentie beschikbaar die geschikt is voor de browser + + + Herverificatie van Tailscale-authenticatie voor het dashboard is mislukt + Opties voor {0} diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index d149a5955..436bc0e32 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -5481,6 +5481,12 @@ 无法启用仪表板的 Tailscale 身份认证:{0} + + 没有可供浏览器使用的网关凭据 + + + 重新验证仪表板的 Tailscale 身份认证失败 + {0} 的选项 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 540db16fa..bf51c059d 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -5481,6 +5481,12 @@ 無法啟用儀表板的 Tailscale 身分驗證:{0} + + 沒有可供瀏覽器使用的閘道認證 + + + 重新驗證儀表板的 Tailscale 身分驗證失敗 + {0} 的選項 diff --git a/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs b/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs index 7b1b84fcf..c89b5def4 100644 --- a/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs +++ b/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs @@ -439,12 +439,13 @@ public async Task ExternalLike_QrOnlyFreshTray_RequiresExplicitDeviceApproval() var requestId = await WaitForFirstPendingDeviceRequestIdAsync(pendingBefore); Assert.False(string.IsNullOrWhiteSpace(requestId)); - using var dashboardDoc = await externalTray.Client.CallToolAsync("app.dashboard.url"); - var dashboardResult = dashboardDoc.RootElement.GetProperty("result"); - Assert.True(dashboardResult.GetProperty("isError").GetBoolean()); - Assert.Equal( - "No browser-compatible gateway credential is available", - dashboardResult.GetProperty("content")[0].GetProperty("text").GetString()); + using var dashboardDoc = await externalTray.Client.CallToolExpectSuccessAsync("app.dashboard.url"); + var dashboard = dashboardDoc.RootElement; + var dashboardUrl = dashboard.GetProperty("url").GetString(); + Assert.NotNull(dashboardUrl); + Assert.False(dashboard.GetProperty("usesSharedGatewayToken").GetBoolean()); + Assert.False(dashboard.GetProperty("hasTokenQuery").GetBoolean()); + Assert.DoesNotContain("token=", dashboardUrl, StringComparison.OrdinalIgnoreCase); using var rejectDoc = await RejectDevicePairingFromConnectionPageAsync(requestId); handledDeviceRequestIds.Add(requestId); diff --git a/tests/OpenClaw.Tray.Tests/GatewayDashboardLinkServiceTests.cs b/tests/OpenClaw.Tray.Tests/GatewayDashboardLinkServiceTests.cs index f732e538d..9551f4f27 100644 --- a/tests/OpenClaw.Tray.Tests/GatewayDashboardLinkServiceTests.cs +++ b/tests/OpenClaw.Tray.Tests/GatewayDashboardLinkServiceTests.cs @@ -45,21 +45,32 @@ public async Task BuildAsync_FailedRevalidation_FallsBackToSharedCredential() Assert.Contains("token=shared-token", result.Url); } - [Theory] - [InlineData(null)] - [InlineData("gateway-1")] - public async Task BuildAsync_WithoutApprovedBrowserCredential_FailsClosed( - string? tailscaleGatewayId) + [Fact] + public async Task BuildAsync_TokenFreeBootstrapRequest_PreservesDashboardUrl() { var service = CreateService((_, _) => Task.FromResult(false)); var result = await service.BuildAsync(Request( appendBrowserCredential: false, - tailscaleGatewayId: tailscaleGatewayId)); + tailscaleGatewayId: null)); + + Assert.True(result.Success); + Assert.NotNull(result.Url); + Assert.DoesNotContain("token=", result.Url); + } + + [Fact] + public async Task BuildAsync_TailscaleRequestWithoutApprovedBrowserCredential_FailsClosed() + { + var service = CreateService((_, _) => Task.FromResult(false)); + + var result = await service.BuildAsync(Request( + appendBrowserCredential: false, + tailscaleGatewayId: "gateway-1")); Assert.False(result.Success); Assert.Null(result.Url); - Assert.Equal(GatewayDashboardLinkService.NoBrowserCompatibleCredential, result.Error); + Assert.Equal("localized:DashboardLink_NoBrowserCompatibleCredential", result.Error); } [Fact] @@ -72,7 +83,7 @@ public async Task BuildAsync_RevalidationException_PreservesFallbackAndSanitizes Assert.True(result.Success); Assert.Contains("token=shared-token", result.Url); - Assert.Equal(GatewayDashboardLinkService.RevalidationFailed, result.RevalidationError); + Assert.Equal("localized:DashboardLink_TailscaleRevalidationFailed", result.RevalidationError); Assert.DoesNotContain("sensitive", result.RevalidationError); } @@ -91,7 +102,7 @@ public async Task BuildAsync_AppendRequestedWithoutCredential_FailsClosed( Assert.False(result.Success); Assert.Null(result.Url); - Assert.Equal(GatewayDashboardLinkService.NoBrowserCompatibleCredential, result.Error); + Assert.Equal("localized:DashboardLink_NoBrowserCompatibleCredential", result.Error); } [Fact] @@ -116,12 +127,13 @@ public async Task BuildAsync_NonCallerCancellation_PreservesFallbackAndSanitizes Assert.True(result.Success); Assert.Contains("token=shared-token", result.Url); - Assert.Equal(GatewayDashboardLinkService.RevalidationFailed, result.RevalidationError); + Assert.Equal("localized:DashboardLink_TailscaleRevalidationFailed", result.RevalidationError); Assert.DoesNotContain("sensitive", result.RevalidationError); } private static GatewayDashboardLinkService CreateService( - Func> revalidate) => new(revalidate); + Func> revalidate) => + new(revalidate, key => $"localized:{key}"); private static GatewayDashboardLinkRequest Request( bool appendBrowserCredential = true, From b66e945c33210f677a21ddc6da91d5a46ec0fc8c Mon Sep 17 00:00:00 2001 From: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:37:44 +0100 Subject: [PATCH 4/6] fix(connection): parse foreground tailscale serve status --- .../TailscaleServeStatusPolicy.cs | 51 ++++++++- .../GatewayTailscaleAuthLiveVerifierTests.cs | 101 ++++++++++++++++++ 2 files changed, 149 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Shared/TailscaleServeStatusPolicy.cs b/src/OpenClaw.Shared/TailscaleServeStatusPolicy.cs index 0f5fedb8f..e5308e97b 100644 --- a/src/OpenClaw.Shared/TailscaleServeStatusPolicy.cs +++ b/src/OpenClaw.Shared/TailscaleServeStatusPolicy.cs @@ -21,14 +21,27 @@ public static bool TryParse( using var document = JsonDocument.Parse(status); var root = document.RootElement; if (root.ValueKind != JsonValueKind.Object || - !HasValidWebShape(root) || - !TryReadFunnelState(root, out var funnelEnabled)) + !TryCollectServeConfigs(root, out var configs)) { return false; } + var routesToGateway = false; + var funnelEnabled = false; + foreach (var config in configs) + { + if (!HasValidWebShape(config) || + !TryReadFunnelState(config, out var configFunnelEnabled)) + { + return false; + } + + routesToGateway |= HasGatewayWebProxy(config, port, expectedEndpoint); + funnelEnabled |= configFunnelEnabled; + } + parsed = new TailscaleServeStatusResult( - RoutesToGateway: HasGatewayWebProxy(root, port, expectedEndpoint), + RoutesToGateway: routesToGateway, FunnelEnabled: funnelEnabled); return true; } @@ -38,6 +51,38 @@ public static bool TryParse( } } + private static bool TryCollectServeConfigs( + JsonElement root, + out IReadOnlyList configs) + { + var collected = new List { root }; + if (!root.TryGetProperty("Foreground", out var foreground)) + { + configs = collected; + return true; + } + + if (foreground.ValueKind != JsonValueKind.Object) + { + configs = []; + return false; + } + + foreach (var entry in foreground.EnumerateObject()) + { + if (entry.Value.ValueKind != JsonValueKind.Object) + { + configs = []; + return false; + } + + collected.Add(entry.Value); + } + + configs = collected; + return true; + } + private static bool HasValidWebShape(JsonElement root) { if (!root.TryGetProperty("Web", out var web)) diff --git a/tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthLiveVerifierTests.cs b/tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthLiveVerifierTests.cs index 30637d6fa..578027b09 100644 --- a/tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthLiveVerifierTests.cs +++ b/tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthLiveVerifierTests.cs @@ -158,6 +158,37 @@ public async Task VerifyAsync_UnrelatedServeHostIsNotReady() Assert.Equal(GatewayTailscaleAuthLiveState.NotReady, result); } + [Fact] + public async Task VerifyAsync_AcceptsCurrentForegroundServeStatusShape() + { + var runner = new FakeWslCommandRunner((_, command, _) => Task.FromResult( + command.Contains("serve", StringComparer.Ordinal) + ? ForegroundServeStatus() + : RunningStatus())); + var verifier = new GatewayTailscaleAuthLiveVerifier(runner, TimeSpan.FromSeconds(1)); + + var result = await verifier.VerifyAsync(ManagedRecord(), 18789, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthLiveState.Ready, result); + } + + [Theory] + [MemberData(nameof(UnsafeForegroundServeStatuses))] + public async Task VerifyAsync_FailsClosedForUnsafeForegroundServeStatus( + string foreground, + string expected) + { + var runner = new FakeWslCommandRunner((_, command, _) => Task.FromResult( + command.Contains("serve", StringComparer.Ordinal) + ? new WslCommandResult(0, $"{{\"Foreground\":{foreground}}}", "") + : RunningStatus())); + var verifier = new GatewayTailscaleAuthLiveVerifier(runner, TimeSpan.FromSeconds(1)); + + var result = await verifier.VerifyAsync(ManagedRecord(), 18789, CancellationToken.None); + + Assert.Equal(expected, result.ToString()); + } + [Theory] [InlineData(8443, "", "NotReady")] [InlineData(443, "/unrelated", "NotReady")] @@ -316,6 +347,57 @@ public async Task VerifyAsync_EvaluatesEveryFunnelAliasAndRejectsUnknownShapes( { new WslCommandResult(1, "", "tailscaled unavailable"), "Unavailable" }, }; + public static TheoryData UnsafeForegroundServeStatuses => new() + { + { "[]", "Unavailable" }, + { "{\"config-id\":[]}", "Unavailable" }, + { + """ + { + "config-id": { + "Web": { + "host.tail.example:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:18789" } } + } + }, + "AllowFunnel": true + } + } + """, + "NotReady" + }, + { + """ + { + "gateway": { + "Web": { + "host.tail.example:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:18789" } } + } + } + }, + "funnel": { "AllowFunnel": true } + } + """, + "NotReady" + }, + { + """ + { + "gateway": { + "Web": { + "host.tail.example:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:18789" } } + } + } + }, + "malformed": [] + } + """, + "Unavailable" + }, + }; + private static WslCommandResult RunningStatus() => Status("Running", "host.tail.example."); private static WslCommandResult ServeStatus( @@ -343,6 +425,25 @@ private static WslCommandResult ServeStatus( ""); } + private static WslCommandResult ForegroundServeStatus() => + new( + 0, + """ + { + "Foreground": { + "75980230dda8b0e0": { + "TCP": { "443": { "HTTPS": true } }, + "Web": { + "host.tail.example:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:18789" } } + } + } + } + } + } + """, + ""); + private static string GetRepositoryRoot() { var current = new DirectoryInfo(AppContext.BaseDirectory); From 4006f6bd52b64cd9a98b6429315a001a09c60a02 Mon Sep 17 00:00:00 2001 From: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:23:59 +0100 Subject: [PATCH 5/6] fix(connection): verify core-managed tailscale ingress --- .../GatewayTailscaleAuthUpgrade.cs | 128 +++++++++- .../TailscaleServeStatusPolicy.cs | 143 ++++++++--- .../GatewayTailscaleAuthLiveVerifierTests.cs | 226 +++++++++++++++++- 3 files changed, 460 insertions(+), 37 deletions(-) diff --git a/src/OpenClaw.Connection/GatewayTailscaleAuthUpgrade.cs b/src/OpenClaw.Connection/GatewayTailscaleAuthUpgrade.cs index 283af7b49..0e7d99129 100644 --- a/src/OpenClaw.Connection/GatewayTailscaleAuthUpgrade.cs +++ b/src/OpenClaw.Connection/GatewayTailscaleAuthUpgrade.cs @@ -200,15 +200,137 @@ public async Task VerifyAsync( return GatewayTailscaleAuthLiveState.Unavailable; } - return serveStatus.RoutesToGateway && !serveStatus.FunnelEnabled - ? GatewayTailscaleAuthLiveState.Ready - : GatewayTailscaleAuthLiveState.NotReady; + if (serveStatus.FunnelEnabled) + return GatewayTailscaleAuthLiveState.NotReady; + if (serveStatus.ForegroundProxyPort is not { } managedIngressPort) + { + return serveStatus.RoutesToGateway + ? GatewayTailscaleAuthLiveState.Ready + : GatewayTailscaleAuthLiveState.NotReady; + } + + WslCommandResult ownershipResult; + try + { + ownershipResult = await _commandRunner.RunInDistroAsync( + distroName, + BuildManagedIngressOwnershipCommand(gatewayPort, managedIngressPort), + timeout.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return GatewayTailscaleAuthLiveState.Unavailable; + } + + if (!ownershipResult.Success) + return GatewayTailscaleAuthLiveState.Unavailable; + + return ownershipResult.StandardOutput.Trim() switch + { + "owned" => GatewayTailscaleAuthLiveState.Ready, + "not-owned" => GatewayTailscaleAuthLiveState.NotReady, + _ => GatewayTailscaleAuthLiveState.Unavailable, + }; } private static IReadOnlyList BuildRootProbeArguments( string distroName, IReadOnlyList command) => ["-d", distroName, "--user", "root", "--", .. command]; + + private static IReadOnlyList BuildManagedIngressOwnershipCommand( + int gatewayPort, + int managedIngressPort) + { + var script = $$""" + pid_before=$(/usr/bin/systemctl --user show openclaw-gateway -p MainPID --value 2>/dev/null) + case "$pid_before" in ''|*[!0-9]*) /usr/bin/printf unavailable; exit 0;; esac + test "$pid_before" -gt 0 || { /usr/bin/printf unavailable; exit 0; } + main_cgroup=$(/usr/bin/awk -F: '$1 == "0" { print $3 }' "/proc/$pid_before/cgroup" 2>/dev/null) + main_start_before=$(/usr/bin/awk '{ print $22 }' "/proc/$pid_before/stat" 2>/dev/null) + main_exe=$(/usr/bin/readlink -f "/proc/$pid_before/exe" 2>/dev/null) + test -n "$main_cgroup" && test -n "$main_start_before" && test -n "$main_exe" || { /usr/bin/printf unavailable; exit 0; } + listener_pattern='(^|[[:space:]])127\.0\.0\.1:({{gatewayPort}}|{{managedIngressPort}})([[:space:]]|$)' + listeners_raw_before=$(/usr/bin/ss -H -ltnp 2>/dev/null) || { /usr/bin/printf unavailable; exit 0; } + listeners_before=$(/usr/bin/printf '%s\n' "$listeners_raw_before" | /usr/bin/grep -E "$listener_pattern" || true) + candidate_count=0 + matched_pid='' + matched_start='' + matched_parent='' + matched_parent_start='' + matched_worker='' + matched_worker_start='' + for proc in /proc/[0-9]*; do + unset argv + declare -a argv=() + while IFS= read -r -d '' arg; do argv+=("$arg"); done < "$proc/cmdline" 2>/dev/null + test "${#argv[@]}" -eq 5 || continue + test "${argv[0]}" = /usr/bin/tailscale && test "${argv[1]}" = serve && test "${argv[2]}" = --yes && test "${argv[3]}" = --bg=false && test "${argv[4]}" = {{managedIngressPort}} || continue + candidate_pid=${proc##*/} + candidate_exe=$(/usr/bin/readlink -f "$proc/exe" 2>/dev/null) + candidate_cgroup=$(/usr/bin/awk -F: '$1 == "0" { print $3 }' "$proc/cgroup" 2>/dev/null) + candidate_start=$(/usr/bin/awk '{ print $22 }' "$proc/stat" 2>/dev/null) + candidate_parent=$(/usr/bin/awk '/^PPid:/ { print $2 }' "$proc/status" 2>/dev/null) + test "$candidate_exe" = /usr/bin/tailscale && test "$candidate_cgroup" = "$main_cgroup" && test -n "$candidate_start" || continue + route_owner=$candidate_parent + parent_exe=$(/usr/bin/readlink -f "/proc/$candidate_parent/exe" 2>/dev/null) + if test "$parent_exe" = /usr/bin/sudo; then + unset sudo_argv + declare -a sudo_argv=() + while IFS= read -r -d '' arg; do sudo_argv+=("$arg"); done < "/proc/$candidate_parent/cmdline" 2>/dev/null + test "${#sudo_argv[@]}" -eq 7 && test "${sudo_argv[0]}" = sudo && test "${sudo_argv[1]}" = -n && test "${sudo_argv[2]}" = /usr/bin/tailscale && test "${sudo_argv[3]}" = serve && test "${sudo_argv[4]}" = --yes && test "${sudo_argv[5]}" = --bg=false && test "${sudo_argv[6]}" = {{managedIngressPort}} || continue + parent_cgroup=$(/usr/bin/awk -F: '$1 == "0" { print $3 }' "/proc/$candidate_parent/cgroup" 2>/dev/null) + test "$parent_cgroup" = "$main_cgroup" || continue + route_owner=$(/usr/bin/awk '/^PPid:/ { print $2 }' "/proc/$candidate_parent/status" 2>/dev/null) + fi + worker_exe=$(/usr/bin/readlink -f "/proc/$route_owner/exe" 2>/dev/null) + worker_cgroup=$(/usr/bin/awk -F: '$1 == "0" { print $3 }' "/proc/$route_owner/cgroup" 2>/dev/null) + worker_parent=$(/usr/bin/awk '/^PPid:/ { print $2 }' "/proc/$route_owner/status" 2>/dev/null) + worker_start=$(/usr/bin/awk '{ print $22 }' "/proc/$route_owner/stat" 2>/dev/null) + test "$worker_exe" = "$main_exe" && test "$worker_cgroup" = "$main_cgroup" && test "$worker_parent" = "$pid_before" && test -n "$worker_start" || continue + unset worker_argv + declare -a worker_argv=() + while IFS= read -r -d '' arg; do worker_argv+=("$arg"); done < "/proc/$route_owner/cmdline" 2>/dev/null + test "${#worker_argv[@]}" -eq 4 || continue + case "${worker_argv[1]}" in */dist/infra/tailscale-route-owner.worker.js) ;; *) continue;; esac + test "${worker_argv[2]}" = --openclaw-tailscale-route-owner || continue + expected_direct='{"argv":["/usr/bin/tailscale","serve","--yes","--bg=false","{{managedIngressPort}}"]}' + expected_sudo='{"argv":["sudo","-n","/usr/bin/tailscale","serve","--yes","--bg=false","{{managedIngressPort}}"]}' + test "${worker_argv[3]}" = "$expected_direct" || test "${worker_argv[3]}" = "$expected_sudo" || continue + candidate_count=$((candidate_count + 1)) + matched_pid=$candidate_pid + matched_start=$candidate_start + matched_parent=$candidate_parent + matched_parent_start=$(/usr/bin/awk '{ print $22 }' "/proc/$candidate_parent/stat" 2>/dev/null) + matched_worker=$route_owner + matched_worker_start=$worker_start + done + pid_after=$(/usr/bin/systemctl --user show openclaw-gateway -p MainPID --value 2>/dev/null) + main_start_after=$(/usr/bin/awk '{ print $22 }' "/proc/$pid_before/stat" 2>/dev/null) + main_exe_after=$(/usr/bin/readlink -f "/proc/$pid_before/exe" 2>/dev/null) + listeners_raw_after=$(/usr/bin/ss -H -ltnp 2>/dev/null) || { /usr/bin/printf unavailable; exit 0; } + listeners_after=$(/usr/bin/printf '%s\n' "$listeners_raw_after" | /usr/bin/grep -E "$listener_pattern" || true) + test "$pid_before" = "$pid_after" && test "$main_start_before" = "$main_start_after" && test "$main_exe" = "$main_exe_after" && test "$listeners_before" = "$listeners_after" || { /usr/bin/printf unavailable; exit 0; } + gateway_owner=$(/usr/bin/printf '%s\n' "$listeners_after" | /usr/bin/grep -E '(^|[[:space:]])127\.0\.0\.1:{{gatewayPort}}([[:space:]]|$)' | /usr/bin/grep -F "pid=$pid_before,") + ingress_owner=$(/usr/bin/printf '%s\n' "$listeners_after" | /usr/bin/grep -E '(^|[[:space:]])127\.0\.0\.1:{{managedIngressPort}}([[:space:]]|$)' | /usr/bin/grep -F "pid=$pid_before,") + candidate_start_after=$(/usr/bin/awk '{ print $22 }' "/proc/$matched_pid/stat" 2>/dev/null) + candidate_parent_after=$(/usr/bin/awk '/^PPid:/ { print $2 }' "/proc/$matched_pid/status" 2>/dev/null) + parent_start_after=$(/usr/bin/awk '{ print $22 }' "/proc/$matched_parent/stat" 2>/dev/null) + worker_start_after=$(/usr/bin/awk '{ print $22 }' "/proc/$matched_worker/stat" 2>/dev/null) + worker_parent_after=$(/usr/bin/awk '/^PPid:/ { print $2 }' "/proc/$matched_worker/status" 2>/dev/null) + if test -n "$gateway_owner" && test -n "$ingress_owner" && test "$candidate_count" -eq 1 && test "$matched_start" = "$candidate_start_after" && test "$matched_parent" = "$candidate_parent_after" && test "$matched_parent_start" = "$parent_start_after" && test "$matched_worker_start" = "$worker_start_after" && test "$worker_parent_after" = "$pid_before"; then + /usr/bin/printf owned + else + /usr/bin/printf not-owned + fi + """; + return ["/bin/bash", "-lc", script]; + } } internal sealed class GatewayTailscaleAuthUpgradeService diff --git a/src/OpenClaw.Shared/TailscaleServeStatusPolicy.cs b/src/OpenClaw.Shared/TailscaleServeStatusPolicy.cs index e5308e97b..4b06b0e7f 100644 --- a/src/OpenClaw.Shared/TailscaleServeStatusPolicy.cs +++ b/src/OpenClaw.Shared/TailscaleServeStatusPolicy.cs @@ -2,7 +2,10 @@ namespace OpenClaw.Shared; -public sealed record TailscaleServeStatusResult(bool RoutesToGateway, bool FunnelEnabled); +public sealed record TailscaleServeStatusResult( + bool RoutesToGateway, + bool FunnelEnabled, + int? ForegroundProxyPort = null); public static class TailscaleServeStatusPolicy { @@ -28,21 +31,65 @@ public static bool TryParse( var routesToGateway = false; var funnelEnabled = false; + int? matchedProxyPort = null; + int? foregroundProxyPort = null; + var foregroundMatchCount = 0; + var unsafeMatchingRoute = false; foreach (var config in configs) { - if (!HasValidWebShape(config) || - !TryReadFunnelState(config, out var configFunnelEnabled)) + if (!HasValidWebShape(config.Value) || + !TryReadFunnelState(config.Value, out var configFunnelEnabled)) { return false; } - - routesToGateway |= HasGatewayWebProxy(config, port, expectedEndpoint); funnelEnabled |= configFunnelEnabled; + + var routeMatch = TryGetGatewayWebProxyPort( + config.Value, + expectedEndpoint, + out var proxyPort, + out var isLiteralIpv4Loopback); + if (routeMatch == GatewayWebProxyMatch.Invalid) + { + unsafeMatchingRoute = true; + continue; + } + + if (routeMatch == GatewayWebProxyMatch.Valid && + config.IsForeground && + !isLiteralIpv4Loopback) + { + unsafeMatchingRoute = true; + continue; + } + + if (routeMatch == GatewayWebProxyMatch.Valid) + { + if (matchedProxyPort is { } existingPort && existingPort != proxyPort) + return false; + + matchedProxyPort = proxyPort; + routesToGateway |= proxyPort == port; + if (config.IsForeground) + { + foregroundMatchCount++; + if (foregroundMatchCount > 1) + return false; + foregroundProxyPort = proxyPort; + } + } + } + + if (unsafeMatchingRoute) + { + routesToGateway = false; + foregroundProxyPort = null; } parsed = new TailscaleServeStatusResult( RoutesToGateway: routesToGateway, - FunnelEnabled: funnelEnabled); + FunnelEnabled: funnelEnabled, + ForegroundProxyPort: foregroundProxyPort); return true; } catch (JsonException) @@ -53,9 +100,9 @@ public static bool TryParse( private static bool TryCollectServeConfigs( JsonElement root, - out IReadOnlyList configs) + out IReadOnlyList configs) { - var collected = new List { root }; + var collected = new List { new(root, IsForeground: false) }; if (!root.TryGetProperty("Foreground", out var foreground)) { configs = collected; @@ -76,7 +123,7 @@ private static bool TryCollectServeConfigs( return false; } - collected.Add(entry.Value); + collected.Add(new ServeConfig(entry.Value, IsForeground: true)); } configs = collected; @@ -117,14 +164,24 @@ private static bool HasValidWebShape(JsonElement root) return true; } - private static bool HasGatewayWebProxy(JsonElement root, int port, Uri? expectedEndpoint) + private static GatewayWebProxyMatch TryGetGatewayWebProxyPort( + JsonElement root, + Uri? expectedEndpoint, + out int proxyPort, + out bool isLiteralIpv4Loopback) { + proxyPort = 0; + isLiteralIpv4Loopback = false; if (!root.TryGetProperty("Web", out var web) || web.ValueKind != JsonValueKind.Object) - return false; + return GatewayWebProxyMatch.None; + var found = false; foreach (var webEndpoint in web.EnumerateObject()) { - if (!EndpointMatches(webEndpoint.Name, expectedEndpoint) || + if (!EndpointMatches(webEndpoint.Name, expectedEndpoint)) + continue; + + if (found || webEndpoint.Value.ValueKind != JsonValueKind.Object || !webEndpoint.Value.TryGetProperty("Handlers", out var handlers) || handlers.ValueKind != JsonValueKind.Object || @@ -133,14 +190,21 @@ private static bool HasGatewayWebProxy(JsonElement root, int port, Uri? expected !rootHandler.TryGetProperty("Proxy", out var proxy) || proxy.ValueKind != JsonValueKind.String) { - continue; + return GatewayWebProxyMatch.Invalid; } - if (IsLoopbackGatewayProxy(proxy.GetString(), port)) - return true; + if (!TryReadLoopbackGatewayProxyPort( + proxy.GetString(), + out proxyPort, + out isLiteralIpv4Loopback)) + { + return GatewayWebProxyMatch.Invalid; + } + + found = true; } - return false; + return found ? GatewayWebProxyMatch.Valid : GatewayWebProxyMatch.None; } private static bool EndpointMatches(string endpoint, Uri? expectedEndpoint) @@ -215,18 +279,41 @@ private static bool TryContainsEnabledFunnelValue(JsonElement value, out bool en } } - private static bool IsLoopbackGatewayProxy(string? proxy, int port) => - proxy is not null && - Uri.TryCreate(proxy, UriKind.Absolute, out var uri) && - uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && - uri.Port == port && - HasCanonicalRootPathAndNoSuffix(proxy) && - !HasUserInfoDelimiter(proxy) && - string.IsNullOrEmpty(uri.UserInfo) && - string.IsNullOrEmpty(uri.Query) && - string.IsNullOrEmpty(uri.Fragment) && - (uri.Host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase) || - uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase)); + private static bool TryReadLoopbackGatewayProxyPort( + string? proxy, + out int port, + out bool isLiteralIpv4Loopback) + { + port = 0; + isLiteralIpv4Loopback = false; + if (proxy is null || + !Uri.TryCreate(proxy, UriKind.Absolute, out var uri) || + !uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || + uri.Port is <= 0 or > 65535 || + !HasCanonicalRootPathAndNoSuffix(proxy) || + HasUserInfoDelimiter(proxy) || + !string.IsNullOrEmpty(uri.UserInfo) || + !string.IsNullOrEmpty(uri.Query) || + !string.IsNullOrEmpty(uri.Fragment) || + (!uri.Host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase) && + !uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + port = uri.Port; + isLiteralIpv4Loopback = uri.Host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase); + return true; + } + + private readonly record struct ServeConfig(JsonElement Value, bool IsForeground); + + private enum GatewayWebProxyMatch + { + None, + Valid, + Invalid, + } private static bool HasUserInfoDelimiter(string value) { diff --git a/tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthLiveVerifierTests.cs b/tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthLiveVerifierTests.cs index 578027b09..29e97ce1a 100644 --- a/tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthLiveVerifierTests.cs +++ b/tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthLiveVerifierTests.cs @@ -164,12 +164,111 @@ public async Task VerifyAsync_AcceptsCurrentForegroundServeStatusShape() var runner = new FakeWslCommandRunner((_, command, _) => Task.FromResult( command.Contains("serve", StringComparer.Ordinal) ? ForegroundServeStatus() - : RunningStatus())); + : command.FirstOrDefault() == "/bin/bash" + ? new WslCommandResult(0, "owned", "") + : RunningStatus())); var verifier = new GatewayTailscaleAuthLiveVerifier(runner, TimeSpan.FromSeconds(1)); var result = await verifier.VerifyAsync(ManagedRecord(), 18789, CancellationToken.None); Assert.Equal(GatewayTailscaleAuthLiveState.Ready, result); + Assert.Equal(3, runner.ProbeCalls); + } + + [Fact] + public async Task VerifyAsync_AcceptsManagedForegroundIngressOwnedByGatewayService() + { + const int managedIngressPort = 35225; + var runner = new FakeWslCommandRunner((_, command, _) => Task.FromResult( + command.Contains("serve", StringComparer.Ordinal) + ? ForegroundServeStatus(proxyPort: managedIngressPort) + : command.FirstOrDefault() == "/bin/bash" + ? new WslCommandResult(0, "owned", "") + : RunningStatus())); + var verifier = new GatewayTailscaleAuthLiveVerifier(runner, TimeSpan.FromSeconds(1)); + + var result = await verifier.VerifyAsync(ManagedRecord(), 18789, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthLiveState.Ready, result); + Assert.Equal(3, runner.ProbeCalls); + Assert.Equal("/bin/bash", runner.Commands[2][0]); + Assert.Contains("systemctl --user show openclaw-gateway", runner.Commands[2][2], StringComparison.Ordinal); + Assert.Equal(2, runner.Commands[2][2].Split("systemctl", StringSplitOptions.None).Length - 1); + Assert.Contains(":18789", runner.Commands[2][2], StringComparison.Ordinal); + Assert.Contains(":35225", runner.Commands[2][2], StringComparison.Ordinal); + Assert.Contains("/proc/$pid_before/cgroup", runner.Commands[2][2], StringComparison.Ordinal); + Assert.Contains("--bg=false", runner.Commands[2][2], StringComparison.Ordinal); + Assert.Contains("tailscale-route-owner.worker.js", runner.Commands[2][2], StringComparison.Ordinal); + Assert.Contains("--openclaw-tailscale-route-owner", runner.Commands[2][2], StringComparison.Ordinal); + Assert.Contains("/proc/$matched_pid/stat", runner.Commands[2][2], StringComparison.Ordinal); + Assert.Contains("candidate_count\" -eq 1", runner.Commands[2][2], StringComparison.Ordinal); + } + + [Fact] + public async Task VerifyAsync_RejectsForegroundIngressNotOwnedByGatewayService() + { + var runner = new FakeWslCommandRunner((_, command, _) => Task.FromResult( + command.Contains("serve", StringComparer.Ordinal) + ? ForegroundServeStatus(proxyPort: 35225) + : command.FirstOrDefault() == "/bin/bash" + ? new WslCommandResult(0, "not-owned", "") + : RunningStatus())); + var verifier = new GatewayTailscaleAuthLiveVerifier(runner, TimeSpan.FromSeconds(1)); + + var result = await verifier.VerifyAsync(ManagedRecord(), 18789, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthLiveState.NotReady, result); + } + + [Theory] + [InlineData(0, "unavailable")] + [InlineData(0, "unexpected")] + [InlineData(1, "")] + public async Task VerifyAsync_FailsClosedWhenManagedIngressOwnershipIsUnavailable( + int exitCode, + string output) + { + var runner = new FakeWslCommandRunner((_, command, _) => Task.FromResult( + command.Contains("serve", StringComparer.Ordinal) + ? ForegroundServeStatus(proxyPort: 35225) + : command.FirstOrDefault() == "/bin/bash" + ? new WslCommandResult(exitCode, output, "") + : RunningStatus())); + var verifier = new GatewayTailscaleAuthLiveVerifier(runner, TimeSpan.FromSeconds(1)); + + var result = await verifier.VerifyAsync(ManagedRecord(), 18789, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthLiveState.Unavailable, result); + } + + [Fact] + public async Task VerifyAsync_RejectsManagedForegroundIngressWhenFunnelIsEnabled() + { + var runner = new FakeWslCommandRunner((_, command, _) => Task.FromResult( + command.Contains("serve", StringComparer.Ordinal) + ? ForegroundServeStatus(proxyPort: 35225, funnelEnabled: true) + : RunningStatus())); + var verifier = new GatewayTailscaleAuthLiveVerifier(runner, TimeSpan.FromSeconds(1)); + + var result = await verifier.VerifyAsync(ManagedRecord(), 18789, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthLiveState.NotReady, result); + Assert.Equal(2, runner.ProbeCalls); + } + + [Fact] + public async Task VerifyAsync_RejectsForegroundLocalhostProxyOutsideCoreContract() + { + var runner = new FakeWslCommandRunner((_, command, _) => Task.FromResult( + command.Contains("serve", StringComparer.Ordinal) + ? ForegroundServeStatus(proxyPort: 35225, proxyHost: "localhost") + : RunningStatus())); + var verifier = new GatewayTailscaleAuthLiveVerifier(runner, TimeSpan.FromSeconds(1)); + + var result = await verifier.VerifyAsync(ManagedRecord(), 18789, CancellationToken.None); + + Assert.Equal(GatewayTailscaleAuthLiveState.NotReady, result); + Assert.Equal(2, runner.ProbeCalls); } [Theory] @@ -187,6 +286,7 @@ public async Task VerifyAsync_FailsClosedForUnsafeForegroundServeStatus( var result = await verifier.VerifyAsync(ManagedRecord(), 18789, CancellationToken.None); Assert.Equal(expected, result.ToString()); + Assert.Equal(2, runner.ProbeCalls); } [Theory] @@ -396,6 +496,111 @@ public async Task VerifyAsync_EvaluatesEveryFunnelAliasAndRejectsUnknownShapes( """, "Unavailable" }, + { + """ + { + "first": { + "Web": { + "host.tail.example:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:35225" } } + } + } + }, + "second": { + "Web": { + "host.tail.example:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:35225" } } + } + } + } + } + """, + "Unavailable" + }, + { + """ + { + "first": { + "Web": { + "host.tail.example:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:35225" } } + } + } + }, + "second": { + "Web": { + "host.tail.example:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:35226" } } + } + } + } + } + """, + "Unavailable" + }, + { + """ + { + "first": { + "Web": { + "host.tail.example:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:35225" } } + } + } + }, + "second": { + "Web": { + "host.tail.example:443": { + "Handlers": { "/": { "Proxy": "http://10.0.0.1:35226" } } + } + } + } + } + """, + "NotReady" + }, + { + """ + { + "literal": { + "Web": { + "host.tail.example:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:35225" } } + } + } + }, + "localhost": { + "Web": { + "host.tail.example:443": { + "Handlers": { "/": { "Proxy": "http://localhost:35225" } } + } + } + } + } + """, + "NotReady" + }, + { + """ + { + "literal": { + "Web": { + "host.tail.example:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:35225" } } + } + } + }, + "localhost": { + "Web": { + "host.tail.example:443": { + "Handlers": { "/": { "Proxy": "http://localhost:35226" } } + } + } + } + } + """, + "NotReady" + }, }; private static WslCommandResult RunningStatus() => Status("Running", "host.tail.example."); @@ -425,19 +630,23 @@ private static WslCommandResult ServeStatus( ""); } - private static WslCommandResult ForegroundServeStatus() => + private static WslCommandResult ForegroundServeStatus( + int proxyPort = 18789, + bool funnelEnabled = false, + string proxyHost = "127.0.0.1") => new( 0, - """ + $$""" { "Foreground": { "75980230dda8b0e0": { "TCP": { "443": { "HTTPS": true } }, "Web": { "host.tail.example:443": { - "Handlers": { "/": { "Proxy": "http://127.0.0.1:18789" } } + "Handlers": { "/": { "Proxy": "http://{{proxyHost}}:{{proxyPort}}" } } } - } + }, + "AllowFunnel": {{funnelEnabled.ToString().ToLowerInvariant()}} } } } @@ -495,7 +704,12 @@ public Task RunInDistroAsync( IReadOnlyList command, CancellationToken cancellationToken = default, IReadOnlyDictionary? environment = null) - => throw new NotSupportedException(); + { + ProbeCalls++; + DistroName = name; + Commands.Add(command); + return runInDistro(name, command, cancellationToken); + } public Task RunAsync( IReadOnlyList arguments, From b139798bd2bed572448447884746b81796418ffa Mon Sep 17 00:00:00 2001 From: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:54:44 +0100 Subject: [PATCH 6/6] fix(connection): stream WSL ownership probe via stdin --- .../GatewayTailscaleAuthUpgrade.cs | 13 ++- src/OpenClaw.Connection/WslCommandRunner.cs | 72 ++++++++++++++++- .../GatewayTailscaleAuthLiveVerifierTests.cs | 81 ++++++++++++++++--- 3 files changed, 149 insertions(+), 17 deletions(-) diff --git a/src/OpenClaw.Connection/GatewayTailscaleAuthUpgrade.cs b/src/OpenClaw.Connection/GatewayTailscaleAuthUpgrade.cs index 0e7d99129..cc104d0fb 100644 --- a/src/OpenClaw.Connection/GatewayTailscaleAuthUpgrade.cs +++ b/src/OpenClaw.Connection/GatewayTailscaleAuthUpgrade.cs @@ -212,9 +212,14 @@ public async Task VerifyAsync( WslCommandResult ownershipResult; try { - ownershipResult = await _commandRunner.RunInDistroAsync( + var ownershipScript = BuildManagedIngressOwnershipScript( + gatewayPort, + managedIngressPort) + .Replace("\r", string.Empty, StringComparison.Ordinal); + ownershipResult = await _commandRunner.RunInDistroWithStandardInputAsync( distroName, - BuildManagedIngressOwnershipCommand(gatewayPort, managedIngressPort), + ["/bin/bash", "-s"], + ownershipScript, timeout.Token) .ConfigureAwait(false); } @@ -243,7 +248,7 @@ private static IReadOnlyList BuildRootProbeArguments( IReadOnlyList command) => ["-d", distroName, "--user", "root", "--", .. command]; - private static IReadOnlyList BuildManagedIngressOwnershipCommand( + private static string BuildManagedIngressOwnershipScript( int gatewayPort, int managedIngressPort) { @@ -329,7 +334,7 @@ unset worker_argv /usr/bin/printf not-owned fi """; - return ["/bin/bash", "-lc", script]; + return script; } } diff --git a/src/OpenClaw.Connection/WslCommandRunner.cs b/src/OpenClaw.Connection/WslCommandRunner.cs index 8dfd3ef50..2eacf5f31 100644 --- a/src/OpenClaw.Connection/WslCommandRunner.cs +++ b/src/OpenClaw.Connection/WslCommandRunner.cs @@ -29,6 +29,19 @@ Task RunInDistroAsync( string name, IReadOnlyList command, CancellationToken cancellationToken = default, IReadOnlyDictionary? environment = null); + + Task RunInDistroWithStandardInputAsync( + string name, + IReadOnlyList command, + string standardInput, + CancellationToken cancellationToken = default, + IReadOnlyDictionary? environment = null) => + cancellationToken.IsCancellationRequested + ? Task.FromCanceled(cancellationToken) + : Task.FromResult(new WslCommandResult( + -1, + string.Empty, + "This WSL command runner does not support standard input.")); } /// @@ -70,6 +83,23 @@ public Task RunInDistroAsync( return RunAsync(args, cancellationToken, environment); } + public Task RunInDistroWithStandardInputAsync( + string name, + IReadOnlyList command, + string standardInput, + CancellationToken cancellationToken = default, + IReadOnlyDictionary? environment = null) + { + var args = new List { "-d", name, "--" }; + args.AddRange(command); + return RunProcessAsync( + "wsl.exe", + args, + cancellationToken, + environment, + standardInput); + } + public Task TerminateDistroAsync(string name, CancellationToken cancellationToken = default) => RunAsync(["--terminate", name], cancellationToken); @@ -108,13 +138,15 @@ private async Task RunProcessAsync( string fileName, IReadOnlyList arguments, CancellationToken cancellationToken, - IReadOnlyDictionary? environment) + IReadOnlyDictionary? environment, + string? standardInput = null) { var psi = new ProcessStartInfo { FileName = fileName, RedirectStandardOutput = true, RedirectStandardError = true, + RedirectStandardInput = standardInput is not null, UseShellExecute = false, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, @@ -147,8 +179,12 @@ private async Task RunProcessAsync( var stdoutTask = process.StandardOutput.ReadToEndAsync(timeoutCts.Token); var stderrTask = process.StandardError.ReadToEndAsync(timeoutCts.Token); + var stdinTask = standardInput is null + ? Task.CompletedTask + : WriteStandardInputAsync(process, standardInput, timeoutCts.Token); bool timedOut = false; + OperationCanceledException? cancellationException = null; try { await process.WaitForExitAsync(timeoutCts.Token); @@ -159,19 +195,49 @@ private async Task RunProcessAsync( // slopwatch-ignore: SW003 Shutdown cancellation or disposal is expected and the caller already preserves the safe state. try { process.Kill(entireProcessTree: true); } catch { } } - catch (OperationCanceledException) + catch (OperationCanceledException ex) { // slopwatch-ignore: SW003 Shutdown cancellation or disposal is expected and the caller already preserves the safe state. try { process.Kill(entireProcessTree: true); } catch { } - throw; + cancellationException = ex; + } + + if (timedOut || cancellationException is not null) + { + using var exitCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try { await process.WaitForExitAsync(exitCts.Token).ConfigureAwait(false); } catch { } } + Exception? stdinFailure = null; string stdout, stderr; + try { await stdinTask; } catch (Exception ex) { stdinFailure = ex; } try { stdout = await stdoutTask; } catch { stdout = string.Empty; } try { stderr = await stderrTask; } catch { stderr = string.Empty; } + cancellationToken.ThrowIfCancellationRequested(); + if (cancellationException is not null) + throw cancellationException; + if (!timedOut && stdinFailure is not null) + return new WslCommandResult(-1, stdout, "wsl.exe failed to receive standard input"); + return timedOut ? new WslCommandResult(-1, stdout, "wsl.exe timed out") : new WslCommandResult(process.ExitCode, stdout, stderr); } + + private static async Task WriteStandardInputAsync( + Process process, + string standardInput, + CancellationToken cancellationToken) + { + try + { + await process.StandardInput.WriteAsync(standardInput.AsMemory(), cancellationToken); + await process.StandardInput.FlushAsync(cancellationToken); + } + finally + { + process.StandardInput.Close(); + } + } } diff --git a/tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthLiveVerifierTests.cs b/tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthLiveVerifierTests.cs index 29e97ce1a..998ff9bbf 100644 --- a/tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthLiveVerifierTests.cs +++ b/tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthLiveVerifierTests.cs @@ -123,6 +123,21 @@ await Assert.ThrowsAnyAsync(() => verifier.VerifyAsync(ManagedRecord(), 18789, cancellation.Token)); } + [Fact] + public async Task StandardInputCompatibilityDefault_PropagatesPreCancellation() + { + IWslCommandRunner runner = new LegacyWslCommandRunner(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => + runner.RunInDistroWithStandardInputAsync( + "OpenClawGateway", + ["/bin/bash", "-s"], + "exit 0", + cancellation.Token)); + } + [Theory] [InlineData("other.tail.example.", 18789, false, "NotReady")] [InlineData("host.tail.example.", 19999, false, "NotReady")] @@ -192,16 +207,20 @@ public async Task VerifyAsync_AcceptsManagedForegroundIngressOwnedByGatewayServi Assert.Equal(GatewayTailscaleAuthLiveState.Ready, result); Assert.Equal(3, runner.ProbeCalls); Assert.Equal("/bin/bash", runner.Commands[2][0]); - Assert.Contains("systemctl --user show openclaw-gateway", runner.Commands[2][2], StringComparison.Ordinal); - Assert.Equal(2, runner.Commands[2][2].Split("systemctl", StringSplitOptions.None).Length - 1); - Assert.Contains(":18789", runner.Commands[2][2], StringComparison.Ordinal); - Assert.Contains(":35225", runner.Commands[2][2], StringComparison.Ordinal); - Assert.Contains("/proc/$pid_before/cgroup", runner.Commands[2][2], StringComparison.Ordinal); - Assert.Contains("--bg=false", runner.Commands[2][2], StringComparison.Ordinal); - Assert.Contains("tailscale-route-owner.worker.js", runner.Commands[2][2], StringComparison.Ordinal); - Assert.Contains("--openclaw-tailscale-route-owner", runner.Commands[2][2], StringComparison.Ordinal); - Assert.Contains("/proc/$matched_pid/stat", runner.Commands[2][2], StringComparison.Ordinal); - Assert.Contains("candidate_count\" -eq 1", runner.Commands[2][2], StringComparison.Ordinal); + Assert.Equal("-s", runner.Commands[2][1]); + Assert.Single(runner.StandardInputs); + var script = runner.StandardInputs[0]; + Assert.DoesNotContain('\r', script); + Assert.Contains("systemctl --user show openclaw-gateway", script, StringComparison.Ordinal); + Assert.Equal(2, script.Split("systemctl", StringSplitOptions.None).Length - 1); + Assert.Contains(":18789", script, StringComparison.Ordinal); + Assert.Contains(":35225", script, StringComparison.Ordinal); + Assert.Contains("/proc/$pid_before/cgroup", script, StringComparison.Ordinal); + Assert.Contains("--bg=false", script, StringComparison.Ordinal); + Assert.Contains("tailscale-route-owner.worker.js", script, StringComparison.Ordinal); + Assert.Contains("--openclaw-tailscale-route-owner", script, StringComparison.Ordinal); + Assert.Contains("/proc/$matched_pid/stat", script, StringComparison.Ordinal); + Assert.Contains("candidate_count\" -eq 1", script, StringComparison.Ordinal); } [Fact] @@ -698,6 +717,7 @@ private sealed class FakeWslCommandRunner( public string? DistroName { get; private set; } public List> Commands { get; } = []; public List> HostArguments { get; } = []; + public List StandardInputs { get; } = []; public Task RunInDistroAsync( string name, @@ -711,6 +731,17 @@ public Task RunInDistroAsync( return runInDistro(name, command, cancellationToken); } + public Task RunInDistroWithStandardInputAsync( + string name, + IReadOnlyList command, + string standardInput, + CancellationToken cancellationToken = default, + IReadOnlyDictionary? environment = null) + { + StandardInputs.Add(standardInput); + return RunInDistroAsync(name, command, cancellationToken, environment); + } + public Task RunAsync( IReadOnlyList arguments, CancellationToken cancellationToken = default, @@ -743,4 +774,34 @@ public Task UnregisterDistroAsync( CancellationToken cancellationToken = default) => throw new NotSupportedException(); } + + private sealed class LegacyWslCommandRunner : IWslCommandRunner + { + public Task RunAsync( + IReadOnlyList arguments, + CancellationToken cancellationToken = default, + IReadOnlyDictionary? environment = null) => + throw new NotSupportedException(); + + public Task> ListDistrosAsync( + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task TerminateDistroAsync( + string name, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task UnregisterDistroAsync( + string name, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task RunInDistroAsync( + string name, + IReadOnlyList command, + CancellationToken cancellationToken = default, + IReadOnlyDictionary? environment = null) => + throw new NotSupportedException(); + } }