acdream/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs
Erik cd6eefd0ba 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>
2026-08-20 21:28:04 +02:00

365 lines
13 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using AcDream.Launcher.Core.Profiles;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Launching;
/// <summary>The composed session-config document plus the per-launch
/// paths derived from the session id, per Campaign LA spec §6.
/// <see cref="StderrLogPath"/> is launcher-internal (fix #406 sibling
/// gap) — it never appears in the written <c>session.json</c>, only in
/// the <see cref="Launching.LauncherProcessSpec"/> the launcher spawns the
/// child with.</summary>
public sealed record ComposedSessionConfig(
string SessionId,
string ConfigFilePath,
string StatusFilePath,
string StderrLogPath,
SessionConfigDocument Document);
/// <summary>
/// Injectable composition/write seam used by the canonical launcher
/// orchestrator. Production delegates to <see cref="SessionConfigComposer"/>;
/// tests can capture the exact request without writing a file or starting a
/// client process.
/// </summary>
public interface ILauncherSessionConfigService
{
ComposedSessionConfig ComposeAndWrite(
ServerProfile server,
AccountProfile account,
CharacterProfile character,
LauncherInstallRecord install,
ApplicationPathSet paths,
string sessionId,
int? loginCommandDelayMs = null);
ComposedSessionConfig ComposeProbeAndWrite(
ServerProfile server,
AccountProfile account,
LauncherInstallRecord install,
ApplicationPathSet paths,
string sessionId);
}
public sealed class LauncherSessionConfigService : ILauncherSessionConfigService
{
public ComposedSessionConfig ComposeAndWrite(
ServerProfile server,
AccountProfile account,
CharacterProfile character,
LauncherInstallRecord install,
ApplicationPathSet paths,
string sessionId,
int? loginCommandDelayMs = null) =>
SessionConfigComposer.ComposeAndWrite(
server,
account,
character,
install,
paths,
sessionId,
loginCommandDelayMs);
public ComposedSessionConfig ComposeProbeAndWrite(
ServerProfile server,
AccountProfile account,
LauncherInstallRecord install,
ApplicationPathSet paths,
string sessionId) =>
SessionConfigComposer.ComposeProbeAndWrite(
server,
account,
install,
paths,
sessionId);
}
/// <summary>
/// Builds the per-launch <see cref="SessionConfigDocument"/> from a
/// profile character + install record (Campaign LA spec §6). Passwords
/// NEVER appear in the composed document — the credential is always the
/// <c>standardInput</c> provider; the launcher feeds the password to the
/// child process's stdin separately (<see cref="LauncherProcessSupervisor"/>).
/// </summary>
public static class SessionConfigComposer
{
internal static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true,
};
/// <summary>
/// Builds the document and the paths it would be written to under
/// <c>&lt;CacheDirectory&gt;/launcher/sessions/&lt;sessionId&gt;/</c>,
/// without touching disk. <paramref name="sessionId"/> is caller-
/// supplied so composition stays a pure function of its inputs
/// (golden-file tests pass a fixed id).
/// </summary>
public static ComposedSessionConfig Compose(
ServerProfile server,
AccountProfile account,
CharacterProfile character,
LauncherInstallRecord install,
ApplicationPathSet paths,
string sessionId,
int? loginCommandDelayMs = null)
{
ArgumentNullException.ThrowIfNull(server);
ArgumentNullException.ThrowIfNull(account);
ArgumentNullException.ThrowIfNull(character);
ArgumentNullException.ThrowIfNull(install);
ArgumentNullException.ThrowIfNull(paths);
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
(string configFilePath, string statusFilePath, string stderrLogPath) =
BuildSessionPaths(paths, sessionId);
SessionCharacterSelector? selector = character.LaunchMode == LaunchMode.GuiSelect
? null
: BuildSelector(character);
SessionPolicyDescriptor? policy = character.LaunchMode == LaunchMode.Headless
? new SessionPolicyDescriptor()
: null;
var descriptor = new SessionDescriptor
{
Id = sessionId,
Endpoint = new SessionEndpointDescriptor
{
Host = server.Host,
Port = server.Port,
},
Account = account.Account,
Character = selector,
Policy = policy,
Credential = new SessionCredentialDescriptor(),
Plugins = ComposePluginAllowList(character.Plugins),
LoginCommands = character.LoginCommands.Count > 0
? [.. character.LoginCommands]
: null,
LoginCommandDelayMs = loginCommandDelayMs,
StatusFile = statusFilePath,
};
var document = new SessionConfigDocument
{
Process = new SessionProcessSettings
{
Content = new SessionContentDescriptor
{
DatDirectory = install.DatDirectory,
PreparedAssetPath = install.PreparedAssetPath,
},
},
Sessions = [descriptor],
};
return new ComposedSessionConfig(
sessionId,
configFilePath,
statusFilePath,
stderrLogPath,
document);
}
/// <summary>
/// Builds a probe session-config document (Campaign LA plan §LA2/
/// §LA3 review finding F2): the session carries <c>mode: "probe"</c>,
/// no <c>character</c> selector, and no <c>policy</c> — the host
/// reports the account's character roster over the status stream and
/// exits without entering the world. Probes carry an explicit empty
/// <c>plugins</c> allow-list so a plugin installed on the machine cannot
/// run merely because the probe has no character-level plugin settings.
/// </summary>
public static ComposedSessionConfig ComposeProbe(
ServerProfile server,
AccountProfile account,
LauncherInstallRecord install,
ApplicationPathSet paths,
string sessionId)
{
ArgumentNullException.ThrowIfNull(server);
ArgumentNullException.ThrowIfNull(account);
ArgumentNullException.ThrowIfNull(install);
ArgumentNullException.ThrowIfNull(paths);
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
(string configFilePath, string statusFilePath, string stderrLogPath) =
BuildSessionPaths(paths, sessionId);
var descriptor = new SessionDescriptor
{
Id = sessionId,
Mode = "probe",
Endpoint = new SessionEndpointDescriptor
{
Host = server.Host,
Port = server.Port,
},
Account = account.Account,
Character = null,
Policy = null,
Credential = new SessionCredentialDescriptor(),
Plugins = [],
LoginCommands = null,
LoginCommandDelayMs = null,
StatusFile = statusFilePath,
};
var document = new SessionConfigDocument
{
Process = new SessionProcessSettings
{
Content = new SessionContentDescriptor
{
DatDirectory = install.DatDirectory,
PreparedAssetPath = install.PreparedAssetPath,
},
},
Sessions = [descriptor],
};
return new ComposedSessionConfig(
sessionId,
configFilePath,
statusFilePath,
stderrLogPath,
document);
}
/// <summary>Composes and writes <c>session.json</c> to
/// <see cref="ComposedSessionConfig.ConfigFilePath"/>, creating the
/// per-session directory. The status file itself is created by the
/// launched host, not the launcher.</summary>
public static ComposedSessionConfig ComposeAndWrite(
ServerProfile server,
AccountProfile account,
CharacterProfile character,
LauncherInstallRecord install,
ApplicationPathSet paths,
string sessionId,
int? loginCommandDelayMs = null)
{
ComposedSessionConfig composed = Compose(
server,
account,
character,
install,
paths,
sessionId,
loginCommandDelayMs);
return Write(composed);
}
/// <summary>Probe counterpart to <see cref="ComposeAndWrite"/>. It
/// writes the pinned <c>mode: "probe"</c> document and never includes
/// a character selector, policy, plugin set, login commands, or password.
/// </summary>
public static ComposedSessionConfig ComposeProbeAndWrite(
ServerProfile server,
AccountProfile account,
LauncherInstallRecord install,
ApplicationPathSet paths,
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);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
using FileStream stream = File.Create(composed.ConfigFilePath);
JsonSerializer.Serialize(stream, composed.Document, SerializerOptions);
return composed;
}
/// <summary>Serializes the composed document exactly as
/// <see cref="ComposeAndWrite"/> would write it — used by golden-file
/// tests that assert on the JSON text without touching disk.</summary>
public static string Serialize(SessionConfigDocument document) =>
JsonSerializer.Serialize(document, SerializerOptions);
private static (string ConfigFilePath, string StatusFilePath, string StderrLogPath)
BuildSessionPaths(
ApplicationPathSet paths,
string sessionId)
{
string sessionDirectory = Path.Combine(
paths.CacheDirectory,
"launcher",
"sessions",
sessionId);
return (
Path.Combine(sessionDirectory, "session.json"),
Path.Combine(sessionDirectory, "status.jsonl"),
// fix #406 sibling gap: lives beside status.jsonl in the same
// per-session directory.
Path.Combine(sessionDirectory, "client.err.log"));
}
private static SessionCharacterSelector BuildSelector(CharacterProfile character)
{
// A parsed id of 0 is not a usable selector — both host loaders
// (App/Headless) reject `id: 0` outright, so falling through to
// the name selector here is the only shape that reaches a real
// character (Campaign LA plan §LA3 review finding F10).
if (CharacterIdFormat.TryParse(character.Id, out uint id) && id != 0)
{
return new SessionCharacterSelector { Id = id };
}
if (!string.IsNullOrWhiteSpace(character.Name))
{
return new SessionCharacterSelector { Name = character.Name };
}
throw new InvalidOperationException(
"Character has neither a usable id nor a name to select by.");
}
}