This commit is contained in:
Thorsten Sommer 2026-03-26 15:03:02 +01:00 committed by GitHub
commit 48bf3cf02f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 1043 additions and 302 deletions

View File

@ -25,13 +25,22 @@ public static partial class PluginFactory
/// <summary>
/// Initializes the enterprise encryption service by reading the encryption secret
/// from the Windows Registry or environment variables.
/// from the effective enterprise source.
/// </summary>
/// <param name="rustService">The Rust service to use for reading the encryption secret.</param>
public static async Task InitializeEnterpriseEncryption(Services.RustService rustService)
{
LOG.LogInformation("Initializing enterprise encryption service...");
var encryptionSecret = await rustService.EnterpriseEnvConfigEncryptionSecret();
InitializeEnterpriseEncryption(encryptionSecret);
}
/// <summary>
/// Initializes the enterprise encryption service using a prefetched secret value.
/// </summary>
/// <param name="encryptionSecret">The base64-encoded enterprise encryption secret.</param>
public static void InitializeEnterpriseEncryption(string? encryptionSecret)
{
LOG.LogInformation("Initializing enterprise encryption service...");
var enterpriseEncryptionLogger = Program.LOGGER_FACTORY.CreateLogger<EnterpriseEncryption>();
EnterpriseEncryption = new EnterpriseEncryption(enterpriseEncryptionLogger, encryptionSecret);

View File

@ -1,4 +1,8 @@
using AIStudio.Tools.PluginSystem;
using AIStudio.Settings;
using System.Security.Cryptography;
using System.Text;
namespace AIStudio.Tools.Services;
@ -8,8 +12,14 @@ public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentSe
public static bool HasValidEnterpriseSnapshot { get; private set; }
private static EnterpriseSecretSnapshot CURRENT_SECRET_SNAPSHOT;
private readonly record struct EnterpriseEnvironmentSnapshot(Guid ConfigurationId, string ConfigurationServerUrl, string? ETag);
private readonly record struct EnterpriseSecretSnapshot(bool HasSecret, string Fingerprint);
private readonly record struct EnterpriseSecretTarget(string SecretId, string SecretName, SecretStoreType StoreType) : ISecretId;
#if DEBUG
private static readonly TimeSpan CHECK_INTERVAL = TimeSpan.FromMinutes(6);
#else
@ -39,6 +49,7 @@ public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentSe
logger.LogInformation("Start updating of the enterprise environment.");
HasValidEnterpriseSnapshot = false;
var previousSnapshot = BuildNormalizedSnapshot(CURRENT_ENVIRONMENTS);
var previousSecretSnapshot = CURRENT_SECRET_SNAPSHOT;
//
// Step 1: Fetch all active configurations.
@ -55,6 +66,21 @@ public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentSe
return;
}
string enterpriseEncryptionSecret;
try
{
enterpriseEncryptionSecret = await rustService.EnterpriseEnvConfigEncryptionSecret();
}
catch (Exception e)
{
logger.LogError(e, "Failed to fetch the enterprise encryption secret from the Rust service.");
await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvConfigEncryptionSecret failed");
return;
}
var nextSecretSnapshot = await BuildSecretSnapshot(enterpriseEncryptionSecret);
var wasSecretChanged = previousSecretSnapshot != nextSecretSnapshot;
//
// Step 2: Determine ETags and build the list of reachable configurations.
// IMPORTANT: when one config server fails, we continue with the others.
@ -169,10 +195,20 @@ public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentSe
logger.LogInformation("AI Studio runs without any enterprise configurations.");
var effectiveSnapshot = BuildNormalizedSnapshot(effectiveEnvironments);
if (PluginFactory.IsInitialized && wasSecretChanged)
{
logger.LogInformation("The enterprise encryption secret changed. Refreshing the enterprise encryption service and reloading plugins.");
PluginFactory.InitializeEnterpriseEncryption(enterpriseEncryptionSecret);
await this.RemoveEnterpriseManagedApiKeysAsync();
await PluginFactory.LoadAll();
}
CURRENT_ENVIRONMENTS = effectiveEnvironments;
CURRENT_SECRET_SNAPSHOT = nextSecretSnapshot;
HasValidEnterpriseSnapshot = true;
if (!previousSnapshot.SequenceEqual(effectiveSnapshot))
if (!previousSnapshot.SequenceEqual(effectiveSnapshot) || wasSecretChanged)
await MessageBus.INSTANCE.SendMessage<bool>(null, Event.ENTERPRISE_ENVIRONMENTS_CHANGED);
}
catch (Exception e)
@ -193,8 +229,81 @@ public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentSe
.ToList();
}
private static async Task<EnterpriseSecretSnapshot> BuildSecretSnapshot(string secret)
{
if (string.IsNullOrWhiteSpace(secret))
return new EnterpriseSecretSnapshot(false, string.Empty);
return new EnterpriseSecretSnapshot(true, await ComputeSecretFingerprint(secret));
}
private static async Task<string> ComputeSecretFingerprint(string secret)
{
using var secretStream = new MemoryStream(Encoding.UTF8.GetBytes(secret));
var hash = await SHA256.HashDataAsync(secretStream);
return Convert.ToHexString(hash);
}
private static string NormalizeServerUrl(string serverUrl)
{
return serverUrl.Trim().TrimEnd('/');
}
private async Task RemoveEnterpriseManagedApiKeysAsync()
{
var secretTargets = GetEnterpriseManagedSecretTargets();
if (secretTargets.Count == 0)
{
logger.LogInformation("No enterprise-managed API keys are currently known in the settings. No keyring cleanup is required.");
return;
}
logger.LogInformation("Removing {SecretCount} enterprise-managed API key(s) from the OS keyring after an enterprise encryption secret change.", secretTargets.Count);
foreach (var target in secretTargets)
{
try
{
var deleteResult = await rustService.DeleteAPIKey(target, target.StoreType);
if (deleteResult.Success)
{
if (deleteResult.WasEntryFound)
logger.LogInformation("Successfully deleted enterprise-managed API key '{SecretName}' from the OS keyring.", target.SecretName);
else
logger.LogInformation("Enterprise-managed API key '{SecretName}' was already absent from the OS keyring.", target.SecretName);
}
else
logger.LogWarning("Failed to delete enterprise-managed API key '{SecretName}' from the OS keyring: {Issue}", target.SecretName, deleteResult.Issue);
}
catch (Exception e)
{
logger.LogWarning(e, "Failed to delete enterprise-managed API key '{SecretName}' from the OS keyring.", target.SecretName);
}
}
}
private static List<EnterpriseSecretTarget> GetEnterpriseManagedSecretTargets()
{
var configurationData = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>().ConfigurationData;
var secretTargets = new HashSet<EnterpriseSecretTarget>();
AddEnterpriseManagedSecretTargets(configurationData.Providers, SecretStoreType.LLM_PROVIDER, secretTargets);
AddEnterpriseManagedSecretTargets(configurationData.EmbeddingProviders, SecretStoreType.EMBEDDING_PROVIDER, secretTargets);
AddEnterpriseManagedSecretTargets(configurationData.TranscriptionProviders, SecretStoreType.TRANSCRIPTION_PROVIDER, secretTargets);
return secretTargets.ToList();
}
private static void AddEnterpriseManagedSecretTargets<TSecret>(
IEnumerable<TSecret> secrets,
SecretStoreType storeType,
ISet<EnterpriseSecretTarget> secretTargets) where TSecret : ISecretId, IConfigurationObject
{
foreach (var secret in secrets)
{
if (!secret.IsEnterpriseConfiguration || secret.EnterpriseConfigurationPluginId == Guid.Empty)
continue;
secretTargets.Add(new EnterpriseSecretTarget(secret.SecretId, secret.SecretName, storeType));
}
}
}

