Merge branch 'main' into chunk-data

Resolved 29 conflicting files. The notable decisions:

Confidence: main's tool-calling gate (RequiredProviderConfidence) and this
branch's local-RAG gate (DataConfidenceLevel) turned out to be the same rule
on the same axis, so they are now one field. Both tool results and data
sources raise it through RequireProviderConfidence(). The gate checks the
level strictly and no longer exempts providers trusted by configuration:
TrustedProviderIds is documented as applying to data-source security checks
only, and organizations set confidence through DataConfidence
.CustomConfidenceScheme instead. The security axis (DataSecurity, ERI,
IsTrustedForDataSourceSecurityChecks) is unchanged.

Provider creation: main's CreateProvider signature won (hfEndpointKind,
capabilityOverrides, no model parameter); tokenizerPath was added to it and
is set for every provider, including the new Hetzner, IONOS and LiteLLM.
Provider and EmbeddingProvider combine the record parameters, Lua parsing and
Lua serialization of both sides.

File types: main's hierarchy (ODT leaf, WORD parent, PowerPoint without the
legacy .ppt, TABULAR instead of DELIMITED_TABLE) plus this branch's
SPREADSHEET parent with ODS and the xlsm/xlsb/xla/xlam extensions, which the
runtime already reads. Both sides had added a conflicting HTML filter; the
reading family keeps the name, and the export path uses a narrow
HTML_DOCUMENT, following the existing LATEX/TEX split.

Runtime: main's file_data.rs is the base, including the prompt-injection
sanitizer and the extraction routes. Token counting and chunk segmentation
moved into take_released, so they act on the text the filter has released
rather than on text it is still holding. A failed count is logged and left
out instead of ending the extraction, because the app counts such a segment
itself.

Data sources: the participating-provider checks of this branch are kept, and
main's GetAllowedDataSources overload now builds on them. DirectChatService
resolves the launched chat's data source options before the check, so filter
and chat see the same options.

.NET and Rust both build clean; I18N regenerated to 4060 keys.
This commit is contained in:
Thorsten Sommer committed 2026-09-05 21:20:33 +02:00
commit fe35630eff
640 files changed
+45059 -4952

No files matched your search

