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

@ -96,6 +96,49 @@ public class PluginLoaderTests
loaded.LoadContext!.Unload();
}
[Fact]
public void Load_UnsupportedApiVersion_IsRefusedBeforeAnyCodeLoads()
{
var host = new StubHost();
var manifest = new PluginManifest(
Id: "future.plugin",
DisplayName: "Future",
Version: "1.0.0",
EntryDll: "nope.dll", // deliberately nonexistent:
ApiVersion: PluginApi.Current + 1,
Dependencies: Array.Empty<string>());
var loaded = PluginLoader.Load("/does/not/exist", manifest, host);
Assert.False(loaded.Success);
// ...the version gate must fire FIRST, before the dll is even probed,
// so the failure names the real remedy (update the client or the
// plugin) instead of a file-not-found or a type-load error from
// half-loaded plugin code.
var mismatch = Assert.IsType<PluginApiVersionException>(loaded.Error);
Assert.Contains("future.plugin", mismatch.Message);
Assert.Null(loaded.LoadContext);
}
[Fact]
public void Load_MinimumSupportedApiVersion_PassesTheGate()
{
var host = new StubHost();
var manifest = new PluginManifest(
Id: "old.plugin",
DisplayName: "Old",
Version: "1.0.0",
EntryDll: "nope.dll",
ApiVersion: PluginApi.MinimumSupported,
Dependencies: Array.Empty<string>());
var loaded = PluginLoader.Load("/does/not/exist", manifest, host);
// Fails on the missing dll, NOT on the version gate.
Assert.False(loaded.Success);
Assert.IsType<FileNotFoundException>(loaded.Error);
}
[Fact]
public void Load_MissingDll_ReturnsFailure()
{

View file

@ -174,8 +174,20 @@ public sealed class SessionConfigComposerTests
Assert.Equal("+Acdream", (string?)session["character"]!["name"]);
}
/// <summary>
/// A character nobody has configured loads every discovered plugin.
/// </summary>
/// <remarks>
/// This REVERSES the original LA5 mapping, deliberately. LA5's
/// omitted-vs-empty distinction is kept intact at the session-config
/// level; what changed is which one an unconfigured character maps to. A
/// new profile starts with an empty list, so a client that ships plugins
/// used to load none of them until the user typed an id — and nothing
/// reported it, the panel simply never appeared. The opt-out lives on as
/// the literal id "none" (see the next test).
/// </remarks>
[Fact]
public void EmptyPluginsRemainAnExplicitLoadNoneAllowListWhileLoginCommandsAreOmitted()
public void UnconfiguredPluginsOmitTheAllowListSoEveryPluginLoads()
{
CharacterProfile character = Character(LaunchMode.Gui);
character.Plugins = [];
@ -189,10 +201,48 @@ public sealed class SessionConfigComposerTests
Paths,
sessionId: "session-empty-lists");
JsonObject session = SingleSession(composed);
Assert.False(session.ContainsKey("plugins"));
Assert.False(session.ContainsKey("loginCommands"));
}
[Fact]
public void ThePluginIdNoneEmitsAnExplicitLoadNoneAllowList()
{
CharacterProfile character = Character(LaunchMode.Gui);
character.Plugins = ["none"];
ComposedSessionConfig composed = SessionConfigComposer.Compose(
Server(),
Account(),
character,
Install,
Paths,
sessionId: "session-no-plugins");
JsonObject session = SingleSession(composed);
Assert.True(session.ContainsKey("plugins"));
Assert.Empty(session["plugins"]!.AsArray());
Assert.False(session.ContainsKey("loginCommands"));
}
[Fact]
public void ConfiguredPluginIdsArePassedThroughUnchanged()
{
CharacterProfile character = Character(LaunchMode.Gui);
character.Plugins = ["acdream.mosstank"];
ComposedSessionConfig composed = SessionConfigComposer.Compose(
Server(),
Account(),
character,
Install,
Paths,
sessionId: "session-one-plugin");
JsonObject session = SingleSession(composed);
Assert.Equal(
["acdream.mosstank"],
session["plugins"]!.AsArray().Select(node => (string?)node));
}
[Fact]

View file

@ -65,7 +65,10 @@ internal static class LauncherCoreSessionConfigFixture
Name = "Composer Character",
Id = "0x50000001",
LaunchMode = LaunchMode.Headless,
Plugins = [],
// "none" is how a profile now spells the explicit load-none
// allow-list; an EMPTY list means unconfigured and composes to an
// OMITTED allow-list (= load all) since plugins became default-on.
Plugins = ["none"],
LoginCommands = [],
};