using AIStudio.Settings; using AIStudio.Settings.DataModel; using AIStudio.Tools.Media; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem.Assistants; namespace AIStudio.Tools.Services; public sealed partial class PluginInstallService { /// /// Checks whether an assistant plugin is one that users may delete. /// public static bool CanDeleteInstalledAssistant(IAvailablePlugin plugin) => string.IsNullOrWhiteSpace(GetAssistantDeletionEligibilityIssue(plugin)); /// /// The plugin types users may remove through the user interface, besides assistants. /// /// /// Assistants have their own path, because a running assistant must not be pulled away from /// under a user and because removing one also removes its security audit. /// private static readonly PluginType[] DELETABLE_LOCAL_PLUGIN_TYPES = [PluginType.CONFIGURATION, PluginType.LANGUAGE]; /// /// Checks whether a plugin is a local configuration or language plugin that users may delete. /// public static bool CanDeleteLocalPlugin(IAvailablePlugin plugin) => string.IsNullOrWhiteSpace(GetLocalPluginDeletionEligibilityIssue(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 or language 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. /// /// /// For a configuration plugin, we do not remove its providers, data sources, chat templates, /// profiles, or locked settings 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.

/// What the reload cannot recognize as left over is everything the user decided about the plugin /// itself: its activation state and, for a language plugin, the language choice. Those are /// removed here. ///
/// Plugin metadata of the configuration or language plugin. /// 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 DeleteLocalPluginAsync(IAvailablePlugin plugin, CancellationToken token) { var eligibilityIssue = GetLocalPluginDeletionEligibilityIssue(plugin); if (!string.IsNullOrEmpty(eligibilityIssue)) return DeleteError(plugin, plugin.LocalPath, eligibilityIssue); await this.installSemaphore.WaitAsync(token); var pluginDirectory = plugin.LocalPath; var backupDirectory = string.Empty; var wasEnabled = false; var wasChosenLanguage = false; try { // Check again under the semaphore: another operation might have changed the plugin state // while we were waiting: eligibilityIssue = GetLocalPluginDeletionEligibilityIssue(plugin); if (!string.IsNullOrEmpty(eligibilityIssue)) return DeleteError(plugin, pluginDirectory, eligibilityIssue); backupDirectory = CreateDeleteBackupDirectory(plugin, plugin.Type is PluginType.LANGUAGE ? "language" : "configuration"); Directory.CreateDirectory(Path.GetDirectoryName(backupDirectory)!); Directory.Move(pluginDirectory, backupDirectory); // // Nothing removes the activation state of a plugin which is gone. Should the user install // a plugin with the same ID again later, it would start enabled without ever having been // switched on. We ask for removal regardless of the plugin type: a configuration plugin // is never listed there, so this simply does nothing for it: // wasEnabled = this.settingsManager.ConfigurationData.EnabledPlugins.Remove(plugin.Id); // // When the user had chosen this language plugin, the app would silently fall back to // English while the settings still point to the deleted plugin. We return the language // choice to automatic instead, so the settings stay truthful: // wasChosenLanguage = plugin.Type is PluginType.LANGUAGE && this.settingsManager.ConfigurationData.App.LanguagePluginId == plugin.Id; if (wasChosenLanguage) { this.settingsManager.ConfigurationData.App.LanguageBehavior = LangBehavior.AUTO; this.settingsManager.ConfigurationData.App.LanguagePluginId = Guid.Empty; } if (wasEnabled || wasChosenLanguage) await this.settingsManager.StoreSettings(); await PluginFactory.LoadAll(token); TryDeleteDirectory(backupDirectory, "local plugin delete backup", this.logger); this.logger.LogInformation($"Deleted {plugin.Type} 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 {plugin.Type} plugin '{plugin.Name}' ({plugin.Id}) from '{pluginDirectory}'."); await this.TryRestoreDeletedLocalPluginAsync(plugin, pluginDirectory, backupDirectory, wasEnabled, wasChosenLanguage, 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 (string.IsNullOrWhiteSpace(plugin.LocalPath)) return TB("The assistant plugin has no local directory."); // // Like for every other plugin type, the plugin path decides, not what the plugin declares // about itself. Neither DEPLOYED_USING_CONFIG_SERVER nor the Assistant Builder metadata is // suitable here: a locally placed assistant could declare itself as deployed by an // organization, or simply omit the builder metadata, and would then be impossible to remove // through the user interface. Editing, revising, and sharing an assistant already follow the // path, so deleting one must not be the single action that trusts the plugin's own claims. // if (PluginFactory.IsEnterpriseConfigurationPath(plugin.LocalPath)) return TB("Config Server managed assistant plugins cannot be deleted."); if (!PluginFactory.IsInsidePluginsRoot(plugin.LocalPath) || PluginFactory.IsPluginsRoot(plugin.LocalPath)) return TB("The assistant plugin directory is outside the plugins directory."); return Directory.Exists(plugin.LocalPath) ? string.Empty : TB("The assistant plugin directory does not exist."); } private static string GetLocalPluginDeletionEligibilityIssue(IAvailablePlugin plugin) { if (!DELETABLE_LOCAL_PLUGIN_TYPES.Contains(plugin.Type)) return TB("Only configuration and language plugins can be deleted this way."); if (plugin.IsInternal) return TB("Plugins shipped with AI Studio cannot be deleted."); if (string.IsNullOrWhiteSpace(plugin.LocalPath)) return TB("The 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("Plugins deployed by your organization cannot be deleted."); if (!PluginFactory.IsInsidePluginsRoot(plugin.LocalPath) || PluginFactory.IsPluginsRoot(plugin.LocalPath)) return TB("The plugin directory is outside the plugins directory."); return Directory.Exists(plugin.LocalPath) ? string.Empty : TB("The 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 TryRestoreDeletedLocalPluginAsync(IAvailablePlugin plugin, string pluginDirectory, string backupDirectory, bool wasEnabled, bool wasChosenLanguage, 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 (wasChosenLanguage) { this.settingsManager.ConfigurationData.App.LanguageBehavior = LangBehavior.MANUAL; this.settingsManager.ConfigurationData.App.LanguagePluginId = plugin.Id; } if (wasEnabled || wasChosenLanguage) await this.settingsManager.StoreSettings(); // 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 {plugin.Type} plugin '{plugin.Name}' ({plugin.Id}) after a failed delete."); } } }