fix(launcher): close Campaign LA LA1 review findings

This commit is contained in:
Erik 2026-08-14 17:00:09 +02:00
parent 4edc122085
commit d511e4c348
12 changed files with 413 additions and 65 deletions

View file

@ -171,6 +171,16 @@ sides. Unknown `e` values must parse to a typed Unknown event, never
throw; a known `e` with a wrong payload shape should be distinguishable throw; a known `e` with a wrong payload shape should be distinguishable
from an unknown `e` (LA3 review finding 12). from an unknown `e` (LA3 review finding 12).
**Known LA1 status limitation:** the stream has no independent mid-play
wire-drop detector. If a transport becomes silent without raising through the
host's tick/teardown path, no immediate `disconnected` line can be promised;
the launcher must not treat the absence of that line as proof that the socket
is healthy. Explicit reconnect is ordered and observable — it emits
`disconnected{reason:"reconnect"}` before the replacement connection's second
`connected` — and normal stop/process teardown closes any still-open
connection before `exited`. A future transport-health signal may improve the
timing without changing this pinned event vocabulary.
Three pieces, one slice, because they share the session-config/status seam: Three pieces, one slice, because they share the session-config/status seam:
1. **App `--session-config <path>`:** parsed once in `Program.cs` into 1. **App `--session-config <path>`:** parsed once in `Program.cs` into

View file

@ -29,11 +29,12 @@ internal sealed class AppCredentialResolver
private readonly bool _isLinux; private readonly bool _isLinux;
/// <summary> /// <summary>
/// <paramref name="isLinux"/> is caller-supplied, never detected in this /// <paramref name="isLinux"/> is the caller-supplied platform-policy
/// file — <c>LinuxPlatformBoundaryTests</c>'s platform-owner guard /// value from <c>GraphicalHostPlatformServices</c>. This file still uses
/// requires every OS-family check to live under <c>Platform/</c>; /// <c>RuntimePlatformGuard.IsLinuxRuntime</c> below as the narrow
/// callers pass <c>GraphicalHostPlatformServices</c>'s already-detected /// CA1416-recognized runtime guard required before calling
/// value instead of this file re-detecting it itself. /// <c>File.GetUnixFileMode</c>; it does not independently select the host
/// platform or bypass the platform-services owner.
/// </summary> /// </summary>
internal AppCredentialResolver( internal AppCredentialResolver(
TextReader standardInput, TextReader standardInput,

View file

@ -1665,7 +1665,7 @@ public sealed class GameWindow :
// OnClosing() native-window-close-request pass) represents the // OnClosing() native-window-close-request pass) represents the
// process actually being done. // process actually being done.
if (releaseNativeWindow) if (releaseNativeWindow)
_statusWriter.Exited(_options.SessionId ?? "app", 0, "disposed"); _statusWriter.Exited(_options.SessionId ?? "app", 0, "graceful");
return; return;
} }

View file

@ -2,6 +2,8 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Reflection;
using System.Text;
using AcDream.App.Configuration; using AcDream.App.Configuration;
using AcDream.App.Rendering.Residency; using AcDream.App.Rendering.Residency;
using AcDream.App.Streaming; using AcDream.App.Streaming;
@ -266,6 +268,44 @@ public sealed record RuntimeOptions(
selector.Id, selector.Id,
selector.Name); selector.Name);
private static readonly PropertyInfo[] PrintableProperties =
typeof(RuntimeOptions)
.GetProperties(
BindingFlags.Instance
| BindingFlags.Public
| BindingFlags.DeclaredOnly)
.Where(static property =>
property.GetMethod is not null
&& property.GetIndexParameters().Length == 0)
.OrderBy(static property => property.MetadataToken)
.ToArray();
/// <summary>
/// Campaign LA LA1 defense in depth: positional records normally print
/// every public property, including the live password. Preserve that
/// ordinary diagnostic property set while substituting the one sensitive
/// value before it can reach a log, debugger display, or exception.
/// Reflection is cached once and runs only on the diagnostic
/// <see cref="object.ToString"/> path.
/// </summary>
private bool PrintMembers(StringBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
for (int index = 0; index < PrintableProperties.Length; index++)
{
PropertyInfo property = PrintableProperties[index];
if (index != 0)
builder.Append(", ");
builder.Append(property.Name);
builder.Append(" = ");
builder.Append(
property.Name == nameof(LivePass) && LivePass is not null
? "<redacted>"
: property.GetValue(this));
}
return PrintableProperties.Length != 0;
}
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary> /// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
public bool HasLiveCredentials => public bool HasLiveCredentials =>
LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass); LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass);

