using System.Text; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem.Assistants; using AIStudio.Tools.Rust; namespace AIStudio.Tools.Services; public sealed partial class PluginInstallService { /// /// The plugin types a user may import from an archive. /// private static readonly PluginType[] IMPORTABLE_PLUGIN_TYPES = [PluginType.ASSISTANT, PluginType.CONFIGURATION, PluginType.LANGUAGE]; /// /// Installs a 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 (!PluginFactory.IsInitialized) return Error(TB("The plugin system is not initialized yet.")); await this.installSemaphore.WaitAsync(token); var stagingDirectory = Path.Join(Path.GetTempPath(), $"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(), IMPORTABLE_PLUGIN_TYPES, TB("Only assistant, configuration, and language plugins can be imported."), TB("The imported plugin is invalid. Issue: {0}"), TB("The imported plugin uses the ID of another installed plugin."), token); if (!validation.Success || validation.Plugin is null) return Error(validation.Issue); var plugin = validation.Plugin; var eligibilityIssue = this.GetImportEligibilityIssue(plugin); if (!string.IsNullOrEmpty(eligibilityIssue)) return Error(eligibilityIssue); // 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(plugin.Id, plugin.Type); if (!string.IsNullOrEmpty(replacementIssue)) return Error(replacementIssue); // Local plugins live in the directory of their type, never in the enterprise // configuration directory. Only a config server deploys plugins there: if (!TryGetPluginRoot(plugin.Type, out var pluginRoot, out var rootIssue)) return Error(rootIssue); // 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(plugin))) return CancelledByUser(); return await this.InstallStagedPluginAsync(pluginRoot, validation with { StagingDirectory = pluginDirectory }, plugin.Type, token); } catch (Exception e) when (e is not OperationCanceledException) { this.logger.LogError(e, "Failed to extract or validate plugin archive '{ArchivePath}'.", archivePath); return Error(string.Format(TB("Unexpected error: {0}"), e.Message)); } finally { this.TryDeleteStagingDirectory(stagingDirectory); this.installSemaphore.Release(); } } /// /// Checks the rules that depend on the type of the plugin inside the archive. /// /// The validated plugin from the archive. /// A user-facing issue when the archive must not be installed, an empty string otherwise. private string GetImportEligibilityIssue(PluginBase plugin) => plugin switch { // 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: PluginAssistants { IsManagedByConfigServer: true } => TB("This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins."), PluginConfiguration configurationPlugin => this.GetConfigurationImportEligibilityIssue(configurationPlugin), _ => string.Empty, }; /// /// Checks the additional rules for importing a configuration plugin. /// /// /// A configuration takes effect immediately and has no on/off switch, so it gets its own /// organization permission on top of the general import permission. /// private string GetConfigurationImportEligibilityIssue(PluginConfiguration configurationPlugin) { if (!this.settingsManager.ConfigurationData.App.AllowUserToImportConfigurationPlugins) return TB("Your organization has disabled importing configuration plugins."); if (configurationPlugin.DeployedUsingConfigServer is true) return TB("This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins."); // Never let an imported configuration take the place of one the organization deployed. This // also covers a deployed configuration which currently cannot be loaded, e.g. because of an // error in its Lua code: if (PluginFactory.IsEnterpriseConfigurationPlugin(configurationPlugin.Id)) return TB("Your organization deployed a configuration with the same ID. An imported configuration must not take its place."); return string.Empty; } }