diff --git a/.gitignore b/.gitignore index f5e67cafd..3e96a077f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Ignore any startup.env file: startup.env +# Ignore the boot link of development builds: +dev-boot-link.txt + # Ignore pdfium library: libpdfium.dylib libpdfium.so diff --git a/app/AGENTS.md b/app/AGENTS.md index b69854b3d..0d8f97b30 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -37,8 +37,10 @@ Key structure: - **Long menus** - MudBlazor keeps a `MudSelect` inside the window by itself, but never a `MudMenu`: a menu taller than the window runs past its edge, and its lower entries cannot be reached. Give every `MudMenu` whose entries come from data, such as the assistants or the profiles, `MaxHeight="@MenuDefaults.MAX_HEIGHT"`, so that it scrolls. - **Growing popovers** - Since version 9, MudBlazor places an open popover again whenever its content changes size. Content which unfolds later, such as a `MudCollapse` or an `ExpansionPanel` inside a `MudPopover`, therefore stays inside the window and needs no code of its own. - **Encryption** - Initialized before Rust service is marked ready +- **Access to the app server** - Only the app window gets into the Blazor server. `AppAccessMiddleware` runs first in `Program.cs`, right after the host filter, and refuses every request without the session cookie of the window or with an origin other than the app's own. Add no exception for any path, not even for static files: `AppAccessPipelineTests` checks the order in `Program.cs`. Since nothing but the window holds the cookie, the app never sends a request to its own server: read files below `wwwroot/` through `WebRootFiles`. The system browser gets no cookie either, so never open a URL of the app server there; offer a save dialog through `RustService` instead, as the ERI Server assistant does for its specification. `runtime/AGENTS.md` describes the runtime side. - **Debug environment** - Reads `startup.env` file with IPC credentials -- **Static files and the browser cache** - When the developer starts the app from Rider, the embedded browser of Tauri keeps the static files under `wwwroot/`, such as `app.css` or a script, in its cache. The app then still shows the old file, and a change seems not to work. Whenever you changed such a file, tell the developer to clear that cache through the developer tools of the embedded browser before testing. Users of a release are not affected. +- **Boot link** - Once the .NET server is ready, a development build writes `dev-boot-link.txt` into the repository root. Opening that link hands another browser the session cookie. Open it only when the developer asks for it: the development build shows the data of the installed app. +- **Static files and the browser cache** - When the developer starts the app from Rider, the embedded browser of Tauri keeps the static files under `wwwroot/`, such as `app.css` or a script, in its cache. The app then still shows the old file, and a change seems not to work. Whenever you changed such a file, tell the developer to clear that cache through the developer tools of the embedded browser before testing, and only the cache: the website data hold the session cookie, and without it the window shows "This window cannot access AI Studio. Please restart AI Studio." until the .NET server starts again. Users of a release are not affected. - **Production environment** - Runtime launches .NET sidecar with environment variables ## Tests diff --git a/app/MindWork AI Studio/Assistants/ERI/AssistantERI.razor b/app/MindWork AI Studio/Assistants/ERI/AssistantERI.razor index 6b4546810..8c330cd7a 100644 --- a/app/MindWork AI Studio/Assistants/ERI/AssistantERI.razor +++ b/app/MindWork AI Studio/Assistants/ERI/AssistantERI.razor @@ -116,8 +116,8 @@ else } - - @T("Download specification") + + @T("Save specification") diff --git a/app/MindWork AI Studio/Assistants/ERI/AssistantERI.razor.cs b/app/MindWork AI Studio/Assistants/ERI/AssistantERI.razor.cs index 09c80c2e0..0d1d4902a 100644 --- a/app/MindWork AI Studio/Assistants/ERI/AssistantERI.razor.cs +++ b/app/MindWork AI Studio/Assistants/ERI/AssistantERI.razor.cs @@ -3,6 +3,7 @@ using AIStudio.Dialogs; using AIStudio.Dialogs.Settings; using AIStudio.Settings.DataModel; using AIStudio.Tools.AssistantSessions; +using AIStudio.Tools.Rust; using Microsoft.AspNetCore.Components; @@ -13,7 +14,7 @@ namespace AIStudio.Assistants.ERI; public partial class AssistantERI : AssistantBaseCore { [Inject] - private HttpClient HttpClient { get; set; } = null!; + private WebRootFiles WebRootFiles { get; set; } = null!; [Inject] private IDialogService DialogService { get; init; } = null!; @@ -375,9 +376,43 @@ public partial class AssistantERI : AssistantBaseCore private bool IsERIInputDisabled => this.IsNoneERIServerSelected || this.IsProcessing; /// - /// Gets whether the selected ERI specification cannot be downloaded. + /// Gets whether the selected ERI specification cannot be saved. /// - private bool IsSpecificationDownloadDisabled => !this.selectedERIVersion.WasSpecificationSelected() || this.IsERIInputDisabled; + private bool IsSpecificationSavingDisabled => !this.selectedERIVersion.WasSpecificationSelected() || this.IsERIInputDisabled; + + /// + /// Lets the user save the selected ERI specification, a copy of the one the app carries. + /// + /// + /// The app server answers the app window only, so a link to the file would fail in the browser + /// of the system. The copy also matches the installed version of AI Studio. + /// + private async Task SaveSpecificationAsync() + { + var specificationPath = this.selectedERIVersion.SpecificationURL(); + var response = await this.RustService.SaveFile(T("Save the ERI specification"), [FileTypes.JSON], Path.GetFileName(specificationPath)); + if (response.UserCancelled) + return; + + await using var feedback = new FileExportFeedback(); + feedback.ShowRunning(response.SaveFilePath); + try + { + if (await this.WebRootFiles.CopyToAsync(specificationPath, response.SaveFilePath)) + { + await feedback.SucceededAsync(Icons.Material.Filled.CheckCircle, T("The ERI specification was saved."), response.SaveFilePath, true); + return; + } + + this.Logger.LogError("The app carries no ERI specification at '{SpecificationPath}'.", specificationPath); + } + catch (Exception exception) + { + this.Logger.LogError(exception, "Failed to save the ERI specification {ERIVersion}.", this.selectedERIVersion); + } + + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, T("The ERI specification could not be saved."))); + } /// /// Gets whether the generated-code target directory selection should be disabled. @@ -843,7 +878,7 @@ public partial class AssistantERI : AssistantBaseCore var baseDirectorySnapshot = this.baseDirectory; var previouslyGeneratedFilesSnapshot = this.previouslyGeneratedFiles.ToArray(); - this.eriSpecification = await this.selectedERIVersion.ReadSpecification(this.HttpClient); + this.eriSpecification = await this.selectedERIVersion.ReadSpecification(this.WebRootFiles); if (string.IsNullOrWhiteSpace(this.eriSpecification)) { this.AddInputIssue(T("The ERI specification could not be loaded. Please try again later.")); diff --git a/app/MindWork AI Studio/Assistants/ERI/ERIVersionExtensions.cs b/app/MindWork AI Studio/Assistants/ERI/ERIVersionExtensions.cs index 99631e142..78d095c83 100644 --- a/app/MindWork AI Studio/Assistants/ERI/ERIVersionExtensions.cs +++ b/app/MindWork AI Studio/Assistants/ERI/ERIVersionExtensions.cs @@ -2,26 +2,14 @@ namespace AIStudio.Assistants.ERI; public static class ERIVersionExtensions { - public static async Task ReadSpecification(this ERIVersion version, HttpClient httpClient) - { - try - { - var url = version.SpecificationURL(); - using var response = await httpClient.GetAsync(url); - return await response.Content.ReadAsStringAsync(); - } - catch - { - return string.Empty; - } - } - + public static Task ReadSpecification(this ERIVersion version, WebRootFiles webRootFiles) => webRootFiles.ReadTextAsync(version.SpecificationURL()); + public static string SpecificationURL(this ERIVersion version) { var nameLower = version.ToString().ToLowerInvariant(); var filename = $"{nameLower}.json"; return $"specs/eri/{filename}"; } - + public static bool WasSpecificationSelected(this ERIVersion version) => version != ERIVersion.NONE; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 759582d4d..62dfac058 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -1537,12 +1537,21 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2205711729"] = "Opera -- You can integrate additional libraries. Perhaps you want to evaluate the prompts in advance using a machine learning method or analyze them with a text mining approach? Or maybe you want to preprocess images in the prompts? For such advanced scenarios, you can specify which libraries you want to use here. It's best to describe which library you want to integrate for which purpose. This way, the LLM that writes the ERI server for you can try to use these libraries effectively. This should result in less rework being necessary. If you don't know the necessary libraries, you can instead attempt to describe the intended use. The LLM can then attempt to choose suitable libraries. However, hallucinations can occur, and fictional libraries might be selected. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2241311898"] = "You can integrate additional libraries. Perhaps you want to evaluate the prompts in advance using a machine learning method or analyze them with a text mining approach? Or maybe you want to preprocess images in the prompts? For such advanced scenarios, you can specify which libraries you want to use here. It's best to describe which library you want to integrate for which purpose. This way, the LLM that writes the ERI server for you can try to use these libraries effectively. This should result in less rework being necessary. If you don't know the necessary libraries, you can instead attempt to describe the intended use. The LLM can then attempt to choose suitable libraries. However, hallucinations can occur, and fictional libraries might be selected." +-- The ERI specification was saved. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2258857117"] = "The ERI specification was saved." + +-- Save the ERI specification +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2260814268"] = "Save the ERI specification" + -- The ERI specification could not be loaded. Please try again later. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2265640084"] = "The ERI specification could not be loaded. Please try again later." -- You can imagine it like this: Hypothetically, when Wikipedia implemented the ERI, it would vectorize all pages using an embedding method. All of Wikipedia’s data would remain with Wikipedia, including the vector database (decentralized approach). Then, any AI Studio user could add Wikipedia as a data source to significantly reduce the hallucination of the LLM in knowledge questions. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2299793803"] = "You can imagine it like this: Hypothetically, when Wikipedia implemented the ERI, it would vectorize all pages using an embedding method. All of Wikipedia’s data would remain with Wikipedia, including the vector database (decentralized approach). Then, any AI Studio user could add Wikipedia as a data source to significantly reduce the hallucination of the LLM in knowledge questions." +-- The ERI specification could not be saved. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2361742875"] = "The ERI specification could not be saved." + -- Are you sure you want to delete the ERI server preset '{0}'? UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2417944396"] = "Are you sure you want to delete the ERI server preset '{0}'?" @@ -1672,9 +1681,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3709185824"] = "Yes, -- Common ERI server settings UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T371718034"] = "Common ERI server settings" --- Download specification -UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3770339156"] = "Download specification" - -- Are you sure you want to delete the embedding '{0}'? UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3792084967"] = "Are you sure you want to delete the embedding '{0}'?" @@ -1741,6 +1747,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T746826190"] = "ERI se -- Are you sure you want to delete the retrieval process '{0}'? UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T748762927"] = "Are you sure you want to delete the retrieval process '{0}'?" +-- Save specification +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T756799053"] = "Save specification" + -- Data source: hostname UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T761552889"] = "Data source: hostname" diff --git a/app/MindWork AI Studio/Components/Changelog.razor.cs b/app/MindWork AI Studio/Components/Changelog.razor.cs index 6402b1dd7..38d41bd71 100644 --- a/app/MindWork AI Studio/Components/Changelog.razor.cs +++ b/app/MindWork AI Studio/Components/Changelog.razor.cs @@ -5,7 +5,7 @@ namespace AIStudio.Components; public partial class Changelog : MSGComponentBase { [Inject] - private HttpClient HttpClient { get; set; } = null!; + private WebRootFiles WebRootFiles { get; set; } = null!; #region Overrides of ComponentBase @@ -23,7 +23,6 @@ public partial class Changelog : MSGComponentBase private async Task ReadLogAsync() { - using var response = await this.HttpClient.GetAsync($"changelog/{this.SelectedLog.Filename}"); - this.LogContent = await response.Content.ReadAsStringAsync(); + this.LogContent = await this.WebRootFiles.ReadTextAsync($"changelog/{this.SelectedLog.Filename}"); } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Pages/Home.razor.cs b/app/MindWork AI Studio/Pages/Home.razor.cs index 011e2d96d..1a8fe5b2a 100644 --- a/app/MindWork AI Studio/Pages/Home.razor.cs +++ b/app/MindWork AI Studio/Pages/Home.razor.cs @@ -13,7 +13,7 @@ namespace AIStudio.Pages; public partial class Home : MSGComponentBase { [Inject] - private HttpClient HttpClient { get; init; } = null!; + private WebRootFiles WebRootFiles { get; init; } = null!; [Inject] private NavigationManager NavigationManager { get; init; } = null!; @@ -222,8 +222,7 @@ public partial class Home : MSGComponentBase private async Task ReadLastChangeAsync() { var latest = Changelog.LOGS.MaxBy(n => n.Build); - using var response = await this.HttpClient.GetAsync($"changelog/{latest.Filename}"); - this.LastChangeContent = await response.Content.ReadAsStringAsync(); + this.LastChangeContent = await this.WebRootFiles.ReadTextAsync($"changelog/{latest.Filename}"); await this.InvokeAsync(this.StateHasChanged); } 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 3ee7c2f8a..892eb90ce 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 @@ -1539,12 +1539,21 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2205711729"] = "Betri -- You can integrate additional libraries. Perhaps you want to evaluate the prompts in advance using a machine learning method or analyze them with a text mining approach? Or maybe you want to preprocess images in the prompts? For such advanced scenarios, you can specify which libraries you want to use here. It's best to describe which library you want to integrate for which purpose. This way, the LLM that writes the ERI server for you can try to use these libraries effectively. This should result in less rework being necessary. If you don't know the necessary libraries, you can instead attempt to describe the intended use. The LLM can then attempt to choose suitable libraries. However, hallucinations can occur, and fictional libraries might be selected. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2241311898"] = "Sie können zusätzliche Bibliotheken integrieren. Vielleicht möchten Sie die Prompts im Voraus mit einer Machine-Learning-Methode auswerten oder sie mithilfe eines Text-Mining-Ansatzes analysieren? Oder möchten Sie vielleicht Bilder in den Prompts vorverarbeiten? Für solche erweiterten Szenarien können Sie hier angeben, welche Bibliotheken Sie verwenden möchten. Beschreiben Sie am besten, welche Bibliothek Sie für welchen Zweck einbinden möchten. So kann das LLM, das den ERI-Server für Sie erstellt, versuchen, diese Bibliotheken effektiv zu nutzen. Dadurch sollte weniger Nacharbeit nötig sein. Falls Sie die benötigten Bibliotheken nicht kennen, können Sie stattdessen versuchen, den geplanten Einsatzzweck zu beschreiben. Das LLM kann dann versuchen, passende Bibliotheken auszuwählen. Allerdings können dabei Halluzinationen auftreten und fiktive Bibliotheken gewählt werden." +-- The ERI specification was saved. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2258857117"] = "Die ERI-Spezifikation wurde gespeichert." + +-- Save the ERI specification +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2260814268"] = "ERI-Spezifikation speichern" + -- The ERI specification could not be loaded. Please try again later. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2265640084"] = "Die ERI-Spezifikation konnte nicht geladen werden. Bitte versuchen Sie es später erneut." -- You can imagine it like this: Hypothetically, when Wikipedia implemented the ERI, it would vectorize all pages using an embedding method. All of Wikipedia’s data would remain with Wikipedia, including the vector database (decentralized approach). Then, any AI Studio user could add Wikipedia as a data source to significantly reduce the hallucination of the LLM in knowledge questions. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2299793803"] = "Sie können es sich so vorstellen: Hypothetisch gesehen würde die Wikipedia beim Einsatz von ERI alle Seiten mithilfe einer Einbettungsmethode „vektorisieren“. Alle Daten von Wikipedia, einschließlich der Vektordatenbank, würden bei der Wikipedia bleiben (dezentraler Ansatz). Danach könnte jeder Nutzer von MindWork AI Studio Wikipedia als Datenquelle hinzufügen, um Halluzinationen des LLM bei Wissensfragen deutlich zu reduzieren." +-- The ERI specification could not be saved. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2361742875"] = "Die ERI-Spezifikation konnte nicht gespeichert werden." + -- Are you sure you want to delete the ERI server preset '{0}'? UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2417944396"] = "Sind Sie sicher, dass Sie die ERI-Server-Voreinstellung „{0}“ löschen möchten?" @@ -1674,9 +1683,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3709185824"] = "Ja, b -- Common ERI server settings UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T371718034"] = "Allgemeine ERI-Server-Einstellungen" --- Download specification -UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3770339156"] = "Spezifikation herunterladen" - -- Are you sure you want to delete the embedding '{0}'? UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3792084967"] = "Möchten Sie die Einbettung „{0}“ wirklich löschen?" @@ -1743,6 +1749,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T746826190"] = "ERI-Se -- Are you sure you want to delete the retrieval process '{0}'? UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T748762927"] = "Sind Sie sicher, dass Sie den Abrufprozess „{0}“ löschen möchten?" +-- Save specification +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T756799053"] = "Spezifikation speichern" + -- Data source: hostname UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T761552889"] = "Datenquelle: Hostname" 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 5eadce4c1..7116cbdcb 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 @@ -1539,12 +1539,21 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2205711729"] = "Opera -- You can integrate additional libraries. Perhaps you want to evaluate the prompts in advance using a machine learning method or analyze them with a text mining approach? Or maybe you want to preprocess images in the prompts? For such advanced scenarios, you can specify which libraries you want to use here. It's best to describe which library you want to integrate for which purpose. This way, the LLM that writes the ERI server for you can try to use these libraries effectively. This should result in less rework being necessary. If you don't know the necessary libraries, you can instead attempt to describe the intended use. The LLM can then attempt to choose suitable libraries. However, hallucinations can occur, and fictional libraries might be selected. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2241311898"] = "You can integrate additional libraries. Perhaps you want to evaluate the prompts in advance using a machine learning method or analyze them with a text mining approach? Or maybe you want to preprocess images in the prompts? For such advanced scenarios, you can specify which libraries you want to use here. It's best to describe which library you want to integrate for which purpose. This way, the LLM that writes the ERI server for you can try to use these libraries effectively. This should result in less rework being necessary. If you don't know the necessary libraries, you can instead attempt to describe the intended use. The LLM can then attempt to choose suitable libraries. However, hallucinations can occur, and fictional libraries might be selected." +-- The ERI specification was saved. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2258857117"] = "The ERI specification was saved." + +-- Save the ERI specification +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2260814268"] = "Save the ERI specification" + -- The ERI specification could not be loaded. Please try again later. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2265640084"] = "The ERI specification could not be loaded. Please try again later." -- You can imagine it like this: Hypothetically, when Wikipedia implemented the ERI, it would vectorize all pages using an embedding method. All of Wikipedia’s data would remain with Wikipedia, including the vector database (decentralized approach). Then, any AI Studio user could add Wikipedia as a data source to significantly reduce the hallucination of the LLM in knowledge questions. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2299793803"] = "You can imagine it like this: Hypothetically, when Wikipedia implemented the ERI, it would vectorize all pages using an embedding method. All of Wikipedia’s data would remain with Wikipedia, including the vector database (decentralized approach). Then, any AI Studio user could add Wikipedia as a data source to significantly reduce the hallucination of the LLM in knowledge questions." +-- The ERI specification could not be saved. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2361742875"] = "The ERI specification could not be saved." + -- Are you sure you want to delete the ERI server preset '{0}'? UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2417944396"] = "Are you sure you want to delete the ERI server preset '{0}'?" @@ -1674,9 +1683,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3709185824"] = "Yes, -- Common ERI server settings UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T371718034"] = "Common ERI server settings" --- Download specification -UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3770339156"] = "Download specification" - -- Are you sure you want to delete the embedding '{0}'? UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3792084967"] = "Are you sure you want to delete the embedding '{0}'?" @@ -1743,6 +1749,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T746826190"] = "ERI se -- Are you sure you want to delete the retrieval process '{0}'? UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T748762927"] = "Are you sure you want to delete the retrieval process '{0}'?" +-- Save specification +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T756799053"] = "Save specification" + -- Data source: hostname UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T761552889"] = "Data source: hostname" diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index 6c4f5ee9f..8bca708ac 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -25,6 +25,7 @@ using AIStudio.Tools.Web; using Microsoft.AspNetCore.Components.Server.Circuits; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.HostFiltering; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Logging.Console; @@ -32,11 +33,6 @@ using MudBlazor.Services; using MudExtensions.Services; -#if !DEBUG -using System.Reflection; -using Microsoft.Extensions.FileProviders; -#endif - namespace AIStudio; internal sealed class Program @@ -96,7 +92,14 @@ internal sealed class Program } API_TOKEN = apiToken; - + + var uiToken = Environment.GetEnvironmentVariable(AppAccessPolicy.UI_TOKEN_ENVIRONMENT_VARIABLE); + if(string.IsNullOrWhiteSpace(uiToken)) + { + Console.WriteLine($"Error: The {AppAccessPolicy.UI_TOKEN_ENVIRONMENT_VARIABLE} environment variable is not set."); + return; + } + using var rust = new RustService(rustApiPort, certificateFingerprint); var appPort = await rust.GetAppPort(); if(appPort == 0) @@ -283,12 +286,18 @@ internal sealed class Program options.KeepAliveInterval = TimeSpan.FromSeconds(30); }); - builder.Services.AddSingleton(new HttpClient - { - BaseAddress = new Uri($"http://localhost:{appPort}") - }); + // For absolute URLs only. The app reads its own files through WebRootFiles: + builder.Services.AddSingleton(new HttpClient()); + builder.Services.AddSingleton(); - builder.WebHost.UseUrls($"http://localhost:{appPort}"); + // + // We listen on the IPv4 loopback address only, where the app window goes. With localhost, the + // server would listen on the IPv6 loopback address as well, and a client which resolves + // localhost could end up with another process holding that port there. + // + builder.WebHost.UseUrls($"http://127.0.0.1:{appPort}"); + builder.Services.Configure(AppAccessPolicy.ConfigureHostFiltering); + builder.Services.AddSingleton(new AppAccessPolicy(appPort, uiToken)); #if DEBUG builder.WebHost.UseWebRoot("wwwroot"); @@ -343,6 +352,13 @@ internal sealed class Program DATABASE_CLIENT_PROVIDER = app.Services.GetRequiredService(); + // + // These two come first, so that no request reaches anything else without passing them. We add + // the host filtering ourselves instead of relying on the defaults of ASP.NET Core to do so: + // + app.UseHostFiltering(); + app.UseMiddleware(); + programLogger.LogInformation("Initialize internal file system."); app.Use(Redirect.HandlerContentAsync); app.Use(FileHandler.HandlerAsync); @@ -351,10 +367,9 @@ internal sealed class Program app.UseStaticFiles(); app.UseDeveloperExceptionPage(); #else - var fileProvider = new ManifestEmbeddedFileProvider(Assembly.GetAssembly(type: typeof(Program))!, "wwwroot"); app.UseStaticFiles(new StaticFileOptions { - FileProvider = fileProvider, + FileProvider = app.Services.GetRequiredService().FileProvider, RequestPath = string.Empty, }); #endif diff --git a/app/MindWork AI Studio/Tools/Security/AppAccessMiddleware.cs b/app/MindWork AI Studio/Tools/Security/AppAccessMiddleware.cs new file mode 100644 index 000000000..bfb2a11c2 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Security/AppAccessMiddleware.cs @@ -0,0 +1,127 @@ +using Microsoft.Extensions.Primitives; + +namespace AIStudio.Tools.Security; + +/// +/// Refuses every request to the app server which does not come from the app window. +/// +/// +/// The middleware runs before every other one, so it covers pages, static files, the Blazor hub +/// with all of its transports, and every endpoint alike. A release build makes no exception: a +/// request without the session cookie gets refused, whatever it asks for. Only a development build +/// also takes its boot link, with which another browser gets the cookie. +/// +/// A request which carries an Origin header must, in addition, come from a page of the app. +/// Browsers send that header with requests made by a script, the WebSocket of the Blazor hub +/// included, but not when they load a page. A sandboxed iframe sends "null", which gets refused as +/// well. +/// +/// The next middleware. +/// Decides which requests may pass. +/// The logger. +public sealed class AppAccessMiddleware(RequestDelegate next, AppAccessPolicy policy, ILogger logger) +{ + /// + /// The shortest interval between two log entries about refused requests. Any process on this + /// machine can send requests, and an entry for each of them would flood the log. + /// + private static readonly TimeSpan REFUSAL_LOG_INTERVAL = TimeSpan.FromMinutes(1); + + /// + /// What a refused request gets to read. The app window only shows it when it lost its cookie, + /// and a new start gives it a new one. + /// + private const string REFUSAL_TEXT = "This window cannot access AI Studio. Please restart AI Studio."; + + private readonly Lock refusalLock = new(); + private DateTimeOffset lastRefusalLog = DateTimeOffset.MinValue; + private int refusalsSinceLastLog; + + public async Task InvokeAsync(HttpContext context) + { + var origin = context.Request.Headers.Origin; + if (!StringValues.IsNullOrEmpty(origin) && (origin.Count != 1 || !policy.IsAppOrigin(origin.ToString()))) + { + await this.RefuseAsync(context, "another origin"); + return; + } + +#if DEBUG + if (HttpMethods.IsGet(context.Request.Method) && context.Request.Path.Equals(DEV_BOOT_PATH, StringComparison.Ordinal)) + { + await this.BootAsync(context); + return; + } +#endif + + if (!policy.HasSessionCookie(context.Request)) + { + await this.RefuseAsync(context, "a client without the session cookie"); + return; + } + + await next(context); + } + +#if DEBUG + /// + /// The path of the boot link which development builds write for other browsers, such as the + /// one of an agent. A release build knows neither the link nor this path. + /// + private const string DEV_BOOT_PATH = "/dev/boot"; + + /// + /// Trades the token of a boot link for the session cookie and sends the browser on to the app. + /// The browser opens the link by itself, so it sends the cookie along after the redirect. + /// + private async Task BootAsync(HttpContext context) + { + var token = context.Request.Query["token"].ToString(); + if (!policy.IsUIToken(token)) + { + await this.RefuseAsync(context, "a boot link with a wrong token"); + return; + } + + context.Response.Cookies.Append(AppAccessPolicy.SESSION_COOKIE_NAME, token, new CookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Lax, + Path = "/", + IsEssential = true + }); + + context.Response.Redirect("/"); + } +#endif + + private async Task RefuseAsync(HttpContext context, string reason) + { + this.LogRefusal(context, reason); + context.Response.StatusCode = StatusCodes.Status403Forbidden; + context.Response.ContentType = "text/plain; charset=utf-8"; + await context.Response.WriteAsync(REFUSAL_TEXT); + } + + /// + /// Logs a refused request, at most once per interval, together with the number of requests + /// refused since the last entry. The entry names the path but never the query or any header. + /// + private void LogRefusal(HttpContext context, string reason) + { + var now = DateTimeOffset.UtcNow; + int refusals; + lock (this.refusalLock) + { + this.refusalsSinceLastLog++; + if (now - this.lastRefusalLog < REFUSAL_LOG_INTERVAL) + return; + + refusals = this.refusalsSinceLastLog; + this.refusalsSinceLastLog = 0; + this.lastRefusalLog = now; + } + + logger.LogWarning("Refused {Refusals} request(s) to the app server since the last report. The latest one, '{Method} {Path}', came from {Reason}.", refusals, context.Request.Method, context.Request.Path.Value, reason); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Security/AppAccessPolicy.cs b/app/MindWork AI Studio/Tools/Security/AppAccessPolicy.cs new file mode 100644 index 000000000..925d62a28 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Security/AppAccessPolicy.cs @@ -0,0 +1,76 @@ +using System.Security.Cryptography; +using System.Text; + +using Microsoft.AspNetCore.HostFiltering; + +namespace AIStudio.Tools.Security; + +/// +/// Decides which requests may reach the app server. +/// +/// +/// The Rust runtime makes a new token for every start. It gives the token to this server through +/// an environment variable, and to the app window as the value of the session cookie. Nothing else +/// on this machine has the cookie, so a request with it comes from the app window. +/// +/// The port the app server listens on. +/// The token of this start, which the session cookie carries. +public sealed class AppAccessPolicy(int appPort, string uiToken) +{ + /// + /// The name of the session cookie. The Rust runtime sets the cookie under the same name. + /// + public const string SESSION_COOKIE_NAME = "ai_studio_session"; + + /// + /// The environment variable which carries the token from the Rust runtime to this server. + /// + public const string UI_TOKEN_ENVIRONMENT_VARIABLE = "AI_STUDIO_UI_TOKEN"; + + /// + /// The host names under which the app server answers. A request for any other name, such as one + /// which a website points at the loopback address, gets refused. + /// + private static readonly string[] ALLOWED_HOSTS = ["127.0.0.1", "localhost", "[::1]"]; + + private readonly HashSet allowedOrigins = ALLOWED_HOSTS.Select(host => $"http://{host}:{appPort}").ToHashSet(StringComparer.OrdinalIgnoreCase); + + private readonly byte[] uiTokenBytes = Encoding.UTF8.GetBytes(uiToken); + + /// + /// Lets the host filtering of ASP.NET Core accept only the host names of the app server. + /// + /// The options of the host filtering. + public static void ConfigureHostFiltering(HostFilteringOptions options) + { + options.AllowedHosts = [..ALLOWED_HOSTS]; + options.AllowEmptyHosts = false; + options.IncludeFailureMessage = false; + } + + /// + /// Checks whether the given origin is the app itself. + /// + /// The value of the Origin header of a request. + /// True when a page of the app made the request. + public bool IsAppOrigin(string origin) => this.allowedOrigins.Contains(origin); + + /// + /// Checks whether the given request carries the session cookie of this start. + /// + /// + /// The comparison takes the same time no matter where a wrong value differs, so the time of a + /// refusal tells nothing about how much of a guess was right. + /// + /// The request. + /// True when the request comes from the app window. + public bool HasSessionCookie(HttpRequest request) => request.Cookies.TryGetValue(SESSION_COOKIE_NAME, out var value) && this.IsUIToken(value); + + /// + /// Checks whether the given value is the token of this start, in a time which does not depend + /// on where a wrong value differs. + /// + /// The value to check. + /// True when the value is the token. + public bool IsUIToken(string? value) => !string.IsNullOrEmpty(value) && CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(value), this.uiTokenBytes); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/WebRootFiles.cs b/app/MindWork AI Studio/Tools/WebRootFiles.cs new file mode 100644 index 000000000..c76f8334c --- /dev/null +++ b/app/MindWork AI Studio/Tools/WebRootFiles.cs @@ -0,0 +1,55 @@ +using System.Reflection; + +using Microsoft.Extensions.FileProviders; + +namespace AIStudio.Tools; + +/// +/// Reads the files below wwwroot, which every build embeds into the app. +/// +/// +/// The app reads files such as its changelogs directly instead of requesting them over HTTP from +/// its own server, which answers the app window only. The release build serves its static files +/// from the same provider. +/// +public sealed class WebRootFiles +{ + /// + /// Gets the files below wwwroot. + /// + public IFileProvider FileProvider { get; } = new ManifestEmbeddedFileProvider(Assembly.GetAssembly(type: typeof(Program))!, "wwwroot"); + + /// + /// Reads a text file below wwwroot. + /// + /// The path below wwwroot, such as changelog/v26.10.1.md. + /// The text of the file, or an empty string when there is no such file. + public async Task ReadTextAsync(string path) + { + var file = this.FileProvider.GetFileInfo(path); + if (!file.Exists) + return string.Empty; + + await using var fileStream = file.CreateReadStream(); + using var reader = new StreamReader(fileStream); + return await reader.ReadToEndAsync(); + } + + /// + /// Copies a file below wwwroot to the given place, such as one the user chose to save it to. + /// + /// The path below wwwroot, such as specs/eri/v1.json. + /// Where to write the copy. An existing file there gets replaced. + /// True when the copy was written, false when there is no such file below wwwroot. + public async Task CopyToAsync(string path, string destinationPath) + { + var file = this.FileProvider.GetFileInfo(path); + if (!file.Exists) + return false; + + await using var source = file.CreateReadStream(); + await using var destination = File.Create(destinationPath); + await source.CopyToAsync(destination); + return true; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.11.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.11.1.md index ac6e43af0..65d11d5de 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.11.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.11.1.md @@ -2,6 +2,7 @@ - Added a setting for the size of the interface, which you find in the app settings. You can choose a size for each of your screens, and AI Studio switches to it when you move its window to another screen. Thanks, Paul Koudelka (`PaulKoudelka`), for this contribution. - Improved the documentation for IT departments, which now lives in our [wiki](https://github.com/MindWorkAI/AI-Studio/wiki/Enterprise-IT). It is sorted by task, explains the setup on Windows, Linux, and macOS, and offers complete configurations for typical needs, such as using only local models. The link in the app settings leads there, and the wiki also holds the installation guide in English and German. - Improved the German translation throughout: we proofread all texts and corrected wrong labels as well as spelling and grammar mistakes. The texts now always address you formally and use the same terms everywhere. +- Improved the security of the connection between the app window and AI Studio. - Changed the size of AI Studio on laptop screens: it now appears in the same size on every laptop, whatever the scaling of your operating system. If you prefer the previous size, choose "System scaling" for the interface size in the app settings. Contributed by Paul Koudelka. - Fixed AI Studio becoming unusable when an update failed after its download. The app now restarts with the version you have installed, so you can simply try the update again. - Fixed configuration plugins you installed yourself being able to change the tool settings of your organization, such as the minimum provider confidence of a tool. These settings now follow the same rules as all other settings, which also gives you back your own values once your organization removes its configuration. diff --git a/app/Tests/Tools/AppAccess/AppAccessMiddlewareTests.cs b/app/Tests/Tools/AppAccess/AppAccessMiddlewareTests.cs new file mode 100644 index 000000000..ddf3a05e6 --- /dev/null +++ b/app/Tests/Tools/AppAccess/AppAccessMiddlewareTests.cs @@ -0,0 +1,176 @@ +using AIStudio.Tools.Security; + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AIStudio.Tests.Tools.AppAccess; + +/// +/// Checks which requests the app server lets through to the app. +/// +/// +/// Only the app window has the session cookie, so every request without it gets refused, whatever +/// it asks for: pages, static files, the Blazor hub, the local files, and the preview of a visual +/// briefing alike. A request which a script sends also carries the origin of its page. Only the +/// pages of the app may send such requests: a page from anywhere else, and a sandboxed iframe with +/// its origin "null", get refused even with the cookie. +/// +[TestFixture] +public sealed class AppAccessMiddlewareTests +{ + private const int APP_PORT = 61153; + + private const string UI_TOKEN = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + /// + /// The status a request gets when it passes the middleware. + /// + private const int PASSED = StatusCodes.Status204NoContent; + + private static readonly string[] PATHS = + [ + "/", + "/chat", + "/_blazor/negotiate", + "/_blazor", + "/_framework/blazor.web.js", + "/local/file", + "/visual-briefing/preview/0b6c1c43-6d2e-4f0b-9a35-6f6f3c1f2a10/6a4e7d6e-2a8c-4bb0-8c55-7a2b7b3c9e01", + "/system/MudBlazor/MudBlazor.min.css", + "/app.css", + "/changelog/v26.10.1.md", + "/does-not-exist" + ]; + + private static async Task SendAsync(string path, string? sessionCookie, params string[] origins) + { + var middleware = new AppAccessMiddleware(Pass, new AppAccessPolicy(APP_PORT, UI_TOKEN), NullLogger.Instance); + var context = new DefaultHttpContext { Request = { Method = HttpMethods.Get, Path = path } }; + if (sessionCookie is not null) + context.Request.Headers.Cookie = $"{AppAccessPolicy.SESSION_COOKIE_NAME}={sessionCookie}"; + + if (origins.Length > 0) + context.Request.Headers.Origin = origins; + + await middleware.InvokeAsync(context); + return context.Response.StatusCode; + } + + private static Task Pass(HttpContext context) + { + context.Response.StatusCode = PASSED; + return Task.CompletedTask; + } + + [TestCaseSource(nameof(PATHS))] + public async Task ARequestWithTheSessionCookiePasses(string path) + { + Assert.That(await SendAsync(path, UI_TOKEN), Is.EqualTo(PASSED)); + } + + [TestCaseSource(nameof(PATHS))] + public async Task ARequestWithoutTheSessionCookieIsRefused(string path) + { + Assert.That(await SendAsync(path, null), Is.EqualTo(StatusCodes.Status403Forbidden)); + } + + [TestCase("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdee")] + [TestCase("0123456789abcdef")] + [TestCase("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0")] + [TestCase("")] + public async Task ARequestWithAWrongSessionCookieIsRefused(string sessionCookie) + { + Assert.That(await SendAsync("/", sessionCookie), Is.EqualTo(StatusCodes.Status403Forbidden)); + } + + [Test] + public async Task TheTokenUnderAnotherCookieNameIsRefused() + { + var middleware = new AppAccessMiddleware(Pass, new AppAccessPolicy(APP_PORT, UI_TOKEN), NullLogger.Instance); + var context = new DefaultHttpContext { Request = { Method = HttpMethods.Get, Path = "/", Headers = { Cookie = $"session={UI_TOKEN}" } } }; + + await middleware.InvokeAsync(context); + Assert.That(context.Response.StatusCode, Is.EqualTo(StatusCodes.Status403Forbidden)); + } + + [TestCase("http://127.0.0.1:61153")] + [TestCase("http://localhost:61153")] + [TestCase("http://[::1]:61153")] + public async Task ARequestFromTheAppPasses(string origin) + { + Assert.That(await SendAsync("/_blazor/negotiate", UI_TOKEN, origin), Is.EqualTo(PASSED)); + } + + [TestCase("http://rebind.example:61153")] + [TestCase("null")] + [TestCase("http://127.0.0.1:61154")] + [TestCase("https://127.0.0.1:61153")] + [TestCase("http://127.0.0.1")] + public async Task ARequestFromAnotherOriginIsRefusedEvenWithTheSessionCookie(string origin) + { + Assert.That(await SendAsync("/_blazor/negotiate", UI_TOKEN, origin), Is.EqualTo(StatusCodes.Status403Forbidden)); + } + + [Test] + public async Task ARequestWithSeveralOriginsIsRefused() + { + Assert.That(await SendAsync("/_blazor/negotiate", UI_TOKEN, "http://127.0.0.1:61153", "http://rebind.example:61153"), Is.EqualTo(StatusCodes.Status403Forbidden)); + } + +#if DEBUG + private static async Task BootAsync(string method, string? token) + { + var middleware = new AppAccessMiddleware(Pass, new AppAccessPolicy(APP_PORT, UI_TOKEN), NullLogger.Instance); + var context = new DefaultHttpContext { Request = { Method = method, Path = "/dev/boot" } }; + if (token is not null) + context.Request.QueryString = QueryString.Create("token", token); + + await middleware.InvokeAsync(context); + return context.Response; + } + + [Test] + public async Task TheBootLinkOfADevelopmentBuildHandsOutTheSessionCookie() + { + var response = await BootAsync(HttpMethods.Get, UI_TOKEN); + var cookie = response.Headers.SetCookie.ToString().ToLowerInvariant(); + + Assert.Multiple(() => + { + Assert.That(response.StatusCode, Is.EqualTo(StatusCodes.Status302Found)); + Assert.That(response.Headers.Location.ToString(), Is.EqualTo("/")); + Assert.That(cookie, Does.StartWith($"{AppAccessPolicy.SESSION_COOKIE_NAME}={UI_TOKEN}")); + Assert.That(cookie, Does.Contain("httponly")); + Assert.That(cookie, Does.Contain("samesite=lax")); + Assert.That(cookie, Does.Contain("path=/")); + Assert.That(cookie, Does.Not.Contain("expires")); + }); + } + + [TestCase("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdee")] + [TestCase("")] + [TestCase(null)] + public async Task TheBootLinkWithAWrongTokenIsRefused(string? token) + { + var response = await BootAsync(HttpMethods.Get, token); + + Assert.Multiple(() => + { + Assert.That(response.StatusCode, Is.EqualTo(StatusCodes.Status403Forbidden)); + Assert.That(response.Headers.SetCookie.ToString(), Is.Empty); + }); + } + + [Test] + public async Task TheBootLinkAnswersOnlyAGet() + { + var response = await BootAsync(HttpMethods.Post, UI_TOKEN); + + Assert.Multiple(() => + { + Assert.That(response.StatusCode, Is.EqualTo(StatusCodes.Status403Forbidden)); + Assert.That(response.Headers.SetCookie.ToString(), Is.Empty); + }); + } +#endif +} \ No newline at end of file diff --git a/app/Tests/Tools/AppAccess/AppAccessPipelineTests.cs b/app/Tests/Tools/AppAccess/AppAccessPipelineTests.cs new file mode 100644 index 000000000..b33b18a89 --- /dev/null +++ b/app/Tests/Tools/AppAccess/AppAccessPipelineTests.cs @@ -0,0 +1,29 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; + +namespace AIStudio.Tests.Tools.AppAccess; + +/// +/// Checks that the access checks run before anything else in the request pipeline of the app. +/// +/// +/// A middleware or an endpoint which comes first would answer requests the checks never saw. The +/// test reads the calls from the source of Program.cs, so that a new one is checked without anybody +/// having to add it to a test. +/// +[TestFixture] +public sealed class AppAccessPipelineTests +{ + private static string ReadProgramSource([CallerFilePath] string sourceFilePath = "") => File.ReadAllText(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "..", "..", "..", "MindWork AI Studio", "Program.cs")); + + [Test] + public void TheAccessChecksComeBeforeEveryOtherMiddlewareAndEndpoint() + { + var calls = Regex.Matches(ReadProgramSource(), @"\bapp\.(Use|Map)\w*(<\w+>)?\(") + .Select(match => match.Value) + .ToList(); + + Assert.That(calls, Has.Count.GreaterThan(2), "The test found no middleware or endpoint after the access checks."); + Assert.That(calls.Take(2), Is.EqualTo(new[] { "app.UseHostFiltering(", "app.UseMiddleware(" })); + } +} \ No newline at end of file diff --git a/app/Tests/Tools/AppAccess/AppAccessRuntimeAgreementTests.cs b/app/Tests/Tools/AppAccess/AppAccessRuntimeAgreementTests.cs new file mode 100644 index 000000000..c861310b6 --- /dev/null +++ b/app/Tests/Tools/AppAccess/AppAccessRuntimeAgreementTests.cs @@ -0,0 +1,40 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; + +using AIStudio.Tools.Security; + +namespace AIStudio.Tests.Tools.AppAccess; + +/// +/// Checks that the app server and the Rust runtime agree on the names around the session cookie. +/// +/// +/// The runtime sets the cookie and hands the token over in an environment variable; the server +/// reads both under its own constants. Should the names drift apart, the server would refuse the +/// app window, and nothing but a start of the app would tell. The test reads the constants of the +/// runtime from its source. +/// +[TestFixture] +public sealed class AppAccessRuntimeAgreementTests +{ + private static string ReadRuntimeSource([CallerFilePath] string sourceFilePath = "") => File.ReadAllText(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "..", "..", "..", "..", "runtime", "src", "app_access.rs")); + + private static string ReadRustConstant(string name) + { + var match = Regex.Match(ReadRuntimeSource(), $"""pub const {name}: &str = "([^"]+)";"""); + Assert.That(match.Success, Is.True, $"The runtime defines no constant {name}."); + return match.Groups[1].Value; + } + + [Test] + public void BothUseTheSameCookieName() + { + Assert.That(ReadRustConstant("SESSION_COOKIE_NAME"), Is.EqualTo(AppAccessPolicy.SESSION_COOKIE_NAME)); + } + + [Test] + public void BothUseTheSameEnvironmentVariable() + { + Assert.That(ReadRustConstant("DOTNET_ENV_UI_TOKEN"), Is.EqualTo(AppAccessPolicy.UI_TOKEN_ENVIRONMENT_VARIABLE)); + } +} \ No newline at end of file diff --git a/app/Tests/Tools/AppAccess/HostFilteringTests.cs b/app/Tests/Tools/AppAccess/HostFilteringTests.cs new file mode 100644 index 000000000..4bf69ba45 --- /dev/null +++ b/app/Tests/Tools/AppAccess/HostFilteringTests.cs @@ -0,0 +1,66 @@ +using AIStudio.Tools.Security; + +using Microsoft.AspNetCore.HostFiltering; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace AIStudio.Tests.Tools.AppAccess; + +/// +/// Checks which host names the app server answers to. +/// +/// +/// A website may point a name of its own at the loopback address. The browser then sends that name +/// as the host, and the app server must refuse the request. The test runs the host filtering of +/// ASP.NET Core with the options the app configures. +/// +[TestFixture] +public sealed class HostFilteringTests +{ + /// + /// The status a request gets when it passes the filter. + /// + private const int PASSED = StatusCodes.Status204NoContent; + + private static async Task SendAsync(string host) + { + await using var services = new ServiceCollection() + .AddLogging() + .Configure(AppAccessPolicy.ConfigureHostFiltering) + .BuildServiceProvider(); + + var middleware = ActivatorUtilities.CreateInstance(services, (RequestDelegate)Pass); + var context = new DefaultHttpContext { Request = { Host = new HostString(host) } }; + + await middleware.Invoke(context); + return context.Response.StatusCode; + } + + private static Task Pass(HttpContext context) + { + context.Response.StatusCode = PASSED; + return Task.CompletedTask; + } + + [TestCase("127.0.0.1:61153")] + [TestCase("localhost:61153")] + [TestCase("[::1]:61153")] + public async Task TheLoopbackNamesPass(string host) + { + Assert.That(await SendAsync(host), Is.EqualTo(PASSED)); + } + + [TestCase("rebind.example:61153")] + [TestCase("127.0.0.1.rebind.example:61153")] + [TestCase("localhost.rebind.example")] + public async Task AnyOtherNameIsRefused(string host) + { + Assert.That(await SendAsync(host), Is.EqualTo(StatusCodes.Status400BadRequest)); + } + + [Test] + public async Task ARequestWithoutAHostIsRefused() + { + Assert.That(await SendAsync(string.Empty), Is.EqualTo(StatusCodes.Status400BadRequest)); + } +} \ No newline at end of file diff --git a/app/Tests/Tools/AppAccess/WebRootFilesTests.cs b/app/Tests/Tools/AppAccess/WebRootFilesTests.cs new file mode 100644 index 000000000..8ddef6e70 --- /dev/null +++ b/app/Tests/Tools/AppAccess/WebRootFilesTests.cs @@ -0,0 +1,73 @@ +using AIStudio.Assistants.ERI; +using AIStudio.Tools; + +using Changelog = AIStudio.Components.Changelog; + +namespace AIStudio.Tests.Tools.AppAccess; + +/// +/// Checks that the app reads the files below wwwroot which it shows itself. +/// +/// +/// The changelogs and the ERI specifications come from the files embedded into the app, not from a +/// request to its own server. A file which is missing there would leave the changelog or the ERI +/// assistant empty without any error. +/// +[TestFixture] +public sealed class WebRootFilesTests +{ + private static IEnumerable ChangelogFiles() => Changelog.LOGS.Select(log => log.Filename); + + private static IEnumerable SpecificationVersions() => Enum.GetValues().Where(version => version.WasSpecificationSelected()); + + [TestCaseSource(nameof(ChangelogFiles))] + public async Task EveryChangelogCanBeRead(string filename) + { + var text = await new WebRootFiles().ReadTextAsync($"changelog/{filename}"); + Assert.That(text, Is.Not.Empty); + } + + [TestCaseSource(nameof(SpecificationVersions))] + public async Task EveryERISpecificationCanBeRead(ERIVersion version) + { + var text = await version.ReadSpecification(new WebRootFiles()); + Assert.That(text, Is.Not.Empty); + } + + [Test] + public async Task AMissingFileReadsAsEmptyText() + { + var text = await new WebRootFiles().ReadTextAsync("changelog/does-not-exist.md"); + Assert.That(text, Is.Empty); + } + + [TestCaseSource(nameof(SpecificationVersions))] + public async Task EveryERISpecificationCanBeSaved(ERIVersion version) + { + var webRootFiles = new WebRootFiles(); + var destinationPath = Path.Combine(Path.GetTempPath(), $"eri-specification-{Guid.NewGuid():N}.json"); + try + { + Assert.That(await webRootFiles.CopyToAsync(version.SpecificationURL(), destinationPath), Is.True); + Assert.That(await File.ReadAllTextAsync(destinationPath), Is.EqualTo(await version.ReadSpecification(webRootFiles))); + } + finally + { + File.Delete(destinationPath); + } + } + + [Test] + public async Task AMissingFileIsNotCopied() + { + var destinationPath = Path.Combine(Path.GetTempPath(), $"eri-specification-{Guid.NewGuid():N}.json"); + + var copied = await new WebRootFiles().CopyToAsync("specs/eri/does-not-exist.json", destinationPath); + + Assert.Multiple(() => + { + Assert.That(copied, Is.False); + Assert.That(File.Exists(destinationPath), Is.False); + }); + } +} \ No newline at end of file diff --git a/runtime/AGENTS.md b/runtime/AGENTS.md index 61a61c283..e25c6b0ac 100644 --- a/runtime/AGENTS.md +++ b/runtime/AGENTS.md @@ -8,6 +8,7 @@ root, which applies here as well. Key modules: - `app_window.rs` - Tauri window management, updater integration +- `app_access.rs` - Session cookie which lets the app window into the .NET server - `dotnet.rs` - Launches and manages the .NET sidecar process - `runtime_api.rs` - Axum-based HTTPS API for .NET ↔ Rust communication - `certificate.rs` - Generates self-signed TLS certificates for secure IPC @@ -25,6 +26,21 @@ a route added afterward would be open to every process on the machine. The test `every_route_requires_the_api_token` reads the routes from `create_router` and fails for any route which answers without the token. +**Only the app window gets into the .NET server.** Every start makes a new UI token in `app_access.rs`. +Rust passes it to .NET as `AI_STUDIO_UI_TOKEN` through `ipc_environment` in `dotnet.rs`, and hands it to +the window as an HttpOnly session cookie before the window opens the app (`change_location_to` in +`app_window.rs`); .NET refuses every request without it. Therefore: + +- Never put the token into a URL, a log line, or `startup.env`. Only a development build writes a boot + link, to `dev-boot-link.txt` in the repository root, so that another browser can get the cookie. +- Keep the cookie `Lax`. A release shows `tauri://localhost` before it opens the app, and WKWebView on + macOS withholds a `Strict` cookie on that navigation. A development build starts from the dev server + of the Tauri CLI on `127.0.0.1`, the same site as the app, which hides the problem; start it with + `cargo tauri dev --no-watch --no-dev-server` to see what a release sees. +- The .NET server listens on `127.0.0.1` only, and the cookie belongs to that host. Change the host in + `APP_HOST` and in `Program.cs` together, since no test compares them. `AppAccessRuntimeAgreementTests` + only checks that both sides use the same cookie name and environment variable. + **Runtime API handlers never block.** All calls of the .NET app share one HTTP/2 connection, and the task driving it may wait on exactly the Tokio worker which a blocking handler occupies, so a single blocking handler can hold up the whole app. Therefore: @@ -47,8 +63,8 @@ blocking handler can hold up the whole app. Therefore: ## IPC Communication Flow 1. Rust runtime starts and generates TLS certificate 2. Rust starts internal HTTPS API on random port -3. Rust launches .NET sidecar, passing: API port, certificate fingerprint, API token, secret key +3. Rust launches .NET sidecar, passing: API port, certificate fingerprint, API token, secret key, UI token 4. .NET reads environment variables and establishes secure HTTPS connection to Rust -5. .NET requests an app port from Rust, starts Blazor Server on that port -6. Rust opens Tauri webview pointing to localhost:app_port +5. .NET requests an app port from Rust, starts Blazor Server on 127.0.0.1 at that port +6. Rust hands the session cookie to the Tauri webview and points it to 127.0.0.1:app_port 7. Bi-directional communication: .NET ↔ Rust via HTTPS API diff --git a/runtime/capabilities/default.json b/runtime/capabilities/default.json index edd9c22fc..945352f7a 100644 --- a/runtime/capabilities/default.json +++ b/runtime/capabilities/default.json @@ -4,7 +4,7 @@ "description": "Default capability for MindWork AI Studio", "remote": { "urls": [ - "http://localhost:*" + "http://127.0.0.1:*" ] }, "windows": [ diff --git a/runtime/src/app_access.rs b/runtime/src/app_access.rs new file mode 100644 index 000000000..54fda55bf --- /dev/null +++ b/runtime/src/app_access.rs @@ -0,0 +1,171 @@ +use log::{error, info}; +use once_cell::sync::Lazy; +use tauri::webview::Cookie; +use tauri::{Url, WebviewWindow}; +use crate::api_token::{generate_api_token, APIToken}; + +/// The token which the app window presents to the .NET server as the value of its session cookie. +/// Every start makes a new one. +pub static UI_ACCESS_TOKEN: Lazy = Lazy::new(generate_api_token); + +/// The name of the session cookie. The .NET server expects the same name. +pub const SESSION_COOKIE_NAME: &str = "ai_studio_session"; + +/// The environment variable which carries the token to the .NET server. +pub const DOTNET_ENV_UI_TOKEN: &str = "AI_STUDIO_UI_TOKEN"; + +/// The host of the app. The .NET server listens on this address only, and the session cookie +/// belongs to it. +pub const APP_HOST: &str = "127.0.0.1"; + +/// The file in the root of the repository which holds the boot link of a development build. +#[cfg(debug_assertions)] +const DEV_BOOT_LINK_FILE: &str = "dev-boot-link.txt"; + +/// Builds the session cookie of the app window. +/// +/// The cookie is for the app host alone and lives as long as the window: it has no expiry date, +/// and scripts cannot read it. The window never sends it with a request which a page of another +/// site makes, such as a script the page loads or a form it posts. +/// +/// The cookie is `Lax`, not `Strict`. Before the window opens the app, it shows a page of Tauri, +/// `tauri://localhost` on macOS, which is another site than the app. WKWebView counts that page +/// as the one which opened the app, and it would withhold a `Strict` cookie. A `Lax` cookie goes +/// along when a whole page of the app opens. Development builds show the page of the Tauri dev +/// server on the app host instead, which hides the difference. +/// +/// We parse the cookie from text because the `cookie` crate, whose builder would need its +/// `SameSite` type, is no direct dependency of ours. +pub fn session_cookie() -> Cookie<'static> { + let token = UI_ACCESS_TOKEN.to_hex_text(); + Cookie::parse(format!("{SESSION_COOKIE_NAME}={token}; Domain={APP_HOST}; Path=/; HttpOnly; SameSite=Lax")) + .expect("The session cookie consists of fixed attributes and a hexadecimal token, which always parse.") +} + +/// Puts the session cookie into the cookie store of the given window, before the window opens +/// the app at the given URL. +/// +/// Setting the cookie does not wait for the webview, and WebView2 even adds it in the background. +/// Reading the cookies back waits until the webview has handled everything sent before, so the +/// cookie is in place once this function returns. That reading is also the only way to learn +/// whether setting the cookie worked. +/// +/// Blocks until the main thread has answered, so never call it on a worker of the async runtime. +/// The log never shows the value of the cookie. +pub fn hand_over_session_cookie(window: &WebviewWindow, app_url: &Url) { + if let Err(error) = window.set_cookie(session_cookie()) { + error!(Source = "App access"; "Failed to hand the session cookie to the app window: {error}"); + return; + } + + match app_host_cookies(window, app_url) { + Ok(cookies) if cookies.iter().any(|cookie| cookie.name() == SESSION_COOKIE_NAME) => { + info!(Source = "App access"; "The app window got its session cookie."); + }, + + Ok(_) => { + error!(Source = "App access"; "The app window lacks its session cookie after it was set."); + }, + + Err(error) => { + error!(Source = "App access"; "Failed to check the session cookie of the app window: {error}"); + }, + } +} + +/// Reads the cookies of the app host from the given window. +/// +/// On macOS, wry picks the cookies for a URL by comparing their domain with the domain of the +/// URL. An IP address such as the app host has no domain in that sense, so wry would never find +/// a cookie of the app there. We read all cookies instead and pick those of the app host +/// ourselves. +#[cfg(target_os = "macos")] +fn app_host_cookies(window: &WebviewWindow, _app_url: &Url) -> tauri::Result>> { + Ok(window.cookies()?.into_iter().filter(|cookie| cookie.domain() == Some(APP_HOST)).collect()) +} + +/// Reads the cookies of the app host from the given window. +/// +/// WebView2 and WebKitGTK pick the cookies for a URL by themselves. Reading all cookies instead +/// would need WebKitGTK 2.42 or newer. +#[cfg(not(target_os = "macos"))] +fn app_host_cookies(window: &WebviewWindow, app_url: &Url) -> tauri::Result>> { + window.cookies_for_url(app_url.clone()) +} + +/// Returns the link with which another browser gets the session cookie of a development build, +/// such as the browser of an agent which checks a change in the running app. +#[cfg(debug_assertions)] +fn dev_boot_link(port: u16) -> String { + format!("http://{APP_HOST}:{port}/dev/boot?token={}", UI_ACCESS_TOKEN.to_hex_text()) +} + +/// Writes the boot link of a development build into a file of its own in the root of the +/// repository. It never goes into the startup env file: that file holds the secrets of the IPC, +/// which a browser needs none of. +/// +/// Only development builds contain this function. A release has no boot link, and its .NET +/// server knows no route which would take one. The log names the file, never the link. +#[cfg(debug_assertions)] +pub fn write_dev_boot_link(port: u16) { + let path = std::path::PathBuf::from("..").join(DEV_BOOT_LINK_FILE); + match std::fs::write(&path, dev_boot_link(port)) { + Ok(_) => info!(Source = "App access"; "Wrote the boot link of this development build to '{}'.", path.display()), + Err(error) => error!(Source = "App access"; "Failed to write the boot link of this development build to '{}': {error}", path.display()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_session_cookie_carries_the_token() { + let cookie = session_cookie(); + + assert_eq!(cookie.name(), SESSION_COOKIE_NAME); + assert_eq!(cookie.value(), UI_ACCESS_TOKEN.to_hex_text()); + } + + #[test] + fn the_session_cookie_belongs_to_the_app_host_alone() { + let cookie = session_cookie(); + + assert_eq!(cookie.domain(), Some(APP_HOST)); + assert_eq!(cookie.path(), Some("/")); + } + + #[test] + fn the_session_cookie_is_hidden_from_scripts_and_other_sites() { + let cookie = session_cookie(); + + assert_eq!(cookie.http_only(), Some(true)); + assert!(cookie.same_site().is_some_and(|same_site| same_site.is_lax())); + } + + #[test] + fn the_session_cookie_lives_only_as_long_as_the_window() { + let cookie = session_cookie(); + + assert!(cookie.expires().is_none()); + assert!(cookie.max_age().is_none()); + } + + #[test] + #[cfg(debug_assertions)] + fn the_dev_boot_link_leads_to_the_boot_route_of_the_app_host() { + let link = Url::parse(dev_boot_link(61153).as_str()).unwrap(); + + assert_eq!(link.host_str(), Some(APP_HOST)); + assert_eq!(link.port(), Some(61153)); + assert_eq!(link.path(), "/dev/boot"); + + let token = link.query_pairs().find(|(key, _)| key == "token").map(|(_, value)| value.into_owned()); + assert_eq!(token.as_deref(), Some(UI_ACCESS_TOKEN.to_hex_text())); + } + + #[test] + fn the_ui_token_differs_from_the_api_token() { + assert!(!UI_ACCESS_TOKEN.validate(&APIToken::from_hex_text(crate::runtime_api_token::API_TOKEN.to_hex_text()))); + } +} \ No newline at end of file diff --git a/runtime/src/app_window.rs b/runtime/src/app_window.rs index a068c7550..e665a5a0b 100644 --- a/runtime/src/app_window.rs +++ b/runtime/src/app_window.rs @@ -21,6 +21,7 @@ use tauri_plugin_updater::{UpdaterExt, Update}; use tauri_plugin_opener::OpenerExt; use tokio::sync::broadcast; use tokio::time; +use crate::app_access::hand_over_session_cookie; use crate::clipboard::shutdown_clipboard; use crate::data_restore::apply_pending_restore; use crate::dotnet::{cleanup_dotnet_server, start_dotnet_server, stop_dotnet_server}; @@ -312,7 +313,7 @@ fn is_tauri_asset_url(url: &tauri::Url) -> bool { matches!(url.scheme(), "http" | "https") && is_tauri_asset_host(url.host_str()) } -fn is_local_http_url(url: &tauri::Url) -> bool { +pub(crate) fn is_local_http_url(url: &tauri::Url) -> bool { matches!(url.scheme(), "http" | "https") && is_local_host(url.host_str()) } @@ -677,8 +678,9 @@ pub fn send_event(event: Event) { } } -/// Changes the location of the main window to the given URL. -pub async fn change_location_to(url: &str) { +/// Changes the location of the main window to the given URL. The window gets its session cookie +/// first, so that the .NET server lets it in. +pub async fn change_location_to(url: tauri::Url) { // Try to get the main window. If it is not available yet, wait for it: let mut main_window_ready = false; let mut main_window_status_reported = false; @@ -700,16 +702,30 @@ pub async fn change_location_to(url: &str) { } } - if let Ok(parsed_url) = tauri::Url::parse(url) && is_local_http_url(&parsed_url) { - *APPROVED_APP_URL.lock().unwrap() = Some(parsed_url); + if is_local_http_url(&url) { + *APPROVED_APP_URL.lock().unwrap() = Some(url.clone()); } - let js_location_change = format!("window.location = '{url}';"); - let main_window = main_window_spawn_clone.lock().unwrap(); - let location_change_result = main_window.as_ref().unwrap().eval(js_location_change.as_str()); - match location_change_result { - Ok(_) => info!("The app location was changed to {url}."), - Err(e) => error!("Failed to change the app location to {url}: {e}."), + // + // The window navigates by itself instead of through a script of the page it shows. That page + // comes from Tauri, which is another site than the app; app_access::session_cookie explains + // why the session cookie goes along all the same. We take the window out of the lock first: + // the cookie and the navigation need the main thread, which may wait for this lock meanwhile. + // + // Handing over the cookie waits for the main thread. This function serves a request of the + // runtime API, so the waiting happens outside the async runtime, see runtime/AGENTS.md. + // + let main_window = main_window_spawn_clone.lock().unwrap().clone().unwrap(); + let navigation = tokio::task::spawn_blocking(move || { + hand_over_session_cookie(&main_window, &url); + match main_window.navigate(url.clone()) { + Ok(_) => info!("The app location was changed to {url}."), + Err(e) => error!("Failed to change the app location to {url}: {e}."), + } + }); + + if let Err(error) = navigation.await { + error!("Failed to open the app in the main window: {error}"); } } diff --git a/runtime/src/dotnet.rs b/runtime/src/dotnet.rs index cbee8c284..e405a8891 100644 --- a/runtime/src/dotnet.rs +++ b/runtime/src/dotnet.rs @@ -9,6 +9,7 @@ use tauri::{Manager, Url}; use tauri_plugin_shell::process::{CommandChild, CommandEvent, TerminatedPayload}; use tauri_plugin_shell::ShellExt; use crate::runtime_api_token::API_TOKEN; +use crate::app_access::{APP_HOST, DOTNET_ENV_UI_TOKEN, UI_ACCESS_TOKEN}; use crate::app_window::change_location_to; use crate::runtime_certificate::CERTIFICATE_FINGERPRINT; use crate::encryption::ENCRYPTION; @@ -177,46 +178,43 @@ fn dotnet_bundle_extraction_directory(app_handle: &tauri::App /// but also remove that code from any production build. #[cfg(debug_assertions)] pub fn create_startup_env_file() { - - // Get the secret password & salt and convert it to a base64 string: - let secret_password = BASE64_STANDARD.encode(ENCRYPTION.secret_password); - let secret_key_salt = BASE64_STANDARD.encode(ENCRYPTION.secret_key_salt); - let api_port = *API_SERVER_PORT; - warn!(Source = "Bootloader .NET"; "Development environment detected; create the startup env file at '../startup.env'."); let env_file_path = std::path::PathBuf::from("..").join("startup.env"); let mut env_file = std::fs::File::create(env_file_path).unwrap(); - let mut env_file_lines = vec![ - format!("AI_STUDIO_SECRET_PASSWORD={secret_password}"), - format!("AI_STUDIO_SECRET_KEY_SALT={secret_key_salt}"), - format!("AI_STUDIO_CERTIFICATE_FINGERPRINT={}", CERTIFICATE_FINGERPRINT.get().unwrap()), - format!("AI_STUDIO_API_PORT={api_port}"), - format!("AI_STUDIO_API_TOKEN={}", API_TOKEN.to_hex_text()), - ]; - for (key, value) in external_http_custom_root_certificate_policy_environment() { - env_file_lines.push(format!("{key}={value}")); - } + let env_file_lines: Vec = ipc_environment() + .into_iter() + .map(|(key, value)| format!("{key}={value}")) + .collect(); std::io::Write::write_all(&mut env_file, env_file_lines.join("\n").as_bytes()).unwrap(); info!(Source = "Bootloader .NET"; "The startup env file was created successfully."); } -/// Starts the .NET server in a separate process. -pub fn start_dotnet_server(app_handle: tauri::AppHandle) { +/// Returns the environment which the .NET server needs to talk to this runtime and to let only +/// the app window in. The sidecar gets it, and so does the startup env file in the development +/// environment, so neither of them can miss a variable. +fn ipc_environment() -> Vec<(String, String)> { // Get the secret password & salt and convert it to a base64 string: let secret_password = BASE64_STANDARD.encode(ENCRYPTION.secret_password); let secret_key_salt = BASE64_STANDARD.encode(ENCRYPTION.secret_key_salt); let api_port = *API_SERVER_PORT; - let mut dotnet_server_environment: HashMap = HashMap::from_iter([ + let mut environment = vec![ (String::from("AI_STUDIO_SECRET_PASSWORD"), secret_password), (String::from("AI_STUDIO_SECRET_KEY_SALT"), secret_key_salt), (String::from("AI_STUDIO_CERTIFICATE_FINGERPRINT"), CERTIFICATE_FINGERPRINT.get().unwrap().to_string()), (String::from("AI_STUDIO_API_PORT"), format!("{api_port}")), (String::from("AI_STUDIO_API_TOKEN"), API_TOKEN.to_hex_text().to_string()), - ]); - dotnet_server_environment.extend(external_http_custom_root_certificate_policy_environment()); + (String::from(DOTNET_ENV_UI_TOKEN), UI_ACCESS_TOKEN.to_hex_text().to_string()), + ]; + environment.extend(external_http_custom_root_certificate_policy_environment()); + environment +} + +/// Starts the .NET server in a separate process. +pub fn start_dotnet_server(app_handle: tauri::AppHandle) { + let mut dotnet_server_environment: HashMap = HashMap::from_iter(ipc_environment()); if let Some(extraction_directory) = dotnet_bundle_extraction_directory(&app_handle) { dotnet_server_environment.insert(String::from(DOTNET_ENV_BUNDLE_EXTRACT_BASE_DIR), extraction_directory); } @@ -303,7 +301,12 @@ pub async fn dotnet_ready() { } let dotnet_server_port = *DOTNET_SERVER_PORT; - let url = match Url::parse(format!("http://localhost:{dotnet_server_port}").as_str()) + + // A development build lets other browsers in, too, such as the one of an agent: + #[cfg(debug_assertions)] + tokio::task::spawn_blocking(move || crate::app_access::write_dev_boot_link(dotnet_server_port)); + + let url = match Url::parse(app_address(dotnet_server_port).as_str()) { Ok(url) => url, Err(msg) => { @@ -312,7 +315,14 @@ pub async fn dotnet_ready() { } }; - change_location_to(url.as_str()).await; + change_location_to(url).await; +} + +/// Returns the address of the app on the .NET server. The server listens on the IPv4 loopback +/// address only, so the window goes there directly instead of resolving `localhost`, which may +/// answer with the IPv6 loopback address first. +fn app_address(port: u16) -> String { + format!("http://{APP_HOST}:{port}/") } /// Stops the .NET server process. @@ -336,4 +346,47 @@ pub fn cleanup_dotnet_server() { if let Err(e) = kill_stale_process(pid_path, SIDECAR_TYPE) { warn!(Source = ".NET"; "Error during the cleanup of .NET: {}", e); } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_window::is_local_http_url; + + #[test] + fn the_app_address_is_the_ipv4_loopback_address() { + let url = Url::parse(app_address(61153).as_str()).unwrap(); + + assert_eq!(url.host_str(), Some("127.0.0.1")); + assert_eq!(url.port(), Some(61153)); + assert_eq!(url.path(), "/"); + } + + #[test] + fn the_app_address_counts_as_local() { + let url = Url::parse(app_address(61153).as_str()).unwrap(); + assert!(is_local_http_url(&url)); + } + + #[test] + fn the_ipc_environment_carries_every_variable_the_dotnet_server_needs() { + // Only the start of the app generates the certificate this fingerprint belongs to: + let _ = CERTIFICATE_FINGERPRINT.set(String::from("test-fingerprint")); + + let environment = ipc_environment(); + let keys: Vec<&str> = environment.iter().map(|(key, _)| key.as_str()).collect(); + for expected in [ + "AI_STUDIO_SECRET_PASSWORD", + "AI_STUDIO_SECRET_KEY_SALT", + "AI_STUDIO_CERTIFICATE_FINGERPRINT", + "AI_STUDIO_API_PORT", + "AI_STUDIO_API_TOKEN", + DOTNET_ENV_UI_TOKEN, + ] { + assert!(keys.contains(&expected), "The environment lacks {expected}."); + } + + let ui_token = environment.iter().find(|(key, _)| key == DOTNET_ENV_UI_TOKEN).map(|(_, value)| value.as_str()); + assert_eq!(ui_token, Some(UI_ACCESS_TOKEN.to_hex_text())); + } } \ No newline at end of file diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 8560a48d0..895bbc5ba 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -4,6 +4,7 @@ pub mod environment; pub mod dotnet; pub mod network; pub mod api_token; +pub mod app_access; pub mod app_window; pub mod secret; pub mod clipboard;