View file

@ -491,8 +491,9 @@ internal sealed class HeadlessSessionHost : IDisposable
_policy.Tick(Runtime, Commands); _policy.Tick(Runtime, Commands);
} }
internal RuntimeTeardownAcknowledgement Stop() internal RuntimeTeardownAcknowledgement Stop(string reason = "stopped")
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
RuntimeTeardownAcknowledgement result = RuntimeTeardownAcknowledgement result =
Commands.Session.Stop(Runtime.Generation); Commands.Session.Stop(Runtime.Generation);
// R9 review fix (2026-08-03): _currentSession is cached across // R9 review fix (2026-08-03): _currentSession is cached across
@ -510,7 +511,7 @@ internal sealed class HeadlessSessionHost : IDisposable
if (_hasConnected) if (_hasConnected)
{ {
_hasConnected = false; _hasConnected = false;
_statusWriter.Disconnected(_descriptor.Id, "stopped"); _statusWriter.Disconnected(_descriptor.Id, reason);
} }
return result; return result;
} }
@ -640,7 +641,7 @@ internal sealed class HeadlessSessionHost : IDisposable
_statusWriter.Exited( _statusWriter.Exited(
_descriptor.Id, _descriptor.Id,
_faulted ? 1 : 0, _faulted ? 1 : 0,
_faulted ? "fault" : "disposed"); _faulted ? "runtime-fault" : "graceful");
_disposeStage++; _disposeStage++;
_disposed = true; _disposed = true;
break; break;
@ -670,8 +671,12 @@ internal sealed class HeadlessSessionHost : IDisposable
if (reconnect) if (reconnect)
{ {
RuntimeTeardownAcknowledgement stopped = // Campaign LA LA1 review fix F3: route reconnect teardown
_liveSession.Stop(expectedGeneration); // through the same status-aware Stop boundary as every other
// host stop. The retiring connection therefore publishes a
// truthful disconnected(reason: "reconnect") edge before the
// fresh LiveSessionHost reports its second connected edge.
RuntimeTeardownAcknowledgement stopped = Stop("reconnect");
if (!stopped.IsComplete) if (!stopped.IsComplete)
{ {
return new RuntimeSessionStartResult( return new RuntimeSessionStartResult(

View file

@ -35,6 +35,18 @@ namespace AcDream.Runtime.Session;
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// The writer also owns the small amount of stream-ordering state needed to
/// keep the external contract coherent across host implementations. A second
/// <c>connected</c> edge while the prior connection is still open first emits
/// <c>disconnected(reason: "reconnect")</c>; a terminal <c>exited</c> edge
/// closes any still-open connection with
/// <c>disconnected(reason: "process-exit")</c>. <c>exited</c> is terminal and
/// idempotent: the first call wins and every later event is ignored. This is
/// deliberately enforced here because both graphical and no-window hosts use
/// this exact sink, while their reconnect command adapters are separate.
/// </para>
///
/// <para>
/// <strong>This writer can never fail or stall the session transaction it /// <strong>This writer can never fail or stall the session transaction it
/// observes</strong> (Campaign LA LA1 review fix F1). Every call site sits /// observes</strong> (Campaign LA LA1 review fix F1). Every call site sits
/// inside a caller-owned try block that treats a throw as a real failure — /// inside a caller-owned try block that treats a throw as a real failure —
@ -91,6 +103,8 @@ public sealed class SessionStatusWriter
private readonly object _gate = new(); private readonly object _gate = new();
private bool _directoryEnsured; private bool _directoryEnsured;
private bool _latchedOff; private bool _latchedOff;
private bool _connected;
private bool _exited;
public SessionStatusWriter(string? path, TimeProvider? timeProvider = null) public SessionStatusWriter(string? path, TimeProvider? timeProvider = null)
{ {
@ -116,14 +130,44 @@ public sealed class SessionStatusWriter
sessionId, sessionId,
}); });
public void Connected(string sessionId) => public void Connected(string sessionId)
Write(new {
if (!IsEnabled)
return;
lock (_gate)
{ {
v = VocabularyVersion, if (_latchedOff || _exited)
e = "connected", return;
t = Now(),
sessionId, if (_connected)
}); {
if (!TryWriteLocked(new
{
v = VocabularyVersion,
e = "disconnected",
t = Now(),
sessionId,
reason = "reconnect",
}))
{
return;
}
_connected = false;
}
if (TryWriteLocked(new
{
v = VocabularyVersion,
e = "connected",
t = Now(),
sessionId,
}))
{
_connected = true;
}
}
}
public void CharacterList(string sessionId, LiveSessionRosterReport roster) public void CharacterList(string sessionId, LiveSessionRosterReport roster)
{ {
@ -161,26 +205,70 @@ public sealed class SessionStatusWriter
characterName, characterName,
}); });
public void Disconnected(string sessionId, string reason) => public void Disconnected(string sessionId, string reason)
Write(new {
{ if (!IsEnabled)
v = VocabularyVersion, return;
e = "disconnected",
t = Now(),
sessionId,
reason,
});
public void Exited(string sessionId, int code, string reason) => lock (_gate)
Write(new
{ {
v = VocabularyVersion, if (_latchedOff || _exited)
e = "exited", return;
t = Now(),
sessionId, if (TryWriteLocked(new
code, {
reason, v = VocabularyVersion,
}); e = "disconnected",
t = Now(),
sessionId,
reason,
}))
{
_connected = false;
}
}
}
public void Exited(string sessionId, int code, string reason)
{
if (!IsEnabled)
return;
lock (_gate)
{
if (_latchedOff || _exited)
return;
if (_connected)
{
if (!TryWriteLocked(new
{
v = VocabularyVersion,
e = "disconnected",
t = Now(),
sessionId,
reason = "process-exit",
}))
{
return;
}
_connected = false;
}
if (TryWriteLocked(new
{
v = VocabularyVersion,
e = "exited",
t = Now(),
sessionId,
code,
reason,
}))
{
_exited = true;
}
}
}
private string Now() => private string Now() =>
_timeProvider.GetUtcNow().ToString( _timeProvider.GetUtcNow().ToString(
@ -189,7 +277,7 @@ public sealed class SessionStatusWriter
private void Write<T>(T value) private void Write<T>(T value)
{ {
if (_path is not { } path || _latchedOff) if (_path is null || _latchedOff)
return; return;
lock (_gate) lock (_gate)
@ -197,26 +285,41 @@ public sealed class SessionStatusWriter
// Re-check inside the lock: another thread may have latched the // Re-check inside the lock: another thread may have latched the
// writer off (or already ensured the directory) between the // writer off (or already ensured the directory) between the
// fast check above and taking the gate. // fast check above and taking the gate.
if (_latchedOff) if (_latchedOff || _exited)
return; return;
try _ = TryWriteLocked(value);
{ }
EnsureDirectory(path); }
string line = JsonSerializer.Serialize(value, JsonOptions);
using FileStream stream = new( /// <summary>
path, /// Writes one event while <see cref="_gate"/> is held. Returning success
FileMode.Append, /// lets the lifecycle methods publish their state transition only after
FileAccess.Write, /// the matching line has reached the stream. A recoverable I/O failure
FileShare.Read); /// latches the writer off, so there is never a retry that could duplicate
using var writer = new StreamWriter(stream); /// an uncertain terminal edge.
writer.WriteLine(line); /// </summary>
writer.Flush(); private bool TryWriteLocked<T>(T value)
} {
catch (Exception error) when (IsRecoverableIoFailure(error)) string path = _path!;
{ try
LatchOff(path, error); {
} EnsureDirectory(path);
string line = JsonSerializer.Serialize(value, JsonOptions);
using FileStream stream = new(
path,
FileMode.Append,
FileAccess.Write,
FileShare.Read);
using var writer = new StreamWriter(stream);
writer.WriteLine(line);
writer.Flush();
return true;
}
catch (Exception error) when (IsRecoverableIoFailure(error))
{
LatchOff(path, error);
return false;
} }
} }
@ -234,10 +337,22 @@ public sealed class SessionStatusWriter
private void LatchOff(string path, Exception error) private void LatchOff(string path, Exception error)
{ {
_latchedOff = true; _latchedOff = true;
Console.Error.WriteLine( try
$"[status-writer] disabling status stream at '{path}' after a " {
+ $"write failure ({error.GetType().Name}: {error.Message}); no " Console.Error.WriteLine(
+ "further events for this session will be written."); $"[status-writer] disabling status stream at '{path}' after a "
+ $"write failure ({error.GetType().Name}: {error.Message}); no "
+ "further events for this session will be written.");
}
catch (Exception diagnosticError)
when (IsRecoverableIoFailure(diagnosticError)
|| diagnosticError is ObjectDisposedException
or InvalidOperationException)
{
// This is the fallback diagnostic for an already-failed
// observability sink. A closed/broken stderr must not turn it
// back into a session-transaction failure.
}
} }
/// <summary> /// <summary>

View file

@ -19,12 +19,18 @@ namespace AcDream.App.Tests.Configuration;
public sealed class SessionConfigurationSharedFixtureTests public sealed class SessionConfigurationSharedFixtureTests
{ {
[Fact] [Fact]
public void AppReaderAcceptsTheSharedFixtureAndParsesTheFiveNewFields() public void AppReaderAcceptsTheProductionShapedSharedFixture()
{ {
(SessionConfiguration configuration, SessionDescriptor session) = (SessionConfiguration configuration, SessionDescriptor session) =
SessionConfigurationLoader.Load(SharedFixturePath()); SessionConfigurationLoader.Load(SharedFixturePath());
Assert.Equal(1, configuration.Version); Assert.Equal(1, configuration.Version);
Assert.Equal(
"shared-fixture-dats",
configuration.Process?.Content?.DatDirectory);
Assert.Equal(
"shared-fixture-dats/acdream.pak",
configuration.Process?.Content?.PreparedAssetPath);
Assert.Equal("shared-fixture", session.Id); Assert.Equal("shared-fixture", session.Id);
Assert.Equal("127.0.0.1", session.Endpoint.Host); Assert.Equal("127.0.0.1", session.Endpoint.Host);
Assert.Equal(9000, session.Endpoint.Port); Assert.Equal(9000, session.Endpoint.Port);
@ -34,9 +40,9 @@ public sealed class SessionConfigurationSharedFixtureTests
// the pinned contract's "parsed-and-ignored" clause. // the pinned contract's "parsed-and-ignored" clause.
Assert.Equal("idle", session.Policy?.Id); Assert.Equal("idle", session.Policy?.Id);
Assert.Equal( Assert.Equal(
SessionCredentialProviderKind.Environment, SessionCredentialProviderKind.StandardInput,
session.Credential.Provider); session.Credential.Provider);
Assert.Equal("SHARED_FIXTURE_PASSWORD", session.Credential.Reference); Assert.Equal("session", session.Credential.Reference);
Assert.Equal(["ExamplePlugin", "AnotherPlugin"], session.Plugins); Assert.Equal(["ExamplePlugin", "AnotherPlugin"], session.Plugins);
Assert.Equal( Assert.Equal(

View file

@ -87,6 +87,27 @@ public sealed class LinuxPlatformBoundaryTests
Assert.Empty(offenders); Assert.Empty(offenders);
} }
[Fact]
public void RuntimePlatformGuardHasOneDefinitionAndOneApprovedConsumer()
{
string app = AppSourceRoot();
string[] files = Directory
.EnumerateFiles(app, "*.cs", SearchOption.AllDirectories)
.Where(path => File.ReadAllText(path).Contains(
"RuntimePlatformGuard",
StringComparison.Ordinal))
.Select(path => Path.GetRelativePath(app, path).Replace('\\', '/'))
.OrderBy(static path => path, StringComparer.Ordinal)
.ToArray();
Assert.Equal(
[
"Credentials/AppCredentialResolver.cs",
"Platform/GraphicalHostPlatformServices.cs",
],
files);
}
[Fact] [Fact]
public void SmokePluginCopyUsesRidAwarePortableBuildAndPublishPaths() public void SmokePluginCopyUsesRidAwarePortableBuildAndPublishPaths()
{ {

View file

@ -234,6 +234,26 @@ public sealed class RuntimeOptionsTests
Assert.Equal("testpassword", realValues.LivePass); Assert.Equal("testpassword", realValues.LivePass);
} }
[Fact]
public void RecordPrintMembersRedactsTheLivePassword()
{
RuntimeOptions options = RuntimeOptions.Parse(
AnyDatDir,
Env(new()
{
["ACDREAM_LIVE"] = "1",
["ACDREAM_TEST_USER"] = "testaccount",
["ACDREAM_TEST_PASS"] = "top-secret-value",
}));
string printed = options.ToString();
Assert.DoesNotContain("top-secret-value", printed, StringComparison.Ordinal);
Assert.Contains("LivePass = <redacted>", printed, StringComparison.Ordinal);
Assert.Contains("LiveHost = 127.0.0.1", printed, StringComparison.Ordinal);
Assert.Contains("HasLiveCredentials = True", printed, StringComparison.Ordinal);
}
[Fact] [Fact]
public void HasLiveCredentials_RequiresLiveModeAndBothUserAndPass() public void HasLiveCredentials_RequiresLiveModeAndBothUserAndPass()
{ {

View file

@ -62,6 +62,73 @@ public sealed class HeadlessSessionHostTests
Assert.DoesNotContain("AcDream.App", diagnostics); Assert.DoesNotContain("AcDream.App", diagnostics);
} }
/// <summary>
/// Campaign LA LA1 review fixes F3/F6: reconnect is a visible lifecycle
/// replacement, so the retiring connection must publish disconnected
/// before the new connection publishes connected. Its reason is distinct
/// from the final host stop and from the terminal process outcome.
/// </summary>
[Fact]
public void ReconnectPublishesDisconnectedBeforeTheSecondConnectedEdge()
{
string statusPath = Path.Combine(
Path.GetTempPath(),
$"acdream-headless-reconnect-status-{Guid.NewGuid():N}.jsonl");
try
{
var operations = new FixtureSessionOperations();
using var diagnosticsOutput = new StringWriter();
using var credential = new HeadlessCredentialSecret(
"fixture",
"password");
using var host = new HeadlessSessionHost(
Descriptor(statusFile: statusPath),
credential,
new HeadlessDiagnosticWriter(diagnosticsOutput),
operations);
Assert.Equal(
RuntimeSessionStartStatus.Connected,
host.Start().Status);
Assert.Equal(
RuntimeSessionStartStatus.Connected,
host.Reconnect().Status);
host.Dispose();
JsonElement[] events = File.ReadAllLines(statusPath)
.Select(static line => JsonDocument.Parse(line).RootElement.Clone())
.ToArray();
Assert.Equal(
[
"started", "connected", "characterList", "enteredWorld",
"disconnected", "connected", "characterList", "enteredWorld",
"disconnected", "exited",
],
events.Select(static item => item.GetProperty("e").GetString()));
JsonElement[] disconnected = events
.Where(static item =>
item.GetProperty("e").GetString() == "disconnected")
.ToArray();
Assert.Equal(2, disconnected.Length);
Assert.Equal(
"reconnect",
disconnected[0].GetProperty("reason").GetString());
Assert.Equal(
"stopped",
disconnected[1].GetProperty("reason").GetString());
JsonElement exited = events[^1];
Assert.Equal(0, exited.GetProperty("code").GetInt32());
Assert.Equal("graceful", exited.GetProperty("reason").GetString());
}
finally
{
if (File.Exists(statusPath))
File.Delete(statusPath);
}
}
/// <summary> /// <summary>
/// Campaign LA slice LA1: proves the status-event writer fires the /// Campaign LA slice LA1: proves the status-event writer fires the
/// pinned lifecycle vocabulary — started/connected/characterList/ /// pinned lifecycle vocabulary — started/connected/characterList/

View file

@ -18,11 +18,17 @@ namespace AcDream.Headless.Tests;
public sealed class SessionConfigurationSharedFixtureTests public sealed class SessionConfigurationSharedFixtureTests
{ {
[Fact] [Fact]
public void HeadlessReaderAcceptsTheSharedFixtureAndParsesTheFiveNewFields() public void HeadlessReaderAcceptsTheProductionShapedSharedFixture()
{ {
HeadlessConfiguration configuration = HeadlessConfiguration configuration =
HeadlessConfigurationLoader.Load(SharedFixturePath()); HeadlessConfigurationLoader.Load(SharedFixturePath());
Assert.Equal(
"shared-fixture-dats",
configuration.Process.Content?.DatDirectory);
Assert.Equal(
"shared-fixture-dats/acdream.pak",
configuration.Process.Content?.PreparedAssetPath);
HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!; HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!;
Assert.Equal("shared-fixture", session.Id); Assert.Equal("shared-fixture", session.Id);
Assert.Equal("127.0.0.1", session.Endpoint.Host); Assert.Equal("127.0.0.1", session.Endpoint.Host);
@ -31,9 +37,9 @@ public sealed class SessionConfigurationSharedFixtureTests
Assert.Equal("SharedToon", session.Character.Name); Assert.Equal("SharedToon", session.Character.Name);
Assert.Equal("idle", session.Policy.Id); Assert.Equal("idle", session.Policy.Id);
Assert.Equal( Assert.Equal(
HeadlessCredentialProviderKind.Environment, HeadlessCredentialProviderKind.StandardInput,
session.Credential.Provider); session.Credential.Provider);
Assert.Equal("SHARED_FIXTURE_PASSWORD", session.Credential.Reference); Assert.Equal("session", session.Credential.Reference);
Assert.Equal(["ExamplePlugin", "AnotherPlugin"], session.Plugins); Assert.Equal(["ExamplePlugin", "AnotherPlugin"], session.Plugins);
Assert.Equal( Assert.Equal(

View file

@ -97,6 +97,63 @@ public sealed class SessionStatusWriterTests
writer.Started("s1"); writer.Started("s1");
} }
/// <summary>
/// F3: both hosts share this writer but have separate reconnect command
/// adapters. The sink therefore closes an open connection before it
/// accepts another connected edge, preserving a coherent external
/// lifecycle even if a host has no reconnect-specific status hook.
/// </summary>
[Fact]
public void SecondConnectedEdgeFirstClosesTheRetiringConnection()
{
using TemporaryFile file = TemporaryFile.Create();
var writer = new SessionStatusWriter(file.Path);
writer.Connected("s1");
writer.Connected("s1");
JsonElement[] events = File.ReadAllLines(file.Path)
.Select(Parse)
.ToArray();
Assert.Equal(
["connected", "disconnected", "connected"],
events.Select(static item => item.GetProperty("e").GetString()));
Assert.Equal(
"reconnect",
events[1].GetProperty("reason").GetString());
}
/// <summary>
/// F6: exited is a terminal fact, not an append request. Repeated host
/// disposal and any late callback after disposal must not create a second
/// terminal edge or resurrect the stream. If a host exits while still
/// connected, the writer closes that connection first with a distinct,
/// truthful reason.
/// </summary>
[Fact]
public void ExitedIsIdempotentTerminalAndClosesAnOpenConnection()
{
using TemporaryFile file = TemporaryFile.Create();
var writer = new SessionStatusWriter(file.Path);
writer.Connected("s1");
writer.Exited("s1", 0, "graceful");
writer.Exited("s1", 1, "duplicate-must-not-win");
writer.Connected("s1");
JsonElement[] events = File.ReadAllLines(file.Path)
.Select(Parse)
.ToArray();
Assert.Equal(
["connected", "disconnected", "exited"],
events.Select(static item => item.GetProperty("e").GetString()));
Assert.Equal(
"process-exit",
events[1].GetProperty("reason").GetString());
Assert.Equal(0, events[2].GetProperty("code").GetInt32());
Assert.Equal("graceful", events[2].GetProperty("reason").GetString());
}
/// <summary> /// <summary>
/// F7 (Campaign LA LA1 review fix round): replaces the earlier /// F7 (Campaign LA LA1 review fix round): replaces the earlier
/// "DoesNotContain 'hunter2'/'password'" assertion, which could never /// "DoesNotContain 'hunter2'/'password'" assertion, which could never