2026-07-05 15:20:29 +02:00
using System.Globalization ;
2026-09-04 15:48:07 +02:00
using AIStudio.Provider ;
2025-06-01 21:14:21 +02:00
using AIStudio.Settings ;
2026-04-10 17:11:05 +02:00
using AIStudio.Settings.DataModel ;
2026-02-07 22:59:41 +01:00
using AIStudio.Tools.Services ;
2025-06-01 21:14:21 +02:00
using Lua ;
namespace AIStudio.Tools.PluginSystem ;
2026-09-13 14:17:25 +02:00
public sealed class PluginConfiguration ( bool isInternal , LuaState state , PluginType type ) : PluginBase ( isInternal , state , type ), ILivePluginContentSource
2025-06-01 21:14:21 +02:00
{
private static string TB ( string fallbackEN ) => I18N . I . T ( fallbackEN , typeof ( PluginConfiguration ). Namespace , nameof ( PluginConfiguration ));
2026-06-10 21:01:27 +02:00
private static SettingsManager SettingsManagerAccess => Program . SERVICE_PROVIDER . GetRequiredService < SettingsManager >();
2026-02-07 22:59:41 +01:00
private static readonly ILogger LOG = Program . LOGGER_FACTORY . CreateLogger ( nameof ( PluginConfiguration ));
2025-08-26 10:59:56 +02:00
private List < PluginConfigurationObject > configObjects = [];
2026-04-10 17:11:05 +02:00
private List < DataMandatoryInfo > mandatoryInfos = [];
2026-06-20 20:28:22 +02:00
private List < DataIntroduction > introductions = [];
2025-08-18 20:40:52 +02:00
/// <summary>
/// The list of configuration objects. Configuration objects are, e.g., providers or chat templates.
/// </summary>
public IEnumerable < PluginConfigurationObject > ConfigObjects => this . configObjects ;
2026-02-19 20:43:47 +01:00
2026-04-10 17:11:05 +02:00
/// <summary>
/// The list of mandatory infos provided by this configuration plugin.
2026-06-20 20:28:22 +02:00
/// Mandatory infos are live plugin content and are not persisted to ConfigurationData.
2026-04-10 17:11:05 +02:00
/// </summary>
public IReadOnlyList < DataMandatoryInfo > MandatoryInfos => this . mandatoryInfos ;
2026-06-20 20:28:22 +02:00
/// <summary>
/// The list of introductions provided by this configuration plugin.
/// Introductions are live plugin content and are not persisted to ConfigurationData.
/// </summary>
public IReadOnlyList < DataIntroduction > Introductions => this . introductions ;
2026-02-19 20:43:47 +01:00
/// <summary>
/// True/false when explicitly configured in the plugin, otherwise null.
/// </summary>
public bool? DeployedUsingConfigServer { get ; } = ReadDeployedUsingConfigServer ( state );
2026-08-08 18:35:46 +02:00
/// <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 );
2026-08-09 19:01:58 +02:00
/// <summary>
/// How many settings this configuration plugin declares.
/// </summary>
/// <remarks>
/// This counts the entries of the Lua SETTINGS table, without the <c>.AllowUserOverride</c>
/// companions. We need it for the import preview: a dry run does not lock anything, so the
/// number of settings the plugin would take over cannot be read from the managed configuration
/// at that point.
/// </remarks>
public int DeclaredSettingsCount { get ; private set ; }
2025-08-18 20:40:52 +02:00
2025-08-09 19:29:43 +02:00
public async Task InitializeAsync ( bool dryRun )
2025-06-01 21:14:21 +02:00
{
2025-08-09 19:29:43 +02:00
if (! this . TryProcessConfiguration ( dryRun , out var issue ))
2026-04-09 10:08:37 +02:00
this . PluginIssues . Add ( issue );
2025-08-09 19:29:43 +02:00
if (! dryRun )
{
2026-09-09 18:43:37 +02:00
await PluginConfigurationObject . SyncManagedTokenizersAsync ( this . Id , this . PluginPath );
2026-02-07 22:59:41 +01:00
// Store any decrypted API keys from enterprise configuration in the OS keyring:
await StoreEnterpriseApiKeysAsync ();
2026-05-18 16:26:51 +02:00
await StoreEnterpriseSecretsAsync ();
2026-02-07 22:59:41 +01:00
2026-06-10 21:01:27 +02:00
await SettingsManagerAccess . StoreSettings ();
2025-08-09 19:29:43 +02:00
await MessageBus . INSTANCE . SendMessage < bool >( null , Event . CONFIGURATION_CHANGED );
}
2025-06-01 21:14:21 +02:00
}
2025-08-09 19:29:43 +02:00
2026-05-18 16:26:51 +02:00
/// <summary>
/// Stores any pending enterprise secrets in the OS keyring.
/// </summary>
private static async Task StoreEnterpriseSecretsAsync ()
{
var pendingSecrets = PendingEnterpriseSecrets . GetAndClear ();
if ( pendingSecrets . Count == 0 )
return ;
LOG . LogInformation ( $"Storing {pendingSecrets.Count} enterprise secret(s) in the OS keyring." );
var rustService = Program . SERVICE_PROVIDER . GetRequiredService < RustService >();
foreach ( var pendingSecret in pendingSecrets )
{
try
{
var secretId = new TemporarySecretId ( pendingSecret . SecretId , pendingSecret . SecretName );
var result = await rustService . SetSecret ( secretId , pendingSecret . SecretData , pendingSecret . StoreType );
if ( result . Success )
LOG . LogDebug ( $"Successfully stored enterprise secret for '{pendingSecret.SecretName}' in the OS keyring." );
else
LOG . LogWarning ( $"Failed to store enterprise secret for '{pendingSecret.SecretName}': {result.Issue}" );
}
catch ( Exception ex )
{
LOG . LogError ( ex , $"Exception while storing enterprise secret for '{pendingSecret.SecretName}'." );
}
}
}
2026-02-07 22:59:41 +01:00
/// <summary>
/// Stores any pending enterprise API keys in the OS keyring.
/// </summary>
private static async Task StoreEnterpriseApiKeysAsync ()
{
var pendingKeys = PendingEnterpriseApiKeys . GetAndClear ();
if ( pendingKeys . Count == 0 )
return ;
LOG . LogInformation ( $"Storing {pendingKeys.Count} enterprise API key(s) in the OS keyring." );
var rustService = Program . SERVICE_PROVIDER . GetRequiredService < RustService >();
foreach ( var pendingKey in pendingKeys )
{
try
{
// Create a temporary secret ID object for storing the key:
var secretId = new TemporarySecretId ( pendingKey . SecretId , pendingKey . SecretName );
var result = await rustService . SetAPIKey ( secretId , pendingKey . ApiKey , pendingKey . StoreType );
if ( result . Success )
LOG . LogDebug ( $"Successfully stored enterprise API key for '{pendingKey.SecretName}' in the OS keyring." );
else
LOG . LogWarning ( $"Failed to store enterprise API key for '{pendingKey.SecretName}': {result.Issue}" );
}
catch ( Exception ex )
{
LOG . LogError ( ex , $"Exception while storing enterprise API key for '{pendingKey.SecretName}'." );
}
}
}
/// <summary>
/// Temporary implementation of ISecretId for storing enterprise API keys.
/// </summary>
private sealed record TemporarySecretId ( string SecretId , string SecretName ) : ISecretId ;
2026-02-19 20:43:47 +01:00
private static bool? ReadDeployedUsingConfigServer ( LuaState state )
{
if ( state . Environment [ "DEPLOYED_USING_CONFIG_SERVER" ]. TryRead < bool >( out var deployedUsingConfigServer ))
return deployedUsingConfigServer ;
return null ;
}
2026-08-08 18:35:46 +02:00
private static int ReadPriority ( LuaState state )
{
if ( state . Environment [ "PRIORITY" ]. TryRead < int >( out var priority ))
return priority ;
return 0 ;
}
2026-08-09 19:01:58 +02:00
/// <summary>
/// Counts the settings a configuration plugin declares, ignoring the <c>.AllowUserOverride</c>
/// companion keys: those refine a setting instead of adding one.
/// </summary>
private static int CountDeclaredSettings ( LuaTable settingsTable )
{
const string USER_OVERRIDE_SUFFIX = ".AllowUserOverride" ;
var count = 0 ;
var previousKey = LuaValue . Nil ;
while ( settingsTable . TryGetNext ( previousKey , out var pair ))
{
previousKey = pair . Key ;
if ( pair . Key . TryRead < string >( out var settingName ) && ! settingName . EndsWith ( USER_OVERRIDE_SUFFIX , StringComparison . Ordinal ))
count ++;
}
return count ;
}
2025-06-01 21:14:21 +02:00
/// <summary>
/// Tries to initialize the UI text content of the plugin.
/// </summary>
2025-08-18 20:40:52 +02:00
/// <param name="dryRun">When true, the method will not apply any changes but only check if the configuration can be read.</param>
2025-06-01 21:14:21 +02:00
/// <param name="message">The error message, when the UI text content could not be read.</param>
/// <returns>True, when the UI text content could be read successfully.</returns>
2025-08-09 19:29:43 +02:00
private bool TryProcessConfiguration ( bool dryRun , out string message )
2025-06-01 21:14:21 +02:00
{
2025-08-18 20:40:52 +02:00
this . configObjects . Clear ();
2026-04-10 17:11:05 +02:00
this . mandatoryInfos . Clear ();
2026-06-20 20:28:22 +02:00
this . introductions . Clear ();
2025-08-18 20:40:52 +02:00
2025-06-01 21:14:21 +02:00
// Ensure that the main CONFIG table exists and is a valid Lua table:
2026-04-09 10:08:37 +02:00
if (! this . State . Environment [ "CONFIG" ]. TryRead < LuaTable >( out var mainTable ))
2025-06-01 21:14:21 +02:00
{
message = TB ( "The CONFIG table does not exist or is not a valid table." );
return false ;
}
2025-08-26 10:59:56 +02:00
// Check for the main SETTINGS table:
2025-06-01 21:14:21 +02:00
if (! mainTable . TryGetValue ( "SETTINGS" , out var settingsValue ) || ! settingsValue . TryRead < LuaTable >( out var settingsTable ))
{
message = TB ( "The SETTINGS table does not exist or is not a valid table." );
return false ;
}
2026-08-09 19:01:58 +02:00
2026-09-04 15:48:07 +02:00
if (! TryValidateMinimumProviderConfidenceConfiguration ( settingsTable , out message ))
return false ;
2026-08-09 19:01:58 +02:00
this . DeclaredSettingsCount = CountDeclaredSettings ( settingsTable );
2025-08-09 19:29:43 +02:00
2025-08-26 10:59:56 +02:00
// Config: check for updates, and if so, how often?
2025-08-26 20:06:13 +02:00
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . UpdateInterval , this . Id , settingsTable , dryRun );
// Config: how should updates be installed?
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . UpdateInstallation , this . Id , settingsTable , dryRun );
2026-03-21 18:05:06 +01:00
// Config: what should be the start page?
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . StartPage , this . Id , settingsTable , dryRun );
2026-06-04 16:24:40 +02:00
2026-08-23 11:09:11 +02:00
// Config: show prompt-injection alert dialogs?
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . ShowPromptInjectionAlert , this . Id , settingsTable , dryRun );
2026-06-20 20:28:22 +02:00
// Config: show built-in introduction on the home page?
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . ShowIntroduction , this . Id , settingsTable , dryRun );
2026-06-04 16:24:40 +02:00
// Config: show quick start guide on the home page?
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . ShowQuickStartGuide , this . Id , settingsTable , dryRun );
2026-07-15 13:27:34 +02:00
// Config: show last changelog on the home page?
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . ShowLastChangelog , this . Id , settingsTable , dryRun );
// Config: show vision panel on the home page?
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . ShowVision , this . Id , settingsTable , dryRun );
2025-08-26 10:59:56 +02:00
// Config: allow the user to add providers?
2025-08-09 19:29:43 +02:00
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . AllowUserToAddProvider , this . Id , settingsTable , dryRun );
2026-02-07 22:59:41 +01:00
2026-09-14 16:05:12 +02:00
// Config: allow the user to add LLM providers?
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . AllowUserToAddLLMProvider , this . Id , settingsTable , dryRun );
// Config: allow the user to add embedding providers?
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . AllowUserToAddEmbeddingProvider , this . Id , settingsTable , dryRun );
// Config: allow the user to add transcription providers?
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . AllowUserToAddTranscriptionProvider , this . Id , settingsTable , dryRun );
2026-08-06 10:42:20 +02:00
// Config: allow the user to import plugin archives?
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . AllowUserToImportPlugins , this . Id , settingsTable , dryRun );
2026-08-09 19:01:58 +02:00
// Config: allow the user to import configuration plugin archives?
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . AllowUserToImportConfigurationPlugins , this . Id , settingsTable , dryRun );
2026-08-06 10:42:20 +02:00
// Config: allow the user to share or export plugins?
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . AllowUserToSharePlugins , this . Id , settingsTable , dryRun );
2026-02-07 22:59:41 +01:00
// Config: show administration settings?
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . ShowAdminSettings , this . Id , settingsTable , dryRun );
2025-06-01 21:14:21 +02:00
2025-10-19 11:51:28 +02:00
// Config: preview features visibility
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . PreviewVisibility , this . Id , settingsTable , dryRun );
2026-02-20 12:40:38 +01:00
// Config: enabled preview features (plugin contribution; users can enable additional features)
ManagedConfiguration . TryProcessConfigurationWithPluginContribution ( x => x . App , x => x . EnabledPreviewFeatures , this . Id , settingsTable , dryRun );
2025-10-19 11:51:28 +02:00
2026-01-12 20:43:45 +01:00
// Config: hide some assistants?
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . HiddenAssistants , this . Id , settingsTable , dryRun );
2026-01-24 20:05:34 +01:00
// Config: global voice recording shortcut
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . ShortcutVoiceRecording , this . Id , settingsTable , dryRun );
2026-05-21 16:48:34 +02:00
2026-09-04 15:48:07 +02:00
// Config: global tool availability
ManagedConfiguration . TryProcessConfiguration ( x => x . Tools , x => x . EnableTools , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . Tools , x => x . DisabledToolIds , this . Id , settingsTable , dryRun );
// Config: minimum provider confidence per tool
ManagedConfiguration . TryProcessConfiguration ( x => x . Tools , x => x . MinimumProviderConfidenceByToolId , this . Id , settingsTable , dryRun );
//
// Config: settings of the individual tools, keyed by tool and field. Two tables rather
// than a property per setting, so that tools an administrator's AI Studio does not know
// at compile time — the ones plugin authors define — can be configured just the same.
//
ManagedConfiguration . TryProcessConfiguration ( x => x . Tools , x => x . LockedToolSettings , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . Tools , x => x . DefaultToolSettings , this . Id , settingsTable , dryRun );
2026-05-21 16:48:34 +02:00
// Config: timeout for external HTTP requests
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . HttpClientTimeoutSeconds , this . Id , settingsTable , dryRun );
2026-05-31 18:46:54 +02:00
// Config: custom root certificates for external HTTP requests
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . ExternalHttpCustomRootCertificatesEnabled , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . ExternalHttpCustomRootCertificateBundlePath , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . ExternalHttpCustomRootCertificateAllowedHosts , this . Id , settingsTable , dryRun );
2026-06-21 11:52:02 +02:00
// Config: provider confidence settings
ManagedConfiguration . TryProcessConfiguration ( x => x . Confidence , x => x . EnforceGlobalMinimumConfidence , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . Confidence , x => x . GlobalMinimumConfidence , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . Confidence , x => x . ShowProviderConfidence , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . Confidence , x => x . ConfidenceScheme , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . Confidence , x => x . CustomConfidenceScheme , this . Id , settingsTable , dryRun );
2026-06-21 15:16:37 +02:00
// Config: data source security settings
ManagedConfiguration . TryProcessConfiguration ( x => x . DataSourceSecurity , x => x . TrustedProviderIds , this . Id , settingsTable , dryRun );
2026-07-05 12:59:03 +02:00
2026-07-05 14:46:31 +02:00
// Config: data source selection agent settings
ManagedConfiguration . TryProcessConfiguration ( x => x . AgentDataSourceSelection , x => x . PreselectAgentOptions , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . AgentDataSourceSelection , x => x . PreselectedAgentProvider , Guid . Empty , this . Id , settingsTable , dryRun );
// Config: retrieval context validation agent settings
ManagedConfiguration . TryProcessConfiguration ( x => x . AgentRetrievalContextValidation , x => x . EnableRetrievalContextValidation , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . AgentRetrievalContextValidation , x => x . PreselectAgentOptions , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . AgentRetrievalContextValidation , x => x . PreselectedAgentProvider , Guid . Empty , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . AgentRetrievalContextValidation , x => x . NumParallelValidations , this . Id , settingsTable , dryRun );
2026-07-05 12:59:03 +02:00
// Config: assistant plugin audit settings
ManagedConfiguration . TryProcessConfiguration ( x => x . AssistantPluginAudit , x => x . RequireAuditBeforeActivation , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . AssistantPluginAudit , x => x . PreselectedAgentProvider , Guid . Empty , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . AssistantPluginAudit , x => x . MinimumLevel , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . AssistantPluginAudit , x => x . BlockActivationBelowMinimum , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . AssistantPluginAudit , x => x . AutomaticallyAuditAssistants , this . Id , settingsTable , dryRun );
2026-07-05 15:20:29 +02:00
// Config: enterprise-managed approvals for assistant plugins
this . TryProcessEnterpriseApprovedAssistantPlugins ( settingsTable , dryRun );
2026-01-24 20:05:34 +01:00
2025-08-26 10:59:56 +02:00
// Handle configured LLM providers:
2026-08-25 12:46:10 +02:00
PluginConfigurationObject . TryParse ( PluginConfigurationObjectType . LLM_PROVIDER , x => x . Providers , x => x . NextProviderNum , mainTable , this . Id , ref this . configObjects , dryRun , this . PluginPath );
2026-01-09 15:41:54 +01:00
2026-01-09 15:49:44 +01:00
// Handle configured transcription providers:
2026-08-25 12:46:10 +02:00
PluginConfigurationObject . TryParse ( PluginConfigurationObjectType . TRANSCRIPTION_PROVIDER , x => x . TranscriptionProviders , x => x . NextTranscriptionNum , mainTable , this . Id , ref this . configObjects , dryRun , this . PluginPath );
2026-01-09 15:49:44 +01:00
2026-01-09 15:41:54 +01:00
// Handle configured embedding providers:
2026-08-25 12:46:10 +02:00
PluginConfigurationObject . TryParse ( PluginConfigurationObjectType . EMBEDDING_PROVIDER , x => x . EmbeddingProviders , x => x . NextEmbeddingNum , mainTable , this . Id , ref this . configObjects , dryRun , this . PluginPath );
2026-01-09 15:41:54 +01:00
2025-08-26 10:59:56 +02:00
// Handle configured chat templates:
2026-05-22 15:46:03 +02:00
PluginConfigurationObject . TryParse ( PluginConfigurationObjectType . CHAT_TEMPLATE , x => x . ChatTemplates , x => x . NextChatTemplateNum , mainTable , this . Id , ref this . configObjects , dryRun , this . PluginPath );
2026-05-18 16:26:51 +02:00
// Handle configured data sources:
PluginConfigurationObject . TryParseDataSources ( mainTable , this . Id , ref this . configObjects , dryRun );
2025-08-18 20:40:52 +02:00
2025-11-14 12:04:01 +01:00
// Handle configured profiles:
PluginConfigurationObject . TryParse ( PluginConfigurationObjectType . PROFILE , x => x . Profiles , x => x . NextProfileNum , mainTable , this . Id , ref this . configObjects , dryRun );
2026-02-01 14:50:19 +01:00
// Handle configured document analysis policies:
PluginConfigurationObject . TryParse ( PluginConfigurationObjectType . DOCUMENT_ANALYSIS_POLICY , x => x . DocumentAnalysis . Policies , x => x . NextDocumentAnalysisPolicyNum , mainTable , this . Id , ref this . configObjects , dryRun );
2026-04-10 17:11:05 +02:00
// Handle configured mandatory infos:
this . TryReadMandatoryInfos ( mainTable );
2026-06-20 20:28:22 +02:00
// Handle configured introductions:
this . TryReadIntroductions ( mainTable );
2026-02-01 14:50:19 +01:00
2026-02-20 10:08:06 +01:00
// Config: preselected provider?
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . PreselectedProvider , Guid . Empty , this . Id , settingsTable , dryRun );
2025-11-14 12:04:01 +01:00
// Config: preselected profile?
2025-12-04 15:37:13 +01:00
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . PreselectedProfile , Guid . Empty , this . Id , settingsTable , dryRun );
2026-01-09 15:49:44 +01:00
2026-06-21 15:59:23 +02:00
// Config: preselected chat options?
ManagedConfiguration . TryProcessConfiguration ( x => x . Chat , x => x . PreselectOptions , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . Chat , x => x . PreselectedProvider , Guid . Empty , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . Chat , x => x . PreselectedProfile , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . Chat , x => x . PreselectedChatTemplate , this . Id , settingsTable , dryRun );
2026-07-05 14:46:31 +02:00
ManagedConfiguration . TryProcessConfiguration ( x => x . Chat , x => x . PreselectedDataSourcesDisabled , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . Chat , x => x . PreselectedDataSourcesAutomaticSelection , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . Chat , x => x . PreselectedDataSourcesAutomaticValidation , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . Chat , x => x . PreselectedDataSourceIds , this . Id , settingsTable , dryRun );
2026-09-27 16:26:52 +02:00
ManagedConfiguration . TryProcessConfiguration ( x => x . Chat , x => x . PreselectedDataSourcesRetrievalMode , this . Id , settingsTable , dryRun );
2026-07-05 14:46:31 +02:00
ManagedConfiguration . TryProcessConfiguration ( x => x . Chat , x => x . SendToChatDataSourceBehavior , this . Id , settingsTable , dryRun );
2026-06-21 15:59:23 +02:00
2026-08-11 20:14:21 +02:00
// Config: Batch Processing Assistant defaults?
ManagedConfiguration . TryProcessConfiguration ( x => x . BatchProcessing , x => x . PreselectOptions , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . BatchProcessing , x => x . InputDirectory , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . BatchProcessing , x => x . OutputDirectory , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . BatchProcessing , x => x . FilePatterns , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . BatchProcessing , x => x . IncludeSubdirectories , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . BatchProcessing , x => x . PromptSource , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . BatchProcessing , x => x . FreePrompt , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . BatchProcessing , x => x . PromptFilePath , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . BatchProcessing , x => x . PreselectedPolicyId , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . BatchProcessing , x => x . OutputMode , this . Id , settingsTable , dryRun );
2026-08-31 08:40:50 +02:00
ManagedConfiguration . TryProcessConfiguration ( x => x . BatchProcessing , x => x . ResultFileFormat , this . Id , settingsTable , dryRun );
2026-08-11 20:14:21 +02:00
ManagedConfiguration . TryProcessConfiguration ( x => x . BatchProcessing , x => x . CsvFileName , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . BatchProcessing , x => x . ResultColumnHeader , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . BatchProcessing , x => x . CsvSeparator , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . BatchProcessing , x => x . CustomCsvSeparator , this . Id , settingsTable , dryRun );
var minimumDelayIsValid = ManagedConfiguration . TryProcessConfiguration ( x => x . BatchProcessing , x => x . MinimumDelaySeconds , this . Id , settingsTable , dryRun , validator : value => value is >= DataBatchProcessing . MIN_DELAY_SECONDS and <= DataBatchProcessing . MAX_DELAY_SECONDS );
if (! minimumDelayIsValid && settingsTable . TryGetValue ( "DataBatchProcessing.MinimumDelaySeconds" , out _ ))
LOG . LogWarning ( "The Batch Processing minimum delay configured by plugin {ConfigPluginId} must be between {MinimumDelaySeconds} and {MaximumDelaySeconds} seconds." , this . Id , DataBatchProcessing . MIN_DELAY_SECONDS , DataBatchProcessing . MAX_DELAY_SECONDS );
ManagedConfiguration . TryProcessConfiguration ( x => x . BatchProcessing , x => x . MinimumProviderConfidence , this . Id , settingsTable , dryRun );
ManagedConfiguration . TryProcessConfiguration ( x => x . BatchProcessing , x => x . PreselectedProvider , Guid . Empty , this . Id , settingsTable , dryRun );
2026-01-09 15:49:44 +01:00
// Config: transcription provider?
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . UseTranscriptionProvider , Guid . Empty , this . Id , settingsTable , dryRun );
2026-09-19 19:13:33 +02:00
// Config: transcription Opus bitrate?
ManagedConfiguration . TryProcessConfiguration ( x => x . App , x => x . OpusBitrate , this . Id , settingsTable , dryRun );
2025-08-26 10:59:56 +02:00
message = string . Empty ;
2025-08-18 20:40:52 +02:00
return true ;
}
2026-04-10 17:11:05 +02:00
2026-09-04 15:48:07 +02:00
private static bool TryValidateMinimumProviderConfidenceConfiguration ( LuaTable settingsTable , out string message )
{
const string SETTING_NAME = "DataTools.MinimumProviderConfidenceByToolId" ;
message = string . Empty ;
if (! settingsTable . TryGetValue ( SETTING_NAME , out var configuredValue ))
return true ;
if ( configuredValue . Type is not LuaValueType . Table || ! configuredValue . TryRead < LuaTable >( out var configuredTable ))
{
message = $"The setting '{SETTING_NAME}' must be a table of tool IDs and confidence levels." ;
return false ;
}
var previousKey = LuaValue . Nil ;
while ( configuredTable . TryGetNext ( previousKey , out var pair ))
{
previousKey = pair . Key ;
if (! pair . Key . TryRead < string >( out var toolId ) || string . IsNullOrWhiteSpace ( toolId ) ||
! pair . Value . TryRead < string >( out var configuredLevel ) ||
! Enum . TryParse < ConfidenceLevel >( configuredLevel , true , out var confidenceLevel ) ||
! Enum . IsDefined ( confidenceLevel ) ||
confidenceLevel is ConfidenceLevel . UNKNOWN )
{
message = $"The setting '{SETTING_NAME}' contains an invalid tool ID or confidence level. Allowed confidence levels are NONE, UNTRUSTED, VERY_LOW, LOW, MODERATE, MEDIUM, and HIGH." ;
return false ;
}
}
return true ;
}
2026-07-05 15:20:29 +02:00
private void TryProcessEnterpriseApprovedAssistantPlugins ( LuaTable settingsTable , bool dryRun )
{
if (! ManagedConfiguration . TryGet ( x => x . AssistantPluginAudit , x => x . EnterpriseApprovedPlugins , out ConfigMeta < DataAssistantPluginAudit , IList < DataAssistantPluginEnterpriseApproval >> configMeta ))
return ;
var settingName = SettingsManager . ToSettingName < DataAssistantPluginAudit , IList < DataAssistantPluginEnterpriseApproval >>( x => x . EnterpriseApprovedPlugins );
var successful = false ;
IList < DataAssistantPluginEnterpriseApproval > configuredApprovals = [];
if ( settingsTable . TryGetValue ( settingName , out var configuredLuaValue )
&& configuredLuaValue . Type is LuaValueType . Table
&& configuredLuaValue . TryRead < LuaTable >( out var approvalsTable ))
{
var approvals = new List < DataAssistantPluginEnterpriseApproval >( approvalsTable . ArrayLength );
for ( var index = 1 ; index <= approvalsTable . ArrayLength ; index ++)
{
var entryValue = approvalsTable [ index ];
if ( entryValue . TryRead < string >( out var hashText ))
{
var normalizedHash = NormalizeApprovalHash ( hashText );
if (! string . IsNullOrWhiteSpace ( normalizedHash ))
approvals . Add ( new () { PluginHash = normalizedHash });
else
LOG . LogWarning ( "The enterprise assistant approval entry at index {Index} contains an empty hash (config plugin id: {ConfigPluginId})." , index , this . Id );
continue ;
}
if (! entryValue . TryRead < LuaTable >( out var entryTable ))
{
LOG . LogWarning ( "The enterprise assistant approval entry at index {Index} is neither a string nor a table (config plugin id: {ConfigPluginId})." , index , this . Id );
continue ;
}
if (! TryParseEnterpriseApprovedAssistantPlugin ( index , entryTable , this . Id , out var approval ))
continue ;
approvals . Add ( approval );
}
2026-08-29 14:12:14 +02:00
// A configuration may list the same hash more than once, e.g. once to describe the
// plugin and once to activate it. Combine those before anything else sees them:
configuredApprovals = CombineApprovals ( approvals );
2026-07-05 15:20:29 +02:00
successful = true ;
}
if ( dryRun )
return ;
2026-08-09 19:01:58 +02:00
//
// Only a configuration which speaks for an organization may approve assistant plugins: one
// deployed by a configuration server, or one staged in the test directory. An approval marks
// a plugin as safe without any security audit, and the user interface states that the
// organization approved it. No local configuration plugin may make that claim: it would
// disable the security audit for arbitrary assistant plugins while telling the user that
// their organization vouched for them.
//
// We decide by the plugin path. The self-declared DEPLOYED_USING_CONFIG_SERVER field would
// not do, because any plugin can set it to true.
//
if (! PluginFactory . IsOrganizationConfigurationPath ( this . PluginPath ))
{
if ( successful )
LOG . LogWarning ( "The configuration plugin '{ConfigPluginId}' at '{PluginPath}' declares enterprise approvals for assistant plugins, but your organization's IT did not deploy it. Ignoring these approvals: only configuration plugins from a configuration server or from the test directory may approve assistant plugins." , this . Id , this . PluginPath );
return ;
}
if ( PluginFactory . IsEnterpriseTestConfigurationPath ( this . PluginPath ))
LOG . LogWarning ( "The test configuration plugin '{ConfigPluginId}' at '{PluginPath}' approves assistant plugins. These approvals are valid for this session only: AI Studio empties the test directory on every start." , this . Id , this . PluginPath );
2026-07-05 15:20:29 +02:00
switch ( successful )
{
case true :
2026-08-08 18:35:46 +02:00
//
// 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:
2026-08-29 14:12:14 +02:00
configMeta . SetValue ( CombineApprovals ( configMeta . GetValue (). Concat ( configuredApprovals )));
2026-07-05 15:20:29 +02:00
configMeta . LockConfiguration ( this . Id );
break ;
case false when configMeta . IsLocked && configMeta . LockedByConfigPluginId == this . Id :
2026-08-08 18:35:46 +02:00
configMeta . RemovePluginContribution ( this . Id );
2026-07-05 15:20:29 +02:00
configMeta . ResetLockedConfiguration ();
break ;
2026-08-08 18:35:46 +02:00
case false :
configMeta . RemovePluginContribution ( this . Id );
break ;
2026-07-05 15:20:29 +02:00
}
}
2026-08-08 18:35:46 +02:00
/// <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 ;
2026-08-29 14:12:14 +02:00
var effectiveApprovals = CombineApprovals ( configMeta . PluginContributions . Values . SelectMany ( contribution => contribution ));
2026-08-08 18:35:46 +02:00
2026-08-29 14:12:14 +02:00
// Compare by what an approval decides, so a different order alone does not rewrite the
// settings on every start, while a changed activation does reach the user:
2026-08-08 18:35:46 +02:00
var currentApprovals = configMeta . GetValue ();
2026-08-29 14:12:14 +02:00
if ( HaveApprovalsSameEffect ( currentApprovals , effectiveApprovals ))
2026-08-08 18:35:46 +02:00
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 ;
}
2026-08-29 14:12:14 +02:00
/// <summary>
/// Reduces approvals of several configuration plugins to one entry per assistant plugin hash.
/// </summary>
/// <remarks>
/// Approving the same plugin twice is normal: a base configuration approves it for the whole
/// organization, and a department configuration lists it again to activate it. Keeping only the
/// entry seen first would silently drop what the other one asked for, and the contributions
/// carry no guaranteed order, so which one that is could differ from start to start.
/// </remarks>
/// <param name="approvals">The approvals of all configuration plugins, in any order.</param>
/// <returns>One approval per hash, in the order the hashes were first seen.</returns>
private static List < DataAssistantPluginEnterpriseApproval > CombineApprovals ( IEnumerable < DataAssistantPluginEnterpriseApproval > approvals )
{
var combined = new List < DataAssistantPluginEnterpriseApproval >();
var positionByHash = new Dictionary < string , int >( StringComparer . Ordinal );
foreach ( var approval in approvals )
{
if ( positionByHash . TryGetValue ( approval . PluginHash , out var position ))
{
combined [ position ] = MergeApprovals ( combined [ position ], approval );
continue ;
}
positionByHash [ approval . PluginHash ] = combined . Count ;
combined . Add ( approval );
}
return combined ;
}
/// <summary>
/// Combines two approvals of the same assistant plugin hash into a single one.
/// </summary>
/// <remarks>
/// The two activation fields are combined in opposite directions on purpose. One configuration
/// asking for the activation is enough to activate, because not asking for it says nothing
/// against it. The freedom to switch the assistant off again, however, only survives when every
/// configuration which does ask for the activation grants it: otherwise a department could take
/// back a lock the organization deliberately set. An approval which does not ask for the
/// activation at all expresses nothing about that freedom and is therefore not counted.<br/><br/>
/// The result of these two fields does not depend on the order the approvals arrive in. For the
/// descriptive fields, the first value which says anything wins, and the approval date is the
/// earliest one given: the plugin has been approved since then.
/// </remarks>
/// <param name="first">The approval seen first.</param>
/// <param name="second">The approval to combine it with.</param>
/// <returns>The combined approval.</returns>
private static DataAssistantPluginEnterpriseApproval MergeApprovals ( DataAssistantPluginEnterpriseApproval first , DataAssistantPluginEnterpriseApproval second ) => new ()
{
PluginHash = first . PluginHash ,
DisplayName = string . IsNullOrWhiteSpace ( first . DisplayName ) ? second . DisplayName : first . DisplayName ,
Comment = string . IsNullOrWhiteSpace ( first . Comment ) ? second . Comment : first . Comment ,
ApprovedBy = string . IsNullOrWhiteSpace ( first . ApprovedBy ) ? second . ApprovedBy : first . ApprovedBy ,
ApprovedAtUtc = EarliestApprovalTime ( first . ApprovedAtUtc , second . ApprovedAtUtc ),
Activate = first . Activate || second . Activate ,
AllowUserOverride = ( first . Activate , second . Activate ) switch
{
( true , true ) => first . AllowUserOverride && second . AllowUserOverride ,
( true , false ) => first . AllowUserOverride ,
( false , true ) => second . AllowUserOverride ,
_ => false ,
},
};
private static DateTimeOffset ? EarliestApprovalTime ( DateTimeOffset ? first , DateTimeOffset ? second ) => ( first , second ) switch
{
( null , _ ) => second ,
( _ , null ) => first ,
_ => first <= second ? first : second ,
};
/// <summary>
/// Checks whether two approval lists decide the same thing for every assistant plugin.
/// </summary>
/// <remarks>
/// This is what tells a rewrite of the settings apart from a mere reordering of the same
/// approvals. Only the hash and the two activation fields are compared: the descriptive fields
/// change nothing about what an approval does, and rewriting the settings because a comment was
/// reworded would store the file on every start.
/// </remarks>
/// <param name="currentApprovals">The approvals currently stored in the settings.</param>
/// <param name="effectiveApprovals">The approvals recomputed from the contributions.</param>
/// <returns>True when both lists have the same effect, otherwise false.</returns>
private static bool HaveApprovalsSameEffect ( IList < DataAssistantPluginEnterpriseApproval > currentApprovals , IList < DataAssistantPluginEnterpriseApproval > effectiveApprovals )
{
if ( currentApprovals . Count != effectiveApprovals . Count )
return false ;
var currentByHash = new Dictionary < string , DataAssistantPluginEnterpriseApproval >( StringComparer . Ordinal );
foreach ( var approval in currentApprovals )
currentByHash [ approval . PluginHash ] = approval ;
foreach ( var effectiveApproval in effectiveApprovals )
{
if (! currentByHash . TryGetValue ( effectiveApproval . PluginHash , out var currentApproval ))
return false ;
if ( currentApproval . Activate != effectiveApproval . Activate || currentApproval . AllowUserOverride != effectiveApproval . AllowUserOverride )
return false ;
}
return true ;
}
2026-07-05 15:20:29 +02:00
private static bool TryParseEnterpriseApprovedAssistantPlugin ( int index , LuaTable table , Guid configPluginId , out DataAssistantPluginEnterpriseApproval approval )
{
approval = new ();
if (! table . TryGetValue ( "PluginHash" , out var pluginHashValue ) || ! pluginHashValue . TryRead < string >( out var pluginHash ))
{
LOG . LogWarning ( "The enterprise assistant approval entry at index {Index} is missing a valid PluginHash (config plugin id: {ConfigPluginId})." , index , configPluginId );
return false ;
}
var normalizedHash = NormalizeApprovalHash ( pluginHash );
if ( string . IsNullOrWhiteSpace ( normalizedHash ))
{
LOG . LogWarning ( "The enterprise assistant approval entry at index {Index} contains an empty PluginHash (config plugin id: {ConfigPluginId})." , index , configPluginId );
return false ;
}
var displayName = TryReadOptionalString ( table , "DisplayName" );
var comment = TryReadOptionalString ( table , "Comment" );
var approvedBy = TryReadOptionalString ( table , "ApprovedBy" );
var approvedAtUtc = TryReadOptionalDateTimeOffset ( table , "ApprovedAtUtc" , index , configPluginId );
2026-08-29 14:12:14 +02:00
var activate = TryReadOptionalBool ( table , "Activate" , index , configPluginId );
var allowUserOverride = TryReadOptionalBool ( table , "AllowUserOverride" , index , configPluginId );
if ( allowUserOverride && ! activate )
LOG . LogWarning ( "The enterprise assistant approval entry at index {Index} allows the user to override an activation it never asks for. 'AllowUserOverride' has no effect without 'Activate' (config plugin id: {ConfigPluginId})." , index , configPluginId );
2026-07-05 15:20:29 +02:00
approval = new ()
{
PluginHash = normalizedHash ,
DisplayName = displayName ,
Comment = comment ,
ApprovedBy = approvedBy ,
ApprovedAtUtc = approvedAtUtc ,
2026-08-29 14:12:14 +02:00
Activate = activate ,
AllowUserOverride = allowUserOverride ,
2026-07-05 15:20:29 +02:00
};
return true ;
}
private static string TryReadOptionalString ( LuaTable table , string key )
{
return table . TryGetValue ( key , out var value ) && value . TryRead < string >( out var text )
? text
: string . Empty ;
}
2026-08-29 14:12:14 +02:00
private static bool TryReadOptionalBool ( LuaTable table , string key , int index , Guid configPluginId )
{
if (! table . TryGetValue ( key , out var value ))
return false ;
if ( value . TryRead < bool >( out var flag ))
return flag ;
LOG . LogWarning ( "The enterprise assistant approval entry at index {Index} contains an invalid {Key} value. Expected a boolean (config plugin id: {ConfigPluginId})." , index , key , configPluginId );
return false ;
}
2026-07-05 15:20:29 +02:00
private static DateTimeOffset ? TryReadOptionalDateTimeOffset ( LuaTable table , string key , int index , Guid configPluginId )
{
if (! table . TryGetValue ( key , out var value ))
return null ;
if ( value . TryRead < string >( out var text ) && DateTimeOffset . TryParse ( text , CultureInfo . InvariantCulture , DateTimeStyles . AssumeUniversal , out var parsed ))
return parsed . ToUniversalTime ();
LOG . LogWarning ( "The enterprise assistant approval entry at index {Index} contains an invalid {Key} value (config plugin id: {ConfigPluginId})." , index , key , configPluginId );
return null ;
}
private static string NormalizeApprovalHash ( string hash ) => string . IsNullOrWhiteSpace ( hash ) ? string . Empty : hash . Trim (). ToUpperInvariant ();
2026-04-10 17:11:05 +02:00
private void TryReadMandatoryInfos ( LuaTable mainTable )
{
if (! mainTable . TryGetValue ( "MANDATORY_INFOS" , out var mandatoryInfosValue ) || ! mandatoryInfosValue . TryRead < LuaTable >( out var mandatoryInfosTable ))
return ;
for ( var i = 1 ; i <= mandatoryInfosTable . ArrayLength ; i ++)
{
var luaMandatoryInfoValue = mandatoryInfosTable [ i ];
if (! luaMandatoryInfoValue . TryRead < LuaTable >( out var luaMandatoryInfoTable ))
{
LOG . LogWarning ( "The table 'MANDATORY_INFOS' entry at index {Index} is not a valid table (config plugin id: {ConfigPluginId})." , i , this . Id );
continue ;
}
if ( DataMandatoryInfo . TryParseConfiguration ( i , luaMandatoryInfoTable , this . Id , out var mandatoryInfo ))
this . mandatoryInfos . Add ( mandatoryInfo );
else
LOG . LogWarning ( "The table 'MANDATORY_INFOS' entry at index {Index} does not contain a valid mandatory info (config plugin id: {ConfigPluginId})." , i , this . Id );
}
}
2026-06-20 20:28:22 +02:00
private void TryReadIntroductions ( LuaTable mainTable )
{
if (! mainTable . TryGetValue ( "INTRODUCTIONS" , out var introductionsValue ) || ! introductionsValue . TryRead < LuaTable >( out var introductionsTable ))
return ;
for ( var i = 1 ; i <= introductionsTable . ArrayLength ; i ++)
{
var luaIntroductionValue = introductionsTable [ i ];
if (! luaIntroductionValue . TryRead < LuaTable >( out var luaIntroductionTable ))
{
LOG . LogWarning ( "The table 'INTRODUCTIONS' entry at index {Index} is not a valid table (config plugin id: {ConfigPluginId})." , i , this . Id );
continue ;
}
if ( DataIntroduction . TryParseConfiguration ( i , luaIntroductionTable , this . Id , out var introduction ))
this . introductions . Add ( introduction );
else
LOG . LogWarning ( "The table 'INTRODUCTIONS' entry at index {Index} does not contain a valid introduction (config plugin id: {ConfigPluginId})." , i , this . Id );
}
}
2026-08-25 12:46:10 +02:00
}