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/MindWork AI Studio/Tools/Security/AppAccessMiddleware.cs b/app/MindWork AI Studio/Tools/Security/AppAccessMiddleware.cs
index 937d98855..3711600de 100644
--- a/app/MindWork AI Studio/Tools/Security/AppAccessMiddleware.cs
+++ b/app/MindWork AI Studio/Tools/Security/AppAccessMiddleware.cs
@@ -7,8 +7,9 @@ namespace AIStudio.Tools.Security;
///
///
/// 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. There are no exceptions: a request without
-/// the session cookie gets refused, whatever it asks for.
+/// 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
@@ -45,6 +46,14 @@ public sealed class AppAccessMiddleware(RequestDelegate next, AppAccessPolicy po
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");
@@ -54,6 +63,38 @@ public sealed class AppAccessMiddleware(RequestDelegate next, AppAccessPolicy po
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.Strict,
+ Path = "/",
+ IsEssential = true
+ });
+
+ context.Response.Redirect("/");
+ }
+#endif
+
private async Task RefuseAsync(HttpContext context, string reason)
{
this.LogRefusal(context, reason);
diff --git a/app/MindWork AI Studio/Tools/Security/AppAccessPolicy.cs b/app/MindWork AI Studio/Tools/Security/AppAccessPolicy.cs
index 4546e185b..925d62a28 100644
--- a/app/MindWork AI Studio/Tools/Security/AppAccessPolicy.cs
+++ b/app/MindWork AI Studio/Tools/Security/AppAccessPolicy.cs
@@ -64,11 +64,13 @@ public sealed class AppAccessPolicy(int appPort, string uiToken)
///
/// The request.
/// True when the request comes from the app window.
- public bool HasSessionCookie(HttpRequest request)
- {
- if (!request.Cookies.TryGetValue(SESSION_COOKIE_NAME, out var value) || string.IsNullOrEmpty(value))
- return false;
+ public bool HasSessionCookie(HttpRequest request) => request.Cookies.TryGetValue(SESSION_COOKIE_NAME, out var value) && this.IsUIToken(value);
- return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(value), this.uiTokenBytes);
- }
+ ///
+ /// 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/Tests/Tools/AppAccess/AppAccessMiddlewareTests.cs b/app/Tests/Tools/AppAccess/AppAccessMiddlewareTests.cs
index 4cfa80337..1f73a3dab 100644
--- a/app/Tests/Tools/AppAccess/AppAccessMiddlewareTests.cs
+++ b/app/Tests/Tools/AppAccess/AppAccessMiddlewareTests.cs
@@ -116,4 +116,61 @@ public sealed class AppAccessMiddlewareTests
{
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=strict"));
+ 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/runtime/src/app_access.rs b/runtime/src/app_access.rs
index b0bea703d..76a6b718e 100644
--- a/runtime/src/app_access.rs
+++ b/runtime/src/app_access.rs
@@ -18,6 +18,10 @@ pub const DOTNET_ENV_UI_TOKEN: &str = "AI_STUDIO_UI_TOKEN";
/// 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,
@@ -61,6 +65,28 @@ pub fn hand_over_session_cookie(window: &WebviewWindow, app_url: &Url) {
}
}
+/// 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::*;
@@ -97,6 +123,19 @@ mod tests {
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())));
diff --git a/runtime/src/dotnet.rs b/runtime/src/dotnet.rs
index 0bb71483a..e405a8891 100644
--- a/runtime/src/dotnet.rs
+++ b/runtime/src/dotnet.rs
@@ -301,6 +301,11 @@ pub async fn dotnet_ready() {
}
let dotnet_server_port = *DOTNET_SERVER_PORT;
+
+ // 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,