2025-03-22 21:12:14 +01:00
using AIStudio.Settings ;
2026-04-10 17:11:05 +02:00
using AIStudio.Settings.DataModel ;
2025-03-22 21:12:14 +01:00
namespace AIStudio.Tools.PluginSystem ;
2025-03-29 18:40:17 +01:00
public static partial class PluginFactory
2025-03-22 21:12:14 +01:00
{
2025-04-12 21:13:33 +02:00
private static readonly ILogger LOG = Program . LOGGER_FACTORY . CreateLogger ( nameof ( PluginFactory ));
2026-06-10 21:01:27 +02:00
private static SettingsManager SettingsManagerAccess => Program . SERVICE_PROVIDER . GetRequiredService < SettingsManager >();
2026-02-19 20:43:47 +01:00
2025-04-12 21:13:33 +02:00
private static string DATA_DIR = string . Empty ;
private static string PLUGINS_ROOT = string . Empty ;
private static string INTERNAL_PLUGINS_ROOT = string . Empty ;
2026-08-08 18:35:46 +02:00
/// <summary>
/// The directory the config server downloads the configuration plugins of an organization into.
/// </summary>
/// <remarks>
/// This is not the home of configuration plugins in general: a local configuration plugin can
/// live in any directory below the plugins root. Only the IT department of an organization
/// deploys plugins here, each in a directory named after its configuration ID.
/// </remarks>
private static string ENTERPRISE_CONFIGURATION_PLUGINS_ROOT = string . Empty ;
2026-08-09 19:01:58 +02:00
/// <summary>
/// The directory administrators use to try out a configuration before their organization deploys it.
/// </summary>
/// <remarks>
/// Everything stored here acts on behalf of the organization, so that a test behaves like the
/// later rollout, including the approval of assistant plugins. In exchange, the directory is
/// emptied on every start: a test configuration lives for one session only. It also never gets
/// the protection of a deployed configuration, so users can remove or replace it through the user
/// interface.
/// </remarks>
private static string ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT = string . Empty ;
2025-06-09 14:06:54 +02:00
private static string HOT_RELOAD_LOCK_FILE = string . Empty ;
2025-04-12 21:13:33 +02:00
private static FileSystemWatcher HOT_RELOAD_WATCHER = null !;
2026-08-09 19:01:58 +02:00
/// <summary>
/// How many test configurations were removed while AI Studio was starting.
/// </summary>
/// <remarks>
/// The user interface reports this: an administrator who placed a test configuration and restarted
/// AI Studio would otherwise face an empty directory without any explanation.
/// </remarks>
public static int RemovedTestConfigurationsAtStartup { get ; private set ; }
2026-02-19 20:43:47 +01:00
public static ILanguagePlugin BaseLanguage { get ; private set ; } = NoPluginLanguage . INSTANCE ;
public static bool IsInitialized { get ; private set ; }
2025-04-07 19:36:24 +02:00
2026-02-07 22:59:41 +01:00
/// <summary>
/// Gets the enterprise encryption instance for decrypting API keys in configuration plugins.
/// </summary>
public static EnterpriseEncryption ? EnterpriseEncryption { get ; private set ; }
/// <summary>
/// Initializes the enterprise encryption service by reading the encryption secret
2026-03-31 13:02:59 +02:00
/// from the effective enterprise source.
2026-02-07 22:59:41 +01:00
/// </summary>
/// <param name="rustService">The Rust service to use for reading the encryption secret.</param>
public static async Task InitializeEnterpriseEncryption ( Services . RustService rustService )
{
var encryptionSecret = await rustService . EnterpriseEnvConfigEncryptionSecret ();
2026-03-31 13:02:59 +02:00
InitializeEnterpriseEncryption ( encryptionSecret );
}
/// <summary>
/// Initializes the enterprise encryption service using a prefetched secret value.
/// </summary>
/// <param name="encryptionSecret">The base64-encoded enterprise encryption secret.</param>
public static void InitializeEnterpriseEncryption ( string? encryptionSecret )
{
LOG . LogInformation ( "Initializing enterprise encryption service..." );
2026-02-07 22:59:41 +01:00
var enterpriseEncryptionLogger = Program . LOGGER_FACTORY . CreateLogger < EnterpriseEncryption >();
EnterpriseEncryption = new EnterpriseEncryption ( enterpriseEncryptionLogger , encryptionSecret );
if ( EnterpriseEncryption . IsAvailable )
LOG . LogInformation ( "Enterprise encryption service is available." );
else
LOG . LogWarning ( "Enterprise encryption service is not available (no secret configured)." );
}
2025-04-12 21:13:33 +02:00
/// <summary>
/// Set up the plugin factory. We will read the data directory from the settings manager.
/// Afterward, we will create the plugins directory and the internal plugin directory.
/// </summary>
2025-04-24 09:50:03 +02:00
public static bool Setup ()
2025-04-07 19:36:24 +02:00
{
2026-02-19 20:43:47 +01:00
if ( IsInitialized )
2025-04-24 09:50:03 +02:00
return false ;
2025-04-23 14:07:22 +02:00
2025-06-02 20:08:25 +02:00
LOG . LogInformation ( "Initializing plugin factory..." );
2025-04-12 21:13:33 +02:00
DATA_DIR = SettingsManager . DataDirectory !;
PLUGINS_ROOT = Path . Join ( DATA_DIR , "plugins" );
2025-06-09 14:06:54 +02:00
HOT_RELOAD_LOCK_FILE = Path . Join ( PLUGINS_ROOT , ".lock" );
2025-04-12 21:13:33 +02:00
INTERNAL_PLUGINS_ROOT = Path . Join ( PLUGINS_ROOT , ".internal" );
2026-08-08 18:35:46 +02:00
ENTERPRISE_CONFIGURATION_PLUGINS_ROOT = Path . Join ( PLUGINS_ROOT , ".config" );
2026-08-09 19:01:58 +02:00
ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT = Path . Join ( PLUGINS_ROOT , ".config-tests" );
2025-04-07 19:36:24 +02:00
if (! Directory . Exists ( PLUGINS_ROOT ))
Directory . CreateDirectory ( PLUGINS_ROOT );
2026-08-09 19:01:58 +02:00
ClearTestConfigurationPlugins ();
2025-04-07 19:36:24 +02:00
HOT_RELOAD_WATCHER = new ( PLUGINS_ROOT );
2026-02-19 20:43:47 +01:00
IsInitialized = true ;
2025-06-02 20:08:25 +02:00
LOG . LogInformation ( "Plugin factory initialized successfully." );
2025-04-24 09:50:03 +02:00
return true ;
2025-04-07 19:36:24 +02:00
}
2025-06-09 14:06:54 +02:00
2026-08-08 18:35:46 +02:00
/// <summary>
/// Checks whether a plugin directory belongs to the enterprise configuration area.
/// </summary>
/// <remarks>
/// Only the IT department of an organization deploys plugins there: the config server downloads
/// them into a directory named after their configuration ID. We decide by path on purpose. The
/// Lua field DEPLOYED_USING_CONFIG_SERVER is self-declared, so any plugin could claim to be
/// deployed by an organization.
/// </remarks>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <returns>True when the directory is nested in the enterprise configuration directory.</returns>
2026-08-09 19:01:58 +02:00
public static bool IsEnterpriseConfigurationPath ( string? pluginPath ) => IsPathInside ( ENTERPRISE_CONFIGURATION_PLUGINS_ROOT , pluginPath );
/// <summary>
/// Checks whether a plugin directory belongs to the test configuration area.
/// </summary>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <returns>True when the directory is nested in the test configuration directory.</returns>
public static bool IsEnterpriseTestConfigurationPath ( string? pluginPath ) => IsPathInside ( ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT , pluginPath );
/// <summary>
/// Checks whether a plugin acts on behalf of an organization, either deployed by a configuration
/// server or staged for a test.
/// </summary>
/// <remarks>
/// Use this wherever a configuration speaks for the organization, e.g. when it approves assistant
/// plugins or claims a setting against a local configuration plugin. Do not use it where a
/// deployed configuration is protected against the user, e.g. against deletion: an administrator
/// must be able to get rid of their own test configuration.
/// </remarks>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <returns>True when the directory belongs to the enterprise or the test configuration area.</returns>
public static bool IsOrganizationConfigurationPath ( string? pluginPath ) => IsEnterpriseConfigurationPath ( pluginPath ) || IsEnterpriseTestConfigurationPath ( pluginPath );
/// <summary>
/// Ranks how much say a configuration plugin has, based on where it is stored. The higher rank
/// wins when two configuration plugins claim the same plugin ID.
/// </summary>
/// <remarks>
/// A test configuration outranks a deployed one on purpose: an administrator tries out the next
/// version of a configuration under the ID it will have later. Local configuration plugins rank
/// lowest, so nobody can push aside what an organization deployed.
/// </remarks>
private static int GetConfigurationAuthority ( string? pluginPath )
{
if ( IsEnterpriseTestConfigurationPath ( pluginPath ))
return 2 ;
return IsEnterpriseConfigurationPath ( pluginPath ) ? 1 : 0 ;
}
/// <summary>
/// Empties the test configuration directory.
/// </summary>
/// <remarks>
/// A test configuration carries the rights of an organization configuration without anybody having
/// deployed it. It must therefore never outlive the session it was placed in, and administrators
/// get a predictable lifetime instead of a configuration which is swept away at some point.
/// </remarks>
private static void ClearTestConfigurationPlugins ()
2026-08-08 18:35:46 +02:00
{
2026-08-09 19:01:58 +02:00
RemovedTestConfigurationsAtStartup = 0 ;
try
{
if ( Directory . Exists ( ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT ))
{
var removedTestConfigurations = Directory . EnumerateDirectories ( ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT ). Count ();
Directory . Delete ( ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT , true );
RemovedTestConfigurationsAtStartup = removedTestConfigurations ;
if ( removedTestConfigurations > 0 )
LOG . LogWarning ( $"Removed {removedTestConfigurations} test configuration(s) from '{ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT}'. Test configurations are valid for one session only." );
}
Directory . CreateDirectory ( ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT );
}
catch ( Exception e )
{
LOG . LogError ( e , $"Failed to empty the test configuration directory '{ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT}'." );
}
}
/// <summary>
/// Checks whether a plugin directory is stored below the plugins directory of AI Studio.
/// </summary>
/// <remarks>
/// Everything that removes or replaces plugin files checks this first, so a plugin directory
/// which points somewhere else can never be touched.
/// </remarks>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <returns>True when the directory is nested in the plugins directory.</returns>
public static bool IsInsidePluginsRoot ( string? pluginPath ) => IsPathInside ( PLUGINS_ROOT , pluginPath );
/// <summary>
/// Checks whether a plugin directory is the plugins directory itself.
/// </summary>
/// <remarks>
/// A `plugin.lua` placed directly in the plugins directory makes that directory the plugin
/// directory. Removing or replacing such a plugin means touching its directory, which would take
/// every other plugin with it.
/// </remarks>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <returns>True when the directory is the plugins directory.</returns>
public static bool IsPluginsRoot ( string? pluginPath )
{
if ( string . IsNullOrWhiteSpace ( pluginPath ) || string . IsNullOrWhiteSpace ( PLUGINS_ROOT ))
2026-08-08 18:35:46 +02:00
return false ;
try
{
2026-08-09 19:01:58 +02:00
var root = Path . GetFullPath ( PLUGINS_ROOT ). TrimEnd ( Path . DirectorySeparatorChar , Path . AltDirectorySeparatorChar );
var pluginDirectory = Path . GetFullPath ( pluginPath ). TrimEnd ( Path . DirectorySeparatorChar , Path . AltDirectorySeparatorChar );
return string . Equals ( root , pluginDirectory , StringComparison . OrdinalIgnoreCase );
}
catch ( Exception e )
{
LOG . LogWarning ( e , $"Was not able to check whether the plugin directory '{pluginPath}' is the plugins directory. Treating it as the plugins directory." );
return true ;
}
}
private static bool IsPathInside ( string rootDirectory , string? pluginPath )
{
if ( string . IsNullOrWhiteSpace ( pluginPath ) || string . IsNullOrWhiteSpace ( rootDirectory ))
return false ;
try
{
var root = Path . GetFullPath ( rootDirectory ). TrimEnd ( Path . DirectorySeparatorChar , Path . AltDirectorySeparatorChar ) + Path . DirectorySeparatorChar ;
2026-08-08 18:35:46 +02:00
var pluginDirectory = Path . GetFullPath ( pluginPath ). TrimEnd ( Path . DirectorySeparatorChar , Path . AltDirectorySeparatorChar ) + Path . DirectorySeparatorChar ;
2026-08-09 19:01:58 +02:00
return pluginDirectory . StartsWith ( root , StringComparison . OrdinalIgnoreCase );
2026-08-08 18:35:46 +02:00
}
catch ( Exception e )
{
2026-08-09 19:01:58 +02:00
LOG . LogWarning ( e , $"Was not able to check whether the plugin directory '{pluginPath}' is nested in '{rootDirectory}'. Treating it as unrelated." );
2026-08-08 18:35:46 +02:00
return false ;
}
}
/// <summary>
/// Checks whether a configuration plugin was deployed by the IT department of an organization.
/// </summary>
/// <remarks>
/// A plugin which is deployed but could not be loaded still counts: it might be broken, e.g. due
/// to invalid Lua code or an incomplete download, but it was not removed. Everything it manages
/// stays under the control of the organization until the plugin is gone for good.
/// </remarks>
/// <param name="configPluginId">The ID of the configuration plugin.</param>
/// <returns>True when the plugin belongs to an organization, false when it is local or unknown.</returns>
public static bool IsEnterpriseConfigurationPlugin ( Guid configPluginId )
{
if ( configPluginId == Guid . Empty || ! IsInitialized )
return false ;
if ( AVAILABLE_PLUGINS . Any ( plugin => plugin . Id == configPluginId && plugin . Type is PluginType . CONFIGURATION && IsEnterpriseConfigurationPath ( plugin . LocalPath )))
return true ;
return Directory . Exists ( Path . Join ( ENTERPRISE_CONFIGURATION_PLUGINS_ROOT , configPluginId . ToString ()));
}
2026-08-09 19:01:58 +02:00
/// <summary>
/// Checks whether a configuration plugin speaks for an organization: either deployed by its IT
/// department, or staged as a test configuration.
/// </summary>
/// <remarks>
/// A test configuration is only ever loaded, never merely present: it is emptied on every start,
/// so there is no unloadable leftover to account for.
/// </remarks>
/// <param name="configPluginId">The ID of the configuration plugin.</param>
/// <returns>True when the plugin speaks for an organization, false when it is local or unknown.</returns>
public static bool IsOrganizationConfigurationPlugin ( Guid configPluginId )
{
if ( configPluginId == Guid . Empty || ! IsInitialized )
return false ;
if ( IsEnterpriseConfigurationPlugin ( configPluginId ))
return true ;
return AVAILABLE_PLUGINS . Any ( plugin => plugin . Id == configPluginId && plugin . Type is PluginType . CONFIGURATION && IsEnterpriseTestConfigurationPath ( plugin . LocalPath ));
}
2025-06-09 14:06:54 +02:00
private static async Task LockHotReloadAsync ()
{
2026-02-19 20:43:47 +01:00
if (! IsInitialized )
2025-06-09 14:06:54 +02:00
{
LOG . LogError ( "PluginFactory is not initialized." );
return ;
}
try
{
if ( File . Exists ( HOT_RELOAD_LOCK_FILE ))
{
LOG . LogWarning ( "Hot reload lock file already exists." );
return ;
}
await File . WriteAllTextAsync ( HOT_RELOAD_LOCK_FILE , DateTime . UtcNow . ToString ( "o" ));
}
catch ( Exception e )
{
LOG . LogError ( e , "An error occurred while trying to lock hot reloading." );
}
}
private static void UnlockHotReload ()
{
2026-02-19 20:43:47 +01:00
if (! IsInitialized )
2025-06-09 14:06:54 +02:00
{
LOG . LogError ( "PluginFactory is not initialized." );
return ;
}
try
{
if ( File . Exists ( HOT_RELOAD_LOCK_FILE ))
File . Delete ( HOT_RELOAD_LOCK_FILE );
else
LOG . LogWarning ( "Hot reload lock file does not exist. Nothing to unlock." );
}
catch ( Exception e )
{
LOG . LogError ( e , "An error occurred while trying to unlock hot reloading." );
}
}
2025-03-29 18:40:17 +01:00
2025-03-30 20:34:30 +02:00
public static void Dispose ()
{
2026-02-19 20:43:47 +01:00
if (! IsInitialized )
2025-04-12 21:13:33 +02:00
return ;
2025-03-30 20:34:30 +02:00
HOT_RELOAD_WATCHER . Dispose ();
}
2026-04-10 17:11:05 +02:00
public static IReadOnlyList < DataMandatoryInfo > GetMandatoryInfos ()
{
return RUNNING_PLUGINS
. OfType < PluginConfiguration >()
. SelectMany ( plugin => plugin . MandatoryInfos )
. ToList ();
}
2026-06-20 20:28:22 +02:00
public static IReadOnlyList < DataIntroduction > GetIntroductions ()
{
return RUNNING_PLUGINS
. OfType < PluginConfiguration >()
. SelectMany ( plugin => plugin . Introductions )
. OrderBy ( introduction => introduction . Index )
. ToList ();
}
2025-03-22 21:12:14 +01:00
}