Added priorities for configuration plugins and fixed how they collide (#899)
Some checks are pending
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 2026-08-08 18:35:46 +02:00 committed by GitHub
parent d1a6781ea6
commit 0eb747b386
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 562 additions and 104 deletions

View File

@ -108,8 +108,10 @@ public partial class SettingsPanelApp : SettingsPanelBase
private HashSet<PreviewFeatures> GetPluginContributedPreviewFeatures()
{
// Several configuration plugins may contribute at the same time, e.g. one preview feature
// for the whole organization and another one for a single department:
if (ManagedConfiguration.TryGet(x => x.App, x => x.EnabledPreviewFeatures, out var meta) && meta.HasPluginContribution)
return meta.PluginContribution.Where(x => !x.IsReleased()).ToHashSet();
return meta.PluginContributions.Values.SelectMany(contribution => contribution).Where(x => !x.IsReleased()).ToHashSet();
return [];
}
@ -122,7 +124,7 @@ public partial class SettingsPanelApp : SettingsPanelBase
if (!ManagedConfiguration.TryGet(x => x.App, x => x.EnabledPreviewFeatures, out var meta) || !meta.HasPluginContribution)
return false;
return meta.PluginContribution.Contains(feature);
return meta.PluginContributions.Values.Any(contribution => contribution.Contains(feature));
}
private HashSet<PreviewFeatures> GetSelectedPreviewFeatures()

View File

