Improved local plugin handling (share, import, delete) (#900)
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-08-09 19:01:58 +02:00
1 parent 0eb747b386
commit 6e143aafaa
47 files changed
+3108 -1480

No files matched your search

@@ -49,6 +49,17 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
/// refine it, e.g. per department.
/// </remarks>
public int Priority { get; } = ReadPriority(state);
/// <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; }
public async Task InitializeAsync(bool dryRun)
{
@@ -148,6 +159,26 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
return 0;
}
/// <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;
}
/// <summary>
/// Tries to initialize the UI text content of the plugin.
/// </summary>
@@ -173,6 +204,8 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
message = TB("The SETTINGS table does not exist or is not a valid table.");
return false;
}
this.DeclaredSettingsCount = CountDeclaredSettings(settingsTable);
// Config: check for updates, and if so, how often?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.UpdateInterval, this.Id, settingsTable, dryRun);
@@ -201,6 +234,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Config: allow the user to import plugin archives?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportPlugins, this.Id, settingsTable, dryRun);
// Config: allow the user to import configuration plugin archives?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportConfigurationPlugins, this.Id, settingsTable, dryRun);
// Config: allow the user to share or export plugins?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToSharePlugins, this.Id, settingsTable, dryRun);
@@ -355,6 +391,28 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
if (dryRun)
return;
//
// 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);
switch (successful)
{
case true:
@@ -34,6 +34,41 @@ public sealed record PluginConfigurationObject
/// </summary>
public required PluginConfigurationObjectType Type { get; init; } = PluginConfigurationObjectType.NONE;
/// <summary>
/// The name of the configuration object, e.g. the name of a provider.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Where this configuration object sends data to: the host of a self-hosted provider or data
/// source, or the name of the cloud provider. Empty for objects without a destination, such as
/// chat templates or profiles.
/// </summary>
/// <remarks>
/// We keep this next to the object metadata so the import preview can tell users where a
/// configuration would send their prompts before its providers are stored.
/// </remarks>
public string Endpoint { get; private init; } = string.Empty;
/// <summary>
/// Determines the destination of a configuration object for the import preview.
/// </summary>
private static string DescribeEndpoint(IConfigurationObject configObject) => configObject switch
{
Settings.Provider { IsSelfHosted: true } provider => provider.Hostname,
Settings.Provider provider => Provider.LLMProvidersExtensions.ToName(provider.UsedLLMProvider),
EmbeddingProvider { IsSelfHosted: true } embeddingProvider => embeddingProvider.Hostname,
EmbeddingProvider embeddingProvider => Provider.LLMProvidersExtensions.ToName(embeddingProvider.UsedLLMProvider),
TranscriptionProvider { IsSelfHosted: true } transcriptionProvider => transcriptionProvider.Hostname,
TranscriptionProvider transcriptionProvider => Provider.LLMProvidersExtensions.ToName(transcriptionProvider.UsedLLMProvider),
DataSourceERI_V1 dataSource => dataSource.Hostname,
_ => string.Empty,
};
/// <summary>
/// Parses Lua table entries into configuration objects of the specified type, populating the
/// provided list with results.
@@ -125,6 +160,8 @@ public sealed record PluginConfigurationObject
ConfigPluginId = configPluginId,
Id = Guid.Parse(configObject.Id),
Type = configObjectType,
Name = configObject.Name,
Endpoint = DescribeEndpoint(configObject),
});
if (dryRun)
@@ -214,6 +251,8 @@ public sealed record PluginConfigurationObject
ConfigPluginId = configPluginId,
Id = Guid.Parse(configObject.Id),
Type = PluginConfigurationObjectType.DATA_SOURCE,
Name = configObject.Name,
Endpoint = DescribeEndpoint(configObject),
});
if (dryRun)
@@ -273,10 +312,10 @@ public sealed record PluginConfigurationObject
if (!existingObject.IsEnterpriseConfiguration || existingObject.EnterpriseConfigurationPluginId == configPluginId)
return true;
if (!PluginFactory.IsEnterpriseConfigurationPlugin(existingObject.EnterpriseConfigurationPluginId))
if (!PluginFactory.IsOrganizationConfigurationPlugin(existingObject.EnterpriseConfigurationPluginId))
return true;
if (PluginFactory.IsEnterpriseConfigurationPlugin(configPluginId))
if (PluginFactory.IsOrganizationConfigurationPlugin(configPluginId))
return true;
LOG.LogWarning("The configuration plugin '{ConfigPluginId}' tried to replace the object '{ConfigObjectName}' (id={ConfigObjectId}), which belongs to the configuration plugin '{OwningConfigPluginId}' of your organization. Ignoring the attempt: configurations deployed by your organization's IT take precedence.", configPluginId, existingObject.Name, existingObject.Id, existingObject.EnterpriseConfigurationPluginId);
@@ -119,13 +119,17 @@ public static partial class PluginFactory
//
if (AVAILABLE_PLUGINS.FirstOrDefault(candidate => candidate.Id == plugin.Id) is { } duplicatePlugin)
{
if (!IsEnterpriseConfigurationPath(pluginPath) || IsEnterpriseConfigurationPath(duplicatePlugin.LocalPath))
if (GetConfigurationAuthority(pluginPath) <= GetConfigurationAuthority(duplicatePlugin.LocalPath))
{
LOG.LogWarning($"Ignoring the plugin '{pluginMainFile}': its ID ('{plugin.Id}') is already used by the plugin at '{duplicatePlugin.LocalPath}'. Plugin IDs must be unique. Please remove one of these plugins.");
continue;
}
LOG.LogWarning($"Ignoring the plugin at '{duplicatePlugin.LocalPath}': it uses the ID ('{plugin.Id}') of the enterprise configuration plugin at '{pluginPath}'. Plugins deployed by your organization's IT take precedence.");
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.");
AVAILABLE_PLUGINS.Remove(duplicatePlugin);
}
@@ -199,6 +203,15 @@ public static partial class PluginFactory
// one broken configuration plugin would wipe the entire organization configuration:
//
var deployedEnterpriseConfigPluginIds = GetDeployedEnterpriseConfigPluginIds();
//
// 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);
var unloadedEnterpriseConfigPluginIds = deployedEnterpriseConfigPluginIds.Where(x => AVAILABLE_PLUGINS.All(plugin => plugin.Id != x)).ToList();
foreach (var unloadedEnterpriseConfigPluginId in unloadedEnterpriseConfigPluginIds)
LOG.LogWarning($"The configuration plugin '{unloadedEnterpriseConfigPluginId}' is deployed, but was not loaded. Everything it manages stays unchanged, because the plugin was not removed. Please check the errors above and fix the plugin.");
@@ -345,7 +358,10 @@ public static partial class PluginFactory
if(type is PluginType.NONE)
return new NoPlugin($"TYPE is not a valid plugin type. Valid types are: {CommonTools.GetAllEnumValues<PluginType>()}");
var isInternal = !string.IsNullOrWhiteSpace(pluginPath) && pluginPath.StartsWith(INTERNAL_PLUGINS_ROOT, StringComparison.OrdinalIgnoreCase);
// 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);
switch (type)
{
case PluginType.LANGUAGE:
@@ -1,129 +1,90 @@
using System.Text.RegularExpressions;
namespace AIStudio.Tools.PluginSystem;
public static partial class PluginFactory
{
private const string REASON_NO_LONGER_REFERENCED = "no longer referenced by active enterprise environments";
/// <summary>
/// Removes the configuration plugins an organization deployed once but does not reference anymore.
/// </summary>
/// <remarks>
/// This is how an organization withdraws a configuration: it removes the configuration ID from the
/// devices, e.g. through a group policy. The next time AI Studio syncs, the local copy has to go.
/// A device which was offline while the policy changed applies the withdrawal when it starts again.
/// <br/><br/>
/// What an organization deployed is decided by the plugin path alone. We must not ask the plugin
/// itself: `DEPLOYED_USING_CONFIG_SERVER` is part of the plugin, so a configuration declaring
/// `false` could never be withdrawn again once it was deployed, while it would keep every right of
/// an organization configuration, including the approval of assistant plugins.
/// </remarks>
/// <param name="activeConfigurationIds">The IDs of the enterprise configurations which are currently referenced.</param>
public static void RemoveUnreferencedManagedConfigurationPlugins(ISet<Guid> activeConfigurationIds)
{
if (!IsInitialized)
if (!IsInitialized || !Directory.Exists(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
return;
var pluginIdsToRemove = new HashSet<Guid>();
// Case 1: Plugins are already loaded and metadata is available.
foreach (var plugin in AVAILABLE_PLUGINS.Where(plugin =>
plugin.Type is PluginType.CONFIGURATION &&
plugin.IsManagedByConfigServer &&
!activeConfigurationIds.Contains(plugin.Id)))
pluginIdsToRemove.Add(plugin.Id);
// Case 2: Startup cleanup before the initial plugin load.
// In this case, we inspect the .config directories directly.
if (Directory.Exists(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
foreach (var configurationDirectory in Directory.EnumerateDirectories(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
{
foreach (var pluginDirectory in Directory.EnumerateDirectories(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
{
var directoryName = Path.GetFileName(pluginDirectory);
if (!Guid.TryParse(directoryName, out var pluginId))
continue;
var directoryName = Path.GetFileName(configurationDirectory);
if (activeConfigurationIds.Contains(pluginId))
continue;
// A download in flight stages and backs up next to the configuration directories. Those
// directories belong to a running update, not to a withdrawn configuration:
if (IsTransientDownloadDirectory(directoryName))
continue;
var deployFlag = ReadDeployFlagFromPluginFile(pluginDirectory);
var isManagedByConfigServer = deployFlag ?? true;
if (!deployFlag.HasValue)
LOG.LogWarning($"Configuration plugin '{pluginId}' does not define 'DEPLOYED_USING_CONFIG_SERVER'. Falling back to the plugin path and treating it as managed because it is stored under '{ENTERPRISE_CONFIGURATION_PLUGINS_ROOT}'.");
//
// A configuration server downloads each configuration into a directory named after its
// ID. Any other directory name cannot be referenced by an enterprise environment, so it
// has no place here either:
//
if (Guid.TryParse(directoryName, out var configurationId) && activeConfigurationIds.Contains(configurationId))
continue;
if (isManagedByConfigServer)
pluginIdsToRemove.Add(pluginId);
}
}
foreach (var pluginId in pluginIdsToRemove)
RemovePluginAsync(pluginId, REASON_NO_LONGER_REFERENCED);
}
private static void RemovePluginAsync(Guid pluginId, string reason)
{
if (!IsInitialized)
return;
LOG.LogWarning("Removing plugin with ID '{PluginId}'. Reason: {Reason}.", pluginId, reason);
//
// Remove the plugin from the available plugins list:
//
var availablePluginToRemove = AVAILABLE_PLUGINS.FirstOrDefault(p => p.Id == pluginId);
if (availablePluginToRemove != null)
AVAILABLE_PLUGINS.Remove(availablePluginToRemove);
else
LOG.LogWarning("No available plugin found with ID '{PluginId}' while removing plugin. Reason: {Reason}.", pluginId, reason);
//
// Remove the plugin from the running plugins list:
//
var runningPluginToRemove = RUNNING_PLUGINS.FirstOrDefault(p => p.Id == pluginId);
if (runningPluginToRemove == null)
LOG.LogWarning("No running plugin found with ID '{PluginId}' while removing plugin. Reason: {Reason}.", pluginId, reason);
else
RUNNING_PLUGINS.Remove(runningPluginToRemove);
//
// Delete the plugin directory:
//
DeleteConfigurationPluginDirectory(pluginId);
LOG.LogInformation("Plugin with ID '{PluginId}' removed successfully. Reason: {Reason}.", pluginId, reason);
}
private static bool? ReadDeployFlagFromPluginFile(string pluginDirectory)
{
try
{
var pluginFile = Path.Join(pluginDirectory, "plugin.lua");
if (!File.Exists(pluginFile))
return null;
var pluginCode = File.ReadAllText(pluginFile);
var match = DeployedByConfigServerRegex().Match(pluginCode);
if (!match.Success)
return null;
return bool.TryParse(match.Groups[1].Value, out var deployFlag)
? deployFlag
: null;
}
catch (Exception ex)
{
LOG.LogWarning(ex, $"Failed to parse deployment flag from plugin directory '{pluginDirectory}'.");
return null;
RemoveConfigurationDirectory(configurationDirectory, REASON_NO_LONGER_REFERENCED);
}
}
private static void DeleteConfigurationPluginDirectory(Guid pluginId)
/// <summary>
/// Checks whether a directory below the enterprise configuration directory belongs to a running
/// download instead of to an installed configuration.
/// </summary>
private static bool IsTransientDownloadDirectory(string directoryName) =>
directoryName.Contains(".staging-", StringComparison.OrdinalIgnoreCase) ||
directoryName.Contains(".backup-", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Unloads every plugin stored in the given directory and deletes the directory afterwards.
/// </summary>
private static void RemoveConfigurationDirectory(string configurationDirectory, string reason)
{
var pluginDirectory = Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, pluginId.ToString());
if (!Directory.Exists(pluginDirectory))
LOG.LogWarning("Removing the enterprise configuration directory '{Directory}'. Reason: {Reason}.", configurationDirectory, reason);
//
// We collect the plugins by path, not by the ID the directory is named after: a plugin may
// declare an ID which differs from its directory name, and a single directory may even hold
// several plugins:
//
foreach (var plugin in AVAILABLE_PLUGINS.Where(plugin => IsPathInside(configurationDirectory, plugin.LocalPath)).ToList())
{
LOG.LogWarning($"Plugin directory '{pluginDirectory}' does not exist.");
return;
AVAILABLE_PLUGINS.Remove(plugin);
if (RUNNING_PLUGINS.FirstOrDefault(runningPlugin => runningPlugin.Id == plugin.Id) is { } runningPluginToRemove)
RUNNING_PLUGINS.Remove(runningPluginToRemove);
LOG.LogInformation("Unloaded the plugin '{PluginName}' ({PluginId}). Reason: {Reason}.", plugin.Name, plugin.Id, reason);
}
if (!Directory.Exists(configurationDirectory))
return;
try
{
Directory.Delete(pluginDirectory, true);
LOG.LogInformation($"Plugin directory '{pluginDirectory}' deleted successfully.");
Directory.Delete(configurationDirectory, true);
LOG.LogInformation($"Plugin directory '{configurationDirectory}' deleted successfully.");
}
catch (Exception ex)
catch (Exception e)
{
LOG.LogError(ex, $"Failed to delete plugin directory '{pluginDirectory}'.");
LOG.LogError(e, $"Failed to delete plugin directory '{configurationDirectory}'.");
}
}
[GeneratedRegex(@"^\s*DEPLOYED_USING_CONFIG_SERVER\s*=\s*(true|false)\s*(?:--.*)?$", RegexOptions.IgnoreCase | RegexOptions.Multiline)]
private static partial Regex DeployedByConfigServerRegex();
}
@@ -110,9 +110,10 @@ public static partial class PluginFactory
/// </summary>
/// <remarks>
/// The configuration plugins an organization deployed go first: they are the baseline for
/// everything else. Local configuration plugins follow, so they can add to that baseline instead
/// of replacing parts of it. All remaining plugin types write no settings at all, so their rank
/// is irrelevant for the outcome.<br/><br/>
/// everything else. A test configuration follows, so that an administrator sees their draft take
/// effect over the deployed baseline. Local configuration plugins come last, so they can add to
/// that baseline instead of replacing parts of it. All remaining plugin types write no settings at
/// all, so their rank is irrelevant for the outcome.<br/><br/>
/// The rank comes before the declared priority on purpose: a local configuration plugin must not
/// be able to jump ahead of an organization by declaring a high priority.
/// </remarks>
@@ -121,9 +122,10 @@ public static partial class PluginFactory
private static int GetStartupRank(IAvailablePlugin plugin) => plugin.Type switch
{
PluginType.CONFIGURATION when IsEnterpriseConfigurationPath(plugin.LocalPath) => 0,
PluginType.CONFIGURATION => 1,
PluginType.CONFIGURATION when IsEnterpriseTestConfigurationPath(plugin.LocalPath) => 1,
PluginType.CONFIGURATION => 2,
_ => 2,
_ => 3,
};
private static void LogAssistantPluginStartupState()
@@ -21,9 +21,31 @@ public static partial class PluginFactory
/// deploys plugins here, each in a directory named after its configuration ID.
/// </remarks>
private static string ENTERPRISE_CONFIGURATION_PLUGINS_ROOT = string.Empty;
/// <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;
private static string HOT_RELOAD_LOCK_FILE = string.Empty;
private static FileSystemWatcher HOT_RELOAD_WATCHER = null!;
/// <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; }
public static ILanguagePlugin BaseLanguage { get; private set; } = NoPluginLanguage.INSTANCE;
public static bool IsInitialized { get; private set; }
@@ -75,10 +97,12 @@ public static partial class PluginFactory
HOT_RELOAD_LOCK_FILE = Path.Join(PLUGINS_ROOT, ".lock");
INTERNAL_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".internal");
ENTERPRISE_CONFIGURATION_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".config");
ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".config-tests");
if (!Directory.Exists(PLUGINS_ROOT))
Directory.CreateDirectory(PLUGINS_ROOT);
ClearTestConfigurationPlugins();
HOT_RELOAD_WATCHER = new(PLUGINS_ROOT);
IsInitialized = true;
LOG.LogInformation("Plugin factory initialized successfully.");
@@ -96,20 +120,130 @@ public static partial class PluginFactory
/// </remarks>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <returns>True when the directory is nested in the enterprise configuration directory.</returns>
private static bool IsEnterpriseConfigurationPath(string? pluginPath)
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 (string.IsNullOrWhiteSpace(pluginPath) || string.IsNullOrWhiteSpace(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
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()
{
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))
return false;
try
{
var configurationRoot = Path.GetFullPath(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
var pluginDirectory = Path.GetFullPath(pluginPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
return pluginDirectory.StartsWith(configurationRoot, StringComparison.OrdinalIgnoreCase);
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}' belongs to the enterprise configuration directory. Treating it as a local plugin.");
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;
var pluginDirectory = Path.GetFullPath(pluginPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
return pluginDirectory.StartsWith(root, StringComparison.OrdinalIgnoreCase);
}
catch (Exception e)
{
LOG.LogWarning(e, $"Was not able to check whether the plugin directory '{pluginPath}' is nested in '{rootDirectory}'. Treating it as unrelated.");
return false;
}
}
@@ -135,6 +269,27 @@ public static partial class PluginFactory
return Directory.Exists(Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, configPluginId.ToString()));
}
/// <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));
}
private static async Task LockHotReloadAsync()
{
if (!IsInitialized)