diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fb18bbeda..21da067dc 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 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.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.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 8e93ebbe9..d3467f8b7 100644 --- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs +++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs @@ -224,16 +224,33 @@ 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 url = GatewayDashboardUrlBuilder.Build( + var active = _gatewayRegistry?.GetActive(); + var activeMatches = active is not null && + string.Equals(active.Url, gatewayUrl, StringComparison.OrdinalIgnoreCase); + 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" }; + + var result = await service.BuildAsync(new GatewayDashboardLinkRequest( gatewayUrl, path, - token, - !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken); + dashboardCredential, + usesSharedCredential, + 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 a57b2c8c1..5b3d3cb2f 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,11 @@ _dispatcherQueue is null tunnelManager: _sshTunnelService, endpointProvenanceProbe: managedLocalPortProvenance.InspectAsync, validationTunnelFactory: () => new SshTunnelService(appLogger)); + _gatewayDashboardLinkService = new GatewayDashboardLinkService( + (gatewayId, cancellationToken) => _connectionManager.RevalidateTailscaleDashboardAuthAsync( + gatewayId, + cancellationToken), + LocalizationHelper.GetString); _connectionManager.OperatorClientChanged += OnOperatorClientChanged; _connectionManager.StateChanged += OnManagerStateChanged; _gatewayDirectConnectService = new GatewayDirectConnectService( @@ -3812,11 +3818,57 @@ private void OpenDashboard(string? path = null) return; } - var url = GatewayDashboardUrlBuilder.Build( + var appendBrowserCredential = + !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken; + var active = _gatewayRegistry?.GetActive(); + var tailscaleGatewayId = active?.TrustTailscaleAuth == true && + string.Equals(active.Url, gatewayUrl, StringComparison.OrdinalIgnoreCase) + ? active.Id + : null; + + _ = OpenDashboardFromLinkServiceAsync(new GatewayDashboardLinkRequest( gatewayUrl, path, token, - !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken); + appendBrowserCredential, + tailscaleGatewayId)); + } + + internal async Task OpenDashboardFromLinkServiceAsync( + GatewayDashboardLinkRequest request, + Func>? validateBeforeLaunch = null) + { + var service = _gatewayDashboardLinkService; + if (service is null) + { + ShowConnectionSettingsForPairingIssue("Dashboard", "Connection manager is not initialized"); + return; + } + + var result = await service.BuildAsync(request); + if (result.RevalidationError is not null) + { + Logger.Warn(result.RevalidationError); + } + + if (!result.Success) + { + ShowConnectionSettingsForPairingIssue( + "Dashboard", + result.Error ?? "Dashboard URL is unavailable"); + return; + } + + if (validateBeforeLaunch is not null && !await validateBeforeLaunch(result)) + { + return; + } + + LaunchDashboardUrl(result.Url!); + } + + 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..a3c43151d 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs @@ -6,7 +6,8 @@ public static string Build( string gatewayUrl, string? path, string? sharedGatewayToken, - bool appendSharedGatewayToken) + bool appendSharedGatewayToken, + bool trustTailscaleAuth = false) { ArgumentException.ThrowIfNullOrWhiteSpace(gatewayUrl); @@ -19,7 +20,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..1e7fb995f 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 = LocalizationHelper.GetString("ConnectionPage_TailscaleDashboardAuthMenu"), + Tag = row.Id, + }; + enableTailscaleAuth.Click += OnEnableTailscaleDashboardAuth; + flyout.Items.Add(enableTailscaleAuth); + } if (row.HasHostTerminal) { var openTerminal = new MenuFlyoutItem { Text = row.HostTerminalLabel, Tag = row.Id }; @@ -2520,30 +2531,13 @@ private async Task OnSavedRowOpenDashboardAsync(object sender) if (rec == null) return; try { - if (!string.IsNullOrWhiteSpace(rec.SharedGatewayToken)) - { - 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; - } - } - - var url = GatewayDashboardUrlBuilder.Build( + await CurrentApp.OpenDashboardFromLinkServiceAsync(new GatewayDashboardLinkRequest( rec.Url, - path: null, + null, rec.SharedGatewayToken, - appendSharedGatewayToken: !string.IsNullOrWhiteSpace(rec.SharedGatewayToken)); - 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) { @@ -2551,6 +2545,79 @@ private async Task OnSavedRowOpenDashboardAsync(object sender) } } + 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), + 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 = LocalizationHelper.GetString("ConnectionPage_TailscaleDashboardAuthTitle"), + Content = LocalizationHelper.GetString("ConnectionPage_TailscaleDashboardAuthBody"), + PrimaryButtonText = LocalizationHelper.GetString("ConnectionPage_TailscaleDashboardAuthEnable"), + 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(LocalizationHelper.GetString("ConnectionPage_TailscaleDashboardAuthEnabled")); + LoadSavedGateways(); + return; + } + + var detail = string.IsNullOrWhiteSpace(result.Error) + ? result.Outcome.ToString() + : result.Error; + SetGatewayHostActionStatus( + string.Format(LocalizationHelper.GetString("ConnectionPage_TailscaleDashboardAuthFailedFormat"), 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/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLinkService.cs b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLinkService.cs new file mode 100644 index 000000000..e009614f9 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLinkService.cs @@ -0,0 +1,92 @@ +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 NoBrowserCompatibleCredentialKey = + "DashboardLink_NoBrowserCompatibleCredential"; + internal const string RevalidationFailedKey = + "DashboardLink_TailscaleRevalidationFailed"; + + private readonly Func> _revalidateTailscaleAuth; + private readonly Func _localize; + + public GatewayDashboardLinkService( + Func> revalidateTailscaleAuth, + Func localize) + { + _revalidateTailscaleAuth = revalidateTailscaleAuth + ?? throw new ArgumentNullException(nameof(revalidateTailscaleAuth)); + _localize = localize ?? throw new ArgumentNullException(nameof(localize)); + } + + 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 = _localize(RevalidationFailedKey); + } + } + + if (!string.IsNullOrWhiteSpace(request.TailscaleGatewayId) && + !trustTailscaleAuth && + (!request.AppendBrowserCredential || string.IsNullOrWhiteSpace(request.BrowserCredential))) + { + return new GatewayDashboardLinkResult( + Url: null, + TrustTailscaleAuth: false, + Error: _localize(NoBrowserCompatibleCredentialKey), + 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..1d5fd715c 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -5467,6 +5467,30 @@ 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} + + + 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 60e556866..946dea0bf 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -5463,6 +5463,30 @@ 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} + + + 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 cbb9ea5f2..44611089f 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -5464,6 +5464,30 @@ 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} + + + 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 bf824cb7a..436bc0e32 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -5463,6 +5463,30 @@ 打开仪表板 + + 使用 Tailscale 身份访问仪表板 + + + 使用 Tailscale 身份访问仪表板? + + + 这将在此托管网关上启用经过验证的 Tailscale 身份认证。已保存的令牌、设备身份、WSL 发行版和配对信息均保持不变。 + + + 启用 + + + 已启用仪表板的 Tailscale 身份认证。 + + + 无法启用仪表板的 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 58acfbc95..bf51c059d 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -5463,6 +5463,30 @@ 開啟儀表板 + + 使用 Tailscale 身分存取儀表板 + + + 使用 Tailscale 身分存取儀表板? + + + 這會在此受管理的閘道上啟用經過驗證的 Tailscale 身分驗證。已儲存的權杖、裝置身分、WSL 發行版本和配對資訊均維持不變。 + + + 啟用 + + + 已啟用儀表板的 Tailscale 身分驗證。 + + + 無法啟用儀表板的 Tailscale 身分驗證:{0} + + + 沒有可供瀏覽器使用的閘道認證 + + + 重新驗證儀表板的 Tailscale 身分驗證失敗 + {0} 的選項 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..c89b5def4 100644 --- a/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs +++ b/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs @@ -441,9 +441,11 @@ public async Task ExternalLike_QrOnlyFreshTray_RequiresExplicitDeviceApproval() using var dashboardDoc = await externalTray.Client.CallToolExpectSuccessAsync("app.dashboard.url"); var dashboard = dashboardDoc.RootElement; - Assert.Equal("record.BootstrapToken", dashboard.GetProperty("credentialSource").GetString()); + 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.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..5f7ed5a5c 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -451,6 +451,17 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() Assert.Contains("Check SSH tunnel settings and logs.", method); } + [Fact] + public void DashboardLinkPolicy_StaysDelegatedToFocusedService() + { + var source = ReadAppSources(); + + Assert.Contains("GatewayDashboardLinkService", source); + Assert.DoesNotContain("OpenDashboardAfterTailscaleAuthRevalidationAsync", source); + Assert.DoesNotContain(".RevalidateTailscaleDashboardAuthAsync(active.Id)", source); + Assert.DoesNotContain("GatewayDashboardUrlBuilder.Build(", source); + } + [Fact] public void SshTunnelExit_RecoversActiveRegistryGatewayThroughConnectionManager() { diff --git a/tests/OpenClaw.Tray.Tests/ConnectionPageTailscaleRecoveryTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionPageTailscaleRecoveryTests.cs index e791458de..91dfa5451 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionPageTailscaleRecoveryTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionPageTailscaleRecoveryTests.cs @@ -5,6 +5,65 @@ namespace OpenClaw.Tray.Tests; public sealed class ConnectionPageTailscaleRecoveryTests { + [Fact] + public void SavedDashboardLaunch_DelegatesToSharedDashboardLinkService() + { + 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]; + + 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] public void NetworkFailure_ForManagedTailscaleGateway_UsesDedicatedRecoveryPlan() { @@ -64,4 +123,5 @@ public void NetworkFailure_ForUnmanagedTailscaleGateway_UsesOrdinaryNetworkRecov Assert.Equal(RecoveryCategory.Network, plan.Recovery); Assert.NotEqual("Tailscale gateway unavailable", plan.StripHeadline); } + } diff --git a/tests/OpenClaw.Tray.Tests/GatewayDashboardLinkServiceTests.cs b/tests/OpenClaw.Tray.Tests/GatewayDashboardLinkServiceTests.cs new file mode 100644 index 000000000..9551f4f27 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/GatewayDashboardLinkServiceTests.cs @@ -0,0 +1,147 @@ +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); + } + + [Fact] + public async Task BuildAsync_TokenFreeBootstrapRequest_PreservesDashboardUrl() + { + var service = CreateService((_, _) => Task.FromResult(false)); + + var result = await service.BuildAsync(Request( + appendBrowserCredential: false, + 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("localized:DashboardLink_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("localized:DashboardLink_TailscaleRevalidationFailed", 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("localized:DashboardLink_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("localized:DashboardLink_TailscaleRevalidationFailed", result.RevalidationError); + Assert.DoesNotContain("sensitive", result.RevalidationError); + } + + private static GatewayDashboardLinkService CreateService( + Func> revalidate) => + new(revalidate, key => $"localized:{key}"); + + 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/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 @@ +