Added export and import of plugin archives (#885)
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
nilskruthoffandThorsten Sommer authored and GitHub committed 2026-08-06 10:42:20 +02:00
1 parent e7407ce60a
commit 8a9e6aeb87
43 files changed
+2041 -256

No files matched your search

@@ -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;
}
}
@@ -179,6 +179,12 @@ 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 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);
@@ -1,4 +1,3 @@
using System.IO.Compression;
using System.Net.Http.Headers;
namespace AIStudio.Tools.PluginSystem;
@@ -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;
}
}
}
@@ -265,6 +265,14 @@ public static partial class PluginFactory
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.AllowUserToAddProvider, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the plugin import permission:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.AllowUserToImportPlugins, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the plugin sharing permission:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.AllowUserToSharePlugins, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for admin settings visibility:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShowAdminSettings, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
@@ -377,16 +385,24 @@ public static partial class PluginFactory
}
}
public static async Task<PluginBase> Load(string? pluginPath, string code, CancellationToken cancellationToken = default)
/// <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="cancellationToken">Cancellation token for running the Lua code.</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>
public static async Task<PluginBase> Load(string? pluginPath, string code, CancellationToken cancellationToken = default, string? allowedBaseDirectory = null)
{
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:
@@ -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,4 +1,5 @@
using AIStudio.Tools.PluginSystem;
// ReSharper disable MemberCanBePrivate.Global
namespace AIStudio.Tools.Rust;
@@ -81,6 +82,7 @@ public static class FileTypes
// Other standalone types
public static readonly FileTypeFilter CERTIFICATE_BUNDLE = FileTypeFilter.Leaf(TB("Certificate bundle"), "pem", "crt", "cer");
public static readonly FileTypeFilter EXECUTABLES = FileTypeFilter.Leaf(TB("Executable"), "exe", "app", "bin", "appimage");
public static readonly FileTypeFilter PLUGIN_ARCHIVE = FileTypeFilter.Leaf(TB("Plugin archive"), PluginArchive.PLUGIN_FILE_EXTENSION.TrimStart('.'), "zip");
public static FileTypeFilter? AsOneFileType(params FileTypeFilter[]? types)
{
@@ -0,0 +1,3 @@
namespace AIStudio.Tools.Services;
public sealed record AssistantPluginCheckResult(bool Success, Guid PluginId, string PluginName, string Issue);
@@ -0,0 +1,3 @@
namespace AIStudio.Tools.Services;
public sealed record AssistantPluginDeleteResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, string Issue);
@@ -0,0 +1,3 @@
namespace AIStudio.Tools.Services;
public sealed record AssistantPluginInstallResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, bool ReplacedExisting, string Issue, bool Cancelled = false);
@@ -4,17 +4,10 @@ using AIStudio.Tools.AssistantSessions;
using AIStudio.Tools.Media;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.PluginSystem.Assistants;
using AIStudio.Tools.Rust;
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 record AssistantPluginDeleteResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, string Issue);
public sealed record AssistantPluginUpdateResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, string Issue);
public sealed class AssistantPluginInstallService
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(AssistantPluginInstallService).Namespace, nameof(AssistantPluginInstallService));
@@ -22,6 +15,7 @@ public sealed class AssistantPluginInstallService
private const string PLUGIN_FILE_NAME = "plugin.lua";
private const string ASSISTANT_BUILDER_DIRECTORY_PREFIX = "assistant-builder";
private const string DELETE_BACKUP_DIRECTORY = ".plugin-delete-backups";
private const string INSTALL_BACKUP_DIRECTORY = ".plugin-install-backups";
private const int DIRECTORY_PREFIX_MAX_LEN = 80;
private readonly ILogger<AssistantPluginInstallService> logger;
@@ -31,6 +25,8 @@ 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 AssistantPluginInstallResult CancelledByUser() => new(false, Guid.Empty, string.Empty, string.Empty, false, string.Empty, true);
private static AssistantPluginCheckResult CheckError(string issue) => new(false, Guid.Empty, string.Empty, issue);
@@ -38,11 +34,7 @@ public sealed class AssistantPluginInstallService
private static AssistantPluginUpdateResult UpdateError(IPluginMetadata plugin, string pluginDirectory, string issue) => new(false, plugin.Id, plugin.Name, pluginDirectory, issue);
public AssistantPluginInstallService(
ILogger<AssistantPluginInstallService> logger,
SettingsManager settingsManager,
AssistantSessionService assistantSessionService,
MediaTranscriptionService mediaTranscriptionService)
public AssistantPluginInstallService(ILogger<AssistantPluginInstallService> logger, SettingsManager settingsManager, AssistantSessionService assistantSessionService, MediaTranscriptionService mediaTranscriptionService)
{
this.logger = logger;
this.settingsManager = settingsManager;
@@ -127,76 +119,104 @@ public sealed class AssistantPluginInstallService
return Error(rootIssue);
await this.installSemaphore.WaitAsync(token);
AssistantPluginValidationResult validation;
try
{
validation = await this.ValidateIntoStagingAsync(lua, token);
var validation = await this.ValidateIntoStagingAsync(lua, token);
if (!validation.Success || validation.AssistantPlugin is null)
return Error(validation.Issue);
Directory.CreateDirectory(assistantPluginsRoot);
return await this.InstallStagedAssistantAsync(assistantPluginsRoot, validation, token);
}
finally
{
this.installSemaphore.Release();
}
}
var stagingDirectory = validation.StagingDirectory;
var assistantPlugin = validation.AssistantPlugin;
string? backupDirectory = null;
string? finalDirectory = null;
var replacedExisting = false;
/// <summary>
/// Installs an assistant plugin archive that contains exactly one <c>plugin.lua</c> file.
/// Companion files are validated from and moved with the same staging directory.
/// </summary>
/// <param name="archivePath">The local <c>.mwplugin</c> or <c>.zip</c> archive path.</param>
/// <param name="confirmAsync">
/// Asks the user whether the validated archive may be installed. It is called after all checks
/// passed and before anything gets written. Returning false aborts the installation.
/// </param>
/// <param name="token">Cancellation token for extraction, validation, file IO, and plugin reload.</param>
/// <returns>Installation result that contains success state, installed plugin metadata, and a user-facing issue when installation failed.</returns>
public async Task<AssistantPluginInstallResult> InstallArchiveAsync(string archivePath, Func<PluginImportPreview, Task<bool>> confirmAsync, CancellationToken token)
{
if (!this.settingsManager.ConfigurationData.App.AllowUserToImportPlugins)
return Error(TB("Your organization has disabled importing plugins."));
if (!FileTypes.IsAllowedPath(archivePath, FileTypes.PLUGIN_ARCHIVE))
return Error(TB("Please select a plugin archive with the extension .mwplugin or .zip."));
if (!File.Exists(archivePath))
return Error(TB("The selected plugin archive does not exist."));
if (!TryGetAssistantPluginsRoot(out var assistantPluginsRoot, out var rootIssue))
return Error(rootIssue);
if (!PluginFactory.IsInitialized)
return Error(TB("The plugin system is not initialized yet."));
await this.installSemaphore.WaitAsync(token);
var stagingDirectory = Path.Join(Path.GetTempPath(), $"assistant-plugin-import.staging-{Guid.NewGuid():N}");
try
{
try
{
finalDirectory = DetermineFinalDirectory(assistantPluginsRoot, assistantPlugin);
if (!IsPathInsideDirectory(assistantPluginsRoot, finalDirectory))
return Error(TB("The resolved plugin directory is outside the assistant plugin directory."));
token.ThrowIfCancellationRequested();
PluginArchive.Extract(archivePath, stagingDirectory);
if (Directory.Exists(finalDirectory))
{
replacedExisting = true;
backupDirectory = Path.Join(assistantPluginsRoot, $".{Path.GetFileName(finalDirectory)}.backup-{Guid.NewGuid():N}");
Directory.Move(finalDirectory, backupDirectory);
}
var pluginFiles = Directory.EnumerateFiles(stagingDirectory, PLUGIN_FILE_NAME, SearchOption.AllDirectories).ToArray();
if (pluginFiles.Length != 1)
return Error(TB("The plugin archive must contain exactly one plugin.lua file."));
Directory.Move(stagingDirectory, finalDirectory);
if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory))
{
try
{
Directory.Delete(backupDirectory, true);
}
catch (Exception e)
{
this.logger.LogError(e, $"Failed to delete assistant plugin backup directory '{backupDirectory}'.");
}
}
var pluginFile = pluginFiles[0];
var pluginDirectory = Path.GetDirectoryName(pluginFile)!;
var pluginCode = await File.ReadAllTextAsync(pluginFile, Encoding.UTF8, token);
var validation = await ValidateAssistantPluginCodeAsync(
pluginDirectory,
pluginCode.Trim(),
TB("Currently, only assistant plugins can be imported."),
TB("The imported assistant plugin is invalid. Issue: {0}"),
TB("The imported assistant plugin uses the ID of another installed plugin."),
token);
await PluginFactory.LoadAll(token);
this.logger.LogInformation($"Installed assistant plugin '{assistantPlugin.Name}' ({assistantPlugin.Id}) to '{finalDirectory}'.");
return new(true, assistantPlugin.Id, assistantPlugin.Name, finalDirectory, replacedExisting, string.Empty);
if (!validation.Success || validation.AssistantPlugin is null)
return Error(validation.Issue);
// A plugin the user imports by hand never comes from a config server. We reject such
// archives because AI Studio trusts this self-declared flag: an imported plugin
// claiming it would be neither replaceable nor deletable through the user interface:
if (validation.AssistantPlugin.IsManagedByConfigServer)
return Error(TB("This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins."));
// The archive would replace an existing plugin: reject it when that plugin belongs
// to the IT department. We check this before asking the user, so that the
// confirmation never offers something we would refuse afterwards anyway:
var replacementIssue = GetAssistantReplacementIssue(validation.AssistantPlugin.Id);
if (!string.IsNullOrEmpty(replacementIssue))
return Error(replacementIssue);
// Everything is validated, but nothing was written yet. This is the point where the
// user decides, because the plugin code comes from an untrusted source:
if (!await confirmAsync(CreateImportPreview(validation.AssistantPlugin)))
return CancelledByUser();
return await this.InstallStagedAssistantAsync(assistantPluginsRoot, validation with { StagingDirectory = pluginDirectory }, token);
}
catch (Exception e)
catch (Exception e) when (e is not OperationCanceledException)
{
this.logger.LogError(e, "Failed to install assistant plugin.");
if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory) && !string.IsNullOrWhiteSpace(finalDirectory) && !Directory.Exists(finalDirectory))
{
try
{
Directory.Move(backupDirectory, finalDirectory);
}
catch (Exception restoreException)
{
this.logger.LogError(restoreException, "Failed to restore the previous assistant plugin after a failed installation.");
}
}
this.logger.LogError(e, "Failed to extract or validate assistant plugin archive '{ArchivePath}'.", archivePath);
return Error(string.Format(TB("Unexpected error: {0}"), e.Message));
}
finally
{
this.TryDeleteStagingDirectory(stagingDirectory);
}
}
finally
{
this.TryDeleteStagingDirectory(stagingDirectory);
this.installSemaphore.Release();
}
}
@@ -289,9 +309,10 @@ public sealed class AssistantPluginInstallService
Directory.Move(pluginDirectory, backupDirectory);
wasEnabled = this.settingsManager.ConfigurationData.EnabledPlugins.Remove(plugin.Id);
removedAudits = this.settingsManager.ConfigurationData.AssistantPluginAudits
.Where(audit => audit.PluginId == plugin.Id)
.ToList();
removedAudits =
[
.. this.settingsManager.ConfigurationData.AssistantPluginAudits.Where(audit => audit.PluginId == plugin.Id)
];
if (removedAudits.Count > 0)
this.settingsManager.ConfigurationData.AssistantPluginAudits.RemoveAll(audit => audit.PluginId == plugin.Id);
@@ -412,6 +433,79 @@ public sealed class AssistantPluginInstallService
}
}
private async Task<AssistantPluginInstallResult> InstallStagedAssistantAsync(string assistantPluginsRoot, AssistantPluginValidationResult validation, CancellationToken token)
{
var stagingDirectory = validation.StagingDirectory;
var assistantPlugin = validation.AssistantPlugin!;
string? backupDirectory = null;
string? finalDirectory = null;
var replacedExisting = false;
var movedIntoPlace = false;
try
{
Directory.CreateDirectory(assistantPluginsRoot);
finalDirectory = DetermineFinalDirectory(assistantPluginsRoot, assistantPlugin);
if (!IsPathInsideDirectory(assistantPluginsRoot, finalDirectory))
return Error(TB("The resolved plugin directory is outside the assistant plugin directory."));
var replacementIssue = GetAssistantReplacementIssue(assistantPlugin.Id);
if (!string.IsNullOrWhiteSpace(replacementIssue))
return Error(replacementIssue);
if (Directory.Exists(finalDirectory))
{
replacedExisting = true;
// The backup goes to a directory outside the plugin root, so the plugin loader
// cannot discover it during the reload below. Otherwise, the previous version
// would be loaded a second time, next to the version we are installing:
backupDirectory = CreateInstallBackupDirectory(assistantPlugin);
Directory.CreateDirectory(Path.GetDirectoryName(backupDirectory)!);
Directory.Move(finalDirectory, backupDirectory);
}
Directory.Move(stagingDirectory, finalDirectory);
movedIntoPlace = true;
await PluginFactory.LoadAll(token);
if (!string.IsNullOrWhiteSpace(backupDirectory))
TryDeleteDirectory(backupDirectory, "assistant plugin backup", this.logger);
this.logger.LogInformation("Installed assistant plugin '{PluginName}' ({PluginId}) to '{PluginDirectory}'.", assistantPlugin.Name, assistantPlugin.Id, finalDirectory);
return new(true, assistantPlugin.Id, assistantPlugin.Name, finalDirectory, replacedExisting, string.Empty);
}
catch (Exception e)
{
this.logger.LogError(e, "Failed to install assistant plugin.");
// Only remove the target directory when this installation actually moved the plugin
// there. Otherwise, when moving the previous plugin into the backup directory failed,
// we would delete the still intact previous plugin:
if (movedIntoPlace && !string.IsNullOrWhiteSpace(finalDirectory) && Directory.Exists(finalDirectory))
TryDeleteDirectory(finalDirectory, "failed assistant plugin installation", this.logger);
if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory) && !string.IsNullOrWhiteSpace(finalDirectory) && !Directory.Exists(finalDirectory))
{
try
{
Directory.Move(backupDirectory, finalDirectory);
await PluginFactory.LoadAll(CancellationToken.None);
}
catch (Exception restoreException)
{
this.logger.LogError(restoreException, "Failed to restore the previous assistant plugin after a failed installation.");
}
}
return Error(string.Format(TB("Unexpected error: {0}"), e.Message));
}
finally
{
this.TryDeleteStagingDirectory(stagingDirectory);
}
}
private async Task<AssistantPluginValidationResult> ValidateIntoStagingAsync(string lua, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(lua))
@@ -429,12 +523,12 @@ public sealed class AssistantPluginInstallService
var stagedPluginFile = Path.Join(stagingDirectory, PLUGIN_FILE_NAME);
await File.WriteAllTextAsync(stagedPluginFile, pluginCode, Encoding.UTF8, token);
var validation = await this.ValidateAssistantPluginCodeAsync(
var validation = await ValidateAssistantPluginCodeAsync(
stagingDirectory,
pluginCode,
TB("The generated plugin is not an assistant plugin. Issue: {0}"),
TB("The generated assistant plugin is invalid. Issue: {0}"),
TB("The generated assistant plugin uses the ID of an internal AI Studio plugin."),
TB("The generated assistant plugin uses the ID of another installed plugin."),
token);
if (!validation.Success || validation.AssistantPlugin is null)
@@ -460,12 +554,12 @@ public sealed class AssistantPluginInstallService
try
{
return await this.ValidateAssistantPluginCodeAsync(
return await ValidateAssistantPluginCodeAsync(
pluginDirectory,
lua.Trim(),
TB("The edited plugin is not an assistant plugin. Issue: {0}"),
TB("The edited assistant plugin is invalid. Issue: {0}"),
TB("The edited assistant plugin uses the ID of an internal AI Studio plugin."),
TB("The edited assistant plugin uses the ID of another installed plugin."),
token);
}
catch (Exception e)
@@ -475,23 +569,25 @@ public sealed class AssistantPluginInstallService
}
}
private async Task<AssistantPluginValidationResult> ValidateAssistantPluginCodeAsync(
string pluginDirectory,
string pluginCode,
string notAssistantIssue,
string invalidAssistantIssue,
string internalPluginIdIssue,
CancellationToken token)
private static async Task<AssistantPluginValidationResult> ValidateAssistantPluginCodeAsync(string pluginDirectory, string pluginCode,
string notAssistantIssue, string invalidAssistantIssue, string conflictingPluginIdIssue, CancellationToken token)
{
var plugin = await PluginFactory.Load(pluginDirectory, pluginCode, token);
// The plugin is not installed yet: it sits in a staging directory outside the installed
// plugins directory. We allow that directory as the module base, so the plugin can load its
// own Lua modules, e.g., an icon.lua, while we validate it:
var plugin = await PluginFactory.Load(pluginDirectory, pluginCode, token, pluginDirectory);
if (plugin is not PluginAssistants assistantPlugin)
return AssistantPluginValidationResult.Failure(string.Format(notAssistantIssue, string.Join("; ", plugin.Issues)));
if (!assistantPlugin.IsValid)
return AssistantPluginValidationResult.Failure(string.Format(invalidAssistantIssue, string.Join("; ", assistantPlugin.Issues)));
if (PluginFactory.AvailablePlugins.Any(availablePlugin => availablePlugin.Type is PluginType.ASSISTANT && availablePlugin.Id == assistantPlugin.Id && availablePlugin.IsInternal))
return AssistantPluginValidationResult.Failure(internalPluginIdIssue);
// Plugin IDs must be unique across all plugin types: several lookups resolve a plugin by its
// ID alone, e.g., the base language plugin in PluginFactory.Starting. An assistant plugin
// carrying the ID of a language or configuration plugin would break those lookups. Reusing
// the ID of another local assistant plugin stays allowed: that is how updating one works.
if (PluginFactory.AvailablePlugins.Any(availablePlugin => availablePlugin.Id == assistantPlugin.Id && (availablePlugin.IsInternal || availablePlugin.Type is not PluginType.ASSISTANT)))
return AssistantPluginValidationResult.Failure(conflictingPluginIdIssue);
return new(true, string.Empty, assistantPlugin, string.Empty);
}
@@ -547,22 +643,63 @@ public sealed class AssistantPluginInstallService
: TB("The assistant plugin directory does not exist.");
}
private void TryDeleteStagingDirectory(string stagingDirectory)
{
TryDeleteDirectory(stagingDirectory, "assistant plugin staging", this.logger);
}
private void TryDeleteStagingDirectory(string stagingDirectory) => TryDeleteDirectory(stagingDirectory, "assistant plugin staging", this.logger);
private static string DetermineFinalDirectory(string assistantPluginsRoot, PluginAssistants assistantPlugin)
{
var existingPlugin = PluginFactory.AvailablePlugins
.OfType<IAvailablePlugin>()
.FirstOrDefault(plugin => plugin.Type is PluginType.ASSISTANT && plugin.Id == assistantPlugin.Id && !plugin.IsInternal);
var existingPlugin = FindReplaceableAssistantPlugin(assistantPlugin.Id);
return existingPlugin is not null
? existingPlugin.LocalPath
: Path.Join(assistantPluginsRoot, CreatePluginDirectoryName(assistantPlugin));
}
/// <summary>
/// Finds the local assistant plugin that an installation with the given ID would replace.
/// </summary>
/// <param name="pluginId">The ID of the assistant plugin about to be installed.</param>
/// <returns>The plugin that would be replaced, or null when the installation adds a new plugin.</returns>
private static IAvailablePlugin? FindReplaceableAssistantPlugin(Guid pluginId) => PluginFactory.AvailablePlugins
.OfType<IAvailablePlugin>()
.FirstOrDefault(plugin => plugin.Type is PluginType.ASSISTANT && plugin.Id == pluginId && !plugin.IsInternal);
/// <summary>
/// Collects the metadata an archive declares about itself, together with the information about
/// the installed plugin it would replace.
/// </summary>
/// <param name="assistantPlugin">The validated assistant plugin from the archive.</param>
/// <returns>The preview shown to the user before the installation starts.</returns>
private static PluginImportPreview CreateImportPreview(PluginAssistants assistantPlugin) => new(assistantPlugin, FindReplaceableAssistantPlugin(assistantPlugin.Id));
/// <summary>
/// Checks whether an installation may replace the assistant plugin that currently uses the given ID.
/// Plugins deployed by a Config Server belong to the organization's IT, so neither an import nor
/// the Assistant Builder may overwrite them.
/// </summary>
/// <param name="pluginId">The ID of the assistant plugin about to be installed.</param>
/// <returns>A user-facing issue when the existing plugin must not be replaced, an empty string otherwise.</returns>
private static string GetAssistantReplacementIssue(Guid pluginId)
{
var existingPlugin = FindReplaceableAssistantPlugin(pluginId);
if (existingPlugin is null)
return string.Empty;
if (existingPlugin.IsManagedByConfigServer)
return TB("Config server managed assistant plugins cannot be replaced.");
if (string.IsNullOrWhiteSpace(existingPlugin.LocalPath))
return string.Empty;
// The metadata above and the running plugin read the same Lua field. We check both, though,
// just like the deletion path does:
var runningPlugin = PluginFactory.RunningPlugins
.OfType<PluginAssistants>()
.FirstOrDefault(candidate => candidate.Id == pluginId && IsSameDirectory(candidate.PluginPath, existingPlugin.LocalPath));
return runningPlugin?.IsManagedByConfigServer is true
? TB("Config server managed assistant plugins cannot be replaced.")
: string.Empty;
}
private static string CreatePluginDirectoryName(PluginAssistants assistantPlugin)
{
var safeName = CreateSafeDirectoryNamePart(assistantPlugin.Name);
@@ -628,6 +765,12 @@ public sealed class AssistantPluginInstallService
return Path.Join(backupRoot, $"assistant-{plugin.Id:N}-{Guid.NewGuid():N}");
}
private static string CreateInstallBackupDirectory(IPluginMetadata plugin)
{
var backupRoot = Path.Join(SettingsManager.DataDirectory, INSTALL_BACKUP_DIRECTORY);
return Path.Join(backupRoot, $"assistant-{plugin.Id:N}-{Guid.NewGuid():N}");
}
private async Task TryRestoreDeletedAssistantPluginAsync(IAvailablePlugin plugin, string pluginDirectory, string backupDirectory, bool wasEnabled, List<PluginAssistantAudit> removedAudits, CancellationToken token)
{
try
@@ -0,0 +1,3 @@
namespace AIStudio.Tools.Services;
public sealed record AssistantPluginUpdateResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, string Issue);
@@ -0,0 +1,6 @@
namespace AIStudio.Tools.Services;
public sealed class NativeShareService(RustService rustService)
{
public Task<bool> Share(string filePath) => rustService.ShareFile(filePath);
}
@@ -0,0 +1,17 @@
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tools.Services;
/// <summary>
/// What the user gets to see before a plugin archive is installed. It is not bound to a specific
/// plugin type, so it also serves upcoming import paths for other plugin types.
/// </summary>
/// <param name="Plugin">The plugin from the archive, with the metadata it declares about itself.</param>
/// <param name="ExistingPlugin">The installed plugin that gets replaced or null when the archive adds a new plugin.</param>
public sealed record PluginImportPreview(IPluginMetadata Plugin, IAvailablePlugin? ExistingPlugin)
{
/// <summary>
/// True when an installed plugin with the same ID gets replaced.
/// </summary>
public bool ReplacesExisting => this.ExistingPlugin is not null;
}
@@ -0,0 +1,3 @@
namespace AIStudio.Tools.Services;
public sealed record PluginShareResult(bool Success, string PluginName, string ArchivePath, string Issue, bool Cancelled = false);
@@ -0,0 +1,242 @@
using System.IO.Compression;
using AIStudio.Settings;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust;
namespace AIStudio.Tools.Services;
public sealed class PluginShareService(NativeShareService nativeShareService, RustService rustService, SettingsManager settingsManager, ILogger<PluginShareService> logger)
{
private static PluginShareResult ShareError(IAvailablePlugin plugin, string issue) => new(false, plugin.Name, string.Empty, issue);
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginShareService).Namespace, nameof(PluginShareService));
private const string PLUGIN_FILE_NAME = "plugin.lua";
/// <remarks>
/// Keep in sync with SHARE_DIRECTORY_NAME in runtime/src/share_sheet.rs: the runtime only hands
/// archives from a directory with this name to the native share sheet.
/// </remarks>
private const string TEMPORARY_ARCHIVE_DIRECTORY = "mindwork-ai-studio-plugin-shares";
private const int TEMPORARY_ARCHIVE_RETENTION_HOURS = 24;
private const int FILE_NAME_PREFIX_MAX_LEN = 80;
/// <summary>
/// Creates a shareable plugin archive from a local plugin and hands it over to the user.
/// The archive contains the plugin root contents, so <c>plugin.lua</c> is located at the archive root.
/// </summary>
/// <remarks>
/// On Windows and macOS, the archive is created in a temporary directory and handed over to the
/// native share sheet. Linux has no such share sheet, since the XDG desktop portals do not provide
/// a share interface. Thus, the archive is exported to a location of the user's choice there.
/// </remarks>
/// <param name="plugin">The local plugin to archive and share.</param>
/// <param name="token">Cancellation token for archive creation.</param>
/// <returns>The share result, including the archive path when successful.</returns>
public async Task<PluginShareResult> ShareAsync(IAvailablePlugin plugin, CancellationToken token)
{
if (plugin.IsInternal)
return ShareError(plugin, TB("Internal plugins cannot be shared."));
if (plugin.IsManagedByConfigServer)
return ShareError(plugin, TB("Config Server managed plugins cannot be shared."));
if (!settingsManager.ConfigurationData.App.AllowUserToSharePlugins)
return ShareError(plugin, TB("Your organization has disabled sharing plugins."));
if (!TryGetPluginRoot(plugin, out var pluginRoot, out var issue))
return ShareError(plugin, issue);
if (OperatingSystem.IsLinux())
return await this.ExportAsync(plugin, pluginRoot, token);
return await this.ShareViaNativeSheetAsync(plugin, pluginRoot, token);
}
/// <summary>
/// Asks the user for a target location and writes the plugin archive to it.
/// </summary>
/// <param name="plugin">The local plugin to archive.</param>
/// <param name="pluginRoot">The validated plugin root directory.</param>
/// <param name="token">Cancellation token for archive creation.</param>
/// <returns>The share result, including the chosen archive path when successful.</returns>
private async Task<PluginShareResult> ExportAsync(IAvailablePlugin plugin, string pluginRoot, CancellationToken token)
{
var suggestedFileName = $"{CreateSafeFileNamePrefix(plugin.Name)}{PluginArchive.PLUGIN_FILE_EXTENSION}";
var saveResponse = await rustService.SaveFile(TB("Export plugin archive"), [FileTypes.PLUGIN_ARCHIVE], suggestedFileName);
if (saveResponse.UserCancelled)
return new(false, plugin.Name, string.Empty, string.Empty, true);
var archivePath = saveResponse.SaveFilePath;
try
{
token.ThrowIfCancellationRequested();
await Task.Run(() =>
{
token.ThrowIfCancellationRequested();
// The save dialog already asked the user about overwriting an existing file.
// ZipFile.CreateFromDirectory would fail on an existing file, though:
if (File.Exists(archivePath))
File.Delete(archivePath);
ZipFile.CreateFromDirectory(pluginRoot, archivePath, CompressionLevel.Optimal, false);
}, token);
logger.LogInformation("Exported plugin '{PluginName}' ({PluginId}) to the archive '{ArchivePath}'.", plugin.Name, plugin.Id, archivePath);
return new(true, plugin.Name, archivePath, string.Empty);
}
catch (OperationCanceledException)
{
this.TryDeleteArchive(archivePath);
throw;
}
catch (Exception exception)
{
this.TryDeleteArchive(archivePath);
logger.LogError(exception, "Failed to export plugin '{PluginName}' ({PluginId}).", plugin.Name, plugin.Id);
return ShareError(plugin, string.Format(TB("Unexpected error: {0}"), exception.Message));
}
}
/// <summary>
/// Creates the plugin archive in a temporary directory and opens the native share sheet for it.
/// </summary>
/// <param name="plugin">The local plugin to archive and share.</param>
/// <param name="pluginRoot">The validated plugin root directory.</param>
/// <param name="token">Cancellation token for archive creation.</param>
/// <returns>The share result, including the retained temporary archive path when successful.</returns>
private async Task<PluginShareResult> ShareViaNativeSheetAsync(IAvailablePlugin plugin, string pluginRoot, CancellationToken token)
{
var archiveDirectory = Path.Join(Path.GetTempPath(), TEMPORARY_ARCHIVE_DIRECTORY);
var archivePath = Path.Join(archiveDirectory, $"{CreateSafeFileNamePrefix(plugin.Name)}-{plugin.Id:N}-{Guid.NewGuid():N}{PluginArchive.PLUGIN_FILE_EXTENSION}");
try
{
token.ThrowIfCancellationRequested();
Directory.CreateDirectory(archiveDirectory);
this.CleanUpExpiredArchives(archiveDirectory);
await Task.Run(() =>
{
token.ThrowIfCancellationRequested();
ZipFile.CreateFromDirectory(pluginRoot, archivePath, CompressionLevel.Optimal, false);
}, token);
token.ThrowIfCancellationRequested();
if (!await nativeShareService.Share(archivePath))
{
this.TryDeleteArchive(archivePath);
return ShareError(plugin, TB("The native share dialog could not be opened."));
}
logger.LogInformation("Created plugin archive '{ArchivePath}' for plugin '{PluginName}' ({PluginId}).", archivePath, plugin.Name, plugin.Id);
return new(true, plugin.Name, archivePath, string.Empty);
}
catch (OperationCanceledException)
{
this.TryDeleteArchive(archivePath);
throw;
}
catch (Exception exception)
{
this.TryDeleteArchive(archivePath);
logger.LogError(exception, "Failed to create a share archive for plugin '{PluginName}' ({PluginId}).", plugin.Name, plugin.Id);
return ShareError(plugin, string.Format(TB("Unexpected error: {0}"), exception.Message));
}
}
private static bool TryGetPluginRoot(IAvailablePlugin plugin, out string pluginRoot, out string issue)
{
pluginRoot = string.Empty;
issue = string.Empty;
if (string.IsNullOrWhiteSpace(plugin.LocalPath))
{
issue = TB("The plugin has no local directory.");
return false;
}
try
{
pluginRoot = Path.GetFullPath(plugin.LocalPath);
}
catch (Exception exception)
{
issue = string.Format(TB("The plugin directory is invalid: {0}"), exception.Message);
return false;
}
if (!Directory.Exists(pluginRoot))
{
issue = TB("The plugin directory does not exist.");
return false;
}
var pluginFile = Path.Join(pluginRoot, PLUGIN_FILE_NAME);
if (!IsPathInsideDirectory(pluginRoot, pluginFile) || !File.Exists(pluginFile))
{
issue = TB("The plugin directory does not contain a plugin.lua file.");
return false;
}
return true;
}
private void CleanUpExpiredArchives(string archiveDirectory)
{
var expiry = DateTime.UtcNow.AddHours(-TEMPORARY_ARCHIVE_RETENTION_HOURS);
foreach (var archivePath in Directory.EnumerateFiles(archiveDirectory, $"*{PluginArchive.PLUGIN_FILE_EXTENSION}", SearchOption.TopDirectoryOnly))
{
try
{
if (File.GetLastWriteTimeUtc(archivePath) < expiry)
File.Delete(archivePath);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to delete expired plugin archive '{ArchivePath}'.", archivePath);
}
}
}
private static string CreateSafeFileNamePrefix(string pluginName)
{
var invalidCharacters = Path.GetInvalidFileNameChars().ToHashSet();
var fileName = new string(pluginName
.Trim()
.Select(character => char.IsLetterOrDigit(character) || ((character is '-' or '_' or '.') && !invalidCharacters.Contains(character))
? character
: '-')
.ToArray())
.Trim('-', '.');
if (fileName.Length > FILE_NAME_PREFIX_MAX_LEN)
fileName = fileName[..FILE_NAME_PREFIX_MAX_LEN].Trim('-', '.');
return string.IsNullOrWhiteSpace(fileName) ? "plugin" : fileName;
}
private static bool IsPathInsideDirectory(string parentDirectory, string path)
{
var parentPath = Path.GetFullPath(parentDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
var childPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
return childPath.StartsWith(parentPath, StringComparison.OrdinalIgnoreCase);
}
private void TryDeleteArchive(string archivePath)
{
if (!File.Exists(archivePath))
return;
try
{
File.Delete(archivePath);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to delete temporary plugin archive '{ArchivePath}'.", archivePath);
}
}
}
@@ -0,0 +1,44 @@
// ReSharper disable NotAccessedPositionalProperty.Local
namespace AIStudio.Tools.Services;
public sealed partial class RustService
{
public async Task<bool> ShareFile(string filePath)
{
try
{
using var response = await this.http.PostAsJsonAsync("/share/file", new ShareFileRequest(filePath), this.jsonRustSerializerOptions);
if (!response.IsSuccessStatusCode)
{
this.logger?.LogError($"The Rust runtime rejected the share request: {response.StatusCode}.");
return false;
}
var result = await response.Content.ReadFromJsonAsync<ShareFileResponse>(this.jsonRustSerializerOptions);
if (result?.Success == true)
return true;
this.logger?.LogError($"The native share sheet could not be opened: {result?.Issue ?? "Unknown error"}");
return false;
}
catch (HttpRequestException exception)
{
this.logger?.LogWarning(exception, "Failed to reach the Rust runtime share endpoint.");
return false;
}
catch (TaskCanceledException exception)
{
this.logger?.LogWarning(exception, "Timed out while reaching the Rust runtime share endpoint.");
return false;
}
catch (Exception exception)
{
this.logger?.LogError(exception, "Failed to process the Rust runtime share response.");
return false;
}
}
private sealed record ShareFileRequest(string FilePath);
private sealed record ShareFileResponse(bool Success, string Issue);
}