2026-08-06 20:59:53 +02:00
using System.Linq.Expressions ;
2025-04-23 14:07:22 +02:00
using System.Text ;
2025-08-09 19:29:43 +02:00
using AIStudio.Settings ;
2026-08-06 20:59:53 +02:00
using AIStudio.Settings.DataModel ;
2026-04-09 10:01:24 +02:00
using AIStudio.Tools.PluginSystem.Assistants ;
2025-04-23 14:07:22 +02:00
using Lua ;
using Lua.Standard ;
namespace AIStudio.Tools.PluginSystem ;
public static partial class PluginFactory
{
private static readonly List < IAvailablePlugin > AVAILABLE_PLUGINS = [];
private static readonly SemaphoreSlim PLUGIN_LOAD_SEMAPHORE = new ( 1 , 1 );
/// <summary>
/// A list of all available plugins.
/// </summary>
public static IReadOnlyCollection < IPluginMetadata > AvailablePlugins => AVAILABLE_PLUGINS ;
/// <summary>
/// Try to load all plugins from the plugins directory.
/// </summary>
/// <remarks>
/// Loading plugins means:<br/>
/// - Parsing and checking the plugin code<br/>
/// - Check for forbidden plugins<br/>
/// - Creating a new instance of the allowed plugin<br/>
/// - Read the plugin metadata<br/>
2025-04-27 09:06:05 +02:00
/// - Start the plugin<br/>
2025-04-23 14:07:22 +02:00
/// </remarks>
public static async Task LoadAll ( CancellationToken cancellationToken = default )
{
2026-02-19 20:43:47 +01:00
if (! IsInitialized )
2025-04-23 14:07:22 +02:00
{
LOG . LogError ( "PluginFactory is not initialized. Please call Setup() before using it." );
return ;
}
2026-07-15 21:00:25 +02:00
// Wait for ongoing reloads instead of silently skipping this request.
// This caller must return only after its reload has run.
await PLUGIN_LOAD_SEMAPHORE . WaitAsync ( cancellationToken );
2025-04-23 14:07:22 +02:00
2025-08-18 20:40:52 +02:00
var configObjectList = new List < PluginConfigurationObject >();
2025-04-23 14:07:22 +02:00
try
{
LOG . LogInformation ( "Start loading plugins." );
2026-08-06 20:59:53 +02:00
//
// Without the plugins directory, we cannot load or start any plugin. Still, we must not
// stop here: the clean-up at the end of this method has to run. Otherwise, settings which
// a configuration plugin has locked would stay locked forever.
//
var pluginsDirectoryExists = Directory . Exists ( PLUGINS_ROOT );
if (! pluginsDirectoryExists )
LOG . LogWarning ( "No plugins found. Checking for left-over configurations of removed configuration plugins." );
2025-04-23 14:07:22 +02:00
AVAILABLE_PLUGINS . Clear ();
2026-08-06 20:59:53 +02:00
2025-04-23 14:07:22 +02:00
//
// The easiest way to load all plugins is to find all `plugin.lua` files and load them.
// By convention, each plugin is enforced to have a `plugin.lua` file.
//
2026-08-06 20:59:53 +02:00
IEnumerable < string > pluginMainFiles = pluginsDirectoryExists ? Directory . EnumerateFiles ( PLUGINS_ROOT , "plugin.lua" , SearchOption . AllDirectories ) : [];
2025-04-23 14:07:22 +02:00
foreach ( var pluginMainFile in pluginMainFiles )
{
2025-04-27 16:13:15 +02:00
try
{
if ( cancellationToken . IsCancellationRequested )
2025-06-01 21:14:21 +02:00
{
LOG . LogWarning ( "Was not able to load all plugins, because the operation was cancelled. It seems to be a timeout." );
2025-04-27 16:13:15 +02:00
break ;
2025-06-01 21:14:21 +02:00
}
2025-04-27 16:13:15 +02:00
LOG . LogInformation ( $"Try to load plugin: {pluginMainFile}" );
var fileInfo = new FileInfo ( pluginMainFile );
string code ;
await using ( var fileStream = fileInfo . Open ( FileMode . Open , FileAccess . Read , FileShare . ReadWrite ))
{
using var reader = new StreamReader ( fileStream , Encoding . UTF8 );
code = await reader . ReadToEndAsync ( cancellationToken );
}
var pluginPath = Path . GetDirectoryName ( pluginMainFile )!;
2026-09-04 15:48:07 +02:00
var plugin = await Load ( pluginPath , code , cancellationToken : cancellationToken );
2025-04-23 14:07:22 +02:00
2025-04-27 16:13:15 +02:00
switch ( plugin )
{
case NoPlugin noPlugin when noPlugin . Issues . Any ():
LOG . LogError ( $"Was not able to load plugin: '{pluginMainFile}'. Reason: {noPlugin.Issues.First()}" );
continue ;
2025-04-23 14:07:22 +02:00
2025-04-27 16:13:15 +02:00
case NoPlugin :
LOG . LogError ( $"Was not able to load plugin: '{pluginMainFile}'. Reason: Unknown." );
continue ;
2025-04-23 14:07:22 +02:00
2025-04-27 16:13:15 +02:00
case { IsValid : false }:
LOG . LogError ( $"Was not able to load plugin '{pluginMainFile}', because the Lua code is not a valid AI Studio plugin. There are {plugin.Issues.Count()} issues to fix. First issue is: {plugin.Issues.FirstOrDefault()}" );
2025-08-26 10:59:56 +02:00
#if DEBUG
2025-04-27 16:13:15 +02:00
foreach ( var pluginIssue in plugin . Issues )
LOG . LogError ( $"Plugin issue: {pluginIssue}" );
2025-08-26 10:59:56 +02:00
#endif
2025-04-27 16:13:15 +02:00
continue ;
2025-04-23 14:07:22 +02:00
2025-04-27 16:13:15 +02:00
case { IsMaintained : false }:
LOG . LogWarning ( $"The plugin '{pluginMainFile}' is not maintained anymore. Please consider to disable it." );
break ;
}
2025-04-23 14:07:22 +02:00
2025-04-27 16:13:15 +02:00
LOG . LogInformation ( $"Successfully loaded plugin: '{pluginMainFile}' (Id='{plugin.Id}', Type='{plugin.Type}', Name='{plugin.Name}', Version='{plugin.Version}', Authors='{string.Join(" , ", plugin.Authors)}')" );
2026-02-15 18:11:57 +01:00
2026-08-08 18:35:46 +02:00
//
// 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 )
{
2026-08-09 19:01:58 +02:00
if ( GetConfigurationAuthority ( pluginPath ) <= GetConfigurationAuthority ( duplicatePlugin . LocalPath ))
2026-08-08 18:35:46 +02:00
{
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 ;
}
2026-08-09 19:01:58 +02:00
if ( IsEnterpriseTestConfigurationPath ( pluginPath ))
LOG . LogWarning ( $"Ignoring the plugin at '{duplicatePlugin.LocalPath}': it uses the ID ('{plugin.Id}') of the test configuration plugin at '{pluginPath}'. A test configuration takes precedence until AI Studio is restarted." );
else
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." );
2026-08-08 18:35:46 +02:00
AVAILABLE_PLUGINS . Remove ( duplicatePlugin );
}
2026-02-19 20:43:47 +01:00
2026-08-28 14:06:24 +02:00
//
// An organization may deploy any kind of plugin, not just configurations: the
// archive it serves under a configuration ID often carries an assistant plugin
// in a subdirectory as well. Everything stored below one of the organization's
// directories therefore belongs to that organization, whatever its type is and
// however deeply it is nested:
//
var isInOrganizationDirectory = IsOrganizationConfigurationPath ( pluginPath );
2026-02-19 20:43:47 +01:00
Guid ? managedConfigurationId = null ;
2026-08-08 18:35:46 +02:00
var configurationPriority = 0 ;
2026-08-28 14:06:24 +02:00
bool? declaredAsManagedByConfigServer = null ;
2026-02-19 20:43:47 +01:00
if ( plugin is PluginConfiguration configPlugin )
{
2026-08-08 18:35:46 +02:00
configurationPriority = configPlugin . Priority ;
2026-08-28 14:06:24 +02:00
declaredAsManagedByConfigServer = configPlugin . DeployedUsingConfigServer ;
}
else if ( plugin is PluginAssistants { HasDeploymentManagementMetadata : true } assistantPlugin )
declaredAsManagedByConfigServer = assistantPlugin . IsManagedByConfigServer ;
2026-08-08 18:35:46 +02:00
2026-08-28 14:06:24 +02:00
//
// The plugin path outranks what a plugin declares about itself. A plugin an
// organization deployed could otherwise deny it and escape the withdrawal of that
// configuration, while keeping every right the directory grants it:
//
var isManagedByConfigServer = isInOrganizationDirectory || declaredAsManagedByConfigServer is true ;
switch ( declaredAsManagedByConfigServer )
{
case null when isInOrganizationDirectory :
LOG . LogWarning ( $"The {plugin.Type} 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 '{pluginPath}'." );
break ;
case false when isInOrganizationDirectory :
LOG . LogWarning ( $"The {plugin.Type} plugin '{plugin.Id}' declares 'DEPLOYED_USING_CONFIG_SERVER = false', but it is stored under '{pluginPath}' and therefore belongs to your organization. Treating it as managed. Please fix the plugin." );
break ;
2026-02-19 20:43:47 +01:00
}
2026-08-28 14:06:24 +02:00
//
// Which configuration a plugin was deployed with is what ties it to the archive it
// came from. Only the configuration plugin itself must carry the configuration ID
// as its own ID: a plugin deployed alongside it has an ID of its own:
//
if ( IsEnterpriseConfigurationPath ( pluginPath ))
2026-02-15 18:11:57 +01:00
{
2026-08-28 14:06:24 +02:00
if ( TryGetDeployedConfigurationId ( pluginPath , out var enterpriseConfigId ))
2026-02-19 20:43:47 +01:00
{
managedConfigurationId = enterpriseConfigId ;
2026-08-28 14:06:24 +02:00
if ( plugin . Type is PluginType . CONFIGURATION && enterpriseConfigId != plugin . Id )
2026-02-19 20:43:47 +01:00
LOG . LogWarning ( $"The configuration plugin's ID ('{plugin.Id}') does not match the enterprise configuration ID ('{enterpriseConfigId}'). These IDs should be identical. Please update the plugin's ID field to match the enterprise configuration ID." );
}
else
2026-08-28 14:06:24 +02:00
LOG . LogWarning ( $"Could not determine the managed configuration ID for the {plugin.Type} plugin '{plugin.Id}'. The plugin directory '{pluginPath}' is not nested in a directory named after a configuration ID." );
2026-02-15 18:11:57 +01:00
}
2026-08-08 18:35:46 +02:00
AVAILABLE_PLUGINS . Add ( new PluginMetadata ( plugin , pluginPath , isManagedByConfigServer , managedConfigurationId , configurationPriority ));
2025-04-27 16:13:15 +02:00
}
catch ( Exception e )
{
LOG . LogError ( $"Was not able to load plugin '{pluginMainFile}'. Issue: {e.Message}" );
LOG . LogDebug ( e . StackTrace );
}
2025-04-23 14:07:22 +02:00
}
// Start or restart all plugins:
2026-08-06 20:59:53 +02:00
if ( pluginsDirectoryExists )
{
var configObjects = await RestartAllPlugins ( cancellationToken );
configObjectList . AddRange ( configObjects );
}
2025-04-23 14:07:22 +02:00
}
finally
{
PLUGIN_LOAD_SEMAPHORE . Release ();
LOG . LogInformation ( "Finished loading plugins." );
}
2025-06-01 21:14:21 +02:00
//
2025-08-09 19:29:43 +02:00
// =========================================================
2025-08-26 10:59:56 +02:00
// Next, we have to clean up our settings. It is possible
// that a configuration plugin was removed. We have to
// remove the related settings as well:
2025-08-09 19:29:43 +02:00
// =========================================================
2025-06-01 21:14:21 +02:00
//
2026-08-06 20:59:53 +02:00
//
2026-08-08 18:35:46 +02:00
// 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:
2026-08-06 20:59:53 +02:00
//
2026-08-08 18:35:46 +02:00
var deployedEnterpriseConfigPluginIds = GetDeployedEnterpriseConfigPluginIds ();
2026-08-09 19:01:58 +02:00
//
// Test configurations manage settings and objects like a deployed configuration, so those must
// not be treated as left over while the test runs. They are only ever loaded, never merely
// present: the test directory is emptied on every start.
//
foreach ( var testConfigurationPlugin in AVAILABLE_PLUGINS . Where ( plugin => plugin . Type is PluginType . CONFIGURATION && IsEnterpriseTestConfigurationPath ( plugin . LocalPath )))
deployedEnterpriseConfigPluginIds . Add ( testConfigurationPlugin . Id );
2026-08-28 14:06:24 +02:00
//
// A deployment does not have to contain a configuration plugin under its own ID: an
// organization uses the same channel to roll out assistant plugins and other plugin types.
// We therefore collect which deployments contributed a plugin at all, so that such a rollout
// is not mistaken for a configuration nobody could read:
//
var configurationIdsWithLoadedPlugins = AVAILABLE_PLUGINS
. Where ( plugin => plugin . ManagedConfigurationId . HasValue )
. Select ( plugin => plugin . ManagedConfigurationId !. Value )
. ToHashSet ();
2026-08-08 18:35:46 +02:00
var unloadedEnterpriseConfigPluginIds = deployedEnterpriseConfigPluginIds . Where ( x => AVAILABLE_PLUGINS . All ( plugin => plugin . Id != x )). ToList ();
foreach ( var unloadedEnterpriseConfigPluginId in unloadedEnterpriseConfigPluginIds )
2026-08-28 14:06:24 +02:00
{
if ( configurationIdsWithLoadedPlugins . Contains ( unloadedEnterpriseConfigPluginId ))
{
LOG . LogInformation ( $"The deployment '{unloadedEnterpriseConfigPluginId}' contains no configuration plugin of its own, but other plugins your organization deployed with it were loaded. Should you expect a configuration plugin here, please check the errors above." );
continue ;
}
2026-08-08 18:35:46 +02:00
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." );
2026-08-28 14:06:24 +02:00
}
2026-08-06 20:59:53 +02:00
2025-06-01 21:14:21 +02:00
// Check LLM providers:
2026-08-08 18:35:46 +02:00
var wasConfigurationChanged = await PluginConfigurationObject . CleanLeftOverConfigurationObjects ( PluginConfigurationObjectType . LLM_PROVIDER , x => x . Providers , AVAILABLE_PLUGINS , deployedEnterpriseConfigPluginIds , configObjectList , SecretStoreType . LLM_PROVIDER );
2026-01-09 15:41:54 +01:00
2026-01-09 15:49:44 +01:00
// Check transcription providers:
2026-08-08 18:35:46 +02:00
if ( await PluginConfigurationObject . CleanLeftOverConfigurationObjects ( PluginConfigurationObjectType . TRANSCRIPTION_PROVIDER , x => x . TranscriptionProviders , AVAILABLE_PLUGINS , deployedEnterpriseConfigPluginIds , configObjectList , SecretStoreType . TRANSCRIPTION_PROVIDER ))
2026-01-09 15:49:44 +01:00
wasConfigurationChanged = true ;
2026-01-09 15:41:54 +01:00
// Check embedding providers:
2026-08-08 18:35:46 +02:00
if ( await PluginConfigurationObject . CleanLeftOverConfigurationObjects ( PluginConfigurationObjectType . EMBEDDING_PROVIDER , x => x . EmbeddingProviders , AVAILABLE_PLUGINS , deployedEnterpriseConfigPluginIds , configObjectList , SecretStoreType . EMBEDDING_PROVIDER ))
2026-01-09 15:41:54 +01:00
wasConfigurationChanged = true ;
2026-05-18 16:26:51 +02:00
// Check data sources:
2026-08-08 18:35:46 +02:00
if ( await PluginConfigurationObject . CleanLeftOverConfigurationObjects ( PluginConfigurationObjectType . DATA_SOURCE , x => x . DataSources , AVAILABLE_PLUGINS , deployedEnterpriseConfigPluginIds , configObjectList , SecretStoreType . DATA_SOURCE , deleteSecret : true ))
2026-05-18 16:26:51 +02:00
wasConfigurationChanged = true ;
2025-08-18 20:40:52 +02:00
// Check chat templates:
2026-08-08 18:35:46 +02:00
if ( await PluginConfigurationObject . CleanLeftOverConfigurationObjects ( PluginConfigurationObjectType . CHAT_TEMPLATE , x => x . ChatTemplates , AVAILABLE_PLUGINS , deployedEnterpriseConfigPluginIds , configObjectList ))
2025-08-26 10:59:56 +02:00
wasConfigurationChanged = true ;
2026-02-07 22:59:41 +01:00
2025-11-14 12:04:01 +01:00
// Check profiles:
2026-08-08 18:35:46 +02:00
if ( await PluginConfigurationObject . CleanLeftOverConfigurationObjects ( PluginConfigurationObjectType . PROFILE , x => x . Profiles , AVAILABLE_PLUGINS , deployedEnterpriseConfigPluginIds , configObjectList ))
2025-11-14 12:04:01 +01:00
wasConfigurationChanged = true ;
2026-02-01 14:50:19 +01:00
// Check document analysis policies:
2026-08-08 18:35:46 +02:00
if ( await PluginConfigurationObject . CleanLeftOverConfigurationObjects ( PluginConfigurationObjectType . DOCUMENT_ANALYSIS_POLICY , x => x . DocumentAnalysis . Policies , AVAILABLE_PLUGINS , deployedEnterpriseConfigPluginIds , configObjectList ))
2026-02-01 14:50:19 +01:00
wasConfigurationChanged = true ;
2026-04-10 17:11:05 +02:00
// Check left-over mandatory info acceptances:
2026-06-10 21:01:27 +02:00
if ( SettingsManagerAccess . ConfigurationData . MandatoryInformation . RemoveLeftOverAcceptances ( GetMandatoryInfos ()))
2026-04-10 17:11:05 +02:00
wasConfigurationChanged = true ;
2025-11-14 12:04:01 +01:00
2026-08-06 20:59:53 +02:00
// Check all managed settings, i.e. settings which a configuration plugin can lock,
// provide as an editable default, or contribute to:
2026-08-08 18:35:46 +02:00
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 ())
2026-06-21 11:52:02 +02:00
wasConfigurationChanged = true ;
2026-08-29 14:12:14 +02:00
//
// Now that the approvals are final, we know which assistant plugins your organization wants
// enabled. This needs no guard of its own: it reads the stored approvals, which stay in place
// when a configuration plugin could not be loaded:
//
if ( RefreshEnterpriseAssistantActivations ())
wasConfigurationChanged = true ;
2026-08-06 20:59:53 +02:00
// Compatibility shim, see documentation/compatibility-shims/2026-08-orphaned-config-locks.md (remove after 2027-08-06):
2026-08-08 18:35:46 +02:00
if ( RepairLegacyConfigOnlySettings ( unloadedEnterpriseConfigPluginIds . Count > 0 ))
2026-06-21 11:52:02 +02:00
wasConfigurationChanged = true ;
2025-06-01 21:14:21 +02:00
if ( wasConfigurationChanged )
{
2026-06-10 21:01:27 +02:00
await SettingsManagerAccess . StoreSettings ();
2025-06-01 21:14:21 +02:00
await MessageBus . INSTANCE . SendMessage < bool >( null , Event . CONFIGURATION_CHANGED );
}
2025-04-23 14:07:22 +02:00
}
2026-08-06 20:59:53 +02:00
/// <summary>
2026-08-08 18:35:46 +02:00
/// Determines the IDs of all configuration plugins which an organization deployed on this machine.
2026-08-06 20:59:53 +02:00
/// </summary>
/// <remarks>
2026-08-08 18:35:46 +02:00
/// 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/>
2026-08-06 20:59:53 +02:00
/// 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
/// instance, so we must not treat its settings as left over. Configuration plugins deployed by a
/// configuration server live in a directory named after their ID, which is the only information
/// left when the plugin itself cannot be read.
/// </remarks>
2026-08-08 18:35:46 +02:00
private static HashSet < Guid > GetDeployedEnterpriseConfigPluginIds ()
2026-08-06 20:59:53 +02:00
{
2026-08-08 18:35:46 +02:00
var deployedEnterpriseConfigPluginIds = new HashSet < Guid >();
if (! Directory . Exists ( ENTERPRISE_CONFIGURATION_PLUGINS_ROOT ))
return deployedEnterpriseConfigPluginIds ;
2026-08-06 20:59:53 +02:00
2026-08-08 18:35:46 +02:00
foreach ( var configPluginDirectory in Directory . EnumerateDirectories ( ENTERPRISE_CONFIGURATION_PLUGINS_ROOT ))
2026-08-06 20:59:53 +02:00
{
if (! Guid . TryParse ( Path . GetFileName ( configPluginDirectory ), out var configPluginId ) || configPluginId == Guid . Empty )
continue ;
// An empty directory is a left-over of a removed plugin, not a deployed plugin:
if (! Directory . EnumerateFileSystemEntries ( configPluginDirectory ). Any ())
continue ;
2026-08-08 18:35:46 +02:00
deployedEnterpriseConfigPluginIds . Add ( configPluginId );
2026-08-06 20:59:53 +02:00
}
2026-08-08 18:35:46 +02:00
return deployedEnterpriseConfigPluginIds ;
2026-08-06 20:59:53 +02:00
}
2026-08-06 10:42:20 +02:00
/// <param name="pluginPath">The directory the plugin is located in, or null when the code has no directory yet.</param>
/// <param name="code">The Lua code of the plugin's main file.</param>
/// <param name="allowedBaseDirectory">
2026-09-04 15:48:07 +02:00
/// The directory the plugin path must be nested in. Without it, the installed plugins directory
/// is used. Validating a plugin before its installation needs this, because the plugin lives in
/// a staging directory at that point and could not load any of its own Lua modules otherwise.
2026-08-06 10:42:20 +02:00
/// </param>
2026-09-04 15:48:07 +02:00
/// <param name="cancellationToken">Cancellation token for running the Lua code.</param>
public static async Task < PluginBase > Load ( string? pluginPath , string code , string? allowedBaseDirectory = null , CancellationToken cancellationToken = default )
2025-04-23 14:07:22 +02:00
{
if ( ForbiddenPlugins . Check ( code ) is { IsForbidden : true } forbiddenState )
return new NoPlugin ( $"This plugin is forbidden: {forbiddenState.Message}" );
2026-08-06 10:42:20 +02:00
2025-04-23 14:07:22 +02:00
var state = LuaState . Create ();
2025-04-26 18:55:23 +02:00
if (! string . IsNullOrWhiteSpace ( pluginPath ))
{
// Add the module loader so that the plugin can load other Lua modules:
2026-08-06 10:42:20 +02:00
state . ModuleLoader = new PluginLoader ( pluginPath , allowedBaseDirectory );
2025-04-26 18:55:23 +02:00
}
2025-04-23 14:07:22 +02:00
// Add some useful libraries:
2026-04-09 10:01:24 +02:00
state . OpenBasicLibrary ();
2025-04-23 14:07:22 +02:00
state . OpenModuleLibrary ();
state . OpenStringLibrary ();
state . OpenTableLibrary ();
state . OpenMathLibrary ();
state . OpenBitwiseLibrary ();
state . OpenCoroutineLibrary ();
try
{
await state . DoStringAsync ( code , cancellationToken : cancellationToken );
}
catch ( LuaParseException e )
{
return new NoPlugin ( $"Was not able to parse the plugin: {e.Message}" );
}
catch ( LuaRuntimeException e )
{
return new NoPlugin ( $"Was not able to run the plugin: {e.Message}" );
}
if (! state . Environment [ "TYPE" ]. TryRead < string >( out var typeText ))
return new NoPlugin ( "TYPE does not exist or is not a valid string." );
if (! Enum . TryParse < PluginType >( typeText , out var type ))
return new NoPlugin ( $"TYPE is not a valid plugin type. Valid types are: {CommonTools.GetAllEnumValues<PluginType>()}" );
if ( type is PluginType . NONE )
return new NoPlugin ( $"TYPE is not a valid plugin type. Valid types are: {CommonTools.GetAllEnumValues<PluginType>()}" );
2026-08-09 19:01:58 +02:00
// Whether a plugin is internal is decided by its path, never by the plugin itself. We use the
// same nesting check as everywhere else, so that a directory like `.internal-old` next to the
// internal plugins does not count as internal:
var isInternal = IsPathInside ( INTERNAL_PLUGINS_ROOT , pluginPath );
2025-06-01 21:14:21 +02:00
switch ( type )
2025-04-23 14:07:22 +02:00
{
2025-06-01 21:14:21 +02:00
case PluginType . LANGUAGE :
return new PluginLanguage ( isInternal , state , type );
case PluginType . CONFIGURATION :
2026-05-22 15:46:03 +02:00
var configPlug = new PluginConfiguration ( isInternal , state , type )
{
PluginPath = pluginPath ?? string . Empty
};
2025-08-09 19:29:43 +02:00
await configPlug . InitializeAsync ( true );
2025-06-01 21:14:21 +02:00
return configPlug ;
2025-04-23 14:07:22 +02:00
2026-04-09 10:01:24 +02:00
case PluginType . ASSISTANT :
var assistantPlugin = new PluginAssistants ( isInternal , state , type );
assistantPlugin . TryLoad ();
return assistantPlugin ;
2026-09-12 15:26:54 +02:00
case PluginType . MODEL :
var modelPlugin = new PluginModels ( isInternal , state , type );
modelPlugin . TryLoad ();
return modelPlugin ;
2025-06-01 21:14:21 +02:00
default :
return new NoPlugin ( "This plugin type is not supported yet. Please try again with a future version of AI Studio." );
}
2025-04-23 14:07:22 +02:00
}
2026-08-06 20:59:53 +02:00
//
// =========================================================
// Compatibility shim. Please read the related document
// before you change anything here:
//
// documentation/compatibility-shims/2026-08-orphaned-config-locks.md
//
// Remove after 2027-08-06. Everything from here down to the
// end of this file belongs to the shim and can be deleted
// in one piece.
// =========================================================
//
/// <summary>
/// Repairs settings that were configured by a configuration plugin which was removed before
/// AI Studio started to persist the configuration ownership.
/// </summary>
/// <remarks>
/// All settings listed here share two properties: a configuration plugin can set them, and
/// there is no user interface to change them back. Therefore, any value that differs from the
/// default must originate from a configuration plugin. When such a setting is not managed
/// anymore, its plugin is gone and we restore the default value.<br/><br/>
/// This is only valid as long as none of these settings gets a user interface. When you add
/// one, remove the setting from this method and from the shim's document.
/// </remarks>
/// <param name="hasUnloadedConfigPlugins" >
/// True when at least one configuration plugin is deployed but could not be loaded. In that case,
/// we cannot tell whether a value comes from that plugin or from a removed one, so we repair
/// nothing at all.
/// </param>
/// <returns>True when at least one setting was repaired, otherwise false.</returns>
private static bool RepairLegacyConfigOnlySettings ( bool hasUnloadedConfigPlugins )
{
if ( hasUnloadedConfigPlugins )
{
LOG . LogWarning ( "Skipping the repair of configuration-only settings: at least one configuration plugin is deployed, but could not be loaded. We try again the next time AI Studio starts." );
return false ;
}
var data = SettingsManagerAccess . ConfigurationData ;
var wasRepaired = false ;
// Settings which are enabled by default and which only a configuration plugin can switch off:
wasRepaired |= RepairLegacyConfigOnlyFlag ( x => x . App , x => x . ShowIntroduction , data . App . ShowIntroduction );
wasRepaired |= RepairLegacyConfigOnlyFlag ( x => x . App , x => x . ShowQuickStartGuide , data . App . ShowQuickStartGuide );
wasRepaired |= RepairLegacyConfigOnlyFlag ( x => x . App , x => x . ShowLastChangelog , data . App . ShowLastChangelog );
wasRepaired |= RepairLegacyConfigOnlyFlag ( x => x . App , x => x . ShowVision , data . App . ShowVision );
wasRepaired |= RepairLegacyConfigOnlyFlag ( x => x . App , x => x . AllowUserToAddProvider , data . App . AllowUserToAddProvider );
wasRepaired |= RepairLegacyConfigOnlyFlag ( x => x . App , x => x . AllowUserToImportPlugins , data . App . AllowUserToImportPlugins );
wasRepaired |= RepairLegacyConfigOnlyFlag ( x => x . App , x => x . AllowUserToSharePlugins , data . App . AllowUserToSharePlugins );
// Collections which stay empty unless a configuration plugin fills them:
wasRepaired |= RepairLegacyConfigOnlyCollection ( x => x . App , x => x . HiddenAssistants , data . App . HiddenAssistants . Count );
wasRepaired |= RepairLegacyConfigOnlyCollection ( x => x . DataSourceSecurity , x => x . TrustedProviderIds , data . DataSourceSecurity . TrustedProviderIds . Count );
wasRepaired |= RepairLegacyConfigOnlyCollection ( x => x . AssistantPluginAudit , x => x . EnterpriseApprovedPlugins , data . AssistantPluginAudit . EnterpriseApprovedPlugins . Count );
return wasRepaired ;
}
/// <summary>
/// Restores the default of a boolean setting when it is switched off without being managed.
/// </summary>
private static bool RepairLegacyConfigOnlyFlag < TClass >( Expression < Func < Data , TClass >> configSelection , Expression < Func < TClass , bool >> propertyExpression , bool currentValue )
{
if ( currentValue )
return false ;
if (! ManagedConfiguration . TryGet ( configSelection , propertyExpression , out var configMeta ) || configMeta . ManagedMode is not null )
return false ;
LOG . LogWarning ( $"Repairing the setting '{configMeta.SettingName}': it was switched off by a configuration plugin which is not available anymore." );
configMeta . ResetLockedConfiguration ();
return true ;
}
/// <summary>
/// Clears a set-based setting when it contains entries without being managed.
/// </summary>
private static bool RepairLegacyConfigOnlyCollection < TClass , TValue >( Expression < Func < Data , TClass >> configSelection , Expression < Func < TClass , ISet < TValue >>> propertyExpression , int currentCount )
{
if ( currentCount is 0 )
return false ;
if (! ManagedConfiguration . TryGet ( configSelection , propertyExpression , out var configMeta ) || configMeta . ManagedMode is not null )
return false ;
LOG . LogWarning ( $"Repairing the setting '{configMeta.SettingName}': it was filled by a configuration plugin which is not available anymore." );
configMeta . ResetLockedConfiguration ();
return true ;
}
/// <summary>
/// Clears a list-based setting when it contains entries without being managed.
/// </summary>
private static bool RepairLegacyConfigOnlyCollection < TClass , TValue >( Expression < Func < Data , TClass >> configSelection , Expression < Func < TClass , IList < TValue >>> propertyExpression , int currentCount )
{
if ( currentCount is 0 )
return false ;
if (! ManagedConfiguration . TryGet ( configSelection , propertyExpression , out var configMeta ) || configMeta . ManagedMode is not null )
return false ;
LOG . LogWarning ( $"Repairing the setting '{configMeta.SettingName}': it was filled by a configuration plugin which is not available anymore." );
configMeta . ResetLockedConfiguration ();
return true ;
}
2026-02-07 22:59:41 +01:00
}