feat(plugins): enforce apiVersion; launcher plugins default ON with "none" opt-out

Two gaps from the MossTank shipment review.

**apiVersion was declared in every manifest and checked by nothing.** The
loader now refuses an unsupported contract BEFORE loading any code from the
plugin — checking after the fact is not equivalent, because by then the
assembly is in a collectible context and the mismatch surfaces as a type-load
or missing-member failure from inside the plugin, which reads like the plugin
is broken rather than built for a different host. PluginApi (Current /
MinimumSupported) lives in Plugin.Abstractions beside the contract it
versions, and the refusal is a distinct PluginApiVersionException so callers
can tell "update the client or the plugin" from "this plugin is broken". The
tests pin the ordering too: a manifest with a future apiVersion AND a missing
dll must fail on the version, a supported one on the dll.

**A launcher-launched client loaded no plugins until the user typed ids.**
LA5 distinguishes an omitted allow-list (load all) from an explicit empty one
(load none); a fresh character profile's list is empty, so it composed to
load-none. Direct launches pass null and load everything -- which is why the
gap never showed in development: the two launch paths disagreed and the
launcher was the one users get. This REVERSES the LA5 default deliberately:
"nothing configured" now composes to the omitted list, so plugins are on by
default, including ones installed later. The opt-out is kept -- losing it
would be a real regression for stripped sessions -- respelled as the literal
id "none", and the launcher's plugin box says so.

The cross-host shared fixture composes its explicit-load-none case through
the new spelling, keeping the reader-side contract tests (App and Headless
both preserve an explicit empty list) exactly as they were.

Complete Release suite: 14,469 tests pass on the standard hermetic lane
filter, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-20 21:28:04 +02:00
parent 7e75be23d1
commit cd6eefd0ba
8 changed files with 194 additions and 7 deletions

View file

@ -20,6 +20,21 @@ public static class PluginLoader
ArgumentNullException.ThrowIfNull(manifest);
ArgumentNullException.ThrowIfNull(host);
// Refuse a contract we cannot honour BEFORE loading any code from it.
// Checking after the fact is not equivalent: the assembly is already in
// a collectible context, and the mismatch surfaces as a type-load or
// missing-member failure from inside the plugin, which reads like the
// plugin is broken rather than built for a different host.
if (!PluginApi.IsSupported(manifest.ApiVersion))
return new LoadedPlugin(
manifest,
Plugin: null,
LoadContext: null,
Error: new PluginApiVersionException(
$"plugin '{manifest.Id}' declares apiVersion {manifest.ApiVersion}, "
+ $"but this build supports {PluginApi.MinimumSupported}"
+ $"..{PluginApi.Current}"));
var dllPath = Path.Combine(pluginDirectory, manifest.EntryDll);
if (!File.Exists(dllPath))
return new LoadedPlugin(

View file

@ -64,6 +64,16 @@ public sealed record PluginManifest(
}
}
/// <summary>
/// A plugin declared a contract version this build cannot load. Distinct from
/// <see cref="PluginManifestException"/> (a malformed manifest) because the
/// remedy differs: this one means update the plugin or the client.
/// </summary>
public sealed class PluginApiVersionException : Exception
{
public PluginApiVersionException(string message) : base(message) { }
}
public sealed class PluginManifestException : Exception
{
public PluginManifestException(string message) : base(message) { }

View file

@ -138,9 +138,7 @@ public static class SessionConfigComposer
Character = selector,
Policy = policy,
Credential = new SessionCredentialDescriptor(),
// LA5 distinguishes an omitted allow-list (load all, preserving
// the developer flow) from an explicit empty list (load none).
Plugins = [.. character.Plugins],
Plugins = ComposePluginAllowList(character.Plugins),
LoginCommands = character.LoginCommands.Count > 0
? [.. character.LoginCommands]
: null,
@ -271,6 +269,41 @@ public static class SessionConfigComposer
string sessionId) =>
Write(ComposeProbe(server, account, install, paths, sessionId));
/// <summary>
/// Maps a character's configured plugin ids to the session config's
/// allow-list.
/// </summary>
/// <remarks>
/// <para>
/// LA5 distinguishes an OMITTED allow-list (load every discovered plugin)
/// from an explicit EMPTY one (load none). A brand-new character profile
/// starts with an empty list, which meant a client that ships plugins
/// loaded none of them until the user typed an id — "ships with the
/// client" and "works out of the box" were different things, and the
/// difference was invisible: nothing was logged, the panel simply never
/// appeared.
/// </para>
/// <para>
/// So "nothing configured" now maps to omitted — plugins are on by
/// default, including ones installed later. The opt-out is kept, because
/// losing it would be a real regression for anyone running a stripped
/// session: the literal id <c>none</c> maps to the explicit empty list.
/// </para>
/// </remarks>
private static List<string>? ComposePluginAllowList(IReadOnlyList<string> configured)
{
if (configured.Count == 0)
return null; // default: load all
if (configured.Count == 1
&& string.Equals(configured[0], "none", StringComparison.OrdinalIgnoreCase))
{
return []; // explicit: load none
}
return [.. configured];
}
private static ComposedSessionConfig Write(ComposedSessionConfig composed)
{
string? directory = Path.GetDirectoryName(composed.ConfigFilePath);

View file

@ -203,7 +203,7 @@
value is left alone; it is simply no longer offered. -->
<Grid ColumnDefinitions="*,12,*">
<StackPanel Spacing="5">
<TextBlock Text="Plugins (one id per line)" Classes="muted" />
<TextBlock Text="Plugins (one id per line — blank loads all, &quot;none&quot; disables)" Classes="muted" />
<TextBox Text="{Binding CharacterPluginsText, Mode=TwoWay}"
AcceptsReturn="True"
TextWrapping="Wrap"

View file

@ -1,6 +1,39 @@
// src/AcDream.Plugin.Abstractions/IAcDreamPlugin.cs
namespace AcDream.Plugin.Abstractions;
/// <summary>The plugin contract's version.</summary>
/// <remarks>
/// <para>
/// A plugin declares the version it was built against in its
/// <c>plugin.json</c> (<c>apiVersion</c>), and the host refuses to load one
/// it cannot honour. Without that check a plugin built against a different
/// contract loads anyway and fails later as a MissingMethodException or a
/// type-load error from inside the plugin's own code — an error that reads
/// like the plugin is broken rather than mismatched.
/// </para>
/// <para>
/// Bump <see cref="Current"/> only for a BREAKING change to the interfaces in
/// this assembly (a removed or re-shaped member). Purely additive changes keep
/// the number, since a plugin built against the older shape still runs.
/// </para>
/// </remarks>
public static class PluginApi
{
/// <summary>The contract version this build implements.</summary>
public const int Current = 1;
/// <summary>
/// The oldest contract version this build can still load. Equal to
/// <see cref="Current"/> until a breaking change ships with a
/// compatibility path.
/// </summary>
public const int MinimumSupported = 1;
/// <summary>Whether a plugin declaring <paramref name="apiVersion"/> can load here.</summary>
public static bool IsSupported(int apiVersion)
=> apiVersion >= MinimumSupported && apiVersion <= Current;
}
public interface IAcDreamPlugin
{
/// <summary>