@ -27,6 +27,26 @@ TYPE = "CONFIGURATION"
-- True when this plugin is deployed by an enterprise configuration server:
DEPLOYED_USING_CONFIG_SERVER = false
-- The priority of this configuration plugin. Optional, defaults to 0.
--
-- It only matters when your organization deploys more than one configuration
-- plugin. A plugin with a higher priority is applied later and therefore wins
-- whenever two of your configuration plugins manage the same setting or define
-- the same object, e.g. the same LLM provider.
--
-- A typical setup: deploy one base configuration for everybody with PRIORITY = 0
-- and one configuration per department with PRIORITY = 100. The department
-- configuration may then override the default model, while everything it does
-- not mention stays at the values of the base configuration.
--
-- Give two plugins that must override each other different priorities. With an
-- equal priority, the order is stable but arbitrary.
--
-- The priority never lifts a local configuration plugin above one of your
-- organization: configuration plugins your IT department deployed are always
-- applied first, whatever a local plugin declares.
PRIORITY = 0
-- The authors of the plugin:
AUTHORS = {"<Company Name>"}
@ -199,6 +219,25 @@ CONFIG["DATA_SOURCES"] = {}
CONFIG["SETTINGS"] = {}
-- ------
-- How settings combine when your organization deploys more than one configuration
-- ------
--
-- A configuration with a higher PRIORITY is applied later and wins. This works per
-- setting: everything a later configuration does not mention keeps the value of the
-- configuration below it.
--
-- For a setting that holds a list or a table, the winning configuration replaces the
-- whole collection instead of merging the entries. A department configuration that
-- lists a single entry therefore drops every entry the base configuration had set for
-- that setting. That is intentional: replacing is the only way a department can take
-- something back that the base configuration has set.
--
-- The affected settings below carry a note. Two settings are the exception and add up
-- across configurations instead: DataApp.EnabledPreviewFeatures and
-- DataAssistantPluginAudit.EnterpriseApprovedPlugins.
-- ------
-- Configure the update check interval:
-- Allowed values are: NO_CHECK, DISABLE_UPDATES, ONCE_STARTUP, HOURLY, DAILY, WEEKLY
-- NO_CHECK disables automatic checks, but users can still check and install updates manually.
@ -257,6 +296,12 @@ CONFIG["SETTINGS"] = {}
-- Configure the enabled preview features:
-- Allowed values are can be found in https://github.com/MindWorkAI/AI-Studio/blob/main/app/MindWork%20AI%20Studio/Settings/DataModel/PreviewFeatures.cs
-- Examples are PRE_WRITER_MODE_2024 and PRE_RAG_2024.
--
-- Adds up, does not replace: this is the one setting where all configurations
-- contribute together. Enable one preview feature for the whole organization and
-- another one for a single department, and users of that department get both. Each
-- configuration keeps its own contribution, so removing one of them only withdraws
-- the features that this configuration had enabled.
-- CONFIG["SETTINGS"]["DataApp.EnabledPreviewFeatures"] = { "PRE_RAG_2024" }
-- Configure the preselected provider.
@ -301,6 +346,12 @@ CONFIG["SETTINGS"] = {}
-- CONFIG["SETTINGS"]["DataChat.PreselectedDataSourcesAutomaticValidation"] = true
-- Must contain IDs from CONFIG["DATA_SOURCES"] or user-configured data sources.
-- IDs from another configuration of your organization work as well: they are resolved
-- against every known data source, not only against the ones defined here. IDs that
-- resolve to nothing are ignored.
--
-- Replaces, does not merge: a configuration with a higher priority replaces this list
-- completely. To keep an entry of the base configuration, list that ID here again.
-- CONFIG["SETTINGS"]["DataChat.PreselectedDataSourceIds"] = {
-- "00000000-0000-0000-0000-000000000000",
-- }
@ -336,6 +387,11 @@ CONFIG["SETTINGS"] = {}
-- JOB_POSTING_ASSISTANT, BIAS_DAY_ASSISTANT, ERI_ASSISTANT,
-- DOCUMENT_ANALYSIS_ASSISTANT, SLIDE_BUILDER_ASSISTANT, VISUAL_BRIEFING_ASSISTANT, I18N_ASSISTANT,
-- LOG_VIEWER_ASSISTANT
--
-- Replaces, does not merge: a configuration with a higher priority replaces this list
-- completely. This is what lets a department show an assistant again that the base
-- configuration hides. The department configuration must then list every other
-- assistant that is supposed to stay hidden, otherwise those become visible too.
-- CONFIG["SETTINGS"]["DataApp.HiddenAssistants"] = { "ERI_ASSISTANT", "I18N_ASSISTANT" }
-- Configure organization defaults for the Visual Briefing Assistant.
@ -404,6 +460,11 @@ CONFIG["SETTINGS"] = {}
-- no user-run security audit is required.
-- You can generate the exact hash with the build-script command:
-- dotnet run --project app/Build -- assistant-plugin-hash "<plugin-dir>" --lua-snippet
--
-- Adds up, does not replace: approvals of all your configurations are combined, so a
-- department configuration can approve additional assistant plugins without repeating
-- the approvals of the base configuration. Each configuration keeps its own approvals,
-- so removing one of them only withdraws the approvals it had granted.
-- CONFIG["SETTINGS"]["DataAssistantPluginAudit.EnterpriseApprovedPlugins"] = {
-- {
-- ["PluginHash"] = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF",
@ -443,6 +504,11 @@ CONFIG["SETTINGS"] = {}
-- MINDWORK_AI_STUDIO_EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATE_BUNDLE_PATH=/path/in/sandbox/company-root-cas.pem
-- MINDWORK_AI_STUDIO_EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATE_ALLOWED_HOSTS=*.intra.example.org;data.example.org
--
-- Replaces, does not merge: a configuration with a higher priority replaces the host
-- list completely. Deploy this setting in one configuration only, or repeat every host
-- of the base configuration. Otherwise, hosts of the base configuration silently stop
-- trusting your root certificates.
--
-- CONFIG["SETTINGS"]["DataApp.ExternalHttpCustomRootCertificatesEnabled"] = true
-- CONFIG["SETTINGS"]["DataApp.ExternalHttpCustomRootCertificateBundlePath"] = "/path/in/sandbox/company-root-cas.pem"
-- CONFIG["SETTINGS"]["DataApp.ExternalHttpCustomRootCertificateAllowedHosts"] = { "*.intra.example.org", "eri.example.org" }
@ -477,6 +543,11 @@ CONFIG["SETTINGS"] = {}
-- Allowed provider keys are: OPEN_AI, ANTHROPIC, MISTRAL, GOOGLE, X, DEEP_SEEK, ALIBABA_CLOUD,
-- PERPLEXITY, OPEN_ROUTER, FIREWORKS, GROQ, HUGGINGFACE, SELF_HOSTED, HELMHOLTZ, GWDG
-- Allowed confidence values are: UNTRUSTED, VERY_LOW, LOW, MODERATE, MEDIUM, HIGH
--
-- Replaces, does not merge: a configuration with a higher priority replaces the whole
-- table. Every configuration that sets this must therefore list all providers it wants
-- to cover. A partial table is not completed from the configuration below it, and the
-- providers left out fall back to the app default.
-- CONFIG["SETTINGS"]["DataConfidence.CustomConfidenceScheme"] = {
-- ["OPEN_AI"] = "MODERATE",
-- ["ANTHROPIC"] = "MODERATE",
@ -502,6 +573,10 @@ CONFIG["SETTINGS"] = {}
-- These IDs may refer to LLM providers, embedding providers, or transcription providers
-- defined in this configuration. Trusted providers are treated like self-hosted providers
-- only for data-source security checks and related local data warnings.
--
-- Replaces, does not merge: a configuration with a higher priority replaces this list
-- completely, so providers trusted by the base configuration lose that status. Repeat
-- them here to keep them trusted.
-- CONFIG["SETTINGS"]["DataSourceSecuritySettings.TrustedProviderIds"] = {
-- "00000000-0000-0000-0000-000000000000",
-- "00000000-0000-0000-0000-000000000001",

View File

@ -33,26 +33,29 @@ public record ConfigMeta<TClass, TValue> : ConfigMetaBase
public required TValue Default { get; init; }
/// <summary>
/// The additive value contribution provided by a configuration plugin.
/// The additive value contributions, one per contributing configuration plugin.
/// </summary>
public TValue PluginContribution { get; private set; } = default!;
/// <summary>
/// Stores an additive plugin contribution.
/// </summary>
public void SetPluginContribution(TValue value, Guid pluginId)
{
this.PluginContribution = value;
this.PluginContributionByConfigPluginId = pluginId;
this.HasPluginContribution = true;
}
/// <remarks>
/// Every configuration plugin keeps its own contribution, so removing one of them leaves the
/// contributions of the others intact. Callers that need the overall contribution combine the
/// values themselves: only they know how to combine the concrete type.
/// </remarks>
public IReadOnlyDictionary<Guid, TValue> PluginContributions => this.pluginContributions;
/// <inheritdoc/>
public override void ClearPluginContribution()
{
this.PluginContribution = default!;
base.ClearPluginContribution();
}
public override IReadOnlyCollection<Guid> ContributingConfigPluginIds => this.pluginContributions.Keys;
private readonly Dictionary<Guid, TValue> pluginContributions = [];
/// <summary>
/// Stores the additive contribution of one configuration plugin, replacing its previous one.
/// </summary>
/// <param name="value">The contributed value.</param>
/// <param name="pluginId">The contributing configuration plugin.</param>
public void SetPluginContribution(TValue value, Guid pluginId) => this.pluginContributions[pluginId] = value;
/// <inheritdoc/>
public override bool RemovePluginContribution(Guid configPluginId) => this.pluginContributions.Remove(configPluginId);
/// <inheritdoc/>
protected override void Reset()

View File

@ -38,14 +38,19 @@ public abstract record ConfigMetaBase(string SettingName) : IConfig
public Guid EditableDefaultByConfigPluginId { get; private set; }
/// <summary>
/// Indicates whether a plugin contribution is available.
/// The configuration plugins which contribute to this setting.
/// </summary>
public bool HasPluginContribution { get; protected set; }
/// <remarks>
/// Contributions are additive, so several configuration plugins may contribute at the same time
/// and each of them keeps its own contribution. An organization might enable one preview feature
/// for everybody and another one for a single department, for example.
/// </remarks>
public abstract IReadOnlyCollection<Guid> ContributingConfigPluginIds { get; }
/// <summary>
/// The ID of the plugin that provided the additive value contribution.
/// Indicates whether at least one configuration plugin contributes to this setting.
/// </summary>
public Guid PluginContributionByConfigPluginId { get; protected set; }
public bool HasPluginContribution => this.ContributingConfigPluginIds.Count > 0;
/// <summary>
/// Locks the configuration state, indicating that it is controlled by a specific plugin.
@ -133,13 +138,11 @@ public abstract record ConfigMetaBase(string SettingName) : IConfig
}
/// <summary>
/// Clears the additive plugin contribution without changing the current value.
/// Removes the contribution of one configuration plugin without changing the current value.
/// </summary>
public virtual void ClearPluginContribution()
{
this.PluginContributionByConfigPluginId = Guid.Empty;
this.HasPluginContribution = false;
}
/// <param name="configPluginId">The configuration plugin whose contribution is removed.</param>
/// <returns>True when that plugin had a contribution, otherwise false.</returns>
public abstract bool RemovePluginContribution(Guid configPluginId);
/// <summary>
/// Resets the configuration property to its default value.

View File

@ -654,6 +654,11 @@ public static partial class ManagedConfiguration
if (dryRun)
return successful;
//
// Contributions need no protection against a takeover: every configuration plugin has its
// own contribution, so no plugin can replace or drop the contribution of another one. This
// is also why a local configuration plugin may contribute next to one of an organization.
//
if (successful)
{
var configInstance = configSelection.Compile().Invoke(SettingsManagerAccess.ConfigurationData);
@ -663,10 +668,8 @@ public static partial class ManagedConfiguration
configMeta.SetValue(merged);
configMeta.SetPluginContribution(new HashSet<TValue>(configuredValue), configPluginId);
}
else if (configMeta.HasPluginContribution && configMeta.PluginContributionByConfigPluginId == configPluginId)
{
configMeta.ClearPluginContribution();
}
else
configMeta.RemovePluginContribution(configPluginId);
if (configMeta.IsLocked && configMeta.LockedByConfigPluginId == configPluginId)
configMeta.UnlockConfiguration();
@ -905,6 +908,11 @@ public static partial class ManagedConfiguration
if(dryRun)
return successful;
// The setting might belong to the IT department of an organization. In that case, no local
// configuration plugin may touch it, no matter what it declares:
if (!MayManageSetting(configPluginId, configMeta))
return false;
switch (successful)
{
case true:
@ -954,6 +962,11 @@ public static partial class ManagedConfiguration
if (dryRun)
return successful;
// The setting might belong to the IT department of an organization. In that case, no local
// configuration plugin may touch it, no matter what it declares:
if (!MayManageSetting(configPluginId, configMeta))
return false;
switch (successful)
{
case true when managedMode is ManagedConfigurationMode.LOCKED:

View File

@ -257,6 +257,51 @@ public static partial class ManagedConfiguration
return false;
}
/// <summary>
/// Checks whether a configuration plugin may manage a setting, or whether that setting belongs
/// to the IT department of an organization.
/// </summary>
/// <remarks>
/// A local configuration plugin must not take over a setting an organization manages. Otherwise,
/// anyone could hand out a configuration plugin that quietly replaces parts of the organization
/// configuration, e.g. the address of a self-hosted provider.<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="configPluginId">The configuration plugin which wants to manage the setting.</param>
/// <param name="configMeta">The configuration metadata of the setting.</param>
/// <returns>True when the plugin may manage this setting, otherwise false.</returns>
private static bool MayManageSetting(Guid configPluginId, ConfigMetaBase configMeta)
{
var owningConfigPluginId = GetSettingOwner(configMeta);
if (owningConfigPluginId == Guid.Empty || owningConfigPluginId == configPluginId)
return true;
if (!PluginFactory.IsEnterpriseConfigurationPlugin(owningConfigPluginId))
return true;
if (PluginFactory.IsEnterpriseConfigurationPlugin(configPluginId))
return true;
Log.LogWarning($"The configuration plugin '{configPluginId}' tried to manage the setting '{configMeta.SettingName}', which is managed by the configuration plugin '{owningConfigPluginId}' of your organization. Ignoring the attempt: configurations deployed by your organization's IT take precedence.");
return false;
}
/// <summary>
/// Determines the configuration plugin which currently manages a setting, if any.
/// </summary>
private static Guid GetSettingOwner(ConfigMetaBase configMeta)
{
if (configMeta.IsLocked && configMeta.LockedByConfigPluginId != Guid.Empty)
return configMeta.LockedByConfigPluginId;
// The editable default is persisted as well, so we prefer it over the in-memory state:
if (TryGetEditableDefaultState(configMeta.SettingName, out var editableDefaultState) && editableDefaultState.ConfigPluginId != Guid.Empty)
return editableDefaultState.ConfigPluginId;
return configMeta.EditableDefaultByConfigPluginId;
}
/// <summary>
/// Removes all managed states whose configuration plugin is not available anymore.
/// </summary>
@ -269,12 +314,13 @@ public static partial class ManagedConfiguration
/// change it at all.
/// </remarks>
/// <param name="availablePlugins">The collection of available plugins to check against.</param>
/// <param name="deployedConfigPluginIds">
/// 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.
/// <param name="deployedEnterpriseConfigPluginIds">
/// The IDs of the configuration plugins which an organization deployed on this machine, including
/// those which could not be loaded. A deployed plugin was not removed, so its settings must stay
/// untouched.
/// </param>
/// <returns>True when at least one setting was changed, otherwise false.</returns>
public static bool CleanupLeftOverManagedConfigurations(IReadOnlyCollection<IAvailablePlugin> availablePlugins, IReadOnlySet<Guid> deployedConfigPluginIds)
public static bool CleanupLeftOverManagedConfigurations(IReadOnlyCollection<IAvailablePlugin> availablePlugins, IReadOnlySet<Guid> deployedEnterpriseConfigPluginIds)
{
var wasChanged = false;
var registeredSettingNames = new HashSet<string>(StringComparer.Ordinal);
@ -293,7 +339,7 @@ public static partial class ManagedConfiguration
configMeta.RestoreLockedConfiguration();
// Check the locked state:
if (configMeta.IsLocked && configMeta.LockedByConfigPluginId != Guid.Empty && !IsPluginPresent(configMeta.LockedByConfigPluginId, availablePlugins, deployedConfigPluginIds))
if (configMeta.IsLocked && configMeta.LockedByConfigPluginId != Guid.Empty && !IsPluginPresent(configMeta.LockedByConfigPluginId, availablePlugins, deployedEnterpriseConfigPluginIds))
{
Log.LogInformation($"Resetting the setting '{configMeta.SettingName}': it was locked by the configuration plugin '{configMeta.LockedByConfigPluginId}', which is not available anymore.");
configMeta.ResetLockedConfiguration();
@ -301,14 +347,18 @@ public static partial class ManagedConfiguration
}
// Check the editable default state:
if (CleanupEditableDefaultState(configMeta, availablePlugins, deployedConfigPluginIds))
if (CleanupEditableDefaultState(configMeta, availablePlugins, deployedEnterpriseConfigPluginIds))
wasChanged = true;
// Check the additive plugin contribution:
if (configMeta.HasPluginContribution && configMeta.PluginContributionByConfigPluginId != Guid.Empty && !IsPluginPresent(configMeta.PluginContributionByConfigPluginId, availablePlugins, deployedConfigPluginIds))
// Check the additive plugin contributions. Every contributing plugin is checked on its
// own, so one removed plugin does not take the contributions of the others with it:
foreach (var contributingConfigPluginId in configMeta.ContributingConfigPluginIds.ToList())
{
Log.LogInformation($"Clearing the plugin contribution for the setting '{configMeta.SettingName}': the configuration plugin '{configMeta.PluginContributionByConfigPluginId}' is not available anymore.");
configMeta.ClearPluginContribution();
if (contributingConfigPluginId != Guid.Empty && IsPluginPresent(contributingConfigPluginId, availablePlugins, deployedEnterpriseConfigPluginIds))
continue;
Log.LogInformation($"Clearing the contribution of the configuration plugin '{contributingConfigPluginId}' to the setting '{configMeta.SettingName}': the plugin is not available anymore.");
configMeta.RemovePluginContribution(contributingConfigPluginId);
wasChanged = true;
}
}
@ -328,7 +378,7 @@ public static partial class ManagedConfiguration
/// The latter matters for organizations: a broken configuration plugin is still in charge, so we
/// must not treat its settings as left over.
/// </remarks>
private static bool IsPluginPresent(Guid configPluginId, IReadOnlyCollection<IAvailablePlugin> availablePlugins, IReadOnlySet<Guid> deployedConfigPluginIds) => deployedConfigPluginIds.Contains(configPluginId) || availablePlugins.Any(x => x.Id == configPluginId);
private static bool IsPluginPresent(Guid configPluginId, IReadOnlyCollection<IAvailablePlugin> availablePlugins, IReadOnlySet<Guid> deployedEnterpriseConfigPluginIds) => deployedEnterpriseConfigPluginIds.Contains(configPluginId) || availablePlugins.Any(x => x.Id == configPluginId);
/// <summary>
/// Removes persisted managed states which belong to settings that are not registered anymore.
@ -390,7 +440,7 @@ public static partial class ManagedConfiguration
private static bool ClearEditableDefaultState(string settingName) => SettingsManagerAccess.ConfigurationData.ManagedEditableDefaults.Remove(settingName);
private static bool CleanupEditableDefaultState(ConfigMetaBase configMeta, IReadOnlyCollection<IAvailablePlugin> availablePlugins, IReadOnlySet<Guid> deployedConfigPluginIds)
private static bool CleanupEditableDefaultState(ConfigMetaBase configMeta, IReadOnlyCollection<IAvailablePlugin> availablePlugins, IReadOnlySet<Guid> deployedEnterpriseConfigPluginIds)
{
if (!TryGetEditableDefaultState(configMeta.SettingName, out var editableDefaultState))
{
@ -401,7 +451,7 @@ public static partial class ManagedConfiguration
return true;
}
if (IsPluginPresent(editableDefaultState.ConfigPluginId, availablePlugins, deployedConfigPluginIds))
if (IsPluginPresent(editableDefaultState.ConfigPluginId, availablePlugins, deployedEnterpriseConfigPluginIds))
return false;
Log.LogInformation($"Clearing the editable default of the setting '{configMeta.SettingName}': the configuration plugin '{editableDefaultState.ConfigPluginId}' is not available anymore.");

View File

@ -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; }
}

View File

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

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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.");

View File

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

View File

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

View File

@ -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
}

View File

@ -6,10 +6,14 @@
- 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.
- Added a priority for configuration plugins. Organizations that deploy several configurations can now decide which one wins: a configuration with a higher priority overrides the settings and providers of a lower one. This allows a company-wide base configuration that each department refines for itself.
- 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.
- Changed how approvals for assistant plugins combine when your organization deploys several configurations. They now add up, so a department can approve additional assistant plugins without repeating the approvals of the company-wide configuration. Previously, the last configuration replaced all earlier approvals, which silently required a new security check for those assistants.
- 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.
- Fixed the integrated code editor to keep errors and other issues in plugin code visible in the footer while scrolling.
- Fixed the trusted badge so you can now see at a glance which models are trusted. It is shown consistently for self-hosted models and models from trusted providers.
- Fixed preview features contributed by several configuration plugins at once. Only the most recent contribution was recognized as coming from your organization, so features enabled by another configuration looked as if you had switched them on yourself. Each configuration is now tracked separately, which lets your organization enable one preview feature company-wide and another one for a single department.
- Fixed which configuration wins when two configuration plugins collide, e.g. by claiming the same plugin ID, by managing the same setting, or by defining the same provider. Previously, this was down to chance, so a local configuration plugin could take over parts of the configuration your IT department deployed. Configurations from your organization now always win, and every ignored attempt is reported in the log.
- Upgraded dependencies to their latest versions to improve security and stability.

View File

@ -54,7 +54,7 @@ The preferred format is a fixed set of indexed pairs:
Each configuration ID must be a valid [GUID](https://en.wikipedia.org/wiki/Universally_unique_identifier#Globally_unique_identifier). Up to 100,000 indexed configuration slots are supported per device.
If multiple configurations define the same setting, the first definition wins. For indexed pairs and policy files, the order is slot `00000`, then `00001`, and so on up to `99999`.
The slot order determines which configurations are downloaded, not which one wins a conflict. When two of your configuration plugins define the same setting or the same object, the declared priority decides. See [Priority of configuration plugins](#priority-of-configuration-plugins).
For backwards compatibility, the older slot names `0` to `9` without an underscore are still supported. AI Studio also accepts other numeric slot suffixes with up to five digits. Slot suffixes are matched exactly, so `config_id_1`, `config_id_01`, and `config_id_00001` are treated as separate slots. Use the five-digit format with an underscore for new deployments.
@ -284,6 +284,60 @@ DEPLOYED_USING_CONFIG_SERVER = true
Local, manually managed configuration plugins should set this to `false`. If the field is missing, AI Studio falls back to the plugin path (`.config`) to determine whether the plugin is managed and logs a warning.
## Priority of configuration plugins
When you deploy more than one configuration, two of your configuration plugins may manage the same setting or define the same object, e.g. the same LLM provider. The optional `PRIORITY` field decides which one wins:
```lua
PRIORITY = 100
```
A configuration plugin with a higher priority is applied later and therefore wins. The field is optional and defaults to `0`.
A typical layered setup:
| Configuration | `PRIORITY` | Role |
|---|---|---|
| Organization-wide base | `0` | Providers, update behavior, and security settings for everybody |
| Department | `100` | Refines the base, e.g. a different default model |
| Project or lab | `200` | Refines the department configuration |
A configuration only overrides what it actually defines. Everything it does not mention keeps the value of the configuration below it. The same applies when you remove a configuration later: its settings fall back to the configuration below, not to the AI Studio defaults.
Give two configurations that must override each other different priorities. With an equal priority, the order is stable across restarts but arbitrary, so the outcome is not the one you designed.
Two guarantees are independent of the priority:
- A local configuration plugin never wins against one your IT department deployed, whatever priority it declares. Local plugins are always applied afterwards, and they may not take over a setting or an object that belongs to one of your configurations.
- Two plugins must not share the same plugin ID. If that happens, AI Studio keeps the one your IT department deployed and logs a warning for the other.
### Settings that hold a list or a table
For a setting that holds a list or a table, the winning configuration replaces the whole collection. It does not merge the entries. A department configuration that lists a single entry drops every entry the base configuration had set for that setting.
This is intentional: replacing is the only way a department can take something back. A department that wants an assistant to be visible again can only achieve that by not listing it.
Plan for it in these settings:
| Setting | What a partial list costs you |
|---|---|
| `DataApp.HiddenAssistants` | Assistants hidden by the base configuration become **visible** again |
| `DataSourceSecuritySettings.TrustedProviderIds` | Providers trusted by the base configuration lose that status |
| `DataApp.ExternalHttpCustomRootCertificateAllowedHosts` | Hosts of the base configuration stop trusting your root certificates |
| `DataConfidence.CustomConfidenceScheme` | Providers left out fall back to the AI Studio default confidence |
| `DataChat.PreselectedDataSourceIds` | Data sources preselected by the base configuration are no longer preselected |
The rule of thumb: whenever a configuration with a higher priority touches one of these settings, it has to repeat every entry it wants to keep. Watch `DataApp.HiddenAssistants` in particular, because it is the only one in this list that opens something up instead of restricting it.
Two settings are the exception and add up instead of replacing:
- `DataApp.EnabledPreviewFeatures` — enable one preview feature for the whole organization and another one for a single department, and users of that department get both.
- `DataAssistantPluginAudit.EnterpriseApprovedPlugins` — a department configuration can approve additional assistant plugins without repeating the approvals of the base configuration. Approving is a pure allowlist over hashes, so there is nothing a replacing list could express that adding does not.
In both cases each configuration keeps its own contribution, so removing one of them only withdraws what this configuration had granted. While a configuration plugin is deployed but cannot be loaded, its approvals are kept: AI Studio does not withdraw approvals it cannot currently read.
One clarification for `DataChat.PreselectedDataSourceIds`: the IDs are not limited to the data sources of the same configuration. They are resolved against every known data source, including those of your other configurations and the ones a user configured. IDs that resolve to nothing are ignored.
## Example AI Studio configuration
The latest example of an AI Studio configuration via configuration plugin can always be found in the repository in the `app/MindWork AI Studio/Plugins/configuration` folder. Here are the links to the files: