From 508ab1ababd29d9bcbdc09697c2bfe29279b30c0 Mon Sep 17 00:00:00 2001 From: Nils Kruthoff Date: Tue, 7 Jul 2026 15:48:58 +0200 Subject: [PATCH] introduced a safe delete service for non-internal assistant plugins --- app/MindWork AI Studio/Pages/Plugins.razor | 9 +- app/MindWork AI Studio/Pages/Plugins.razor.cs | 37 +++- .../Services/AssistantPluginInstallService.cs | 174 +++++++++++++++--- 3 files changed, 193 insertions(+), 27 deletions(-) diff --git a/app/MindWork AI Studio/Pages/Plugins.razor b/app/MindWork AI Studio/Pages/Plugins.razor index 26167b11..fc561089 100644 --- a/app/MindWork AI Studio/Pages/Plugins.razor +++ b/app/MindWork AI Studio/Pages/Plugins.razor @@ -64,7 +64,7 @@ - + @if (context.Type is PluginType.ASSISTANT) { var assistantPlugin = PluginFactory.RunningPlugins.OfType().FirstOrDefault(x => x.Id == context.Id); @@ -96,6 +96,13 @@ } } + + @if (context is IAvailablePlugin availablePlugin && CanDeleteAssistantPlugin(availablePlugin)) + { + + + + } diff --git a/app/MindWork AI Studio/Pages/Plugins.razor.cs b/app/MindWork AI Studio/Pages/Plugins.razor.cs index 914a13b7..8a616ad4 100644 --- a/app/MindWork AI Studio/Pages/Plugins.razor.cs +++ b/app/MindWork AI Studio/Pages/Plugins.razor.cs @@ -4,6 +4,7 @@ using AIStudio.Dialogs; using AIStudio.Settings.DataModel; using AIStudio.Tools.PluginSystem.Assistants; using AIStudio.Tools.PluginSystem; +using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; using DialogOptions = AIStudio.Dialogs.DialogOptions; @@ -27,6 +28,11 @@ public partial class Plugins : MSGComponentBase [Inject] private AssistantPluginAuditService AssistantPluginAuditService { get; init; } = null!; + [Inject] + private AssistantPluginInstallService AssistantPluginInstallService { get; init; } = null!; + + private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(Plugins)); + #region Overrides of ComponentBase protected override async Task OnInitializedAsync() @@ -135,7 +141,7 @@ public partial class Plugins : MSGComponentBase { x => x.Message, string.Format( - this.T("The assistant plugin \"{0}\" was audited with the level \"{1}\", which is below the required minimum level \"{2}\". Your current settings allow activation anyway, but this may be potentially dangerous. Do you really want to enable this plugin?"), + this.T("The assistant plugin '{0}' was audited with the level '{1}', which is below the required minimum level \"{2}\". Your current settings allow activation anyway, but this may be potentially dangerous. Do you really want to enable this plugin?"), pluginName, actualLevel.GetName(), this.AssistantPluginAuditSettings.MinimumLevel.GetName()) @@ -182,6 +188,35 @@ public partial class Plugins : MSGComponentBase : this.T("Enable plugin"); } + private static bool CanDeleteAssistantPlugin(IAvailablePlugin plugin) => plugin is { IsInternal: false, Type: PluginType.ASSISTANT } && !string.IsNullOrWhiteSpace(plugin.LocalPath); + + private async Task DeleteAssistantPluginAsync(IAvailablePlugin plugin) + { + var dialogParameters = new DialogParameters + { + { + x => x.Message, + string.Format(this.T("Do you really want to delete the assistant plugin '{0}'? This will permanently delete the local plugin files."), plugin.Name) + }, + }; + + var dialogReference = await this.DialogService.ShowAsync(this.T("Delete Assistant Plugin"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult is null || dialogResult.Canceled) + return; + + var result = await this.AssistantPluginInstallService.DeleteInstalledAssistantAsync(plugin, CancellationToken.None); + if (!result.Success) + { + LOG.LogError($"Failed to delete assistant plugin '{result.PluginName}' ({result.PluginId}) from '{result.PluginDirectory}' with issue '{result.Issue}'."); + await this.MessageBus.SendError(new(Icons.Material.Filled.DeleteForever, string.Format(this.T("The assistant plugin '{0}' could not be deleted: {1}"), plugin.Name, result.Issue))); + return; + } + + await this.MessageBus.SendSuccess(new(Icons.Material.Filled.Check, string.Format(this.T("The '{0}' assistant plugin has been successfully removed."), result.PluginName))); + await this.InvokeAsync(this.StateHasChanged); + } + private static bool IsSendingMail(string sourceUrl) => sourceUrl.TrimStart().StartsWith("mailto:", StringComparison.OrdinalIgnoreCase); private PluginAssistants? TryGetAssistantPlugin(Guid pluginId) => PluginFactory.RunningPlugins.OfType().FirstOrDefault(x => x.Id == pluginId); diff --git a/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs b/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs index 5e9879c7..514643b6 100644 --- a/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs +++ b/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs @@ -9,22 +9,31 @@ public sealed record AssistantPluginInstallResult(bool Success, Guid PluginId, s public sealed record AssistantPluginCheckResult(bool Success, Guid PluginId, string PluginName, string Issue); +public sealed record AssistantPluginDeleteResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, string Issue); + public sealed class AssistantPluginInstallService { + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(AssistantPluginInstallService).Namespace, nameof(AssistantPluginInstallService)); + private const string PLUGIN_FILE_NAME = "plugin.lua"; private const string ASSISTANT_BUILDER_DIRECTORY_PREFIX = "assistant-builder"; + private const string DELETE_BACKUP_DIRECTORY = ".plugin-delete-backups"; private const int DIRECTORY_PREFIX_MAX_LEN = 80; private readonly ILogger logger; + private readonly SettingsManager settingsManager; private readonly SemaphoreSlim installSemaphore = new(1, 1); private static AssistantPluginInstallResult Error(string issue) => new(false, Guid.Empty, string.Empty, string.Empty, false, issue); private static AssistantPluginCheckResult CheckError(string issue) => new(false, Guid.Empty, string.Empty, issue); + + private static AssistantPluginDeleteResult DeleteError(IPluginMetadata plugin, string pluginDirectory, string issue) => new(false, plugin.Id, plugin.Name, pluginDirectory, issue); - public AssistantPluginInstallService(ILogger logger) + public AssistantPluginInstallService(ILogger logger, SettingsManager settingsManager) { this.logger = logger; + this.settingsManager = settingsManager; this.logger.LogInformation("The assistant plugin install service has been initialized."); } @@ -54,7 +63,7 @@ public sealed class AssistantPluginInstallService stagingDirectory = validation.StagingDirectory; var finalDirectory = DetermineFinalDirectory(assistantPluginsRoot, validation.AssistantPlugin); if (!IsPathInsideDirectory(assistantPluginsRoot, finalDirectory)) - return CheckError("The resolved plugin directory is outside the assistant plugin directory."); + return CheckError(TB("The resolved plugin directory is outside the assistant plugin directory.")); return new(true, validation.AssistantPlugin.Id, validation.AssistantPlugin.Name, string.Empty); } @@ -103,7 +112,7 @@ public sealed class AssistantPluginInstallService { finalDirectory = DetermineFinalDirectory(assistantPluginsRoot, assistantPlugin); if (!IsPathInsideDirectory(assistantPluginsRoot, finalDirectory)) - return Error("The resolved plugin directory is outside the assistant plugin directory."); + return Error(TB("The resolved plugin directory is outside the assistant plugin directory.")); if (Directory.Exists(finalDirectory)) { @@ -121,12 +130,12 @@ public sealed class AssistantPluginInstallService } catch (Exception e) { - this.logger.LogError(e, "Failed to delete assistant plugin backup directory '{BackupDirectory}'.", backupDirectory); + this.logger.LogError(e, $"Failed to delete assistant plugin backup directory '{backupDirectory}'."); } } await PluginFactory.LoadAll(token); - this.logger.LogInformation("Installed assistant plugin '{PluginName}' ({PluginId}) to '{PluginDirectory}'.", assistantPlugin.Name, assistantPlugin.Id, finalDirectory); + this.logger.LogInformation($"Installed assistant plugin '{assistantPlugin.Name}' ({assistantPlugin.Id}) to '{finalDirectory}'."); return new(true, assistantPlugin.Id, assistantPlugin.Name, finalDirectory, replacedExisting, string.Empty); } catch (Exception e) @@ -145,7 +154,7 @@ public sealed class AssistantPluginInstallService } } - return Error(e.Message); + return Error(string.Format(TB("Unexpected error: {0}"), e.Message)); } finally { @@ -158,13 +167,85 @@ public sealed class AssistantPluginInstallService } } + /// + /// Deletes installed local assistant plugin directories. + /// The directory gets moved to a backup dir outside the plugin root so the + /// plugin loader cannot discover it during reload. On failure, the directory + /// and related assistant settings are restored. + /// + /// Assistant plugin metadata + /// Cancellation token for settings storage and plugin reload + /// + /// Delete result that contains success state, deleted plugin metadata, the original plugin directory, + /// and a user-facing issue when deletion failed. + /// + public async Task DeleteInstalledAssistantAsync(IAvailablePlugin plugin, CancellationToken token) + { + if (plugin.Type is not PluginType.ASSISTANT) + return DeleteError(plugin, plugin.LocalPath, TB("Only assistant plugins can be deleted.")); + + if (plugin.IsInternal) + return DeleteError(plugin, plugin.LocalPath, TB("Internal assistant plugins cannot be deleted.")); + + if (string.IsNullOrWhiteSpace(plugin.LocalPath)) + return DeleteError(plugin, string.Empty, TB("The assistant plugin has no local directory.")); + + if (!TryGetAssistantPluginsRoot(out var assistantPluginsRoot, out var rootIssue)) + return DeleteError(plugin, plugin.LocalPath, rootIssue); + + var pluginDirectory = plugin.LocalPath; + if (!IsPathInsideDirectory(assistantPluginsRoot, pluginDirectory) || IsSameDirectory(assistantPluginsRoot, pluginDirectory)) + return DeleteError(plugin, pluginDirectory, TB("The assistant plugin directory is outside the local assistant plugin directory.")); + + if (!Directory.Exists(pluginDirectory)) + return DeleteError(plugin, pluginDirectory, TB("The assistant plugin directory does not exist.")); + + await this.installSemaphore.WaitAsync(token); + var backupDirectory = string.Empty; + var wasEnabled = false; + var removedAudits = new List(); + + try + { + backupDirectory = CreateDeleteBackupDirectory(plugin); + Directory.CreateDirectory(Path.GetDirectoryName(backupDirectory)!); + Directory.Move(pluginDirectory, backupDirectory); + + wasEnabled = this.settingsManager.ConfigurationData.EnabledPlugins.Remove(plugin.Id); + removedAudits = this.settingsManager.ConfigurationData.AssistantPluginAudits + .Where(audit => audit.PluginId == plugin.Id) + .ToList(); + + if (removedAudits.Count > 0) + this.settingsManager.ConfigurationData.AssistantPluginAudits.RemoveAll(audit => audit.PluginId == plugin.Id); + + await this.settingsManager.StoreSettings(); + await PluginFactory.LoadAll(token); + + TryDeleteDirectory(backupDirectory, "assistant plugin delete backup", this.logger); + this.logger.LogInformation($"Deleted assistant plugin '{plugin.Name}' ({plugin.Id}) from '{pluginDirectory}'."); + return new(true, plugin.Id, plugin.Name, pluginDirectory, string.Empty); + } + catch (Exception e) + { + this.logger.LogError(e, $"Failed to delete assistant plugin '{plugin.Name}' ({plugin.Id}) from '{pluginDirectory}'."); + + await this.TryRestoreDeletedAssistantPluginAsync(plugin, pluginDirectory, backupDirectory, wasEnabled, removedAudits, token); + return DeleteError(plugin, pluginDirectory, string.Format(TB("Unexpected error: {0}"), e.Message)); + } + finally + { + this.installSemaphore.Release(); + } + } + private async Task ValidateIntoStagingAsync(string lua, CancellationToken token) { if (string.IsNullOrWhiteSpace(lua)) - return AssistantPluginValidationResult.Failure("No Lua plugin code was generated."); + return AssistantPluginValidationResult.Failure(TB("No Lua plugin code was generated.")); if (!PluginFactory.IsInitialized) - return AssistantPluginValidationResult.Failure("The plugin system is not initialized yet."); + return AssistantPluginValidationResult.Failure(TB("The plugin system is not initialized yet.")); var pluginCode = lua.Trim(); var stagingDirectory = Path.Join(Path.GetTempPath(), $"{ASSISTANT_BUILDER_DIRECTORY_PREFIX}.staging-{Guid.NewGuid():N}"); @@ -179,19 +260,19 @@ public sealed class AssistantPluginInstallService if (plugin is not PluginAssistants assistantPlugin) { this.TryDeleteStagingDirectory(stagingDirectory); - return AssistantPluginValidationResult.Failure($"The generated plugin is not an assistant plugin. Issue: {string.Join("; ", plugin.Issues)}"); + return AssistantPluginValidationResult.Failure(string.Format(TB("The generated plugin is not an assistant plugin. Issue: {0}"), string.Join("; ", plugin.Issues))); } if (!assistantPlugin.IsValid) { this.TryDeleteStagingDirectory(stagingDirectory); - return AssistantPluginValidationResult.Failure($"The generated assistant plugin is invalid. Issue: {string.Join("; ", assistantPlugin.Issues)}"); + return AssistantPluginValidationResult.Failure(string.Format(TB("The generated assistant plugin is invalid. Issue: {0}"), string.Join("; ", assistantPlugin.Issues))); } if (PluginFactory.AvailablePlugins.Any(availablePlugin => availablePlugin.Type is PluginType.ASSISTANT && availablePlugin.Id == assistantPlugin.Id && availablePlugin.IsInternal)) { this.TryDeleteStagingDirectory(stagingDirectory); - return AssistantPluginValidationResult.Failure("The generated assistant plugin uses the ID of an internal AI Studio plugin."); + return AssistantPluginValidationResult.Failure(TB("The generated assistant plugin uses the ID of an internal AI Studio plugin.")); } return new(true, stagingDirectory, assistantPlugin, string.Empty); @@ -200,7 +281,7 @@ public sealed class AssistantPluginInstallService { this.logger.LogError(e, "Failed to validate generated assistant plugin."); this.TryDeleteStagingDirectory(stagingDirectory); - return AssistantPluginValidationResult.Failure(e.Message); + return AssistantPluginValidationResult.Failure(string.Format(TB("Unexpected error: {0}"), e.Message)); } } @@ -212,7 +293,7 @@ public sealed class AssistantPluginInstallService var dataDirectory = SettingsManager.DataDirectory; if (string.IsNullOrWhiteSpace(dataDirectory)) { - issue = "The AI Studio data directory is not initialized yet."; + issue = TB("The AI Studio data directory is not initialized yet."); return false; } @@ -222,17 +303,7 @@ public sealed class AssistantPluginInstallService private void TryDeleteStagingDirectory(string stagingDirectory) { - if (!Directory.Exists(stagingDirectory)) - return; - - try - { - Directory.Delete(stagingDirectory, true); - } - catch (Exception e) - { - this.logger.LogError(e, "Failed to delete assistant plugin staging directory '{StagingDirectory}'.", stagingDirectory); - } + TryDeleteDirectory(stagingDirectory, "assistant plugin staging", this.logger); } private static string DetermineFinalDirectory(string assistantPluginsRoot, PluginAssistants assistantPlugin) @@ -298,8 +369,61 @@ public sealed class AssistantPluginInstallService return childPath.StartsWith(parentPath, StringComparison.OrdinalIgnoreCase); } + private static bool IsSameDirectory(string firstDirectory, string secondDirectory) + { + var firstPath = Path.GetFullPath(firstDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var secondPath = Path.GetFullPath(secondDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return string.Equals(firstPath, secondPath, StringComparison.OrdinalIgnoreCase); + } + + private static string CreateDeleteBackupDirectory(IAvailablePlugin plugin) + { + var backupRoot = Path.Join(SettingsManager.DataDirectory, DELETE_BACKUP_DIRECTORY); + return Path.Join(backupRoot, $"assistant-{plugin.Id:N}-{Guid.NewGuid():N}"); + } + + private async Task TryRestoreDeletedAssistantPluginAsync(IAvailablePlugin plugin, string pluginDirectory, string backupDirectory, bool wasEnabled, List removedAudits, CancellationToken token) + { + try + { + if (!Directory.Exists(pluginDirectory) && Directory.Exists(backupDirectory)) + Directory.Move(backupDirectory, pluginDirectory); + + if (wasEnabled && !this.settingsManager.ConfigurationData.EnabledPlugins.Contains(plugin.Id)) + this.settingsManager.ConfigurationData.EnabledPlugins.Add(plugin.Id); + + if (removedAudits.Count > 0) + { + this.settingsManager.ConfigurationData.AssistantPluginAudits.RemoveAll(audit => audit.PluginId == plugin.Id); + this.settingsManager.ConfigurationData.AssistantPluginAudits.AddRange(removedAudits); + } + + await this.settingsManager.StoreSettings(); + await PluginFactory.LoadAll(token); + } + catch (Exception restoreException) + { + this.logger.LogError(restoreException, $"Failed to restore assistant plugin '{plugin.Name}' ({plugin.Id}) after a failed delete."); + } + } + + private static void TryDeleteDirectory(string directory, string directoryDescription, ILogger logger) + { + if (!Directory.Exists(directory)) + return; + + try + { + Directory.Delete(directory, true); + } + catch (Exception e) + { + logger.LogError(e, $"Failed to delete {directoryDescription} directory '{directory}'."); + } + } + private sealed record AssistantPluginValidationResult(bool Success, string StagingDirectory, PluginAssistants? AssistantPlugin, string Issue) { public static AssistantPluginValidationResult Failure(string issue) => new(false, string.Empty, null, issue); } -} \ No newline at end of file +}