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

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-08-08 18:35:46 +02:00
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
}