From ffc5b8a882fb9e319395aebfee98f42e3c249db5 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sat, 8 Aug 2026 20:50:23 +0200 Subject: [PATCH] Split the plugin install service into partial classes --- .../PluginInstallService.AssistantBuilder.cs | 116 ++ .../Services/PluginInstallService.Delete.cs | 308 +++++ .../Services/PluginInstallService.Editing.cs | 195 ++++ .../PluginInstallService.FileSystem.cs | 50 + .../Services/PluginInstallService.Import.cs | 94 ++ .../PluginInstallService.Installation.cs | 252 +++++ .../Tools/Services/PluginInstallService.cs | 1001 +---------------- 7 files changed, 1033 insertions(+), 983 deletions(-) create mode 100644 app/MindWork AI Studio/Tools/Services/PluginInstallService.AssistantBuilder.cs create mode 100644 app/MindWork AI Studio/Tools/Services/PluginInstallService.Delete.cs create mode 100644 app/MindWork AI Studio/Tools/Services/PluginInstallService.Editing.cs create mode 100644 app/MindWork AI Studio/Tools/Services/PluginInstallService.FileSystem.cs create mode 100644 app/MindWork AI Studio/Tools/Services/PluginInstallService.Import.cs create mode 100644 app/MindWork AI Studio/Tools/Services/PluginInstallService.Installation.cs diff --git a/app/MindWork AI Studio/Tools/Services/PluginInstallService.AssistantBuilder.cs b/app/MindWork AI Studio/Tools/Services/PluginInstallService.AssistantBuilder.cs new file mode 100644 index 00000000..99db6d98 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/PluginInstallService.AssistantBuilder.cs @@ -0,0 +1,116 @@ +using System.Text; +using AIStudio.Tools.PluginSystem; + +namespace AIStudio.Tools.Services; + +public sealed partial class PluginInstallService +{ + /// + /// Checks whether generated Lua assistant plugin code can be loaded and installed. + /// The plugin is written to a temporary staging directory and validated through the + /// normal plugin loader, but it is not moved into the user plugin directory. + /// + /// The full generated plugin.lua content. + /// A cancellation token for file IO and Lua validation. + /// + /// Check result that contains success state, plugin metadata, and a user-facing issue when validation failed. + /// + public async Task CheckInstallabilityAsync(string lua, CancellationToken token) + { + if (!TryGetPluginRoot(PluginType.ASSISTANT, out var assistantPluginsRoot, out var rootIssue)) + return CheckError(rootIssue); + + await this.installSemaphore.WaitAsync(token); + var stagingDirectory = string.Empty; + try + { + var validation = await this.ValidateIntoStagingAsync(lua, token); + if (!validation.Success || validation.AssistantPlugin is null) + return CheckError(validation.Issue); + + stagingDirectory = validation.StagingDirectory; + var finalDirectory = DetermineFinalDirectory(assistantPluginsRoot, validation.AssistantPlugin, PluginType.ASSISTANT); + if (!IsPathInsideDirectory(assistantPluginsRoot, finalDirectory)) + return CheckError(TB("The resolved plugin directory is outside the plugin directory.")); + + return new(true, validation.AssistantPlugin.Id, validation.AssistantPlugin.Name, string.Empty); + } + finally + { + this.TryDeleteStagingDirectory(stagingDirectory); + this.installSemaphore.Release(); + } + } + + /// + /// Installs generated Lua assistant plugin code into the user plugin directory. + /// Writes the plugin into a temporary staging directory first, validates it through the + /// normal plugin loader, then moves into data/plugins/assistants. + /// If plugin with same ID already exists, the existing directory is moved + /// aside as backup and restored when replacement fails. + /// + /// The full generated plugin.lua content. + /// A cancellation token for file IO, Lua validation, and plugin reload. + /// + /// Installation result that contains success state, installed plugin metadata, final directory, + /// whether an existing plugin was replaced, and user-facing issue when installation failed. + /// + public async Task InstallAsync(string lua, CancellationToken token) + { + if (!TryGetPluginRoot(PluginType.ASSISTANT, out var assistantPluginsRoot, out var rootIssue)) + return Error(rootIssue); + + await this.installSemaphore.WaitAsync(token); + try + { + var validation = await this.ValidateIntoStagingAsync(lua, token); + if (!validation.Success || validation.AssistantPlugin is null) + return Error(validation.Issue); + + return await this.InstallStagedPluginAsync(assistantPluginsRoot, validation, PluginType.ASSISTANT, token); + } + finally + { + this.installSemaphore.Release(); + } + } + + private async Task ValidateIntoStagingAsync(string lua, CancellationToken token) + { + if (string.IsNullOrWhiteSpace(lua)) + return PluginValidationResult.Failure(TB("No Lua plugin code was generated.")); + + if (!PluginFactory.IsInitialized) + return PluginValidationResult.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}"); + + try + { + Directory.CreateDirectory(stagingDirectory); + var stagedPluginFile = Path.Join(stagingDirectory, PLUGIN_FILE_NAME); + await File.WriteAllTextAsync(stagedPluginFile, pluginCode, Encoding.UTF8, token); + + var validation = await ValidatePluginCodeAsync( + stagingDirectory, + pluginCode, + PluginType.ASSISTANT, + TB("The generated plugin is not an assistant plugin. Issue: {0}"), + TB("The generated assistant plugin is invalid. Issue: {0}"), + TB("The generated assistant plugin uses the ID of another installed plugin."), + token); + + if (!validation.Success || validation.AssistantPlugin is null) + this.TryDeleteStagingDirectory(stagingDirectory); + + return validation with { StagingDirectory = stagingDirectory }; + } + catch (Exception e) + { + this.logger.LogError(e, "Failed to validate generated assistant plugin."); + this.TryDeleteStagingDirectory(stagingDirectory); + return PluginValidationResult.Failure(string.Format(TB("Unexpected error: {0}"), e.Message)); + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/PluginInstallService.Delete.cs b/app/MindWork AI Studio/Tools/Services/PluginInstallService.Delete.cs new file mode 100644 index 00000000..f9edf49a --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/PluginInstallService.Delete.cs @@ -0,0 +1,308 @@ +using AIStudio.Settings; +using AIStudio.Tools.Media; +using AIStudio.Tools.PluginSystem; +using AIStudio.Tools.PluginSystem.Assistants; + +namespace AIStudio.Tools.Services; + +public sealed partial class PluginInstallService +{ + /// + /// Checks whether a local plugin is an Assistant Builder generated assistant that users may delete. + /// + public static bool CanDeleteInstalledAssistant(IAvailablePlugin plugin) => string.IsNullOrWhiteSpace(GetAssistantDeletionEligibilityIssue(plugin)); + + /// + /// Checks whether a plugin is a local configuration plugin that users may delete. + /// + public static bool CanDeleteInstalledConfiguration(IAvailablePlugin plugin) => string.IsNullOrWhiteSpace(GetConfigurationDeletionEligibilityIssue(plugin)); + + /// + /// Collects what deleting a local configuration plugin removes besides the plugin directory. + /// + /// The configuration plugin about to be deleted. + /// + /// The summary shown to the user before the deletion starts. It is empty when the plugin is not + /// running, because we cannot tell what an unloadable plugin had configured. + /// + public ConfigurationPluginDeleteSummary BuildConfigurationDeleteSummary(IAvailablePlugin plugin) + { + var configurationPlugin = PluginFactory.RunningPlugins.OfType().FirstOrDefault(candidate => candidate.Id == plugin.Id); + if (configurationPlugin is null) + return ConfigurationPluginDeleteSummary.EMPTY; + + var configObjects = configurationPlugin.ConfigObjects.ToList(); + var configurationData = this.settingsManager.ConfigurationData; + + // Both maps record which configuration plugin manages a setting. Everything this plugin owns + // returns to its default value once the plugin is gone: + var lockedSettings = + configurationData.ManagedLockedConfigurations.Count(entry => entry.Value == plugin.Id) + + configurationData.ManagedEditableDefaults.Count(entry => entry.Value.ConfigPluginId == plugin.Id); + + return new( + LlmProviders: CountObjects(PluginConfigurationObjectType.LLM_PROVIDER), + TranscriptionProviders: CountObjects(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER), + EmbeddingProviders: CountObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER), + DataSources: CountObjects(PluginConfigurationObjectType.DATA_SOURCE), + ChatTemplates: CountObjects(PluginConfigurationObjectType.CHAT_TEMPLATE), + Profiles: CountObjects(PluginConfigurationObjectType.PROFILE), + DocumentAnalysisPolicies: CountObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY), + LockedSettings: lockedSettings, + MandatoryInfos: configurationPlugin.MandatoryInfos.Count, + Introductions: configurationPlugin.Introductions.Count); + + int CountObjects(PluginConfigurationObjectType type) => configObjects.Count(configObject => configObject.Type == type); + } + + /// + /// Checks whether an assistant still owns running or canceling background work. + /// + public bool HasActiveAssistantWork(Guid pluginId) + { + var instanceId = pluginId.ToString(); + if (this.assistantSessionService.GetSnapshots().Any(snapshot => snapshot.IsActive && string.Equals(snapshot.Key.InstanceId, instanceId, StringComparison.Ordinal))) + return true; + + var ownerIdSuffix = $":{instanceId}"; + return this.mediaTranscriptionService.GetSnapshots().Any(snapshot => + snapshot is { IsBusy: true, Owner.Kind: MediaImportOwnerKind.ASSISTANT } && + snapshot.Owner.Id.EndsWith(ownerIdSuffix, StringComparison.Ordinal)); + } + + /// + /// 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) + { + var eligibilityIssue = GetAssistantDeletionEligibilityIssue(plugin); + if (!string.IsNullOrEmpty(eligibilityIssue)) + return DeleteError(plugin, plugin.LocalPath, eligibilityIssue); + + if (this.HasActiveAssistantWork(plugin.Id)) + return DeleteError(plugin, plugin.LocalPath, TB("The assistant cannot be deleted while background work is still running.")); + + await this.installSemaphore.WaitAsync(token); + var pluginDirectory = plugin.LocalPath; + var backupDirectory = string.Empty; + var wasEnabled = false; + var removedAudits = new List(); + + try + { + eligibilityIssue = GetAssistantDeletionEligibilityIssue(plugin); + if (!string.IsNullOrEmpty(eligibilityIssue)) + return DeleteError(plugin, pluginDirectory, eligibilityIssue); + + if (this.HasActiveAssistantWork(plugin.Id)) + return DeleteError(plugin, pluginDirectory, TB("The assistant cannot be deleted while background work is still running.")); + + backupDirectory = CreateDeleteBackupDirectory(plugin, "assistant"); + 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) + ]; + + 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(); + } + } + + /// + /// Deletes a local configuration plugin directory. + /// 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 is restored. + /// + /// + /// We do not remove the providers, data sources, chat templates, profiles, or locked settings of + /// the plugin ourselves. The reload does that: it recognizes them as left over once their + /// configuration plugin is gone, and it also deletes the related secrets from the OS keyring. + /// + /// Configuration plugin metadata. + /// Cancellation token for the 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 DeleteInstalledConfigurationAsync(IAvailablePlugin plugin, CancellationToken token) + { + var eligibilityIssue = GetConfigurationDeletionEligibilityIssue(plugin); + if (!string.IsNullOrEmpty(eligibilityIssue)) + return DeleteError(plugin, plugin.LocalPath, eligibilityIssue); + + await this.installSemaphore.WaitAsync(token); + var pluginDirectory = plugin.LocalPath; + var backupDirectory = string.Empty; + + try + { + // Check again under the semaphore: another operation might have changed the plugin state + // while we were waiting: + eligibilityIssue = GetConfigurationDeletionEligibilityIssue(plugin); + if (!string.IsNullOrEmpty(eligibilityIssue)) + return DeleteError(plugin, pluginDirectory, eligibilityIssue); + + backupDirectory = CreateDeleteBackupDirectory(plugin, "configuration"); + Directory.CreateDirectory(Path.GetDirectoryName(backupDirectory)!); + Directory.Move(pluginDirectory, backupDirectory); + + await PluginFactory.LoadAll(token); + + TryDeleteDirectory(backupDirectory, "configuration plugin delete backup", this.logger); + this.logger.LogInformation($"Deleted configuration 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 configuration plugin '{plugin.Name}' ({plugin.Id}) from '{pluginDirectory}'."); + + await this.TryRestoreDeletedConfigurationPluginAsync(plugin, pluginDirectory, backupDirectory, token); + return DeleteError(plugin, pluginDirectory, string.Format(TB("Unexpected error: {0}"), e.Message)); + } + finally + { + this.installSemaphore.Release(); + } + } + + private static string GetAssistantDeletionEligibilityIssue(IAvailablePlugin plugin) + { + if (plugin.Type is not PluginType.ASSISTANT) + return TB("Only assistant plugins can be deleted."); + + if (plugin.IsInternal) + return TB("Internal assistant plugins cannot be deleted."); + + if (plugin.IsManagedByConfigServer) + return TB("Config Server managed assistant plugins cannot be deleted."); + + if (string.IsNullOrWhiteSpace(plugin.LocalPath)) + return TB("The assistant plugin has no local directory."); + + var assistantPlugin = PluginFactory.RunningPlugins + .OfType() + .FirstOrDefault(candidate => candidate.Id == plugin.Id && IsSameDirectory(candidate.PluginPath, plugin.LocalPath)); + + if (assistantPlugin is null || assistantPlugin.IsInternal || !assistantPlugin.IsAssistantBuilderGenerated) + return TB("Only assistants generated by the Assistant Builder can be deleted."); + + if (assistantPlugin.IsManagedByConfigServer) + return TB("Config Server managed assistant plugins cannot be deleted."); + + if (!TryGetPluginRoot(PluginType.ASSISTANT, out var assistantPluginsRoot, out var rootIssue)) + return rootIssue; + + if (!IsPathInsideDirectory(assistantPluginsRoot, plugin.LocalPath) || IsSameDirectory(assistantPluginsRoot, plugin.LocalPath)) + return TB("The assistant plugin directory is outside the local assistant plugin directory."); + + return Directory.Exists(plugin.LocalPath) + ? string.Empty + : TB("The assistant plugin directory does not exist."); + } + + private static string GetConfigurationDeletionEligibilityIssue(IAvailablePlugin plugin) + { + if (plugin.Type is not PluginType.CONFIGURATION) + return TB("Only configuration plugins can be deleted this way."); + + if (plugin.IsInternal) + return TB("Internal configuration plugins cannot be deleted."); + + if (string.IsNullOrWhiteSpace(plugin.LocalPath)) + return TB("The configuration plugin has no local directory."); + + // + // We decide by the plugin path, not by IsManagedByConfigServer. That value comes from the + // plugin's own DEPLOYED_USING_CONFIG_SERVER field: a locally placed plugin could declare + // itself managed and would then be impossible to remove through the user interface, which + // is exactly the situation this deletion is meant to resolve. + // + if (PluginFactory.IsEnterpriseConfigurationPath(plugin.LocalPath)) + return TB("Configuration plugins deployed by your organization cannot be deleted."); + + if (!PluginFactory.IsInsidePluginsRoot(plugin.LocalPath)) + return TB("The configuration plugin directory is outside the plugins directory."); + + return Directory.Exists(plugin.LocalPath) + ? string.Empty + : TB("The configuration plugin directory does not exist."); + } + + private static string CreateDeleteBackupDirectory(IAvailablePlugin plugin, string pluginKind) + { + var backupRoot = Path.Join(SettingsManager.DataDirectory, DELETE_BACKUP_DIRECTORY); + return Path.Join(backupRoot, $"{pluginKind}-{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 async Task TryRestoreDeletedConfigurationPluginAsync(IAvailablePlugin plugin, string pluginDirectory, string backupDirectory, CancellationToken token) + { + try + { + if (!Directory.Exists(pluginDirectory) && Directory.Exists(backupDirectory)) + Directory.Move(backupDirectory, pluginDirectory); + + // The reload restores everything the plugin configured, because it is back in place: + await PluginFactory.LoadAll(token); + } + catch (Exception restoreException) + { + this.logger.LogError(restoreException, $"Failed to restore configuration plugin '{plugin.Name}' ({plugin.Id}) after a failed delete."); + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/PluginInstallService.Editing.cs b/app/MindWork AI Studio/Tools/Services/PluginInstallService.Editing.cs new file mode 100644 index 00000000..937fe2f9 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/PluginInstallService.Editing.cs @@ -0,0 +1,195 @@ +using System.Text; +using AIStudio.Tools.PluginSystem; + +namespace AIStudio.Tools.Services; + +public sealed partial class PluginInstallService +{ + /// + /// Checks whether edited assistant plugin code can replace an installed local assistant plugin + /// without writing the file. + /// + /// The installed local assistant plugin to validate against. + /// The edited plugin.lua content. + /// Cancellation token for Lua validation. + /// Check result that contains success state, plugin metadata, and a user-facing issue when validation failed. + public async Task CheckInstalledAssistantUpdateAsync(IAvailablePlugin plugin, string lua, CancellationToken token) + { + if (plugin.Type is not PluginType.ASSISTANT) + return CheckError(TB("Only assistant plugins can be edited.")); + + if (plugin.IsInternal) + return CheckError(TB("Internal assistant plugins cannot be edited.")); + + if (string.IsNullOrWhiteSpace(plugin.LocalPath)) + return CheckError(TB("The assistant plugin has no local directory.")); + + if (!TryGetPluginRoot(PluginType.ASSISTANT, out var assistantPluginsRoot, out var rootIssue)) + return CheckError(rootIssue); + + var pluginDirectory = plugin.LocalPath; + if (!IsPathInsideDirectory(assistantPluginsRoot, pluginDirectory) || IsSameDirectory(assistantPluginsRoot, pluginDirectory)) + return CheckError(TB("The assistant plugin directory is outside the local assistant plugin directory.")); + + if (!Directory.Exists(pluginDirectory)) + return CheckError(TB("The assistant plugin directory does not exist.")); + + await this.installSemaphore.WaitAsync(token); + try + { + var validation = await this.ValidateInPluginDirectoryAsync(lua, pluginDirectory, token); + if (!validation.Success || validation.AssistantPlugin is null) + return CheckError(validation.Issue); + + var assistantPlugin = validation.AssistantPlugin; + return assistantPlugin.Id != plugin.Id + ? CheckError(TB("The edited assistant plugin must keep the same plugin ID.")) + : new(true, assistantPlugin.Id, assistantPlugin.Name, string.Empty); + } + finally + { + this.installSemaphore.Release(); + } + } + + /// + /// Updates installed assistant plugin plugin.lua file. + /// The edited Lua code is validated from the provided string before it is written, + /// but validation uses existing plugin directory as loader context so + /// require(...) can resolve companion files such as icon.lua. + /// After successful validation, the current plugin.lua is backed up, + /// replaced atomically through a temporary file in the plugin directory, and + /// restored when the plugin reload fails. + /// + /// The installed local assistant plugin to update. + /// The edited plugin.lua content. + /// Cancellation token for Lua validation, file IO, and plugin reload. + /// + /// Update result that contains success state, updated plugin metadata, the plugin directory, + /// and a user-facing issue when the update failed. + /// + public async Task UpdateInstalledAssistantAsync(IAvailablePlugin plugin, string lua, CancellationToken token) + { + if (plugin.Type is not PluginType.ASSISTANT) + return UpdateError(plugin, plugin.LocalPath, TB("Only assistant plugins can be edited.")); + + if (plugin.IsInternal) + return UpdateError(plugin, plugin.LocalPath, TB("Internal assistant plugins cannot be edited.")); + + if (string.IsNullOrWhiteSpace(plugin.LocalPath)) + return UpdateError(plugin, string.Empty, TB("The assistant plugin has no local directory.")); + + if (!TryGetPluginRoot(PluginType.ASSISTANT, out var assistantPluginsRoot, out var rootIssue)) + return UpdateError(plugin, plugin.LocalPath, rootIssue); + + var pluginDirectory = plugin.LocalPath; + if (!IsPathInsideDirectory(assistantPluginsRoot, pluginDirectory) || IsSameDirectory(assistantPluginsRoot, pluginDirectory)) + return UpdateError(plugin, pluginDirectory, TB("The assistant plugin directory is outside the local assistant plugin directory.")); + + if (!Directory.Exists(pluginDirectory)) + return UpdateError(plugin, pluginDirectory, TB("The assistant plugin directory does not exist.")); + + var pluginFile = Path.Join(pluginDirectory, PLUGIN_FILE_NAME); + if (!IsPathInsideDirectory(pluginDirectory, pluginFile)) + return UpdateError(plugin, pluginDirectory, TB("The plugin file is outside the assistant plugin directory.")); + + await this.installSemaphore.WaitAsync(token); + var tempFile = string.Empty; + var backupFile = string.Empty; + + try + { + var validation = await this.ValidateInPluginDirectoryAsync(lua, pluginDirectory, token); + if (!validation.Success || validation.AssistantPlugin is null) + return UpdateError(plugin, pluginDirectory, validation.Issue); + + var assistantPlugin = validation.AssistantPlugin; + if (assistantPlugin.Id != plugin.Id) + return UpdateError(plugin, pluginDirectory, TB("The edited assistant plugin must keep the same plugin ID.")); + + var pluginCode = lua.Trim(); + tempFile = Path.Join(pluginDirectory, $"{PLUGIN_FILE_NAME}.tmp-{Guid.NewGuid():N}"); + backupFile = Path.Join(pluginDirectory, $"{PLUGIN_FILE_NAME}.backup-{Guid.NewGuid():N}"); + + await File.WriteAllTextAsync(tempFile, pluginCode, Encoding.UTF8, token); + + if (File.Exists(pluginFile)) + File.Replace(tempFile, pluginFile, backupFile); + else + File.Move(tempFile, pluginFile); + + try + { + await PluginFactory.LoadAll(token); + if (File.Exists(backupFile)) + File.Delete(backupFile); + + this.logger.LogInformation($"Updated assistant plugin '{assistantPlugin.Name}' ({assistantPlugin.Id}) at '{pluginFile}'."); + return new(true, assistantPlugin.Id, assistantPlugin.Name, pluginDirectory, string.Empty); + } + catch (Exception reloadException) + { + this.logger.LogError(reloadException, $"Failed to reload plugins after editing assistant plugin '{plugin.Name}' ({plugin.Id})."); + await this.TryRestoreEditedAssistantPluginAsync(pluginFile, backupFile, token); + return UpdateError(plugin, pluginDirectory, string.Format(TB("Unexpected error: {0}"), reloadException.Message)); + } + } + catch (Exception e) + { + this.logger.LogError(e, $"Failed to update assistant plugin '{plugin.Name}' ({plugin.Id}) at '{pluginDirectory}'."); + await this.TryRestoreEditedAssistantPluginAsync(pluginFile, backupFile, token); + return UpdateError(plugin, pluginDirectory, string.Format(TB("Unexpected error: {0}"), e.Message)); + } + finally + { + this.TryDeleteFile(tempFile, "assistant plugin edit temp file"); + + this.installSemaphore.Release(); + } + } + + private async Task ValidateInPluginDirectoryAsync(string lua, string pluginDirectory, CancellationToken token) + { + if (string.IsNullOrWhiteSpace(lua)) + return PluginValidationResult.Failure(TB("No Lua plugin code was generated.")); + + if (!PluginFactory.IsInitialized) + return PluginValidationResult.Failure(TB("The plugin system is not initialized yet.")); + + try + { + return await ValidatePluginCodeAsync( + pluginDirectory, + lua.Trim(), + PluginType.ASSISTANT, + TB("The edited plugin is not an assistant plugin. Issue: {0}"), + TB("The edited assistant plugin is invalid. Issue: {0}"), + TB("The edited assistant plugin uses the ID of another installed plugin."), + token); + } + catch (Exception e) + { + this.logger.LogError(e, "Failed to validate edited assistant plugin."); + return PluginValidationResult.Failure(string.Format(TB("Unexpected error: {0}"), e.Message)); + } + } + + private async Task TryRestoreEditedAssistantPluginAsync(string pluginFile, string backupFile, CancellationToken token) + { + try + { + if (string.IsNullOrWhiteSpace(backupFile) || !File.Exists(backupFile)) + return; + + if (File.Exists(pluginFile)) + File.Delete(pluginFile); + + File.Move(backupFile, pluginFile); + await PluginFactory.LoadAll(token); + } + catch (Exception restoreException) + { + this.logger.LogError(restoreException, $"Failed to restore assistant plugin file '{pluginFile}' after a failed edit."); + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/PluginInstallService.FileSystem.cs b/app/MindWork AI Studio/Tools/Services/PluginInstallService.FileSystem.cs new file mode 100644 index 00000000..2f4e15ac --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/PluginInstallService.FileSystem.cs @@ -0,0 +1,50 @@ +namespace AIStudio.Tools.Services; + +public sealed partial class PluginInstallService +{ + private static bool IsPathInsideDirectory(string parentDirectory, string path) + { + var parentPath = Path.GetFullPath(parentDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + var childPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + 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 void TryDeleteStagingDirectory(string stagingDirectory) => TryDeleteDirectory(stagingDirectory, "assistant plugin staging", this.logger); + + 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 void TryDeleteFile(string filePath, string fileDescription) + { + if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + return; + + try + { + File.Delete(filePath); + } + catch (Exception e) + { + this.logger.LogError(e, $"Failed to delete {fileDescription} '{filePath}'."); + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/PluginInstallService.Import.cs b/app/MindWork AI Studio/Tools/Services/PluginInstallService.Import.cs new file mode 100644 index 00000000..1cf99c2c --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/PluginInstallService.Import.cs @@ -0,0 +1,94 @@ +using System.Text; +using AIStudio.Tools.PluginSystem; +using AIStudio.Tools.Rust; + +namespace AIStudio.Tools.Services; + +public sealed partial class PluginInstallService +{ + /// + /// Installs an assistant plugin archive that contains exactly one plugin.lua file. + /// Companion files are validated from and moved with the same staging directory. + /// + /// The local .mwplugin or .zip archive path. + /// + /// Asks the user whether the validated archive may be installed. It is called after all checks + /// passed and before anything gets written. Returning false aborts the installation. + /// + /// Cancellation token for extraction, validation, file IO, and plugin reload. + /// Installation result that contains success state, installed plugin metadata, and a user-facing issue when installation failed. + public async Task InstallArchiveAsync(string archivePath, Func> confirmAsync, CancellationToken token) + { + if (!this.settingsManager.ConfigurationData.App.AllowUserToImportPlugins) + return Error(TB("Your organization has disabled importing plugins.")); + + if (!FileTypes.IsAllowedPath(archivePath, FileTypes.PLUGIN_ARCHIVE)) + return Error(TB("Please select a plugin archive with the extension .mwplugin or .zip.")); + + if (!File.Exists(archivePath)) + return Error(TB("The selected plugin archive does not exist.")); + + if (!TryGetPluginRoot(PluginType.ASSISTANT, out var assistantPluginsRoot, out var rootIssue)) + return Error(rootIssue); + + if (!PluginFactory.IsInitialized) + return Error(TB("The plugin system is not initialized yet.")); + + await this.installSemaphore.WaitAsync(token); + var stagingDirectory = Path.Join(Path.GetTempPath(), $"assistant-plugin-import.staging-{Guid.NewGuid():N}"); + try + { + token.ThrowIfCancellationRequested(); + PluginArchive.Extract(archivePath, stagingDirectory); + + var pluginFiles = Directory.EnumerateFiles(stagingDirectory, PLUGIN_FILE_NAME, SearchOption.AllDirectories).ToArray(); + if (pluginFiles.Length != 1) + return Error(TB("The plugin archive must contain exactly one plugin.lua file.")); + + var pluginFile = pluginFiles[0]; + var pluginDirectory = Path.GetDirectoryName(pluginFile)!; + var pluginCode = await File.ReadAllTextAsync(pluginFile, Encoding.UTF8, token); + var validation = await ValidatePluginCodeAsync( + pluginDirectory, + pluginCode.Trim(), + PluginType.ASSISTANT, + TB("Currently, only assistant plugins can be imported."), + TB("The imported assistant plugin is invalid. Issue: {0}"), + TB("The imported assistant plugin uses the ID of another installed plugin."), + token); + + if (!validation.Success || validation.AssistantPlugin is null) + return Error(validation.Issue); + + // A plugin the user imports by hand never comes from a config server. We reject such + // archives because AI Studio trusts this self-declared flag: an imported plugin + // claiming it would be neither replaceable nor deletable through the user interface: + if (validation.AssistantPlugin.IsManagedByConfigServer) + return Error(TB("This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins.")); + + // The archive would replace an existing plugin: reject it when that plugin belongs + // to the IT department. We check this before asking the user, so that the + // confirmation never offers something we would refuse afterwards anyway: + var replacementIssue = GetReplacementIssue(validation.AssistantPlugin.Id, PluginType.ASSISTANT); + if (!string.IsNullOrEmpty(replacementIssue)) + return Error(replacementIssue); + + // Everything is validated, but nothing was written yet. This is the point where the + // user decides, because the plugin code comes from an untrusted source: + if (!await confirmAsync(CreateImportPreview(validation.AssistantPlugin))) + return CancelledByUser(); + + return await this.InstallStagedPluginAsync(assistantPluginsRoot, validation with { StagingDirectory = pluginDirectory }, PluginType.ASSISTANT, token); + } + catch (Exception e) when (e is not OperationCanceledException) + { + this.logger.LogError(e, "Failed to extract or validate assistant plugin archive '{ArchivePath}'.", archivePath); + return Error(string.Format(TB("Unexpected error: {0}"), e.Message)); + } + finally + { + this.TryDeleteStagingDirectory(stagingDirectory); + this.installSemaphore.Release(); + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/PluginInstallService.Installation.cs b/app/MindWork AI Studio/Tools/Services/PluginInstallService.Installation.cs new file mode 100644 index 00000000..8912837f --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/PluginInstallService.Installation.cs @@ -0,0 +1,252 @@ +using System.Text; +using AIStudio.Settings; +using AIStudio.Tools.PluginSystem; +using AIStudio.Tools.PluginSystem.Assistants; + +namespace AIStudio.Tools.Services; + +public sealed partial class PluginInstallService +{ + private async Task InstallStagedPluginAsync(string pluginRoot, PluginValidationResult validation, PluginType pluginType, CancellationToken token) + { + var stagingDirectory = validation.StagingDirectory; + var plugin = validation.Plugin!; + string? backupDirectory = null; + string? finalDirectory = null; + var replacedExisting = false; + var movedIntoPlace = false; + + try + { + Directory.CreateDirectory(pluginRoot); + finalDirectory = DetermineFinalDirectory(pluginRoot, plugin, pluginType); + if (!IsPathInsideDirectory(pluginRoot, finalDirectory)) + return Error(TB("The resolved plugin directory is outside the plugin directory.")); + + var replacementIssue = GetReplacementIssue(plugin.Id, pluginType); + if (!string.IsNullOrWhiteSpace(replacementIssue)) + return Error(replacementIssue); + + if (Directory.Exists(finalDirectory)) + { + replacedExisting = true; + + // The backup goes to a directory outside the plugin root, so the plugin loader + // cannot discover it during the reload below. Otherwise, the previous version + // would be loaded a second time, next to the version we are installing: + backupDirectory = CreateInstallBackupDirectory(plugin); + Directory.CreateDirectory(Path.GetDirectoryName(backupDirectory)!); + Directory.Move(finalDirectory, backupDirectory); + } + + Directory.Move(stagingDirectory, finalDirectory); + movedIntoPlace = true; + await PluginFactory.LoadAll(token); + + if (!string.IsNullOrWhiteSpace(backupDirectory)) + TryDeleteDirectory(backupDirectory, "plugin backup", this.logger); + + this.logger.LogInformation("Installed plugin '{PluginName}' ({PluginId}, {PluginType}) to '{PluginDirectory}'.", plugin.Name, plugin.Id, pluginType, finalDirectory); + return new(true, plugin.Id, plugin.Name, finalDirectory, replacedExisting, string.Empty); + } + catch (Exception e) + { + this.logger.LogError(e, "Failed to install plugin."); + + // Only remove the target directory when this installation actually moved the plugin + // there. Otherwise, when moving the previous plugin into the backup directory failed, + // we would delete the still intact previous plugin: + if (movedIntoPlace && !string.IsNullOrWhiteSpace(finalDirectory) && Directory.Exists(finalDirectory)) + TryDeleteDirectory(finalDirectory, "failed assistant plugin installation", this.logger); + + if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory) && !string.IsNullOrWhiteSpace(finalDirectory) && !Directory.Exists(finalDirectory)) + { + try + { + Directory.Move(backupDirectory, finalDirectory); + await PluginFactory.LoadAll(CancellationToken.None); + } + catch (Exception restoreException) + { + this.logger.LogError(restoreException, "Failed to restore the previous assistant plugin after a failed installation."); + } + } + + return Error(string.Format(TB("Unexpected error: {0}"), e.Message)); + } + finally + { + this.TryDeleteStagingDirectory(stagingDirectory); + } + } + + /// + /// Loads and validates plugin code that is not installed yet. + /// + /// The staging directory the plugin currently lives in. + /// The plugin.lua content to validate. + /// The plugin type the caller accepts. + /// Issue when the plugin has another type. Gets the plugin issues as {0}. + /// Issue when the plugin is of the right type, but invalid. Gets the plugin issues as {0}. + /// Issue when another plugin already uses this plugin ID. + /// Cancellation token for running the Lua code. + /// The validation result, including the loaded plugin when it passed. + private static async Task ValidatePluginCodeAsync(string pluginDirectory, string pluginCode, PluginType expectedType, + string wrongTypeIssue, string invalidPluginIssue, string conflictingPluginIdIssue, CancellationToken token) + { + // The plugin is not installed yet: it sits in a staging directory outside the installed + // plugins directory. We allow that directory as the module base, so the plugin can load its + // own Lua modules, e.g., an icon.lua, while we validate it: + var plugin = await PluginFactory.Load(pluginDirectory, pluginCode, token, pluginDirectory); + if (plugin.Type != expectedType) + return PluginValidationResult.Failure(string.Format(wrongTypeIssue, string.Join("; ", plugin.Issues))); + + if (!plugin.IsValid) + return PluginValidationResult.Failure(string.Format(invalidPluginIssue, string.Join("; ", plugin.Issues))); + + // Plugin IDs must be unique across all plugin types: several lookups resolve a plugin by its + // ID alone, e.g., the base language plugin in PluginFactory.Starting. A plugin carrying the + // ID of a plugin of another type would break those lookups. Reusing the ID of another local + // plugin of the same type stays allowed: that is how updating one works. + if (PluginFactory.AvailablePlugins.Any(availablePlugin => availablePlugin.Id == plugin.Id && (availablePlugin.IsInternal || availablePlugin.Type != expectedType))) + return PluginValidationResult.Failure(conflictingPluginIdIssue); + + return new(true, string.Empty, plugin, string.Empty); + } + + /// + /// Determines the directory local plugins of the given type are installed into. + /// + private static bool TryGetPluginRoot(PluginType pluginType, out string pluginRoot, out string issue) + { + pluginRoot = string.Empty; + issue = string.Empty; + + var dataDirectory = SettingsManager.DataDirectory; + if (string.IsNullOrWhiteSpace(dataDirectory)) + { + issue = TB("The AI Studio data directory is not initialized yet."); + return false; + } + + pluginRoot = Path.Join(dataDirectory, "plugins", pluginType.GetDirectory()); + return true; + } + + private static string DetermineFinalDirectory(string pluginRoot, IPluginMetadata plugin, PluginType pluginType) + { + var existingPlugin = FindReplaceablePlugin(plugin.Id, pluginType); + return existingPlugin is not null + ? existingPlugin.LocalPath + : Path.Join(pluginRoot, CreatePluginDirectoryName(plugin)); + } + + /// + /// Finds the local plugin that an installation with the given ID and type would replace. + /// + /// The ID of the plugin about to be installed. + /// The type of the plugin about to be installed. + /// The plugin that would be replaced, or null when the installation adds a new plugin. + private static IAvailablePlugin? FindReplaceablePlugin(Guid pluginId, PluginType pluginType) => PluginFactory.AvailablePlugins + .OfType() + .FirstOrDefault(plugin => plugin.Type == pluginType && plugin.Id == pluginId && !plugin.IsInternal); + + /// + /// Collects the metadata an archive declares about itself, together with the information about + /// the installed plugin it would replace. + /// + /// The validated plugin from the archive. + /// The preview shown to the user before the installation starts. + private static PluginImportPreview CreateImportPreview(PluginBase plugin) => new(plugin, FindReplaceablePlugin(plugin.Id, plugin.Type)); + + /// + /// Checks whether an installation may replace the plugin that currently uses the given ID. + /// Plugins deployed by a Config Server belong to the organization's IT, so neither an import nor + /// the Assistant Builder may overwrite them. + /// + /// The ID of the plugin about to be installed. + /// The type of the plugin about to be installed. + /// A user-facing issue when the existing plugin must not be replaced, an empty string otherwise. + private static string GetReplacementIssue(Guid pluginId, PluginType pluginType) + { + var existingPlugin = FindReplaceablePlugin(pluginId, pluginType); + if (existingPlugin is null) + return string.Empty; + + if (existingPlugin.IsManagedByConfigServer) + return TB("Plugins deployed by your organization cannot be replaced."); + + if (string.IsNullOrWhiteSpace(existingPlugin.LocalPath)) + return string.Empty; + + // The metadata above and the running plugin read the same Lua field. We check both, though, + // just like the deletion path does: + var runningPlugin = PluginFactory.RunningPlugins + .FirstOrDefault(candidate => candidate.Id == pluginId && IsSameDirectory(candidate.PluginPath, existingPlugin.LocalPath)); + + var isManagedByConfigServer = runningPlugin switch + { + PluginAssistants assistantPlugin => assistantPlugin.IsManagedByConfigServer, + PluginConfiguration configurationPlugin => configurationPlugin.DeployedUsingConfigServer ?? false, + + _ => false, + }; + + return isManagedByConfigServer + ? TB("Plugins deployed by your organization cannot be replaced.") + : string.Empty; + } + + private static string CreateInstallBackupDirectory(IPluginMetadata plugin) + { + var backupRoot = Path.Join(SettingsManager.DataDirectory, INSTALL_BACKUP_DIRECTORY); + return Path.Join(backupRoot, $"assistant-{plugin.Id:N}-{Guid.NewGuid():N}"); + } + + private static string CreatePluginDirectoryName(IPluginMetadata plugin) + { + var safeName = CreateSafeDirectoryNamePart(plugin.Name); + return $"{safeName}-{plugin.Id:N}"; + } + + private static string CreateSafeDirectoryNamePart(string name) + { + var sb = new StringBuilder(); + var invalidChars = Path.GetInvalidFileNameChars().ToHashSet(); + + foreach (var character in name.Trim()) + { + if (char.IsLetterOrDigit(character)) + { + sb.Append(char.ToLowerInvariant(character)); + continue; + } + + if (character is '-' or '_' or '.' && !invalidChars.Contains(character)) + { + sb.Append(character); + continue; + } + + AppendSeparator(); + } + + var safeName = sb.ToString().Trim('-', '.'); + if (safeName.Length > DIRECTORY_PREFIX_MAX_LEN) + safeName = safeName[..DIRECTORY_PREFIX_MAX_LEN].Trim('-', '.'); + + // Fallback for a plugin name without any usable character. The plugin ID is appended by the + // caller, so the directory stays unique either way: + return string.IsNullOrWhiteSpace(safeName) + ? "plugin" + : safeName; + + void AppendSeparator() + { + if (sb.Length == 0 || sb[^1] == '-') + return; + + sb.Append('-'); + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/PluginInstallService.cs b/app/MindWork AI Studio/Tools/Services/PluginInstallService.cs index d7ff2e91..44a81d52 100644 --- a/app/MindWork AI Studio/Tools/Services/PluginInstallService.cs +++ b/app/MindWork AI Studio/Tools/Services/PluginInstallService.cs @@ -1,35 +1,44 @@ -using System.Text; using AIStudio.Settings; using AIStudio.Tools.AssistantSessions; -using AIStudio.Tools.Media; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem.Assistants; -using AIStudio.Tools.Rust; namespace AIStudio.Tools.Services; -public sealed class PluginInstallService +/// +/// Installs, updates, and removes the plugins AI Studio manages locally. +/// +/// +/// The implementation is split across several files:
+/// - PluginInstallService.AssistantBuilder.cs: installing generated assistant plugin code
+/// - PluginInstallService.Editing.cs: editing an installed assistant plugin
+/// - PluginInstallService.Import.cs: importing plugin archives
+/// - PluginInstallService.Delete.cs: removing installed plugins
+/// - PluginInstallService.Installation.cs: the shared validation and installation steps
+/// - PluginInstallService.FileSystem.cs: the shared path and directory helpers +///
+public sealed partial class PluginInstallService { private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginInstallService).Namespace, nameof(PluginInstallService)); - + 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 string INSTALL_BACKUP_DIRECTORY = ".plugin-install-backups"; private const int DIRECTORY_PREFIX_MAX_LEN = 80; - + private readonly ILogger logger; private readonly SettingsManager settingsManager; private readonly AssistantSessionService assistantSessionService; private readonly MediaTranscriptionService mediaTranscriptionService; 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 AssistantPluginInstallResult CancelledByUser() => new(false, Guid.Empty, string.Empty, string.Empty, false, string.Empty, true); - + private static AssistantPluginCheckResult CheckError(string issue) => new(false, Guid.Empty, string.Empty, issue); - + private static PluginDeleteResult DeleteError(IPluginMetadata plugin, string pluginDirectory, string issue) => new(false, plugin.Id, plugin.Name, pluginDirectory, issue); private static AssistantPluginUpdateResult UpdateError(IPluginMetadata plugin, string pluginDirectory, string issue) => new(false, plugin.Id, plugin.Name, pluginDirectory, issue); @@ -43,980 +52,6 @@ public sealed class PluginInstallService this.logger.LogInformation("The plugin install service has been initialized."); } - /// - /// Checks whether a local plugin is an Assistant Builder generated assistant that users may delete. - /// - public static bool CanDeleteInstalledAssistant(IAvailablePlugin plugin) => string.IsNullOrWhiteSpace(GetAssistantDeletionEligibilityIssue(plugin)); - - /// - /// Checks whether a plugin is a local configuration plugin that users may delete. - /// - public static bool CanDeleteInstalledConfiguration(IAvailablePlugin plugin) => string.IsNullOrWhiteSpace(GetConfigurationDeletionEligibilityIssue(plugin)); - - /// - /// Collects what deleting a local configuration plugin removes besides the plugin directory. - /// - /// The configuration plugin about to be deleted. - /// - /// The summary shown to the user before the deletion starts. It is empty when the plugin is not - /// running, because we cannot tell what an unloadable plugin had configured. - /// - public ConfigurationPluginDeleteSummary BuildConfigurationDeleteSummary(IAvailablePlugin plugin) - { - var configurationPlugin = PluginFactory.RunningPlugins.OfType().FirstOrDefault(candidate => candidate.Id == plugin.Id); - if (configurationPlugin is null) - return ConfigurationPluginDeleteSummary.EMPTY; - - var configObjects = configurationPlugin.ConfigObjects.ToList(); - var configurationData = this.settingsManager.ConfigurationData; - - // Both maps record which configuration plugin manages a setting. Everything this plugin owns - // returns to its default value once the plugin is gone: - var lockedSettings = - configurationData.ManagedLockedConfigurations.Count(entry => entry.Value == plugin.Id) + - configurationData.ManagedEditableDefaults.Count(entry => entry.Value.ConfigPluginId == plugin.Id); - - return new( - LlmProviders: CountObjects(PluginConfigurationObjectType.LLM_PROVIDER), - TranscriptionProviders: CountObjects(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER), - EmbeddingProviders: CountObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER), - DataSources: CountObjects(PluginConfigurationObjectType.DATA_SOURCE), - ChatTemplates: CountObjects(PluginConfigurationObjectType.CHAT_TEMPLATE), - Profiles: CountObjects(PluginConfigurationObjectType.PROFILE), - DocumentAnalysisPolicies: CountObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY), - LockedSettings: lockedSettings, - MandatoryInfos: configurationPlugin.MandatoryInfos.Count, - Introductions: configurationPlugin.Introductions.Count); - - int CountObjects(PluginConfigurationObjectType type) => configObjects.Count(configObject => configObject.Type == type); - } - - /// - /// Checks whether an assistant still owns running or canceling background work. - /// - public bool HasActiveAssistantWork(Guid pluginId) - { - var instanceId = pluginId.ToString(); - if (this.assistantSessionService.GetSnapshots().Any(snapshot => snapshot.IsActive && string.Equals(snapshot.Key.InstanceId, instanceId, StringComparison.Ordinal))) - return true; - - var ownerIdSuffix = $":{instanceId}"; - return this.mediaTranscriptionService.GetSnapshots().Any(snapshot => - snapshot is { IsBusy: true, Owner.Kind: MediaImportOwnerKind.ASSISTANT } && - snapshot.Owner.Id.EndsWith(ownerIdSuffix, StringComparison.Ordinal)); - } - - /// - /// Checks whether generated Lua assistant plugin code can be loaded and installed. - /// The plugin is written to a temporary staging directory and validated through the - /// normal plugin loader, but it is not moved into the user plugin directory. - /// - /// The full generated plugin.lua content. - /// A cancellation token for file IO and Lua validation. - /// - /// Check result that contains success state, plugin metadata, and a user-facing issue when validation failed. - /// - public async Task CheckInstallabilityAsync(string lua, CancellationToken token) - { - if (!TryGetPluginRoot(PluginType.ASSISTANT, out var assistantPluginsRoot, out var rootIssue)) - return CheckError(rootIssue); - - await this.installSemaphore.WaitAsync(token); - var stagingDirectory = string.Empty; - try - { - var validation = await this.ValidateIntoStagingAsync(lua, token); - if (!validation.Success || validation.AssistantPlugin is null) - return CheckError(validation.Issue); - - stagingDirectory = validation.StagingDirectory; - var finalDirectory = DetermineFinalDirectory(assistantPluginsRoot, validation.AssistantPlugin, PluginType.ASSISTANT); - if (!IsPathInsideDirectory(assistantPluginsRoot, finalDirectory)) - return CheckError(TB("The resolved plugin directory is outside the plugin directory.")); - - return new(true, validation.AssistantPlugin.Id, validation.AssistantPlugin.Name, string.Empty); - } - finally - { - this.TryDeleteStagingDirectory(stagingDirectory); - this.installSemaphore.Release(); - } - } - - /// - /// Installs generated Lua assistant plugin code into the user plugin directory. - /// Writes the plugin into a temporary staging directory first, validates it through the - /// normal plugin loader, then moves into data/plugins/assistants. - /// If plugin with same ID already exists, the existing directory is moved - /// aside as backup and restored when replacement fails. - /// - /// The full generated plugin.lua content. - /// A cancellation token for file IO, Lua validation, and plugin reload. - /// - /// Installation result that contains success state, installed plugin metadata, final directory, - /// whether an existing plugin was replaced, and user-facing issue when installation failed. - /// - public async Task InstallAsync(string lua, CancellationToken token) - { - if (!TryGetPluginRoot(PluginType.ASSISTANT, out var assistantPluginsRoot, out var rootIssue)) - return Error(rootIssue); - - await this.installSemaphore.WaitAsync(token); - try - { - var validation = await this.ValidateIntoStagingAsync(lua, token); - if (!validation.Success || validation.AssistantPlugin is null) - return Error(validation.Issue); - - return await this.InstallStagedPluginAsync(assistantPluginsRoot, validation, PluginType.ASSISTANT, token); - } - finally - { - this.installSemaphore.Release(); - } - } - - /// - /// Installs an assistant plugin archive that contains exactly one plugin.lua file. - /// Companion files are validated from and moved with the same staging directory. - /// - /// The local .mwplugin or .zip archive path. - /// - /// Asks the user whether the validated archive may be installed. It is called after all checks - /// passed and before anything gets written. Returning false aborts the installation. - /// - /// Cancellation token for extraction, validation, file IO, and plugin reload. - /// Installation result that contains success state, installed plugin metadata, and a user-facing issue when installation failed. - public async Task InstallArchiveAsync(string archivePath, Func> confirmAsync, CancellationToken token) - { - if (!this.settingsManager.ConfigurationData.App.AllowUserToImportPlugins) - return Error(TB("Your organization has disabled importing plugins.")); - - if (!FileTypes.IsAllowedPath(archivePath, FileTypes.PLUGIN_ARCHIVE)) - return Error(TB("Please select a plugin archive with the extension .mwplugin or .zip.")); - - if (!File.Exists(archivePath)) - return Error(TB("The selected plugin archive does not exist.")); - - if (!TryGetPluginRoot(PluginType.ASSISTANT, out var assistantPluginsRoot, out var rootIssue)) - return Error(rootIssue); - - if (!PluginFactory.IsInitialized) - return Error(TB("The plugin system is not initialized yet.")); - - await this.installSemaphore.WaitAsync(token); - var stagingDirectory = Path.Join(Path.GetTempPath(), $"assistant-plugin-import.staging-{Guid.NewGuid():N}"); - try - { - try - { - token.ThrowIfCancellationRequested(); - PluginArchive.Extract(archivePath, stagingDirectory); - - var pluginFiles = Directory.EnumerateFiles(stagingDirectory, PLUGIN_FILE_NAME, SearchOption.AllDirectories).ToArray(); - if (pluginFiles.Length != 1) - return Error(TB("The plugin archive must contain exactly one plugin.lua file.")); - - var pluginFile = pluginFiles[0]; - var pluginDirectory = Path.GetDirectoryName(pluginFile)!; - var pluginCode = await File.ReadAllTextAsync(pluginFile, Encoding.UTF8, token); - var validation = await ValidatePluginCodeAsync( - pluginDirectory, - pluginCode.Trim(), - PluginType.ASSISTANT, - TB("Currently, only assistant plugins can be imported."), - TB("The imported assistant plugin is invalid. Issue: {0}"), - TB("The imported assistant plugin uses the ID of another installed plugin."), - token); - - if (!validation.Success || validation.AssistantPlugin is null) - return Error(validation.Issue); - - // A plugin the user imports by hand never comes from a config server. We reject such - // archives because AI Studio trusts this self-declared flag: an imported plugin - // claiming it would be neither replaceable nor deletable through the user interface: - if (validation.AssistantPlugin.IsManagedByConfigServer) - return Error(TB("This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins.")); - - // The archive would replace an existing plugin: reject it when that plugin belongs - // to the IT department. We check this before asking the user, so that the - // confirmation never offers something we would refuse afterwards anyway: - var replacementIssue = GetReplacementIssue(validation.AssistantPlugin.Id, PluginType.ASSISTANT); - if (!string.IsNullOrEmpty(replacementIssue)) - return Error(replacementIssue); - - // Everything is validated, but nothing was written yet. This is the point where the - // user decides, because the plugin code comes from an untrusted source: - if (!await confirmAsync(CreateImportPreview(validation.AssistantPlugin))) - return CancelledByUser(); - - return await this.InstallStagedPluginAsync(assistantPluginsRoot, validation with { StagingDirectory = pluginDirectory }, PluginType.ASSISTANT, token); - } - catch (Exception e) when (e is not OperationCanceledException) - { - this.logger.LogError(e, "Failed to extract or validate assistant plugin archive '{ArchivePath}'.", archivePath); - return Error(string.Format(TB("Unexpected error: {0}"), e.Message)); - } - } - finally - { - this.TryDeleteStagingDirectory(stagingDirectory); - this.installSemaphore.Release(); - } - } - - /// - /// Checks whether edited assistant plugin code can replace an installed local assistant plugin - /// without writing the file. - /// - /// The installed local assistant plugin to validate against. - /// The edited plugin.lua content. - /// Cancellation token for Lua validation. - /// Check result that contains success state, plugin metadata, and a user-facing issue when validation failed. - public async Task CheckInstalledAssistantUpdateAsync(IAvailablePlugin plugin, string lua, CancellationToken token) - { - if (plugin.Type is not PluginType.ASSISTANT) - return CheckError(TB("Only assistant plugins can be edited.")); - - if (plugin.IsInternal) - return CheckError(TB("Internal assistant plugins cannot be edited.")); - - if (string.IsNullOrWhiteSpace(plugin.LocalPath)) - return CheckError(TB("The assistant plugin has no local directory.")); - - if (!TryGetPluginRoot(PluginType.ASSISTANT, out var assistantPluginsRoot, out var rootIssue)) - return CheckError(rootIssue); - - var pluginDirectory = plugin.LocalPath; - if (!IsPathInsideDirectory(assistantPluginsRoot, pluginDirectory) || IsSameDirectory(assistantPluginsRoot, pluginDirectory)) - return CheckError(TB("The assistant plugin directory is outside the local assistant plugin directory.")); - - if (!Directory.Exists(pluginDirectory)) - return CheckError(TB("The assistant plugin directory does not exist.")); - - await this.installSemaphore.WaitAsync(token); - try - { - var validation = await this.ValidateInPluginDirectoryAsync(lua, pluginDirectory, token); - if (!validation.Success || validation.AssistantPlugin is null) - return CheckError(validation.Issue); - - var assistantPlugin = validation.AssistantPlugin; - return assistantPlugin.Id != plugin.Id - ? CheckError(TB("The edited assistant plugin must keep the same plugin ID.")) - : new(true, assistantPlugin.Id, assistantPlugin.Name, string.Empty); - } - finally - { - this.installSemaphore.Release(); - } - } - - /// - /// 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) - { - var eligibilityIssue = GetAssistantDeletionEligibilityIssue(plugin); - if (!string.IsNullOrEmpty(eligibilityIssue)) - return DeleteError(plugin, plugin.LocalPath, eligibilityIssue); - - if (this.HasActiveAssistantWork(plugin.Id)) - return DeleteError(plugin, plugin.LocalPath, TB("The assistant cannot be deleted while background work is still running.")); - - await this.installSemaphore.WaitAsync(token); - var pluginDirectory = plugin.LocalPath; - var backupDirectory = string.Empty; - var wasEnabled = false; - var removedAudits = new List(); - - try - { - eligibilityIssue = GetAssistantDeletionEligibilityIssue(plugin); - if (!string.IsNullOrEmpty(eligibilityIssue)) - return DeleteError(plugin, pluginDirectory, eligibilityIssue); - - if (this.HasActiveAssistantWork(plugin.Id)) - return DeleteError(plugin, pluginDirectory, TB("The assistant cannot be deleted while background work is still running.")); - - backupDirectory = CreateDeleteBackupDirectory(plugin, "assistant"); - 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) - ]; - - 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(); - } - } - - /// - /// Deletes a local configuration plugin directory. - /// 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 is restored. - /// - /// - /// We do not remove the providers, data sources, chat templates, profiles, or locked settings of - /// the plugin ourselves. The reload does that: it recognizes them as left over once their - /// configuration plugin is gone, and it also deletes the related secrets from the OS keyring. - /// - /// Configuration plugin metadata. - /// Cancellation token for the 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 DeleteInstalledConfigurationAsync(IAvailablePlugin plugin, CancellationToken token) - { - var eligibilityIssue = GetConfigurationDeletionEligibilityIssue(plugin); - if (!string.IsNullOrEmpty(eligibilityIssue)) - return DeleteError(plugin, plugin.LocalPath, eligibilityIssue); - - await this.installSemaphore.WaitAsync(token); - var pluginDirectory = plugin.LocalPath; - var backupDirectory = string.Empty; - - try - { - // Check again under the semaphore: another operation might have changed the plugin state - // while we were waiting: - eligibilityIssue = GetConfigurationDeletionEligibilityIssue(plugin); - if (!string.IsNullOrEmpty(eligibilityIssue)) - return DeleteError(plugin, pluginDirectory, eligibilityIssue); - - backupDirectory = CreateDeleteBackupDirectory(plugin, "configuration"); - Directory.CreateDirectory(Path.GetDirectoryName(backupDirectory)!); - Directory.Move(pluginDirectory, backupDirectory); - - await PluginFactory.LoadAll(token); - - TryDeleteDirectory(backupDirectory, "configuration plugin delete backup", this.logger); - this.logger.LogInformation($"Deleted configuration 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 configuration plugin '{plugin.Name}' ({plugin.Id}) from '{pluginDirectory}'."); - - await this.TryRestoreDeletedConfigurationPluginAsync(plugin, pluginDirectory, backupDirectory, token); - return DeleteError(plugin, pluginDirectory, string.Format(TB("Unexpected error: {0}"), e.Message)); - } - finally - { - this.installSemaphore.Release(); - } - } - - /// - /// Updates installed assistant plugin plugin.lua file. - /// The edited Lua code is validated from the provided string before it is written, - /// but validation uses existing plugin directory as loader context so - /// require(...) can resolve companion files such as icon.lua. - /// After successful validation, the current plugin.lua is backed up, - /// replaced atomically through a temporary file in the plugin directory, and - /// restored when the plugin reload fails. - /// - /// The installed local assistant plugin to update. - /// The edited plugin.lua content. - /// Cancellation token for Lua validation, file IO, and plugin reload. - /// - /// Update result that contains success state, updated plugin metadata, the plugin directory, - /// and a user-facing issue when the update failed. - /// - public async Task UpdateInstalledAssistantAsync(IAvailablePlugin plugin, string lua, CancellationToken token) - { - if (plugin.Type is not PluginType.ASSISTANT) - return UpdateError(plugin, plugin.LocalPath, TB("Only assistant plugins can be edited.")); - - if (plugin.IsInternal) - return UpdateError(plugin, plugin.LocalPath, TB("Internal assistant plugins cannot be edited.")); - - if (string.IsNullOrWhiteSpace(plugin.LocalPath)) - return UpdateError(plugin, string.Empty, TB("The assistant plugin has no local directory.")); - - if (!TryGetPluginRoot(PluginType.ASSISTANT, out var assistantPluginsRoot, out var rootIssue)) - return UpdateError(plugin, plugin.LocalPath, rootIssue); - - var pluginDirectory = plugin.LocalPath; - if (!IsPathInsideDirectory(assistantPluginsRoot, pluginDirectory) || IsSameDirectory(assistantPluginsRoot, pluginDirectory)) - return UpdateError(plugin, pluginDirectory, TB("The assistant plugin directory is outside the local assistant plugin directory.")); - - if (!Directory.Exists(pluginDirectory)) - return UpdateError(plugin, pluginDirectory, TB("The assistant plugin directory does not exist.")); - - var pluginFile = Path.Join(pluginDirectory, PLUGIN_FILE_NAME); - if (!IsPathInsideDirectory(pluginDirectory, pluginFile)) - return UpdateError(plugin, pluginDirectory, TB("The plugin file is outside the assistant plugin directory.")); - - await this.installSemaphore.WaitAsync(token); - var tempFile = string.Empty; - var backupFile = string.Empty; - - try - { - var validation = await this.ValidateInPluginDirectoryAsync(lua, pluginDirectory, token); - if (!validation.Success || validation.AssistantPlugin is null) - return UpdateError(plugin, pluginDirectory, validation.Issue); - - var assistantPlugin = validation.AssistantPlugin; - if (assistantPlugin.Id != plugin.Id) - return UpdateError(plugin, pluginDirectory, TB("The edited assistant plugin must keep the same plugin ID.")); - - var pluginCode = lua.Trim(); - tempFile = Path.Join(pluginDirectory, $"{PLUGIN_FILE_NAME}.tmp-{Guid.NewGuid():N}"); - backupFile = Path.Join(pluginDirectory, $"{PLUGIN_FILE_NAME}.backup-{Guid.NewGuid():N}"); - - await File.WriteAllTextAsync(tempFile, pluginCode, Encoding.UTF8, token); - - if (File.Exists(pluginFile)) - File.Replace(tempFile, pluginFile, backupFile); - else - File.Move(tempFile, pluginFile); - - try - { - await PluginFactory.LoadAll(token); - if (File.Exists(backupFile)) - File.Delete(backupFile); - - this.logger.LogInformation($"Updated assistant plugin '{assistantPlugin.Name}' ({assistantPlugin.Id}) at '{pluginFile}'."); - return new(true, assistantPlugin.Id, assistantPlugin.Name, pluginDirectory, string.Empty); - } - catch (Exception reloadException) - { - this.logger.LogError(reloadException, $"Failed to reload plugins after editing assistant plugin '{plugin.Name}' ({plugin.Id})."); - await this.TryRestoreEditedAssistantPluginAsync(pluginFile, backupFile, token); - return UpdateError(plugin, pluginDirectory, string.Format(TB("Unexpected error: {0}"), reloadException.Message)); - } - } - catch (Exception e) - { - this.logger.LogError(e, $"Failed to update assistant plugin '{plugin.Name}' ({plugin.Id}) at '{pluginDirectory}'."); - await this.TryRestoreEditedAssistantPluginAsync(pluginFile, backupFile, token); - return UpdateError(plugin, pluginDirectory, string.Format(TB("Unexpected error: {0}"), e.Message)); - } - finally - { - this.TryDeleteFile(tempFile, "assistant plugin edit temp file"); - - this.installSemaphore.Release(); - } - } - - private async Task InstallStagedPluginAsync(string pluginRoot, PluginValidationResult validation, PluginType pluginType, CancellationToken token) - { - var stagingDirectory = validation.StagingDirectory; - var plugin = validation.Plugin!; - string? backupDirectory = null; - string? finalDirectory = null; - var replacedExisting = false; - var movedIntoPlace = false; - - try - { - Directory.CreateDirectory(pluginRoot); - finalDirectory = DetermineFinalDirectory(pluginRoot, plugin, pluginType); - if (!IsPathInsideDirectory(pluginRoot, finalDirectory)) - return Error(TB("The resolved plugin directory is outside the plugin directory.")); - - var replacementIssue = GetReplacementIssue(plugin.Id, pluginType); - if (!string.IsNullOrWhiteSpace(replacementIssue)) - return Error(replacementIssue); - - if (Directory.Exists(finalDirectory)) - { - replacedExisting = true; - - // The backup goes to a directory outside the plugin root, so the plugin loader - // cannot discover it during the reload below. Otherwise, the previous version - // would be loaded a second time, next to the version we are installing: - backupDirectory = CreateInstallBackupDirectory(plugin); - Directory.CreateDirectory(Path.GetDirectoryName(backupDirectory)!); - Directory.Move(finalDirectory, backupDirectory); - } - - Directory.Move(stagingDirectory, finalDirectory); - movedIntoPlace = true; - await PluginFactory.LoadAll(token); - - if (!string.IsNullOrWhiteSpace(backupDirectory)) - TryDeleteDirectory(backupDirectory, "plugin backup", this.logger); - - this.logger.LogInformation("Installed plugin '{PluginName}' ({PluginId}, {PluginType}) to '{PluginDirectory}'.", plugin.Name, plugin.Id, pluginType, finalDirectory); - return new(true, plugin.Id, plugin.Name, finalDirectory, replacedExisting, string.Empty); - } - catch (Exception e) - { - this.logger.LogError(e, "Failed to install plugin."); - - // Only remove the target directory when this installation actually moved the plugin - // there. Otherwise, when moving the previous plugin into the backup directory failed, - // we would delete the still intact previous plugin: - if (movedIntoPlace && !string.IsNullOrWhiteSpace(finalDirectory) && Directory.Exists(finalDirectory)) - TryDeleteDirectory(finalDirectory, "failed assistant plugin installation", this.logger); - - if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory) && !string.IsNullOrWhiteSpace(finalDirectory) && !Directory.Exists(finalDirectory)) - { - try - { - Directory.Move(backupDirectory, finalDirectory); - await PluginFactory.LoadAll(CancellationToken.None); - } - catch (Exception restoreException) - { - this.logger.LogError(restoreException, "Failed to restore the previous assistant plugin after a failed installation."); - } - } - - return Error(string.Format(TB("Unexpected error: {0}"), e.Message)); - } - finally - { - this.TryDeleteStagingDirectory(stagingDirectory); - } - } - - private async Task ValidateIntoStagingAsync(string lua, CancellationToken token) - { - if (string.IsNullOrWhiteSpace(lua)) - return PluginValidationResult.Failure(TB("No Lua plugin code was generated.")); - - if (!PluginFactory.IsInitialized) - return PluginValidationResult.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}"); - - try - { - Directory.CreateDirectory(stagingDirectory); - var stagedPluginFile = Path.Join(stagingDirectory, PLUGIN_FILE_NAME); - await File.WriteAllTextAsync(stagedPluginFile, pluginCode, Encoding.UTF8, token); - - var validation = await ValidatePluginCodeAsync( - stagingDirectory, - pluginCode, - PluginType.ASSISTANT, - TB("The generated plugin is not an assistant plugin. Issue: {0}"), - TB("The generated assistant plugin is invalid. Issue: {0}"), - TB("The generated assistant plugin uses the ID of another installed plugin."), - token); - - if (!validation.Success || validation.AssistantPlugin is null) - this.TryDeleteStagingDirectory(stagingDirectory); - - return validation with { StagingDirectory = stagingDirectory }; - } - catch (Exception e) - { - this.logger.LogError(e, "Failed to validate generated assistant plugin."); - this.TryDeleteStagingDirectory(stagingDirectory); - return PluginValidationResult.Failure(string.Format(TB("Unexpected error: {0}"), e.Message)); - } - } - - private async Task ValidateInPluginDirectoryAsync(string lua, string pluginDirectory, CancellationToken token) - { - if (string.IsNullOrWhiteSpace(lua)) - return PluginValidationResult.Failure(TB("No Lua plugin code was generated.")); - - if (!PluginFactory.IsInitialized) - return PluginValidationResult.Failure(TB("The plugin system is not initialized yet.")); - - try - { - return await ValidatePluginCodeAsync( - pluginDirectory, - lua.Trim(), - PluginType.ASSISTANT, - TB("The edited plugin is not an assistant plugin. Issue: {0}"), - TB("The edited assistant plugin is invalid. Issue: {0}"), - TB("The edited assistant plugin uses the ID of another installed plugin."), - token); - } - catch (Exception e) - { - this.logger.LogError(e, "Failed to validate edited assistant plugin."); - return PluginValidationResult.Failure(string.Format(TB("Unexpected error: {0}"), e.Message)); - } - } - - /// - /// Loads and validates plugin code that is not installed yet. - /// - /// The staging directory the plugin currently lives in. - /// The plugin.lua content to validate. - /// The plugin type the caller accepts. - /// Issue when the plugin has another type. Gets the plugin issues as {0}. - /// Issue when the plugin is of the right type, but invalid. Gets the plugin issues as {0}. - /// Issue when another plugin already uses this plugin ID. - /// Cancellation token for running the Lua code. - /// The validation result, including the loaded plugin when it passed. - private static async Task ValidatePluginCodeAsync(string pluginDirectory, string pluginCode, PluginType expectedType, - string wrongTypeIssue, string invalidPluginIssue, string conflictingPluginIdIssue, CancellationToken token) - { - // The plugin is not installed yet: it sits in a staging directory outside the installed - // plugins directory. We allow that directory as the module base, so the plugin can load its - // own Lua modules, e.g., an icon.lua, while we validate it: - var plugin = await PluginFactory.Load(pluginDirectory, pluginCode, token, pluginDirectory); - if (plugin.Type != expectedType) - return PluginValidationResult.Failure(string.Format(wrongTypeIssue, string.Join("; ", plugin.Issues))); - - if (!plugin.IsValid) - return PluginValidationResult.Failure(string.Format(invalidPluginIssue, string.Join("; ", plugin.Issues))); - - // Plugin IDs must be unique across all plugin types: several lookups resolve a plugin by its - // ID alone, e.g., the base language plugin in PluginFactory.Starting. A plugin carrying the - // ID of a plugin of another type would break those lookups. Reusing the ID of another local - // plugin of the same type stays allowed: that is how updating one works. - if (PluginFactory.AvailablePlugins.Any(availablePlugin => availablePlugin.Id == plugin.Id && (availablePlugin.IsInternal || availablePlugin.Type != expectedType))) - return PluginValidationResult.Failure(conflictingPluginIdIssue); - - return new(true, string.Empty, plugin, string.Empty); - } - - /// - /// Determines the directory local plugins of the given type are installed into. - /// - private static bool TryGetPluginRoot(PluginType pluginType, out string pluginRoot, out string issue) - { - pluginRoot = string.Empty; - issue = string.Empty; - - var dataDirectory = SettingsManager.DataDirectory; - if (string.IsNullOrWhiteSpace(dataDirectory)) - { - issue = TB("The AI Studio data directory is not initialized yet."); - return false; - } - - pluginRoot = Path.Join(dataDirectory, "plugins", pluginType.GetDirectory()); - return true; - } - - private static string GetAssistantDeletionEligibilityIssue(IAvailablePlugin plugin) - { - if (plugin.Type is not PluginType.ASSISTANT) - return TB("Only assistant plugins can be deleted."); - - if (plugin.IsInternal) - return TB("Internal assistant plugins cannot be deleted."); - - if (plugin.IsManagedByConfigServer) - return TB("Config Server managed assistant plugins cannot be deleted."); - - if (string.IsNullOrWhiteSpace(plugin.LocalPath)) - return TB("The assistant plugin has no local directory."); - - var assistantPlugin = PluginFactory.RunningPlugins - .OfType() - .FirstOrDefault(candidate => candidate.Id == plugin.Id && IsSameDirectory(candidate.PluginPath, plugin.LocalPath)); - - if (assistantPlugin is null || assistantPlugin.IsInternal || !assistantPlugin.IsAssistantBuilderGenerated) - return TB("Only assistants generated by the Assistant Builder can be deleted."); - - if (assistantPlugin.IsManagedByConfigServer) - return TB("Config Server managed assistant plugins cannot be deleted."); - - if (!TryGetPluginRoot(PluginType.ASSISTANT, out var assistantPluginsRoot, out var rootIssue)) - return rootIssue; - - if (!IsPathInsideDirectory(assistantPluginsRoot, plugin.LocalPath) || IsSameDirectory(assistantPluginsRoot, plugin.LocalPath)) - return TB("The assistant plugin directory is outside the local assistant plugin directory."); - - return Directory.Exists(plugin.LocalPath) - ? string.Empty - : TB("The assistant plugin directory does not exist."); - } - - private static string GetConfigurationDeletionEligibilityIssue(IAvailablePlugin plugin) - { - if (plugin.Type is not PluginType.CONFIGURATION) - return TB("Only configuration plugins can be deleted this way."); - - if (plugin.IsInternal) - return TB("Internal configuration plugins cannot be deleted."); - - if (string.IsNullOrWhiteSpace(plugin.LocalPath)) - return TB("The configuration plugin has no local directory."); - - // - // We decide by the plugin path, not by IsManagedByConfigServer. That value comes from the - // plugin's own DEPLOYED_USING_CONFIG_SERVER field: a locally placed plugin could declare - // itself managed and would then be impossible to remove through the user interface, which - // is exactly the situation this deletion is meant to resolve. - // - if (PluginFactory.IsEnterpriseConfigurationPath(plugin.LocalPath)) - return TB("Configuration plugins deployed by your organization cannot be deleted."); - - if (!PluginFactory.IsInsidePluginsRoot(plugin.LocalPath)) - return TB("The configuration plugin directory is outside the plugins directory."); - - return Directory.Exists(plugin.LocalPath) - ? string.Empty - : TB("The configuration plugin directory does not exist."); - } - - private void TryDeleteStagingDirectory(string stagingDirectory) => TryDeleteDirectory(stagingDirectory, "assistant plugin staging", this.logger); - - private static string DetermineFinalDirectory(string pluginRoot, IPluginMetadata plugin, PluginType pluginType) - { - var existingPlugin = FindReplaceablePlugin(plugin.Id, pluginType); - return existingPlugin is not null - ? existingPlugin.LocalPath - : Path.Join(pluginRoot, CreatePluginDirectoryName(plugin)); - } - - /// - /// Finds the local plugin that an installation with the given ID and type would replace. - /// - /// The ID of the plugin about to be installed. - /// The type of the plugin about to be installed. - /// The plugin that would be replaced, or null when the installation adds a new plugin. - private static IAvailablePlugin? FindReplaceablePlugin(Guid pluginId, PluginType pluginType) => PluginFactory.AvailablePlugins - .OfType() - .FirstOrDefault(plugin => plugin.Type == pluginType && plugin.Id == pluginId && !plugin.IsInternal); - - /// - /// Collects the metadata an archive declares about itself, together with the information about - /// the installed plugin it would replace. - /// - /// The validated plugin from the archive. - /// The preview shown to the user before the installation starts. - private static PluginImportPreview CreateImportPreview(PluginBase plugin) => new(plugin, FindReplaceablePlugin(plugin.Id, plugin.Type)); - - /// - /// Checks whether an installation may replace the plugin that currently uses the given ID. - /// Plugins deployed by a Config Server belong to the organization's IT, so neither an import nor - /// the Assistant Builder may overwrite them. - /// - /// The ID of the plugin about to be installed. - /// The type of the plugin about to be installed. - /// A user-facing issue when the existing plugin must not be replaced, an empty string otherwise. - private static string GetReplacementIssue(Guid pluginId, PluginType pluginType) - { - var existingPlugin = FindReplaceablePlugin(pluginId, pluginType); - if (existingPlugin is null) - return string.Empty; - - if (existingPlugin.IsManagedByConfigServer) - return TB("Plugins deployed by your organization cannot be replaced."); - - if (string.IsNullOrWhiteSpace(existingPlugin.LocalPath)) - return string.Empty; - - // The metadata above and the running plugin read the same Lua field. We check both, though, - // just like the deletion path does: - var runningPlugin = PluginFactory.RunningPlugins - .FirstOrDefault(candidate => candidate.Id == pluginId && IsSameDirectory(candidate.PluginPath, existingPlugin.LocalPath)); - - var isManagedByConfigServer = runningPlugin switch - { - PluginAssistants assistantPlugin => assistantPlugin.IsManagedByConfigServer, - PluginConfiguration configurationPlugin => configurationPlugin.DeployedUsingConfigServer ?? false, - - _ => false, - }; - - return isManagedByConfigServer - ? TB("Plugins deployed by your organization cannot be replaced.") - : string.Empty; - } - - private static string CreatePluginDirectoryName(IPluginMetadata plugin) - { - var safeName = CreateSafeDirectoryNamePart(plugin.Name); - return $"{safeName}-{plugin.Id:N}"; - } - - private static string CreateSafeDirectoryNamePart(string name) - { - var sb = new StringBuilder(); - var invalidChars = Path.GetInvalidFileNameChars().ToHashSet(); - - foreach (var character in name.Trim()) - { - if (char.IsLetterOrDigit(character)) - { - sb.Append(char.ToLowerInvariant(character)); - continue; - } - - if (character is '-' or '_' or '.' && !invalidChars.Contains(character)) - { - sb.Append(character); - continue; - } - - AppendSeparator(); - } - - var safeName = sb.ToString().Trim('-', '.'); - if (safeName.Length > DIRECTORY_PREFIX_MAX_LEN) - safeName = safeName[..DIRECTORY_PREFIX_MAX_LEN].Trim('-', '.'); - - // Fallback for a plugin name without any usable character. The plugin ID is appended by the - // caller, so the directory stays unique either way: - return string.IsNullOrWhiteSpace(safeName) - ? "plugin" - : safeName; - - void AppendSeparator() - { - if (sb.Length == 0 || sb[^1] == '-') - return; - - sb.Append('-'); - } - } - - private static bool IsPathInsideDirectory(string parentDirectory, string path) - { - var parentPath = Path.GetFullPath(parentDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; - var childPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; - 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, string pluginKind) - { - var backupRoot = Path.Join(SettingsManager.DataDirectory, DELETE_BACKUP_DIRECTORY); - return Path.Join(backupRoot, $"{pluginKind}-{plugin.Id:N}-{Guid.NewGuid():N}"); - } - - private static string CreateInstallBackupDirectory(IPluginMetadata plugin) - { - var backupRoot = Path.Join(SettingsManager.DataDirectory, INSTALL_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 async Task TryRestoreDeletedConfigurationPluginAsync(IAvailablePlugin plugin, string pluginDirectory, string backupDirectory, CancellationToken token) - { - try - { - if (!Directory.Exists(pluginDirectory) && Directory.Exists(backupDirectory)) - Directory.Move(backupDirectory, pluginDirectory); - - // The reload restores everything the plugin configured, because it is back in place: - await PluginFactory.LoadAll(token); - } - catch (Exception restoreException) - { - this.logger.LogError(restoreException, $"Failed to restore configuration plugin '{plugin.Name}' ({plugin.Id}) after a failed delete."); - } - } - - private async Task TryRestoreEditedAssistantPluginAsync(string pluginFile, string backupFile, CancellationToken token) - { - try - { - if (string.IsNullOrWhiteSpace(backupFile) || !File.Exists(backupFile)) - return; - - if (File.Exists(pluginFile)) - File.Delete(pluginFile); - - File.Move(backupFile, pluginFile); - await PluginFactory.LoadAll(token); - } - catch (Exception restoreException) - { - this.logger.LogError(restoreException, $"Failed to restore assistant plugin file '{pluginFile}' after a failed edit."); - } - } - - 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 void TryDeleteFile(string filePath, string fileDescription) - { - if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) - return; - - try - { - File.Delete(filePath); - } - catch (Exception e) - { - this.logger.LogError(e, $"Failed to delete {fileDescription} '{filePath}'."); - } - } - private sealed record PluginValidationResult(bool Success, string StagingDirectory, PluginBase? Plugin, string Issue) { public static PluginValidationResult Failure(string issue) => new(false, string.Empty, null, issue);