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);