Fixed local configuration plugins overriding the tool settings of an organization (#1139)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-31 13:50:00 +01:00
1 parent eef475581c
commit 2c2ad0dfde
5 files changed
+579 -80

No files matched your search

@@ -830,7 +830,7 @@ public static partial class ManagedConfiguration
var settingName = SettingName(propertyExpression);
var managedMode = ReadManagedConfigurationMode(propertyExpression, settings);
return HandleParsedDictionaryValue(configPluginId, dryRun, successful, configMeta, configuredValue, managedMode, settingName);
return HandleParsedScalarValue(configPluginId, dryRun, successful, configMeta, configuredValue, managedMode, settingName);
}
/// <summary>
@@ -1080,9 +1080,14 @@ 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))
// configuration plugin may touch it, no matter what it declares. A plugin which does not
// declare the setting touches nothing below unless it owns the setting, so we ask only when it
// declares the setting. Otherwise, the log would report every local plugin for every setting
// of the organization:
//
if (successful && !MayManageSetting(configPluginId, configMeta))
return false;
//
@@ -1141,9 +1146,14 @@ 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))
// configuration plugin may touch it, no matter what it declares. A plugin which does not
// declare the setting touches nothing below unless it owns the setting, so we ask only when it
// declares the setting. Otherwise, the log would report every local plugin for every setting
// of the organization:
//
if (successful && !MayManageSetting(configPluginId, configMeta))
return false;
//
@@ -1204,67 +1214,6 @@ public static partial class ManagedConfiguration
return successful;
}
private static bool HandleParsedDictionaryValue<TClass>(
Guid configPluginId,
bool dryRun,
bool successful,
ConfigMeta<TClass, IDictionary<string, string>> configMeta,
IDictionary<string, string> configuredValue,
ManagedConfigurationMode managedMode,
string settingName)
{
if (dryRun)
return successful;
switch (successful)
{
case true when managedMode is ManagedConfigurationMode.LOCKED:
ClearEditableDefaultState(settingName);
configMeta.ClearEditableDefaultConfiguration();
configMeta.SetValue(CloneStringDictionary(configuredValue));
configMeta.LockConfiguration(configPluginId);
break;
case true when managedMode is ManagedConfigurationMode.EDITABLE_DEFAULT:
var currentValueSerialized = SerializeManagedStringDictionaryValue(configMeta.GetValue());
var configuredValueSerialized = SerializeManagedStringDictionaryValue(configuredValue);
string lastAppliedValue;
if (!TryGetEditableDefaultState(settingName, out var editableDefaultState))
{
configMeta.SetValue(CloneStringDictionary(configuredValue));
lastAppliedValue = configuredValueSerialized;
}
else
{
lastAppliedValue = editableDefaultState.LastAppliedValue;
if (string.Equals(currentValueSerialized, lastAppliedValue, StringComparison.Ordinal))
{
configMeta.SetValue(CloneStringDictionary(configuredValue));
lastAppliedValue = configuredValueSerialized;
}
}
SetEditableDefaultState(settingName, configPluginId, lastAppliedValue);
configMeta.UnlockConfiguration();
configMeta.SetEditableDefaultConfiguration(configPluginId);
break;
case false when configMeta.IsLocked && configMeta.LockedByConfigPluginId == configPluginId:
configMeta.ResetLockedConfiguration();
break;
case false when configMeta.ManagedMode is ManagedConfigurationMode.EDITABLE_DEFAULT
&& TryGetEditableDefaultState(settingName, out var editableDefaultStateToRemove)
&& editableDefaultStateToRemove.ConfigPluginId == configPluginId:
configMeta.ClearEditableDefaultConfiguration();
ClearEditableDefaultState(settingName);
break;
}
return successful;
}
private static ManagedConfigurationMode ReadManagedConfigurationMode<TClass, TValue>(
Expression<Func<TClass, TValue>> propertyExpression,
LuaTable settings)
@@ -1286,6 +1235,17 @@ public static partial class ManagedConfiguration
{
null => string.Empty,
string text => text,
//
// Tables of texts, i.e., the tool tables, keep the form they were stored in before they shared
// this method with the other settings. The settings of installed versions hold the last applied
// editable default in that form, and any other form would make each of them look as if the user
// had changed it, so that the organization could never update it again:
//
IDictionary<string, string> texts => string.Join("\n", texts
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}")),
System.Collections.IDictionary dictionary => string.Join(";", dictionary.Keys
.Cast<object>()
.OrderBy(key => key.ToString(), StringComparer.Ordinal)
@@ -1300,10 +1260,4 @@ public static partial class ManagedConfiguration
};
private static Dictionary<string, string> CloneStringDictionary(IDictionary<string, string> values) => new(values, StringComparer.Ordinal);
private static string SerializeManagedStringDictionaryValue(IDictionary<string, string> values) => string.Join(
"\n",
values
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
}
@@ -14,6 +14,17 @@ public static partial class ManagedConfiguration
private static ILogger Log => Program.LOGGER_FACTORY.CreateLogger(nameof(ManagedConfiguration));
/// <summary>
/// Decides whether a configuration plugin speaks for an organization.
/// </summary>
/// <remarks>
/// The plugin factory decides this by the directories of a running app, which a test does not
/// have. Tests therefore replace this for their duration and restore it afterward, so that they
/// can check what an organization owns against a local configuration plugin. The app itself
/// never replaces it.
/// </remarks>
internal static Func<Guid, bool> IsOrganizationPlugin { get; set; } = PluginFactory.IsOrganizationConfigurationPlugin;
/// <summary>
/// All registered settings, in no particular order.
/// </summary>
@@ -354,10 +365,10 @@ public static partial class ManagedConfiguration
if (owningConfigPluginId == Guid.Empty || owningConfigPluginId == configPluginId)
return true;
if (!PluginFactory.IsOrganizationConfigurationPlugin(owningConfigPluginId))
if (!IsOrganizationPlugin(owningConfigPluginId))
return true;
if (PluginFactory.IsOrganizationConfigurationPlugin(configPluginId))
if (IsOrganizationPlugin(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.");
@@ -3,6 +3,8 @@
- Improved the documentation for IT departments, which now lives in our [wiki](https://github.com/MindWorkAI/AI-Studio/wiki/Enterprise-IT). It is sorted by task, explains the setup on Windows, Linux, and macOS, and offers complete configurations for typical needs, such as using only local models. The link in the app settings leads there, and the wiki also holds the installation guide in English and German.
- Changed the size of AI Studio on laptop screens: it now appears in the same size on every laptop, whatever the scaling of your operating system. If you prefer the previous size, choose "System scaling" for the interface size in the app settings. Contributed by Paul Koudelka.
- Fixed AI Studio becoming unusable when an update failed after its download. The app now restarts with the version you have installed, so you can simply try the update again.
- Fixed configuration plugins you installed yourself being able to change the tool settings of your organization, such as the minimum provider confidence of a tool. These settings now follow the same rules as all other settings, which also gives you back your own values once your organization removes its configuration.
- Fixed the log of AI Studio reporting that a configuration plugin you installed yourself tried to take over a setting of your organization, even when the plugin does not contain that setting.
- Upgraded several libraries to improve security.
- Upgraded Tauri to v2.12.5.
- Upgraded MudBlazor to v9.12.0.
@@ -0,0 +1,493 @@
using System.Linq.Expressions;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using Lua;
using Lua.Standard;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Settings;
/// <summary>
/// Checks who owns a managed setting when configuration plugins of an organization and local ones
/// declare it.
/// </summary>
/// <remarks>
/// A local configuration plugin must never take over a setting an organization manages, while two
/// configuration plugins of the same organization may override each other. Whether a plugin speaks for
/// an organization depends on the directory it lives in, which only a running app has, so this fixture
/// tells ManagedConfiguration which of its plugin IDs belong to an organization. The settings are reached
/// through Program.SERVICE_PROVIDER, and the managed state of each setting is static, which is why this
/// fixture does not run alongside others.
/// </remarks>
[TestFixture]
[NonParallelizable]
public sealed class ManagedSettingOwnershipTests
{
private static readonly Guid ORGANIZATION_PLUGIN_ID = Guid.Parse("5d0e7c31-8a4f-4b62-9e17-2c6b8f4a1d93");
private static readonly Guid SECOND_ORGANIZATION_PLUGIN_ID = Guid.Parse("a7c94e02-3b5d-4f81-8d26-7e1f0b9c4a58");
private static readonly Guid LOCAL_PLUGIN_ID = Guid.Parse("e2b61f8d-4c07-4a9e-b3d5-90f8a6c2e714");
private const string PROHIBITED_PROVIDERS = "DataApp.ProhibitedProviders";
private const string LOCKED_TOOL_SETTINGS = "DataTools.LockedToolSettings";
private const string DEFAULT_TOOL_SETTINGS = "DataTools.DefaultToolSettings";
private const string MINIMUM_PROVIDER_CONFIDENCE = "DataTools.MinimumProviderConfidenceByToolId";
private SettingsManager settingsManager = null!;
private ServiceProvider serviceProvider = null!;
private IServiceProvider previousServiceProvider = null!;
private Func<Guid, bool> previousIsOrganizationPlugin = null!;
private ILoggerFactory previousLoggerFactory = null!;
[SetUp]
public void CreateSettings()
{
// Creating the settings data registers every managed setting:
this.settingsManager = new SettingsManager(NullLogger<SettingsManager>.Instance, null!);
this.previousServiceProvider = Program.SERVICE_PROVIDER;
this.serviceProvider = new ServiceCollection().AddSingleton(this.settingsManager).BuildServiceProvider();
Program.SERVICE_PROVIDER = this.serviceProvider;
this.previousIsOrganizationPlugin = ManagedConfiguration.IsOrganizationPlugin;
ManagedConfiguration.IsOrganizationPlugin = pluginId => pluginId == ORGANIZATION_PLUGIN_ID || pluginId == SECOND_ORGANIZATION_PLUGIN_ID;
this.previousLoggerFactory = Program.LOGGER_FACTORY;
}
[TearDown]
public void RestoreApplicationState()
{
// The managed state of a setting outlives this fixture, so we release what the plugins took over:
ManagedConfiguration.CleanupLeftOverManagedConfigurations([], new HashSet<Guid>());
ManagedConfiguration.IsOrganizationPlugin = this.previousIsOrganizationPlugin;
Program.LOGGER_FACTORY = this.previousLoggerFactory;
Program.SERVICE_PROVIDER = this.previousServiceProvider;
this.serviceProvider.Dispose();
}
[Test]
public async Task ALocalPluginCannotTakeOverALockedSettingOfTheOrganization()
{
await ProcessProhibitedProvidersAsync(ORGANIZATION_PLUGIN_ID, """{ "DEEP_SEEK" }""");
var processed = await ProcessProhibitedProvidersAsync(LOCAL_PLUGIN_ID, """{ "MISTRAL" }""");
ManagedConfiguration.TryGet(x => x.App, x => x.ProhibitedProviders, out var configMeta);
Assert.Multiple(() =>
{
Assert.That(processed, Is.False, "The setting of the organization is out of reach of a local plugin.");
Assert.That(this.settingsManager.ConfigurationData.App.ProhibitedProviders, Is.EquivalentTo(new[] { LLMProviders.DEEP_SEEK }), "The value of the organization has to stay.");
Assert.That(configMeta.LockedByConfigPluginId, Is.EqualTo(ORGANIZATION_PLUGIN_ID), "The organization has to stay the owner of the setting.");
});
}
[Test]
public async Task ALocalPluginCannotTakeOverAnEditableDefaultOfTheOrganization()
{
await ProcessUpdateIntervalAsync(ORGANIZATION_PLUGIN_ID, "WEEKLY", allowUserOverride: true);
var processed = await ProcessUpdateIntervalAsync(LOCAL_PLUGIN_ID, "DAILY", allowUserOverride: false);
ManagedConfiguration.TryGet(x => x.App, x => x.UpdateInterval, out var configMeta);
Assert.Multiple(() =>
{
Assert.That(processed, Is.False, "The editable default of the organization is out of reach of a local plugin.");
Assert.That(this.settingsManager.ConfigurationData.App.UpdateInterval, Is.EqualTo(UpdateInterval.WEEKLY), "The value of the organization has to stay.");
Assert.That(configMeta.IsLocked, Is.False, "A local plugin must not lock what the organization left to the user.");
Assert.That(ManagedConfiguration.GetSettingOwner(configMeta), Is.EqualTo(ORGANIZATION_PLUGIN_ID), "The organization has to stay the owner of the setting.");
});
}
[Test]
public async Task AnotherOrganizationPluginMayTakeOverTheSetting()
{
await ProcessProhibitedProvidersAsync(ORGANIZATION_PLUGIN_ID, """{ "DEEP_SEEK" }""");
var processed = await ProcessProhibitedProvidersAsync(SECOND_ORGANIZATION_PLUGIN_ID, """{ "MISTRAL" }""");
ManagedConfiguration.TryGet(x => x.App, x => x.ProhibitedProviders, out var configMeta);
Assert.Multiple(() =>
{
Assert.That(processed, Is.True, "Two configuration plugins of the same organization may override each other.");
Assert.That(this.settingsManager.ConfigurationData.App.ProhibitedProviders, Is.EquivalentTo(new[] { LLMProviders.MISTRAL }), "The plugin applied later wins.");
Assert.That(configMeta.LockedByConfigPluginId, Is.EqualTo(SECOND_ORGANIZATION_PLUGIN_ID));
});
}
[Test]
public async Task ALocalPluginManagesASettingNoOrganizationManages()
{
var processed = await ProcessProhibitedProvidersAsync(LOCAL_PLUGIN_ID, """{ "MISTRAL" }""");
ManagedConfiguration.TryGet(x => x.App, x => x.ProhibitedProviders, out var configMeta);
Assert.Multiple(() =>
{
Assert.That(processed, Is.True, "Without an organization, a local plugin may manage the setting.");
Assert.That(this.settingsManager.ConfigurationData.App.ProhibitedProviders, Is.EquivalentTo(new[] { LLMProviders.MISTRAL }));
Assert.That(configMeta.LockedByConfigPluginId, Is.EqualTo(LOCAL_PLUGIN_ID));
});
}
[TestCase(LOCKED_TOOL_SETTINGS)]
[TestCase(DEFAULT_TOOL_SETTINGS)]
[TestCase(MINIMUM_PROVIDER_CONFIDENCE)]
public async Task ALocalPluginCannotReplaceAToolTableOfTheOrganization(string settingName)
{
var entry = SampleEntryOf(settingName);
await ProcessToolTableAsync(settingName, ORGANIZATION_PLUGIN_ID, ToLuaTable(entry.Key, entry.OrganizationValue), allowUserOverride: false);
var processed = await ProcessToolTableAsync(settingName, LOCAL_PLUGIN_ID, ToLuaTable(entry.Key, entry.LocalValue), allowUserOverride: false);
var configMeta = GetToolTableMeta(settingName);
Assert.Multiple(() =>
{
Assert.That(processed, Is.False, "The table of the organization is out of reach of a local plugin.");
Assert.That(this.ToolTableOf(settingName), Is.EquivalentTo(new Dictionary<string, string> { [entry.Key] = entry.OrganizationValue }), "The values of the organization have to stay.");
Assert.That(configMeta.LockedByConfigPluginId, Is.EqualTo(ORGANIZATION_PLUGIN_ID), "The organization has to stay the owner of the table.");
});
}
[TestCase(LOCKED_TOOL_SETTINGS)]
[TestCase(DEFAULT_TOOL_SETTINGS)]
[TestCase(MINIMUM_PROVIDER_CONFIDENCE)]
public async Task ALocalPluginCannotTurnALockedToolTableIntoAnEditableDefault(string settingName)
{
var entry = SampleEntryOf(settingName);
await ProcessToolTableAsync(settingName, ORGANIZATION_PLUGIN_ID, ToLuaTable(entry.Key, entry.OrganizationValue), allowUserOverride: false);
var processed = await ProcessToolTableAsync(settingName, LOCAL_PLUGIN_ID, ToLuaTable(entry.Key, entry.LocalValue), allowUserOverride: true);
var configMeta = GetToolTableMeta(settingName);
Assert.Multiple(() =>
{
Assert.That(processed, Is.False);
Assert.That(configMeta.IsLocked, Is.True, "A local plugin must not hand the user a table the organization locked.");
Assert.That(configMeta.LockedByConfigPluginId, Is.EqualTo(ORGANIZATION_PLUGIN_ID));
Assert.That(this.ToolTableOf(settingName), Is.EquivalentTo(new Dictionary<string, string> { [entry.Key] = entry.OrganizationValue }));
});
}
[TestCase(LOCKED_TOOL_SETTINGS)]
[TestCase(DEFAULT_TOOL_SETTINGS)]
[TestCase(MINIMUM_PROVIDER_CONFIDENCE)]
public async Task ALocalPluginCannotReplaceAnEditableDefaultToolTableOfTheOrganization(string settingName)
{
var entry = SampleEntryOf(settingName);
await ProcessToolTableAsync(settingName, ORGANIZATION_PLUGIN_ID, ToLuaTable(entry.Key, entry.OrganizationValue), allowUserOverride: true);
var processed = await ProcessToolTableAsync(settingName, LOCAL_PLUGIN_ID, ToLuaTable(entry.Key, entry.LocalValue), allowUserOverride: false);
var configMeta = GetToolTableMeta(settingName);
Assert.Multiple(() =>
{
Assert.That(processed, Is.False);
Assert.That(configMeta.IsLocked, Is.False, "A local plugin must not lock what the organization left to the user.");
Assert.That(ManagedConfiguration.GetSettingOwner(configMeta), Is.EqualTo(ORGANIZATION_PLUGIN_ID), "The organization has to stay the owner of the table.");
Assert.That(this.ToolTableOf(settingName), Is.EquivalentTo(new Dictionary<string, string> { [entry.Key] = entry.OrganizationValue }));
});
}
[TestCase(LOCKED_TOOL_SETTINGS)]
[TestCase(DEFAULT_TOOL_SETTINGS)]
[TestCase(MINIMUM_PROVIDER_CONFIDENCE)]
public async Task AnotherOrganizationPluginMayReplaceAToolTable(string settingName)
{
var entry = SampleEntryOf(settingName);
await ProcessToolTableAsync(settingName, ORGANIZATION_PLUGIN_ID, ToLuaTable(entry.Key, entry.OrganizationValue), allowUserOverride: false);
var processed = await ProcessToolTableAsync(settingName, SECOND_ORGANIZATION_PLUGIN_ID, ToLuaTable(entry.Key, entry.LocalValue), allowUserOverride: false);
var configMeta = GetToolTableMeta(settingName);
Assert.Multiple(() =>
{
Assert.That(processed, Is.True, "Two configuration plugins of the same organization may override each other.");
Assert.That(this.ToolTableOf(settingName), Is.EquivalentTo(new Dictionary<string, string> { [entry.Key] = entry.LocalValue }), "The plugin applied later wins.");
Assert.That(configMeta.LockedByConfigPluginId, Is.EqualTo(SECOND_ORGANIZATION_PLUGIN_ID));
});
}
[TestCase(LOCKED_TOOL_SETTINGS)]
[TestCase(DEFAULT_TOOL_SETTINGS)]
[TestCase(MINIMUM_PROVIDER_CONFIDENCE)]
public async Task ALocalPluginManagesAToolTableNoOrganizationManages(string settingName)
{
var entry = SampleEntryOf(settingName);
var processed = await ProcessToolTableAsync(settingName, LOCAL_PLUGIN_ID, ToLuaTable(entry.Key, entry.LocalValue), allowUserOverride: false);
var configMeta = GetToolTableMeta(settingName);
Assert.Multiple(() =>
{
Assert.That(processed, Is.True, "Without an organization, a local plugin may manage the table.");
Assert.That(this.ToolTableOf(settingName), Is.EquivalentTo(new Dictionary<string, string> { [entry.Key] = entry.LocalValue }));
Assert.That(configMeta.LockedByConfigPluginId, Is.EqualTo(LOCAL_PLUGIN_ID));
});
}
/// <summary>
/// Checks that the user gets their own minimum confidence levels back once the organization's
/// configuration plugin is gone.
/// </summary>
/// <remarks>
/// Only this one of the tool tables holds values of the user: the tool settings of the user live
/// elsewhere, and the two other tables belong to configuration plugins alone.
/// </remarks>
[TestCase(false)]
[TestCase(true)]
public async Task WithdrawingTheOrganizationHandsTheUsersConfidenceLevelsBack(bool allowUserOverride)
{
this.SetUsersConfidenceLevels(new() { ["read_web_page"] = "MEDIUM" });
await ProcessToolTableAsync(MINIMUM_PROVIDER_CONFIDENCE, ORGANIZATION_PLUGIN_ID, ToLuaTable("read_web_page", "HIGH"), allowUserOverride);
Assert.That(this.ToolTableOf(MINIMUM_PROVIDER_CONFIDENCE), Is.EquivalentTo(new Dictionary<string, string> { ["read_web_page"] = "HIGH" }), "While the organization manages the table, its level applies.");
ManagedConfiguration.CleanupLeftOverManagedConfigurations([], new HashSet<Guid>());
Assert.That(this.ToolTableOf(MINIMUM_PROVIDER_CONFIDENCE), Is.EquivalentTo(new Dictionary<string, string> { ["read_web_page"] = "MEDIUM" }), "Without its plugin, the user has to get their own level back instead of the defaults.");
}
[TestCase(false)]
[TestCase(true)]
public async Task DroppingTheTableFromTheConfigurationHandsTheUsersConfidenceLevelsBack(bool allowUserOverride)
{
this.SetUsersConfidenceLevels(new() { ["read_web_page"] = "MEDIUM" });
await ProcessToolTableAsync(MINIMUM_PROVIDER_CONFIDENCE, ORGANIZATION_PLUGIN_ID, ToLuaTable("read_web_page", "HIGH"), allowUserOverride);
// The plugin stays, but its next version no longer declares the table:
await ProcessToolTableAsync(MINIMUM_PROVIDER_CONFIDENCE, ORGANIZATION_PLUGIN_ID, "nil", allowUserOverride);
Assert.That(this.ToolTableOf(MINIMUM_PROVIDER_CONFIDENCE), Is.EquivalentTo(new Dictionary<string, string> { ["read_web_page"] = "MEDIUM" }), "Once no plugin manages the table, the user has to get their own level back.");
}
[Test]
public async Task AnEditableDefaultTheUserChangedOutlivesTheOrganization()
{
this.SetUsersConfidenceLevels(new() { ["read_web_page"] = "MEDIUM" });
await ProcessToolTableAsync(MINIMUM_PROVIDER_CONFIDENCE, ORGANIZATION_PLUGIN_ID, ToLuaTable("read_web_page", "HIGH"), allowUserOverride: true);
this.SetUsersConfidenceLevels(new() { ["read_web_page"] = "LOW" });
ManagedConfiguration.CleanupLeftOverManagedConfigurations([], new HashSet<Guid>());
Assert.That(this.ToolTableOf(MINIMUM_PROVIDER_CONFIDENCE), Is.EquivalentTo(new Dictionary<string, string> { ["read_web_page"] = "LOW" }), "The user decided against the default of the organization, and their decision outlives it.");
}
[Test]
public async Task AnUpdatedEditableDefaultReachesUsersWhoKeptTheOldOne()
{
await ProcessToolTableAsync(MINIMUM_PROVIDER_CONFIDENCE, ORGANIZATION_PLUGIN_ID, ToLuaTable("read_web_page", "HIGH"), allowUserOverride: true);
await ProcessToolTableAsync(MINIMUM_PROVIDER_CONFIDENCE, ORGANIZATION_PLUGIN_ID, ToLuaTable("read_web_page", "MODERATE"), allowUserOverride: true);
Assert.That(this.ToolTableOf(MINIMUM_PROVIDER_CONFIDENCE), Is.EquivalentTo(new Dictionary<string, string> { ["read_web_page"] = "MODERATE" }), "Whoever kept the previous default gets the new one.");
}
/// <summary>
/// Checks the form in which the last applied editable default of a tool table is stored.
/// </summary>
/// <remarks>
/// The settings of installed versions hold this form. Another one would make every stored default
/// look as if the user had changed it, so that the organization could never update it again.
/// </remarks>
[Test]
public async Task TheStoredEditableDefaultOfAToolTableKeepsItsForm()
{
await ProcessToolTableAsync(MINIMUM_PROVIDER_CONFIDENCE, ORGANIZATION_PLUGIN_ID, """{ ["read_web_page"] = "HIGH", ["mailboxes"] = "MEDIUM" }""", allowUserOverride: true);
var editableDefaultState = this.settingsManager.ConfigurationData.ManagedEditableDefaults[MINIMUM_PROVIDER_CONFIDENCE];
Assert.That(editableDefaultState.LastAppliedValue, Is.EqualTo("mailboxes=MEDIUM\nread_web_page=HIGH"));
}
/// <summary>
/// Checks that the log names a local configuration plugin only for a setting of the organization
/// which the plugin actually declares.
/// </summary>
/// <remarks>
/// Administrators read this warning as an attempt to take over what their organization configured.
/// A local plugin which merely exists, without declaring the setting, attempts nothing, so warning
/// about it would bury the real attempts under false ones: one for every setting of the organization,
/// every time the plugins load. Both ways of handling a setting are covered, the one for sets and the
/// one for single values and tables.
/// </remarks>
[TestCase(PROHIBITED_PROVIDERS, """{ "DEEP_SEEK" }""", """{ "MISTRAL" }""")]
[TestCase(MINIMUM_PROVIDER_CONFIDENCE, """{ ["read_web_page"] = "HIGH" }""", """{ ["read_web_page"] = "NONE" }""")]
public async Task ALocalPluginIsReportedOnlyForSettingsItDeclares(string settingName, string organizationValue, string localValue)
{
await ProcessSettingAsync(settingName, ORGANIZATION_PLUGIN_ID, organizationValue);
var recorder = new WarningRecorder();
Program.LOGGER_FACTORY = recorder;
await ProcessSettingAsync(settingName, LOCAL_PLUGIN_ID, "nil");
var warningsWithoutDeclaration = recorder.Warnings.ToList();
await ProcessSettingAsync(settingName, LOCAL_PLUGIN_ID, localValue);
var warningsWithDeclaration = recorder.Warnings.Skip(warningsWithoutDeclaration.Count).ToList();
Assert.Multiple(() =>
{
Assert.That(warningsWithoutDeclaration, Is.Empty, "A local plugin which does not declare the setting attempts nothing.");
Assert.That(warningsWithDeclaration, Has.Count.EqualTo(1), "A local plugin which declares the setting of the organization has to be reported.");
Assert.That(warningsWithDeclaration, Has.All.Contains(settingName).And.All.Contains(LOCAL_PLUGIN_ID.ToString()), "The warning has to name the setting and the plugin.");
});
}
/// <summary>
/// Lets a configuration plugin set one of the settings which ALocalPluginIsReportedOnlyForSettingsItDeclares covers.
/// </summary>
private static Task<bool> ProcessSettingAsync(string settingName, Guid pluginId, string luaValue) => settingName switch
{
PROHIBITED_PROVIDERS => ProcessProhibitedProvidersAsync(pluginId, luaValue),
_ => ProcessToolTableAsync(settingName, pluginId, luaValue, allowUserOverride: false),
};
/// <summary>
/// Lets a configuration plugin set the prohibited providers, a setting which holds a set.
/// </summary>
private static async Task<bool> ProcessProhibitedProvidersAsync(Guid pluginId, string luaList)
{
var settings = await CreateSettingsAsync(PROHIBITED_PROVIDERS, luaList, allowUserOverride: false);
return ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ProhibitedProviders, pluginId, settings, false);
}
/// <summary>
/// Lets a configuration plugin set the update interval, a setting which holds a single value.
/// </summary>
private static async Task<bool> ProcessUpdateIntervalAsync(Guid pluginId, string interval, bool allowUserOverride)
{
var settings = await CreateSettingsAsync("DataApp.UpdateInterval", $"\"{interval}\"", allowUserOverride);
return ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.UpdateInterval, pluginId, settings, false);
}
/// <summary>
/// Lets a configuration plugin set one of the tool tables, the settings which hold a table of texts.
/// </summary>
/// <param name="settingName">The name of the tool table.</param>
/// <param name="pluginId">The configuration plugin.</param>
/// <param name="luaTable">The table in Lua notation, or nil for a plugin which does not declare it.</param>
/// <param name="allowUserOverride">True when the plugin offers the table as an editable default instead of locking it.</param>
private static async Task<bool> ProcessToolTableAsync(string settingName, Guid pluginId, string luaTable, bool allowUserOverride)
{
var settings = await CreateSettingsAsync(settingName, luaTable, allowUserOverride);
return ManagedConfiguration.TryProcessConfiguration(x => x.Tools, ToolTableProperty(settingName), pluginId, settings, false);
}
/// <summary>
/// Gets the managed state of a tool table.
/// </summary>
private static ConfigMeta<DataTools, IDictionary<string, string>> GetToolTableMeta(string settingName)
{
if (!ManagedConfiguration.TryGet(x => x.Tools, ToolTableProperty(settingName), out var configMeta))
throw new InvalidOperationException($"The tool table '{settingName}' is not registered.");
return configMeta;
}
/// <summary>
/// Selects the property of a tool table, the way PluginConfiguration hands it to ManagedConfiguration.
/// </summary>
private static Expression<Func<DataTools, IDictionary<string, string>>> ToolTableProperty(string settingName) => settingName switch
{
LOCKED_TOOL_SETTINGS => x => x.LockedToolSettings,
DEFAULT_TOOL_SETTINGS => x => x.DefaultToolSettings,
MINIMUM_PROVIDER_CONFIDENCE => x => x.MinimumProviderConfidenceByToolId,
_ => throw new ArgumentOutOfRangeException(nameof(settingName), settingName, "This test knows no such tool table."),
};
/// <summary>
/// Reads a tool table straight from the settings data.
/// </summary>
private Dictionary<string, string> ToolTableOf(string settingName) => settingName switch
{
LOCKED_TOOL_SETTINGS => this.settingsManager.ConfigurationData.Tools.LockedToolSettings,
DEFAULT_TOOL_SETTINGS => this.settingsManager.ConfigurationData.Tools.DefaultToolSettings,
MINIMUM_PROVIDER_CONFIDENCE => this.settingsManager.ConfigurationData.Tools.MinimumProviderConfidenceByToolId,
_ => throw new ArgumentOutOfRangeException(nameof(settingName), settingName, "This test knows no such tool table."),
};
/// <summary>
/// Sets the minimum confidence levels the user chose for the tools.
/// </summary>
/// <remarks>
/// This assigns a new table instead of changing the present one: the settings data registered
/// first in a test run shares its table with the default value of the setting, and changing it
/// would change that default for every later test.
/// </remarks>
private void SetUsersConfidenceLevels(Dictionary<string, string> levels) => this.settingsManager.ConfigurationData.Tools.MinimumProviderConfidenceByToolId = new(levels, StringComparer.Ordinal);
/// <summary>
/// Chooses an entry which suits a tool table: a strict value of an organization, and a loose
/// value of a local plugin.
/// </summary>
private static ToolTableEntry SampleEntryOf(string settingName) => settingName switch
{
MINIMUM_PROVIDER_CONFIDENCE => new("read_web_page", "HIGH", "NONE"),
_ => new("read_web_page.freeAddressChoice", "OFF", "ON"),
};
private static string ToLuaTable(string key, string value) => $$"""{ ["{{key}}"] = "{{value}}" }""";
/// <summary>
/// An entry of a tool table, with the value an organization configures and the one a local plugin configures.
/// </summary>
private sealed record ToolTableEntry(string Key, string OrganizationValue, string LocalValue);
/// <summary>
/// Records the warnings ManagedConfiguration writes to the log.
/// </summary>
/// <remarks>
/// Every other category gets a logger which writes nowhere. A type which keeps its logger in a
/// static field, and happens to be initialized during the test, would otherwise keep this recorder
/// for the rest of the test run.
/// </remarks>
private sealed class WarningRecorder : ILoggerFactory, ILogger
{
private readonly List<string> warnings = [];
public IReadOnlyList<string> Warnings => this.warnings;
public ILogger CreateLogger(string categoryName) => categoryName == nameof(ManagedConfiguration) ? this : NullLogger.Instance;
public void AddProvider(ILoggerProvider provider)
{
}
public void Dispose()
{
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => logLevel is LogLevel.Warning;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (logLevel is LogLevel.Warning)
this.warnings.Add(formatter(state, exception));
}
}
/// <summary>
/// Builds the table of settings a configuration plugin declares.
/// </summary>
/// <param name="settingName">The name of the setting.</param>
/// <param name="luaValue">The value of the setting in Lua notation.</param>
/// <param name="allowUserOverride">True when the plugin offers the value as an editable default instead of locking it.</param>
private static async Task<LuaTable> CreateSettingsAsync(string settingName, string luaValue, bool allowUserOverride)
{
var state = LuaState.Create();
state.OpenBasicLibrary();
state.OpenTableLibrary();
await state.DoStringAsync($$"""
SETTINGS = {}
SETTINGS["{{settingName}}"] = {{luaValue}}
SETTINGS["{{settingName}}.AllowUserOverride"] = {{(allowUserOverride ? "true" : "false")}}
""");
if (!state.Environment["SETTINGS"].TryRead<LuaTable>(out var settingsTable))
throw new InvalidOperationException("The settings of this test are not a Lua table.");
return settingsTable;
}
}
@@ -22,14 +22,17 @@ namespace AIStudio.Tests.Tools.PluginSystem;
/// Briefing assistant stayed out of reach of every organization that way.<br/><br/>
/// The test therefore configures every registered setting in one plugin and runs it for real. A dry
/// run would not do: it reads the values but applies none of them, so it cannot tell which settings
/// were read. The settings are reached through Program.SERVICE_PROVIDER, and the managed state of each
/// setting is static, which is why this fixture does not run alongside others.
/// were read. The same plugin, run once for an organization and once locally, shows that no way of
/// reading a setting lets a local plugin take over what the organization manages. The settings are
/// reached through Program.SERVICE_PROVIDER, and the managed state of each setting is static, which is
/// why this fixture does not run alongside others.
/// </remarks>
[TestFixture]
[NonParallelizable]
public sealed class ManagedSettingsCoverageTests
{
private static readonly Guid PLUGIN_ID = Guid.Parse("cd426cd3-bcff-4d6f-87bd-2c3c0756aa7d");
private static readonly Guid LOCAL_PLUGIN_ID = Guid.Parse("71f3b8e5-2d94-4c0a-a6e1-5b8c07d2f9a3");
/// <summary>
/// The value configured for every setting holding a string.
@@ -72,6 +75,7 @@ public sealed class ManagedSettingsCoverageTests
private RustService rustService = null!;
private ServiceProvider serviceProvider = null!;
private IServiceProvider previousServiceProvider = null!;
private Func<Guid, bool> previousIsOrganizationPlugin = null!;
[SetUp]
public void CreateSettings()
@@ -85,6 +89,8 @@ public sealed class ManagedSettingsCoverageTests
this.previousServiceProvider = Program.SERVICE_PROVIDER;
this.serviceProvider = new ServiceCollection().AddSingleton(settingsManager).AddSingleton(this.rustService).BuildServiceProvider();
Program.SERVICE_PROVIDER = this.serviceProvider;
this.previousIsOrganizationPlugin = ManagedConfiguration.IsOrganizationPlugin;
}
[TearDown]
@@ -96,6 +102,7 @@ public sealed class ManagedSettingsCoverageTests
// long as it still reaches the settings of this test:
//
ManagedConfiguration.CleanupLeftOverManagedConfigurations([], new HashSet<Guid>());
ManagedConfiguration.IsOrganizationPlugin = this.previousIsOrganizationPlugin;
Program.SERVICE_PROVIDER = this.previousServiceProvider;
this.serviceProvider.Dispose();
@@ -105,7 +112,7 @@ public sealed class ManagedSettingsCoverageTests
[Test]
public async Task EveryRegisteredSettingCanBeManagedByAConfigurationPlugin()
{
using var plugin = await RunPluginWithEveryRegisteredSettingAsync();
using var plugin = await RunPluginWithEveryRegisteredSettingAsync(PLUGIN_ID);
var registeredSettings = ManagedConfiguration.RegisteredSettings.ToList();
var unmanagedSettingNames = registeredSettings
.Where(setting => !EXEMPT_SETTINGS.ContainsKey(setting.SettingName) && !IsManagedByThePlugin(setting))
@@ -124,7 +131,7 @@ public sealed class ManagedSettingsCoverageTests
[Test]
public async Task EveryExemptSettingIsRegisteredAndStaysUnmanaged()
{
using var plugin = await RunPluginWithEveryRegisteredSettingAsync();
using var plugin = await RunPluginWithEveryRegisteredSettingAsync(PLUGIN_ID);
var registeredSettings = ManagedConfiguration.RegisteredSettings.ToDictionary(setting => setting.SettingName, StringComparer.Ordinal);
Assert.Multiple(() =>
@@ -138,15 +145,47 @@ public sealed class ManagedSettingsCoverageTests
});
}
/// <summary>
/// Checks that a local configuration plugin takes over none of the settings an organization manages.
/// </summary>
/// <remarks>
/// Every way of reading a setting from a configuration plugin has to ask whether that plugin may
/// manage the setting before it changes anything. A way which forgets to ask lets any local plugin
/// replace what the organization configured, as the tool tables once did. A local plugin declaring
/// every setting after the organization did finds such a way, whichever setting it serves.
/// </remarks>
[Test]
public async Task NoLocalPluginTakesOverASettingOfAnOrganization()
{
ManagedConfiguration.IsOrganizationPlugin = pluginId => pluginId == PLUGIN_ID;
using var organizationPlugin = await RunPluginWithEveryRegisteredSettingAsync(PLUGIN_ID);
var settingsOfTheOrganization = ManagedConfiguration.RegisteredSettings.Where(IsManagedByThePlugin).ToList();
using var localPlugin = await RunPluginWithEveryRegisteredSettingAsync(LOCAL_PLUGIN_ID);
var takenOverSettingNames = settingsOfTheOrganization
.Where(setting => !IsManagedByThePlugin(setting))
.Select(setting => setting.SettingName)
.Order(StringComparer.Ordinal)
.ToList();
Assert.Multiple(() =>
{
Assert.That(settingsOfTheOrganization, Is.Not.Empty, "The organization manages no setting, so this test would check nothing.");
Assert.That(takenOverSettingNames, Is.Empty, "A local configuration plugin took these settings over from the organization. Every way of reading a setting from a configuration plugin has to ask ManagedConfiguration.MayManageSetting before it changes anything.");
});
}
/// <summary>
/// Loads a configuration plugin which declares a valid value for every registered setting, and
/// applies it the way AI Studio does when the plugin starts.
/// </summary>
/// <param name="pluginId">The ID of the configuration plugin.</param>
/// <returns>The started configuration plugin.</returns>
private static async Task<PluginConfiguration> RunPluginWithEveryRegisteredSettingAsync()
private static async Task<PluginConfiguration> RunPluginWithEveryRegisteredSettingAsync(Guid pluginId)
{
var code = new StringBuilder($$"""
ID = "{{PLUGIN_ID}}"
ID = "{{pluginId}}"
NAME = "Configuration for the tests"
DESCRIPTION = "A configuration plugin which declares every managed setting."
VERSION = "1.0.0"