introduced a safe delete service for non-internal assistant plugins

This commit is contained in:
Nils Kruthoff 2026-07-07 15:48:58 +02:00
parent 00d732678c
commit 508ab1abab
No known key found for this signature in database
GPG Key ID: A5C0151B4DDB172C
3 changed files with 193 additions and 27 deletions

View File

@ -64,7 +64,7 @@
</MudStack>
</MudTd>
<MudTd>
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudStack Row="true" Spacing="0" AlignItems="AlignItems.Center">
@if (context.Type is PluginType.ASSISTANT)
{
var assistantPlugin = PluginFactory.RunningPlugins.OfType<PluginAssistants>().FirstOrDefault(x => x.Id == context.Id);
@ -96,6 +96,13 @@
</MudTooltip>
}
}
@if (context is IAvailablePlugin availablePlugin && CanDeleteAssistantPlugin(availablePlugin))
{
<MudTooltip Text="@T("Delete assistant plugin")">
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline" Size="Size.Medium" OnClick="@(() => this.DeleteAssistantPluginAsync(availablePlugin))"/>
</MudTooltip>
}
</MudStack>
</MudTd>
</RowTemplate>

View File

@ -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<ConfirmDialog>
{
{
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<ConfirmDialog>(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<PluginAssistants>().FirstOrDefault(x => x.Id == pluginId);

View File

@ -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<AssistantPluginInstallService> 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<AssistantPluginInstallService> logger)
public AssistantPluginInstallService(ILogger<AssistantPluginInstallService> 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
}
}
/// <summary>
/// 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.
/// </summary>
/// <param name="plugin">Assistant plugin metadata</param>
/// <param name="token">Cancellation token for settings storage and plugin reload</param>
/// <returns>
/// Delete result that contains success state, deleted plugin metadata, the original plugin directory,
/// and a user-facing issue when deletion failed.
/// </returns>
public async Task<AssistantPluginDeleteResult> 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<PluginAssistantAudit>();
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<AssistantPluginValidationResult> 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<PluginAssistantAudit> 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);
}
}
}