From 91506f83146d4fcfe3277cfc07201abc6aa4d092 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Thu, 6 Aug 2026 20:03:09 +0200 Subject: [PATCH] Kept managed settings untouched when a configuration plugin is deployed but cannot be loaded --- .../Settings/ManagedConfiguration.cs | 26 +++-- .../PluginSystem/PluginConfigurationObject.cs | 18 +++- .../PluginSystem/PluginFactory.Loading.cs | 100 ++++++++++++++---- .../wwwroot/changelog/v26.8.1.md | 1 + .../2026-08-orphaned-config-locks.md | 2 + 5 files changed, 119 insertions(+), 28 deletions(-) diff --git a/app/MindWork AI Studio/Settings/ManagedConfiguration.cs b/app/MindWork AI Studio/Settings/ManagedConfiguration.cs index 174aa001..e92ac103 100644 --- a/app/MindWork AI Studio/Settings/ManagedConfiguration.cs +++ b/app/MindWork AI Studio/Settings/ManagedConfiguration.cs @@ -269,8 +269,12 @@ public static partial class ManagedConfiguration /// change it at all. /// /// The collection of available plugins to check against. + /// + /// The IDs of all configuration plugins which are deployed on this machine, including those which + /// could not be loaded. A deployed plugin was not removed, so its settings must stay untouched. + /// /// True when at least one setting was changed, otherwise false. - public static bool CleanupLeftOverManagedConfigurations(IReadOnlyCollection availablePlugins) + public static bool CleanupLeftOverManagedConfigurations(IReadOnlyCollection availablePlugins, IReadOnlySet deployedConfigPluginIds) { var wasChanged = false; var registeredSettingNames = new HashSet(StringComparer.Ordinal); @@ -289,7 +293,7 @@ public static partial class ManagedConfiguration configMeta.RestoreLockedConfiguration(); // Check the locked state: - if (configMeta.IsLocked && configMeta.LockedByConfigPluginId != Guid.Empty && !IsPluginAvailable(configMeta.LockedByConfigPluginId, availablePlugins)) + if (configMeta.IsLocked && configMeta.LockedByConfigPluginId != Guid.Empty && !IsPluginPresent(configMeta.LockedByConfigPluginId, availablePlugins, deployedConfigPluginIds)) { Log.LogInformation($"Resetting the setting '{configMeta.SettingName}': it was locked by the configuration plugin '{configMeta.LockedByConfigPluginId}', which is not available anymore."); configMeta.ResetLockedConfiguration(); @@ -297,11 +301,11 @@ public static partial class ManagedConfiguration } // Check the editable default state: - if (CleanupEditableDefaultState(configMeta, availablePlugins)) + if (CleanupEditableDefaultState(configMeta, availablePlugins, deployedConfigPluginIds)) wasChanged = true; // Check the additive plugin contribution: - if (configMeta.HasPluginContribution && configMeta.PluginContributionByConfigPluginId != Guid.Empty && !IsPluginAvailable(configMeta.PluginContributionByConfigPluginId, availablePlugins)) + if (configMeta.HasPluginContribution && configMeta.PluginContributionByConfigPluginId != Guid.Empty && !IsPluginPresent(configMeta.PluginContributionByConfigPluginId, availablePlugins, deployedConfigPluginIds)) { Log.LogInformation($"Clearing the plugin contribution for the setting '{configMeta.SettingName}': the configuration plugin '{configMeta.PluginContributionByConfigPluginId}' is not available anymore."); configMeta.ClearPluginContribution(); @@ -316,7 +320,15 @@ public static partial class ManagedConfiguration return wasChanged; } - private static bool IsPluginAvailable(Guid configPluginId, IReadOnlyCollection availablePlugins) => availablePlugins.Any(x => x.Id == configPluginId); + /// + /// Checks whether a configuration plugin is still present on this machine. + /// + /// + /// A plugin counts as present when it was loaded, or when it is deployed but could not be loaded. + /// The latter matters for organizations: a broken configuration plugin is still in charge, so we + /// must not treat its settings as left over. + /// + private static bool IsPluginPresent(Guid configPluginId, IReadOnlyCollection availablePlugins, IReadOnlySet deployedConfigPluginIds) => deployedConfigPluginIds.Contains(configPluginId) || availablePlugins.Any(x => x.Id == configPluginId); /// /// Removes persisted managed states which belong to settings that are not registered anymore. @@ -378,7 +390,7 @@ public static partial class ManagedConfiguration private static bool ClearEditableDefaultState(string settingName) => SettingsManagerAccess.ConfigurationData.ManagedEditableDefaults.Remove(settingName); - private static bool CleanupEditableDefaultState(ConfigMetaBase configMeta, IReadOnlyCollection availablePlugins) + private static bool CleanupEditableDefaultState(ConfigMetaBase configMeta, IReadOnlyCollection availablePlugins, IReadOnlySet deployedConfigPluginIds) { if (!TryGetEditableDefaultState(configMeta.SettingName, out var editableDefaultState)) { @@ -389,7 +401,7 @@ public static partial class ManagedConfiguration return true; } - if (IsPluginAvailable(editableDefaultState.ConfigPluginId, availablePlugins)) + if (IsPluginPresent(editableDefaultState.ConfigPluginId, availablePlugins, deployedConfigPluginIds)) return false; Log.LogInformation($"Clearing the editable default of the setting '{configMeta.SettingName}': the configuration plugin '{editableDefaultState.ConfigPluginId}' is not available anymore."); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs index 620ca5a7..b3b69d21 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs @@ -255,6 +255,11 @@ public sealed record PluginConfigurationObject /// The type of configuration object to process. /// A selection expression to retrieve the configuration objects from the main configuration. /// A list of currently available plugins. + /// + /// The IDs of all configuration plugins which are deployed on this machine, including those which + /// could not be loaded. Objects of a deployed plugin are never removed, because the plugin was not + /// removed either. + /// /// A list of all existing configuration objects. /// An optional parameter specifying the type of secret store to use for deleting associated API keys from the OS keyring, if applicable. /// When true, delete the associated non-API-key secret from the OS keyring. @@ -263,6 +268,7 @@ public sealed record PluginConfigurationObject PluginConfigurationObjectType configObjectType, Expression>> configObjectSelection, IList availablePlugins, + IReadOnlySet deployedConfigPluginIds, IList configObjectList, SecretStoreType? secretStoreType = null, bool deleteSecret = false) where TClass : IConfigurationObject @@ -281,7 +287,17 @@ public sealed record PluginConfigurationObject var configObjectSourcePluginId = configuredObject.EnterpriseConfigurationPluginId; if(configObjectSourcePluginId == Guid.Empty) continue; - + + // + // Is the source plugin deployed, but could not be loaded? Then we must not touch any of + // its objects. The plugin was not removed, it is broken: it might be invalid Lua code, + // a missing `plugin.lua`, or an incomplete download. Removing the objects would delete + // the organization's providers and data sources, including their secrets, although the + // organization still manages this AI Studio instance: + // + if(deployedConfigPluginIds.Contains(configObjectSourcePluginId) && availablePlugins.All(plugin => plugin.Id != configObjectSourcePluginId)) + continue; + // Is the source plugin still available? If not, we can be pretty sure that this configuration object is left // over and should be removed: var templateSourcePlugin = availablePlugins.FirstOrDefault(plugin => plugin.Id == configObjectSourcePluginId); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs index fc12ef11..6e026533 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs @@ -46,19 +46,23 @@ public static partial class PluginFactory try { LOG.LogInformation("Start loading plugins."); - if (!Directory.Exists(PLUGINS_ROOT)) - { - LOG.LogInformation("No plugins found."); - return; - } - + + // + // Without the plugins directory, we cannot load or start any plugin. Still, we must not + // stop here: the clean-up at the end of this method has to run. Otherwise, settings which + // a configuration plugin has locked would stay locked forever. + // + var pluginsDirectoryExists = Directory.Exists(PLUGINS_ROOT); + if (!pluginsDirectoryExists) + LOG.LogWarning("No plugins found. Checking for left-over configurations of removed configuration plugins."); + AVAILABLE_PLUGINS.Clear(); - + // // The easiest way to load all plugins is to find all `plugin.lua` files and load them. // By convention, each plugin is enforced to have a `plugin.lua` file. // - var pluginMainFiles = Directory.EnumerateFiles(PLUGINS_ROOT, "plugin.lua", SearchOption.AllDirectories); + IEnumerable pluginMainFiles = pluginsDirectoryExists ? Directory.EnumerateFiles(PLUGINS_ROOT, "plugin.lua", SearchOption.AllDirectories) : []; foreach (var pluginMainFile in pluginMainFiles) { try @@ -151,8 +155,11 @@ public static partial class PluginFactory } // Start or restart all plugins: - var configObjects = await RestartAllPlugins(cancellationToken); - configObjectList.AddRange(configObjects); + if (pluginsDirectoryExists) + { + var configObjects = await RestartAllPlugins(cancellationToken); + configObjectList.AddRange(configObjects); + } } finally { @@ -168,31 +175,41 @@ public static partial class PluginFactory // ========================================================= // + // + // Configuration plugins which are deployed but could not be loaded count as present: they + // were not removed, so everything they manage must stay as it is. Otherwise, one broken + // configuration plugin would wipe the entire organization configuration: + // + var deployedConfigPluginIds = GetDeployedConfigPluginIds(); + var unloadedConfigPluginIds = deployedConfigPluginIds.Where(x => AVAILABLE_PLUGINS.All(plugin => plugin.Id != x)).ToList(); + foreach (var unloadedConfigPluginId in unloadedConfigPluginIds) + LOG.LogWarning($"The configuration plugin '{unloadedConfigPluginId}' is deployed, but was not loaded. Everything it manages stays unchanged, because the plugin was not removed. Please check the errors above and fix the plugin."); + // Check LLM providers: - var wasConfigurationChanged = await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.LLM_PROVIDER); + var wasConfigurationChanged = await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, AVAILABLE_PLUGINS, deployedConfigPluginIds, configObjectList, SecretStoreType.LLM_PROVIDER); // Check transcription providers: - if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.TRANSCRIPTION_PROVIDER)) + if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, AVAILABLE_PLUGINS, deployedConfigPluginIds, configObjectList, SecretStoreType.TRANSCRIPTION_PROVIDER)) wasConfigurationChanged = true; // Check embedding providers: - if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.EMBEDDING_PROVIDER)) + if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, AVAILABLE_PLUGINS, deployedConfigPluginIds, configObjectList, SecretStoreType.EMBEDDING_PROVIDER)) wasConfigurationChanged = true; // Check data sources: - if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DATA_SOURCE, x => x.DataSources, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.DATA_SOURCE, deleteSecret: true)) + if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DATA_SOURCE, x => x.DataSources, AVAILABLE_PLUGINS, deployedConfigPluginIds, configObjectList, SecretStoreType.DATA_SOURCE, deleteSecret: true)) wasConfigurationChanged = true; // Check chat templates: - if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, AVAILABLE_PLUGINS, configObjectList)) + if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, AVAILABLE_PLUGINS, deployedConfigPluginIds, configObjectList)) wasConfigurationChanged = true; // Check profiles: - if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.PROFILE, x => x.Profiles, AVAILABLE_PLUGINS, configObjectList)) + if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.PROFILE, x => x.Profiles, AVAILABLE_PLUGINS, deployedConfigPluginIds, configObjectList)) wasConfigurationChanged = true; // Check document analysis policies: - if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, AVAILABLE_PLUGINS, configObjectList)) + if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, AVAILABLE_PLUGINS, deployedConfigPluginIds, configObjectList)) wasConfigurationChanged = true; // Check left-over mandatory info acceptances: @@ -201,11 +218,11 @@ public static partial class PluginFactory // Check all managed settings, i.e. settings which a configuration plugin can lock, // provide as an editable default, or contribute to: - if(ManagedConfiguration.CleanupLeftOverManagedConfigurations(AVAILABLE_PLUGINS)) + if(ManagedConfiguration.CleanupLeftOverManagedConfigurations(AVAILABLE_PLUGINS, deployedConfigPluginIds)) wasConfigurationChanged = true; // Compatibility shim, see documentation/compatibility-shims/2026-08-orphaned-config-locks.md (remove after 2027-08-06): - if (RepairLegacyConfigOnlySettings()) + if (RepairLegacyConfigOnlySettings(unloadedConfigPluginIds.Count > 0)) wasConfigurationChanged = true; if (wasConfigurationChanged) @@ -215,6 +232,38 @@ public static partial class PluginFactory } } + /// + /// Determines the IDs of all configuration plugins which are deployed on this machine. + /// + /// + /// We read these IDs from the file system instead of taking them from the loaded plugins. A + /// configuration plugin might be present but not loadable, e.g. due to invalid Lua code, a + /// missing `plugin.lua`, or an incomplete download. Such a plugin still manages this AI Studio + /// instance, so we must not treat its settings as left over. Configuration plugins deployed by a + /// configuration server live in a directory named after their ID, which is the only information + /// left when the plugin itself cannot be read. + /// + private static HashSet GetDeployedConfigPluginIds() + { + var deployedConfigPluginIds = new HashSet(); + if (!Directory.Exists(CONFIGURATION_PLUGINS_ROOT)) + return deployedConfigPluginIds; + + foreach (var configPluginDirectory in Directory.EnumerateDirectories(CONFIGURATION_PLUGINS_ROOT)) + { + if (!Guid.TryParse(Path.GetFileName(configPluginDirectory), out var configPluginId) || configPluginId == Guid.Empty) + continue; + + // An empty directory is a left-over of a removed plugin, not a deployed plugin: + if (!Directory.EnumerateFileSystemEntries(configPluginDirectory).Any()) + continue; + + deployedConfigPluginIds.Add(configPluginId); + } + + return deployedConfigPluginIds; + } + /// The directory the plugin is located in, or null when the code has no directory yet. /// The Lua code of the plugin's main file. /// Cancellation token for running the Lua code. @@ -316,9 +365,20 @@ public static partial class PluginFactory /// This is only valid as long as none of these settings gets a user interface. When you add /// one, remove the setting from this method and from the shim's document. /// + /// + /// True when at least one configuration plugin is deployed but could not be loaded. In that case, + /// we cannot tell whether a value comes from that plugin or from a removed one, so we repair + /// nothing at all. + /// /// True when at least one setting was repaired, otherwise false. - private static bool RepairLegacyConfigOnlySettings() + private static bool RepairLegacyConfigOnlySettings(bool hasUnloadedConfigPlugins) { + if (hasUnloadedConfigPlugins) + { + LOG.LogWarning("Skipping the repair of configuration-only settings: at least one configuration plugin is deployed, but could not be loaded. We try again the next time AI Studio starts."); + return false; + } + var data = SettingsManagerAccess.ConfigurationData; var wasRepaired = false; diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md index 813c6e17..1ea9e7bc 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md @@ -6,6 +6,7 @@ - Added the option to import plugins by dropping a plugin archive onto the plugin page. - Added the dedicated file extension `.mwplugin` for plugin archives. - Added an option for organizations to disable importing, sharing, and exporting plugins. +- Improved how your organization's configuration behaves when a configuration plugin is present but cannot be loaded, e.g. because of an error in the plugin. Such a plugin still manages your app, so its settings, providers, data sources, profiles, and chat templates now stay in place instead of being removed. - Fixed reset buttons in assistants. As you may have noticed in the Document Analysis Assistant, resetting it could leave content from the previous analysis visible. Reset buttons now clear previous results completely. - Fixed dropping files after you closed a dialog that accepts files itself. Such a dialog takes over dropped files while it is open, but never handed that role back when you closed it. Afterwards, the chat and the assistants silently ignored dropped files until you switched to another page. Each time you opened such a dialog again, the problem got worse. - Fixed configuration-managed settings remaining active after their configuration plugin was removed. diff --git a/documentation/compatibility-shims/2026-08-orphaned-config-locks.md b/documentation/compatibility-shims/2026-08-orphaned-config-locks.md index 577962e2..1d39f96c 100644 --- a/documentation/compatibility-shims/2026-08-orphaned-config-locks.md +++ b/documentation/compatibility-shims/2026-08-orphaned-config-locks.md @@ -23,6 +23,8 @@ At the end of `PluginFactory.LoadAll`, AI Studio checks a fixed list of settings Repairing means restoring the default value. Each repair is logged as a warning. +Nothing is repaired at all while a configuration plugin is deployed but could not be loaded, e.g. because of invalid Lua code. In that situation, we cannot tell whether a value comes from that plugin or from a removed one, so the repair is postponed to the next start. + The check runs on every start, not once. This is safe because none of these settings has a user interface that writes to it, so a non-default value can only originate from a configuration plugin. This is the load-bearing assumption of the whole shim: as soon as one of these settings gets a user interface, the shim would overwrite the user's choice on every start. In that case, remove the setting from `RepairLegacyConfigOnlySettings` and from the list above. Settings that a configuration plugin can lock but that users can change themselves are deliberately not part of this list. Their owner is persisted from this release on, and the regular left-over cleanup handles them.