diff --git a/.gitattributes b/.gitattributes index 1b8a169a6c..794e109aae 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ src/UniGetUI.PackageEngine.Managers.Chocolatey/choco-cli/** linguist-vendored -.githooks/* text eol=lf \ No newline at end of file +.githooks/* text eol=lf + +policies/samples/** linguist-generated +policies/schemas/** linguist-generated \ No newline at end of file diff --git a/.gitignore b/.gitignore index 711cadf3d3..4c20afaa00 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,7 @@ src/UniGetUI.v3.ncrunchsolution # macOS Finder metadata .DS_Store /src/UniGetUI.Avalonia/Generated Files + + +policies/csharp/.vs/* +src/UniGetUI.PackageEngine.Managers.WinGet \ No newline at end of file diff --git a/global.json b/global.json index c358071f08..abe5820849 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { "version": "10.0.103", - "rollForward": "latestPatch" + "rollForward": "latestFeature" } } diff --git a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs index 601c1865f6..e04209e349 100644 --- a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs +++ b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs @@ -92,6 +92,9 @@ public enum K DisableClassicMode, DisableInstallerHostChangeWarning, BunPreferLatestVersions, + // NOTE: Set this to true to delegate package operations to Devolutions Agent broker + // instead of using local UAC elevation. Change default here when ready for production. + UseAgentBroker, Test1, Test2, @@ -195,6 +198,7 @@ public static string ResolveKey(K key) K.DisableClassicMode => "DisableClassicMode", K.DisableInstallerHostChangeWarning => "DisableInstallerHostChangeWarning", K.BunPreferLatestVersions => "BunPreferLatestVersions", + K.UseAgentBroker => "UseAgentBroker", K.Test1 => "TestSetting1", K.Test2 => "TestSetting2", diff --git a/src/UniGetUI.PackageEngine.AgentBroker/BrokerRequestBuilder.cs b/src/UniGetUI.PackageEngine.AgentBroker/BrokerRequestBuilder.cs new file mode 100644 index 0000000000..db3cd0385b --- /dev/null +++ b/src/UniGetUI.PackageEngine.AgentBroker/BrokerRequestBuilder.cs @@ -0,0 +1,148 @@ +using Devolutions.Now.Policy.Api; +using Devolutions.Now.Policy.Client; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Serializable; +// Aliased to avoid clashing with UniGetUI.PackageEngine.Enums.Architecture. +using BrokerArchitecture = Devolutions.Now.Policy.Api.Architecture; + +namespace UniGetUI.PackageEngine.AgentBroker; + +/// +/// Builds broker protocol requests from UniGetUI domain objects. +/// Maps IPackage + InstallOptions + OperationType into the canonical +/// consumed by the Devolutions Agent broker. +/// +public static class BrokerRequestBuilder +{ + /// Build a broker request from UniGetUI package operation parameters. + public static PackageOperationRequest Build(IPackage package, InstallOptions options, OperationType role) + { + return new PackageOperationRequest + { + RequestId = BrokerClient.GenerateRequestId(), + CreatedAt = DateTimeOffset.UtcNow, + Operation = MapOperation(role), + Manager = MapManagerName(package.Manager.Name), + CaptureOutput = true, + Source = new RequestSource + { + Name = package.Source.Name, + Url = package.Source.Url?.ToString(), + }, + Package = new RequestPackage + { + Id = package.Id, + Version = string.IsNullOrEmpty(options.Version) ? null : options.Version, + Architecture = MapArchitecture(options.Architecture), + }, + Options = new RequestOptions + { + Scope = MapScope(options.InstallationScope), + Interactive = options.InteractiveInstallation, + SkipHashCheck = options.SkipHashCheck, + PreRelease = options.PreRelease, + CustomParameters = GetCustomParameters(options, role), + CustomInstallLocation = NullIfEmpty(options.CustomInstallLocation), + KillBeforeOperation = options.KillBeforeOperation ?? [], + PreOperationCommand = GetPreCommand(options, role), + PostOperationCommand = GetPostCommand(options, role), + UninstallPrevious = role is OperationType.Update && options.UninstallPreviousVersionsOnUpdate, + NoUpgrade = role is OperationType.Update && options.SkipMinorUpdates, + }, + }; + } + + private static Operation MapOperation(OperationType role) => role switch + { + OperationType.Install => Operation.Install, + OperationType.Update => Operation.Update, + OperationType.Uninstall => Operation.Uninstall, + _ => throw new ArgumentException($"Unsupported operation type: {role}"), + }; + + /// + /// Maps UniGetUI manager names to the broker protocol canonical managers. + /// PowerShell 5 and PowerShell 7 are modeled as separate managers. + /// + private static ManagerName MapManagerName(string managerName) + { + if (managerName.Equals("Winget", StringComparison.OrdinalIgnoreCase)) + { + return ManagerName.Winget; + } + + if (managerName.Equals("PowerShell", StringComparison.OrdinalIgnoreCase)) + { + return ManagerName.PowerShell; + } + + if (managerName.Equals("PowerShell7", StringComparison.OrdinalIgnoreCase) || + managerName.Equals("pwsh", StringComparison.OrdinalIgnoreCase)) + { + return ManagerName.PowerShell7; + } + + throw new ArgumentException($"Unsupported manager for the broker: {managerName}"); + } + + private static Scope? MapScope(string? scope) + { + if (string.IsNullOrEmpty(scope)) + { + return null; + } + + return scope.ToLowerInvariant() switch + { + "user" => Scope.User, + "machine" => Scope.Machine, + "global" => Scope.Machine, + _ => null, + }; + } + + private static BrokerArchitecture? MapArchitecture(string? architecture) + { + if (string.IsNullOrEmpty(architecture)) + { + return null; + } + + return architecture.ToLowerInvariant() switch + { + "x86" => BrokerArchitecture.X86, + "x64" => BrokerArchitecture.X64, + "arm64" => BrokerArchitecture.Arm64, + "neutral" => BrokerArchitecture.Neutral, + _ => null, + }; + } + + private static List GetCustomParameters(InstallOptions options, OperationType role) => role switch + { + OperationType.Install => options.CustomParameters_Install ?? [], + OperationType.Update => options.CustomParameters_Update ?? [], + OperationType.Uninstall => options.CustomParameters_Uninstall ?? [], + _ => [], + }; + + private static string? GetPreCommand(InstallOptions options, OperationType role) => role switch + { + OperationType.Install => NullIfEmpty(options.PreInstallCommand), + OperationType.Update => NullIfEmpty(options.PreUpdateCommand), + OperationType.Uninstall => NullIfEmpty(options.PreUninstallCommand), + _ => null, + }; + + private static string? GetPostCommand(InstallOptions options, OperationType role) => role switch + { + OperationType.Install => NullIfEmpty(options.PostInstallCommand), + OperationType.Update => NullIfEmpty(options.PostUpdateCommand), + OperationType.Uninstall => NullIfEmpty(options.PostUninstallCommand), + _ => null, + }; + + private static string? NullIfEmpty(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value; +} diff --git a/src/UniGetUI.PackageEngine.AgentBroker/UniGetUI.PackageEngine.AgentBroker.csproj b/src/UniGetUI.PackageEngine.AgentBroker/UniGetUI.PackageEngine.AgentBroker.csproj new file mode 100644 index 0000000000..890336facc --- /dev/null +++ b/src/UniGetUI.PackageEngine.AgentBroker/UniGetUI.PackageEngine.AgentBroker.csproj @@ -0,0 +1,25 @@ + + + + $(SharedTargetFrameworks) + UniGetUI.PackageEngine.AgentBroker + + + + + + + + + + + + + + + + + + + + diff --git a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs index 56741694d2..1421e699df 100644 --- a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs +++ b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs @@ -1,9 +1,11 @@ +using System.Text; using UniGetUI.Core.Classes; using UniGetUI.Core.Data; using UniGetUI.Core.Logging; using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.Tools; using UniGetUI.Interface.Enums; +using UniGetUI.PackageEngine.AgentBroker; using UniGetUI.PackageEngine.Classes.Packages.Classes; using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces; @@ -11,6 +13,12 @@ using UniGetUI.PackageEngine.PackageLoader; using UniGetUI.PackageEngine.Serializable; using UniGetUI.PackageOperations; +using BrokerClient = Devolutions.Now.Policy.Client.BrokerClient; +using BrokerClientErrorKind = Devolutions.Now.Policy.Client.BrokerClientErrorKind; +using BrokerClientException = Devolutions.Now.Policy.Client.BrokerClientException; +using BrokerClientOptions = Devolutions.Now.Policy.Client.BrokerClientOptions; +using BrokerElevation = Devolutions.Now.Policy.Api.Elevation; +using BrokerOperationStatus = Devolutions.Now.Policy.Api.OperationStatus; #if WINDOWS using UniGetUI.PackageEngine.Managers.WingetManager; #endif @@ -148,6 +156,188 @@ protected sealed override void PrepareProcessStartInfo() ); } + /// + /// Override to intercept operations and route through the Devolutions Agent broker + /// when the UseAgentBroker setting is enabled and the manager supports it (WinGet only for now). + /// Falls back to process-based execution otherwise. + /// + protected override async Task PerformOperation() + { + if (!ShouldUseAgentBroker()) + { + return await base.PerformOperation(); + } + + return await PerformBrokerOperation(); + } + + /// + /// Determines whether this operation should be routed through the agent broker. + /// + private bool ShouldUseAgentBroker() + { + // NOTE: Change this condition to enable agent broker by default when ready. + // Currently opt-in via settings. + bool settingEnabled = Settings.Get(Settings.K.UseAgentBroker); + bool isWinGet = IsWinGetManager(Package.Manager); + Logger.Info($"[AgentBroker] ShouldUseAgentBroker check: setting={settingEnabled}, isWinGet={isWinGet}, manager={Package.Manager.Name}"); + + if (!settingEnabled) + { + return false; + } + + // Only WinGet is supported in this iteration. + if (!isWinGet) + { + return false; + } + + return true; + } + + /// + /// Perform the package operation through the Devolutions Agent broker. + /// Sends the request over named pipe and interprets the response. + /// + private async Task PerformBrokerOperation() + { + Line("Routing operation through Devolutions Agent broker...", LineType.Information); + + using var client = CreateBrokerClient(RequiresAdminRights()); + + // Check broker availability. + if (!await client.IsAvailable()) + { + Line("Agent broker is not available, falling back to local execution.", LineType.Information); + Logger.Warn("[AgentBroker] Broker not available, falling back to process execution"); + return await base.PerformOperation(); + } + + // Build the broker request. + var request = BrokerRequestBuilder.Build(Package, Options, Role); + + Line($"Sending request to broker: {request.RequestId}", LineType.VerboseDetails); + Line($" Package: {request.Package.Id} ({request.Operation})", LineType.VerboseDetails); + Line($" Manager: {request.Manager}", LineType.VerboseDetails); + Line($" User: {GetEffectiveUser()}", LineType.VerboseDetails); + + try + { + // Send to broker and poll until completion. + var status = await client.ExecuteAndWait(request); + + // Log status details. + Line($"Broker status: {status.Status}, exitCode={status.ExitCode}", LineType.Information); + if (!string.IsNullOrWhiteSpace(status.Message)) + { + Line($" Message: {status.Message}", LineType.Information); + } + var output = DisplayBrokerOutput(status.Stdout); + + if (status.Status == BrokerOperationStatus.Completed) + { + var veredict = await GetProcessVeredict(status.ExitCode ?? -1, output); + if (veredict is OperationVeredict.Success) + { + Line("Operation completed successfully via agent broker.", LineType.Information); + } + else if (!string.IsNullOrWhiteSpace(status.Message)) + { + Metadata.FailureMessage = status.Message; + } + + return veredict; + } + + // Operation failed — surface a user-visible error. + string reason = status.Message ?? $"Exit code: {status.ExitCode}"; + Line($"Operation failed via broker: {reason}", LineType.Error); + Metadata.FailureTitle = CoreTools.Translate("Operation denied or failed via broker"); + Metadata.FailureMessage = reason; + return OperationVeredict.Failure; + } + catch (BrokerClientException ex) + { + Line($"Broker operation failed: {ex.Message}", LineType.Error); + Logger.Error($"[AgentBroker] Broker operation failed: {ex}"); + Metadata.FailureTitle = CoreTools.Translate(GetBrokerFailureTitle(ex.Kind)); + Metadata.FailureMessage = ex.Message; + return OperationVeredict.Failure; + } + } + + private List DisplayBrokerOutput(string? encodedStdout) + { + List output = []; + if (string.IsNullOrWhiteSpace(encodedStdout)) + { + return output; + } + + string decoded; + try + { + decoded = Encoding.UTF8.GetString(Convert.FromBase64String(encodedStdout)); + } + catch (FormatException ex) + { + Logger.Error($"[AgentBroker] Broker returned invalid base64 stdout: {ex}"); + Line("Broker returned captured output in an invalid format.", LineType.Error); + return output; + } + + foreach (var line in decoded.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n')) + { + if (line.Length == 0) + { + continue; + } + + output.Add(line); + Line(line, LineType.Information); + } + + return output; + } + + private static BrokerClient CreateBrokerClient(bool requestedElevation) => + new( + new BrokerClientOptions + { + RequestedElevation = requestedElevation + ? BrokerElevation.Elevated + : BrokerElevation.Standard, + EffectiveUser = GetEffectiveUser(), + ClientExecutablePath = Environment.ProcessPath, + ClientVersion = + System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString() + ?? "0.0.0", + } + ) + { + Trace = message => Logger.Info($"[AgentBroker] {message}"), + }; + + private static string GetEffectiveUser() + { + if (string.IsNullOrWhiteSpace(Environment.UserDomainName)) + { + return Environment.UserName; + } + + return $"{Environment.UserDomainName}\\{Environment.UserName}"; + } + + private static string GetBrokerFailureTitle(BrokerClientErrorKind kind) => + kind switch + { + BrokerClientErrorKind.PolicyDenied => "Operation denied by policy", + BrokerClientErrorKind.UnsupportedCapability => "Operation unsupported by broker", + BrokerClientErrorKind.BrokerUnavailable or BrokerClientErrorKind.Timeout => "Broker communication error", + _ => "Operation failed via broker", + }; + protected sealed override Task GetProcessVeredict( int ReturnCode, List Output diff --git a/src/UniGetUI.PackageEngine.Operations/UniGetUI.PackageEngine.Operations.csproj b/src/UniGetUI.PackageEngine.Operations/UniGetUI.PackageEngine.Operations.csproj index 6b664b6de2..0c8c3cbec3 100644 --- a/src/UniGetUI.PackageEngine.Operations/UniGetUI.PackageEngine.Operations.csproj +++ b/src/UniGetUI.PackageEngine.Operations/UniGetUI.PackageEngine.Operations.csproj @@ -9,6 +9,7 @@ + diff --git a/src/UniGetUI.Windows.slnx b/src/UniGetUI.Windows.slnx index d3ecc29b0e..1633590b49 100644 --- a/src/UniGetUI.Windows.slnx +++ b/src/UniGetUI.Windows.slnx @@ -118,6 +118,10 @@ + + + +