From 386ac8f02ad1ba91a93f7c91a7aa8753f7c4d821 Mon Sep 17 00:00:00 2001 From: nilsk Date: Thu, 2 Jul 2026 00:13:16 +0200 Subject: [PATCH] added a stepper to interactively show all the processes from validating and installing to automatic security audits --- .../Assistants/Builder/AssistantBuilder.razor | 184 ++++++++++- .../Builder/AssistantBuilder.razor.cs | 302 +++++++++++++++--- .../Services/AssistantPluginInstallService.cs | 168 +++++++--- 3 files changed, 566 insertions(+), 88 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/Builder/AssistantBuilder.razor b/app/MindWork AI Studio/Assistants/Builder/AssistantBuilder.razor index 14407d4f..adc84ac3 100644 --- a/app/MindWork AI Studio/Assistants/Builder/AssistantBuilder.razor +++ b/app/MindWork AI Studio/Assistants/Builder/AssistantBuilder.razor @@ -1,4 +1,5 @@ @attribute [Route(Routes.ASSISTANT_META_ASSISTANT)] +@using AIStudio.Agents.AssistantAudit @using AIStudio.Tools.PluginSystem.Assistants.DataModel @inherits AssistantBaseCore @@ -38,7 +39,7 @@ - @this.highPerformanceLLMInfo + @this.HighPerformanceLLMInfo } else { @@ -71,11 +72,188 @@ else - @this.highPerformanceLLMInfo + @this.HighPerformanceLLMInfo } @code { - private protected override RenderFragment? AfterSubmitContent => @ + private protected override RenderFragment? BelowSubmitContent => this.step is BuilderStep.DONE && !string.IsNullOrWhiteSpace(this.generatedLuaAssistant) + ? @ + + + +
+ + + @T("Generated Lua plugin") + +
+
+ + + +
+
+ + + + + + @if (this.isCheckingPlugin) + { + + @T("Validating the generated assistant...") + } + else if (this.IsInstallStepFailed(BuilderInstallStep.CHECK_PLUGIN)) + { + + @T("The generated assistant could not be checked.") + @if (!string.IsNullOrWhiteSpace(this.installFlowIssue)) + { + @string.Format(T("Issue: {0}"), this.installFlowIssue) + } + + } + else if (this.PluginCheckCompleted) + { + + @string.Format(T("The generated assistant \"{0}\" is valid and runnable."), this.pluginCheckResult?.PluginName ?? T("Unknown assistant")) + + } + else + { + + @T("Validate generated assistant") + + } + + + + + + @if (this.isInstallingPlugin) + { + + @T("Installing the assistant...") + } + else if (this.IsInstallStepFailed(BuilderInstallStep.INSTALL_ASSISTANT)) + { + + @T("The assistant could not be installed.") + @if (!string.IsNullOrWhiteSpace(this.installFlowIssue)) + { + @string.Format(T("Issue: {0}"), this.installFlowIssue) + } + + } + else if (this.PluginInstallCompleted) + { + + @(this.pluginInstallResult?.ReplacedExisting is true + ? string.Format(T("The assistant \"{0}\" was updated."), this.pluginInstallResult?.PluginName ?? T("Unknown assistant")) + : string.Format(T("The assistant \"{0}\" was installed."), this.pluginInstallResult?.PluginName ?? T("Unknown assistant"))) + + } + else + { + + @T("Install assistant") + + } + + + + + + @if (this.isAuditingPlugin) + { + + @T("Auditing assistants safety...") + } + else if (this.IsInstallStepFailed(BuilderInstallStep.SECURITY_CHECK)) + { + + @T("The security audit could not be completed.") + @if (!string.IsNullOrWhiteSpace(this.installFlowIssue)) + { + @string.Format(T("Issue: {0}"), this.installFlowIssue) + } + + } + else if (this.AuditCompleted) + { + + @this.pluginAudit.Level.GetName(): @this.pluginAudit.Summary + + } + else + { + + @T("Start security audit") + + } + + + + + + @if (this.isEnablingPlugin) + { + + @T("Enabling the assistant...") + } + else if (this.IsInstallStepFailed(BuilderInstallStep.ENABLE_ASSISTANT)) + { + + @T("The assistant cannot be enabled.") + @if (!string.IsNullOrWhiteSpace(this.installFlowIssue)) + { + @string.Format(T("Issue: {0}"), this.installFlowIssue) + } + + } + else if (this.EnableCompleted) + { + + @T("The assistant is enabled.") + + } + else + { + @if (this.RequiresActivationConfirmation) + { + + @T("The security check is below your required level. Your settings allow activation after confirmation.") + + } + + @T("Enable assistant") + + } + + + + + + @if (this.CanOpenAssistant) + { + + @T("Open assistant") + + } + else + { + @T("Enable the assistant before opening it.") + } + + + + + + +
+ : null; + + private protected override RenderFragment AfterSubmitContent => @ diff --git a/app/MindWork AI Studio/Assistants/Builder/AssistantBuilder.razor.cs b/app/MindWork AI Studio/Assistants/Builder/AssistantBuilder.razor.cs index 55087fb8..900a323c 100644 --- a/app/MindWork AI Studio/Assistants/Builder/AssistantBuilder.razor.cs +++ b/app/MindWork AI Studio/Assistants/Builder/AssistantBuilder.razor.cs @@ -4,9 +4,11 @@ using System.Reflection; // ReSharper restore RedundantUsingDirective using System.Text; using System.Text.Json; -using AIStudio.Chat; +using AIStudio.Agents.AssistantAudit; using AIStudio.Dialogs; using AIStudio.Dialogs.Settings; +using AIStudio.Tools.PluginSystem; +using AIStudio.Tools.PluginSystem.Assistants; using AIStudio.Tools.PluginSystem.Assistants.DataModel; using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; @@ -22,6 +24,9 @@ public partial class AssistantBuilder : AssistantBaseCore [Inject] private AssistantPluginInstallService AssistantPluginInstallService { get; init; } = null!; + [Inject] + private AssistantPluginAuditService AssistantPluginAuditService { get; init; } = null!; + private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(nameof(AssistantBuilder)); private static readonly JsonSerializerOptions UNTRUSTED_PROMPT_JSON_OPTIONS = new() { @@ -63,15 +68,12 @@ public partial class AssistantBuilder : AssistantBaseCore BuilderStep.DONE => this.GenerateLuaAssistant, _ => this.GenerateAssistantSpec, }; - protected override bool SubmitDisabled => this.isAgentRunning || this.isInstallingPlugin; - protected override bool ShowResult => this.step is BuilderStep.DONE; - protected override bool ShowEntireChatThread => this.step is BuilderStep.DONE; + protected override bool SubmitDisabled => this.isAgentRunning || this.IsInstallFlowRunning; + protected override bool ShowResult => false; + protected override bool ShowEntireChatThread => false; protected override bool AllowProfiles => false; protected override bool ShowProfileSelection => false; protected override bool ShowCopyResult => this.step is BuilderStep.DONE; - protected override IReadOnlyList FooterButtons => this.step is BuilderStep.DONE - ? [new ButtonData(T("Install assistant"), Icons.Material.Filled.Extension, Color.Primary, T("Install this generated assistant as a plugin."), this.InstallPluginAsync, () => this.isAgentRunning || this.isInstallingPlugin || string.IsNullOrWhiteSpace(this.generatedLuaAssistant))] - : []; protected override bool HasSettingsPanel => false; protected override Func Result2Copy => () => !string.IsNullOrWhiteSpace(this.generatedLuaAssistant) @@ -80,7 +82,10 @@ public partial class AssistantBuilder : AssistantBaseCore private BuilderStep step = BuilderStep.DESCRIBE; private bool isAgentRunning; + private bool isCheckingPlugin; private bool isInstallingPlugin; + private bool isAuditingPlugin; + private bool isEnablingPlugin; private string assistantDescription = string.Empty; private AssistantCategory selectedCategory; private string customCategory = string.Empty; @@ -97,8 +102,14 @@ public partial class AssistantBuilder : AssistantBaseCore private string reviewNotes = string.Empty; private string generatedLuaAssistant = string.Empty; private Guid pluginId = Guid.NewGuid(); - - private string highPerformanceLLMInfo => T("It is recommended to a powerful LLM."); + private string HighPerformanceLLMInfo => T("It is recommended to a powerful LLM."); + private int stepperIndex; + private AssistantPluginCheckResult? pluginCheckResult; + private AssistantPluginInstallResult? pluginInstallResult; + private PluginAssistantAudit? pluginAudit; + private PluginAssistants? installedAssistantPlugin; + private BuilderInstallStep? failedInstallStep; + private string installFlowIssue = string.Empty; private static readonly AssistantContextFile[] ASSISTANT_CONTEXT_FILES = [ new("Assistant plugin schema", "Plugins/assistants/README.md", IsRequired: true), @@ -113,6 +124,56 @@ public partial class AssistantBuilder : AssistantBaseCore REVIEW_SPEC, DONE, } + + private enum BuilderInstallStep + { + CHECK_PLUGIN = 0, + INSTALL_ASSISTANT = 1, + SECURITY_CHECK = 2, + ENABLE_ASSISTANT = 3, + OPEN_ASSISTANT = 4, + } + + private bool IsInstallFlowRunning => this.isCheckingPlugin || this.isInstallingPlugin || this.isAuditingPlugin || this.isEnablingPlugin; + + private bool PluginCheckCompleted => this.pluginCheckResult?.Success is true; + + private bool PluginInstallCompleted => this.pluginInstallResult?.Success is true; + + private bool AuditCompleted => this.pluginAudit is not null && this.pluginAudit.Level is not AssistantAuditLevel.UNKNOWN; + + private bool AuditRequiredForActivation => this.SettingsManager.ConfigurationData.AssistantPluginAudit.RequireAuditBeforeActivation; + + private bool EnableCompleted => this.pluginInstallResult is not null && this.SettingsManager.ConfigurationData.EnabledPlugins.Contains(this.pluginInstallResult.PluginId); + + private bool CanRunPluginCheck => !this.IsInstallFlowRunning && !string.IsNullOrWhiteSpace(this.generatedLuaAssistant); + + private bool CanInstallPlugin => !this.IsInstallFlowRunning && this.PluginCheckCompleted; + + private bool CanRunAudit => !this.IsInstallFlowRunning && this.PluginInstallCompleted && this.installedAssistantPlugin is not null; + + private bool CanEnableAssistant => !this.IsInstallFlowRunning && this.PluginInstallCompleted && !this.IsActivationBlockedBySettings; + + private bool CanOpenAssistant => this.EnableCompleted && this.pluginInstallResult is not null; + + private bool IsAuditBelowMinimum => this.pluginAudit is not null && this.pluginAudit.Level < this.SettingsManager.ConfigurationData.AssistantPluginAudit.MinimumLevel; + + private bool IsActivationBlockedBySettings => this.AuditRequiredForActivation && + (!this.AuditCompleted || + this.IsAuditBelowMinimum && this.SettingsManager.ConfigurationData.AssistantPluginAudit.BlockActivationBelowMinimum); + + private bool RequiresActivationConfirmation => this.AuditCompleted && + this.IsAuditBelowMinimum && + !this.IsActivationBlockedBySettings; + + private Severity AuditSeverity => this.pluginAudit?.Level switch + { + AssistantAuditLevel.DANGEROUS => Severity.Error, + AssistantAuditLevel.CAUTION => Severity.Warning, + AssistantAuditLevel.SAFE => Severity.Info, + _ => Severity.Normal, + }; + private static readonly AssistantComponentType[] ASSISTANT_COMPONENT_OPTIONS = [ AssistantComponentType.TEXT_AREA, @@ -145,6 +206,7 @@ public partial class AssistantBuilder : AssistantBaseCore this.generatedAssistantSpec = string.Empty; this.reviewNotes = string.Empty; this.generatedLuaAssistant = string.Empty; + this.ResetInstallFlow(); } protected override bool MightPreselectValues() => false; @@ -240,8 +302,8 @@ public partial class AssistantBuilder : AssistantBaseCore return; } + this.ResetInstallFlow(); this.generatedLuaAssistant = parsedResponse.FullLua.Trim(); - this.AddGeneratedLuaPreviewResult(); this.step = BuilderStep.DONE; } finally @@ -254,12 +316,14 @@ public partial class AssistantBuilder : AssistantBaseCore { this.step = BuilderStep.DESCRIBE; this.generatedLuaAssistant = string.Empty; + this.ResetInstallFlow(); } private void BackToSpecReview() { this.step = BuilderStep.REVIEW_SPEC; this.generatedLuaAssistant = string.Empty; + this.ResetInstallFlow(); } private async Task EditDraftAndDiscardPluginPreview() @@ -291,6 +355,7 @@ public partial class AssistantBuilder : AssistantBaseCore return; this.generatedLuaAssistant = string.Empty; + this.ResetInstallFlow(); this.step = BuilderStep.REVIEW_SPEC; } @@ -353,7 +418,8 @@ public partial class AssistantBuilder : AssistantBaseCore - The future Lua plugin must be loadable by AI Studio. - Include assumptions instead of asking follow-up questions. - Treat filled optional guidance as explicit user intent. - - Keep technical identifiers untranslated, such as TEXT_AREA, DROPDOWN, PROVIDER_SELECTION, PROFILE_SELECTION, BuildPrompt, and plugin.lua. + - Do not mention the PROVIDER_SELECTION or the submit button in the ## {{T("UI Components")}} section as they are mandatory anyway. + - Keep technical identifiers untranslated, such as TEXT_AREA, DROPDOWN, PROFILE_SELECTION, BuildPrompt, and plugin.lua. - Exception: Do not use technical identifiers in the "{{T("Inputs")}}" section, it should be easy comprehensible what the usual user input will be """; @@ -504,28 +570,71 @@ public partial class AssistantBuilder : AssistantBaseCore return string.Empty; } - private async Task InstallPluginAsync() + private async Task CheckGeneratedAssistantAsync() { if (string.IsNullOrWhiteSpace(this.generatedLuaAssistant)) { - this.Snackbar.Add(T("No assistant plugin was generated yet."), Severity.Warning); + await this.MessageBus.SendError(new(Icons.Material.Filled.Extension, T("No assistant plugin was generated yet."))); return; } + this.ResetInstallFlow(); + this.stepperIndex = (int)BuilderInstallStep.CHECK_PLUGIN; + this.isCheckingPlugin = true; + try + { + var result = await this.AssistantPluginInstallService.CheckInstallabilityAsync(this.generatedLuaAssistant, CancellationToken.None); + this.pluginCheckResult = result; + if (!result.Success) + { + this.FailInstallStep(BuilderInstallStep.CHECK_PLUGIN, result.Issue); + await this.MessageBus.SendError(new(Icons.Material.Filled.ReportProblem, T("The generated assistant could not be checked."))); + return; + } + + await this.MessageBus.SendSuccess(new(Icons.Material.Filled.CheckCircle, T("The generated assistant can be installed."))); + this.stepperIndex = (int)BuilderInstallStep.INSTALL_ASSISTANT; + } + finally + { + this.isCheckingPlugin = false; + await this.InvokeAsync(this.StateHasChanged); + } + } + + private async Task InstallGeneratedAssistantAsync() + { + if (!this.PluginCheckCompleted) + return; + + this.ClearInstallStepIssue(); + this.stepperIndex = (int)BuilderInstallStep.INSTALL_ASSISTANT; this.isInstallingPlugin = true; try { var result = await this.AssistantPluginInstallService.InstallAsync(this.generatedLuaAssistant, CancellationToken.None); + this.pluginInstallResult = result; if (!result.Success) { - this.Snackbar.Add(result.Issue, Severity.Error); + this.FailInstallStep(BuilderInstallStep.INSTALL_ASSISTANT, result.Issue); + await this.MessageBus.SendError(new(Icons.Material.Filled.ReportProblem, T("The assistant could not be installed."))); return; } - var message = result.ReplacedExisting - ? string.Format(T("The assistant plugin \"{0}\" was updated."), result.PluginName) - : string.Format(T("The assistant plugin \"{0}\" was installed."), result.PluginName); - this.Snackbar.Add(message, Severity.Success); + this.installedAssistantPlugin = ResolveAssistantPlugin(result.PluginId); + if (this.installedAssistantPlugin is null) + { + this.FailInstallStep(BuilderInstallStep.INSTALL_ASSISTANT, T("The installed assistant could not be loaded.")); + await this.MessageBus.SendError(new(Icons.Material.Filled.ReportProblem, T("The installed assistant could not be loaded."))); + return; + } + + await this.MessageBus.SendSuccess(new(Icons.Material.Filled.Extension, result.ReplacedExisting ? T("Assistant updated.") : T("Assistant installed."))); + this.stepperIndex = this.AuditRequiredForActivation + ? (int)BuilderInstallStep.SECURITY_CHECK + : this.EnableCompleted + ? (int)BuilderInstallStep.OPEN_ASSISTANT + : (int)BuilderInstallStep.ENABLE_ASSISTANT; } finally { @@ -534,49 +643,146 @@ public partial class AssistantBuilder : AssistantBaseCore } } - private static string CreateLuaCodeFence(string lua) + private async Task RunSecurityCheckAsync() { - var fenceLength = Math.Max(3, GetLongestBacktickRun(lua) + 1); - var fence = new string('`', fenceLength); - return $""" - {fence}lua - {lua.Trim()} - {fence} - """; - } + if (this.installedAssistantPlugin is null) + return; - private static int GetLongestBacktickRun(string text) - { - var longestRun = 0; - var currentRun = 0; - - foreach (var character in text) + this.ClearInstallStepIssue(); + this.stepperIndex = (int)BuilderInstallStep.SECURITY_CHECK; + this.isAuditingPlugin = true; + try { - if (character is '`') + this.pluginAudit = await this.AssistantPluginAuditService.RunAuditAsync(this.installedAssistantPlugin); + if (this.pluginAudit.Level is AssistantAuditLevel.UNKNOWN) { - currentRun++; - longestRun = Math.Max(longestRun, currentRun); - continue; + this.FailInstallStep(BuilderInstallStep.SECURITY_CHECK, T("The security check could not determine a result.")); + await this.MessageBus.SendError(new(Icons.Material.Filled.GppMaybe, T("The security check could not be completed."))); + return; } - currentRun = 0; - } + this.UpsertAudit(this.pluginAudit); + await this.SettingsManager.StoreSettings(); + await this.MessageBus.SendSuccess(new( + this.pluginAudit.Level.GetIcon(), + this.pluginAudit.Findings.Count == 0 + ? T("Security check completed. No security issues were found.") + : T("Security check completed with findings."))); - return longestRun; + if (this.IsActivationBlockedBySettings) + { + this.stepperIndex = (int)BuilderInstallStep.ENABLE_ASSISTANT; + this.FailInstallStep(BuilderInstallStep.ENABLE_ASSISTANT, T("This assistant cannot be enabled because the security check is below your required level.")); + await this.MessageBus.SendError(new(Icons.Material.Filled.Block, T("The assistant cannot be enabled because it is below your required security level."))); + return; + } + + this.stepperIndex = this.EnableCompleted + ? (int)BuilderInstallStep.OPEN_ASSISTANT + : (int)BuilderInstallStep.ENABLE_ASSISTANT; + } + finally + { + this.isAuditingPlugin = false; + await this.InvokeAsync(this.StateHasChanged); + } } - private void AddGeneratedLuaPreviewResult() + private async Task EnableInstalledAssistantAsync() { - this.ChatThread?.Blocks.Add(new() + if (this.pluginInstallResult is null || this.IsActivationBlockedBySettings) + return; + + if (this.RequiresActivationConfirmation && !await this.ConfirmActivationBelowMinimumAsync()) + return; + + this.ClearInstallStepIssue(); + this.stepperIndex = (int)BuilderInstallStep.ENABLE_ASSISTANT; + this.isEnablingPlugin = true; + try + { + if (!this.SettingsManager.ConfigurationData.EnabledPlugins.Contains(this.pluginInstallResult.PluginId)) + this.SettingsManager.ConfigurationData.EnabledPlugins.Add(this.pluginInstallResult.PluginId); + + await this.SettingsManager.StoreSettings(); + await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); + await this.MessageBus.SendSuccess(new(Icons.Material.Filled.ToggleOn, T("Assistant enabled."))); + this.stepperIndex = (int)BuilderInstallStep.OPEN_ASSISTANT; + } + finally + { + this.isEnablingPlugin = false; + await this.InvokeAsync(this.StateHasChanged); + } + } + + private async Task ConfirmActivationBelowMinimumAsync() + { + var dialogParameters = new DialogParameters { - Time = DateTimeOffset.Now, - ContentType = ContentType.TEXT, - Role = ChatRole.AI, - Content = new ContentText { - Text = CreateLuaCodeFence(this.generatedLuaAssistant), + x => x.Message, + string.Format( + T("The assistant \"{0}\" was checked with the level \"{1}\", which is below your required level \"{2}\". Your settings allow activation anyway, but this may be unsafe. Do you want to enable this assistant?"), + this.pluginInstallResult?.PluginName ?? T("Unknown assistant"), + this.pluginAudit?.Level.GetName() ?? T("Unknown"), + this.SettingsManager.ConfigurationData.AssistantPluginAudit.MinimumLevel.GetName()) }, - }); + }; + + var dialogReference = await this.DialogService.ShowAsync(T("Potentially Unsafe Assistant"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + return dialogResult is not null && !dialogResult.Canceled; + } + + private void OpenInstalledAssistant() + { + if (this.pluginInstallResult is null) + return; + + this.NavigationManager.NavigateTo($"{Routes.ASSISTANT_DYNAMIC}?assistantId={this.pluginInstallResult.PluginId}"); + } + + private static PluginAssistants? ResolveAssistantPlugin(Guid pluginId) => PluginFactory.RunningPlugins.OfType().FirstOrDefault(plugin => plugin.Id == pluginId); + + private void UpsertAudit(PluginAssistantAudit audit) + { + var audits = this.SettingsManager.ConfigurationData.AssistantPluginAudits; + var existingIndex = audits.FindIndex(x => x.PluginId == audit.PluginId); + if (existingIndex >= 0) + audits[existingIndex] = audit; + else + audits.Add(audit); + } + + private void FailInstallStep(BuilderInstallStep installStep, string issue) + { + this.failedInstallStep = installStep; + this.installFlowIssue = issue; + this.stepperIndex = (int)installStep; + } + + private void ClearInstallStepIssue() + { + this.failedInstallStep = null; + this.installFlowIssue = string.Empty; + } + + private bool IsInstallStepFailed(BuilderInstallStep installStep) => this.failedInstallStep == installStep; + + private void ResetInstallFlow() + { + this.stepperIndex = (int)BuilderInstallStep.CHECK_PLUGIN; + this.isCheckingPlugin = false; + this.isInstallingPlugin = false; + this.isAuditingPlugin = false; + this.isEnablingPlugin = false; + this.pluginCheckResult = null; + this.pluginInstallResult = null; + this.pluginAudit = null; + this.installedAssistantPlugin = null; + this.failedInstallStep = null; + this.installFlowIssue = string.Empty; } private async Task LoadAssistantBuilderContextAsync() diff --git a/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs b/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs index b954093e..3b71cdaf 100644 --- a/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs +++ b/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs @@ -6,6 +6,7 @@ using AIStudio.Tools.PluginSystem.Assistants; namespace AIStudio.Tools.Services; public sealed record AssistantPluginInstallResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, bool ReplacedExisting, string Issue); +public sealed record AssistantPluginCheckResult(bool Success, Guid PluginId, string PluginName, string Issue); public sealed class AssistantPluginInstallService { @@ -17,6 +18,7 @@ public sealed class AssistantPluginInstallService private readonly SemaphoreSlim installSemaphore = new(1, 1); private static AssistantPluginInstallResult Error(string issue) => new(false, Guid.Empty, string.Empty, string.Empty, false, issue); + private static AssistantPluginCheckResult CheckError(string issue) => new(false, Guid.Empty, string.Empty, issue); public AssistantPluginInstallService(ILogger logger) { @@ -24,6 +26,43 @@ public sealed class AssistantPluginInstallService this.logger.LogInformation("The assistant plugin install service has been initialized."); } + /// + /// Checks whether generated Lua assistant plugin code can be loaded and installed. + /// The plugin is written to a temporary staging directory and validated through the + /// normal plugin loader, but it is not moved into the user plugin directory. + /// + /// The full generated plugin.lua content. + /// A cancellation token for file IO and Lua validation. + /// + /// Check result that contains success state, plugin metadata, and a user-facing issue when validation failed. + /// + public async Task CheckInstallabilityAsync(string lua, CancellationToken token) + { + if (!TryGetAssistantPluginsRoot(out var assistantPluginsRoot, out var rootIssue)) + return CheckError(rootIssue); + + await this.installSemaphore.WaitAsync(token); + var stagingDirectory = string.Empty; + try + { + var validation = await this.ValidateIntoStagingAsync(lua, token); + if (!validation.Success || validation.AssistantPlugin is null) + return CheckError(validation.Issue); + + stagingDirectory = validation.StagingDirectory; + var finalDirectory = DetermineFinalDirectory(assistantPluginsRoot, validation.AssistantPlugin); + if (!IsPathInsideDirectory(assistantPluginsRoot, finalDirectory)) + return CheckError("The resolved plugin directory is outside the assistant plugin directory."); + + return new(true, validation.AssistantPlugin.Id, validation.AssistantPlugin.Name, string.Empty); + } + finally + { + this.TryDeleteStagingDirectory(stagingDirectory); + this.installSemaphore.Release(); + } + } + /// /// Installs generated Lua assistant plugin code into the user plugin directory. /// Writes the plugin into a temporary staging directory first, validates it through the @@ -39,44 +78,27 @@ public sealed class AssistantPluginInstallService /// public async Task InstallAsync(string lua, CancellationToken token) { - if (string.IsNullOrWhiteSpace(lua)) - return Error("No Lua plugin code was generated."); - - var pluginCode = lua.Trim(); - if (!PluginFactory.IsInitialized) - return Error("The plugin system is not initialized yet."); - - var dataDirectory = SettingsManager.DataDirectory; - if (string.IsNullOrWhiteSpace(dataDirectory)) - return Error("The AI Studio data directory is not initialized yet."); + if (!TryGetAssistantPluginsRoot(out var assistantPluginsRoot, out var rootIssue)) + return Error(rootIssue); await this.installSemaphore.WaitAsync(token); + AssistantPluginValidationResult validation; try { - var assistantPluginsRoot = Path.Join(dataDirectory, "plugins", PluginType.ASSISTANT.GetDirectory()); + validation = await this.ValidateIntoStagingAsync(lua, token); + if (!validation.Success || validation.AssistantPlugin is null) + return Error(validation.Issue); + Directory.CreateDirectory(assistantPluginsRoot); - var stagingDirectory = Path.Join(Path.GetTempPath(), $"{ASSISTANT_BUILDER_DIRECTORY_PREFIX}.staging-{Guid.NewGuid():N}"); + var stagingDirectory = validation.StagingDirectory; + var assistantPlugin = validation.AssistantPlugin; string? backupDirectory = null; string? finalDirectory = null; var replacedExisting = false; try { - Directory.CreateDirectory(stagingDirectory); - var stagedPluginFile = Path.Join(stagingDirectory, PLUGIN_FILE_NAME); - await File.WriteAllTextAsync(stagedPluginFile, pluginCode, Encoding.UTF8, token); - - var plugin = await PluginFactory.Load(stagingDirectory, pluginCode, token); - if (plugin is not PluginAssistants assistantPlugin) - return Error($"The generated plugin is not an assistant plugin. Issue: {string.Join("; ", plugin.Issues)}"); - - if (!assistantPlugin.IsValid) - return Error($"The generated assistant plugin is invalid. Issue: {string.Join("; ", assistantPlugin.Issues)}"); - - if (PluginFactory.AvailablePlugins.Any(plugin => plugin.Type is PluginType.ASSISTANT && plugin.Id == assistantPlugin.Id && plugin.IsInternal)) - return Error("The generated assistant plugin uses the ID of an internal AI Studio plugin."); - finalDirectory = DetermineFinalDirectory(assistantPluginsRoot, assistantPlugin); if (!IsPathInsideDirectory(assistantPluginsRoot, finalDirectory)) return Error("The resolved plugin directory is outside the assistant plugin directory."); @@ -125,17 +147,7 @@ public sealed class AssistantPluginInstallService } finally { - if (Directory.Exists(stagingDirectory)) - { - try - { - Directory.Delete(stagingDirectory, true); - } - catch (Exception e) - { - this.logger.LogError(e, "Failed to delete assistant plugin staging directory '{StagingDirectory}'.", stagingDirectory); - } - } + this.TryDeleteStagingDirectory(stagingDirectory); } } finally @@ -143,6 +155,83 @@ public sealed class AssistantPluginInstallService this.installSemaphore.Release(); } } + + private async Task ValidateIntoStagingAsync(string lua, CancellationToken token) + { + if (string.IsNullOrWhiteSpace(lua)) + return AssistantPluginValidationResult.Error("No Lua plugin code was generated."); + + if (!PluginFactory.IsInitialized) + return AssistantPluginValidationResult.Error("The plugin system is not initialized yet."); + + var pluginCode = lua.Trim(); + var stagingDirectory = Path.Join(Path.GetTempPath(), $"{ASSISTANT_BUILDER_DIRECTORY_PREFIX}.staging-{Guid.NewGuid():N}"); + + try + { + Directory.CreateDirectory(stagingDirectory); + var stagedPluginFile = Path.Join(stagingDirectory, PLUGIN_FILE_NAME); + await File.WriteAllTextAsync(stagedPluginFile, pluginCode, Encoding.UTF8, token); + + var plugin = await PluginFactory.Load(stagingDirectory, pluginCode, token); + if (plugin is not PluginAssistants assistantPlugin) + { + this.TryDeleteStagingDirectory(stagingDirectory); + return AssistantPluginValidationResult.Error($"The generated plugin is not an assistant plugin. Issue: {string.Join("; ", plugin.Issues)}"); + } + + if (!assistantPlugin.IsValid) + { + this.TryDeleteStagingDirectory(stagingDirectory); + return AssistantPluginValidationResult.Error($"The generated assistant plugin is invalid. Issue: {string.Join("; ", assistantPlugin.Issues)}"); + } + + if (PluginFactory.AvailablePlugins.Any(plugin => plugin.Type is PluginType.ASSISTANT && plugin.Id == assistantPlugin.Id && plugin.IsInternal)) + { + this.TryDeleteStagingDirectory(stagingDirectory); + return AssistantPluginValidationResult.Error("The generated assistant plugin uses the ID of an internal AI Studio plugin."); + } + + return new(true, stagingDirectory, assistantPlugin, string.Empty); + } + catch (Exception e) + { + this.logger.LogError(e, "Failed to validate generated assistant plugin."); + this.TryDeleteStagingDirectory(stagingDirectory); + return AssistantPluginValidationResult.Error(e.Message); + } + } + + private static bool TryGetAssistantPluginsRoot(out string assistantPluginsRoot, out string issue) + { + assistantPluginsRoot = string.Empty; + issue = string.Empty; + + var dataDirectory = SettingsManager.DataDirectory; + if (string.IsNullOrWhiteSpace(dataDirectory)) + { + issue = "The AI Studio data directory is not initialized yet."; + return false; + } + + assistantPluginsRoot = Path.Join(dataDirectory, "plugins", PluginType.ASSISTANT.GetDirectory()); + return true; + } + + private void TryDeleteStagingDirectory(string stagingDirectory) + { + if (!Directory.Exists(stagingDirectory)) + return; + + try + { + Directory.Delete(stagingDirectory, true); + } + catch (Exception e) + { + this.logger.LogError(e, "Failed to delete assistant plugin staging directory '{StagingDirectory}'.", stagingDirectory); + } + } private static string DetermineFinalDirectory(string assistantPluginsRoot, PluginAssistants assistantPlugin) { @@ -206,4 +295,9 @@ public sealed class AssistantPluginInstallService var childPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; return childPath.StartsWith(parentPath, StringComparison.OrdinalIgnoreCase); } + + private sealed record AssistantPluginValidationResult(bool Success, string StagingDirectory, PluginAssistants? AssistantPlugin, string Issue) + { + public static AssistantPluginValidationResult Error(string issue) => new(false, string.Empty, null, issue); + } }