@@ -0,0 +1,4 @@
namespace AIStudio.Tools.PluginSystem.Assistants;
/// <param name="ToolIds">The tools preselected for the chat, or null when the launcher names none.</param>
public sealed record AssistantChatLaunchConfiguration(string WorkspaceName, Guid? ProviderId, Guid? ProfileId, Guid? ChatTemplateId, IReadOnlyList<Guid>? DataSourceIds, IReadOnlyList<string>? ToolIds);
@@ -7,66 +7,88 @@ public class AssistantComponentFactory
{
private static readonly ILogger<AssistantComponentFactory> LOGGER = Program.LOGGER_FACTORY.CreateLogger<AssistantComponentFactory>();
public static IAssistantComponent CreateComponent(
AssistantComponentType type,
Dictionary<string, object> props,
List<IAssistantComponent> children)
public static IAssistantComponent CreateComponent(AssistantComponentType type, Dictionary<string, object> props, List<IAssistantComponent> children)
{
switch (type)
{
case AssistantComponentType.FORM:
return new AssistantForm { Props = props, Children = children };
case AssistantComponentType.TEXT_AREA:
return new AssistantTextArea { Props = props, Children = children };
case AssistantComponentType.BUTTON:
return new AssistantButton { Props = props, Children = children};
case AssistantComponentType.BUTTON_GROUP:
return new AssistantButtonGroup { Props = props, Children = children };
case AssistantComponentType.DROPDOWN:
return new AssistantDropdown { Props = props, Children = children };
case AssistantComponentType.PROVIDER_SELECTION:
return new AssistantProviderSelection { Props = props, Children = children };
case AssistantComponentType.PROFILE_SELECTION:
return new AssistantProfileSelection { Props = props, Children = children };
case AssistantComponentType.SWITCH:
return new AssistantSwitch { Props = props, Children = children };
case AssistantComponentType.HEADING:
return new AssistantHeading { Props = props, Children = children };
case AssistantComponentType.TEXT:
return new AssistantText { Props = props, Children = children };
case AssistantComponentType.LIST:
return new AssistantList { Props = props, Children = children };
case AssistantComponentType.WEB_CONTENT_READER:
return new AssistantWebContentReader { Props = props, Children = children };
case AssistantComponentType.FILE_CONTENT_READER:
return new AssistantFileContentReader { Props = props, Children = children };
case AssistantComponentType.FILE_ATTACHMENTS:
return new AssistantFileAttachment { Props = props, Children = children };
case AssistantComponentType.IMAGE:
return new AssistantImage { Props = props, Children = children };
case AssistantComponentType.COLOR_PICKER:
return new AssistantColorPicker { Props = props, Children = children };
case AssistantComponentType.DATE_PICKER:
return new AssistantDatePicker { Props = props, Children = children };
case AssistantComponentType.DATE_RANGE_PICKER:
return new AssistantDateRangePicker { Props = props, Children = children };
case AssistantComponentType.TIME_PICKER:
return new AssistantTimePicker { Props = props, Children = children };
case AssistantComponentType.LAYOUT_ITEM:
return new AssistantItem { Props = props, Children = children };
case AssistantComponentType.LAYOUT_GRID:
return new AssistantGrid { Props = props, Children = children };
case AssistantComponentType.LAYOUT_PAPER:
return new AssistantPaper { Props = props, Children = children };
case AssistantComponentType.LAYOUT_STACK:
return new AssistantStack { Props = props, Children = children };
case AssistantComponentType.LAYOUT_ACCORDION:
return new AssistantAccordion { Props = props, Children = children };
case AssistantComponentType.LAYOUT_ACCORDION_SECTION:
return new AssistantAccordionSection { Props = props, Children = children };
default:
LOGGER.LogError($"Unknown assistant component type!\n{type} is not a supported assistant component type");
throw new Exception($"Unknown assistant component type: {type}");
}
}
}
}
@@ -10,9 +10,9 @@ public sealed class AssistantPluginAuditService(AssistantAuditAgent auditAgent)
/// <summary>
/// Runs an assistant plugin audit, optionally falling back to the supplied provider when no audit provider is configured.
/// </summary>
public async Task<PluginAssistantAudit> RunAuditAsync(PluginAssistants plugin, CancellationToken token = default, Settings.Provider? fallbackProvider = null)
public async Task<PluginAssistantAudit> RunAuditAsync(PluginAssistants plugin, Settings.Provider? fallbackProvider = null, CancellationToken token = default)
{
var result = await auditAgent.AuditAsync(plugin, token, fallbackProvider);
var result = await auditAgent.AuditAsync(plugin, fallbackProvider, token);
var provider = auditAgent.ProviderSettings;
var promptPreview = await plugin.BuildAuditPromptPreviewAsync(token);
@@ -0,0 +1,10 @@
namespace AIStudio.Tools.PluginSystem.Assistants;
/// <summary>
/// Everything a user may change about an installed direct chat launcher.
/// </summary>
/// <param name="PluginName">The plugin name, shown on the plugins page.</param>
/// <param name="Title">The assistant title, shown on the tile.</param>
/// <param name="Description">The description, used for both the plugin and the assistant.</param>
/// <param name="Launch">The workspace and the chat settings the tile starts its chat with.</param>
public sealed record DirectChatLauncherDefinition(string PluginName, string Title, string Description, AssistantChatLaunchConfiguration Launch);
@@ -0,0 +1,239 @@
using System.Text;
using System.Text.RegularExpressions;
using SharedTools;
namespace AIStudio.Tools.PluginSystem.Assistants;
/// <summary>
/// Writes the complete plugin.lua of a direct chat launcher from its metadata and the settings a
/// user chose.
/// </summary>
/// <remarks>
/// <para>
/// A launcher needs no LLM to be changed: it has no system prompt, no UI, and no prompt builder.
/// The plugin loader stops reading those fields as soon as a launch behavior is present, so a
/// launcher is fully described by its top-level metadata plus a flat ASSISTANT table. That makes a
/// canonical rewrite lossless in behavior, which is what this writer produces.
/// </para>
/// <para>
/// It is not lossless in text: comments, formatting, and anything the file carries beyond that
/// shape are gone afterward. Callers must therefore check both CanRewrite and IsCanonicalSource
/// before offering the mechanical editing path, and fall back to the code editor or the AI revision
/// otherwise.
/// </para>
/// </remarks>
public static class DirectChatLauncherLuaWriter
{
private const string PLUGIN_FILE_NAME = "plugin.lua";
//
// The plugin loader rejects empty authors, categories, and target groups. A plugin that is
// running should have all of them, but a defective one must not turn into a file that cannot be
// loaded back, hence these fallbacks. They mirror what the Assistant Builder generates.
//
private const string FALLBACK_AUTHOR = "MindWork AI - Assistant Builder";
private const string FALLBACK_SUPPORT_CONTACT = "mailto:info@mindwork.ai";
private const string FALLBACK_SOURCE_URL = "https://github.com/MindWorkAI/AI-Studio";
private const string FALLBACK_CATEGORY = nameof(PluginCategory.CORE);
private const string FALLBACK_TARGET_GROUP = nameof(PluginTargetGroup.EVERYONE);
//
// An inline icon or a companion file would be dropped by a canonical rewrite, and neither is
// recoverable from the loaded plugin: the icon is kept as a data URL, and companion files are
// pulled in by Lua itself.
//
private static readonly Regex NON_CANONICAL_CONTENT = new(@"\bICON_SVG\b|\brequire\s*\(", RegexOptions.CultureInvariant);
/// <summary>
/// Whether this plugin is a locally managed launcher whose settings a user may edit at all.
/// This check reads no files, so it is safe to call while rendering.
/// </summary>
public static bool CanRewrite(PluginAssistants plugin) =>
plugin is { StartsChatDirectly: true, IsInternal: false, IsManagedByConfigServer: false } &&
!string.IsNullOrWhiteSpace(plugin.PluginPath);
/// <summary>
/// Whether the current plugin.lua holds nothing a canonical rewrite would throw away.
/// </summary>
/// <param name="currentLua">The current plugin.lua content.</param>
public static bool IsCanonicalSource(string currentLua) => !string.IsNullOrWhiteSpace(currentLua) && !NON_CANONICAL_CONTENT.IsMatch(currentLua);
/// <summary>
/// Whether the plugin directory holds a single plugin.lua and no companion Lua files.
/// This one touches the file system, so keep it out of render paths.
/// </summary>
public static bool HasCompanionLuaFiles(PluginAssistants plugin) =>
plugin.ReadAllLuaFiles().Keys.Any(relativePath => !string.Equals(relativePath, PLUGIN_FILE_NAME, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Writes the complete plugin.lua for an installed launcher whose settings changed.
/// </summary>
/// <param name="plugin">The installed launcher whose metadata is carried over.</param>
/// <param name="definition">The name, title, description, and chat settings the user chose.</param>
/// <returns>The plugin.lua content, ready to be validated and written.</returns>
public static string Write(PluginAssistants plugin, DirectChatLauncherDefinition definition) =>
Write(DirectChatLauncherPluginMetadata.FromPlugin(plugin), definition);
/// <summary>
/// Writes the complete plugin.lua for a launcher.
/// </summary>
/// <remarks>
/// A launcher is fully described by its metadata plus a flat ASSISTANT table, so this is the
/// whole file rather than a starting point. The Assistant Builder uses that: for a launcher it
/// asks a model for the texts only and writes the file itself, because there is nothing left
/// for a model to decide.
/// </remarks>
/// <param name="plugin">The metadata of the launcher, either carried over or newly chosen.</param>
/// <param name="definition">The name, title, description, and chat settings the user chose.</param>
/// <returns>The plugin.lua content, ready to be validated and written.</returns>
public static string Write(DirectChatLauncherPluginMetadata plugin, DirectChatLauncherDefinition definition)
{
var builder = new StringBuilder();
builder.AppendLine("--[[");
builder.AppendLine(" This direct chat launcher is maintained by AI Studio: its settings dialog rewrites this");
builder.AppendLine(" file as a whole. Editing it by hand works, but the next change made through the dialog");
builder.AppendLine(" replaces everything below, including comments and formatting.");
builder.AppendLine("]]");
builder.AppendLine();
builder.AppendLine("-- The ID for this plugin:");
builder.AppendLine($"ID = \"{plugin.Id}\"");
builder.AppendLine();
builder.AppendLine("-- The name of the plugin:");
builder.AppendLine($"NAME = \"{Escape(definition.PluginName)}\"");
builder.AppendLine();
builder.AppendLine("-- The description of the plugin:");
builder.AppendLine($"DESCRIPTION = \"{Escape(definition.Description)}\"");
builder.AppendLine();
builder.AppendLine("-- The version of the plugin:");
builder.AppendLine($"VERSION = \"{plugin.Version}\"");
builder.AppendLine();
builder.AppendLine("-- The type of the plugin:");
builder.AppendLine($"TYPE = \"{nameof(PluginType.ASSISTANT)}\"");
builder.AppendLine();
builder.AppendLine("-- The authors of the plugin:");
builder.AppendLine($"AUTHORS = {WriteStringList(plugin.Authors, FALLBACK_AUTHOR)}");
builder.AppendLine();
builder.AppendLine("-- The support contact for the plugin:");
builder.AppendLine($"SUPPORT_CONTACT = \"{Escape(ValueOrFallback(plugin.SupportContact, FALLBACK_SUPPORT_CONTACT))}\"");
builder.AppendLine();
builder.AppendLine("-- The source URL for the plugin:");
builder.AppendLine($"SOURCE_URL = \"{Escape(ValueOrFallback(plugin.SourceURL, FALLBACK_SOURCE_URL))}\"");
builder.AppendLine();
builder.AppendLine("-- The categories for the plugin:");
builder.AppendLine($"CATEGORIES = {WriteEnumList(plugin.Categories, FALLBACK_CATEGORY)}");
builder.AppendLine();
builder.AppendLine("-- The target groups for the plugin:");
builder.AppendLine($"TARGET_GROUPS = {WriteEnumList(plugin.TargetGroups, FALLBACK_TARGET_GROUP)}");
builder.AppendLine();
builder.AppendLine("-- The flag for whether the plugin is maintained:");
builder.AppendLine($"IS_MAINTAINED = {WriteBoolean(plugin.IsMaintained)}");
builder.AppendLine();
builder.AppendLine("-- When the plugin is deprecated, this message will be shown to users:");
builder.AppendLine($"DEPRECATION_MESSAGE = \"{Escape(plugin.DeprecationMessage)}\"");
builder.AppendLine();
builder.AppendLine("-- Enterprise-managed assistants cannot be revised with AI. Keep false for locally managed plugins:");
builder.AppendLine("DEPLOYED_USING_CONFIG_SERVER = false");
builder.AppendLine();
//
// This metadata marks assistants the Builder created and must not appear on manually
// authored plugins, so it is carried over rather than always written:
//
if (plugin.IsAssistantBuilderGenerated)
{
builder.AppendLine("-- This assistant was created by the AI Studio Assistant Builder:");
builder.AppendLine("AI_STUDIO_ASSISTANT_BUILDER = {Generated = true, SchemaVersion = 1}");
builder.AppendLine();
}
builder.AppendLine("-- The tile opens a chat directly, hence it needs no system prompt, no submit text, and no UI:");
builder.AppendLine("ASSISTANT = {");
builder.AppendLine($" [\"Title\"] = \"{Escape(definition.Title)}\",");
builder.AppendLine($" [\"Description\"] = \"{Escape(definition.Description)}\",");
builder.AppendLine($" [\"LaunchBehavior\"] = \"{nameof(AssistantPluginLaunchBehavior.OPEN_WORKSPACE_CHAT_BY_NAME)}\",");
builder.AppendLine($" [\"WorkspaceName\"] = \"{Escape(definition.Launch.WorkspaceName.Trim())}\",");
//
// Omitted IDs mean "use the chat defaults", while an empty GUID explicitly selects no
// profile or no chat template. An empty provider GUID has no such meaning and is invalid:
//
if (definition.Launch.ProviderId is { } providerId && providerId != Guid.Empty)
builder.AppendLine($" [\"ProviderId\"] = \"{providerId}\",");
if (definition.Launch.ProfileId is { } profileId)
builder.AppendLine($" [\"ProfileId\"] = \"{profileId}\",");
if (definition.Launch.ChatTemplateId is { } chatTemplateId)
builder.AppendLine($" [\"ChatTemplateId\"] = \"{chatTemplateId}\",");
if (definition.Launch.DataSourceIds is { Count: > 0 } dataSourceIds)
{
builder.AppendLine(" [\"DataSourceIds\"] = {");
foreach (var dataSourceId in dataSourceIds)
builder.AppendLine($" \"{dataSourceId}\",");
builder.AppendLine(" },");
}
if (definition.Launch.ToolIds is { Count: > 0 } toolIds)
{
builder.AppendLine(" [\"ToolIds\"] = {");
foreach (var toolId in toolIds)
builder.AppendLine($" {LuaTools.ToLuaStringLiteral(toolId)},");
builder.AppendLine(" },");
}
builder.Append('}');
return builder.ToString();
}
private static string WriteStringList(IReadOnlyList<string> values, string fallback)
{
var usableValues = values.Where(value => !string.IsNullOrWhiteSpace(value)).Select(value => value.Trim()).ToArray();
if (usableValues.Length == 0)
usableValues = [fallback];
return $"{{{string.Join(", ", usableValues.Select(value => $"\"{Escape(value)}\""))}}}";
}
private static string WriteEnumList<T>(IReadOnlyList<T> values, string fallback) where T : struct, Enum
{
var names = values.Select(value => Enum.GetName(value) ?? string.Empty).Where(name => !string.IsNullOrWhiteSpace(name)).ToArray();
if (names.Length == 0)
names = [fallback];
return $"{{{string.Join(", ", names.Select(name => $"\"{name}\""))}}}";
}
private static string WriteBoolean(bool value) => value ? "true" : "false";
private static string ValueOrFallback(string value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
//
// Titles, descriptions, and workspace names are free text. Lua has no raw newlines inside
// quoted strings, so everything that would break out of one is escaped. The backslash must come
// first, otherwise the escapes added afterwards would be escaped again:
//
private static string Escape(string value) => value
.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("\"", "\\\"", StringComparison.Ordinal)
.Replace("\r", "\\r", StringComparison.Ordinal)
.Replace("\n", "\\n", StringComparison.Ordinal)
.Replace("\t", "\\t", StringComparison.Ordinal);
}
@@ -0,0 +1,29 @@
namespace AIStudio.Tools.PluginSystem.Assistants;
/// <summary>
/// The plugin metadata a direct chat launcher carries beyond its chat settings.
/// </summary>
/// <remarks>
/// An installed launcher keeps these in its plugin.lua, and editing one carries them over. A
/// launcher the Assistant Builder is about to create has no file yet, so its metadata comes from
/// the Builder's defaults instead. Both paths end in the same writer, which is where the two meet.
/// </remarks>
/// <param name="Id">The plugin ID, which stays with the plugin for its whole life.</param>
/// <param name="Version">The plugin version, as it appears in the Lua file.</param>
/// <param name="Authors">The authors of the plugin.</param>
/// <param name="SupportContact">Where users turn with questions about this plugin.</param>
/// <param name="SourceURL">Where the plugin comes from.</param>
/// <param name="Categories">The categories this plugin belongs to.</param>
/// <param name="TargetGroups">The target groups this plugin is meant for.</param>
/// <param name="IsMaintained">Whether the plugin is still maintained.</param>
/// <param name="DeprecationMessage">What users are told when the plugin is deprecated.</param>
/// <param name="IsAssistantBuilderGenerated">Whether the Assistant Builder created this plugin.</param>
public sealed record DirectChatLauncherPluginMetadata(Guid Id, string Version, IReadOnlyList<string> Authors, string SupportContact, string SourceURL,
IReadOnlyList<PluginCategory> Categories, IReadOnlyList<PluginTargetGroup> TargetGroups, bool IsMaintained, string DeprecationMessage, bool IsAssistantBuilderGenerated)
{
/// <summary>
/// Takes the metadata of an installed launcher for the case where one is edited.
/// </summary>
public static DirectChatLauncherPluginMetadata FromPlugin(PluginAssistants plugin) => new(plugin.Id, plugin.Version.ToString(), plugin.Authors,
plugin.SupportContact, plugin.SourceURL, plugin.Categories, plugin.TargetGroups, plugin.IsMaintained, plugin.DeprecationMessage, plugin.IsAssistantBuilderGenerated);
}
@@ -19,6 +19,22 @@ public sealed class PluginAssistantSecurityState
public string CurrentHash { get; init; } = string.Empty;
public bool HasAudit => this.Audit is not null;
public bool IsEnterpriseApproved => this.Source is PluginAssistantSecurityStatusSource.ENTERPRISE_APPROVAL;
/// <summary>
/// Whether your organization requires this assistant plugin to stay enabled.
/// </summary>
/// <remarks>
/// This asks the plugin factory instead of reading the approval, because an approval alone does
/// not activate anything: it is matched by hash, so it also covers a copy of the plugin your
/// organization never rolled out. The factory is the one place which knows both.
/// </remarks>
public bool IsActivationEnforcedByOrganization => PluginFactory.IsAssistantActivationEnforced(this.Plugin.Id);
/// <summary>
/// Whether your organization enabled this assistant plugin for you, leaving you free to switch it
/// off again.
/// </summary>
public bool IsActivatedByOrganizationDefault => PluginFactory.IsAssistantActivationOrganizationDefault(this.Plugin.Id);
public bool HashMatches { get; init; }
public bool HasHashMismatch { get; init; }
public bool IsBelowMinimum { get; init; }
@@ -34,14 +34,26 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType
public string SystemPrompt { get; private set; } = string.Empty;
public string SubmitText { get; private set; } = string.Empty;
public bool AllowProfiles { get; private set; } = true;
/// <summary>
/// The tools this assistant runs with, when its plugin names any.
/// </summary>
/// <remarks>
/// Null means the plugin says nothing about tools, and the user picks them as in any other
/// assistant. A list takes that choice away: the assistant then runs with exactly these tools,
/// which is what an author who tested their assistant with them wants. It is a wish, not a
/// permission — a tool switched off in the settings, or one the selected provider is not
/// trusted enough to receive, stays out of reach either way.
/// </remarks>
public IReadOnlyList<string>? AssistantToolIds { get; private set; }
public bool HasEmbeddedProfileSelection { get; private set; }
public bool HasCustomPromptBuilder => this.buildPromptFunction is not null;
public bool IsAssistantBuilderGenerated { get; private set; }
public bool HasDeploymentManagementMetadata { get; private set; }
public bool IsManagedByConfigServer { get; private set; }
public AssistantPluginLaunchBehavior LaunchBehavior { get; private set; }
public string LaunchWorkspaceName { get; private set; } = string.Empty;
public bool StartsChatDirectly => this.LaunchBehavior is AssistantPluginLaunchBehavior.OPEN_WORKSPACE_CHAT_BY_NAME;
public AssistantChatLaunchConfiguration? ChatLaunchConfiguration { get; private set; }
public bool StartsChatDirectly => this.ChatLaunchConfiguration is not null;
public const int TEXT_AREA_MAX_VALUE = 524288;
private LuaFunction? buildPromptFunction;
@@ -65,13 +77,21 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType
private bool TryProcessAssistant(out string message)
{
message = string.Empty;
this.RootComponent = null;
this.AssistantTitle = string.Empty;
this.AssistantDescription = string.Empty;
this.RawSystemPrompt = string.Empty;
this.SystemPrompt = string.Empty;
this.SubmitText = string.Empty;
this.AllowProfiles = true;
this.AssistantToolIds = null;
this.HasEmbeddedProfileSelection = false;
this.IsAssistantBuilderGenerated = false;
this.HasDeploymentManagementMetadata = false;
this.IsManagedByConfigServer = false;
this.buildPromptFunction = null;
this.LaunchBehavior = AssistantPluginLaunchBehavior.NONE;
this.LaunchWorkspaceName = string.Empty;
this.ChatLaunchConfiguration = null;
this.RegisterLuaHelpers();
this.TryReadAssistantBuilderMetadata();
@@ -97,6 +117,18 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType
message = TB("The provided ASSISTANT lua table does not contain a valid description.");
return false;
}
this.AssistantTitle = assistantTitle;
this.AssistantDescription = assistantDescription;
if (!this.TryReadLaunchConfiguration(assistantTable, out var launchConfigIssue))
{
message = launchConfigIssue;
return false;
}
if (this.StartsChatDirectly)
return true;
if (!assistantTable.TryGetValue("SystemPrompt", out var assistantSystemPromptValue) ||
!assistantSystemPromptValue.TryRead<string>(out var assistantSystemPrompt))
@@ -119,6 +151,9 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType
return false;
}
if (!TryReadOptionalToolIds(assistantTable, out var assistantToolIds, out message))
return false;
if (assistantTable.TryGetValue("BuildPrompt", out var buildPromptValue))
{
if (buildPromptValue.TryRead<LuaFunction>(out var buildPrompt))
@@ -129,18 +164,11 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType
var rawSystemPrompt = assistantSystemPrompt.Trim();
this.AssistantTitle = assistantTitle;
this.AssistantDescription = assistantDescription;
this.RawSystemPrompt = rawSystemPrompt;
this.SystemPrompt = BuildSecureSystemPrompt(rawSystemPrompt);
this.SubmitText = assistantSubmitText;
this.AllowProfiles = assistantAllowProfiles;
if (!this.TryReadLaunchConfiguration(assistantTable, out var launchConfigIssue))
{
message = launchConfigIssue;
return false;
}
this.AssistantToolIds = assistantToolIds;
// Ensure that the UI table exists nested in the ASSISTANT table and is a valid Lua table:
if (!assistantTable.TryGetValue("UI", out var uiVal) || !uiVal.TryRead<LuaTable>(out var uiTable))
@@ -212,7 +240,14 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType
return false;
}
this.LaunchWorkspaceName = workspaceName;
if (!TryReadOptionalGuid(assistantTable, "ProviderId", false, out var providerId, out message) ||
!TryReadOptionalGuid(assistantTable, "ProfileId", true, out var profileId, out message) ||
!TryReadOptionalGuid(assistantTable, "ChatTemplateId", true, out var chatTemplateId, out message) ||
!TryReadOptionalDataSourceIds(assistantTable, out var dataSourceIds, out message) ||
!TryReadOptionalToolIds(assistantTable, out var toolIds, out message))
return false;
this.ChatLaunchConfiguration = new(workspaceName, providerId, profileId, chatTemplateId, dataSourceIds, toolIds);
return true;
@@ -222,6 +257,100 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType
}
}
private static bool TryReadOptionalGuid(LuaTable assistantTable, string fieldName, bool allowEmpty, out Guid? id, out string message)
{
id = null;
message = string.Empty;
if (!assistantTable.TryGetValue(fieldName, out var idValue))
return true;
if (!idValue.TryRead<string>(out var idText) || !Guid.TryParse(idText, out var parsedId) || (!allowEmpty && parsedId == Guid.Empty))
{
message = string.Format(TB("The ASSISTANT table contains an invalid {0}. Expected a {1}GUID."), fieldName, allowEmpty ? string.Empty : "non-empty ");
return false;
}
id = parsedId;
return true;
}
private static bool TryReadOptionalDataSourceIds(LuaTable assistantTable, out IReadOnlyList<Guid>? dataSourceIds, out string message)
{
dataSourceIds = null;
message = string.Empty;
if (!assistantTable.TryGetValue("DataSourceIds", out var dataSourceIdsValue))
return true;
if (!dataSourceIdsValue.TryRead<LuaTable>(out var dataSourceIdsTable) || dataSourceIdsTable.ArrayLength == 0)
{
message = TB("The ASSISTANT table contains invalid DataSourceIds. Expected a non-empty list of unique, non-empty GUIDs.");
return false;
}
var parsedIds = new List<Guid>(dataSourceIdsTable.ArrayLength);
var uniqueIds = new HashSet<Guid>();
for (var index = 1; index <= dataSourceIdsTable.ArrayLength; index++)
{
if (!dataSourceIdsTable[index].TryRead<string>(out var idText) ||
!Guid.TryParse(idText, out var parsedId) ||
parsedId == Guid.Empty ||
!uniqueIds.Add(parsedId))
{
message = TB("The ASSISTANT table contains invalid DataSourceIds. Expected a non-empty list of unique, non-empty GUIDs.");
return false;
}
parsedIds.Add(parsedId);
}
dataSourceIds = parsedIds.ToImmutableArray();
return true;
}
/// <summary>
/// Reads the tools an assistant names: the ones a launcher preselects for its chat, or the ones
/// the assistant itself runs with.
/// </summary>
/// <remarks>
/// Unlike the data sources, these are plain tool IDs rather than GUIDs, and an ID unknown to
/// this installation is not an error: a plugin may name a tool that arrives with another plugin
/// which is not installed yet. Whoever runs the tools drops what they cannot offer.
/// </remarks>
private static bool TryReadOptionalToolIds(LuaTable assistantTable, out IReadOnlyList<string>? toolIds, out string message)
{
toolIds = null;
message = string.Empty;
if (!assistantTable.TryGetValue("ToolIds", out var toolIdsValue))
return true;
if (!toolIdsValue.TryRead<LuaTable>(out var toolIdsTable) || toolIdsTable.ArrayLength == 0)
{
message = TB("The ASSISTANT table contains invalid ToolIds. Expected a non-empty list of unique, non-empty tool IDs.");
return false;
}
var parsedIds = new List<string>(toolIdsTable.ArrayLength);
var uniqueIds = new HashSet<string>(StringComparer.Ordinal);
for (var index = 1; index <= toolIdsTable.ArrayLength; index++)
{
if (!toolIdsTable[index].TryRead<string>(out var toolId) ||
string.IsNullOrWhiteSpace(toolId) ||
!uniqueIds.Add(toolId.Trim()))
{
message = TB("The ASSISTANT table contains invalid ToolIds. Expected a non-empty list of unique, non-empty tool IDs.");
return false;
}
parsedIds.Add(toolId.Trim());
}
toolIds = parsedIds.ToImmutableArray();
return true;
}
public async Task<string?> TryBuildPromptAsync(LuaTable input, CancellationToken cancellationToken = default)
{
if (this.buildPromptFunction is null)
@@ -301,12 +430,40 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType
return fileMap.ToImmutable();
}
/// <summary>
/// The audit hash of this plugin, together with the directory it was computed for.
/// </summary>
/// <remarks>
/// One record instead of two fields, so that a reader always sees a directory and a hash which
/// belong together. Recomputing the same hash twice costs nothing but time, mixing up a hash
/// with the wrong directory would show a wrong security state.
/// </remarks>
private sealed record AuditHashCache(string PluginPath, string Hash);
private AuditHashCache? auditHashCache;
/// <summary>
/// Computes a stable audit hash across all Lua files by hashing a canonical
/// sequence of relative path length, relative path, content length, and content
/// for each file in ordinal path order.
/// </summary>
public string ComputeAuditHash() => AssistantPluginHash.Compute(this.PluginPath);
/// <remarks>
/// The result is kept, because computing it reads every Lua file of the plugin, and the plugins
/// page as well as the assistants page ask for it on every render. That is safe: the files of
/// one plugin instance never change. Whenever something in the plugins directory changes, the
/// plugin factory reloads and creates new instances, cf. PluginFactory.Starting.RestartAllPlugins.
/// The plugin directory is assigned after the instance was created, so the cache remembers which
/// directory it belongs to.
/// </remarks>
public string ComputeAuditHash()
{
if (this.auditHashCache is { } cache && string.Equals(cache.PluginPath, this.PluginPath, StringComparison.Ordinal))
return cache.Hash;
var hash = AssistantPluginHash.Compute(this.PluginPath);
this.auditHashCache = new(this.PluginPath, hash);
return hash;
}
private static string BuildSecureSystemPrompt(string pluginSystemPrompt)
{
@@ -5,6 +5,17 @@ public interface IAvailablePlugin : IPluginMetadata
public string LocalPath { get; }
public bool IsManagedByConfigServer { get; }
public Guid? ManagedConfigurationId { get; }
/// <summary>
/// The priority of a configuration plugin. Zero for every other plugin type.
/// </summary>
/// <remarks>
/// Configuration plugins with a higher priority start later and therefore win when two of them
/// manage the same setting or define the same configuration object. The priority only orders
/// plugins of the same origin: a local configuration plugin never starts before one which an
/// organization deployed, no matter which priority it declares.
/// </remarks>
public int ConfigurationPriority { get; }
}
@@ -3,9 +3,14 @@ namespace AIStudio.Tools.PluginSystem;
public interface IPluginMetadata
{
/// <summary>
/// The icon of this plugin.
/// The icon of this plugin, as a data URL ready for the src attribute of an image element.
/// </summary>
public string IconSVG { get; }
/// <remarks>
/// Deliberately a data URL and not the raw markup: the icon comes from the plugin, so it must
/// never be rendered inline into the DOM. Inside an image element the browser treats it as a
/// standalone document which runs no script and loads nothing from the network.
/// </remarks>
public string IconDataUrl { get; }
/// <summary>
/// The type of this plugin.
@@ -0,0 +1,15 @@
namespace AIStudio.Tools.PluginSystem;
/// <summary>
/// Represents a configuration object whose API key is managed by the user, although the object
/// itself is managed by a configuration plugin. Implemented by all provider kinds which support
/// the "AllowUserProvidedAPIKey" option, i.e., LLM, embedding, and transcription providers.
/// </summary>
public interface IUserProvidedAPIKey
{
/// <summary>
/// When set by a configuration plugin, the user may set their own API key for this otherwise
/// locked, enterprise-managed object.
/// </summary>
public bool AllowUserProvidedAPIKey { get; }
}
@@ -0,0 +1,80 @@
using System.IO.Compression;
namespace AIStudio.Tools.PluginSystem;
public static class PluginArchive
{
/// <summary>
/// The file extension of plugin archives.
/// </summary>
/// <remarks>
/// Keep in sync with SHARE_FILE_EXTENSION in runtime/src/share_sheet.rs: the runtime only hands
/// archives with this extension to the native share sheet.
/// </remarks>
public const string PLUGIN_FILE_EXTENSION = ".mwplugin";
// Compatibility shim for Windows-created ZIPs with backslashes in entry names (dotnet/runtime#27620);
// remove after dotnet/runtime#27620 and #41914 are fixed.
// See documentation/compatibility-shims/2026-07-plugin-archive-zip-backslashes.md.
public static void Extract(string sourceArchiveFileName, string destinationDirectory)
{
using var archive = ZipFile.OpenRead(sourceArchiveFileName);
Directory.CreateDirectory(destinationDirectory);
var destinationDirectoryFullPath = Path.GetFullPath(destinationDirectory);
if (!destinationDirectoryFullPath.EndsWith(Path.DirectorySeparatorChar))
destinationDirectoryFullPath += Path.DirectorySeparatorChar;
foreach (var entry in archive.Entries)
{
var normalizedEntryName = NormalizeEntryName(entry.FullName);
var destinationPath = GetEntryDestinationPath(destinationDirectoryFullPath, normalizedEntryName);
if (normalizedEntryName.EndsWith('/'))
{
if (entry.Length != 0)
throw new InvalidDataException($"The plugin archive contains a directory entry with data: '{entry.FullName}'.");
Directory.CreateDirectory(destinationPath);
continue;
}
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
entry.ExtractToFile(destinationPath);
}
}
private static string NormalizeEntryName(string entryName)
{
var normalizedEntryName = entryName.Replace('\\', '/');
if (string.IsNullOrWhiteSpace(normalizedEntryName))
throw new InvalidDataException("The plugin archive contains an empty entry name.");
if (normalizedEntryName.Contains('\0'))
throw new InvalidDataException($"The plugin archive contains an invalid entry name: '{entryName}'.");
if (normalizedEntryName.StartsWith('/'))
throw new InvalidDataException($"The plugin archive contains a rooted entry name: '{entryName}'.");
if (normalizedEntryName is [_, ':', ..])
throw new InvalidDataException($"The plugin archive contains a drive-qualified entry name: '{entryName}'.");
var pathSegments = normalizedEntryName.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (pathSegments.Length == 0 || pathSegments.Any(segment => segment is "." or ".."))
throw new InvalidDataException($"The plugin archive contains an unsafe entry name: '{entryName}'.");
return normalizedEntryName;
}
private static string GetEntryDestinationPath(string destinationDirectoryFullPath, string normalizedEntryName)
{
var pathSegments = normalizedEntryName.Split('/', StringSplitOptions.RemoveEmptyEntries);
var relativePath = Path.Combine(pathSegments);
var destinationPath = Path.GetFullPath(Path.Combine(destinationDirectoryFullPath, relativePath));
if (!destinationPath.StartsWith(destinationDirectoryFullPath, StringComparison.Ordinal))
throw new InvalidDataException($"The plugin archive contains an entry outside the destination directory: '{normalizedEntryName}'.");
return destinationPath;
}
}
@@ -7,39 +7,62 @@ public abstract partial class PluginBase
<svg height="1.5em" width="1.5em" viewBox="0 0 24 24" fill="#1f1f1f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 13h-2V7h-6V5c0-.28-.22-.5-.5-.5s-.5.22-.5.5v2H4l.01 2.12C5.76 9.8 7 11.51 7 13.5c0 1.99-1.25 3.7-3 4.38V20h2.12c.68-1.75 2.39-3 4.38-3 1.99 0 3.7 1.25 4.38 3H17v-6h2c.28 0 .5-.22.5-.5s-.22-.5-.5-.5z" opacity=".3"/><path d="M19 11V7c0-1.1-.9-2-2-2h-4c0-1.38-1.12-2.5-2.5-2.5S8 3.62 8 5H4c-1.1 0-1.99.9-1.99 2v3.8h.29c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-.3c0-1.49 1.21-2.7 2.7-2.7s2.7 1.21 2.7 2.7v.3H17c1.1 0 2-.9 2-2v-4c1.38 0 2.5-1.12 2.5-2.5S20.38 11 19 11zm0 3h-2v6h-2.12c-.68-1.75-2.39-3-4.38-3-1.99 0-3.7 1.25-4.38 3H4v-2.12c1.75-.68 3-2.39 3-4.38 0-1.99-1.24-3.7-2.99-4.38L4 7h6V5c0-.28.22-.5.5-.5s.5.22.5.5v2h6v6h2c.28 0 .5.22.5.5s-.22.5-.5.5z"/></svg>
""";
private static readonly string DEFAULT_ICON_DATA_URL = CreateDefaultIconDataUrl();
#region Initialization-related methods
/// <summary>
/// Tries to initialize the icon of the plugin.
/// </summary>
/// <remarks>
/// When no icon is specified, the default icon will be used.
/// <para>
/// When no icon is specified, or when the specified icon is unusable, the default icon will be
/// used. A plugin never fails to load over its icon.
/// </para>
/// <para>
/// The icon is handed out as a data URL, not as markup: plugins are shown through an image
/// element so the browser treats their icon as a standalone, script-less document. Rendering
/// plugin-supplied markup inline would hand every plugin author a way to run code in the app.
/// </para>
/// </remarks>
/// <param name="message">The error message, when the icon could not be read.</param>
/// <param name="iconSVG">The read icon as SVG.</param>
/// <param name="iconDataUrl">The read icon as a data URL.</param>
/// <returns>True, when the icon could be read successfully.</returns>
// ReSharper disable once OutParameterValueIsAlwaysDiscarded.Local
// ReSharper disable once UnusedMethodReturnValue.Local
private bool TryInitIconSVG(out string message, out string iconSVG)
private bool TryInitIconDataUrl(out string message, out string iconDataUrl)
{
if (!this.State.Environment["ICON_SVG"].TryRead(out iconSVG))
if (!this.State.Environment["ICON_SVG"].TryRead<string>(out var iconSVG))
{
iconSVG = DEFAULT_ICON_SVG;
iconDataUrl = DEFAULT_ICON_DATA_URL;
message = "The field ICON_SVG does not exist or is not a valid string.";
return true;
}
if (string.IsNullOrWhiteSpace(iconSVG))
{
iconSVG = DEFAULT_ICON_SVG;
iconDataUrl = DEFAULT_ICON_DATA_URL;
message = "The field ICON_SVG is empty. The icon must be a non-empty string.";
return true;
}
if (!SvgIcon.TryCreateDataUrl(iconSVG, out iconDataUrl, out var issue))
{
iconDataUrl = DEFAULT_ICON_DATA_URL;
message = $"The field ICON_SVG is not a usable icon: {issue}";
return true;
}
message = string.Empty;
return true;
}
private static string CreateDefaultIconDataUrl()
{
SvgIcon.TryCreateDataUrl(DEFAULT_ICON_SVG, out var dataUrl, out _);
return dataUrl;
}
#endregion
}
@@ -6,7 +6,7 @@ namespace AIStudio.Tools.PluginSystem;
/// <summary>
/// Represents the base of any AI Studio plugin.
/// </summary>
public abstract partial class PluginBase : IPluginMetadata
public abstract partial class PluginBase : IPluginMetadata, IDisposable
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginBase).Namespace, nameof(PluginBase));
@@ -16,8 +16,8 @@ public abstract partial class PluginBase : IPluginMetadata
protected readonly List<string> PluginIssues = [];
/// <inheritdoc />
public string IconSVG { get; }
public string IconDataUrl { get; }
/// <inheritdoc />
public PluginType Type { get; }
@@ -88,14 +88,14 @@ public abstract partial class PluginBase : IPluginMetadata
if (this is NoPlugin or NoPluginLanguage)
{
this.IsInternal = isInternal;
this.IconSVG = string.Empty;
this.IconDataUrl = string.Empty;
this.baseIssues = issues;
return;
}
// Notice: when no icon is specified, the default icon will be used.
this.TryInitIconSVG(out _, out var iconSVG);
this.IconSVG = iconSVG;
this.TryInitIconDataUrl(out _, out var iconDataUrl);
this.IconDataUrl = iconDataUrl;
if(this.TryInitId(out var issue, out var id))
{
@@ -546,4 +546,18 @@ public abstract partial class PluginBase : IPluginMetadata
}
#endregion
#region Implementation of IDisposable
/// <summary>
/// Releases the Lua runtime of this plugin.
/// </summary>
/// <remarks>
/// Every plugin owns a Lua state, which is an entire scripting runtime. Dropping a plugin
/// without disposing it leaves that runtime behind: before this existed, each hot reload added
/// another set of them for as long as the app was running.
/// </remarks>
public void Dispose() => this.State.Dispose();
#endregion
}
@@ -1,4 +1,6 @@
using System.Globalization;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Services;
@@ -38,6 +40,28 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
/// True/false when explicitly configured in the plugin, otherwise null.
/// </summary>
public bool? DeployedUsingConfigServer { get; } = ReadDeployedUsingConfigServer(state);
/// <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);
/// <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)
{
@@ -131,6 +155,34 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
return null;
}
private static int ReadPriority(LuaState state)
{
if (state.Environment["PRIORITY"].TryRead<int>(out var priority))
return priority;
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>
@@ -156,6 +208,11 @@ 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;
}
if (!TryValidateMinimumProviderConfidenceConfiguration(settingsTable, out message))
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);
@@ -166,6 +223,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Config: what should be the start page?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.StartPage, this.Id, settingsTable, dryRun);
// Config: show prompt-injection alert dialogs?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShowPromptInjectionAlert, this.Id, settingsTable, dryRun);
// Config: show built-in introduction on the home page?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShowIntroduction, this.Id, settingsTable, dryRun);
@@ -181,6 +241,15 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Config: allow the user to add providers?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToAddProvider, this.Id, settingsTable, dryRun);
// 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);
// Config: show administration settings?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShowAdminSettings, this.Id, settingsTable, dryRun);
@@ -196,6 +265,21 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Config: global voice recording shortcut
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShortcutVoiceRecording, this.Id, settingsTable, dryRun);
// 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);
// Config: timeout for external HTTP requests
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.HttpClientTimeoutSeconds, this.Id, settingsTable, dryRun);
@@ -235,13 +319,13 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
this.TryProcessEnterpriseApprovedAssistantPlugins(settingsTable, dryRun);
// Handle configured LLM providers:
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, x => x.NextProviderNum, mainTable, this.Id, ref this.configObjects, dryRun);
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, x => x.NextProviderNum, mainTable, this.Id, ref this.configObjects, dryRun, this.PluginPath);
// Handle configured transcription providers:
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, x => x.NextTranscriptionNum, mainTable, this.Id, ref this.configObjects, dryRun);
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, x => x.NextTranscriptionNum, mainTable, this.Id, ref this.configObjects, dryRun, this.PluginPath);
// Handle configured embedding providers:
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, x => x.NextEmbeddingNum, mainTable, this.Id, ref this.configObjects, dryRun);
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, x => x.NextEmbeddingNum, mainTable, this.Id, ref this.configObjects, dryRun, this.PluginPath);
// Handle configured chat templates:
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, x => x.NextChatTemplateNum, mainTable, this.Id, ref this.configObjects, dryRun, this.PluginPath);
@@ -278,6 +362,30 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.PreselectedDataSourceIds, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.SendToChatDataSourceBehavior, this.Id, settingsTable, dryRun);
// 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);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.ResultFileFormat, this.Id, settingsTable, dryRun);
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);
// Config: transcription provider?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.UseTranscriptionProvider, Guid.Empty, this.Id, settingsTable, dryRun);
@@ -285,6 +393,37 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
return true;
}
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;
}
private void TryProcessEnterpriseApprovedAssistantPlugins(LuaTable settingsTable, bool dryRun)
{
if (!ManagedConfiguration.TryGet(x => x.AssistantPluginAudit, x => x.EnterpriseApprovedPlugins, out ConfigMeta<DataAssistantPluginAudit, IList<DataAssistantPluginEnterpriseApproval>> configMeta))
@@ -325,26 +464,199 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
approvals.Add(approval);
}
configuredApprovals = approvals;
// 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);
successful = true;
}
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:
configMeta.SetValue(configuredApprovals);
//
// 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:
configMeta.SetValue(CombineApprovals(configMeta.GetValue().Concat(configuredApprovals)));
configMeta.LockConfiguration(this.Id);
break;
case false when configMeta.IsLocked && configMeta.LockedByConfigPluginId == this.Id:
configMeta.RemovePluginContribution(this.Id);
configMeta.ResetLockedConfiguration();
break;
case false:
configMeta.RemovePluginContribution(this.Id);
break;
}
}
/// <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;
var effectiveApprovals = CombineApprovals(configMeta.PluginContributions.Values.SelectMany(contribution => contribution));
// 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:
var currentApprovals = configMeta.GetValue();
if (HaveApprovalsSameEffect(currentApprovals, effectiveApprovals))
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;
}
/// <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;
}
private static bool TryParseEnterpriseApprovedAssistantPlugin(int index, LuaTable table, Guid configPluginId, out DataAssistantPluginEnterpriseApproval approval)
{
approval = new();
@@ -366,6 +678,11 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
var comment = TryReadOptionalString(table, "Comment");
var approvedBy = TryReadOptionalString(table, "ApprovedBy");
var approvedAtUtc = TryReadOptionalDateTimeOffset(table, "ApprovedAtUtc", index, configPluginId);
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);
approval = new()
{
@@ -374,6 +691,8 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
Comment = comment,
ApprovedBy = approvedBy,
ApprovedAtUtc = approvedAtUtc,
Activate = activate,
AllowUserOverride = allowUserOverride,
};
return true;
}
@@ -385,6 +704,18 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
: string.Empty;
}
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;
}
private static DateTimeOffset? TryReadOptionalDateTimeOffset(LuaTable table, string key, int index, Guid configPluginId)
{
if (!table.TryGetValue(key, out var value))
@@ -35,6 +35,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.
@@ -108,11 +143,11 @@ public sealed record PluginConfigurationObject
var (wasParsingSuccessful, configObject) = configObjectType switch
{
PluginConfigurationObjectType.LLM_PROVIDER => (Settings.Provider.TryParseProviderTable(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject != Settings.Provider.NONE, configurationObject),
PluginConfigurationObjectType.LLM_PROVIDER => (Settings.Provider.TryParseProviderTable(i, luaObjectTable, configPluginId, pluginPath, out var configurationObject) && configurationObject != Settings.Provider.NONE, configurationObject),
PluginConfigurationObjectType.CHAT_TEMPLATE => (ChatTemplate.TryParseChatTemplateTable(i, luaObjectTable, configPluginId, pluginPath, out var configurationObject) && configurationObject != ChatTemplate.NO_CHAT_TEMPLATE, configurationObject),
PluginConfigurationObjectType.PROFILE => (Profile.TryParseProfileTable(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject != Profile.NO_PROFILE, configurationObject),
PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER => (TranscriptionProvider.TryParseTranscriptionProviderTable(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject != TranscriptionProvider.NONE, configurationObject),
PluginConfigurationObjectType.EMBEDDING_PROVIDER => (EmbeddingProvider.TryParseEmbeddingProviderTable(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject != EmbeddingProvider.NONE, configurationObject),
PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER => (TranscriptionProvider.TryParseTranscriptionProviderTable(i, luaObjectTable, configPluginId, pluginPath, out var configurationObject) && configurationObject != TranscriptionProvider.NONE, configurationObject),
PluginConfigurationObjectType.EMBEDDING_PROVIDER => (EmbeddingProvider.TryParseEmbeddingProviderTable(i, luaObjectTable, configPluginId, pluginPath, out var configurationObject) && configurationObject != EmbeddingProvider.NONE, configurationObject),
PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY => (DataDocumentAnalysisPolicy.TryProcessConfiguration(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject is DataDocumentAnalysisPolicy, configurationObject),
_ => (false, NoConfigurationObject.INSTANCE)
@@ -126,17 +161,22 @@ public sealed record PluginConfigurationObject
ConfigPluginId = configPluginId,
Id = Guid.Parse(configObject.Id),
Type = configObjectType,
Name = configObject.Name,
Endpoint = DescribeEndpoint(configObject),
});
if (dryRun)
continue;
var objectIndex = storedObjects.FindIndex(t => t.Id == configObject.Id);
// Case: The object already exists, we update it:
if (objectIndex > -1)
{
var existingObject = storedObjects[objectIndex];
if (!MayReplaceConfigurationObject(existingObject, configPluginId))
continue;
configObject = configObject with { Num = existingObject.Num };
storedObjects[objectIndex] = (TClass)configObject;
}
@@ -249,6 +289,8 @@ public sealed record PluginConfigurationObject
ConfigPluginId = configPluginId,
Id = Guid.Parse(configObject.Id),
Type = PluginConfigurationObjectType.DATA_SOURCE,
Name = configObject.Name,
Endpoint = DescribeEndpoint(configObject),
});
if (dryRun)
@@ -258,6 +300,9 @@ public sealed record PluginConfigurationObject
if (objectIndex > -1)
{
var existingObject = storedObjects[objectIndex];
if (!MayReplaceConfigurationObject(existingObject, configPluginId))
continue;
configObject = configObject with { Num = existingObject.Num };
storedObjects[objectIndex] = configObject;
}
@@ -286,6 +331,35 @@ public sealed record PluginConfigurationObject
}
}
/// <summary>
/// Checks whether a configuration plugin may replace a stored configuration object, or whether
/// that object belongs to the IT department of an organization.
/// </summary>
/// <remarks>
/// Configuration objects are matched by their ID alone. Without this check, a local configuration
/// plugin could claim the ID of an object an organization deployed and replace it, e.g. to point
/// a self-hosted LLM provider at a different host.<br/><br/>
/// Between two configuration plugins of the same organization, we do not interfere: both belong
/// to the IT department, so the one processed later wins, as before.
/// </remarks>
/// <param name="existingObject">The configuration object which is stored already.</param>
/// <param name="configPluginId">The configuration plugin which wants to replace that object.</param>
/// <returns>True when the plugin may replace the object, otherwise false.</returns>
private static bool MayReplaceConfigurationObject(IConfigurationObject existingObject, Guid configPluginId)
{
if (!existingObject.IsEnterpriseConfiguration || existingObject.EnterpriseConfigurationPluginId == configPluginId)
return true;
if (!PluginFactory.IsOrganizationConfigurationPlugin(existingObject.EnterpriseConfigurationPluginId))
return true;
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);
return false;
}
/// <summary>
/// Cleans up configuration objects of a specified type that are no longer associated with any available plugin.
/// </summary>
@@ -293,6 +367,11 @@ public sealed record PluginConfigurationObject
/// <param name="configObjectType">The type of configuration object to process.</param>
/// <param name="configObjectSelection">A selection expression to retrieve the configuration objects from the main configuration.</param>
/// <param name="availablePlugins">A list of currently available plugins.</param>
/// <param name="deployedEnterpriseConfigPluginIds">
/// The IDs of the configuration plugins which an organization deployed on this machine, including
/// those which could not be loaded. Objects of a deployed plugin are never removed, because the
/// plugin was not removed either.
/// </param>
/// <param name="configObjectList">A list of all existing configuration objects.</param>
/// <param name="secretStoreType">An optional parameter specifying the type of secret store to use for deleting associated API keys from the OS keyring, if applicable.</param>
/// <param name="deleteSecret">When true, delete the associated non-API-key secret from the OS keyring.</param>
@@ -301,6 +380,7 @@ public sealed record PluginConfigurationObject
PluginConfigurationObjectType configObjectType,
Expression<Func<Data, List<TClass>>> configObjectSelection,
IList<IAvailablePlugin> availablePlugins,
IReadOnlySet<Guid> deployedEnterpriseConfigPluginIds,
IList<PluginConfigurationObject> configObjectList,
SecretStoreType? secretStoreType = null,
bool deleteSecret = false) where TClass : IConfigurationObject
@@ -319,7 +399,17 @@ public sealed record PluginConfigurationObject
var configObjectSourcePluginId = configuredObject.EnterpriseConfigurationPluginId;
if(configObjectSourcePluginId == Guid.Empty)
continue;
//
// Is the source plugin deployed, but could not be loaded? Then we must not touch any of
// its objects. The plugin was not removed, it is broken: it might be invalid Lua code,
// a missing `plugin.lua`, or an incomplete download. Removing the objects would delete
// the organization's providers and data sources, including their secrets, although the
// organization still manages this AI Studio instance:
//
if(deployedEnterpriseConfigPluginIds.Contains(configObjectSourcePluginId) && availablePlugins.All(plugin => plugin.Id != configObjectSourcePluginId))
continue;
// Is the source plugin still available? If not, we can be pretty sure that this configuration object is left
// over and should be removed:
var templateSourcePlugin = availablePlugins.FirstOrDefault(plugin => plugin.Id == configObjectSourcePluginId);
@@ -368,6 +458,13 @@ public sealed record PluginConfigurationObject
else
LOG.LogWarning($"Failed to delete secret for removed enterprise object '{item.Name}' from the OS keyring: {deleteResult.Issue}");
}
else if(item is IUserProvidedAPIKey { AllowUserProvidedAPIKey: true })
{
// The user manages their own key for this provider. Keep it in the OS keyring
// in case the organization's configuration comes back later, instead of forcing
// the user to re-enter it:
LOG.LogInformation($"Preserving the user-provided API key for removed enterprise provider '{item.Name}' in the OS keyring.");
}
else if(secretStoreType is not null && item is ISecretId secretId)
{
var deleteResult = await RustService.DeleteAPIKey(secretId, secretStoreType.Value);
@@ -0,0 +1,138 @@
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem.Assistants;
namespace AIStudio.Tools.PluginSystem;
public static partial class PluginFactory
{
/// <summary>
/// The assistant plugins your organization enabled without leaving the user a way to switch them off.
/// </summary>
/// <remarks>
/// This is deliberately not persisted. Such an activation is decided live from the approvals of
/// your organization, so it ends the moment the approval does, without anything to clean up. The
/// field is replaced as a whole instead of being edited in place, so a reload never lets the user
/// interface observe a half-built state.
/// </remarks>
private static IReadOnlySet<Guid> ENFORCED_ASSISTANT_ACTIVATIONS = new HashSet<Guid>();
/// <summary>
/// The assistant plugins your organization enabled while leaving the user free to switch them off.
/// </summary>
/// <remarks>
/// This is not what decides the activation: such a default is applied once and then belongs to the
/// user, which is what the applied activations in the settings remember. We keep the plugins it
/// concerns so that the user interface can say where the activation came from, whether the default
/// was applied just now or during an earlier start.
/// </remarks>
private static IReadOnlySet<Guid> DEFAULT_ASSISTANT_ACTIVATIONS = new HashSet<Guid>();
/// <summary>
/// Whether your organization requires this assistant plugin to stay enabled.
/// </summary>
/// <param name="pluginId">The ID of the plugin in question.</param>
/// <returns>True when the user may not switch this assistant plugin off.</returns>
public static bool IsAssistantActivationEnforced(Guid pluginId) => ENFORCED_ASSISTANT_ACTIVATIONS.Contains(pluginId);
/// <summary>
/// Whether your organization enables this assistant plugin by default, leaving you free to switch
/// it off again.
/// </summary>
/// <param name="pluginId">The ID of the plugin in question.</param>
/// <returns>True when the organization asked for this assistant plugin to be enabled by default.</returns>
public static bool IsAssistantActivationOrganizationDefault(Guid pluginId) => DEFAULT_ASSISTANT_ACTIVATIONS.Contains(pluginId);
/// <summary>
/// Applies what the approvals of your organization say about enabling assistant plugins.
/// </summary>
/// <remarks>
/// Approving an assistant plugin only states that it is safe. Whether it is enabled is a second
/// decision, and an organization expresses it with the Activate field of an approval. Without that
/// field nothing changes: the plugin is approved, and the user switches it on.<br/><br/>
/// We read the approvals as they are stored, which is the same source the security card uses. They
/// survive a configuration plugin which failed to load, so one broken configuration cannot
/// silently withdraw what an organization enabled.<br/><br/>
/// Call this once all plugins are running and the effective approvals were recomputed.
/// </remarks>
/// <returns>True when the settings were changed and have to be stored, otherwise false.</returns>
private static bool RefreshEnterpriseAssistantActivations()
{
var approvalsByHash = new Dictionary<string, DataAssistantPluginEnterpriseApproval>(StringComparer.Ordinal);
foreach (var approval in SettingsManagerAccess.ConfigurationData.AssistantPluginAudit.EnterpriseApprovedPlugins)
approvalsByHash[NormalizeAssistantHash(approval.PluginHash)] = approval;
var appliedActivations = SettingsManagerAccess.ConfigurationData.AppliedEnterpriseAssistantActivations;
var enforcedActivations = new HashSet<Guid>();
var defaultActivations = new HashSet<Guid>();
var wasConfigurationChanged = false;
foreach (var assistantPlugin in RUNNING_PLUGINS.OfType<PluginAssistants>())
{
var pluginHash = NormalizeAssistantHash(assistantPlugin.ComputeAuditHash());
if (!approvalsByHash.TryGetValue(pluginHash, out var approval) || !approval.Activate)
continue;
//
// An approval is matched by its hash alone, without looking at where the plugin is stored:
// a plugin the user placed themselves counts as approved as soon as its Lua files are the
// ones the organization approved. For an approval that is right, because the hash is the
// code. For enabling a plugin on the user's behalf it is not enough: the organization would
// then enforce a copy it never rolled out, cannot update, and cannot withdraw again. So we
// ask for the rollout in addition to the approval:
//
var pluginMetadata = AVAILABLE_PLUGINS.FirstOrDefault(plugin => plugin.Id == assistantPlugin.Id);
if (pluginMetadata is not { IsManagedByConfigServer: true })
{
LOG.LogInformation($"Your organization asks for the assistant plugin '{assistantPlugin.Name}' (id '{assistantPlugin.Id}') to be enabled, but it did not deploy this copy of the plugin. Ignoring the activation: the approval stays in place, and you decide about enabling it.");
continue;
}
if (!approval.AllowUserOverride)
{
enforcedActivations.Add(assistantPlugin.Id);
LOG.LogInformation($"Your organization requires the assistant plugin '{assistantPlugin.Name}' (id '{assistantPlugin.Id}') to stay enabled.");
continue;
}
defaultActivations.Add(assistantPlugin.Id);
// An organization default is applied once. Afterwards the decision belongs to the user:
if (appliedActivations.Contains(pluginHash))
continue;
appliedActivations.Add(pluginHash);
wasConfigurationChanged = true;
if (SettingsManagerAccess.ConfigurationData.EnabledPlugins.Contains(assistantPlugin.Id))
continue;
SettingsManagerAccess.ConfigurationData.EnabledPlugins.Add(assistantPlugin.Id);
LOG.LogInformation($"Enabled the assistant plugin '{assistantPlugin.Name}' (id '{assistantPlugin.Id}') because your organization enables it by default. You may switch it off again.");
}
ENFORCED_ASSISTANT_ACTIVATIONS = enforcedActivations;
DEFAULT_ASSISTANT_ACTIVATIONS = defaultActivations;
//
// Forget the defaults we applied for plugins no approval asks for anymore. Otherwise, an
// organization which rolls the same plugin out again later would find its default silently
// ignored, because we would still consider it applied:
//
var leftOverActivations = appliedActivations.Where(hash => !IsOrganizationDefaultActivation(approvalsByHash, hash)).ToList();
foreach (var leftOverActivation in leftOverActivations)
{
appliedActivations.Remove(leftOverActivation);
wasConfigurationChanged = true;
}
if (leftOverActivations.Count > 0)
LOG.LogInformation($"Forgot {leftOverActivations.Count} applied organization default(s) for assistant plugin activations, because your organization does not ask for them anymore.");
return wasConfigurationChanged;
}
private static bool IsOrganizationDefaultActivation(Dictionary<string, DataAssistantPluginEnterpriseApproval> approvalsByHash, string pluginHash)
=> approvalsByHash.TryGetValue(pluginHash, out var approval) && approval is { Activate: true, AllowUserOverride: true };
private static string NormalizeAssistantHash(string hash) => string.IsNullOrWhiteSpace(hash) ? string.Empty : hash.Trim().ToUpperInvariant();
}
@@ -1,4 +1,3 @@
using System.IO.Compression;
using System.Net.Http.Headers;
namespace AIStudio.Tools.PluginSystem;
@@ -46,7 +45,7 @@ public static partial class PluginFactory
LOG.LogInformation($"Try to download configuration plugin with ID='{configPlugId}' from server='{configServerUrl}' (GET {downloadUrl})");
var tempDownloadFile = Path.GetTempFileName();
var stagedDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, $"{configPlugId}.staging-{Guid.NewGuid():N}");
var stagedDirectory = Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, $"{configPlugId}.staging-{Guid.NewGuid():N}");
string? backupDirectory = null;
var wasSuccessful = false;
try
@@ -67,10 +66,10 @@ public static partial class PluginFactory
ExtractConfigPluginArchive(tempDownloadFile, stagedDirectory);
var configDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString());
var configDirectory = Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString());
if (Directory.Exists(configDirectory))
{
backupDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, $"{configPlugId}.backup-{Guid.NewGuid():N}");
backupDirectory = Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, $"{configPlugId}.backup-{Guid.NewGuid():N}");
Directory.Move(configDirectory, backupDirectory);
}
@@ -85,7 +84,7 @@ public static partial class PluginFactory
{
LOG.LogError(e, "An error occurred while downloading or extracting the enterprise configuration plugin.");
var configDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString());
var configDirectory = Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString());
if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory) && !Directory.Exists(configDirectory))
{
try
@@ -130,69 +129,11 @@ public static partial class PluginFactory
return wasSuccessful;
}
// Compatibility shim for Windows-created ZIPs with backslashes in entry names (dotnet/runtime#27620).
// See documentation/compatibility-shims/2026-07-enterprise-config-zip-backslashes.md.
private static void ExtractConfigPluginArchive(string sourceArchiveFileName, string destinationDirectory)
{
using var archive = ZipFile.OpenRead(sourceArchiveFileName);
Directory.CreateDirectory(destinationDirectory);
var destinationDirectoryFullPath = Path.GetFullPath(destinationDirectory);
if (!destinationDirectoryFullPath.EndsWith(Path.DirectorySeparatorChar))
destinationDirectoryFullPath += Path.DirectorySeparatorChar;
foreach (var entry in archive.Entries)
{
var normalizedEntryName = NormalizeConfigPluginZipEntryName(entry.FullName);
var destinationPath = GetConfigPluginZipEntryDestinationPath(destinationDirectoryFullPath, normalizedEntryName);
if (normalizedEntryName.EndsWith('/'))
{
if (entry.Length != 0)
throw new InvalidDataException($"The enterprise configuration plugin archive contains a directory entry with data: '{entry.FullName}'.");
Directory.CreateDirectory(destinationPath);
continue;
}
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
entry.ExtractToFile(destinationPath);
}
PluginArchive.Extract(sourceArchiveFileName, destinationDirectory);
if (!Directory.EnumerateFiles(destinationDirectory, "plugin.lua", SearchOption.AllDirectories).Any())
throw new InvalidDataException("The enterprise configuration plugin archive does not contain a plugin.lua file.");
}
private static string NormalizeConfigPluginZipEntryName(string entryName)
{
var normalizedEntryName = entryName.Replace('\\', '/');
if (string.IsNullOrWhiteSpace(normalizedEntryName))
throw new InvalidDataException("The enterprise configuration plugin archive contains an empty entry name.");
if (normalizedEntryName.Contains('\0'))
throw new InvalidDataException($"The enterprise configuration plugin archive contains an invalid entry name: '{entryName}'.");
if (normalizedEntryName.StartsWith('/'))
throw new InvalidDataException($"The enterprise configuration plugin archive contains a rooted entry name: '{entryName}'.");
if (normalizedEntryName is [_, ':', ..])
throw new InvalidDataException($"The enterprise configuration plugin archive contains a drive-qualified entry name: '{entryName}'.");
var pathSegments = normalizedEntryName.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (pathSegments.Length == 0 || pathSegments.Any(segment => segment is "." or ".."))
throw new InvalidDataException($"The enterprise configuration plugin archive contains an unsafe entry name: '{entryName}'.");
return normalizedEntryName;
}
private static string GetConfigPluginZipEntryDestinationPath(string destinationDirectoryFullPath, string normalizedEntryName)
{
var pathSegments = normalizedEntryName.Split('/', StringSplitOptions.RemoveEmptyEntries);
var relativePath = Path.Combine(pathSegments);
var destinationPath = Path.GetFullPath(Path.Combine(destinationDirectoryFullPath, relativePath));
if (!destinationPath.StartsWith(destinationDirectoryFullPath, StringComparison.Ordinal))
throw new InvalidDataException($"The enterprise configuration plugin archive contains an entry outside the destination directory: '{normalizedEntryName}'.");
return destinationPath;
}
}
}
@@ -1,9 +1,36 @@
using Timer = System.Timers.Timer;
namespace AIStudio.Tools.PluginSystem;
public static partial class PluginFactory
{
private static readonly SemaphoreSlim HOT_RELOAD_SEMAPHORE = new(1, 1);
/// <summary>
/// How long the plugins directory has to stay quiet before we reload.
/// </summary>
/// <remarks>
/// One change never arrives as one event: writing a single file produces several, and moving an
/// entire plugin directory into place produces dozens. Reloading on each of them would restart
/// every plugin over and over.
/// </remarks>
private static readonly TimeSpan HOT_RELOAD_DEBOUNCE_INTERVAL = TimeSpan.FromSeconds(1);
private static readonly Timer HOT_RELOAD_DEBOUNCE_TIMER = new(HOT_RELOAD_DEBOUNCE_INTERVAL)
{
AutoReset = false,
};
/// <summary>
/// Whether hot reloading was set up already.
/// </summary>
/// <remarks>
/// The timer and the watcher are static, while this method is called from a component. Calling
/// it twice would add a second handler to each of them, and every change in the plugins
/// directory would then trigger as many reloads as there were calls.
/// </remarks>
private static bool IS_HOT_RELOADING_SET_UP;
public static void SetUpHotReloading()
{
if (!IsInitialized)
@@ -11,17 +38,34 @@ public static partial class PluginFactory
LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it.");
return;
}
if (IS_HOT_RELOADING_SET_UP)
{
LOG.LogInformation("Hot reloading is already set up. Skipping.");
return;
}
IS_HOT_RELOADING_SET_UP = true;
LOG.LogInformation($"Start hot reloading plugins for path '{HOT_RELOAD_WATCHER.Path}'.");
try
{
HOT_RELOAD_DEBOUNCE_TIMER.Elapsed += (_, _) => ReloadPluginsAsync().Observe($"{nameof(PluginFactory)}: hot reloading plugins");
HOT_RELOAD_WATCHER.IncludeSubdirectories = true;
HOT_RELOAD_WATCHER.NotifyFilter = NotifyFilters.CreationTime
| NotifyFilters.DirectoryName
//
// We watch for plugins appearing, disappearing, and changing. We do not watch access
// times: reading a plugin is not a change, and on Linux our own reads would be
// reported back to us. Loading the plugins and computing the audit hash of an
// assistant plugin both read every Lua file in this directory, so such a filter
// makes each reload cause the next one:
//
HOT_RELOAD_WATCHER.NotifyFilter = NotifyFilters.DirectoryName
| NotifyFilters.FileName
| NotifyFilters.LastWrite
| NotifyFilters.Size;
HOT_RELOAD_WATCHER.Changed += HotReloadEventHandler;
HOT_RELOAD_WATCHER.Deleted += HotReloadEventHandler;
HOT_RELOAD_WATCHER.Created += HotReloadEventHandler;
@@ -41,64 +85,96 @@ public static partial class PluginFactory
LOG.LogInformation("Hot reloading plugins set up.");
}
}
private static async void HotReloadEventHandler(object _, FileSystemEventArgs args)
private static void HotReloadEventHandler(object _, FileSystemEventArgs args)
{
try
{
var changeType = args.ChangeType.ToString().ToLowerInvariant();
if (!await HOT_RELOAD_SEMAPHORE.WaitAsync(0))
{
LOG.LogInformation($"File changed '{args.FullPath}' (event={changeType}). Already processing another change.");
//
// Our own lock file lives in the watched directory. Writing and removing it are not
// plugin changes, and reacting to them would turn every locked operation into a
// reload of its own:
//
if (IsHotReloadLockFile(args.FullPath))
return;
}
try
{
LOG.LogInformation($"File changed '{args.FullPath}' (event={changeType}). Reloading plugins...");
if (File.Exists(HOT_RELOAD_LOCK_FILE))
{
LOG.LogInformation("Hot reload lock file exists. Waiting for it to be released before proceeding with the reload.");
var changeType = args.ChangeType.ToString().ToLowerInvariant();
LOG.LogInformation($"File changed '{args.FullPath}' (event={changeType}). Scheduling a plugin reload.");
var lockFileCancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var token = lockFileCancellationTokenSource.Token;
var waitTime = TimeSpan.FromSeconds(1);
while (File.Exists(HOT_RELOAD_LOCK_FILE) && !token.IsCancellationRequested)
{
try
{
LOG.LogDebug("Waiting for hot reload lock to be released...");
await Task.Delay(waitTime, token);
waitTime = TimeSpan.FromSeconds(Math.Min(waitTime.TotalSeconds * 2, 120)); // Exponential backoff with a cap
}
catch (TaskCanceledException)
{
// Case: The cancellation token was triggered, meaning the lock file is still present.
// We expect that something goes wrong. So, we try to delete the lock file:
LOG.LogWarning("Hot reload lock file still exists after 30 seconds. Attempting to delete it...");
UnlockHotReload();
break;
}
}
LOG.LogInformation("Hot reload lock file released. Proceeding with plugin reload.");
}
await LoadAll();
await MessageBus.INSTANCE.SendMessage<bool>(null, Event.PLUGINS_RELOADED);
}
catch(Exception e)
{
LOG.LogError(e, $"Error while reloading plugins after change in file '{args.FullPath}' with change type '{changeType}'.");
}
finally
{
HOT_RELOAD_SEMAPHORE.Release();
}
// Restart the debounce window, so that a burst of events results in one reload:
HOT_RELOAD_DEBOUNCE_TIMER.Stop();
HOT_RELOAD_DEBOUNCE_TIMER.Start();
}
catch (Exception e)
{
LOG.LogError(e, $"Error while handling hot reload event for file '{args.FullPath}' with change type '{args.ChangeType}'.");
}
}
private static bool IsHotReloadLockFile(string path)
{
if (string.IsNullOrWhiteSpace(path) || string.IsNullOrWhiteSpace(HOT_RELOAD_LOCK_FILE))
return false;
return string.Equals(path, HOT_RELOAD_LOCK_FILE, StringComparison.OrdinalIgnoreCase);
}
private static async Task ReloadPluginsAsync()
{
//
// Reloads must never overlap. When one is still running, we do not drop this one: the
// changes which triggered it might have arrived after the running reload had already read
// them. We try again after another quiet window instead:
//
if (!await HOT_RELOAD_SEMAPHORE.WaitAsync(0))
{
LOG.LogInformation("A plugin reload is already running. Waiting for it to finish before reloading again.");
HOT_RELOAD_DEBOUNCE_TIMER.Stop();
HOT_RELOAD_DEBOUNCE_TIMER.Start();
return;
}
try
{
LOG.LogInformation("Reloading plugins...");
if (File.Exists(HOT_RELOAD_LOCK_FILE))
{
LOG.LogInformation("Hot reload lock file exists. Waiting for it to be released before proceeding with the reload.");
var lockFileCancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var token = lockFileCancellationTokenSource.Token;
var waitTime = TimeSpan.FromSeconds(1);
while (File.Exists(HOT_RELOAD_LOCK_FILE) && !token.IsCancellationRequested)
{
try
{
LOG.LogDebug("Waiting for hot reload lock to be released...");
await Task.Delay(waitTime, token);
waitTime = TimeSpan.FromSeconds(Math.Min(waitTime.TotalSeconds * 2, 120)); // Exponential backoff with a cap
}
catch (TaskCanceledException)
{
// Case: The cancellation token was triggered, meaning the lock file is still present.
// We expect that something goes wrong. So, we try to delete the lock file:
LOG.LogWarning("Hot reload lock file still exists after 30 seconds. Attempting to delete it...");
UnlockHotReload();
break;
}
}
LOG.LogInformation("Hot reload lock file released. Proceeding with plugin reload.");
}
// LoadAll announces the reload itself, cf. PluginFactory.Starting.RestartAllPlugins:
await LoadAll();
}
catch(Exception e)
{
LOG.LogError(e, "Error while reloading plugins after a change in the plugins directory.");
}
finally
{
HOT_RELOAD_SEMAPHORE.Release();
}
}
}
@@ -1,5 +1,7 @@
using System.Linq.Expressions;
using System.Text;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem.Assistants;
using Lua;
using Lua.Standard;
@@ -44,19 +46,23 @@ public static partial class PluginFactory
try
{
LOG.LogInformation("Start loading plugins.");
if (!Directory.Exists(PLUGINS_ROOT))
{
LOG.LogInformation("No plugins found.");
return;
}
//
// 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.");
AVAILABLE_PLUGINS.Clear();
//
// 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.
//
var pluginMainFiles = Directory.EnumerateFiles(PLUGINS_ROOT, "plugin.lua", SearchOption.AllDirectories);
IEnumerable<string> pluginMainFiles = pluginsDirectoryExists ? Directory.EnumerateFiles(PLUGINS_ROOT, "plugin.lua", SearchOption.AllDirectories) : [];
foreach (var pluginMainFile in pluginMainFiles)
{
try
@@ -77,7 +83,7 @@ public static partial class PluginFactory
}
var pluginPath = Path.GetDirectoryName(pluginMainFile)!;
var plugin = await Load(pluginPath, code, cancellationToken);
var plugin = await Load(pluginPath, code, cancellationToken: cancellationToken);
switch (plugin)
{
@@ -104,42 +110,84 @@ public static partial class PluginFactory
LOG.LogInformation($"Successfully loaded plugin: '{pluginMainFile}' (Id='{plugin.Id}', Type='{plugin.Type}', Name='{plugin.Name}', Version='{plugin.Version}', Authors='{string.Join(", ", plugin.Authors)}')");
var isConfigurationPluginInConfigDirectory =
plugin.Type is PluginType.CONFIGURATION &&
pluginPath.StartsWith(CONFIGURATION_PLUGINS_ROOT, StringComparison.OrdinalIgnoreCase);
//
// 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)
{
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;
}
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);
}
//
// 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);
var isManagedByConfigServer = false;
Guid? managedConfigurationId = null;
var configurationPriority = 0;
bool? declaredAsManagedByConfigServer = null;
if (plugin is PluginConfiguration configPlugin)
{
if (configPlugin.DeployedUsingConfigServer.HasValue)
isManagedByConfigServer = configPlugin.DeployedUsingConfigServer.Value;
else if (isConfigurationPluginInConfigDirectory)
{
isManagedByConfigServer = true;
LOG.LogWarning($"The configuration 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 '{CONFIGURATION_PLUGINS_ROOT}'.");
}
configurationPriority = configPlugin.Priority;
declaredAsManagedByConfigServer = configPlugin.DeployedUsingConfigServer;
}
else if (plugin is PluginAssistants assistantPlugin)
isManagedByConfigServer = assistantPlugin.IsManagedByConfigServer;
else if (plugin is PluginAssistants { HasDeploymentManagementMetadata: true } assistantPlugin)
declaredAsManagedByConfigServer = assistantPlugin.IsManagedByConfigServer;
// For configuration plugins, validate that the plugin ID matches the enterprise config ID
// (the directory name under which the plugin was downloaded):
if (isConfigurationPluginInConfigDirectory && isManagedByConfigServer)
//
// 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)
{
var directoryName = Path.GetFileName(pluginPath);
if (Guid.TryParse(directoryName, out var enterpriseConfigId))
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;
}
//
// 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))
{
if (TryGetDeployedConfigurationId(pluginPath, out var enterpriseConfigId))
{
managedConfigurationId = enterpriseConfigId;
if (enterpriseConfigId != plugin.Id)
if (plugin.Type is PluginType.CONFIGURATION && enterpriseConfigId != plugin.Id)
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
LOG.LogWarning($"Could not determine the managed configuration ID for configuration plugin '{plugin.Id}'. The plugin directory '{pluginPath}' does not end with a valid GUID.");
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.");
}
AVAILABLE_PLUGINS.Add(new PluginMetadata(plugin, pluginPath, isManagedByConfigServer, managedConfigurationId));
AVAILABLE_PLUGINS.Add(new PluginMetadata(plugin, pluginPath, isManagedByConfigServer, managedConfigurationId, configurationPriority));
}
catch (Exception e)
{
@@ -149,8 +197,11 @@ public static partial class PluginFactory
}
// Start or restart all plugins:
var configObjects = await RestartAllPlugins(cancellationToken);
configObjectList.AddRange(configObjects);
if (pluginsDirectoryExists)
{
var configObjects = await RestartAllPlugins(cancellationToken);
configObjectList.AddRange(configObjects);
}
}
finally
{
@@ -166,210 +217,102 @@ public static partial class PluginFactory
// =========================================================
//
//
// 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:
//
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);
//
// 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();
var unloadedEnterpriseConfigPluginIds = deployedEnterpriseConfigPluginIds.Where(x => AVAILABLE_PLUGINS.All(plugin => plugin.Id != x)).ToList();
foreach (var unloadedEnterpriseConfigPluginId in unloadedEnterpriseConfigPluginIds)
{
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;
}
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.");
}
// Check LLM providers:
var wasConfigurationChanged = await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.LLM_PROVIDER);
var wasConfigurationChanged = await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList, SecretStoreType.LLM_PROVIDER);
// Check transcription providers:
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.TRANSCRIPTION_PROVIDER))
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList, SecretStoreType.TRANSCRIPTION_PROVIDER))
wasConfigurationChanged = true;
// Check embedding providers:
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.EMBEDDING_PROVIDER))
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList, SecretStoreType.EMBEDDING_PROVIDER))
wasConfigurationChanged = true;
// Check data sources:
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DATA_SOURCE, x => x.DataSources, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.DATA_SOURCE, deleteSecret: true))
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DATA_SOURCE, x => x.DataSources, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList, SecretStoreType.DATA_SOURCE, deleteSecret: true))
wasConfigurationChanged = true;
// Check chat templates:
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, AVAILABLE_PLUGINS, configObjectList))
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList))
wasConfigurationChanged = true;
// Check profiles:
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.PROFILE, x => x.Profiles, AVAILABLE_PLUGINS, configObjectList))
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.PROFILE, x => x.Profiles, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList))
wasConfigurationChanged = true;
// Check document analysis policies:
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, AVAILABLE_PLUGINS, configObjectList))
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList))
wasConfigurationChanged = true;
// Check left-over mandatory info acceptances:
if (SettingsManagerAccess.ConfigurationData.MandatoryInformation.RemoveLeftOverAcceptances(GetMandatoryInfos()))
wasConfigurationChanged = true;
// Check for a preselected provider:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.PreselectedProvider, AVAILABLE_PLUGINS))
// Check all managed settings, i.e. settings which a configuration plugin can lock,
// provide as an editable default, or contribute to:
if(ManagedConfiguration.CleanupLeftOverManagedConfigurations(AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds))
wasConfigurationChanged = true;
// Check for a preselected profile:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.PreselectedProfile, AVAILABLE_PLUGINS))
//
// 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())
wasConfigurationChanged = true;
// Check for preselected chat options:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectOptions, AVAILABLE_PLUGINS))
//
// 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;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedProvider, AVAILABLE_PLUGINS))
// Compatibility shim, see documentation/compatibility-shims/2026-08-orphaned-config-locks.md (remove after 2027-08-06):
if (RepairLegacyConfigOnlySettings(unloadedEnterpriseConfigPluginIds.Count > 0))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedProfile, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedChatTemplate, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedDataSourcesDisabled, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedDataSourcesAutomaticSelection, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedDataSourcesAutomaticValidation, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedDataSourceIds, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.SendToChatDataSourceBehavior, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the update interval:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.UpdateInterval, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the update installation method:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.UpdateInstallation, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the start page:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.StartPage, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the built-in introduction visibility:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShowIntroduction, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the quick start guide visibility:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShowQuickStartGuide, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the last changelog visibility:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShowLastChangelog, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the vision panel visibility:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShowVision, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for users allowed to added providers:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.AllowUserToAddProvider, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for admin settings visibility:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShowAdminSettings, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for preview visibility:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.PreviewVisibility, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for enabled preview features:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.EnabledPreviewFeatures, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsPluginContributionLeftOver(x => x.App, x => x.EnabledPreviewFeatures, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the transcription provider:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.UseTranscriptionProvider, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for hidden assistants:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.HiddenAssistants, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the voice recording shortcut:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShortcutVoiceRecording, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the external HTTP client timeout:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.HttpClientTimeoutSeconds, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for custom root certificates for external HTTP requests:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ExternalHttpCustomRootCertificatesEnabled, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ExternalHttpCustomRootCertificateBundlePath, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ExternalHttpCustomRootCertificateAllowedHosts, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check provider confidence settings:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Confidence, x => x.EnforceGlobalMinimumConfidence, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Confidence, x => x.GlobalMinimumConfidence, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Confidence, x => x.ShowProviderConfidence, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Confidence, x => x.ConfidenceScheme, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Confidence, x => x.CustomConfidenceScheme, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check data source security settings:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.DataSourceSecurity, x => x.TrustedProviderIds, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check data source selection agent settings:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AgentDataSourceSelection, x => x.PreselectAgentOptions, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AgentDataSourceSelection, x => x.PreselectedAgentProvider, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check retrieval context validation agent settings:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AgentRetrievalContextValidation, x => x.EnableRetrievalContextValidation, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AgentRetrievalContextValidation, x => x.PreselectAgentOptions, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AgentRetrievalContextValidation, x => x.PreselectedAgentProvider, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AgentRetrievalContextValidation, x => x.NumParallelValidations, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check if audit is required before it can be activated
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.RequireAuditBeforeActivation, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Register new preselected provider for the security audit
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.PreselectedAgentProvider, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Change the minimum required audit level that is required for the allowance of assistants
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.MinimumLevel, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check if external plugins are strictly forbidden, when the minimum audit level is fell below
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.BlockActivationBelowMinimum, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check if security audits are invoked automatically and transparent for the user
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.AutomaticallyAuditAssistants, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check enterprise-managed assistant plugin approvals
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.EnterpriseApprovedPlugins, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if (wasConfigurationChanged)
{
await SettingsManagerAccess.StoreSettings();
@@ -377,16 +320,58 @@ public static partial class PluginFactory
}
}
public static async Task<PluginBase> Load(string? pluginPath, string code, CancellationToken cancellationToken = default)
/// <summary>
/// Determines the IDs of all configuration plugins which an organization deployed on this machine.
/// </summary>
/// <remarks>
/// 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/>
/// 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>
private static HashSet<Guid> GetDeployedEnterpriseConfigPluginIds()
{
var deployedEnterpriseConfigPluginIds = new HashSet<Guid>();
if (!Directory.Exists(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
return deployedEnterpriseConfigPluginIds;
foreach (var configPluginDirectory in Directory.EnumerateDirectories(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
{
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;
deployedEnterpriseConfigPluginIds.Add(configPluginId);
}
return deployedEnterpriseConfigPluginIds;
}
/// <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">
/// 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.
/// </param>
/// <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)
{
if(ForbiddenPlugins.Check(code) is { IsForbidden: true } forbiddenState)
return new NoPlugin($"This plugin is forbidden: {forbiddenState.Message}");
var state = LuaState.Create();
if (!string.IsNullOrWhiteSpace(pluginPath))
{
// Add the module loader so that the plugin can load other Lua modules:
state.ModuleLoader = new PluginLoader(pluginPath);
state.ModuleLoader = new PluginLoader(pluginPath, allowedBaseDirectory);
}
// Add some useful libraries:
@@ -420,7 +405,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:
@@ -444,4 +432,111 @@ public static partial class PluginFactory
return new NoPlugin("This plugin type is not supported yet. Please try again with a future version of AI Studio.");
}
}
//
// =========================================================
// 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;
}
}
@@ -1,129 +1,95 @@
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(CONFIGURATION_PLUGINS_ROOT))
foreach (var configurationDirectory in Directory.EnumerateDirectories(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
{
foreach (var pluginDirectory in Directory.EnumerateDirectories(CONFIGURATION_PLUGINS_ROOT))
var directoryName = Path.GetFileName(configurationDirectory);
// 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;
//
// 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;
RemoveConfigurationDirectory(configurationDirectory, REASON_NO_LONGER_REFERENCED);
}
}
/// <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)
{
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())
{
AVAILABLE_PLUGINS.Remove(plugin);
if (RUNNING_PLUGINS.FirstOrDefault(runningPlugin => runningPlugin.Id == plugin.Id) is { } runningPluginToRemove)
{
var directoryName = Path.GetFileName(pluginDirectory);
if (!Guid.TryParse(directoryName, out var pluginId))
continue;
RUNNING_PLUGINS.Remove(runningPluginToRemove);
if (activeConfigurationIds.Contains(pluginId))
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 '{CONFIGURATION_PLUGINS_ROOT}'.");
if (isManagedByConfigServer)
pluginIdsToRemove.Add(pluginId);
// The plugin is unloaded, so its Lua runtime is of no use anymore:
runningPluginToRemove.Dispose();
}
LOG.LogInformation("Unloaded the plugin '{PluginName}' ({PluginId}). Reason: {Reason}.", plugin.Name, plugin.Id, reason);
}
foreach (var pluginId in pluginIdsToRemove)
RemovePluginAsync(pluginId, REASON_NO_LONGER_REFERENCED);
}
private static void RemovePluginAsync(Guid pluginId, string reason)
{
if (!IsInitialized)
if (!Directory.Exists(configurationDirectory))
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;
}
}
private static void DeleteConfigurationPluginDirectory(Guid pluginId)
{
var pluginDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, pluginId.ToString());
if (!Directory.Exists(pluginDirectory))
{
LOG.LogWarning($"Plugin directory '{pluginDirectory}' does not exist.");
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();
}
@@ -18,6 +18,15 @@ public static partial class PluginFactory
{
LOG.LogInformation("Try to start or restart all plugins.");
var configObjects = new List<PluginConfigurationObject>();
//
// Dropping the plugins is not enough: each one owns a Lua runtime, which we have to release
// ourselves. Otherwise, every restart — above all every hot reload during development —
// leaves another set of runtimes behind:
//
foreach (var runningPlugin in RUNNING_PLUGINS)
runningPlugin.Dispose();
RUNNING_PLUGINS.Clear();
//
@@ -52,9 +61,25 @@ public static partial class PluginFactory
}
//
// Iterate over all available plugins and try to start them.
// Iterate over all available plugins and try to start them. We do that in a deterministic
// order, starting with the configuration plugins of the organization. Three reasons:
//
foreach (var availablePlugin in AVAILABLE_PLUGINS)
// - Configuration plugins write settings and configuration objects. Whoever writes one
// first owns it, so the organization has to come first: its configuration is the baseline
// every other plugin has to respect.
//
// - Within one origin, the declared priority decides. An organization can deploy a base
// configuration for everybody and refine it, e.g. per department: the higher priority is
// applied later and therefore wins.
//
// - Without an explicit order, the sequence is the one Directory.EnumerateFiles produced in
// LoadAll. That order is not guaranteed, so the same installation could behave
// differently on two machines. The plugin directory breaks any remaining tie.
//
foreach (var availablePlugin in AVAILABLE_PLUGINS
.OrderBy(GetStartupRank)
.ThenBy(plugin => plugin.ConfigurationPriority)
.ThenBy(plugin => plugin.LocalPath, StringComparer.OrdinalIgnoreCase))
{
if(cancellationToken.IsCancellationRequested)
{
@@ -89,19 +114,51 @@ public static partial class PluginFactory
return configObjects;
}
/// <summary>
/// Determines the position of a plugin in the startup sequence. Plugins with a lower rank start earlier.
/// </summary>
/// <remarks>
/// The configuration plugins an organization deployed go first: they are the baseline for
/// 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>
/// <param name="plugin">The plugin about to be started.</param>
/// <returns>The startup rank of the plugin.</returns>
private static int GetStartupRank(IAvailablePlugin plugin) => plugin.Type switch
{
PluginType.CONFIGURATION when IsEnterpriseConfigurationPath(plugin.LocalPath) => 0,
PluginType.CONFIGURATION when IsEnterpriseTestConfigurationPath(plugin.LocalPath) => 1,
PluginType.CONFIGURATION => 2,
_ => 3,
};
private static void LogAssistantPluginStartupState()
{
ManagedConfiguration.TryGet(x => x.AssistantPluginAudit, x => x.EnterpriseApprovedPlugins, out ConfigMeta<DataAssistantPluginAudit, IList<DataAssistantPluginEnterpriseApproval>> configMeta);
var approvedByConfigPluginId = configMeta is { IsLocked: true } ? configMeta.LockedByConfigPluginId : Guid.Empty;
var approvedByConfigPluginName = approvedByConfigPluginId == Guid.Empty
? string.Empty
: AVAILABLE_PLUGINS.FirstOrDefault(x => x.Id == approvedByConfigPluginId)?.Name ?? string.Empty;
foreach (var assistantPlugin in RUNNING_PLUGINS.OfType<PluginAssistants>())
{
var securityState = PluginAssistantSecurityResolver.Resolve(SettingsManagerAccess, assistantPlugin);
if (securityState.IsEnterpriseApproved)
{
//
// Several configuration plugins may approve assistant plugins. We look up the one
// which approved this particular plugin instead of naming an arbitrary contributor:
//
var approvedByConfigPluginId = configMeta.PluginContributions
.Where(contribution => contribution.Value.Any(approval => string.Equals(approval.PluginHash, securityState.CurrentHash, StringComparison.Ordinal)))
.Select(contribution => contribution.Key)
.FirstOrDefault();
var approvedByConfigPluginName = approvedByConfigPluginId == Guid.Empty
? string.Empty
: AVAILABLE_PLUGINS.FirstOrDefault(x => x.Id == approvedByConfigPluginId)?.Name ?? string.Empty;
LOG.LogInformation(
$"Successfully started assistant plugin: Id='{assistantPlugin.Id}', Type='{assistantPlugin.Type}', Name='{assistantPlugin.Name}', Version='{assistantPlugin.Version}', SecuritySource='EnterpriseApproval', ApprovedByConfigPluginId='{approvedByConfigPluginId}', ApprovedByConfigPluginName='{approvedByConfigPluginName}'");
continue;
@@ -122,7 +179,7 @@ public static partial class PluginFactory
}
var code = await File.ReadAllTextAsync(pluginMainFile, Encoding.UTF8, cancellationToken);
var plugin = await Load(meta.LocalPath, code, cancellationToken);
var plugin = await Load(meta.LocalPath, code, cancellationToken: cancellationToken);
plugin.PluginPath = meta.LocalPath;
if (plugin is NoPlugin noPlugin)
{
@@ -11,10 +11,50 @@ public static partial class PluginFactory
private static string DATA_DIR = string.Empty;
private static string PLUGINS_ROOT = string.Empty;
private static string INTERNAL_PLUGINS_ROOT = string.Empty;
private static string CONFIGURATION_PLUGINS_ROOT = string.Empty;
/// <summary>
/// The directory the config server downloads the 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 deployment in a directory named after its configuration ID.<br/><br/>
/// A deployment is not limited to a configuration, even though the directory name says so. An
/// organization serves one archive per configuration ID and uses it for every kind of plugin:
/// assistants, languages, themes, and whatever else follows. Those plugins live in
/// subdirectories, each with its own plugin.lua and its own plugin ID, and only the
/// configuration plugin itself carries the configuration ID as its ID. Everything below such a
/// deployment belongs to the organization, whatever its type is and however deeply it is nested.
/// </remarks>
private static string ENTERPRISE_CONFIGURATION_PLUGINS_ROOT = string.Empty;
/// <summary>
/// The directory administrators use to try a deployment out before their organization rolls it out.
/// </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 and the protection against changes
/// through the user interface. It takes every kind of plugin, exactly like a real deployment, so
/// the directory structure of the later archive can be reproduced one to one. In exchange, the
/// directory is emptied on every start: a test lives for one session only.<br/><br/>
/// A test therefore ends by restarting AI Studio, or by removing the files again. Whoever builds
/// enterprise plugins places them here by hand in the first place, so both ways are open to them
/// anyway, and neither weakens what the directory grants a plugin.
/// </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; }
@@ -65,18 +105,262 @@ public static partial class PluginFactory
PLUGINS_ROOT = Path.Join(DATA_DIR, "plugins");
HOT_RELOAD_LOCK_FILE = Path.Join(PLUGINS_ROOT, ".lock");
INTERNAL_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".internal");
CONFIGURATION_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".config");
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.");
return true;
}
private static async Task LockHotReloadAsync()
/// <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
/// each deployment into a directory named after its configuration ID, and a plugin of any type
/// may sit in a subdirectory of it. 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, and one an organization did deploy could deny it.
/// </remarks>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <returns>True when the directory is nested in the enterprise configuration directory.</returns>
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 belongs to an organization, either deployed by a configuration server
/// or staged for a test.
/// </summary>
/// <remarks>
/// This is the criterion for everything an organization owns, and it holds for every plugin type:
/// a configuration speaking for the organization when it approves assistant plugins or claims a
/// setting, and the protection of a plugin against the user, e.g. against deletion or editing
/// through the user interface.<br/><br/>
/// A test deployment is protected just like a real one, so that a test shows what colleagues will
/// see later. Administrators end a test by restarting AI Studio or by removing the files they
/// placed, which is why they do not need the user interface to get rid of it.<br/><br/>
/// Plugins an organization rolls out past these directories, e.g. through an MDM solution, carry
/// no path to prove it. Those declare DEPLOYED_USING_CONFIG_SERVER instead, which is read into
/// the IsManagedByConfigServer property of a plugin's metadata. Check that property in addition
/// to this method wherever a plugin is protected against the user.
/// </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>
/// Determines which deployed configuration a plugin below the enterprise configuration directory
/// belongs to.
/// </summary>
/// <remarks>
/// A configuration server downloads each configuration into a directory named after its ID. That
/// archive may carry more than the configuration itself: organizations deploy assistant plugins
/// and other plugin types alongside it, each in its own subdirectory. We therefore look at the
/// topmost directory below the enterprise configuration directory instead of the directory the
/// plugin lives in, which for such a plugin is a nested one.
/// </remarks>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <param name="configurationId">The ID of the configuration the plugin was deployed with.</param>
/// <returns>True when the plugin is nested in a directory named after a configuration ID.</returns>
public static bool TryGetDeployedConfigurationId(string? pluginPath, out Guid configurationId)
{
configurationId = Guid.Empty;
if (!IsEnterpriseConfigurationPath(pluginPath))
return false;
try
{
var root = Path.GetFullPath(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT);
var relativePath = Path.GetRelativePath(root, Path.GetFullPath(pluginPath!));
var deploymentDirectory = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)[0];
return Guid.TryParse(deploymentDirectory, out configurationId) && configurationId != Guid.Empty;
}
catch (Exception e)
{
LOG.LogWarning(e, $"Was not able to determine the deployed configuration ID for the plugin directory '{pluginPath}'.");
return false;
}
}
/// <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()
{
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 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;
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;
}
}
/// <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()));
}
/// <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));
}
/// <summary>
/// Counts how many operations currently write to the plugins directory.
/// </summary>
/// <remarks>
/// Downloading an organization's configuration and installing a plugin can run at the same
/// time. Without counting, whichever finishes first would unlock hot reloading while the other
/// is still writing.
/// </remarks>
private static int HOT_RELOAD_LOCK_COUNT;
private static readonly SemaphoreSlim HOT_RELOAD_LOCK_SEMAPHORE = new(1, 1);
/// <summary>
/// Holds back hot reloading while the caller writes to the plugins directory.
/// </summary>
/// <remarks>
/// Every caller has to release the lock again, so wrap the write in a try-finally block. Hot
/// reloading resumes once the last caller has released it.
/// </remarks>
public static async Task LockHotReloadAsync()
{
if (!IsInitialized)
{
@@ -84,23 +368,28 @@ public static partial class PluginFactory
return;
}
await HOT_RELOAD_LOCK_SEMAPHORE.WaitAsync();
try
{
if (File.Exists(HOT_RELOAD_LOCK_FILE))
{
LOG.LogWarning("Hot reload lock file already exists.");
if (HOT_RELOAD_LOCK_COUNT++ > 0)
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.");
}
finally
{
HOT_RELOAD_LOCK_SEMAPHORE.Release();
}
}
private static void UnlockHotReload()
/// <summary>
/// Releases the hot reload lock of one caller, see LockHotReloadAsync.
/// </summary>
public static void UnlockHotReload()
{
if (!IsInitialized)
{
@@ -108,8 +397,20 @@ public static partial class PluginFactory
return;
}
HOT_RELOAD_LOCK_SEMAPHORE.Wait();
try
{
//
// The count can be zero when the reload gave up waiting and removed the lock file
// itself. We must not go negative, because that would keep the next lock from ever
// writing the file again:
//
if (HOT_RELOAD_LOCK_COUNT > 0)
HOT_RELOAD_LOCK_COUNT--;
if (HOT_RELOAD_LOCK_COUNT > 0)
return;
if(File.Exists(HOT_RELOAD_LOCK_FILE))
File.Delete(HOT_RELOAD_LOCK_FILE);
else
@@ -119,30 +420,106 @@ public static partial class PluginFactory
{
LOG.LogError(e, "An error occurred while trying to unlock hot reloading.");
}
finally
{
HOT_RELOAD_LOCK_SEMAPHORE.Release();
}
}
public static void Dispose()
{
if(!IsInitialized)
return;
HOT_RELOAD_WATCHER.Dispose();
HOT_RELOAD_DEBOUNCE_TIMER.Dispose();
}
public static IReadOnlyList<DataMandatoryInfo> GetMandatoryInfos()
{
return RUNNING_PLUGINS
.OfType<PluginConfiguration>()
.SelectMany(plugin => plugin.MandatoryInfos)
.ToList();
return ResolveLivePluginContent<DataMandatoryInfo>("mandatory info", plugin => plugin.MandatoryInfos).ToList();
}
public static IReadOnlyList<DataIntroduction> GetIntroductions()
{
return RUNNING_PLUGINS
.OfType<PluginConfiguration>()
.SelectMany(plugin => plugin.Introductions)
return ResolveLivePluginContent<DataIntroduction>("introduction", plugin => plugin.Introductions)
.OrderBy(introduction => introduction.Index)
.ThenBy(introduction => introduction.Id, StringComparer.Ordinal)
.ToList();
}
/// <summary>
/// Collects live content from all running configuration plugins, so that each content ID appears exactly once.
/// </summary>
/// <remarks>
/// The IDs of live content are chosen by whoever writes the configuration, so two configuration
/// plugins may use the same ID. We resolve such a collision the same way a collision on a setting
/// is resolved: a configuration which acts on behalf of the organization wins, so nobody can push
/// aside what an organization deployed. Among configurations of the same origin, the declared
/// priority decides, and when even that is equal, the plugin which started later wins.<br/><br/>
/// Duplicates are not merely a cosmetic problem: the home page keys its panels by the introduction
/// ID, and the acceptance of a mandatory info is stored per ID as well.
/// </remarks>
/// <param name="contentKind">The kind of content, used to report a collision in the log.</param>
/// <param name="selector">Selects the content of one configuration plugin.</param>
/// <typeparam name="T">The type of the live plugin content.</typeparam>
/// <returns>The content of all configuration plugins, with every ID resolved to one winner.</returns>
private static IEnumerable<T> ResolveLivePluginContent<T>(string contentKind, Func<PluginConfiguration, IEnumerable<T>> selector) where T : ILivePluginContent
{
var contentById = new Dictionary<string, (T Content, int Authority, int Priority)>(StringComparer.Ordinal);
foreach (var plugin in RUNNING_PLUGINS.OfType<PluginConfiguration>())
{
var authority = GetConfigurationAuthority(plugin.PluginPath);
foreach (var content in selector(plugin))
{
if (contentById.TryGetValue(content.Id, out var currentWinner))
{
//
// The candidate needs the higher authority to take over. Within the same
// authority, the higher priority wins, and an equal priority falls back to the
// start order, where the plugin processed later wins:
//
var isTakingOver = authority > currentWinner.Authority || (authority == currentWinner.Authority && plugin.Priority >= currentWinner.Priority);
var winnerPluginId = isTakingOver ? content.EnterpriseConfigurationPluginId : currentWinner.Content.EnterpriseConfigurationPluginId;
var ignoredPluginId = isTakingOver ? currentWinner.Content.EnterpriseConfigurationPluginId : content.EnterpriseConfigurationPluginId;
if (winnerPluginId == ignoredPluginId)
LOG.LogWarning($"The configuration plugin '{winnerPluginId}' defines the {contentKind} ID '{content.Id}' more than once. Using its last definition and ignoring the earlier one. Please use each ID only once.");
else
{
var reason = isTakingOver
? DescribeConfigurationPrecedence(authority, plugin.Priority, currentWinner.Authority, currentWinner.Priority)
: DescribeConfigurationPrecedence(currentWinner.Authority, currentWinner.Priority, authority, plugin.Priority);
LOG.LogWarning($"Multiple configuration plugins define the {contentKind} ID '{content.Id}'. Using the one from the configuration plugin '{winnerPluginId}' and ignoring the one from the configuration plugin '{ignoredPluginId}', because {reason}.");
}
if (!isTakingOver)
continue;
}
contentById[content.Id] = (content, authority, plugin.Priority);
}
}
return contentById.Values.Select(entry => entry.Content);
}
/// <summary>
/// Explains in one phrase why one configuration plugin won a collision against another.
/// </summary>
/// <remarks>
/// Administrators read this in the log while they are testing their configuration. Naming the
/// deciding rule saves them from guessing why their change had no effect.
/// </remarks>
private static string DescribeConfigurationPrecedence(int winnerAuthority, int winnerPriority, int ignoredAuthority, int ignoredPriority)
{
if (winnerAuthority != ignoredAuthority)
return "a configuration which acts on behalf of your organization takes precedence over a locally placed one";
if (winnerPriority != ignoredPriority)
return $"it declares the higher priority ({winnerPriority} instead of {ignoredPriority})";
return $"both declare the same priority ({winnerPriority}), so the configuration plugin which started later wins";
}
}
@@ -14,10 +14,17 @@ namespace AIStudio.Tools.PluginSystem;
/// Loading other modules outside the plugin directory is not allowed.
/// </remarks>
/// <param name="pluginDirectory">The directory where the plugin is located.</param>
public sealed class PluginLoader(string pluginDirectory) : ILuaModuleLoader
/// <param name="allowedBaseDirectory">
/// The directory the plugin directory must be nested in. Without it, the installed plugins directory
/// is used. Validating a plugin before its installation needs this, because the plugin is not
/// installed yet and lives in a staging directory outside the installed plugins directory.
/// </param>
public sealed class PluginLoader(string pluginDirectory, string? allowedBaseDirectory = null) : ILuaModuleLoader
{
private static readonly string PLUGIN_BASE_PATH = Path.Join(SettingsManager.DataDirectory, "plugins");
private readonly string baseDirectory = string.IsNullOrWhiteSpace(allowedBaseDirectory) ? PLUGIN_BASE_PATH : allowedBaseDirectory;
#region Implementation of ILuaModuleLoader
/// <inheritdoc />
@@ -26,11 +33,11 @@ public sealed class PluginLoader(string pluginDirectory) : ILuaModuleLoader
// Ensure that the user doesn't try to escape the plugin directory:
if (moduleName.Contains("..") || pluginDirectory.Contains(".."))
return false;
// Ensure that the plugin directory is nested in the plugin base path:
if (!pluginDirectory.StartsWith(PLUGIN_BASE_PATH, StringComparison.OrdinalIgnoreCase))
// Ensure that the plugin directory is nested in the allowed base directory:
if (!pluginDirectory.StartsWith(this.baseDirectory, StringComparison.OrdinalIgnoreCase))
return false;
var path = Path.Join(pluginDirectory, $"{moduleName}.lua");
return File.Exists(path);
}
@@ -40,7 +47,7 @@ public sealed class PluginLoader(string pluginDirectory) : ILuaModuleLoader
{
var path = Path.Join(pluginDirectory, $"{moduleName}.lua");
var code = await File.ReadAllTextAsync(path, Encoding.UTF8, cancellationToken);
return new(moduleName, code);
}
@@ -1,11 +1,11 @@
namespace AIStudio.Tools.PluginSystem;
public sealed class PluginMetadata(PluginBase plugin, string localPath, bool isManagedByConfigServer = false, Guid? managedConfigurationId = null) : IAvailablePlugin
public sealed class PluginMetadata(PluginBase plugin, string localPath, bool isManagedByConfigServer = false, Guid? managedConfigurationId = null, int configurationPriority = 0) : IAvailablePlugin
{
#region Implementation of IPluginMetadata
/// <inheritdoc />
public string IconSVG { get; } = plugin.IconSVG;
public string IconDataUrl { get; } = plugin.IconDataUrl;
/// <inheritdoc />
public PluginType Type { get; } = plugin.Type;
@@ -53,8 +53,11 @@ public sealed class PluginMetadata(PluginBase plugin, string localPath, bool isM
public string LocalPath { get; } = localPath;
public bool IsManagedByConfigServer { get; } = isManagedByConfigServer;
public Guid? ManagedConfigurationId { get; } = managedConfigurationId;
/// <inheritdoc />
public int ConfigurationPriority { get; } = configurationPriority;
#endregion
}