From 3da085093ecb0b996f35870967e444de9580d6df Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 27 Oct 2026 13:35:35 -0400 Subject: [PATCH 1/2] Tell why the Devolutions Agent broker cannot be reached --- src/Languages/lang_en.json | 4 + .../AgentPolicyInspectorViewModel.cs | 18 +++++ .../BrokerFailureDescriber.cs | 27 ++++++- .../BrokerPolicyFailure.cs | 4 + .../BrokerReachability.cs | 75 +++++++++++++++++++ .../BrokerPolicyManagementModels.cs | 3 + .../BrokerPolicyManagementService.cs | 28 +++++-- .../Shared/BoundedNamedPipeBrokerTransport.cs | 20 ++++- .../PackageOperations.cs | 43 ++++++++--- .../BrokerFailureDescriberTests.cs | 12 +++ .../BrokerPolicyManagementServiceTests.cs | 30 +++++++- .../BrokerPolicyTransportTests.cs | 58 +++++++++++++- .../PackageOperationsTests.cs | 32 +++++++- .../AgentPolicyInspectorViewModelTests.cs | 3 + 14 files changed, 330 insertions(+), 27 deletions(-) create mode 100644 src/UniGetUI.PackageEngine.AgentBroker/BrokerReachability.cs diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index 8f57852cfd..0f28c7cd51 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -1347,6 +1347,10 @@ "Automatic updates": "Automatic updates", "Here you can change UniGetUI's behaviour regarding the following shortcuts. Checking a shortcut will make UniGetUI delete it if if gets created on a future upgrade. Unchecking it will keep the shortcut intact": "Here you can change UniGetUI's behaviour regarding the following shortcuts. Checking a shortcut will make UniGetUI delete it if gets created on a future upgrade. Unchecking it will keep the shortcut intact", "Agent broker unavailable": "Agent broker unavailable", + "The Devolutions Agent package broker is not running": "The Devolutions Agent package broker is not running", + "UniGetUI could not find the package broker of Devolutions Agent on this computer. Make sure that Devolutions Agent is installed and running, and that its package broker is enabled in the Agent configuration. Versions of Devolutions Agent without a package broker cannot run package operations.": "UniGetUI could not find the package broker of Devolutions Agent on this computer. Make sure that Devolutions Agent is installed and running, and that its package broker is enabled in the Agent configuration. Versions of Devolutions Agent without a package broker cannot run package operations.", + "Devolutions Agent refused this copy of UniGetUI": "Devolutions Agent refused this copy of UniGetUI", + "The package broker is running, but it closed the connection without answering. This usually means that it does not trust this copy of UniGetUI: the Devolutions Agent only accepts UniGetUI installed for all users in a folder that only administrators can modify, such as Program Files. If you are running a development or self-built version, install an official release of UniGetUI and try again.": "The package broker is running, but it closed the connection without answering. This usually means that it does not trust this copy of UniGetUI: the Devolutions Agent only accepts UniGetUI installed for all users in a folder that only administrators can modify, such as Program Files. If you are running a development or self-built version, install an official release of UniGetUI and try again.", "These options cannot be used through the Devolutions Agent, which will refuse the operation:": "These options cannot be used through the Devolutions Agent, which will refuse the operation:", "--override and --custom pass arbitrary arguments to the package installer. Through the Devolutions Agent, the installer can run with administrator rights, so your organization's policy may block these arguments.": "--override and --custom pass arbitrary arguments to the package installer. Through the Devolutions Agent, the installer can run with administrator rights, so your organization's policy may block these arguments.", "Custom arguments are passed to WinGet by the Devolutions Agent, which runs this operation with administrator rights. Your organization's policy may block custom arguments.": "Custom arguments are passed to WinGet by the Devolutions Agent, which runs this operation with administrator rights. Your organization's policy may block custom arguments.", diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AgentPolicyInspectorViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AgentPolicyInspectorViewModel.cs index d80395108a..8e3d39ad40 100644 --- a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AgentPolicyInspectorViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AgentPolicyInspectorViewModel.cs @@ -609,6 +609,21 @@ private void ApplyManagementResult( CoreTools.Translate("Communication with the package broker could not be completed. Verify that Devolutions Agent is installed and running. If the problem persists, check the Agent logs, then refresh."), InfoBarSeverity.Error); break; + case BrokerPolicyManagementStatus.BrokerNotRunning: + SetManagementStatus( + BrokerFailureDescriber.DescribeBrokerNotRunning(), + InfoBarSeverity.Error); + break; + case BrokerPolicyManagementStatus.ClientRejected: + SetManagementStatus( + BrokerFailureDescriber.DescribeClientRejected(), + InfoBarSeverity.Error); + break; + case BrokerPolicyManagementStatus.ClientNotAuthorized: + SetManagementStatus( + BrokerFailureDescriber.DescribeClientNotAuthorized(), + InfoBarSeverity.Error); + break; case BrokerPolicyManagementStatus.Unsupported: SetManagementStatus( CoreTools.Translate("Policy management is unsupported"), @@ -815,6 +830,9 @@ private void AnnounceManagementStatus() : AutomationLiveSetting.Polite); } + private void SetManagementStatus(BrokerFailureDescription description, InfoBarSeverity severity) => + SetManagementStatus(description.Title, description.Message, severity); + private void SetManagementStatus(string title, string message, InfoBarSeverity severity) { ManagementStatus.Title = title; diff --git a/src/UniGetUI.PackageEngine.AgentBroker/BrokerFailureDescriber.cs b/src/UniGetUI.PackageEngine.AgentBroker/BrokerFailureDescriber.cs index 1aaa797f9d..fc4187725d 100644 --- a/src/UniGetUI.PackageEngine.AgentBroker/BrokerFailureDescriber.cs +++ b/src/UniGetUI.PackageEngine.AgentBroker/BrokerFailureDescriber.cs @@ -50,13 +50,12 @@ .. issues.Select(issue => "• " + issue), /// public static BrokerFailureDescription? DescribeAccessFailure(BrokerClientException exception) { - ErrorCode? code = exception.BrokerError?.Code; - if (exception.StatusCode is 401 || code is ErrorCode.Unauthorized or ErrorCode.Unauthenticated) + if (BrokerPolicyFailure.IsClientNotAuthenticated(exception)) { return DescribeClientNotAuthorized(); } - if (code is ErrorCode.AdministratorRequired) + if (exception.BrokerError?.Code is ErrorCode.AdministratorRequired) { return new( CoreTools.Translate("Administrator rights are required"), @@ -76,12 +75,32 @@ .. issues.Select(issue => "• " + issue), DescribeAccessFailure(exception) ?? (exception.StatusCode is 403 ? DescribeClientNotAuthorized() : null); - private static BrokerFailureDescription DescribeClientNotAuthorized() => + public static BrokerFailureDescription DescribeClientNotAuthorized() => new( CoreTools.Translate("UniGetUI is not authorized to use the Devolutions Agent"), CoreTools.Translate( "The Devolutions Agent only accepts requests from signed, unmodified copies of UniGetUI. If you are running a development or self-built version, install an official release of UniGetUI and try again.")); + public static BrokerFailureDescription DescribeBrokerNotRunning() => + new( + CoreTools.Translate("The Devolutions Agent package broker is not running"), + CoreTools.Translate( + "UniGetUI could not find the package broker of Devolutions Agent on this computer. Make sure that Devolutions Agent is installed and running, and that its package broker is enabled in the Agent configuration. Versions of Devolutions Agent without a package broker cannot run package operations.")); + + public static BrokerFailureDescription DescribeClientRejected() => + new( + CoreTools.Translate("Devolutions Agent refused this copy of UniGetUI"), + CoreTools.Translate( + "The package broker is running, but it closed the connection without answering. This usually means that it does not trust this copy of UniGetUI: the Devolutions Agent only accepts UniGetUI installed for all users in a folder that only administrators can modify, such as Program Files. If you are running a development or self-built version, install an official release of UniGetUI and try again.")); + + public static BrokerFailureDescription? DescribeUnreachable(BrokerUnreachableCause cause) => + cause switch + { + BrokerUnreachableCause.NotListening => DescribeBrokerNotRunning(), + BrokerUnreachableCause.ConnectionClosed => DescribeClientRejected(), + _ => null, + }; + /// Explains a failed broker request. public static BrokerFailureDescription Describe(BrokerClientException exception) { diff --git a/src/UniGetUI.PackageEngine.AgentBroker/BrokerPolicyFailure.cs b/src/UniGetUI.PackageEngine.AgentBroker/BrokerPolicyFailure.cs index e66233f9e3..7b1dacc091 100644 --- a/src/UniGetUI.PackageEngine.AgentBroker/BrokerPolicyFailure.cs +++ b/src/UniGetUI.PackageEngine.AgentBroker/BrokerPolicyFailure.cs @@ -13,6 +13,10 @@ exception.Kind is BrokerClientErrorKind.BrokerUnavailable or BrokerClientErrorKi && exception.StatusCode is null && exception.BrokerError is null); + public static bool IsClientNotAuthenticated(BrokerClientException exception) => + exception.StatusCode is 401 + || exception.BrokerError?.Code is ErrorCode.Unauthorized or ErrorCode.Unauthenticated; + public static bool IsAccessDenied(BrokerClientException exception) => exception.BrokerError is { } error && (exception.StatusCode is 401 or 403 diff --git a/src/UniGetUI.PackageEngine.AgentBroker/BrokerReachability.cs b/src/UniGetUI.PackageEngine.AgentBroker/BrokerReachability.cs new file mode 100644 index 0000000000..c303f72d7d --- /dev/null +++ b/src/UniGetUI.PackageEngine.AgentBroker/BrokerReachability.cs @@ -0,0 +1,75 @@ +using Devolutions.Now.Policy.Api; +using Devolutions.Now.Policy.Client; +using UniGetUI.PackageEngine.AgentBroker.PolicyWriteElevation; + +namespace UniGetUI.PackageEngine.AgentBroker; + +public enum BrokerUnreachableCause +{ + Unknown, + NotListening, + ConnectionClosed, +} + +internal sealed class BrokerConnectionClosedException(string message, Exception? innerException = null) + : IOException(message, innerException); + +public static class BrokerReachability +{ + private const int DiagnosticResponseBodyBytes = 65536; + + public static bool BrokerPipeExists() + { + if (!OperatingSystem.IsWindows()) + return true; + + try + { + return Directory.EnumerateFiles(@"\\.\pipe\", BrokerApi.DefaultPipeName).Any(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return true; + } + } + + public static BrokerUnreachableCause Diagnose(BrokerClientException exception, Func brokerPipeExists) + { + if (!brokerPipeExists()) + return BrokerUnreachableCause.NotListening; + + return exception.InnerException is BrokerConnectionClosedException + ? BrokerUnreachableCause.ConnectionClosed + : BrokerUnreachableCause.Unknown; + } + + public static Task DiagnoseAsync(CancellationToken cancellationToken) => + OperatingSystem.IsWindows() + ? DiagnoseAsync(BrokerPipeExists, CreateDiagnosticClient, cancellationToken) + : Task.FromResult(BrokerUnreachableCause.Unknown); + + internal static async Task DiagnoseAsync( + Func brokerPipeExists, + Func clientFactory, + CancellationToken cancellationToken) + { + if (!brokerPipeExists()) + return BrokerUnreachableCause.NotListening; + + try + { + using BrokerClient client = clientFactory(); + await client.GetHealth(cancellationToken).ConfigureAwait(false); + return BrokerUnreachableCause.Unknown; + } + catch (BrokerClientException ex) + { + return Diagnose(ex, brokerPipeExists); + } + } + + private static BrokerClient CreateDiagnosticClient() => + BrokerClientFactory.Create( + Elevation.Standard, + new BoundedNamedPipeBrokerTransport(DiagnosticResponseBodyBytes)); +} diff --git a/src/UniGetUI.PackageEngine.AgentBroker/PolicyManagement/BrokerPolicyManagementModels.cs b/src/UniGetUI.PackageEngine.AgentBroker/PolicyManagement/BrokerPolicyManagementModels.cs index 472a545bef..829a04b017 100644 --- a/src/UniGetUI.PackageEngine.AgentBroker/PolicyManagement/BrokerPolicyManagementModels.cs +++ b/src/UniGetUI.PackageEngine.AgentBroker/PolicyManagement/BrokerPolicyManagementModels.cs @@ -11,6 +11,9 @@ public enum BrokerPolicyManagementStatus /// The management snapshot was retrieved and passed validation. Retrieved, AgentUnavailable, + BrokerNotRunning, + ClientRejected, + ClientNotAuthorized, /// Ordinary 404 (or an explicit UnsupportedEndpoint error code): the Agent is older and does not support this endpoint. Unsupported, AccessDenied, diff --git a/src/UniGetUI.PackageEngine.AgentBroker/PolicyManagement/BrokerPolicyManagementService.cs b/src/UniGetUI.PackageEngine.AgentBroker/PolicyManagement/BrokerPolicyManagementService.cs index cea0e7f581..bf86d22489 100644 --- a/src/UniGetUI.PackageEngine.AgentBroker/PolicyManagement/BrokerPolicyManagementService.cs +++ b/src/UniGetUI.PackageEngine.AgentBroker/PolicyManagement/BrokerPolicyManagementService.cs @@ -30,9 +30,10 @@ public sealed partial class BrokerPolicyManagementService : IBrokerPolicyManagem { private readonly Func _clientFactory; private readonly Func _isWindows; + private readonly Func? _brokerPipeExists; public BrokerPolicyManagementService() - : this(CreateStandardClient, OperatingSystem.IsWindows) + : this(CreateStandardClient, OperatingSystem.IsWindows, BrokerReachability.BrokerPipeExists) { } @@ -41,10 +42,14 @@ private static BrokerClient CreateStandardClient() => ApiElevation.Standard, new BoundedNamedPipeBrokerTransport(BrokerPolicyManagementLimits.MaxResponseBodyBytes)); - public BrokerPolicyManagementService(Func clientFactory, Func? isWindows = null) + public BrokerPolicyManagementService( + Func clientFactory, + Func? isWindows = null, + Func? brokerPipeExists = null) { _clientFactory = clientFactory; _isWindows = isWindows ?? OperatingSystem.IsWindows; + _brokerPipeExists = brokerPipeExists; } public async Task GetManagementAsync(CancellationToken cancellationToken) @@ -176,7 +181,7 @@ private static bool IsResponseVersion(string? value) // per the policy management contract. This follows the BrokerPolicyInspector.MapFailure precedent: // BrokerClientErrorKind.BrokerError collapses every structured broker error into one kind, so // disambiguation must happen via StatusCode/BrokerError.Code first. - private static BrokerPolicyManagementStatus MapManagementFailure(BrokerClientException ex) + private BrokerPolicyManagementStatus MapManagementFailure(BrokerClientException ex) { if (ex.StatusCode == 404 || ex.BrokerError?.Code is ErrorCode.NotFound or ErrorCode.UnsupportedEndpoint) @@ -186,7 +191,9 @@ private static BrokerPolicyManagementStatus MapManagementFailure(BrokerClientExc if (BrokerPolicyFailure.IsAccessDenied(ex)) { - return BrokerPolicyManagementStatus.AccessDenied; + return BrokerPolicyFailure.IsClientNotAuthenticated(ex) + ? BrokerPolicyManagementStatus.ClientNotAuthorized + : BrokerPolicyManagementStatus.AccessDenied; } switch (ex.BrokerError?.Code) @@ -201,8 +208,7 @@ private static BrokerPolicyManagementStatus MapManagementFailure(BrokerClientExc return ex.Kind switch { - _ when BrokerPolicyFailure.IsTransportUnavailable(ex) => - BrokerPolicyManagementStatus.AgentUnavailable, + _ when BrokerPolicyFailure.IsTransportUnavailable(ex) => MapUnreachable(ex), BrokerClientErrorKind.EmptyResponse or BrokerClientErrorKind.InvalidResponse => BrokerPolicyManagementStatus.InvalidResponse, BrokerClientErrorKind.BrokerError when ex.BrokerError is not null => @@ -211,6 +217,16 @@ _ when BrokerPolicyFailure.IsTransportUnavailable(ex) => }; } + private BrokerPolicyManagementStatus MapUnreachable(BrokerClientException ex) => + _brokerPipeExists is null + ? BrokerPolicyManagementStatus.AgentUnavailable + : BrokerReachability.Diagnose(ex, _brokerPipeExists) switch + { + BrokerUnreachableCause.NotListening => BrokerPolicyManagementStatus.BrokerNotRunning, + BrokerUnreachableCause.ConnectionClosed => BrokerPolicyManagementStatus.ClientRejected, + _ => BrokerPolicyManagementStatus.AgentUnavailable, + }; + private static BrokerPolicyValidationStatus MapValidationFailure(BrokerClientException ex) { if (ex.StatusCode == 404 diff --git a/src/UniGetUI.PackageEngine.AgentBroker/PolicyWriteElevation/Shared/BoundedNamedPipeBrokerTransport.cs b/src/UniGetUI.PackageEngine.AgentBroker/PolicyWriteElevation/Shared/BoundedNamedPipeBrokerTransport.cs index 8a202ef689..27ef35003b 100644 --- a/src/UniGetUI.PackageEngine.AgentBroker/PolicyWriteElevation/Shared/BoundedNamedPipeBrokerTransport.cs +++ b/src/UniGetUI.PackageEngine.AgentBroker/PolicyWriteElevation/Shared/BoundedNamedPipeBrokerTransport.cs @@ -56,7 +56,19 @@ public async Task Send( await pipe.ConnectAsync(connectCancellation.Token).ConfigureAwait(false); } - await WriteRequestAsync(pipe, request, cancellationToken).ConfigureAwait(false); + try + { + await WriteRequestAsync(pipe, request, cancellationToken).ConfigureAwait(false); + } + catch (IOException ex) + { + throw BrokerFailure( + BrokerClientErrorKind.BrokerUnavailable, + $"The package broker closed the connection before accepting the request for {request.Path}.", + request.Path, + new BrokerConnectionClosedException(ex.Message, ex)); + } + using CancellationTokenSource readCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); readCancellation.CancelAfter(ReadTimeoutMilliseconds); @@ -137,10 +149,12 @@ internal static async Task ReadResponseAsync( .ConfigureAwait(false); if (read == 0) { + string message = $"The package broker disconnected before sending a complete response for {path}."; throw BrokerFailure( incompleteResponseKind, - $"The package broker disconnected before sending a complete response for {path}.", - path); + message, + path, + totalRead == 0 ? new BrokerConnectionClosedException(message) : null); } totalRead += read; diff --git a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs index 6d1960d04c..0742ac3ce5 100644 --- a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs +++ b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs @@ -53,6 +53,8 @@ public abstract class PackageOperation : AbstractProcessOperation /// internal static Func? BrokerTransportFactory; + internal static Func>? BrokerUnreachableDiagnosis; + /// /// Interval between broker operation status polls. Internal so tests can shorten it. /// @@ -479,7 +481,7 @@ private async Task PerformBrokerOperation() // execution: policy evaluation and kill/pre/post actions are owned by the broker. if (!await client.IsAvailable(CancellationToken)) { - return HandleBrokerUnavailable(); + return await HandleBrokerUnavailable(); } // Fetch the capabilities up front: the broker requires an authenticated client for @@ -603,7 +605,7 @@ private async Task PerformBrokerOperation() // The broker can stop between the availability probe and the request itself; // route this through the same unavailable handling as a failed probe. Logger.Error($"[AgentBroker] Broker became unavailable during the operation: {ex}"); - return HandleBrokerUnavailable(); + return await HandleBrokerUnavailable(); } catch (BrokerClientException ex) { @@ -643,7 +645,7 @@ private async Task PerformBrokerOperation() catch (BrokerClientException ex) when (ex.Kind is BrokerClientErrorKind.BrokerUnavailable) { Logger.Error($"[AgentBroker] Broker became unavailable while reading its capabilities: {ex}"); - return HandleBrokerUnavailable(); + return await HandleBrokerUnavailable(); } catch (BrokerClientException ex) { @@ -988,18 +990,41 @@ private async Task InterpretBrokerTerminalStatus(BrokerStatus /// actions are owned by the broker. Sets the failure metadata and raises /// so the UI can notify the user. /// - private OperationVeredict HandleBrokerUnavailable() + private async Task HandleBrokerUnavailable() { Line("Agent broker is not available. The operation cannot continue.", LineType.Error); Logger.Error("[AgentBroker] Broker not available, aborting operation"); - string message = CoreTools.Translate( - "The Devolutions Agent broker is not available. The operation cannot be performed. Please ensure the Devolutions Agent is installed and running."); - Metadata.FailureTitle = CoreTools.Translate("Agent broker unavailable"); - Metadata.FailureMessage = message; - BrokerUnavailable?.Invoke(this, new BrokerFailureDescription(Metadata.FailureTitle, message)); + BrokerUnreachableCause cause = await DiagnoseUnreachableBroker(); + Logger.Info($"[AgentBroker] Unreachable broker diagnosis: {cause}"); + BrokerFailureDescription failure = BrokerFailureDescriber.DescribeUnreachable(cause) + ?? new BrokerFailureDescription( + CoreTools.Translate("Agent broker unavailable"), + CoreTools.Translate( + "The Devolutions Agent broker is not available. The operation cannot be performed. Please ensure the Devolutions Agent is installed and running.")); + Metadata.FailureTitle = failure.Title; + Metadata.FailureMessage = failure.Message; + BrokerUnavailable?.Invoke(this, failure); return OperationVeredict.Failure; } + private async Task DiagnoseUnreachableBroker() + { + Func> diagnose = + BrokerUnreachableDiagnosis + ?? (BrokerTransportFactory is null + ? BrokerReachability.DiagnoseAsync + : _ => Task.FromResult(BrokerUnreachableCause.Unknown)); + try + { + return await diagnose(CancellationToken); + } + catch (Exception ex) + { + Logger.Warn($"[AgentBroker] Could not diagnose why the broker is unreachable: {ex}"); + return BrokerUnreachableCause.Unknown; + } + } + /// /// Buffers streamed process output and emits it line by line through /// , mirroring the local process reader: diff --git a/src/UniGetUI.PackageEngine.Tests/BrokerFailureDescriberTests.cs b/src/UniGetUI.PackageEngine.Tests/BrokerFailureDescriberTests.cs index ddb8ced7f0..811537b4b6 100644 --- a/src/UniGetUI.PackageEngine.Tests/BrokerFailureDescriberTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/BrokerFailureDescriberTests.cs @@ -20,6 +20,18 @@ private static BrokerClientException BrokerError(int statusCode, ErrorCode code, Details = [.. details.Select(detail => new ErrorDetail { Message = detail })], }); + [Fact] + public void DescribeUnreachable_ExplainsEachDiagnosedCause() + { + Assert.Equal( + CoreTools.Translate("The Devolutions Agent package broker is not running"), + BrokerFailureDescriber.DescribeUnreachable(BrokerUnreachableCause.NotListening)?.Title); + Assert.Equal( + CoreTools.Translate("Devolutions Agent refused this copy of UniGetUI"), + BrokerFailureDescriber.DescribeUnreachable(BrokerUnreachableCause.ConnectionClosed)?.Title); + Assert.Null(BrokerFailureDescriber.DescribeUnreachable(BrokerUnreachableCause.Unknown)); + } + [Fact] public void DescribeDenial_IncludesReasonAndRule() { diff --git a/src/UniGetUI.PackageEngine.Tests/BrokerPolicyManagementServiceTests.cs b/src/UniGetUI.PackageEngine.Tests/BrokerPolicyManagementServiceTests.cs index 81302a2026..96fc0da83d 100644 --- a/src/UniGetUI.PackageEngine.Tests/BrokerPolicyManagementServiceTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/BrokerPolicyManagementServiceTests.cs @@ -316,7 +316,8 @@ public async Task GetManagementAsync_DoesNotConstructClientOnNonWindows() [Theory] [InlineData(404, ErrorCode.NotFound, BrokerPolicyManagementStatus.Unsupported)] [InlineData(404, ErrorCode.UnsupportedEndpoint, BrokerPolicyManagementStatus.Unsupported)] - [InlineData(401, ErrorCode.Unauthenticated, BrokerPolicyManagementStatus.AccessDenied)] + [InlineData(401, ErrorCode.Unauthenticated, BrokerPolicyManagementStatus.ClientNotAuthorized)] + [InlineData(401, ErrorCode.Unauthorized, BrokerPolicyManagementStatus.ClientNotAuthorized)] [InlineData(403, ErrorCode.Forbidden, BrokerPolicyManagementStatus.AccessDenied)] [InlineData(403, ErrorCode.AdministratorRequired, BrokerPolicyManagementStatus.AccessDenied)] [InlineData(400, ErrorCode.UnsafePolicyPath, BrokerPolicyManagementStatus.UnsafePolicyPath)] @@ -374,6 +375,33 @@ public async Task GetManagementAsync_ClassifiesTransportFailureAsUnavailable(Bro Assert.Equal(BrokerPolicyManagementStatus.AgentUnavailable, result.Status); } + [Theory] + [InlineData(BrokerClientErrorKind.BrokerUnavailable)] + [InlineData(BrokerClientErrorKind.Timeout)] + public async Task GetManagementAsync_ReportsBrokerNotRunningWhenItsPipeIsMissing(BrokerClientErrorKind kind) + { + var transport = new FakeTransport(exception: new BrokerClientException(kind, "offline")); + var service = new BrokerPolicyManagementService(() => CreateClient(transport), () => true, () => false); + + BrokerPolicyManagementResult result = await service.GetManagementAsync(CancellationToken.None); + + Assert.Equal(BrokerPolicyManagementStatus.BrokerNotRunning, result.Status); + } + + [Fact] + public async Task GetManagementAsync_KeepsUnknownTransportFailuresUnavailableWhenThePipeExists() + { + var transport = new FakeTransport(exception: new BrokerClientException( + BrokerClientErrorKind.BrokerUnavailable, + "transport failed", + innerException: new IOException("connection reset"))); + var service = new BrokerPolicyManagementService(() => CreateClient(transport), () => true, () => true); + + BrokerPolicyManagementResult result = await service.GetManagementAsync(CancellationToken.None); + + Assert.Equal(BrokerPolicyManagementStatus.AgentUnavailable, result.Status); + } + [Theory] [InlineData(false)] [InlineData(true)] diff --git a/src/UniGetUI.PackageEngine.Tests/BrokerPolicyTransportTests.cs b/src/UniGetUI.PackageEngine.Tests/BrokerPolicyTransportTests.cs index 1afa20fdcf..c81dcc3712 100644 --- a/src/UniGetUI.PackageEngine.Tests/BrokerPolicyTransportTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/BrokerPolicyTransportTests.cs @@ -38,7 +38,11 @@ public class BrokerPolicyTransportTests Code = status == 401 ? ErrorCode.Unauthorized : ErrorCode.Forbidden, Message = "structured denial", }); - yield return [endpoint, HttpResponse(status, body), "AccessDenied"]; + yield return [ + endpoint, + HttpResponse(status, body), + endpoint == "management" && status == 401 ? "ClientNotAuthorized" : "AccessDenied", + ]; } } } @@ -71,6 +75,58 @@ public async Task PolicyAdapters_DistinguishDisconnectsFromReceivedResponses( Assert.Equal(expectedStatus, status); } + [Theory] + [InlineData(null, true, "ClientRejected")] + [InlineData("", true, "ClientRejected")] + [InlineData("HTTP/1.1 200 OK\r\nContent-Length:", true, "AgentUnavailable")] + [InlineData("HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\n{", true, "AgentUnavailable")] + [InlineData("", false, "BrokerNotRunning")] + public async Task PolicyManagementTransport_TellsARefusedConnectionFromATruncatedResponse( + string? wireResponse, + bool brokerPipeExists, + string expectedStatus) + { + string status = await WithPipeAsync(wireResponse, async (client, token) => + { + var service = new BrokerPolicyManagementService(() => client, () => true, () => brokerPipeExists); + return (await service.GetManagementAsync(token)).Status.ToString(); + }, boundedTransport: true); + + Assert.Equal(expectedStatus, status); + } + + [Theory] + [InlineData(null, BrokerUnreachableCause.ConnectionClosed)] + [InlineData("", BrokerUnreachableCause.ConnectionClosed)] + [InlineData("HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\n{", BrokerUnreachableCause.Unknown)] + public async Task Reachability_DiagnosesARefusedConnectionOverTheBoundedTransport( + string? wireResponse, + BrokerUnreachableCause expectedCause) + { + BrokerUnreachableCause cause = await WithPipeAsync(wireResponse, (client, token) => + BrokerReachability.DiagnoseAsync(() => true, () => client, token), boundedTransport: true); + + Assert.Equal(expectedCause, cause); + } + + [Fact] + public async Task Reachability_ReportsAMissingPipeWithoutConnecting() + { + bool connected = false; + + BrokerUnreachableCause cause = await BrokerReachability.DiagnoseAsync( + () => false, + () => + { + connected = true; + throw new InvalidOperationException(); + }, + CancellationToken.None); + + Assert.Equal(BrokerUnreachableCause.NotListening, cause); + Assert.False(connected); + } + [Theory] [InlineData("")] [InlineData("HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\n{")] diff --git a/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs b/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs index 85da46c718..962fdca04e 100644 --- a/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs @@ -1426,6 +1426,24 @@ public async Task BrokerOperationFailsWithoutLocalFallbackWhenProbeFails() await AssertBrokerUnavailableFailure(transport); } + [Theory] + [InlineData(BrokerUnreachableCause.NotListening, "The Devolutions Agent package broker is not running")] + [InlineData(BrokerUnreachableCause.ConnectionClosed, "Devolutions Agent refused this copy of UniGetUI")] + [InlineData(BrokerUnreachableCause.Unknown, "Agent broker unavailable")] + public async Task BrokerOperationExplainsWhyTheBrokerIsUnreachable(BrokerUnreachableCause cause, string expectedTitle) + { + var transport = new FakeBrokerTransport(healthy: false); + PackageOperation.BrokerUnreachableDiagnosis = _ => Task.FromResult(cause); + try + { + await AssertBrokerUnavailableFailure(transport, expectedTitle); + } + finally + { + PackageOperation.BrokerUnreachableDiagnosis = null; + } + } + [Fact] public async Task BrokerOperationFailsWithoutLocalFallbackWhenBrokerDropsAfterProbe() { @@ -1550,7 +1568,9 @@ public async Task BrokeredInstallSendsTheResolvedPackageVersion() /// broker-unavailable metadata, raises , /// and never falls back to local process execution. /// - private static async Task AssertBrokerUnavailableFailure(FakeBrokerTransport transport) + private static async Task AssertBrokerUnavailableFailure( + FakeBrokerTransport transport, + string expectedTitle = "Agent broker unavailable") { bool originalSetting = Settings.Get(Settings.K.UseAgentBroker); bool localExecutionPrepared = false; @@ -1570,7 +1590,12 @@ private static async Task AssertBrokerUnavailableFailure(FakeBrokerTransport tra .Build(); var package = new PackageBuilder().WithManager(manager).Build(); string? notifiedMessage = null; - EventHandler onBrokerUnavailable = (_, failure) => notifiedMessage = failure.Message; + string? notifiedTitle = null; + EventHandler onBrokerUnavailable = (_, failure) => + { + notifiedTitle = failure.Title; + notifiedMessage = failure.Message; + }; PackageOperation.BrokerUnavailable += onBrokerUnavailable; PackageOperation.BrokerTransportFactory = () => transport; Settings.Set(Settings.K.UseAgentBroker, true); @@ -1581,9 +1606,10 @@ private static async Task AssertBrokerUnavailableFailure(FakeBrokerTransport tra Assert.Equal(OperationVeredict.Failure, veredict); Assert.Equal( - CoreTools.Translate("Agent broker unavailable"), + CoreTools.Translate(expectedTitle), operation.Metadata.FailureTitle); Assert.False(string.IsNullOrWhiteSpace(operation.Metadata.FailureMessage)); + Assert.Equal(operation.Metadata.FailureTitle, notifiedTitle); Assert.Equal(operation.Metadata.FailureMessage, notifiedMessage); Assert.False(localExecutionPrepared); } diff --git a/src/UniGetUI.Tests/AgentPolicyInspectorViewModelTests.cs b/src/UniGetUI.Tests/AgentPolicyInspectorViewModelTests.cs index d24688a991..4d6ff46d08 100644 --- a/src/UniGetUI.Tests/AgentPolicyInspectorViewModelTests.cs +++ b/src/UniGetUI.Tests/AgentPolicyInspectorViewModelTests.cs @@ -294,6 +294,9 @@ public async Task PageRefresh_UsesExactlyOneManagementRequestAndNoInspection( [Theory] [InlineData(BrokerPolicyManagementStatus.AgentUnavailable, "Devolutions Agent is unavailable")] + [InlineData(BrokerPolicyManagementStatus.BrokerNotRunning, "The Devolutions Agent package broker is not running")] + [InlineData(BrokerPolicyManagementStatus.ClientRejected, "Devolutions Agent refused this copy of UniGetUI")] + [InlineData(BrokerPolicyManagementStatus.ClientNotAuthorized, "UniGetUI is not authorized to use the Devolutions Agent")] [InlineData(BrokerPolicyManagementStatus.AccessDenied, "Access to policy management was denied")] [InlineData(BrokerPolicyManagementStatus.Unsupported, "Policy management is unsupported")] [InlineData(BrokerPolicyManagementStatus.InvalidResponse, "The policy management response is invalid")] From 69ae32e7345def1cf9d6eda3a45fb448cc03ade4 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 27 Oct 2026 13:56:00 -0400 Subject: [PATCH 2/2] Move the broker connection-closed marker into the shared transport sources --- src/UniGetUI.PackageEngine.AgentBroker/BrokerReachability.cs | 3 --- .../Shared/BrokerConnectionClosedException.cs | 4 ++++ 2 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 src/UniGetUI.PackageEngine.AgentBroker/PolicyWriteElevation/Shared/BrokerConnectionClosedException.cs diff --git a/src/UniGetUI.PackageEngine.AgentBroker/BrokerReachability.cs b/src/UniGetUI.PackageEngine.AgentBroker/BrokerReachability.cs index c303f72d7d..6896df5cf6 100644 --- a/src/UniGetUI.PackageEngine.AgentBroker/BrokerReachability.cs +++ b/src/UniGetUI.PackageEngine.AgentBroker/BrokerReachability.cs @@ -11,9 +11,6 @@ public enum BrokerUnreachableCause ConnectionClosed, } -internal sealed class BrokerConnectionClosedException(string message, Exception? innerException = null) - : IOException(message, innerException); - public static class BrokerReachability { private const int DiagnosticResponseBodyBytes = 65536; diff --git a/src/UniGetUI.PackageEngine.AgentBroker/PolicyWriteElevation/Shared/BrokerConnectionClosedException.cs b/src/UniGetUI.PackageEngine.AgentBroker/PolicyWriteElevation/Shared/BrokerConnectionClosedException.cs new file mode 100644 index 0000000000..6b3c9ab7c8 --- /dev/null +++ b/src/UniGetUI.PackageEngine.AgentBroker/PolicyWriteElevation/Shared/BrokerConnectionClosedException.cs @@ -0,0 +1,4 @@ +namespace UniGetUI.PackageEngine.AgentBroker.PolicyWriteElevation; + +internal sealed class BrokerConnectionClosedException(string message, Exception? innerException = null) + : IOException(message, innerException);