mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-13 12:33:48 +00:00
Added priorities for configuration plugins and fixed how they collide (#899)
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
This commit is contained in:
1 parent
d1a6781ea6
commit
0eb747b386
17 files changed
+562
-104
No files matched your search
@@ -5,6 +5,17 @@ public interface IAvailablePlugin : IPluginMetadata
|
||||
public string LocalPath { get; }
|
||||
|
||||
public bool IsManagedByConfigServer { get; }
|
||||
|
||||
|
||||
public Guid? ManagedConfigurationId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The priority of a configuration plugin. Zero for every other plugin type.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Configuration plugins with a higher priority start later and therefore win when two of them
|
||||
/// manage the same setting or define the same configuration object. The priority only orders
|
||||
/// plugins of the same origin: a local configuration plugin never starts before one which an
|
||||
/// organization deployed, no matter which priority it declares.
|
||||
/// </remarks>
|
||||
public int ConfigurationPriority { get; }
|
||||
}
|
||||
@@ -38,6 +38,17 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
/// True/false when explicitly configured in the plugin, otherwise null.
|
||||
/// </summary>
|
||||
public bool? DeployedUsingConfigServer { get; } = ReadDeployedUsingConfigServer(state);
|
||||
|
||||
/// <summary>
|
||||
/// The priority of this configuration plugin. Defaults to zero when the plugin declares none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Configuration plugins with a higher priority are applied later and therefore win when two of
|
||||
/// them manage the same setting or define the same configuration object. This lets an
|
||||
/// organization deploy one base configuration for everybody and additional configurations which
|
||||
/// refine it, e.g. per department.
|
||||
/// </remarks>
|
||||
public int Priority { get; } = ReadPriority(state);
|
||||
|
||||
public async Task InitializeAsync(bool dryRun)
|
||||
{
|
||||
@@ -129,6 +140,14 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ReadPriority(LuaState state)
|
||||
{
|
||||
if (state.Environment["PRIORITY"].TryRead<int>(out var priority))
|
||||
return priority;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to initialize the UI text content of the plugin.
|
||||
/// </summary>
|
||||
@@ -339,16 +358,67 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
switch (successful)
|
||||
{
|
||||
case true:
|
||||
configMeta.SetValue(configuredApprovals);
|
||||
//
|
||||
// Approvals of several configuration plugins add up. An approval list is a pure
|
||||
// allowlist over hashes: not listing a plugin already means "not approved", so
|
||||
// replacing the list would only ever withdraw the approvals of another
|
||||
// configuration without expressing anything new.
|
||||
//
|
||||
configMeta.SetPluginContribution(configuredApprovals, this.Id);
|
||||
|
||||
// Merge into the stored list right away, so the approvals of this plugin take
|
||||
// effect immediately. PluginFactory.LoadAll recomputes the authoritative list once
|
||||
// every configuration plugin has contributed:
|
||||
var mergedApprovals = new List<DataAssistantPluginEnterpriseApproval>(configMeta.GetValue());
|
||||
var knownHashes = mergedApprovals.Select(approval => approval.PluginHash).ToHashSet(StringComparer.Ordinal);
|
||||
mergedApprovals.AddRange(configuredApprovals.Where(approval => knownHashes.Add(approval.PluginHash)));
|
||||
|
||||
configMeta.SetValue(mergedApprovals);
|
||||
configMeta.LockConfiguration(this.Id);
|
||||
break;
|
||||
|
||||
case false when configMeta.IsLocked && configMeta.LockedByConfigPluginId == this.Id:
|
||||
configMeta.RemovePluginContribution(this.Id);
|
||||
configMeta.ResetLockedConfiguration();
|
||||
break;
|
||||
|
||||
case false:
|
||||
configMeta.RemovePluginContribution(this.Id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes the effective enterprise approvals from the contributions of all configuration plugins.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every configuration plugin merges its own approvals into the stored list while it starts, but
|
||||
/// nothing there can withdraw the approvals of a plugin which was removed in the meantime. This
|
||||
/// method rebuilds the list from the remaining contributions and is therefore called once all
|
||||
/// configuration plugins have been started.
|
||||
/// </remarks>
|
||||
/// <returns>True when the effective approvals changed, otherwise false.</returns>
|
||||
public static bool RefreshEnterpriseApprovedAssistantPlugins()
|
||||
{
|
||||
if (!ManagedConfiguration.TryGet(x => x.AssistantPluginAudit, x => x.EnterpriseApprovedPlugins, out ConfigMeta<DataAssistantPluginAudit, IList<DataAssistantPluginEnterpriseApproval>> configMeta))
|
||||
return false;
|
||||
|
||||
var effectiveApprovals = new List<DataAssistantPluginEnterpriseApproval>();
|
||||
var effectiveHashes = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var approval in configMeta.PluginContributions.Values.SelectMany(contribution => contribution))
|
||||
if (effectiveHashes.Add(approval.PluginHash))
|
||||
effectiveApprovals.Add(approval);
|
||||
|
||||
// Compare by hash, so a different order alone does not rewrite the settings on every start:
|
||||
var currentApprovals = configMeta.GetValue();
|
||||
if (currentApprovals.Count == effectiveApprovals.Count && effectiveHashes.SetEquals(currentApprovals.Select(approval => approval.PluginHash)))
|
||||
return false;
|
||||
|
||||
LOG.LogInformation($"The enterprise approvals for assistant plugins changed from {currentApprovals.Count} to {effectiveApprovals.Count} entries, contributed by {configMeta.PluginContributions.Count} configuration plugin(s).");
|
||||
configMeta.SetValue(effectiveApprovals);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseEnterpriseApprovedAssistantPlugin(int index, LuaTable table, Guid configPluginId, out DataAssistantPluginEnterpriseApproval approval)
|
||||
{
|
||||
approval = new();
|
||||
|
||||
@@ -131,11 +131,14 @@ public sealed record PluginConfigurationObject
|
||||
continue;
|
||||
|
||||
var objectIndex = storedObjects.FindIndex(t => t.Id == configObject.Id);
|
||||
|
||||
|
||||
// Case: The object already exists, we update it:
|
||||
if (objectIndex > -1)
|
||||
{
|
||||
var existingObject = storedObjects[objectIndex];
|
||||
if (!MayReplaceConfigurationObject(existingObject, configPluginId))
|
||||
continue;
|
||||
|
||||
configObject = configObject with { Num = existingObject.Num };
|
||||
storedObjects[objectIndex] = (TClass)configObject;
|
||||
}
|
||||
@@ -220,6 +223,9 @@ public sealed record PluginConfigurationObject
|
||||
if (objectIndex > -1)
|
||||
{
|
||||
var existingObject = storedObjects[objectIndex];
|
||||
if (!MayReplaceConfigurationObject(existingObject, configPluginId))
|
||||
continue;
|
||||
|
||||
configObject = configObject with { Num = existingObject.Num };
|
||||
storedObjects[objectIndex] = configObject;
|
||||
}
|
||||
@@ -248,6 +254,35 @@ public sealed record PluginConfigurationObject
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a configuration plugin may replace a stored configuration object, or whether
|
||||
/// that object belongs to the IT department of an organization.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Configuration objects are matched by their ID alone. Without this check, a local configuration
|
||||
/// plugin could claim the ID of an object an organization deployed and replace it, e.g. to point
|
||||
/// a self-hosted LLM provider at a different host.<br/><br/>
|
||||
/// Between two configuration plugins of the same organization, we do not interfere: both belong
|
||||
/// to the IT department, so the one processed later wins, as before.
|
||||
/// </remarks>
|
||||
/// <param name="existingObject">The configuration object which is stored already.</param>
|
||||
/// <param name="configPluginId">The configuration plugin which wants to replace that object.</param>
|
||||
/// <returns>True when the plugin may replace the object, otherwise false.</returns>
|
||||
private static bool MayReplaceConfigurationObject(IConfigurationObject existingObject, Guid configPluginId)
|
||||
{
|
||||
if (!existingObject.IsEnterpriseConfiguration || existingObject.EnterpriseConfigurationPluginId == configPluginId)
|
||||
return true;
|
||||
|
||||
if (!PluginFactory.IsEnterpriseConfigurationPlugin(existingObject.EnterpriseConfigurationPluginId))
|
||||
return true;
|
||||
|
||||
if (PluginFactory.IsEnterpriseConfigurationPlugin(configPluginId))
|
||||
return true;
|
||||
|
||||
LOG.LogWarning("The configuration plugin '{ConfigPluginId}' tried to replace the object '{ConfigObjectName}' (id={ConfigObjectId}), which belongs to the configuration plugin '{OwningConfigPluginId}' of your organization. Ignoring the attempt: configurations deployed by your organization's IT take precedence.", configPluginId, existingObject.Name, existingObject.Id, existingObject.EnterpriseConfigurationPluginId);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up configuration objects of a specified type that are no longer associated with any available plugin.
|
||||
/// </summary>
|
||||
@@ -255,10 +290,10 @@ public sealed record PluginConfigurationObject
|
||||
/// <param name="configObjectType">The type of configuration object to process.</param>
|
||||
/// <param name="configObjectSelection">A selection expression to retrieve the configuration objects from the main configuration.</param>
|
||||
/// <param name="availablePlugins">A list of currently available plugins.</param>
|
||||
/// <param name="deployedConfigPluginIds">
|
||||
/// 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.
|
||||
/// <param name="deployedEnterpriseConfigPluginIds">
|
||||
/// The IDs of the configuration plugins which an organization 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.
|
||||
/// </param>
|
||||
/// <param name="configObjectList">A list of all existing configuration objects.</param>
|
||||
/// <param name="secretStoreType">An optional parameter specifying the type of secret store to use for deleting associated API keys from the OS keyring, if applicable.</param>
|
||||
@@ -268,7 +303,7 @@ public sealed record PluginConfigurationObject
|
||||
PluginConfigurationObjectType configObjectType,
|
||||
Expression<Func<Data, List<TClass>>> configObjectSelection,
|
||||
IList<IAvailablePlugin> availablePlugins,
|
||||
IReadOnlySet<Guid> deployedConfigPluginIds,
|
||||
IReadOnlySet<Guid> deployedEnterpriseConfigPluginIds,
|
||||
IList<PluginConfigurationObject> configObjectList,
|
||||
SecretStoreType? secretStoreType = null,
|
||||
bool deleteSecret = false) where TClass : IConfigurationObject
|
||||
@@ -295,7 +330,7 @@ public sealed record PluginConfigurationObject
|
||||
// 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))
|
||||
if(deployedEnterpriseConfigPluginIds.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
|
||||
|
||||
@@ -45,7 +45,7 @@ public static partial class PluginFactory
|
||||
|
||||
LOG.LogInformation($"Try to download configuration plugin with ID='{configPlugId}' from server='{configServerUrl}' (GET {downloadUrl})");
|
||||
var tempDownloadFile = Path.GetTempFileName();
|
||||
var stagedDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, $"{configPlugId}.staging-{Guid.NewGuid():N}");
|
||||
var stagedDirectory = Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, $"{configPlugId}.staging-{Guid.NewGuid():N}");
|
||||
string? backupDirectory = null;
|
||||
var wasSuccessful = false;
|
||||
try
|
||||
@@ -66,10 +66,10 @@ public static partial class PluginFactory
|
||||
|
||||
ExtractConfigPluginArchive(tempDownloadFile, stagedDirectory);
|
||||
|
||||
var configDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString());
|
||||
var configDirectory = Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString());
|
||||
if (Directory.Exists(configDirectory))
|
||||
{
|
||||
backupDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, $"{configPlugId}.backup-{Guid.NewGuid():N}");
|
||||
backupDirectory = Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, $"{configPlugId}.backup-{Guid.NewGuid():N}");
|
||||
Directory.Move(configDirectory, backupDirectory);
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ public static partial class PluginFactory
|
||||
{
|
||||
LOG.LogError(e, "An error occurred while downloading or extracting the enterprise configuration plugin.");
|
||||
|
||||
var configDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString());
|
||||
var configDirectory = Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString());
|
||||
if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory) && !Directory.Exists(configDirectory))
|
||||
{
|
||||
try
|
||||
|
||||
@@ -110,21 +110,39 @@ public static partial class PluginFactory
|
||||
|
||||
LOG.LogInformation($"Successfully loaded plugin: '{pluginMainFile}' (Id='{plugin.Id}', Type='{plugin.Type}', Name='{plugin.Name}', Version='{plugin.Version}', Authors='{string.Join(", ", plugin.Authors)}')");
|
||||
|
||||
var isConfigurationPluginInConfigDirectory =
|
||||
plugin.Type is PluginType.CONFIGURATION &&
|
||||
pluginPath.StartsWith(CONFIGURATION_PLUGINS_ROOT, StringComparison.OrdinalIgnoreCase);
|
||||
//
|
||||
// Plugin IDs must be unique: many lookups resolve a plugin by its ID alone, e.g.
|
||||
// the base language plugin in PluginFactory.Starting or the owner of a locked
|
||||
// setting. When two plugins share an ID, the one deployed by the organization's
|
||||
// IT wins. Otherwise, a manually placed copy could outrank the enterprise
|
||||
// configuration, which is the exact opposite of what an organization expects:
|
||||
//
|
||||
if (AVAILABLE_PLUGINS.FirstOrDefault(candidate => candidate.Id == plugin.Id) is { } duplicatePlugin)
|
||||
{
|
||||
if (!IsEnterpriseConfigurationPath(pluginPath) || IsEnterpriseConfigurationPath(duplicatePlugin.LocalPath))
|
||||
{
|
||||
LOG.LogWarning($"Ignoring the plugin '{pluginMainFile}': its ID ('{plugin.Id}') is already used by the plugin at '{duplicatePlugin.LocalPath}'. Plugin IDs must be unique. Please remove one of these plugins.");
|
||||
continue;
|
||||
}
|
||||
|
||||
LOG.LogWarning($"Ignoring the plugin at '{duplicatePlugin.LocalPath}': it uses the ID ('{plugin.Id}') of the enterprise configuration plugin at '{pluginPath}'. Plugins deployed by your organization's IT take precedence.");
|
||||
AVAILABLE_PLUGINS.Remove(duplicatePlugin);
|
||||
}
|
||||
|
||||
var isConfigurationPluginInConfigDirectory = plugin.Type is PluginType.CONFIGURATION && IsEnterpriseConfigurationPath(pluginPath);
|
||||
var isManagedByConfigServer = false;
|
||||
Guid? managedConfigurationId = null;
|
||||
var configurationPriority = 0;
|
||||
if (plugin is PluginConfiguration configPlugin)
|
||||
{
|
||||
configurationPriority = configPlugin.Priority;
|
||||
if (configPlugin.DeployedUsingConfigServer.HasValue)
|
||||
isManagedByConfigServer = configPlugin.DeployedUsingConfigServer.Value;
|
||||
|
||||
|
||||
else if (isConfigurationPluginInConfigDirectory)
|
||||
{
|
||||
isManagedByConfigServer = true;
|
||||
LOG.LogWarning($"The configuration plugin '{plugin.Id}' does not define 'DEPLOYED_USING_CONFIG_SERVER'. Falling back to the plugin path and treating it as managed because it is stored under '{CONFIGURATION_PLUGINS_ROOT}'.");
|
||||
LOG.LogWarning($"The configuration plugin '{plugin.Id}' does not define 'DEPLOYED_USING_CONFIG_SERVER'. Falling back to the plugin path and treating it as managed because it is stored under '{ENTERPRISE_CONFIGURATION_PLUGINS_ROOT}'.");
|
||||
}
|
||||
}
|
||||
else if (plugin is PluginAssistants assistantPlugin)
|
||||
@@ -145,7 +163,7 @@ public static partial class PluginFactory
|
||||
LOG.LogWarning($"Could not determine the managed configuration ID for configuration plugin '{plugin.Id}'. The plugin directory '{pluginPath}' does not end with a valid GUID.");
|
||||
}
|
||||
|
||||
AVAILABLE_PLUGINS.Add(new PluginMetadata(plugin, pluginPath, isManagedByConfigServer, managedConfigurationId));
|
||||
AVAILABLE_PLUGINS.Add(new PluginMetadata(plugin, pluginPath, isManagedByConfigServer, managedConfigurationId, configurationPriority));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
@@ -176,40 +194,40 @@ 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:
|
||||
// Enterprise 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.");
|
||||
var deployedEnterpriseConfigPluginIds = GetDeployedEnterpriseConfigPluginIds();
|
||||
var unloadedEnterpriseConfigPluginIds = deployedEnterpriseConfigPluginIds.Where(x => AVAILABLE_PLUGINS.All(plugin => plugin.Id != x)).ToList();
|
||||
foreach (var unloadedEnterpriseConfigPluginId in unloadedEnterpriseConfigPluginIds)
|
||||
LOG.LogWarning($"The configuration plugin '{unloadedEnterpriseConfigPluginId}' 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, deployedConfigPluginIds, configObjectList, SecretStoreType.LLM_PROVIDER);
|
||||
var wasConfigurationChanged = await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList, SecretStoreType.LLM_PROVIDER);
|
||||
|
||||
// Check transcription providers:
|
||||
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, AVAILABLE_PLUGINS, deployedConfigPluginIds, configObjectList, SecretStoreType.TRANSCRIPTION_PROVIDER))
|
||||
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList, SecretStoreType.TRANSCRIPTION_PROVIDER))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check embedding providers:
|
||||
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, AVAILABLE_PLUGINS, deployedConfigPluginIds, configObjectList, SecretStoreType.EMBEDDING_PROVIDER))
|
||||
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList, SecretStoreType.EMBEDDING_PROVIDER))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check data sources:
|
||||
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DATA_SOURCE, x => x.DataSources, AVAILABLE_PLUGINS, deployedConfigPluginIds, configObjectList, SecretStoreType.DATA_SOURCE, deleteSecret: true))
|
||||
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DATA_SOURCE, x => x.DataSources, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList, SecretStoreType.DATA_SOURCE, deleteSecret: true))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check chat templates:
|
||||
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, AVAILABLE_PLUGINS, deployedConfigPluginIds, configObjectList))
|
||||
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check profiles:
|
||||
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.PROFILE, x => x.Profiles, AVAILABLE_PLUGINS, deployedConfigPluginIds, configObjectList))
|
||||
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.PROFILE, x => x.Profiles, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check document analysis policies:
|
||||
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, AVAILABLE_PLUGINS, deployedConfigPluginIds, configObjectList))
|
||||
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check left-over mandatory info acceptances:
|
||||
@@ -218,11 +236,21 @@ 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, deployedConfigPluginIds))
|
||||
if(ManagedConfiguration.CleanupLeftOverManagedConfigurations(AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
//
|
||||
// The enterprise approvals of all configuration plugins add up. Now that every plugin has
|
||||
// contributed and the clean-up above has dropped the removed ones, we rebuild the effective
|
||||
// list. We skip that while a configuration plugin is deployed but could not be loaded: its
|
||||
// approvals are missing from the contributions, and withdrawing them would demand a new
|
||||
// security audit for assistant plugins the organization has approved:
|
||||
//
|
||||
if(unloadedEnterpriseConfigPluginIds.Count == 0 && PluginConfiguration.RefreshEnterpriseApprovedAssistantPlugins())
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Compatibility shim, see documentation/compatibility-shims/2026-08-orphaned-config-locks.md (remove after 2027-08-06):
|
||||
if (RepairLegacyConfigOnlySettings(unloadedConfigPluginIds.Count > 0))
|
||||
if (RepairLegacyConfigOnlySettings(unloadedEnterpriseConfigPluginIds.Count > 0))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
if (wasConfigurationChanged)
|
||||
@@ -233,9 +261,11 @@ public static partial class PluginFactory
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the IDs of all configuration plugins which are deployed on this machine.
|
||||
/// Determines the IDs of all configuration plugins which an organization deployed on this machine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Local configuration plugins are not part of this: they belong to the user, not to an
|
||||
/// organization, and they can live in any directory below the plugins root.<br/><br/>
|
||||
/// 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
|
||||
@@ -243,13 +273,13 @@ public static partial class PluginFactory
|
||||
/// configuration server live in a directory named after their ID, which is the only information
|
||||
/// left when the plugin itself cannot be read.
|
||||
/// </remarks>
|
||||
private static HashSet<Guid> GetDeployedConfigPluginIds()
|
||||
private static HashSet<Guid> GetDeployedEnterpriseConfigPluginIds()
|
||||
{
|
||||
var deployedConfigPluginIds = new HashSet<Guid>();
|
||||
if (!Directory.Exists(CONFIGURATION_PLUGINS_ROOT))
|
||||
return deployedConfigPluginIds;
|
||||
var deployedEnterpriseConfigPluginIds = new HashSet<Guid>();
|
||||
if (!Directory.Exists(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
|
||||
return deployedEnterpriseConfigPluginIds;
|
||||
|
||||
foreach (var configPluginDirectory in Directory.EnumerateDirectories(CONFIGURATION_PLUGINS_ROOT))
|
||||
foreach (var configPluginDirectory in Directory.EnumerateDirectories(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
|
||||
{
|
||||
if (!Guid.TryParse(Path.GetFileName(configPluginDirectory), out var configPluginId) || configPluginId == Guid.Empty)
|
||||
continue;
|
||||
@@ -258,10 +288,10 @@ public static partial class PluginFactory
|
||||
if (!Directory.EnumerateFileSystemEntries(configPluginDirectory).Any())
|
||||
continue;
|
||||
|
||||
deployedConfigPluginIds.Add(configPluginId);
|
||||
deployedEnterpriseConfigPluginIds.Add(configPluginId);
|
||||
}
|
||||
|
||||
return deployedConfigPluginIds;
|
||||
return deployedEnterpriseConfigPluginIds;
|
||||
}
|
||||
|
||||
/// <param name="pluginPath">The directory the plugin is located in, or null when the code has no directory yet.</param>
|
||||
|
||||
@@ -22,9 +22,9 @@ public static partial class PluginFactory
|
||||
|
||||
// Case 2: Startup cleanup before the initial plugin load.
|
||||
// In this case, we inspect the .config directories directly.
|
||||
if (Directory.Exists(CONFIGURATION_PLUGINS_ROOT))
|
||||
if (Directory.Exists(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
|
||||
{
|
||||
foreach (var pluginDirectory in Directory.EnumerateDirectories(CONFIGURATION_PLUGINS_ROOT))
|
||||
foreach (var pluginDirectory in Directory.EnumerateDirectories(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
|
||||
{
|
||||
var directoryName = Path.GetFileName(pluginDirectory);
|
||||
if (!Guid.TryParse(directoryName, out var pluginId))
|
||||
@@ -36,7 +36,7 @@ public static partial class PluginFactory
|
||||
var deployFlag = ReadDeployFlagFromPluginFile(pluginDirectory);
|
||||
var isManagedByConfigServer = deployFlag ?? true;
|
||||
if (!deployFlag.HasValue)
|
||||
LOG.LogWarning($"Configuration plugin '{pluginId}' does not define 'DEPLOYED_USING_CONFIG_SERVER'. Falling back to the plugin path and treating it as managed because it is stored under '{CONFIGURATION_PLUGINS_ROOT}'.");
|
||||
LOG.LogWarning($"Configuration plugin '{pluginId}' does not define 'DEPLOYED_USING_CONFIG_SERVER'. Falling back to the plugin path and treating it as managed because it is stored under '{ENTERPRISE_CONFIGURATION_PLUGINS_ROOT}'.");
|
||||
|
||||
if (isManagedByConfigServer)
|
||||
pluginIdsToRemove.Add(pluginId);
|
||||
@@ -106,7 +106,7 @@ public static partial class PluginFactory
|
||||
|
||||
private static void DeleteConfigurationPluginDirectory(Guid pluginId)
|
||||
{
|
||||
var pluginDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, pluginId.ToString());
|
||||
var pluginDirectory = Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, pluginId.ToString());
|
||||
if (!Directory.Exists(pluginDirectory))
|
||||
{
|
||||
LOG.LogWarning($"Plugin directory '{pluginDirectory}' does not exist.");
|
||||
|
||||
@@ -52,9 +52,25 @@ public static partial class PluginFactory
|
||||
}
|
||||
|
||||
//
|
||||
// Iterate over all available plugins and try to start them.
|
||||
// Iterate over all available plugins and try to start them. We do that in a deterministic
|
||||
// order, starting with the configuration plugins of the organization. Three reasons:
|
||||
//
|
||||
foreach (var availablePlugin in AVAILABLE_PLUGINS)
|
||||
// - Configuration plugins write settings and configuration objects. Whoever writes one
|
||||
// first owns it, so the organization has to come first: its configuration is the baseline
|
||||
// every other plugin has to respect.
|
||||
//
|
||||
// - Within one origin, the declared priority decides. An organization can deploy a base
|
||||
// configuration for everybody and refine it, e.g. per department: the higher priority is
|
||||
// applied later and therefore wins.
|
||||
//
|
||||
// - Without an explicit order, the sequence is the one Directory.EnumerateFiles produced in
|
||||
// LoadAll. That order is not guaranteed, so the same installation could behave
|
||||
// differently on two machines. The plugin directory breaks any remaining tie.
|
||||
//
|
||||
foreach (var availablePlugin in AVAILABLE_PLUGINS
|
||||
.OrderBy(GetStartupRank)
|
||||
.ThenBy(plugin => plugin.ConfigurationPriority)
|
||||
.ThenBy(plugin => plugin.LocalPath, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if(cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -89,19 +105,49 @@ public static partial class PluginFactory
|
||||
return configObjects;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the position of a plugin in the startup sequence. Plugins with a lower rank start earlier.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The configuration plugins an organization deployed go first: they are the baseline for
|
||||
/// everything else. Local configuration plugins follow, so they can add to that baseline instead
|
||||
/// of replacing parts of it. All remaining plugin types write no settings at all, so their rank
|
||||
/// is irrelevant for the outcome.<br/><br/>
|
||||
/// The rank comes before the declared priority on purpose: a local configuration plugin must not
|
||||
/// be able to jump ahead of an organization by declaring a high priority.
|
||||
/// </remarks>
|
||||
/// <param name="plugin">The plugin about to be started.</param>
|
||||
/// <returns>The startup rank of the plugin.</returns>
|
||||
private static int GetStartupRank(IAvailablePlugin plugin) => plugin.Type switch
|
||||
{
|
||||
PluginType.CONFIGURATION when IsEnterpriseConfigurationPath(plugin.LocalPath) => 0,
|
||||
PluginType.CONFIGURATION => 1,
|
||||
|
||||
_ => 2,
|
||||
};
|
||||
|
||||
private static void LogAssistantPluginStartupState()
|
||||
{
|
||||
ManagedConfiguration.TryGet(x => x.AssistantPluginAudit, x => x.EnterpriseApprovedPlugins, out ConfigMeta<DataAssistantPluginAudit, IList<DataAssistantPluginEnterpriseApproval>> configMeta);
|
||||
var approvedByConfigPluginId = configMeta is { IsLocked: true } ? configMeta.LockedByConfigPluginId : Guid.Empty;
|
||||
var approvedByConfigPluginName = approvedByConfigPluginId == Guid.Empty
|
||||
? string.Empty
|
||||
: AVAILABLE_PLUGINS.FirstOrDefault(x => x.Id == approvedByConfigPluginId)?.Name ?? string.Empty;
|
||||
|
||||
foreach (var assistantPlugin in RUNNING_PLUGINS.OfType<PluginAssistants>())
|
||||
{
|
||||
var securityState = PluginAssistantSecurityResolver.Resolve(SettingsManagerAccess, assistantPlugin);
|
||||
if (securityState.IsEnterpriseApproved)
|
||||
{
|
||||
//
|
||||
// Several configuration plugins may approve assistant plugins. We look up the one
|
||||
// which approved this particular plugin instead of naming an arbitrary contributor:
|
||||
//
|
||||
var approvedByConfigPluginId = configMeta.PluginContributions
|
||||
.Where(contribution => contribution.Value.Any(approval => string.Equals(approval.PluginHash, securityState.CurrentHash, StringComparison.Ordinal)))
|
||||
.Select(contribution => contribution.Key)
|
||||
.FirstOrDefault();
|
||||
|
||||
var approvedByConfigPluginName = approvedByConfigPluginId == Guid.Empty
|
||||
? string.Empty
|
||||
: AVAILABLE_PLUGINS.FirstOrDefault(x => x.Id == approvedByConfigPluginId)?.Name ?? string.Empty;
|
||||
|
||||
LOG.LogInformation(
|
||||
$"Successfully started assistant plugin: Id='{assistantPlugin.Id}', Type='{assistantPlugin.Type}', Name='{assistantPlugin.Name}', Version='{assistantPlugin.Version}', SecuritySource='EnterpriseApproval', ApprovedByConfigPluginId='{approvedByConfigPluginId}', ApprovedByConfigPluginName='{approvedByConfigPluginName}'");
|
||||
continue;
|
||||
|
||||
@@ -11,7 +11,16 @@ public static partial class PluginFactory
|
||||
private static string DATA_DIR = string.Empty;
|
||||
private static string PLUGINS_ROOT = string.Empty;
|
||||
private static string INTERNAL_PLUGINS_ROOT = string.Empty;
|
||||
private static string CONFIGURATION_PLUGINS_ROOT = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The directory the config server downloads the configuration plugins of an organization into.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is not the home of configuration plugins in general: a local configuration plugin can
|
||||
/// live in any directory below the plugins root. Only the IT department of an organization
|
||||
/// deploys plugins here, each in a directory named after its configuration ID.
|
||||
/// </remarks>
|
||||
private static string ENTERPRISE_CONFIGURATION_PLUGINS_ROOT = string.Empty;
|
||||
private static string HOT_RELOAD_LOCK_FILE = string.Empty;
|
||||
private static FileSystemWatcher HOT_RELOAD_WATCHER = null!;
|
||||
|
||||
@@ -65,7 +74,7 @@ public static partial class PluginFactory
|
||||
PLUGINS_ROOT = Path.Join(DATA_DIR, "plugins");
|
||||
HOT_RELOAD_LOCK_FILE = Path.Join(PLUGINS_ROOT, ".lock");
|
||||
INTERNAL_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".internal");
|
||||
CONFIGURATION_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".config");
|
||||
ENTERPRISE_CONFIGURATION_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".config");
|
||||
|
||||
if (!Directory.Exists(PLUGINS_ROOT))
|
||||
Directory.CreateDirectory(PLUGINS_ROOT);
|
||||
@@ -76,6 +85,56 @@ public static partial class PluginFactory
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a plugin directory belongs to the enterprise configuration area.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only the IT department of an organization deploys plugins there: the config server downloads
|
||||
/// them into a directory named after their configuration ID. We decide by path on purpose. The
|
||||
/// Lua field DEPLOYED_USING_CONFIG_SERVER is self-declared, so any plugin could claim to be
|
||||
/// deployed by an organization.
|
||||
/// </remarks>
|
||||
/// <param name="pluginPath">The directory of the plugin.</param>
|
||||
/// <returns>True when the directory is nested in the enterprise configuration directory.</returns>
|
||||
private static bool IsEnterpriseConfigurationPath(string? pluginPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pluginPath) || string.IsNullOrWhiteSpace(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var configurationRoot = Path.GetFullPath(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
var pluginDirectory = Path.GetFullPath(pluginPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
return pluginDirectory.StartsWith(configurationRoot, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.LogWarning(e, $"Was not able to check whether the plugin directory '{pluginPath}' belongs to the enterprise configuration directory. Treating it as a local plugin.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a configuration plugin was deployed by the IT department of an organization.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A plugin which is deployed but could not be loaded still counts: it might be broken, e.g. due
|
||||
/// to invalid Lua code or an incomplete download, but it was not removed. Everything it manages
|
||||
/// stays under the control of the organization until the plugin is gone for good.
|
||||
/// </remarks>
|
||||
/// <param name="configPluginId">The ID of the configuration plugin.</param>
|
||||
/// <returns>True when the plugin belongs to an organization, false when it is local or unknown.</returns>
|
||||
public static bool IsEnterpriseConfigurationPlugin(Guid configPluginId)
|
||||
{
|
||||
if (configPluginId == Guid.Empty || !IsInitialized)
|
||||
return false;
|
||||
|
||||
if (AVAILABLE_PLUGINS.Any(plugin => plugin.Id == configPluginId && plugin.Type is PluginType.CONFIGURATION && IsEnterpriseConfigurationPath(plugin.LocalPath)))
|
||||
return true;
|
||||
|
||||
return Directory.Exists(Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, configPluginId.ToString()));
|
||||
}
|
||||
|
||||
private static async Task LockHotReloadAsync()
|
||||
{
|
||||
if (!IsInitialized)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public sealed class PluginMetadata(PluginBase plugin, string localPath, bool isManagedByConfigServer = false, Guid? managedConfigurationId = null) : IAvailablePlugin
|
||||
public sealed class PluginMetadata(PluginBase plugin, string localPath, bool isManagedByConfigServer = false, Guid? managedConfigurationId = null, int configurationPriority = 0) : IAvailablePlugin
|
||||
{
|
||||
#region Implementation of IPluginMetadata
|
||||
|
||||
@@ -53,8 +53,11 @@ public sealed class PluginMetadata(PluginBase plugin, string localPath, bool isM
|
||||
public string LocalPath { get; } = localPath;
|
||||
|
||||
public bool IsManagedByConfigServer { get; } = isManagedByConfigServer;
|
||||
|
||||
|
||||
public Guid? ManagedConfigurationId { get; } = managedConfigurationId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int ConfigurationPriority { get; } = configurationPriority;
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in new issue
Block a user