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>
365 lines
13 KiB
C#
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><CacheDirectory>/launcher/sessions/<sessionId>/</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.");
|
|
}
|
|
}
|