Added a test directory for staging enterprise configurations locally

This commit is contained in:
Thorsten Sommer 2026-08-09 18:36:55 +02:00
parent 666b4574a9
commit 52b4d70fc9
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
7 changed files with 183 additions and 27 deletions

View File

@ -277,10 +277,10 @@ public static partial class ManagedConfiguration
if (owningConfigPluginId == Guid.Empty || owningConfigPluginId == configPluginId) if (owningConfigPluginId == Guid.Empty || owningConfigPluginId == configPluginId)
return true; return true;
if (!PluginFactory.IsEnterpriseConfigurationPlugin(owningConfigPluginId)) if (!PluginFactory.IsOrganizationConfigurationPlugin(owningConfigPluginId))
return true; return true;
if (PluginFactory.IsEnterpriseConfigurationPlugin(configPluginId)) if (PluginFactory.IsOrganizationConfigurationPlugin(configPluginId))
return true; return true;
Log.LogWarning($"The configuration plugin '{configPluginId}' tried to manage the setting '{configMeta.SettingName}', which is managed by the configuration plugin '{owningConfigPluginId}' of your organization. Ignoring the attempt: configurations deployed by your organization's IT take precedence."); Log.LogWarning($"The configuration plugin '{configPluginId}' tried to manage the setting '{configMeta.SettingName}', which is managed by the configuration plugin '{owningConfigPluginId}' of your organization. Ignoring the attempt: configurations deployed by your organization's IT take precedence.");

View File