View File

@ -7,6 +7,7 @@
- Added a start-page setting, so AI Studio can now open directly on your preferred page when the app starts. Configuration plugins can also provide and optionally lock this default for organizations.
- Added math rendering in chats for LaTeX display formulas, including block formats such as `$$ ... $$` and `\[ ... \]`.
- Released the document analysis assistant after an intense testing phase.
- Improved enterprise deployment for organizations: administrators can now provide up to 10 centrally managed enterprise configuration slots, use policy files on Linux and macOS, and continue using older configuration formats as a fallback during migration.
- Improved the profile selection for assistants and the chat. You can now explicitly choose between the app default profile, no profile, or a specific profile.
- Improved the performance by caching the OS language detection and requesting the user language only once per app start.
- Improved the chat performance by reducing unnecessary UI updates, making chats smoother and more responsive, especially in longer conversations.

View File

@ -15,123 +15,118 @@ AI Studio checks about every 16 minutes to see if the configuration ID, the serv
## Configure the devices
So that MindWork AI Studio knows where to load which configuration, this information must be provided as metadata on employees' devices. Currently, the following options are available:
- **Registry** (only available for Microsoft Windows): On Windows devices, AI Studio first tries to read the information from the registry. The registry information can be managed and distributed centrally as a so-called Group Policy Object (GPO).
- **Windows Registry / GPO**: On Windows, AI Studio first tries to read the enterprise configuration metadata from the registry. This is the preferred option for centrally managed Windows devices.
- **Environment variables**: On all operating systems (on Windows as a fallback after the registry), AI Studio tries to read the configuration metadata from environment variables.
- **Policy files**: AI Studio can read simple YAML policy files from a system-wide directory. On Linux and macOS, this is the preferred option. On Windows, it is used as a fallback after the registry.
- **Environment variables**: Environment variables are still supported on all operating systems, but they are now only used as the last fallback.
### Source order and fallback behavior
AI Studio does **not** merge the registry, policy files, and environment variables. Instead, it checks them in order:
- **Windows:** Registry -> Policy files -> Environment variables
- **Linux:** Policy files -> Environment variables
- **macOS:** Policy files -> Environment variables
For enterprise configurations, AI Studio uses the **first source that contains at least one valid enterprise configuration**.
For the encryption secret, AI Studio uses the **first source that contains a non-empty encryption secret**, even if that source does not contain any enterprise configuration IDs or server URLs. This allows secret-only setups during migration or on machines that only need encrypted API key support.
### Multiple configurations (recommended)
AI Studio supports loading multiple enterprise configurations simultaneously. This enables hierarchical configuration schemes, e.g., organization-wide settings combined with department-specific settings. The following keys and variables are used:
AI Studio supports loading multiple enterprise configurations simultaneously. This enables hierarchical configuration schemes, such as organization-wide settings combined with institute- or department-specific settings.
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `configs` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS`: A combined format containing one or more configuration entries. Each entry consists of a configuration ID and a server URL separated by `@`. Multiple entries are separated by `;`. The format is: `id1@url1;id2@url2;id3@url3`. The configuration ID must be a valid [GUID](https://en.wikipedia.org/wiki/Universally_unique_identifier#Globally_unique_identifier).
The preferred format is a fixed set of indexed pairs:
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_encryption_secret` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET`: A base64-encoded 32-byte encryption key for decrypting API keys in configuration plugins. This is optional and only needed if you want to include encrypted API keys in your configuration. All configurations share the same encryption secret.
- Registry values `config_id0` to `config_id9` together with `config_server_url0` to `config_server_url9`
- Environment variables `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID0` to `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID9` together with `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL0` to `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL9`
- Policy files `config0.yaml` to `config9.yaml`
**Example:** To configure two enterprise configurations (one for the organization and one for a department):
Each configuration ID must be a valid [GUID](https://en.wikipedia.org/wiki/Universally_unique_identifier#Globally_unique_identifier). Up to ten configurations are supported per device.
```
MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS=9072b77d-ca81-40da-be6a-861da525ef7b@https://intranet.my-company.com:30100/ai-studio/configuration;a1b2c3d4-e5f6-7890-abcd-ef1234567890@https://intranet.my-company.com:30100/ai-studio/department-config
If multiple configurations define the same setting, the first definition wins. For indexed pairs and policy files, the order is slot `0`, then `1`, and so on up to `9`.
### Windows registry example
The Windows registry path is:
`HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`
Example values:
- `config_id0` = `9072b77d-ca81-40da-be6a-861da525ef7b`
- `config_server_url0` = `https://intranet.example.org/ai-studio/configuration`
- `config_id1` = `a1b2c3d4-e5f6-7890-abcd-ef1234567890`
- `config_server_url1` = `https://intranet.example.org/ai-studio/department-config`
- `config_encryption_secret` = `BASE64...`
This approach works well with GPOs because each slot can be managed independently without rewriting a shared combined string.
### Policy files
#### Windows policy directory
`%ProgramData%\MindWorkAI\AI-Studio\`
#### Linux policy directories
AI Studio checks each directory listed in `$XDG_CONFIG_DIRS` and looks for a `mindwork-ai-studio` subdirectory in each one. If `$XDG_CONFIG_DIRS` is empty or not set, AI Studio falls back to:
`/etc/xdg/mindwork-ai-studio/`
The directories from `$XDG_CONFIG_DIRS` are processed in order.
#### macOS policy directory
`/Library/Application Support/MindWork/AI Studio/`
#### Policy file names and content
Configuration files:
- `config0.yaml`
- `config1.yaml`
- ...
- `config9.yaml`
Each configuration file contains one configuration ID and one server URL:
```yaml
id: "9072b77d-ca81-40da-be6a-861da525ef7b"
server_url: "https://intranet.example.org/ai-studio/configuration"
```
**Priority:** When multiple configurations define the same setting (e.g., a provider with the same ID), the first definition wins. The order of entries in the variable determines priority. Place the organization-wide configuration first, followed by department-specific configurations if the organization should have higher priority.
Optional encryption secret file:
### Windows GPO / PowerShell example for `configs`
- `config_encryption_secret.yaml`
If you distribute multiple GPOs, each GPO should read and write the same registry value (`configs`) and only update its own `id@url` entry. Other entries must stay untouched.
The following PowerShell example provides helper functions for appending and removing entries safely:
```powershell
$RegistryPath = "HKCU:\Software\github\MindWork AI Studio\Enterprise IT"
$ConfigsValueName = "configs"
function Get-ConfigEntries {
param([string]$RawValue)
if ([string]::IsNullOrWhiteSpace($RawValue)) { return @() }
$entries = @()
foreach ($part in $RawValue.Split(';')) {
$trimmed = $part.Trim()
if ([string]::IsNullOrWhiteSpace($trimmed)) { continue }
$pair = $trimmed.Split('@', 2)
if ($pair.Count -ne 2) { continue }
$id = $pair[0].Trim().ToLowerInvariant()
$url = $pair[1].Trim()
if ([string]::IsNullOrWhiteSpace($id) -or [string]::IsNullOrWhiteSpace($url)) { continue }
$entries += [PSCustomObject]@{
Id = $id
Url = $url
}
}
return $entries
}
function ConvertTo-ConfigValue {
param([array]$Entries)
return ($Entries | ForEach-Object { "$($_.Id)@$($_.Url)" }) -join ';'
}
function Add-EnterpriseConfigEntry {
param(
[Parameter(Mandatory=$true)][Guid]$ConfigId,
[Parameter(Mandatory=$true)][string]$ServerUrl
)
if (-not (Test-Path $RegistryPath)) {
New-Item -Path $RegistryPath -Force | Out-Null
}
$raw = (Get-ItemProperty -Path $RegistryPath -Name $ConfigsValueName -ErrorAction SilentlyContinue).$ConfigsValueName
$entries = Get-ConfigEntries -RawValue $raw
$normalizedId = $ConfigId.ToString().ToLowerInvariant()
$normalizedUrl = $ServerUrl.Trim()
# Replace only this one ID, keep all other entries unchanged.
$entries = @($entries | Where-Object { $_.Id -ne $normalizedId })
$entries += [PSCustomObject]@{
Id = $normalizedId
Url = $normalizedUrl
}
Set-ItemProperty -Path $RegistryPath -Name $ConfigsValueName -Type String -Value (ConvertTo-ConfigValue -Entries $entries)
}
function Remove-EnterpriseConfigEntry {
param(
[Parameter(Mandatory=$true)][Guid]$ConfigId
)
if (-not (Test-Path $RegistryPath)) { return }
$raw = (Get-ItemProperty -Path $RegistryPath -Name $ConfigsValueName -ErrorAction SilentlyContinue).$ConfigsValueName
$entries = Get-ConfigEntries -RawValue $raw
$normalizedId = $ConfigId.ToString().ToLowerInvariant()
# Remove only this one ID, keep all other entries unchanged.
$updated = @($entries | Where-Object { $_.Id -ne $normalizedId })
Set-ItemProperty -Path $RegistryPath -Name $ConfigsValueName -Type String -Value (ConvertTo-ConfigValue -Entries $updated)
}
# Example usage:
# Add-EnterpriseConfigEntry -ConfigId "9072b77d-ca81-40da-be6a-861da525ef7b" -ServerUrl "https://intranet.example.org:30100/ai-studio/configuration"
# Remove-EnterpriseConfigEntry -ConfigId "9072b77d-ca81-40da-be6a-861da525ef7b"
```yaml
config_encryption_secret: "BASE64..."
```
### Single configuration (legacy)
### Environment variable example
The following single-configuration keys and variables are still supported for backwards compatibility. AI Studio always reads both the multi-config and legacy variables and merges all found configurations into one list. If a configuration ID appears in both, the entry from the multi-config format takes priority (first occurrence wins). This means you can migrate to the new format incrementally without losing existing configurations:
If you need the fallback environment-variable format, configure the values like this:
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_id` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID`: This must be a valid [GUID](https://en.wikipedia.org/wiki/Universally_unique_identifier#Globally_unique_identifier). It uniquely identifies the configuration. You can use an ID per department, institute, or even per person.
```bash
MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID0=9072b77d-ca81-40da-be6a-861da525ef7b
MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL0=https://intranet.example.org/ai-studio/configuration
MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID1=a1b2c3d4-e5f6-7890-abcd-ef1234567890
MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL1=https://intranet.example.org/ai-studio/department-config
MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET=BASE64...
```
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_server_url` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL`: An HTTP or HTTPS address using an IP address or DNS name. This is the web server from which AI Studio attempts to load the specified configuration as a ZIP file.
### Legacy formats (still supported)
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_encryption_secret` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET`: A base64-encoded 32-byte encryption key for decrypting API keys in configuration plugins. This is optional and only needed if you want to include encrypted API keys in your configuration.
The following older formats are still supported for backwards compatibility:
- Registry value `configs` or environment variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS`: Combined format `id1@url1;id2@url2;...`
- Registry value `config_id` or environment variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID`
- Registry value `config_server_url` or environment variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL`
- Registry value `config_encryption_secret` or environment variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET`
Within a single source, AI Studio reads the new indexed pairs first, then the combined legacy format, and finally the legacy single-configuration format. This makes it possible to migrate gradually without breaking older setups.
### How configurations are downloaded
@ -183,7 +178,7 @@ intranet.my-company.com:30100 {
## Important: Plugin ID must match the enterprise configuration ID
The `ID` field inside your configuration plugin (the Lua file) **must** be identical to the enterprise configuration ID used in the registry or environment variable. AI Studio uses this ID to match downloaded configurations to their plugins. If the IDs do not match, AI Studio will log a warning and the configuration may not be displayed correctly on the Information page.
The `ID` field inside your configuration plugin (the Lua file) **must** be identical to the enterprise configuration ID configured on the client device, whether it comes from the registry, a policy file, or an environment variable. AI Studio uses this ID to match downloaded configurations to their plugins. If the IDs do not match, AI Studio will log a warning and the configuration may not be displayed correctly on the Information page.
For example, if your enterprise configuration ID is `9072b77d-ca81-40da-be6a-861da525ef7b`, then your plugin must declare:
@ -233,9 +228,10 @@ You can include encrypted API keys in your configuration plugins for cloud provi
In AI Studio, enable the "Show administration settings" toggle in the app settings. Then click the "Generate encryption secret and copy to clipboard" button in the "Enterprise Administration" section. This generates a cryptographically secure 256-bit key and copies it to your clipboard as a base64 string.
2. **Deploy the encryption secret:**
Distribute the secret to all client machines via Group Policy (Windows Registry) or environment variables:
- Registry: `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT\config_encryption_secret`
- Environment: `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET`
Distribute the secret to all client machines using any supported enterprise source. The secret can be deployed on its own, even when no enterprise configuration IDs or server URLs are defined on that machine:
- Windows Registry / GPO: `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT\config_encryption_secret`
- Policy file: `config_encryption_secret.yaml`
- Environment fallback: `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET`
You must also deploy the same secret on the machine where you will export the encrypted API keys (step 3).

File diff suppressed because it is too large Load Diff