From 7a092418884223b6ae8a1fda5fe412be7cca3f8a Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 18 May 2026 16:26:51 +0200 Subject: [PATCH] Configure ERI servers in config plugins (#767) --- AGENTS.md | 5 +- app/MindWork AI Studio.sln.DotSettings | 1 + .../Assistants/I18N/allTexts.lua | 63 +++++ ...rceERIV1UsernamePasswordExportDialog.razor | 26 ++ ...ERIV1UsernamePasswordExportDialog.razor.cs | 37 +++ ...ERIV1UsernamePasswordExportDialogResult.cs | 5 + .../Dialogs/DataSourceERI_V1Dialog.razor.cs | 5 +- .../Dialogs/DataSourceERI_V1InfoDialog.razor | 2 +- .../DataSourceERI_V1InfoDialog.razor.cs | 30 ++- .../Settings/SettingsDialogDataSources.razor | 27 +- .../SettingsDialogDataSources.razor.cs | 108 +++++++- .../Layout/MainLayout.razor.cs | 2 + .../Pages/Information.razor | 2 + .../Pages/Information.razor.cs | 4 + .../Plugins/configuration/plugin.lua | 48 ++++ .../plugin.lua | 63 +++++ .../plugin.lua | 63 +++++ .../DataSourceERIUsernamePasswordMode.cs | 19 ++ .../Settings/DataModel/DataSourceERI_V1.cs | 252 +++++++++++++++++- .../DataModel/DataSourceLocalDirectory.cs | 6 + .../Settings/DataModel/DataSourceLocalFile.cs | 6 + .../Settings/IDataSource.cs | 18 +- .../Settings/IERIDataSource.cs | 6 + .../Settings/IExternalDataSource.cs | 2 +- .../Tools/ERIClient/ERIClientV1.cs | 18 +- .../PluginSystem/PendingEnterpriseApiKey.cs | 35 +-- .../PluginSystem/PendingEnterpriseApiKeys.cs | 34 +++ .../PluginSystem/PendingEnterpriseSecret.cs | 14 + .../PluginSystem/PendingEnterpriseSecrets.cs | 34 +++ .../Tools/PluginSystem/PluginConfiguration.cs | 34 +++ .../PluginSystem/PluginConfigurationObject.cs | 95 ++++++- .../PluginSystem/PluginFactory.Loading.cs | 4 + .../Tools/SecretStoreType.cs | 11 +- .../Tools/SecretStoreTypeExtensions.cs | 4 +- .../Services/EnterpriseEnvironmentService.cs | 21 +- .../Tools/Services/RustService.OS.cs | 31 +++ .../Tools/Services/RustService.Secrets.cs | 79 ++++-- .../Tools/Services/RustService.cs | 3 + .../wwwroot/changelog/v26.5.5.md | 3 + runtime/Cargo.lock | 41 ++- runtime/Cargo.toml | 1 + runtime/src/environment.rs | 10 +- runtime/src/runtime_api.rs | 1 + 43 files changed, 1165 insertions(+), 108 deletions(-) create mode 100644 app/MindWork AI Studio/Dialogs/DataSourceERIV1UsernamePasswordExportDialog.razor create mode 100644 app/MindWork AI Studio/Dialogs/DataSourceERIV1UsernamePasswordExportDialog.razor.cs create mode 100644 app/MindWork AI Studio/Dialogs/DataSourceERIV1UsernamePasswordExportDialogResult.cs create mode 100644 app/MindWork AI Studio/Settings/DataModel/DataSourceERIUsernamePasswordMode.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKeys.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseSecret.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseSecrets.cs diff --git a/AGENTS.md b/AGENTS.md index 7908fdcd..48a25021 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ Currently, no automated test suite exists in the repository. Key modules: - `app_window.rs` - Tauri window management, updater integration - `dotnet.rs` - Launches and manages the .NET sidecar process -- `runtime_api.rs` - Rocket-based HTTPS API for .NET ↔ Rust communication +- `runtime_api.rs` - Axum-based HTTPS API for .NET ↔ Rust communication - `certificate.rs` - Generates self-signed TLS certificates for secure IPC - `secret.rs` - Secure secret storage using OS keyring (Keychain/Credential Manager) - `clipboard.rs` - Cross-platform clipboard operations @@ -152,7 +152,7 @@ Multi-level confidence scheme allows users to control which providers see which **Rust:** - Tauri 1.8 - Desktop application framework -- Rocket - HTTPS API server +- Axum - HTTPS API server - tokio - Async runtime - keyring - OS keyring integration - pdfium-render - PDF text extraction @@ -187,6 +187,7 @@ Multi-level confidence scheme allows users to control which providers see which - **File changes require Write/Edit tools** - Never use bash commands like `cat <` - **End of file formatting** - Do not append an extra empty line at the end of files. - **No automated formatting for Rust or .NET files** - Never run automated formatters on Rust files (`.rs`) or .NET files (`.cs`, `.razor`, `.csproj`, etc.). Only make the minimal manual formatting changes required for the specific edit. +- **I18N resources are generated** - Do not manually edit `app/MindWork AI Studio/Assistants/I18N/allTexts.lua`, `app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua`, or `app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua`. These files are updated automatically by the I18N process. - **Spaces in paths** - Always quote paths with spaces in bash commands - **Agent-run .NET builds** - Do not run `.NET` builds from an agent. Ask the user to run the build locally in their IDE, preferably via `cd app/Build && dotnet run build` in an IDE terminal, then wait for their feedback before continuing. - **Debug environment** - Reads `startup.env` file with IPC credentials diff --git a/app/MindWork AI Studio.sln.DotSettings b/app/MindWork AI Studio.sln.DotSettings index d35acefd..8919d73e 100644 --- a/app/MindWork AI Studio.sln.DotSettings +++ b/app/MindWork AI Studio.sln.DotSettings @@ -2,6 +2,7 @@ AI EDI ERI + ERIV FNV GWDG HF diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index a028108e..a4205982 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -3631,6 +3631,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T2879113658"] = -- Maximum matches per query UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T2889706179"] = "Maximum matches per query" +-- Failed to read the user's username from the operating system. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T2909734556"] = "Failed to read the user's username from the operating system." + -- Open web link, show more information UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T2968752071"] = "Open web link, show more information" @@ -3682,6 +3685,27 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T742006305"] = " -- Embeddings UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T951463987"] = "Embeddings" +-- Use the same username and password for all users +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1769874785"] = "Use the same username and password for all users" + +-- Username and password mode +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1787063064"] = "Username and password mode" + +-- How should AI Studio export the username and password configuration for the ERI v1 data source '{0}'? +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3081234668"] = "How should AI Studio export the username and password configuration for the ERI v1 data source '{0}'?" + +-- User-managed username and password +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T365340972"] = "User-managed username and password" + +-- Export +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3898821075"] = "Export" + +-- Read each user's username from the operating system and share one password +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T76405695"] = "Read each user's username from the operating system and share one password" + +-- Cancel +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T900713019"] = "Cancel" + -- Describe what data this directory contains to help the AI select it. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1136409150"] = "Describe what data this directory contains to help the AI select it." @@ -4810,6 +4834,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T145419 -- Delete UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T1469573738"] = "Delete" +-- Kerberos/SSO ERI data sources cannot be exported yet. Please configure them manually in the configuration plugin. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T1577531115"] = "Kerberos/SSO ERI data sources cannot be exported yet. Please configure them manually in the configuration plugin." + +-- Cannot export this ERI data source because the authentication secret could not be encrypted. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T1592527757"] = "Cannot export this ERI data source because the authentication secret could not be encrypted." + -- External (ERI) UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T1652430727"] = "External (ERI)" @@ -4840,6 +4870,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T269820 -- Embedding UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T2838542994"] = "Embedding" +-- This data source is managed by your organization. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3031462878"] = "This data source is managed by your organization." + -- Edit UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3267849393"] = "Edit" @@ -4864,21 +4897,39 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T352566 -- No data sources configured yet. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3549650120"] = "No data sources configured yet." +-- Export Access Token? +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3595669127"] = "Export Access Token?" + +-- Export ERI Data Source +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3831281036"] = "Export ERI Data Source" + -- Actions UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3865031940"] = "Actions" +-- This ERI data source has an access token configured. Do you want to include the encrypted access token in the export? Note: The recipient will need the same encryption secret to use the access token. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T4027572258"] = "This ERI data source has an access token configured. Do you want to include the encrypted access token in the export? Note: The recipient will need the same encryption secret to use the access token." + -- Configured Data Sources UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T543942217"] = "Configured Data Sources" -- Add ERI v1 Data Source UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T590005498"] = "Add ERI v1 Data Source" +-- Cannot export this ERI data source because no enterprise encryption secret is configured. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T750361472"] = "Cannot export this ERI data source because no enterprise encryption secret is configured." + -- External Data (ERI-Server v1) UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T774473996"] = "External Data (ERI-Server v1)" +-- Cannot export this ERI data source because no authentication secret is configured. The issue was: {0} +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T782820095"] = "Cannot export this ERI data source because no authentication secret is configured. The issue was: {0}" + -- Local Directory UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T926703547"] = "Local Directory" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T975426229"] = "Export configuration" + -- When enabled, you can preselect some ERI server options. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGERISERVER::T1280666275"] = "When enabled, you can preselect some ERI server options." @@ -6169,6 +6220,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3574465749"] = "not available" -- This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3722989559"] = "This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat." +-- Username provided by the OS +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3764549776"] = "Username provided by the OS" + -- this version does not met the requirements UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3813932670"] = "this version does not met the requirements" @@ -6190,6 +6244,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4010195468"] = "Versions" -- Database UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4036243672"] = "Database" +-- This library is used by the Rust runtime to read the current user's username, e.g. when an organization-managed ERI server uses the OS username for authentication. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4060906280"] = "This library is used by the Rust runtime to read the current user's username, e.g. when an organization-managed ERI server uses the OS username for authentication." + -- This library is used to create asynchronous streams in Rust. It allows us to work with streams of data that can be produced asynchronously, making it easier to handle events or data that arrive over time. We use this, e.g., to stream arbitrary data from the file system to the embedding system. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4079152443"] = "This library is used to create asynchronous streams in Rust. It allows us to work with streams of data that can be produced asynchronously, making it easier to handle events or data that arrive over time. We use this, e.g., to stream arbitrary data from the file system to the embedding system." @@ -6928,6 +6985,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T2858189239"] = "Faile -- Failed to retrieve the security requirements: the request was canceled either by the user or due to a timeout. UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T286437836"] = "Failed to retrieve the security requirements: the request was canceled either by the user or due to a timeout." +-- Failed to read the user's username from the operating system. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T2909734556"] = "Failed to read the user's username from the operating system." + -- Failed to retrieve the security requirements due to an exception: {0} UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T3221004295"] = "Failed to retrieve the security requirements due to an exception: {0}" @@ -7504,6 +7564,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T18544701 -- Pandoc may be required for importing files. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T2596465560"] = "Pandoc may be required for importing files." +-- Failed to store the secret data due to an API issue. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1110203516"] = "Failed to store the secret data due to an API issue." + -- Failed to delete the secret data due to an API issue. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2303057928"] = "Failed to delete the secret data due to an API issue." diff --git a/app/MindWork AI Studio/Dialogs/DataSourceERIV1UsernamePasswordExportDialog.razor b/app/MindWork AI Studio/Dialogs/DataSourceERIV1UsernamePasswordExportDialog.razor new file mode 100644 index 00000000..088be8ea --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/DataSourceERIV1UsernamePasswordExportDialog.razor @@ -0,0 +1,26 @@ +@inherits MSGComponentBase + + + + + @string.Format(T("How should AI Studio export the username and password configuration for the ERI v1 data source '{0}'?"), this.DataSource.Name) + + + + @foreach (var mode in this.availableUsernamePasswordModes) + { + + @this.GetUsernamePasswordModeText(mode) + + } + + + + + @T("Cancel") + + + @T("Export") + + + \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/DataSourceERIV1UsernamePasswordExportDialog.razor.cs b/app/MindWork AI Studio/Dialogs/DataSourceERIV1UsernamePasswordExportDialog.razor.cs new file mode 100644 index 00000000..cf0ec960 --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/DataSourceERIV1UsernamePasswordExportDialog.razor.cs @@ -0,0 +1,37 @@ +using AIStudio.Components; +using AIStudio.Settings.DataModel; + +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Dialogs; + +public partial class DataSourceERIV1UsernamePasswordExportDialog : MSGComponentBase +{ + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] + public DataSourceERI_V1 DataSource { get; set; } + + private readonly DataSourceERIUsernamePasswordMode[] availableUsernamePasswordModes = + [ + DataSourceERIUsernamePasswordMode.OS_USERNAME_SHARED_PASSWORD, + DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD + ]; + + private DataSourceERIUsernamePasswordMode usernamePasswordMode = DataSourceERIUsernamePasswordMode.OS_USERNAME_SHARED_PASSWORD; + + private string GetUsernamePasswordModeText() => this.GetUsernamePasswordModeText(this.usernamePasswordMode); + + private string GetUsernamePasswordModeText(DataSourceERIUsernamePasswordMode mode) => mode switch + { + DataSourceERIUsernamePasswordMode.OS_USERNAME_SHARED_PASSWORD => T("Read each user's username from the operating system and share one password"), + DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD => T("Use the same username and password for all users"), + + _ => T("User-managed username and password"), + }; + + private void Cancel() => this.MudDialog.Cancel(); + + private void Export() => this.MudDialog.Close(DialogResult.Ok(new DataSourceERIV1UsernamePasswordExportDialogResult(this.usernamePasswordMode))); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/DataSourceERIV1UsernamePasswordExportDialogResult.cs b/app/MindWork AI Studio/Dialogs/DataSourceERIV1UsernamePasswordExportDialogResult.cs new file mode 100644 index 00000000..907f920e --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/DataSourceERIV1UsernamePasswordExportDialogResult.cs @@ -0,0 +1,5 @@ +using AIStudio.Settings.DataModel; + +namespace AIStudio.Dialogs; + +public readonly record struct DataSourceERIV1UsernamePasswordExportDialogResult(DataSourceERIUsernamePasswordMode UsernamePasswordMode); \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor.cs b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor.cs index 4a16bd18..8bec772a 100644 --- a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor.cs @@ -116,7 +116,7 @@ public partial class DataSourceERI_V1Dialog : MSGComponentBase, ISecretId if (this.dataAuthMethod is AuthMethod.TOKEN or AuthMethod.USERNAME_PASSWORD) { // Load the secret: - var requestedSecret = await this.RustService.GetSecret(this); + var requestedSecret = await this.RustService.GetSecret(this, SecretStoreType.DATA_SOURCE); if (requestedSecret.Success) this.dataSecret = await requestedSecret.Secret.Decrypt(this.encryption); else @@ -169,6 +169,7 @@ public partial class DataSourceERI_V1Dialog : MSGComponentBase, ISecretId Hostname = cleanedHostname.EndsWith('/') ? cleanedHostname[..^1] : cleanedHostname, AuthMethod = this.dataAuthMethod, Username = this.dataUsername, + UsernamePasswordMode = DataSourceERIUsernamePasswordMode.USER_MANAGED, Type = DataSourceType.ERI_V1, SecurityPolicy = this.dataSecurityPolicy, SelectedRetrievalId = this.dataSelectedRetrievalProcess.Id, @@ -323,7 +324,7 @@ public partial class DataSourceERI_V1Dialog : MSGComponentBase, ISecretId if (!string.IsNullOrWhiteSpace(this.dataSecret)) { // Store the secret in the OS secure storage: - var storeResponse = await this.RustService.SetSecret(this, this.dataSecret); + var storeResponse = await this.RustService.SetSecret(this, this.dataSecret, SecretStoreType.DATA_SOURCE); if (!storeResponse.Success) { this.dataSecretStorageIssue = string.Format(T("Failed to store the auth. secret in the operating system. The message was: {0}. Please try again."), storeResponse.Issue); diff --git a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1InfoDialog.razor b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1InfoDialog.razor index aa6b7b7d..fa9766fe 100644 --- a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1InfoDialog.razor +++ b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1InfoDialog.razor @@ -21,7 +21,7 @@ @if (this.DataSource.AuthMethod is AuthMethod.USERNAME_PASSWORD) { - + } diff --git a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1InfoDialog.razor.cs b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1InfoDialog.razor.cs index 38ed220a..02d522b6 100644 --- a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1InfoDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1InfoDialog.razor.cs @@ -41,6 +41,7 @@ public partial class DataSourceERI_V1InfoDialog : MSGComponentBase, IAsyncDispos private readonly List dataIssues = []; private string serverDescription = string.Empty; + private string effectiveUsername = string.Empty; private ProviderType securityRequirements = ProviderType.NONE; private IReadOnlyList retrievalInfoformation = []; private RetrievalInfo selectedRetrievalInfo; @@ -51,6 +52,27 @@ public partial class DataSourceERI_V1InfoDialog : MSGComponentBase, IAsyncDispos private string Port => this.DataSource.Port == 0 ? string.Empty : $"{this.DataSource.Port}"; + private async Task<(bool Success, DataSourceERI_V1 EffectiveDataSource)> CreateEffectiveDataSource() + { + this.effectiveUsername = this.DataSource.Username; + if (this.DataSource is not { AuthMethod: AuthMethod.USERNAME_PASSWORD, UsernamePasswordMode: DataSourceERIUsernamePasswordMode.OS_USERNAME_SHARED_PASSWORD }) + return (true, this.DataSource); + + var osUsername = await this.RustService.ReadUserName(); + if (string.IsNullOrWhiteSpace(osUsername)) + { + this.dataIssues.Add(T("Failed to read the user's username from the operating system.")); + return (false, this.DataSource); + } + + this.effectiveUsername = osUsername; + return (true, this.DataSource with + { + Username = osUsername, + UsernamePasswordMode = DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD, + }); + } + private string RetrievalName(RetrievalInfo retrievalInfo) { var hasId = !string.IsNullOrWhiteSpace(retrievalInfo.Id); @@ -91,15 +113,19 @@ public partial class DataSourceERI_V1InfoDialog : MSGComponentBase, IAsyncDispos { this.IsOperationInProgress = true; this.StateHasChanged(); + + var effectiveDataSourceResult = await this.CreateEffectiveDataSource(); + if (!effectiveDataSourceResult.Success) + return; - using var client = ERIClientFactory.Get(ERIVersion.V1, this.DataSource); + using var client = ERIClientFactory.Get(ERIVersion.V1, effectiveDataSourceResult.EffectiveDataSource); if(client is null) { this.dataIssues.Add(T("Failed to connect to the ERI v1 server. The server is not supported.")); return; } - var loginResult = await client.AuthenticateAsync(this.RustService); + var loginResult = await client.AuthenticateAsync(this.RustService, cancellationToken: this.cts.Token); if (!loginResult.Successful) { this.dataIssues.Add(loginResult.Message); diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor index 74b15fdb..7755044d 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor @@ -38,12 +38,27 @@ - - @T("Edit") - - - @T("Delete") - + @if (context.IsEnterpriseConfiguration) + { + + + + } + else + { + + @T("Edit") + + @if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings && context is DataSourceERI_V1) + { + + + + } + + @T("Delete") + + } diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor.cs b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor.cs index c22bed94..ff706363 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor.cs +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor.cs @@ -1,11 +1,17 @@ using AIStudio.Settings; using AIStudio.Settings.DataModel; using AIStudio.Tools.ERIClient.DataModel; +using AIStudio.Tools.PluginSystem; + +using Microsoft.AspNetCore.Components; namespace AIStudio.Dialogs.Settings; public partial class SettingsDialogDataSources : SettingsDialogBase { + [Inject] + private ISnackbar Snackbar { get; init; } = null!; + private string GetEmbeddingName(IDataSource dataSource) { if(dataSource is IInternalDataSource internalDataSource) @@ -86,9 +92,106 @@ public partial class SettingsDialogDataSources : SettingsDialogBase await this.SettingsManager.StoreSettings(); await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } + + private async Task ExportDataSource(IDataSource dataSource) + { + if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings) + return; + + if (dataSource is not DataSourceERI_V1 eriDataSource) + return; + + if (eriDataSource.AuthMethod is AuthMethod.KERBEROS) + { + await this.DialogService.ShowMessageBox( + T("Export ERI Data Source"), + T("Kerberos/SSO ERI data sources cannot be exported yet. Please configure them manually in the configuration plugin."), + T("Close")); + return; + } + + var needsSecret = eriDataSource.AuthMethod is AuthMethod.TOKEN or AuthMethod.USERNAME_PASSWORD; + if (!needsSecret) + { + var publicLuaCode = eriDataSource.ExportAsConfigurationSection(); + if (!string.IsNullOrWhiteSpace(publicLuaCode)) + await this.RustService.CopyText2Clipboard(this.Snackbar, publicLuaCode); + + return; + } + + var secretResponse = await this.RustService.GetSecret(eriDataSource, SecretStoreType.DATA_SOURCE, isTrying: true); + if (!secretResponse.Success) + { + await this.DialogService.ShowMessageBox( + T("Export ERI Data Source"), + string.Format(T("Cannot export this ERI data source because no authentication secret is configured. The issue was: {0}"), secretResponse.Issue), + T("Close")); + return; + } + + var encryption = PluginFactory.EnterpriseEncryption; + if (encryption?.IsAvailable != true) + { + await this.DialogService.ShowMessageBox( + T("Export ERI Data Source"), + T("Cannot export this ERI data source because no enterprise encryption secret is configured."), + T("Close")); + return; + } + + var usernamePasswordMode = DataSourceERIUsernamePasswordMode.USER_MANAGED; + if (eriDataSource.AuthMethod is AuthMethod.TOKEN) + { + var dialogParameters = new DialogParameters + { + { x => x.Message, T("This ERI data source has an access token configured. Do you want to include the encrypted access token in the export? Note: The recipient will need the same encryption secret to use the access token.") }, + }; + + var dialogReference = await this.DialogService.ShowAsync(T("Export Access Token?"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult is null || dialogResult.Canceled) + return; + } + else if (eriDataSource.AuthMethod is AuthMethod.USERNAME_PASSWORD) + { + var dialogParameters = new DialogParameters + { + { x => x.DataSource, eriDataSource }, + }; + + var dialogReference = await this.DialogService.ShowAsync(T("Export ERI Data Source"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is not DataSourceERIV1UsernamePasswordExportDialogResult exportResult) + return; + + usernamePasswordMode = exportResult.UsernamePasswordMode; + } + + var decryptedSecret = await secretResponse.Secret.Decrypt(Program.ENCRYPTION); + if (!encryption.TryEncrypt(decryptedSecret, out var encryptedSecret)) + { + await this.DialogService.ShowMessageBox( + T("Export ERI Data Source"), + T("Cannot export this ERI data source because the authentication secret could not be encrypted."), + T("Close")); + return; + } + + var luaCode = eriDataSource.ExportAsConfigurationSection( + encryptedSecret, + usernamePasswordMode); + if (string.IsNullOrWhiteSpace(luaCode)) + return; + + await this.RustService.CopyText2Clipboard(this.Snackbar, luaCode); + } private async Task EditDataSource(IDataSource dataSource) { + if (dataSource.IsEnterpriseConfiguration) + return; + IDataSource? editedDataSource = null; switch (dataSource) { @@ -151,6 +254,9 @@ public partial class SettingsDialogDataSources : SettingsDialogBase private async Task DeleteDataSource(IDataSource dataSource) { + if (dataSource.IsEnterpriseConfiguration) + return; + var dialogParameters = new DialogParameters { { x => x.Message, string.Format(T("Are you sure you want to delete the data source '{0}' of type {1}?"), dataSource.Name, dataSource.Type.GetDisplayName()) }, @@ -174,7 +280,7 @@ public partial class SettingsDialogDataSources : SettingsDialogBase // All other auth methods require a secret, which we need to delete now: else { - var deleteSecretResponse = await this.RustService.DeleteSecret(externalDataSource); + var deleteSecretResponse = await this.RustService.DeleteSecret(externalDataSource, SecretStoreType.DATA_SOURCE); if (deleteSecretResponse.Success) applyChanges = true; } diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index a1659f34..a7a6a8df 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -83,7 +83,9 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan // Read the user language from Rust: // var userLanguage = await this.RustService.ReadUserLanguage(); + var userName = await this.RustService.ReadUserName(); this.Logger.LogInformation($"The OS says '{userLanguage}' is the user language."); + this.Logger.LogInformation($"The OS says '{userName}' is the username."); // Ensure that all settings are loaded: await this.SettingsManager.LoadSettings(); diff --git a/app/MindWork AI Studio/Pages/Information.razor b/app/MindWork AI Studio/Pages/Information.razor index 119611cb..ae24887d 100644 --- a/app/MindWork AI Studio/Pages/Information.razor +++ b/app/MindWork AI Studio/Pages/Information.razor @@ -47,6 +47,7 @@ + @switch (HasAnyActiveEnvironment) { @@ -301,6 +302,7 @@ + diff --git a/app/MindWork AI Studio/Pages/Information.razor.cs b/app/MindWork AI Studio/Pages/Information.razor.cs index 8f2192a5..10a6b614 100644 --- a/app/MindWork AI Studio/Pages/Information.razor.cs +++ b/app/MindWork AI Studio/Pages/Information.razor.cs @@ -40,6 +40,7 @@ public partial class Information : MSGComponentBase private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(Information).Namespace, nameof(Information)); private string osLanguage = string.Empty; + private string osUserName = string.Empty; private static string VersionApp => $"MindWork AI Studio: v{META_DATA.Version} (commit {META_DATA.AppCommitHash}, build {META_DATA.BuildNum}, {META_DATA_ARCH.Architecture.ToRID().ToUserFriendlyName()})"; @@ -49,6 +50,8 @@ public partial class Information : MSGComponentBase private string OSLanguage => $"{T("User-language provided by the OS")}: '{this.osLanguage}'"; + private string OSUserName => $"{T("Username provided by the OS")}: '{this.osUserName}'"; + private string VersionRust => $"{T("Used Rust compiler")}: v{META_DATA.RustVersion}"; private string VersionDotnetRuntime => $"{T("Used .NET runtime")}: v{META_DATA.DotnetVersion}"; @@ -128,6 +131,7 @@ public partial class Information : MSGComponentBase this.RefreshEnterpriseConfigurationState(); this.osLanguage = await this.RustService.ReadUserLanguage(); + this.osUserName = await this.RustService.ReadUserName(); this.logPaths = await this.RustService.GetLogPaths(); await foreach (var (label, value) in this.DatabaseClient.GetDisplayInfo()) diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index b4a942a7..6d2d51d3 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -136,6 +136,54 @@ CONFIG["EMBEDDING_PROVIDERS"] = {} -- } -- } +-- ERI v1 data sources for retrieval-augmented generation: +CONFIG["DATA_SOURCES"] = {} + +-- Example: ERI v1 data source with a shared access token. +-- CONFIG["DATA_SOURCES"][#CONFIG["DATA_SOURCES"]+1] = { +-- ["Id"] = "00000000-0000-0000-0000-000000000000", +-- ["Name"] = "", +-- ["Type"] = "ERI_V1", +-- ["Hostname"] = "", +-- ["Port"] = 443, +-- ["AuthMethod"] = "TOKEN", +-- ["Token"] = "ENC:v1:", +-- ["SecurityPolicy"] = "SELF_HOSTED", +-- ["SelectedRetrievalId"] = "", +-- ["MaxMatches"] = 10, +-- } + +-- Example: ERI v1 data source with a shared username and password. +-- CONFIG["DATA_SOURCES"][#CONFIG["DATA_SOURCES"]+1] = { +-- ["Id"] = "00000000-0000-0000-0000-000000000000", +-- ["Name"] = "", +-- ["Type"] = "ERI_V1", +-- ["Hostname"] = "", +-- ["Port"] = 443, +-- ["AuthMethod"] = "USERNAME_PASSWORD", +-- ["UsernamePasswordMode"] = "SHARED_USERNAME_AND_PASSWORD", +-- ["Username"] = "", +-- ["Password"] = "ENC:v1:", +-- ["SecurityPolicy"] = "SELF_HOSTED", +-- ["SelectedRetrievalId"] = "", +-- ["MaxMatches"] = 10, +-- } + +-- Example: ERI v1 data source using the user's username and a shared password. +-- CONFIG["DATA_SOURCES"][#CONFIG["DATA_SOURCES"]+1] = { +-- ["Id"] = "00000000-0000-0000-0000-000000000000", +-- ["Name"] = "", +-- ["Type"] = "ERI_V1", +-- ["Hostname"] = "", +-- ["Port"] = 443, +-- ["AuthMethod"] = "USERNAME_PASSWORD", +-- ["UsernamePasswordMode"] = "OS_USERNAME_SHARED_PASSWORD", +-- ["Password"] = "ENC:v1:", +-- ["SecurityPolicy"] = "SELF_HOSTED", +-- ["SelectedRetrievalId"] = "", +-- ["MaxMatches"] = 10, +-- } + CONFIG["SETTINGS"] = {} -- Configure the update check interval: diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index 300ad595..adcba747 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -3633,6 +3633,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T2879113658"] = -- Maximum matches per query UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T2889706179"] = "Maximale Treffer pro Abfrage" +-- Failed to read the user's username from the operating system. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T2909734556"] = "Der Benutzername des Nutzers konnte nicht aus dem Betriebssystem gelesen werden." + -- Open web link, show more information UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T2968752071"] = "Weblink öffnen & mehr Informationen anzeigen" @@ -3684,6 +3687,27 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T742006305"] = " -- Embeddings UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T951463987"] = "Einbettungen" +-- Use the same username and password for all users +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1769874785"] = "Für alle Benutzer denselben Benutzernamen und dasselbe Passwort verwenden" + +-- Username and password mode +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1787063064"] = "Modus für den Benutzernamen und das Passwort" + +-- How should AI Studio export the username and password configuration for the ERI v1 data source '{0}'? +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3081234668"] = "Wie soll AI Studio die Konfiguration von Benutzername und Passwort für die ERI-v1-Datenquelle „{0}“ exportieren?" + +-- User-managed username and password +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T365340972"] = "Vom Benutzer verwaltete Anmeldedaten (Benutzername und Passwort)" + +-- Export +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3898821075"] = "Exportieren" + +-- Read each user's username from the operating system and share one password +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T76405695"] = "Den Benutzernamen jedes Benutzers aus dem Betriebssystem auslesen und ein Passwort teilen." + +-- Cancel +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T900713019"] = "Abbrechen" + -- Describe what data this directory contains to help the AI select it. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1136409150"] = "Beschreiben Sie, welche Daten dieses Verzeichnis enthält, um der KI bei der Auswahl zu helfen." @@ -4812,6 +4836,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T145419 -- Delete UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T1469573738"] = "Löschen" +-- Kerberos/SSO ERI data sources cannot be exported yet. Please configure them manually in the configuration plugin. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T1577531115"] = "Kerberos-/SSO-ERI-Datenquellen können noch nicht exportiert werden. Bitte konfigurieren Sie diese manuell im Konfigurations-Plugin." + +-- Cannot export this ERI data source because the authentication secret could not be encrypted. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T1592527757"] = "Diese ERI-Datenquelle kann nicht exportiert werden, da das Authentifizierungsgeheimnis nicht verschlüsselt werden konnte." + -- External (ERI) UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T1652430727"] = "Extern (ERI)" @@ -4842,6 +4872,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T269820 -- Embedding UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T2838542994"] = "Einbettung" +-- This data source is managed by your organization. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3031462878"] = "Diese Datenquelle wird von Ihrer Organisation verwaltet." + -- Edit UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3267849393"] = "Bearbeiten" @@ -4866,21 +4899,39 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T352566 -- No data sources configured yet. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3549650120"] = "Noch keine Datenquellen konfiguriert." +-- Export Access Token? +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3595669127"] = "Zugriffstoken exportieren?" + +-- Export ERI Data Source +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3831281036"] = "ERI-Datenquelle exportieren" + -- Actions UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3865031940"] = "Aktionen" +-- This ERI data source has an access token configured. Do you want to include the encrypted access token in the export? Note: The recipient will need the same encryption secret to use the access token. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T4027572258"] = "Für diese ERI-Datenquelle ist ein Zugriffstoken konfiguriert. Möchten Sie das verschlüsselte Zugriffstoken in den Export aufnehmen? Hinweis: Der Empfänger benötigt dasselbe Geheimnis für die Verschlüsselung, um das Zugriffstoken verwenden zu können." + -- Configured Data Sources UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T543942217"] = "Konfigurierte Datenquellen" -- Add ERI v1 Data Source UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T590005498"] = "ERI v1 Datenquelle hinzufügen" +-- Cannot export this ERI data source because no enterprise encryption secret is configured. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T750361472"] = "Diese ERI-Datenquelle kann nicht exportiert werden, da kein Geheimnis für die Verschlüsselung konfiguriert ist." + -- External Data (ERI-Server v1) UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T774473996"] = "Externe Daten (ERI-Server v1)" +-- Cannot export this ERI data source because no authentication secret is configured. The issue was: {0} +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T782820095"] = "Diese ERI-Datenquelle kann nicht exportiert werden, da kein Authentifizierungsgeheimnis konfiguriert ist. Das Problem war: {0}" + -- Local Directory UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T926703547"] = "Lokaler Ordner" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T975426229"] = "Konfiguration exportieren" + -- When enabled, you can preselect some ERI server options. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGERISERVER::T1280666275"] = "Wenn aktiviert, können Sie einige ERI-Serveroptionen vorauswählen." @@ -6171,6 +6222,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3574465749"] = "nicht verfügbar -- This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3722989559"] = "Diese Bibliothek wird verwendet, um Excel- und OpenDocument-Tabellendateien zu lesen. Dies ist zum Beispiel notwendig, wenn Tabellen als Datenquelle für einen Chat verwendet werden sollen." +-- Username provided by the OS +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3764549776"] = "Vom Betriebssystem bereitgestellter Benutzername" + -- this version does not met the requirements UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3813932670"] = "diese Version erfüllt die Anforderungen nicht" @@ -6192,6 +6246,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4010195468"] = "Versionen" -- Database UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4036243672"] = "Datenbank" +-- This library is used by the Rust runtime to read the current user's username, e.g. when an organization-managed ERI server uses the OS username for authentication. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4060906280"] = "Diese Bibliothek wird von der Rust-Laufzeitumgebung verwendet, um den Benutzernamen des aktuellen Benutzers auszulesen, z. B. wenn ein von einer Organisation verwalteter ERI-Server den OS-Benutzernamen für die Authentifizierung verwendet." + -- This library is used to create asynchronous streams in Rust. It allows us to work with streams of data that can be produced asynchronously, making it easier to handle events or data that arrive over time. We use this, e.g., to stream arbitrary data from the file system to the embedding system. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4079152443"] = "Diese Bibliothek wird verwendet, um asynchrone Datenströme in Rust zu erstellen. Sie ermöglicht es uns, mit Datenströmen zu arbeiten, die asynchron bereitgestellt werden, wodurch sich Ereignisse oder Daten, die nach und nach eintreffen, leichter verarbeiten lassen. Wir nutzen dies zum Beispiel, um beliebige Daten aus dem Dateisystem an das Einbettungssystem zu übertragen." @@ -6930,6 +6987,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T2858189239"] = "Authe -- Failed to retrieve the security requirements: the request was canceled either by the user or due to a timeout. UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T286437836"] = "Die Sicherheitsanforderungen konnten nicht abgerufen werden: Die Anfrage wurde entweder vom Benutzer abgebrochen oder ist aufgrund eines Zeitüberschreitungsfehlers fehlgeschlagen." +-- Failed to read the user's username from the operating system. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T2909734556"] = "Der Benutzername konnte nicht aus dem Betriebssystem ausgelesen werden." + -- Failed to retrieve the security requirements due to an exception: {0} UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T3221004295"] = "Die Sicherheitsanforderungen konnten wegen eines Problems nicht abgerufen werden: {0}" @@ -7506,6 +7566,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T18544701 -- Pandoc may be required for importing files. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T2596465560"] = "Zum Importieren von Dateien kann Pandoc erforderlich sein." +-- Failed to store the secret data due to an API issue. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1110203516"] = "Fehler beim Speichern der geheimen Daten aufgrund eines API-Problems." + -- Failed to delete the secret data due to an API issue. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2303057928"] = "Das Löschen der geheimen Daten ist aufgrund eines API-Problems fehlgeschlagen." diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index e629d833..0f5389cf 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -3633,6 +3633,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T2879113658"] = -- Maximum matches per query UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T2889706179"] = "Maximum matches per query" +-- Failed to read the user's username from the operating system. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T2909734556"] = "Failed to read the user's username from the operating system." + -- Open web link, show more information UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T2968752071"] = "Open web link, show more information" @@ -3684,6 +3687,27 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T742006305"] = " -- Embeddings UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T951463987"] = "Embeddings" +-- Use the same username and password for all users +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1769874785"] = "Use the same username and password for all users" + +-- Username and password mode +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1787063064"] = "Username and password mode" + +-- How should AI Studio export the username and password configuration for the ERI v1 data source '{0}'? +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3081234668"] = "How should AI Studio export the username and password configuration for the ERI v1 data source '{0}'?" + +-- User-managed username and password +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T365340972"] = "User-managed username and password" + +-- Export +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3898821075"] = "Export" + +-- Read each user's username from the operating system and share one password +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T76405695"] = "Read each user's username from the operating system and share one password" + +-- Cancel +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T900713019"] = "Cancel" + -- Describe what data this directory contains to help the AI select it. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1136409150"] = "Describe what data this directory contains to help the AI select it." @@ -4812,6 +4836,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T145419 -- Delete UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T1469573738"] = "Delete" +-- Kerberos/SSO ERI data sources cannot be exported yet. Please configure them manually in the configuration plugin. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T1577531115"] = "Kerberos/SSO ERI data sources cannot be exported yet. Please configure them manually in the configuration plugin." + +-- Cannot export this ERI data source because the authentication secret could not be encrypted. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T1592527757"] = "Cannot export this ERI data source because the authentication secret could not be encrypted." + -- External (ERI) UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T1652430727"] = "External (ERI)" @@ -4842,6 +4872,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T269820 -- Embedding UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T2838542994"] = "Embedding" +-- This data source is managed by your organization. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3031462878"] = "This data source is managed by your organization." + -- Edit UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3267849393"] = "Edit" @@ -4866,21 +4899,39 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T352566 -- No data sources configured yet. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3549650120"] = "No data sources configured yet." +-- Export Access Token? +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3595669127"] = "Export Access Token?" + +-- Export ERI Data Source +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3831281036"] = "Export ERI Data Source" + -- Actions UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3865031940"] = "Actions" +-- This ERI data source has an access token configured. Do you want to include the encrypted access token in the export? Note: The recipient will need the same encryption secret to use the access token. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T4027572258"] = "This ERI data source has an access token configured. Do you want to include the encrypted access token in the export? Note: The recipient will need the same encryption secret to use the access token." + -- Configured Data Sources UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T543942217"] = "Configured Data Sources" -- Add ERI v1 Data Source UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T590005498"] = "Add ERI v1 Data Source" +-- Cannot export this ERI data source because no enterprise encryption secret is configured. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T750361472"] = "Cannot export this ERI data source because no enterprise encryption secret is configured." + -- External Data (ERI-Server v1) UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T774473996"] = "External Data (ERI-Server v1)" +-- Cannot export this ERI data source because no authentication secret is configured. The issue was: {0} +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T782820095"] = "Cannot export this ERI data source because no authentication secret is configured. The issue was: {0}" + -- Local Directory UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T926703547"] = "Local Directory" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T975426229"] = "Export configuration" + -- When enabled, you can preselect some ERI server options. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGERISERVER::T1280666275"] = "When enabled, you can preselect some ERI server options." @@ -6171,6 +6222,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3574465749"] = "not available" -- This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3722989559"] = "This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat." +-- Username provided by the OS +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3764549776"] = "Username provided by the OS" + -- this version does not met the requirements UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3813932670"] = "this version does not met the requirements" @@ -6192,6 +6246,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4010195468"] = "Versions" -- Database UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4036243672"] = "Database" +-- This library is used by the Rust runtime to read the current user's username, e.g. when an organization-managed ERI server uses the OS username for authentication. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4060906280"] = "This library is used by the Rust runtime to read the current user's username, e.g. when an organization-managed ERI server uses the OS username for authentication." + -- This library is used to create asynchronous streams in Rust. It allows us to work with streams of data that can be produced asynchronously, making it easier to handle events or data that arrive over time. We use this, e.g., to stream arbitrary data from the file system to the embedding system. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4079152443"] = "This library is used to create asynchronous streams in Rust. It allows us to work with streams of data that can be produced asynchronously, making it easier to handle events or data that arrive over time. We use this, e.g., to stream arbitrary data from the file system to the embedding system." @@ -6930,6 +6987,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T2858189239"] = "Faile -- Failed to retrieve the security requirements: the request was canceled either by the user or due to a timeout. UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T286437836"] = "Failed to retrieve the security requirements: the request was canceled either by the user or due to a timeout." +-- Failed to read the user's username from the operating system. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T2909734556"] = "Failed to read the user's username from the operating system." + -- Failed to retrieve the security requirements due to an exception: {0} UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T3221004295"] = "Failed to retrieve the security requirements due to an exception: {0}" @@ -7506,6 +7566,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T18544701 -- Pandoc may be required for importing files. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T2596465560"] = "Pandoc may be required for importing files." +-- Failed to store the secret data due to an API issue. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1110203516"] = "Failed to store the secret data due to an API issue." + -- Failed to delete the secret data due to an API issue. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2303057928"] = "Failed to delete the secret data due to an API issue." diff --git a/app/MindWork AI Studio/Settings/DataModel/DataSourceERIUsernamePasswordMode.cs b/app/MindWork AI Studio/Settings/DataModel/DataSourceERIUsernamePasswordMode.cs new file mode 100644 index 00000000..67a86c41 --- /dev/null +++ b/app/MindWork AI Studio/Settings/DataModel/DataSourceERIUsernamePasswordMode.cs @@ -0,0 +1,19 @@ +namespace AIStudio.Settings.DataModel; + +public enum DataSourceERIUsernamePasswordMode +{ + /// + /// The user manages the username and password locally. + /// + USER_MANAGED, + + /// + /// The username and password are shared by all users and provided by configuration. + /// + SHARED_USERNAME_AND_PASSWORD, + + /// + /// The username is read from the operating system, and the password is shared by all users. + /// + OS_USERNAME_SHARED_PASSWORD, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/DataModel/DataSourceERI_V1.cs b/app/MindWork AI Studio/Settings/DataModel/DataSourceERI_V1.cs index cbc3839c..cd254751 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataSourceERI_V1.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataSourceERI_V1.cs @@ -4,9 +4,12 @@ using AIStudio.Assistants.ERI; using AIStudio.Chat; using AIStudio.Tools.ERIClient; using AIStudio.Tools.ERIClient.DataModel; +using AIStudio.Tools.PluginSystem; using AIStudio.Tools.RAG; using AIStudio.Tools.Services; +using Lua; + using ChatThread = AIStudio.Chat.ChatThread; using ContentType = AIStudio.Tools.ERIClient.DataModel.ContentType; @@ -17,6 +20,8 @@ namespace AIStudio.Settings.DataModel; /// public readonly record struct DataSourceERI_V1 : IERIDataSource { + private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(); + public DataSourceERI_V1() { } @@ -45,8 +50,17 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource /// public string Username { get; init; } = string.Empty; + /// + public DataSourceERIUsernamePasswordMode UsernamePasswordMode { get; init; } = DataSourceERIUsernamePasswordMode.USER_MANAGED; + /// public DataSourceSecurity SecurityPolicy { get; init; } = DataSourceSecurity.NOT_SPECIFIED; + + /// + public bool IsEnterpriseConfiguration { get; init; } + + /// + public Guid EnterpriseConfigurationPluginId { get; init; } = Guid.Empty; /// public ERIVersion Version { get; init; } = ERIVersion.V1; @@ -82,7 +96,7 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource Thread = await thread.ToERIChatThread(token), MaxMatches = this.MaxMatches, - RetrievalProcessId = string.IsNullOrWhiteSpace(this.SelectedRetrievalId) ? null : this.SelectedRetrievalId, + RetrievalProcessId = this.SelectedRetrievalId, Parameters = null, // The ERI server selects useful default parameters }; @@ -139,4 +153,240 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource logger.LogWarning($"Was not able to authenticate with the ERI data source '{this.Name}'. Message: {authResponse.Message}"); return []; } + + public static bool TryParseConfiguration(int idx, LuaTable table, Guid configPluginId, out DataSourceERI_V1 dataSource) + { + dataSource = default; + if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead(out var idText) || !Guid.TryParse(idText, out var id)) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid ID. The ID must be a valid GUID. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("Name", out var nameValue) || !nameValue.TryRead(out var name) || string.IsNullOrWhiteSpace(name)) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid name. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("Type", out var typeValue) || !typeValue.TryRead(out var typeText) || !Enum.TryParse(typeText, true, out var type) || type is not DataSourceType.ERI_V1) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a supported data source type. Only ERI_V1 is supported. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("Hostname", out var hostnameValue) || !hostnameValue.TryRead(out var hostname) || string.IsNullOrWhiteSpace(hostname)) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid hostname. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("Port", out var portValue) || !portValue.TryRead(out var port) || port is < 1 or > 65535) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid port. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("AuthMethod", out var authMethodValue) || !authMethodValue.TryRead(out var authMethodText) || !Enum.TryParse(authMethodText, true, out var authMethod)) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid auth method. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("SecurityPolicy", out var securityPolicyValue) || !securityPolicyValue.TryRead(out var securityPolicyText) || !Enum.TryParse(securityPolicyText, true, out var securityPolicy)) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid security policy. (Plugin ID: {configPluginId})"); + return false; + } + + if (securityPolicy is DataSourceSecurity.NOT_SPECIFIED) + { + LOGGER.LogWarning($"The configured data source {idx} must specify a security policy. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("SelectedRetrievalId", out var selectedRetrievalIdValue) || !selectedRetrievalIdValue.TryRead(out var selectedRetrievalId) || string.IsNullOrWhiteSpace(selectedRetrievalId)) + { + LOGGER.LogWarning($"The configured data source {idx} must specify a selected retrieval ID. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("MaxMatches", out var maxMatchesValue) || !maxMatchesValue.TryRead(out var maxMatches) || maxMatches is < 1 or > ushort.MaxValue) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid maximum number of matches. (Plugin ID: {configPluginId})"); + return false; + } + + var username = string.Empty; + var usernamePasswordMode = DataSourceERIUsernamePasswordMode.USER_MANAGED; + if (table.TryGetValue("UsernamePasswordMode", out var usernamePasswordModeValue) && usernamePasswordModeValue.TryRead(out var usernamePasswordModeText)) + { + if (!Enum.TryParse(usernamePasswordModeText, true, out usernamePasswordMode)) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid username/password mode. (Plugin ID: {configPluginId})"); + return false; + } + + if (usernamePasswordMode is DataSourceERIUsernamePasswordMode.USER_MANAGED) + { + LOGGER.LogWarning($"The configured data source {idx} uses the user-managed username/password mode. This mode is not allowed in configuration plugins. (Plugin ID: {configPluginId})"); + return false; + } + } + + if (authMethod is AuthMethod.USERNAME_PASSWORD) + { + if (!table.TryGetValue("UsernamePasswordMode", out _) || usernamePasswordMode is DataSourceERIUsernamePasswordMode.USER_MANAGED) + { + LOGGER.LogWarning($"The configured data source {idx} must specify an organization-managed username/password mode. (Plugin ID: {configPluginId})"); + return false; + } + + if (usernamePasswordMode is DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD && + (!table.TryGetValue("Username", out var usernameValue) || !usernameValue.TryRead(out username) || string.IsNullOrWhiteSpace(username))) + { + LOGGER.LogWarning($"The configured data source {idx} must specify a username. (Plugin ID: {configPluginId})"); + return false; + } + } + + dataSource = new DataSourceERI_V1 + { + Num = 0, + Id = id.ToString(), + Name = name, + Type = DataSourceType.ERI_V1, + Hostname = CleanHostname(hostname), + Port = port, + AuthMethod = authMethod, + Username = username, + UsernamePasswordMode = usernamePasswordMode, + SecurityPolicy = securityPolicy, + Version = ERIVersion.V1, + SelectedRetrievalId = selectedRetrievalId, + MaxMatches = (ushort)maxMatches, + IsEnterpriseConfiguration = true, + EnterpriseConfigurationPluginId = configPluginId, + }; + + return TryQueueEnterpriseSecret(idx, table, configPluginId, dataSource); + } + + /// + /// Exports the ERI v1 data source configuration as a Lua configuration section. + /// + /// Optional encrypted token or password to include in the export. + /// The organization-managed username/password mode to export. + /// A Lua configuration section string. + public string ExportAsConfigurationSection(string? encryptedSecret = null, DataSourceERIUsernamePasswordMode usernamePasswordMode = DataSourceERIUsernamePasswordMode.USER_MANAGED) + { + var secretLine = string.Empty; + var usernamePasswordModeLine = string.Empty; + var usernameLine = string.Empty; + + switch (this.AuthMethod) + { + case AuthMethod.TOKEN: + secretLine = CreateSecretLine("Token", encryptedSecret); + break; + + case AuthMethod.USERNAME_PASSWORD: + if (usernamePasswordMode is DataSourceERIUsernamePasswordMode.USER_MANAGED) + usernamePasswordMode = DataSourceERIUsernamePasswordMode.OS_USERNAME_SHARED_PASSWORD; + + usernamePasswordModeLine = $""" + ["UsernamePasswordMode"] = "{usernamePasswordMode}", + """; + + if (usernamePasswordMode is DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD) + { + var username = string.IsNullOrWhiteSpace(this.Username) ? "" : this.Username; + usernameLine = $""" + ["Username"] = "{LuaTools.EscapeLuaString(username)}", + """; + } + + secretLine = CreateSecretLine("Password", encryptedSecret); + break; + } + + return $$""" + CONFIG["DATA_SOURCES"][#CONFIG["DATA_SOURCES"]+1] = { + ["Id"] = "{{Guid.NewGuid().ToString()}}", + ["Name"] = "{{LuaTools.EscapeLuaString(this.Name)}}", + ["Type"] = "ERI_V1", + ["Hostname"] = "{{LuaTools.EscapeLuaString(this.Hostname)}}", + ["Port"] = {{this.Port}}, + ["AuthMethod"] = "{{this.AuthMethod}}", + {{usernamePasswordModeLine}} + {{usernameLine}} + {{secretLine}} + ["SecurityPolicy"] = "{{this.SecurityPolicy}}", + ["SelectedRetrievalId"] = "{{LuaTools.EscapeLuaString(this.SelectedRetrievalId)}}", + ["MaxMatches"] = {{this.MaxMatches}}, + } + """; + } + + private static bool TryQueueEnterpriseSecret(int idx, LuaTable table, Guid configPluginId, DataSourceERI_V1 dataSource) + { + var secretFieldName = dataSource.AuthMethod switch + { + AuthMethod.TOKEN => "Token", + AuthMethod.USERNAME_PASSWORD => "Password", + _ => string.Empty, + }; + + if (string.IsNullOrWhiteSpace(secretFieldName)) + return true; + + if (!table.TryGetValue(secretFieldName, out var secretValue) || !secretValue.TryRead(out var encryptedSecret) || string.IsNullOrWhiteSpace(encryptedSecret)) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid encrypted {secretFieldName}. (Plugin ID: {configPluginId})"); + return false; + } + + if (!EnterpriseEncryption.IsEncrypted(encryptedSecret)) + { + LOGGER.LogWarning($"The configured data source {idx} contains a plaintext {secretFieldName}. Only encrypted secrets (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})"); + return false; + } + + var encryption = PluginFactory.EnterpriseEncryption; + if (encryption?.IsAvailable != true) + { + LOGGER.LogWarning($"The configured data source {idx} contains an encrypted {secretFieldName}, but no encryption secret is configured. (Plugin ID: {configPluginId})"); + return false; + } + + if (!encryption.TryDecrypt(encryptedSecret, out var decryptedSecret)) + { + LOGGER.LogWarning($"Failed to decrypt the {secretFieldName} for data source {idx}. The encryption secret may be incorrect. (Plugin ID: {configPluginId})"); + return false; + } + + PendingEnterpriseSecrets.Add(new( + $"{ISecretId.ENTERPRISE_KEY_PREFIX}::{dataSource.Id}", + dataSource.Name, + decryptedSecret, + SecretStoreType.DATA_SOURCE)); + LOGGER.LogDebug($"Successfully decrypted the {secretFieldName} for data source {idx}. It will be stored in the OS keyring. (Plugin ID: {configPluginId})"); + return true; + } + + private static string CreateSecretLine(string fieldName, string? encryptedSecret) + { + if (string.IsNullOrWhiteSpace(encryptedSecret)) + return string.Empty; + + return $""" + ["{fieldName}"] = "{LuaTools.EscapeLuaString(encryptedSecret)}", + """; + } + + private static string CleanHostname(string hostname) + { + var cleanedHostname = hostname.Trim(); + return cleanedHostname.EndsWith('/') ? cleanedHostname[..^1] : cleanedHostname; + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalDirectory.cs b/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalDirectory.cs index d8b263c3..a7531e74 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalDirectory.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalDirectory.cs @@ -35,6 +35,12 @@ public readonly record struct DataSourceLocalDirectory : IInternalDataSource /// public DataSourceSecurity SecurityPolicy { get; init; } = DataSourceSecurity.NOT_SPECIFIED; + + /// + public bool IsEnterpriseConfiguration { get; init; } + + /// + public Guid EnterpriseConfigurationPluginId { get; init; } = Guid.Empty; /// public ushort MaxMatches { get; init; } = 10; diff --git a/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalFile.cs b/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalFile.cs index 11b857d0..0df0790f 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalFile.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalFile.cs @@ -35,6 +35,12 @@ public readonly record struct DataSourceLocalFile : IInternalDataSource /// public DataSourceSecurity SecurityPolicy { get; init; } = DataSourceSecurity.NOT_SPECIFIED; + + /// + public bool IsEnterpriseConfiguration { get; init; } + + /// + public Guid EnterpriseConfigurationPluginId { get; init; } = Guid.Empty; /// public ushort MaxMatches { get; init; } = 10; diff --git a/app/MindWork AI Studio/Settings/IDataSource.cs b/app/MindWork AI Studio/Settings/IDataSource.cs index 9ce3dc9f..7e29123d 100644 --- a/app/MindWork AI Studio/Settings/IDataSource.cs +++ b/app/MindWork AI Studio/Settings/IDataSource.cs @@ -2,6 +2,7 @@ using System.Text.Json.Serialization; using AIStudio.Chat; using AIStudio.Settings.DataModel; +using AIStudio.Tools.PluginSystem; using AIStudio.Tools.RAG; namespace AIStudio.Settings; @@ -13,23 +14,8 @@ namespace AIStudio.Settings; [JsonDerivedType(typeof(DataSourceLocalDirectory), nameof(DataSourceType.LOCAL_DIRECTORY))] [JsonDerivedType(typeof(DataSourceLocalFile), nameof(DataSourceType.LOCAL_FILE))] [JsonDerivedType(typeof(DataSourceERI_V1), nameof(DataSourceType.ERI_V1))] -public interface IDataSource +public interface IDataSource : IConfigurationObject { - /// - /// The number of the data source. - /// - public uint Num { get; init; } - - /// - /// The unique identifier of the data source. - /// - public string Id { get; init; } - - /// - /// The name of the data source. - /// - public string Name { get; init; } - /// /// Which type of data source is this? /// diff --git a/app/MindWork AI Studio/Settings/IERIDataSource.cs b/app/MindWork AI Studio/Settings/IERIDataSource.cs index 55138978..40dd625d 100644 --- a/app/MindWork AI Studio/Settings/IERIDataSource.cs +++ b/app/MindWork AI Studio/Settings/IERIDataSource.cs @@ -1,4 +1,5 @@ using AIStudio.Assistants.ERI; +using AIStudio.Settings.DataModel; using AIStudio.Tools.ERIClient.DataModel; namespace AIStudio.Settings; @@ -24,6 +25,11 @@ public interface IERIDataSource : IExternalDataSource /// The username to use for authentication, when the auth. method is USERNAME_PASSWORD. /// public string Username { get; init; } + + /// + /// How username/password authentication should obtain the username. + /// + public DataSourceERIUsernamePasswordMode UsernamePasswordMode { get; init; } /// /// The ERI specification to use. diff --git a/app/MindWork AI Studio/Settings/IExternalDataSource.cs b/app/MindWork AI Studio/Settings/IExternalDataSource.cs index 8a7c067c..6b75fa56 100644 --- a/app/MindWork AI Studio/Settings/IExternalDataSource.cs +++ b/app/MindWork AI Studio/Settings/IExternalDataSource.cs @@ -7,7 +7,7 @@ public interface IExternalDataSource : IDataSource, ISecretId #region Implementation of ISecretId [JsonIgnore] - string ISecretId.SecretId => this.Id; + string ISecretId.SecretId => this.IsEnterpriseConfiguration ? $"{ENTERPRISE_KEY_PREFIX}::{this.Id}" : this.Id; [JsonIgnore] string ISecretId.SecretName => this.Name; diff --git a/app/MindWork AI Studio/Tools/ERIClient/ERIClientV1.cs b/app/MindWork AI Studio/Tools/ERIClient/ERIClientV1.cs index 2653ca2a..f00976b9 100644 --- a/app/MindWork AI Studio/Tools/ERIClient/ERIClientV1.cs +++ b/app/MindWork AI Studio/Tools/ERIClient/ERIClientV1.cs @@ -2,6 +2,7 @@ using System.Text; using System.Text.Json; using AIStudio.Settings; +using AIStudio.Settings.DataModel; using AIStudio.Tools.ERIClient.DataModel; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.Services; @@ -102,10 +103,23 @@ public class ERIClientV1(IERIDataSource dataSource) : ERIClientBase(dataSource), } case AuthMethod.USERNAME_PASSWORD: + if (this.DataSource.UsernamePasswordMode is DataSourceERIUsernamePasswordMode.OS_USERNAME_SHARED_PASSWORD) + { + username = await rustService.ReadUserName(); + if (string.IsNullOrWhiteSpace(username)) + { + return new() + { + Successful = false, + Message = TB("Failed to read the user's username from the operating system.") + }; + } + } + string password; if (string.IsNullOrWhiteSpace(temporarySecret)) { - var passwordResponse = await rustService.GetSecret(this.DataSource); + var passwordResponse = await rustService.GetSecret(this.DataSource, SecretStoreType.DATA_SOURCE); if (!passwordResponse.Success) { return new() @@ -159,7 +173,7 @@ public class ERIClientV1(IERIDataSource dataSource) : ERIClientBase(dataSource), string token; if (string.IsNullOrWhiteSpace(temporarySecret)) { - var tokenResponse = await rustService.GetSecret(this.DataSource); + var tokenResponse = await rustService.GetSecret(this.DataSource, SecretStoreType.DATA_SOURCE); if (!tokenResponse.Success) { return new() diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKey.cs b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKey.cs index 5f1cb58b..63b7ebfb 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKey.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKey.cs @@ -13,37 +13,4 @@ public sealed record PendingEnterpriseApiKey( string SecretId, string SecretName, string ApiKey, - SecretStoreType StoreType); - -/// -/// Static container for pending API keys during plugin loading. -/// -public static class PendingEnterpriseApiKeys -{ - private static readonly List PENDING_KEYS = []; - private static readonly Lock LOCK = new(); - - /// - /// Adds a pending API key to the list. - /// - /// The pending API key to add. - public static void Add(PendingEnterpriseApiKey key) - { - lock (LOCK) - PENDING_KEYS.Add(key); - } - - /// - /// Gets and clears all pending API keys. - /// - /// A list of all pending API keys. - public static IReadOnlyList GetAndClear() - { - lock (LOCK) - { - var keys = PENDING_KEYS.ToList(); - PENDING_KEYS.Clear(); - return keys; - } - } -} + SecretStoreType StoreType); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKeys.cs b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKeys.cs new file mode 100644 index 00000000..8824424e --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKeys.cs @@ -0,0 +1,34 @@ +namespace AIStudio.Tools.PluginSystem; + +/// +/// Static container for pending API keys during plugin loading. +/// +public static class PendingEnterpriseApiKeys +{ + private static readonly List PENDING_KEYS = []; + private static readonly Lock LOCK = new(); + + /// + /// Adds a pending API key to the list. + /// + /// The pending API key to add. + public static void Add(PendingEnterpriseApiKey key) + { + lock (LOCK) + PENDING_KEYS.Add(key); + } + + /// + /// Gets and clears all pending API keys. + /// + /// A list of all pending API keys. + public static IReadOnlyList GetAndClear() + { + lock (LOCK) + { + var keys = PENDING_KEYS.ToList(); + PENDING_KEYS.Clear(); + return keys; + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseSecret.cs b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseSecret.cs new file mode 100644 index 00000000..d4be88e4 --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseSecret.cs @@ -0,0 +1,14 @@ +namespace AIStudio.Tools.PluginSystem; + +/// +/// Represents a pending enterprise secret that needs to be stored in the OS keyring. +/// +/// The secret ID. +/// The secret name. +/// The decrypted secret data. +/// The type of secret store to use. +public sealed record PendingEnterpriseSecret( + string SecretId, + string SecretName, + string SecretData, + SecretStoreType StoreType); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseSecrets.cs b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseSecrets.cs new file mode 100644 index 00000000..1fef45fa --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseSecrets.cs @@ -0,0 +1,34 @@ +namespace AIStudio.Tools.PluginSystem; + +/// +/// Static container for pending enterprise secrets during plugin loading. +/// +public static class PendingEnterpriseSecrets +{ + private static readonly List PENDING_SECRETS = []; + private static readonly Lock LOCK = new(); + + /// + /// Adds a pending enterprise secret to the list. + /// + /// The pending enterprise secret to add. + public static void Add(PendingEnterpriseSecret secret) + { + lock (LOCK) + PENDING_SECRETS.Add(secret); + } + + /// + /// Gets and clears all pending enterprise secrets. + /// + /// A list of all pending enterprise secrets. + public static IReadOnlyList GetAndClear() + { + lock (LOCK) + { + var secrets = PENDING_SECRETS.ToList(); + PENDING_SECRETS.Clear(); + return secrets; + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index da504b29..77391601 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -39,12 +39,43 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT { // Store any decrypted API keys from enterprise configuration in the OS keyring: await StoreEnterpriseApiKeysAsync(); + await StoreEnterpriseSecretsAsync(); await SETTINGS_MANAGER.StoreSettings(); await MessageBus.INSTANCE.SendMessage(null, Event.CONFIGURATION_CHANGED); } } + /// + /// Stores any pending enterprise secrets in the OS keyring. + /// + private static async Task StoreEnterpriseSecretsAsync() + { + var pendingSecrets = PendingEnterpriseSecrets.GetAndClear(); + if (pendingSecrets.Count == 0) + return; + + LOG.LogInformation($"Storing {pendingSecrets.Count} enterprise secret(s) in the OS keyring."); + var rustService = Program.SERVICE_PROVIDER.GetRequiredService(); + foreach (var pendingSecret in pendingSecrets) + { + try + { + var secretId = new TemporarySecretId(pendingSecret.SecretId, pendingSecret.SecretName); + var result = await rustService.SetSecret(secretId, pendingSecret.SecretData, pendingSecret.StoreType); + + if (result.Success) + LOG.LogDebug($"Successfully stored enterprise secret for '{pendingSecret.SecretName}' in the OS keyring."); + else + LOG.LogWarning($"Failed to store enterprise secret for '{pendingSecret.SecretName}': {result.Issue}"); + } + catch (Exception ex) + { + LOG.LogError(ex, $"Exception while storing enterprise secret for '{pendingSecret.SecretName}'."); + } + } + } + /// /// Stores any pending enterprise API keys in the OS keyring. /// @@ -152,6 +183,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT // Handle configured chat templates: PluginConfigurationObject.TryParse(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, x => x.NextChatTemplateNum, mainTable, this.Id, ref this.configObjects, dryRun); + + // Handle configured data sources: + PluginConfigurationObject.TryParseDataSources(mainTable, this.Id, ref this.configObjects, dryRun); // Handle configured profiles: PluginConfigurationObject.TryParse(PluginConfigurationObjectType.PROFILE, x => x.Profiles, x => x.NextProfileNum, mainTable, this.Id, ref this.configObjects, dryRun); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs index d0b299d3..26f10e7d 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs @@ -162,6 +162,87 @@ public sealed record PluginConfigurationObject return true; } + /// + /// Parses configured data sources from a configuration plugin. + /// + /// The Lua table containing entries to parse into data sources. + /// The unique identifier of the plugin associated with the data sources. + /// The list to populate with the parsed configuration objects. + /// Specifies whether to perform the operation as a dry run. + /// True if the table was present and processed; otherwise false. + public static bool TryParseDataSources( + LuaTable mainTable, + Guid configPluginId, + ref List configObjects, + bool dryRun) + { + const string LUA_TABLE_NAME = "DATA_SOURCES"; + if (!mainTable.TryGetValue(LUA_TABLE_NAME, out var luaValue) || !luaValue.TryRead(out var luaTable)) + { + LOG.LogWarning("The table '{LuaTableName}' does not exist or is not a valid table (config plugin id: {ConfigPluginId}).", LUA_TABLE_NAME, configPluginId); + return false; + } + + var storedObjects = SETTINGS_MANAGER.ConfigurationData.DataSources; + var numberObjects = luaTable.ArrayLength; + ThreadSafeRandom? random = null; + for (var i = 1; i <= numberObjects; i++) + { + var luaObjectTableValue = luaTable[i]; + if (!luaObjectTableValue.TryRead(out var luaObjectTable)) + { + LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} is not a valid table (config plugin id: {ConfigPluginId}).", LUA_TABLE_NAME, i, configPluginId); + continue; + } + + if (!DataSourceERI_V1.TryParseConfiguration(i, luaObjectTable, configPluginId, out var configObject)) + { + LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} does not contain a valid data source (config plugin id: {ConfigPluginId}).", LUA_TABLE_NAME, i, configPluginId); + continue; + } + + configObjects.Add(new() + { + ConfigPluginId = configPluginId, + Id = Guid.Parse(configObject.Id), + Type = PluginConfigurationObjectType.DATA_SOURCE, + }); + + if (dryRun) + continue; + + var objectIndex = storedObjects.FindIndex(t => t.Id == configObject.Id); + if (objectIndex > -1) + { + var existingObject = storedObjects[objectIndex]; + configObject = configObject with { Num = existingObject.Num }; + storedObjects[objectIndex] = configObject; + } + else + { + if (IncrementDataSourceNum() is { Success: true, UpdatedValue: var nextNum }) + { + configObject = configObject with { Num = nextNum }; + storedObjects.Add(configObject); + } + else + { + random ??= new ThreadSafeRandom(); + configObject = configObject with { Num = (uint)random.Next(500_000, 1_000_000) }; + storedObjects.Add(configObject); + LOG.LogWarning("The next number for the data source '{ConfigObjectName}' (id={ConfigObjectId}) could not be incremented. Using a random number instead (config plugin id: {ConfigPluginId}).", configObject.Name, configObject.Id, configPluginId); + } + } + } + + return true; + + static IncrementResult IncrementDataSourceNum() + { + return ((Expression>)(x => x.NextDataSourceNum)).TryIncrement(SETTINGS_MANAGER.ConfigurationData, IncrementType.POST); + } + } + /// /// Cleans up configuration objects of a specified type that are no longer associated with any available plugin. /// @@ -171,13 +252,15 @@ public sealed record PluginConfigurationObject /// A list of currently available plugins. /// A list of all existing configuration objects. /// An optional parameter specifying the type of secret store to use for deleting associated API keys from the OS keyring, if applicable. + /// When true, delete the associated non-API-key secret from the OS keyring. /// Returns true if the configuration was altered during cleanup; otherwise, false. public static async Task CleanLeftOverConfigurationObjects( PluginConfigurationObjectType configObjectType, Expression>> configObjectSelection, IList availablePlugins, IList configObjectList, - SecretStoreType? secretStoreType = null) where TClass : IConfigurationObject + SecretStoreType? secretStoreType = null, + bool deleteSecret = false) where TClass : IConfigurationObject { var configuredObjects = configObjectSelection.Compile()(SETTINGS_MANAGER.ConfigurationData); var leftOverObjects = new List(); @@ -220,7 +303,15 @@ public sealed record PluginConfigurationObject configuredObjects.Remove(item); // Delete the API key from the OS keyring if the removed object has one: - if(secretStoreType is not null && item is ISecretId secretId) + if(deleteSecret && item is ISecretId regularSecretId) + { + var deleteResult = await RUST_SERVICE.DeleteSecret(regularSecretId, secretStoreType ?? SecretStoreType.DATA_SOURCE); + if (deleteResult.Success) + LOG.LogInformation($"Successfully deleted secret for removed enterprise object '{item.Name}' from the OS keyring."); + else + LOG.LogWarning($"Failed to delete secret for removed enterprise object '{item.Name}' from the OS keyring: {deleteResult.Issue}"); + } + else if(secretStoreType is not null && item is ISecretId secretId) { var deleteResult = await RUST_SERVICE.DeleteAPIKey(secretId, secretStoreType.Value); if (deleteResult.Success) diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs index aedc7f7e..d09eaf34 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs @@ -174,6 +174,10 @@ public static partial class PluginFactory if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, AVAILABLE_PLUGINS, 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)) + wasConfigurationChanged = true; + // Check chat templates: if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, AVAILABLE_PLUGINS, configObjectList)) wasConfigurationChanged = true; diff --git a/app/MindWork AI Studio/Tools/SecretStoreType.cs b/app/MindWork AI Studio/Tools/SecretStoreType.cs index c4382b7b..5e9182d7 100644 --- a/app/MindWork AI Studio/Tools/SecretStoreType.cs +++ b/app/MindWork AI Studio/Tools/SecretStoreType.cs @@ -1,10 +1,10 @@ namespace AIStudio.Tools; /// -/// Represents the type of secret store used for API keys. +/// Represents the type of secret store used for API keys and other secrets. /// /// -/// Different provider types use different prefixes for storing API keys. +/// Different provider and secret types use different prefixes for storing secrets. /// This prevents collisions when the same instance name is used across /// different provider types (e.g., LLM, Embedding, Transcription). /// @@ -29,4 +29,9 @@ public enum SecretStoreType /// Image provider secrets. Uses the "image::" prefix. /// IMAGE_PROVIDER, -} + + /// + /// Data source secrets. Uses the "data-source::" prefix. + /// + DATA_SOURCE, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/SecretStoreTypeExtensions.cs b/app/MindWork AI Studio/Tools/SecretStoreTypeExtensions.cs index d0d4ba9e..5e8ae2f0 100644 --- a/app/MindWork AI Studio/Tools/SecretStoreTypeExtensions.cs +++ b/app/MindWork AI Studio/Tools/SecretStoreTypeExtensions.cs @@ -9,12 +9,14 @@ public static class SecretStoreTypeExtensions /// LLM_PROVIDER uses the legacy "provider" prefix for backward compatibility. /// /// The SecretStoreType enum value. - /// >The corresponding prefix string. + /// The corresponding prefix string. public static string Prefix(this SecretStoreType type) => type switch { SecretStoreType.LLM_PROVIDER => "provider", SecretStoreType.EMBEDDING_PROVIDER => "embedding", SecretStoreType.TRANSCRIPTION_PROVIDER => "transcription", + SecretStoreType.IMAGE_PROVIDER => "image", + SecretStoreType.DATA_SOURCE => "data-source", _ => "provider", }; diff --git a/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs b/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs index 656d7358..6db55a6c 100644 --- a/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs +++ b/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs @@ -200,7 +200,7 @@ public sealed class EnterpriseEnvironmentService(ILogger(), SecretStoreType.DATA_SOURCE, secretTargets); return secretTargets.ToList(); } diff --git a/app/MindWork AI Studio/Tools/Services/RustService.OS.cs b/app/MindWork AI Studio/Tools/Services/RustService.OS.cs index 0b81ccfe..9fd151e8 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.OS.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.OS.cs @@ -32,4 +32,35 @@ public sealed partial class RustService this.userLanguageLock.Release(); } } + + public async Task ReadUserName(bool forceRequest = false) + { + if (!forceRequest && !string.IsNullOrWhiteSpace(this.cachedUserName)) + return this.cachedUserName; + + await this.userNameLock.WaitAsync(); + try + { + if (!forceRequest && !string.IsNullOrWhiteSpace(this.cachedUserName)) + return this.cachedUserName; + + var response = await this.http.GetAsync("/system/username"); + if (!response.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to read the user name from Rust: '{response.StatusCode}'"); + return string.Empty; + } + + var userName = (await response.Content.ReadAsStringAsync()).Trim(); + if (string.IsNullOrWhiteSpace(userName)) + return string.Empty; + + this.cachedUserName = userName; + return userName; + } + finally + { + this.userNameLock.Release(); + } + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Secrets.cs b/app/MindWork AI Studio/Tools/Services/RustService.Secrets.cs index 49f51a1d..36ed6b6b 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Secrets.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Secrets.cs @@ -4,26 +4,34 @@ namespace AIStudio.Tools.Services; public sealed partial class RustService { + private static string SecretKey(ISecretId secretId, SecretStoreType storeType) => $"{storeType.Prefix()}::{secretId.SecretId}::{secretId.SecretName}"; + + private static string LegacySecretKey(ISecretId secretId) => $"secret::{secretId.SecretId}::{secretId.SecretName}"; + /// /// Try to get the secret data for the given secret ID. /// /// The secret ID to get the data for. + /// The secret store type. /// Indicates if we are trying to get the data. In that case, we don't log errors. /// The requested secret. - public async Task GetSecret(ISecretId secretId, bool isTrying = false) + public async Task GetSecret(ISecretId secretId, SecretStoreType storeType, bool isTrying = false) { - var secretRequest = new SelectSecretRequest($"secret::{secretId.SecretId}::{secretId.SecretName}", Environment.UserName, isTrying); - var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, this.jsonRustSerializerOptions); - if (!result.IsSuccessStatusCode) + var secretKey = SecretKey(secretId, storeType); + var secret = await this.GetSecretByKey(secretKey, isTrying || storeType is SecretStoreType.DATA_SOURCE); + if (secret.Success || storeType is not SecretStoreType.DATA_SOURCE) + return secret; + + var legacySecretKey = LegacySecretKey(secretId); + var legacySecret = await this.GetSecretByKey(legacySecretKey, isTrying: true); + if (legacySecret.Success) { - if(!isTrying) - this.logger!.LogError($"Failed to get the secret data for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'"); - return new RequestedSecret(false, new EncryptedText(string.Empty), TB("Failed to get the secret data due to an API issue.")); + this.logger!.LogDebug($"Successfully retrieved the legacy data source secret for '{legacySecretKey}'."); + return legacySecret; } - - var secret = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + if (!secret.Success && !isTrying) - this.logger!.LogError($"Failed to get the secret data for secret ID '{secretId.SecretId}': '{secret.Issue}'"); + this.logger!.LogError($"Failed to get the secret data for '{secretKey}': '{secret.Issue}'"); return secret; } @@ -33,21 +41,26 @@ public sealed partial class RustService /// /// The secret ID to store the data for. /// The data to store. + /// The secret store type. /// The store secret response. - public async Task SetSecret(ISecretId secretId, string secretData) + public async Task SetSecret(ISecretId secretId, string secretData, SecretStoreType storeType) { + var secretKey = SecretKey(secretId, storeType); var encryptedSecret = await this.encryptor!.Encrypt(secretData); - var request = new StoreSecretRequest($"secret::{secretId.SecretId}::{secretId.SecretName}", Environment.UserName, encryptedSecret); + var request = new StoreSecretRequest(secretKey, Environment.UserName, encryptedSecret); var result = await this.http.PostAsJsonAsync("/secrets/store", request, this.jsonRustSerializerOptions); if (!result.IsSuccessStatusCode) { - this.logger!.LogError($"Failed to store the secret data for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'"); - return new StoreSecretResponse(false, TB("Failed to get the secret data due to an API issue.")); + this.logger!.LogError($"Failed to store the secret data for '{secretKey}' due to an API issue: '{result.StatusCode}'"); + return new StoreSecretResponse(false, TB("Failed to store the secret data due to an API issue.")); } var state = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); if (!state.Success) - this.logger!.LogError($"Failed to store the secret data for secret ID '{secretId.SecretId}': '{state.Issue}'"); + this.logger!.LogError($"Failed to store the secret data for '{secretKey}': '{state.Issue}'"); + + if (state.Success && storeType is SecretStoreType.DATA_SOURCE) + await this.DeleteSecretByKey(LegacySecretKey(secretId)); return state; } @@ -56,20 +69,48 @@ public sealed partial class RustService /// Tries to delete the secret data for the given secret ID. /// /// The secret ID to delete the data for. + /// The secret store type. /// The delete secret response. - public async Task DeleteSecret(ISecretId secretId) + public async Task DeleteSecret(ISecretId secretId, SecretStoreType storeType) { - var request = new SelectSecretRequest($"secret::{secretId.SecretId}::{secretId.SecretName}", Environment.UserName, false); + var deleteResult = await this.DeleteSecretByKey(SecretKey(secretId, storeType)); + if (storeType is not SecretStoreType.DATA_SOURCE || !deleteResult.Success) + return deleteResult; + + var legacyDeleteResult = await this.DeleteSecretByKey(LegacySecretKey(secretId)); + if (!legacyDeleteResult.Success) + return legacyDeleteResult; + + return deleteResult with { WasEntryFound = deleteResult.WasEntryFound || legacyDeleteResult.WasEntryFound }; + } + + private async Task GetSecretByKey(string secretKey, bool isTrying) + { + var secretRequest = new SelectSecretRequest(secretKey, Environment.UserName, isTrying); + var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, this.jsonRustSerializerOptions); + if (!result.IsSuccessStatusCode) + { + if(!isTrying) + this.logger!.LogError($"Failed to get the secret data for '{secretKey}' due to an API issue: '{result.StatusCode}'"); + return new RequestedSecret(false, new EncryptedText(string.Empty), TB("Failed to get the secret data due to an API issue.")); + } + + return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + } + + private async Task DeleteSecretByKey(string secretKey) + { + var request = new SelectSecretRequest(secretKey, Environment.UserName, false); var result = await this.http.PostAsJsonAsync("/secrets/delete", request, this.jsonRustSerializerOptions); if (!result.IsSuccessStatusCode) { - this.logger!.LogError($"Failed to delete the secret data for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'"); + this.logger!.LogError($"Failed to delete the secret data for '{secretKey}' due to an API issue: '{result.StatusCode}'"); return new DeleteSecretResponse{Success = false, WasEntryFound = false, Issue = TB("Failed to delete the secret data due to an API issue.")}; } var state = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); if (!state.Success) - this.logger!.LogError($"Failed to delete the secret data for secret ID '{secretId.SecretId}': '{state.Issue}'"); + this.logger!.LogError($"Failed to delete the secret data for '{secretKey}': '{state.Issue}'"); return state; } diff --git a/app/MindWork AI Studio/Tools/Services/RustService.cs b/app/MindWork AI Studio/Tools/Services/RustService.cs index 9f495adb..6bcef10c 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.cs @@ -18,6 +18,7 @@ public sealed partial class RustService : BackgroundService private readonly HttpClient http; private readonly SemaphoreSlim userLanguageLock = new(1, 1); + private readonly SemaphoreSlim userNameLock = new(1, 1); private readonly JsonSerializerOptions jsonRustSerializerOptions = new() { @@ -31,6 +32,7 @@ public sealed partial class RustService : BackgroundService private ILogger? logger; private Encryption? encryptor; private string? cachedUserLanguage; + private string? cachedUserName; private readonly string apiPort; private readonly string certificateFingerprint; @@ -91,6 +93,7 @@ public sealed partial class RustService : BackgroundService { this.http.Dispose(); this.userLanguageLock.Dispose(); + this.userNameLock.Dispose(); base.Dispose(); } diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md b/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md index 1008fe32..c2ac3cd5 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md @@ -1,5 +1,8 @@ # v26.5.5, build 240 (2026-05-xx xx:xx UTC) - Released the voice recording and transcription for all users. You no longer need to enable a preview feature to configure transcription providers, select a transcription provider, or use dictation. +- Added support for organization-managed ERI servers in configuration plugins, so admins can preconfigure external data sources for users. +- Added an export option for ERI server data sources, so admins can create configuration plugin snippets without writing the Lua code manually. +- Added the username to the information page to make organization support easier when users share their screen. - Improved the app's security foundation with major modernization of the native runtime and its internal communication layer. This work is mostly invisible during everyday use, but it replaces older components that no longer received the security updates we require. We also continued updating security-sensitive dependencies so AI Studio stays on a healthier, better maintained base. - Improved the Pandoc management and detection process to make it more reliable. - Fixed the Pandoc installation, which could fail and prevent AI Studio from installing its local Pandoc dependency. diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index c8894cac..ed6866a7 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -2893,9 +2893,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.183" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libdbus-sys" @@ -2928,11 +2928,10 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.3" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.11.1", "libc", ] @@ -3097,6 +3096,7 @@ dependencies = [ "tempfile", "tokio", "tokio-stream", + "whoami", "windows-native-keyring-store", "windows-registry", ] @@ -3549,6 +3549,15 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "objc2-ui-kit" version = "0.3.0" @@ -6079,6 +6088,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.13.3+wasi-0.2.2", +] + [[package]] name = "wasm-bindgen" version = "0.2.120" @@ -6298,6 +6316,19 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 304d0332..3578b14f 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -41,6 +41,7 @@ file-format = "0.29.0" calamine = "0.35.0" pdfium-render = "0.9.1" sys-locale = "0.3.2" +whoami = "2.1.2" cfg-if = "1.0.4" pptx-to-md = "0.4.0" tempfile = "3.27.0" diff --git a/runtime/src/environment.rs b/runtime/src/environment.rs index 1e45b5f3..3f8dd43c 100644 --- a/runtime/src/environment.rs +++ b/runtime/src/environment.rs @@ -1,6 +1,6 @@ use crate::api_token::APIToken; use axum::Json; -use log::{debug, info, warn}; +use log::{debug, error, info, warn}; use serde::Serialize; use std::collections::{HashMap, HashSet}; use std::env; @@ -43,6 +43,14 @@ pub async fn get_data_directory(_token: APIToken) -> String { } } +/// Returns the current user's username. +pub async fn read_user_name(_token: APIToken) -> String { + whoami::username().unwrap_or_else(|e| { + error!("Failed to read the current OS username: {e}."); + String::new() + }) +} + /// Returns true if the application is running in development mode. pub fn is_dev() -> bool { cfg!(debug_assertions) diff --git a/runtime/src/runtime_api.rs b/runtime/src/runtime_api.rs index 213c8a55..89f6cec0 100644 --- a/runtime/src/runtime_api.rs +++ b/runtime/src/runtime_api.rs @@ -48,6 +48,7 @@ pub fn start_runtime_api() { .route("/system/directories/config", get(crate::environment::get_config_directory)) .route("/system/directories/data", get(crate::environment::get_data_directory)) .route("/system/language", get(crate::environment::read_user_language)) + .route("/system/username", get(crate::environment::read_user_name)) .route("/system/enterprise/config/id", get(crate::environment::read_enterprise_env_config_id)) .route("/system/enterprise/config/server", get(crate::environment::read_enterprise_env_config_server_url)) .route("/system/enterprise/config/encryption_secret", get(crate::environment::read_enterprise_env_config_encryption_secret))