@ -392,8 +392,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
return; return;
// //
// Only the IT department of an organization may approve assistant plugins. An approval // Only a configuration which speaks for an organization may approve assistant plugins: one
// marks a plugin as safe without any security audit, and the user interface states that the // deployed by a configuration server, or one staged in the test directory. An approval marks
// a plugin as safe without any security audit, and the user interface states that the
// organization approved it. No local configuration plugin may make that claim: it would // organization approved it. No local configuration plugin may make that claim: it would
// disable the security audit for arbitrary assistant plugins while telling the user that // disable the security audit for arbitrary assistant plugins while telling the user that
// their organization vouched for them. // their organization vouched for them.
@ -401,14 +402,17 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// We decide by the plugin path. The self-declared DEPLOYED_USING_CONFIG_SERVER field would // We decide by the plugin path. The self-declared DEPLOYED_USING_CONFIG_SERVER field would
// not do, because any plugin can set it to true. // not do, because any plugin can set it to true.
// //
if (!PluginFactory.IsEnterpriseConfigurationPath(this.PluginPath)) if (!PluginFactory.IsOrganizationConfigurationPath(this.PluginPath))
{ {
if (successful) if (successful)
LOG.LogWarning("The configuration plugin '{ConfigPluginId}' at '{PluginPath}' declares enterprise approvals for assistant plugins, but your organization's IT did not deploy it. Ignoring these approvals: only configuration plugins from a configuration server may approve assistant plugins.", this.Id, this.PluginPath); LOG.LogWarning("The configuration plugin '{ConfigPluginId}' at '{PluginPath}' declares enterprise approvals for assistant plugins, but your organization's IT did not deploy it. Ignoring these approvals: only configuration plugins from a configuration server or from the test directory may approve assistant plugins.", this.Id, this.PluginPath);
return; return;
} }
if (PluginFactory.IsEnterpriseTestConfigurationPath(this.PluginPath))
LOG.LogWarning("The test configuration plugin '{ConfigPluginId}' at '{PluginPath}' approves assistant plugins. These approvals are valid for this session only: AI Studio empties the test directory on every start.", this.Id, this.PluginPath);
switch (successful) switch (successful)
{ {
case true: case true:

View File

@ -312,10 +312,10 @@ public sealed record PluginConfigurationObject
if (!existingObject.IsEnterpriseConfiguration || existingObject.EnterpriseConfigurationPluginId == configPluginId) if (!existingObject.IsEnterpriseConfiguration || existingObject.EnterpriseConfigurationPluginId == configPluginId)
return true; return true;
if (!PluginFactory.IsEnterpriseConfigurationPlugin(existingObject.EnterpriseConfigurationPluginId)) if (!PluginFactory.IsOrganizationConfigurationPlugin(existingObject.EnterpriseConfigurationPluginId))
return true; return true;
if (PluginFactory.IsEnterpriseConfigurationPlugin(configPluginId)) if (PluginFactory.IsOrganizationConfigurationPlugin(configPluginId))
return true; return true;
LOG.LogWarning("The configuration plugin '{ConfigPluginId}' tried to replace the object '{ConfigObjectName}' (id={ConfigObjectId}), which belongs to the configuration plugin '{OwningConfigPluginId}' of your organization. Ignoring the attempt: configurations deployed by your organization's IT take precedence.", configPluginId, existingObject.Name, existingObject.Id, existingObject.EnterpriseConfigurationPluginId); LOG.LogWarning("The configuration plugin '{ConfigPluginId}' tried to replace the object '{ConfigObjectName}' (id={ConfigObjectId}), which belongs to the configuration plugin '{OwningConfigPluginId}' of your organization. Ignoring the attempt: configurations deployed by your organization's IT take precedence.", configPluginId, existingObject.Name, existingObject.Id, existingObject.EnterpriseConfigurationPluginId);

View File

@ -119,13 +119,17 @@ public static partial class PluginFactory
// //
if (AVAILABLE_PLUGINS.FirstOrDefault(candidate => candidate.Id == plugin.Id) is { } duplicatePlugin) if (AVAILABLE_PLUGINS.FirstOrDefault(candidate => candidate.Id == plugin.Id) is { } duplicatePlugin)
{ {
if (!IsEnterpriseConfigurationPath(pluginPath) || IsEnterpriseConfigurationPath(duplicatePlugin.LocalPath)) if (GetConfigurationAuthority(pluginPath) <= GetConfigurationAuthority(duplicatePlugin.LocalPath))
{ {
LOG.LogWarning($"Ignoring the plugin '{pluginMainFile}': its ID ('{plugin.Id}') is already used by the plugin at '{duplicatePlugin.LocalPath}'. Plugin IDs must be unique. Please remove one of these plugins."); LOG.LogWarning($"Ignoring the plugin '{pluginMainFile}': its ID ('{plugin.Id}') is already used by the plugin at '{duplicatePlugin.LocalPath}'. Plugin IDs must be unique. Please remove one of these plugins.");
continue; continue;
} }
LOG.LogWarning($"Ignoring the plugin at '{duplicatePlugin.LocalPath}': it uses the ID ('{plugin.Id}') of the enterprise configuration plugin at '{pluginPath}'. Plugins deployed by your organization's IT take precedence."); if (IsEnterpriseTestConfigurationPath(pluginPath))
LOG.LogWarning($"Ignoring the plugin at '{duplicatePlugin.LocalPath}': it uses the ID ('{plugin.Id}') of the test configuration plugin at '{pluginPath}'. A test configuration takes precedence until AI Studio is restarted.");
else
LOG.LogWarning($"Ignoring the plugin at '{duplicatePlugin.LocalPath}': it uses the ID ('{plugin.Id}') of the enterprise configuration plugin at '{pluginPath}'. Plugins deployed by your organization's IT take precedence.");
AVAILABLE_PLUGINS.Remove(duplicatePlugin); AVAILABLE_PLUGINS.Remove(duplicatePlugin);
} }
@ -199,6 +203,15 @@ public static partial class PluginFactory
// one broken configuration plugin would wipe the entire organization configuration: // one broken configuration plugin would wipe the entire organization configuration:
// //
var deployedEnterpriseConfigPluginIds = GetDeployedEnterpriseConfigPluginIds(); var deployedEnterpriseConfigPluginIds = GetDeployedEnterpriseConfigPluginIds();
//
// Test configurations manage settings and objects like a deployed configuration, so those must
// not be treated as left over while the test runs. They are only ever loaded, never merely
// present: the test directory is emptied on every start.
//
foreach (var testConfigurationPlugin in AVAILABLE_PLUGINS.Where(plugin => plugin.Type is PluginType.CONFIGURATION && IsEnterpriseTestConfigurationPath(plugin.LocalPath)))
deployedEnterpriseConfigPluginIds.Add(testConfigurationPlugin.Id);
var unloadedEnterpriseConfigPluginIds = deployedEnterpriseConfigPluginIds.Where(x => AVAILABLE_PLUGINS.All(plugin => plugin.Id != x)).ToList(); var unloadedEnterpriseConfigPluginIds = deployedEnterpriseConfigPluginIds.Where(x => AVAILABLE_PLUGINS.All(plugin => plugin.Id != x)).ToList();
foreach (var unloadedEnterpriseConfigPluginId in unloadedEnterpriseConfigPluginIds) foreach (var unloadedEnterpriseConfigPluginId in unloadedEnterpriseConfigPluginIds)
LOG.LogWarning($"The configuration plugin '{unloadedEnterpriseConfigPluginId}' is deployed, but was not loaded. Everything it manages stays unchanged, because the plugin was not removed. Please check the errors above and fix the plugin."); LOG.LogWarning($"The configuration plugin '{unloadedEnterpriseConfigPluginId}' is deployed, but was not loaded. Everything it manages stays unchanged, because the plugin was not removed. Please check the errors above and fix the plugin.");

View File

@ -110,9 +110,10 @@ public static partial class PluginFactory
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The configuration plugins an organization deployed go first: they are the baseline for /// The configuration plugins an organization deployed go first: they are the baseline for
/// everything else. Local configuration plugins follow, so they can add to that baseline instead /// everything else. A test configuration follows, so that an administrator sees their draft take
/// of replacing parts of it. All remaining plugin types write no settings at all, so their rank /// effect over the deployed baseline. Local configuration plugins come last, so they can add to
/// is irrelevant for the outcome.<br/><br/> /// that baseline instead of replacing parts of it. All remaining plugin types write no settings at
/// all, so their rank is irrelevant for the outcome.<br/><br/>
/// The rank comes before the declared priority on purpose: a local configuration plugin must not /// The rank comes before the declared priority on purpose: a local configuration plugin must not
/// be able to jump ahead of an organization by declaring a high priority. /// be able to jump ahead of an organization by declaring a high priority.
/// </remarks> /// </remarks>
@ -121,9 +122,10 @@ public static partial class PluginFactory
private static int GetStartupRank(IAvailablePlugin plugin) => plugin.Type switch private static int GetStartupRank(IAvailablePlugin plugin) => plugin.Type switch
{ {
PluginType.CONFIGURATION when IsEnterpriseConfigurationPath(plugin.LocalPath) => 0, PluginType.CONFIGURATION when IsEnterpriseConfigurationPath(plugin.LocalPath) => 0,
PluginType.CONFIGURATION => 1, PluginType.CONFIGURATION when IsEnterpriseTestConfigurationPath(plugin.LocalPath) => 1,
PluginType.CONFIGURATION => 2,
_ => 2, _ => 3,
}; };
private static void LogAssistantPluginStartupState() private static void LogAssistantPluginStartupState()

View File

@ -21,9 +21,31 @@ public static partial class PluginFactory
/// deploys plugins here, each in a directory named after its configuration ID. /// deploys plugins here, each in a directory named after its configuration ID.
/// </remarks> /// </remarks>
private static string ENTERPRISE_CONFIGURATION_PLUGINS_ROOT = string.Empty; private static string ENTERPRISE_CONFIGURATION_PLUGINS_ROOT = string.Empty;
/// <summary>
/// The directory administrators use to try out a configuration before their organization deploys it.
/// </summary>
/// <remarks>
/// Everything stored here acts on behalf of the organization, so that a test behaves like the
/// later rollout, including the approval of assistant plugins. In exchange, the directory is
/// emptied on every start: a test configuration lives for one session only. It also never gets
/// the protection of a deployed configuration, so users can remove or replace it through the user
/// interface.
/// </remarks>
private static string ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT = string.Empty;
private static string HOT_RELOAD_LOCK_FILE = string.Empty; private static string HOT_RELOAD_LOCK_FILE = string.Empty;
private static FileSystemWatcher HOT_RELOAD_WATCHER = null!; private static FileSystemWatcher HOT_RELOAD_WATCHER = null!;
/// <summary>
/// How many test configurations were removed while AI Studio was starting.
/// </summary>
/// <remarks>
/// The user interface reports this: an administrator who placed a test configuration and restarted
/// AI Studio would otherwise face an empty directory without any explanation.
/// </remarks>
public static int RemovedTestConfigurationsAtStartup { get; private set; }
public static ILanguagePlugin BaseLanguage { get; private set; } = NoPluginLanguage.INSTANCE; public static ILanguagePlugin BaseLanguage { get; private set; } = NoPluginLanguage.INSTANCE;
public static bool IsInitialized { get; private set; } public static bool IsInitialized { get; private set; }
@ -75,10 +97,12 @@ public static partial class PluginFactory
HOT_RELOAD_LOCK_FILE = Path.Join(PLUGINS_ROOT, ".lock"); HOT_RELOAD_LOCK_FILE = Path.Join(PLUGINS_ROOT, ".lock");
INTERNAL_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".internal"); INTERNAL_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".internal");
ENTERPRISE_CONFIGURATION_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".config"); ENTERPRISE_CONFIGURATION_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".config");
ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".config-tests");
if (!Directory.Exists(PLUGINS_ROOT)) if (!Directory.Exists(PLUGINS_ROOT))
Directory.CreateDirectory(PLUGINS_ROOT); Directory.CreateDirectory(PLUGINS_ROOT);
ClearTestConfigurationPlugins();
HOT_RELOAD_WATCHER = new(PLUGINS_ROOT); HOT_RELOAD_WATCHER = new(PLUGINS_ROOT);
IsInitialized = true; IsInitialized = true;
LOG.LogInformation("Plugin factory initialized successfully."); LOG.LogInformation("Plugin factory initialized successfully.");
@ -98,6 +122,75 @@ public static partial class PluginFactory
/// <returns>True when the directory is nested in the enterprise configuration directory.</returns> /// <returns>True when the directory is nested in the enterprise configuration directory.</returns>
public static bool IsEnterpriseConfigurationPath(string? pluginPath) => IsPathInside(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, pluginPath); public static bool IsEnterpriseConfigurationPath(string? pluginPath) => IsPathInside(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, pluginPath);
/// <summary>
/// Checks whether a plugin directory belongs to the test configuration area.
/// </summary>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <returns>True when the directory is nested in the test configuration directory.</returns>
public static bool IsEnterpriseTestConfigurationPath(string? pluginPath) => IsPathInside(ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT, pluginPath);
/// <summary>
/// Checks whether a plugin acts on behalf of an organization, either deployed by a configuration
/// server or staged for a test.
/// </summary>
/// <remarks>
/// Use this wherever a configuration speaks for the organization, e.g. when it approves assistant
/// plugins or claims a setting against a local configuration plugin. Do not use it where a
/// deployed configuration is protected against the user, e.g. against deletion: an administrator
/// must be able to get rid of their own test configuration.
/// </remarks>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <returns>True when the directory belongs to the enterprise or the test configuration area.</returns>
public static bool IsOrganizationConfigurationPath(string? pluginPath) => IsEnterpriseConfigurationPath(pluginPath) || IsEnterpriseTestConfigurationPath(pluginPath);
/// <summary>
/// Ranks how much say a configuration plugin has, based on where it is stored. The higher rank
/// wins when two configuration plugins claim the same plugin ID.
/// </summary>
/// <remarks>
/// A test configuration outranks a deployed one on purpose: an administrator tries out the next
/// version of a configuration under the ID it will have later. Local configuration plugins rank
/// lowest, so nobody can push aside what an organization deployed.
/// </remarks>
private static int GetConfigurationAuthority(string? pluginPath)
{
if (IsEnterpriseTestConfigurationPath(pluginPath))
return 2;
return IsEnterpriseConfigurationPath(pluginPath) ? 1 : 0;
}
/// <summary>
/// Empties the test configuration directory.
/// </summary>
/// <remarks>
/// A test configuration carries the rights of an organization configuration without anybody having
/// deployed it. It must therefore never outlive the session it was placed in, and administrators
/// get a predictable lifetime instead of a configuration which is swept away at some point.
/// </remarks>
private static void ClearTestConfigurationPlugins()
{
RemovedTestConfigurationsAtStartup = 0;
try
{
if (Directory.Exists(ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT))
{
var removedTestConfigurations = Directory.EnumerateDirectories(ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT).Count();
Directory.Delete(ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT, true);
RemovedTestConfigurationsAtStartup = removedTestConfigurations;
if (removedTestConfigurations > 0)
LOG.LogWarning($"Removed {removedTestConfigurations} test configuration(s) from '{ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT}'. Test configurations are valid for one session only.");
}
Directory.CreateDirectory(ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT);
}
catch (Exception e)
{
LOG.LogError(e, $"Failed to empty the test configuration directory '{ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT}'.");
}
}
/// <summary> /// <summary>
/// Checks whether a plugin directory is stored below the plugins directory of AI Studio. /// Checks whether a plugin directory is stored below the plugins directory of AI Studio.
/// </summary> /// </summary>
@ -176,6 +269,27 @@ public static partial class PluginFactory
return Directory.Exists(Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, configPluginId.ToString())); return Directory.Exists(Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, configPluginId.ToString()));
} }
/// <summary>
/// Checks whether a configuration plugin speaks for an organization: either deployed by its IT
/// department, or staged as a test configuration.
/// </summary>
/// <remarks>
/// A test configuration is only ever loaded, never merely present: it is emptied on every start,
/// so there is no unloadable leftover to account for.
/// </remarks>
/// <param name="configPluginId">The ID of the configuration plugin.</param>
/// <returns>True when the plugin speaks for an organization, false when it is local or unknown.</returns>
public static bool IsOrganizationConfigurationPlugin(Guid configPluginId)
{
if (configPluginId == Guid.Empty || !IsInitialized)
return false;
if (IsEnterpriseConfigurationPlugin(configPluginId))
return true;
return AVAILABLE_PLUGINS.Any(plugin => plugin.Id == configPluginId && plugin.Type is PluginType.CONFIGURATION && IsEnterpriseTestConfigurationPath(plugin.LocalPath));
}
private static async Task LockHotReloadAsync() private static async Task LockHotReloadAsync()
{ {
if (!IsInitialized) if (!IsInitialized)

View File

@ -284,6 +284,8 @@ DEPLOYED_USING_CONFIG_SERVER = true
Local, manually managed configuration plugins should set this to `false`. If the field is missing, AI Studio falls back to the plugin path (`.config`) to determine whether the plugin is managed and logs a warning. Local, manually managed configuration plugins should set this to `false`. If the field is missing, AI Studio falls back to the plugin path (`.config`) to determine whether the plugin is managed and logs a warning.
The field describes a plugin, it does not grant it anything. Which configurations belong to your organization is always decided by the plugin path: which approvals for assistant plugins are honored, which configuration wins a conflict, and which configuration AI Studio withdraws once you stop referencing it. A configuration stored under `.config` is therefore removed when your organization no longer references its ID, whatever this field says.
## Priority of configuration plugins ## Priority of configuration plugins
When you deploy more than one configuration, two of your configuration plugins may manage the same setting or define the same object, e.g. the same LLM provider. The optional `PRIORITY` field decides which one wins: When you deploy more than one configuration, two of your configuration plugins may manage the same setting or define the same object, e.g. the same LLM provider. The optional `PRIORITY` field decides which one wins:
@ -311,6 +313,8 @@ Two guarantees are independent of the priority:
- A local configuration plugin never wins against one your IT department deployed, whatever priority it declares. Local plugins are always applied afterwards, and they may not take over a setting or an object that belongs to one of your configurations. - A local configuration plugin never wins against one your IT department deployed, whatever priority it declares. Local plugins are always applied afterwards, and they may not take over a setting or an object that belongs to one of your configurations.
- Two plugins must not share the same plugin ID. If that happens, AI Studio keeps the one your IT department deployed and logs a warning for the other. - Two plugins must not share the same plugin ID. If that happens, AI Studio keeps the one your IT department deployed and logs a warning for the other.
The single exception is a configuration you stage for a test under `.config-tests`. It is applied after your deployed configurations and wins a shared plugin ID, so that you can try out the next version of a configuration under its final ID. See [Local staging and testing](#local-staging-and-testing).
### Settings that hold a list or a table ### Settings that hold a list or a table
For a setting that holds a list or a table, the winning configuration replaces the whole collection. It does not merge the entries. A department configuration that lists a single entry drops every entry the base configuration had set for that setting. For a setting that holds a list or a table, the winning configuration replaces the whole collection. It does not merge the entries. A department configuration that lists a single entry drops every entry the base configuration had set for that setting.
@ -371,7 +375,7 @@ If any Lua file changes, the hash changes automatically and the enterprise appro
### Only your configurations may approve ### Only your configurations may approve
Approvals are honored only in configuration plugins that a configuration server deployed, meaning plugins stored under the `.config` directory. AI Studio ignores the approvals of a locally placed configuration plugin and writes a warning to the log. Approvals are honored only in configuration plugins that speak for your organization: plugins a configuration server deployed, meaning plugins stored under the `.config` directory, and plugins you staged for a test under `.config-tests`. AI Studio ignores the approvals of any other locally placed configuration plugin and writes a warning to the log.
The reason is what an approval does: it marks an assistant plugin as safe without any security audit, and AI Studio then tells the user that their organization approved it. Anyone who can drop a file into the plugin directory could otherwise disable the security audit for an assistant plugin of their choosing while the app vouches for it in your name. The reason is what an approval does: it marks an assistant plugin as safe without any security audit, and AI Studio then tells the user that their organization approved it. Anyone who can drop a file into the plugin directory could otherwise disable the security audit for an assistant plugin of their choosing while the app vouches for it in your name.
@ -411,7 +415,9 @@ This prints the canonical hash and, with `--lua-snippet`, also prints a ready-to
Before you roll a configuration out through a configuration web server, you can stage it on a device and test it end to end, including the enterprise approvals for assistant plugins described above. This needs no configuration web server, no registry, policy, or environment entry, and no encryption secret. Before you roll a configuration out through a configuration web server, you can stage it on a device and test it end to end, including the enterprise approvals for assistant plugins described above. This needs no configuration web server, no registry, policy, or environment entry, and no encryption secret.
What makes this work is where the configuration is stored: AI Studio treats every configuration plugin below the `.config` directory as deployed by your organization. That is the same directory a configuration web server downloads into. AI Studio has a dedicated directory for this: `.config-tests`. A configuration stored there speaks for your organization exactly like a deployed one. In exchange, AI Studio empties the directory on every start, so a test configuration is valid for one session.
Do not use the `.config` directory for this. It belongs to your configuration web server, and AI Studio removes everything there that your organization does not reference anymore.
### The data directory ### The data directory
@ -426,25 +432,42 @@ Plugins live in the data directory of AI Studio:
### Staging a configuration ### Staging a configuration
1. Create the directory `<data directory>/plugins/.config/<configuration plugin ID>/` and place your `plugin.lua` there. Name the directory after the `ID` field of your configuration plugin: AI Studio derives the enterprise configuration ID from the directory name. When both differ, AI Studio writes a warning to the log and cannot relate the plugin to a configuration on the Information page. Place the files **while AI Studio is running**: the test directory is emptied whenever the app starts.
2. Place the assistant plugin you want to test in `<data directory>/plugins/assistants/<any name>/`.
3. AI Studio watches the plugin directory and reloads without a restart. The security card of the assistant then states that your organization approved it, exactly as it will after the rollout.
Everything else behaves as in production as well: the approvals are honored, the configuration takes precedence over locally placed configurations when both use the same plugin ID, and neither the delete button nor an import may replace it. 1. Start AI Studio. It creates `<data directory>/plugins/.config-tests/` if it does not exist yet.
2. Create a directory below it and place your `plugin.lua` there, e.g. `.config-tests/my-department-draft/`. The directory name is up to you here: a test configuration is identified by the `ID` field inside the plugin, not by the directory it lives in.
3. Place the assistant plugin you want to test in `<data directory>/plugins/assistants/<any name>/`.
4. AI Studio watches the plugin directory and picks both up without a restart. The security card of the assistant then states that your organization approved it, exactly as it will after the rollout.
What behaves like the later rollout:
- The approvals for assistant plugins are honored.
- Settings and configuration objects the test configuration manages are protected against local configuration plugins.
- When the test configuration declares the same plugin `ID` as one your organization deployed, the test configuration wins. This is how you try out the next version of an existing configuration under its final ID.
What deliberately does not:
- A test configuration has no protection against the user. You can remove it on the plugin page and replace it by importing a new version.
- It does not survive a restart.
### Testing with a small group ### Testing with a small group
To let colleagues take part in the test, place the same two directories on each of their devices, for example through a script, your MDM solution, or a login script. A configuration web server is not involved, and nothing has to be enabled inside AI Studio. Ordinary user accounts can take part: the data directory belongs to the user, so no administrator rights are needed to place the files. To let colleagues take part in the test, place the same two directories on each of their devices while AI Studio runs, for example through a script, your MDM solution, or a login script. A configuration web server is not involved, and nothing has to be enabled inside AI Studio. Ordinary user accounts can take part: the data directory belongs to the user, so no administrator rights are needed to place the files.
Keep in mind that everybody in the group loses the test configuration the next time they start AI Studio. Either repeat the step, or let your script place the files at every login.
### Cleaning up ### Cleaning up
AI Studio refuses to delete configuration plugins below `.config` through the user interface, because that is where your IT department deploys them. Remove the directory by hand when your test ends. After the next reload, the approvals are gone and the assistant requires a security audit again. Restart AI Studio: the test directory is emptied, the approvals are gone, and the assistant requires a security audit again. To end a test without restarting, delete the configuration on the plugin page.
### Security note ### Security note
AI Studio trusts everything below `.config` as if your IT department had deployed it: approvals for assistant plugins, precedence when plugin IDs collide, and protection against deletion and replacement. The data directory belongs to the user account, so whoever can write there can approve assistant plugins in the name of your organization. A test configuration carries the rights of an organization configuration without anybody having deployed it. Two properties keep that in check, and you should not work around either of them:
No feature inside AI Studio writes to that directory. Importing, sharing, and deleting plugins never touch it, so a user cannot be talked into staging a configuration by opening a file. Still, treat write access to the data directory as equivalent to deploying a configuration, and protect it accordingly on managed devices. - The directory is emptied on every start, so nothing staged for a test can settle in unnoticed.
- No feature inside AI Studio writes into that directory. Importing, sharing, and deleting plugins never touch it, so a user cannot be talked into staging a configuration by opening a file.
The data directory belongs to the user account, so whoever can write there can approve assistant plugins in the name of your organization until the next restart. Treat write access to the data directory as equivalent to deploying a configuration, and protect it accordingly on managed devices.
## Encrypted API Keys ## Encrypted API Keys