diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index c909820d..b4b000b9 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -343,6 +343,14 @@ src/
-> references Platform only; no Avalonia or game-host dependency
AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell
+ Startup/Program -> one immutable process-local option graph before
+ owner construction; config/data/cache require
+ three absolute normalized roots and one exact
+ `ApplicationPathSet` reaches profiles, installer,
+ versions/updater, sessions, cache, orchestration
+ -> manifest override reaches only update composition,
+ is never persisted, and permits HTTP only for a
+ loopback fixture; production remains pinned HTTPS
ViewModels/ -> thin MVVM projection over Launcher.Core,
including the first-run DAT/bake wizard and
nonfatal startup/manual update state, actions,
diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md
index 76fd65bc..a07b767a 100644
--- a/docs/plans/2026-08-14-launcher-campaign.md
+++ b/docs/plans/2026-08-14-launcher-campaign.md
@@ -732,4 +732,4 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny.
| LA8 | — | | | |
| LA9 | — | | | |
| LA10 | — | | | |
-| LA11 | **IMPLEMENTATION CHECKPOINT 2026-08-14 — USER GATE PENDING** | pending integration | Review and connected/visual acceptance pending | Strict isolated-root/feed parsing, Windows targeted CTRL_BREAK fixtures, deterministic A/B loopback fixture, automated preflight/status validators, and the exact Windows + Ubuntu/WSL operator script are implemented. Launcher startup composition awaits the LA10 review-fix rebase; no connected row has run and the campaign is not shipped. |
+| LA11 | **IMPLEMENTATION CHECKPOINT 2026-08-14 — USER GATE PENDING** | pending integration | Review and connected/visual acceptance pending | Strict isolated-root/feed parsing and one exact launcher path graph are composed on the reviewed LA10 updater base; Windows targeted CTRL_BREAK fixtures, deterministic A/B loopback fixture, automated preflight/status validators, and the exact Windows + Ubuntu/WSL operator script are implemented. No connected row has run and the campaign is not shipped. |
diff --git a/docs/research/2026-08-14-campaign-la-test-script.md b/docs/research/2026-08-14-campaign-la-test-script.md
index 664a569e..25109d73 100644
--- a/docs/research/2026-08-14-campaign-la-test-script.md
+++ b/docs/research/2026-08-14-campaign-la-test-script.md
@@ -72,15 +72,18 @@ The expected matrix is:
| Windows | Release `AcDream.slnx` build, `-m:1` | exit 0 | 5–15 min |
| Windows | complete Release solution test, serial | exit 0; ordinary known skips only | 20–60 min |
| Windows | focused Launcher.Core update tests and launcher update/startup-option tests | exit 0 | 1–4 min |
-| Windows | canonical portable project build/test closure from `headless-portability.yml` | every project exits 0 | 10–25 min |
+| Windows | canonical portable project build/test closure plus Headless `--help` and empty-config `validate` from `headless-portability.yml` | every project/CLI row exits 0 | 10–25 min |
| Windows | self-contained single-file launcher publish for `win-x64` and `linux-x64` | launcher + bake roots present, no root DLL fallback | 3–10 min |
| Windows | native launcher `--verify-publish` and bake `--help` with bogus `DOTNET_ROOT*` | both exit 0 | <1 min |
| Ubuntu/WSL | run the same helper natively from the Linux path to the worktree | Linux RID report and every row exit 0 | 35–90 min |
`report.json` records the tested HEAD/dirty state, OS/RID, exact commands,
durations, exits, redacted logs, and SHA-256/size inventory. A normal preflight
-plans 26 commands. It never launches App or Headless in connected mode and
-never reads a credential.
+plans 30 rows. It never launches App or Headless in connected mode and
+never reads a credential. Every child starts with all inherited `ACDREAM_*`
+variables removed, so a developer shell cannot accidentally enable live,
+installed-DAT, fixture-regeneration, or diagnostic gates. Only the optional
+row below adds the two named DAT variables back for its three exact tests.
### Optional installed-DAT read-only row
@@ -100,7 +103,7 @@ The mandatory installed-DAT result is
`ACDREAM_DAT_DIR` set inside the child environment. The helper reads the TRX
and fails if the test skipped or did anything other than pass. The action-map
and portal-asset probes are additional coverage, never substitutes. Expected
-matrix size: 30 rows.
+matrix size: 34 rows.
On Ubuntu/WSL, invoke the same script with native `pwsh`, a Linux repository
path, and a Linux output path. Do not treat a Windows-hosted run over
@@ -167,9 +170,12 @@ pwsh -NoProfile -File (Join-Path $Repo 'tools/new-campaign-la-update-fixture.ps1
```
The helper rejects nonempty output, invalid or non-monotonic versions, missing
-root executables, and nonabsolute inputs. It writes fixed-timestamp sorted ZIPs,
+root executables (including the co-deployed Bake CLI), and nonabsolute inputs.
+It writes fixed-timestamp sorted ZIPs,
the exact LA10 v1 SHA/size manifest, `fixture-report.json`, a loopback-only
-server, and an A/B selector. It does not download or mutate payload sources.
+server (with optional bounded `-MaximumRequests` smoke mode), and an atomic A/B
+selector. Both generated helpers reject a `-Root` other than their own fixture
+directory. The generator does not download or mutate payload sources.
Start the Windows loopback server without a shell or visible helper window:
diff --git a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md
index dbd662b3..0224a7f7 100644
--- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md
+++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md
@@ -333,7 +333,10 @@ the process supervisor executes.
(load, capability flag, teardown).
- **Connected gates (user-driven):** execute the exact serial matrix in
`docs/research/2026-08-14-campaign-la-test-script.md` only after its
- connection-free automated preflight passes. Cover every launch mode against local ACE
+ connection-free automated preflight passes. The launcher uses one immutable
+ process-local config/data/cache path set for the whole matrix; the local feed
+ URI reaches only updater composition and is never persisted. Cover every
+ launch mode against local ACE
(gui / guiSelect / headless), the character probe (fresh account →
refresh → roster appears, and repeated probes leaving no stale ACE
session), clean-profile first-run wizard end-to-end, staged-manifest
diff --git a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs
index a31625c1..7736fee5 100644
--- a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs
+++ b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs
@@ -11,8 +11,9 @@ public interface IReleaseManifestClient
///
/// Strict, bounded reader for the pinned GitHub Releases manifest. Production
-/// construction is HTTPS-only. The loopback HTTP allowance is available only
-/// through an internal fixture factory and is never inferred from a URI.
+/// construction is pinned and HTTPS-only. The explicitly named process-local
+/// feed factory independently revalidates its URI and can admit HTTP only for
+/// the loopback operator fixture; it cannot change the production constructor.
/// Redirects are followed manually so every hop is checked before any bytes
/// cross that hop.
///
@@ -74,6 +75,49 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
CreateRedirectDisabledHandler(),
timeout);
+ ///
+ /// Creates the explicit process-local feed seam used by the Campaign LA
+ /// isolated operator fixture. HTTPS stays HTTPS-only. HTTP is admitted
+ /// only for a loopback manifest, and never by the pinned production
+ /// constructor. Credential-bearing or mutable URI suffixes are rejected.
+ ///
+ public static ReleaseManifestClient CreateLocalUpdateFeedOverride(
+ Uri manifestUri,
+ TimeSpan? timeout = null)
+ {
+ ArgumentNullException.ThrowIfNull(manifestUri);
+ if (!string.IsNullOrEmpty(manifestUri.UserInfo)
+ || !string.IsNullOrEmpty(manifestUri.Query)
+ || !string.IsNullOrEmpty(manifestUri.Fragment))
+ {
+ throw new LauncherUpdateException(
+ "A process-local manifest URI cannot contain user information, "
+ + "a query, or a fragment.");
+ }
+
+ bool allowLoopbackHttp = string.Equals(
+ manifestUri.Scheme,
+ Uri.UriSchemeHttp,
+ StringComparison.Ordinal)
+ && manifestUri.IsLoopback;
+ if (!string.Equals(
+ manifestUri.Scheme,
+ Uri.UriSchemeHttps,
+ StringComparison.Ordinal)
+ && !allowLoopbackHttp)
+ {
+ throw new LauncherUpdateException(
+ "A process-local manifest URI must use HTTPS "
+ + "(loopback HTTP is fixture-only).");
+ }
+
+ return new ReleaseManifestClient(
+ manifestUri,
+ allowLoopbackHttp,
+ CreateRedirectDisabledHandler(),
+ timeout);
+ }
+
internal static ReleaseManifestClient CreateForTransportTest(
Uri manifestUri,
bool allowLoopbackHttp,
diff --git a/src/AcDream.Launcher/AcDream.Launcher.csproj b/src/AcDream.Launcher/AcDream.Launcher.csproj
index 1b02f993..350260b2 100644
--- a/src/AcDream.Launcher/AcDream.Launcher.csproj
+++ b/src/AcDream.Launcher/AcDream.Launcher.csproj
@@ -28,10 +28,6 @@
-
-
-
-
diff --git a/src/AcDream.Launcher/App.axaml.cs b/src/AcDream.Launcher/App.axaml.cs
index c42f0de0..b348cf07 100644
--- a/src/AcDream.Launcher/App.axaml.cs
+++ b/src/AcDream.Launcher/App.axaml.cs
@@ -14,17 +14,33 @@ namespace AcDream.Launcher;
public sealed partial class App : Application
{
+ private readonly LauncherStartupOptions? _startupOptions;
private LauncherOrchestrator? _orchestrator;
private LauncherWindowViewModel? _viewModel;
private LauncherUpdateComposition? _updateComposition;
+ public App()
+ {
+ }
+
+ internal App(LauncherStartupOptions startupOptions)
+ {
+ _startupOptions = startupOptions
+ ?? throw new ArgumentNullException(nameof(startupOptions));
+ }
+
+ internal LauncherStartupOptions StartupOptions => _startupOptions
+ ?? throw new InvalidOperationException(
+ "Launcher startup options were not supplied by the composition root.");
+
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
- ApplicationPathSet paths = ApplicationPathSet.Resolve();
+ LauncherStartupOptions startupOptions = StartupOptions;
+ ApplicationPathSet paths = startupOptions.Paths;
LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths);
string rid = LauncherRuntimeIdentity.DetectRid();
string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
@@ -57,7 +73,8 @@ public sealed partial class App : Application
GetLauncherVersion(),
AppContext.BaseDirectory,
() => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive)
- == true);
+ == true,
+ updateManifestUri: startupOptions.UpdateManifestUri);
_updateComposition = updates;
_orchestrator = new LauncherOrchestrator(
diff --git a/src/AcDream.Launcher/LauncherStartupOptions.cs b/src/AcDream.Launcher/LauncherStartupOptions.cs
index 1f1c9164..43b3429e 100644
--- a/src/AcDream.Launcher/LauncherStartupOptions.cs
+++ b/src/AcDream.Launcher/LauncherStartupOptions.cs
@@ -9,6 +9,7 @@ internal enum LauncherStartupMode
VerifyPublish,
SelfUpdateHelper,
SelfUpdateConfirmation,
+ SelfUpdateDeferred,
}
///
@@ -18,6 +19,12 @@ internal enum LauncherStartupMode
///
internal sealed class LauncherStartupOptions
{
+ // This prefix is consumed only after LauncherSelfUpdateBootstrap has
+ // already decided to continue after a recovered rollback. Keep it local
+ // so the process-level bootstrap can remain internal to Launcher.Core.
+ private const string DeferredSelfUpdateArgument =
+ "--acdream-self-update-deferred-v1";
+
private readonly IReadOnlyList _publicArguments;
private LauncherStartupOptions(
@@ -138,6 +145,13 @@ internal sealed class LauncherStartupOptions
"The update manifest URI cannot contain user information.");
}
+ if (!string.IsNullOrEmpty(parsed.Query)
+ || !string.IsNullOrEmpty(parsed.Fragment))
+ {
+ throw new LauncherStartupOptionsException(
+ "The update manifest URI cannot contain a query or fragment.");
+ }
+
updateManifestUri = parsed;
break;
default:
@@ -199,6 +213,14 @@ internal sealed class LauncherStartupOptions
arguments.Count >= 2 ? 2 : arguments.Count);
}
+ if (string.Equals(
+ arguments[0],
+ DeferredSelfUpdateArgument,
+ StringComparison.Ordinal))
+ {
+ return (LauncherStartupMode.SelfUpdateDeferred, 1);
+ }
+
return (LauncherStartupMode.Desktop, 0);
}
diff --git a/src/AcDream.Launcher/LauncherUpdateComposition.cs b/src/AcDream.Launcher/LauncherUpdateComposition.cs
index 3b395042..22af717f 100644
--- a/src/AcDream.Launcher/LauncherUpdateComposition.cs
+++ b/src/AcDream.Launcher/LauncherUpdateComposition.cs
@@ -22,12 +22,14 @@ internal sealed class LauncherUpdateComposition : IDisposable
ClientVersionStore versions,
LauncherExecutableSet executables,
ILauncherUpdater updater,
+ Uri updateManifestUri,
HttpClient? artifactClient,
ReleaseManifestClient? manifestClient)
{
Versions = versions;
Executables = executables;
Updater = updater;
+ UpdateManifestUri = updateManifestUri;
_artifactClient = artifactClient;
_manifestClient = manifestClient;
}
@@ -38,17 +40,22 @@ internal sealed class LauncherUpdateComposition : IDisposable
public ILauncherUpdater Updater { get; }
+ internal Uri UpdateManifestUri { get; }
+
public static LauncherUpdateComposition Create(
ApplicationPathSet paths,
string rid,
LauncherVersion launcherVersion,
string launcherTargetDirectory,
Func hasRunningSessions,
- Func? initialize = null)
+ Func? initialize = null,
+ Uri? updateManifestUri = null)
{
ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(launcherVersion);
ArgumentNullException.ThrowIfNull(hasRunningSessions);
+ Uri manifestUri = updateManifestUri
+ ?? ReleaseManifestClient.ProductionManifestUri;
var versions = new ClientVersionStore(paths);
HttpClient? artifactClient = null;
ReleaseManifestClient? manifestClient = null;
@@ -69,7 +76,7 @@ internal sealed class LauncherUpdateComposition : IDisposable
Timeout = TimeSpan.FromSeconds(15),
};
artifactClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
- manifestClient = new ReleaseManifestClient(TimeSpan.FromSeconds(15));
+ manifestClient = CreateManifestClient(manifestUri);
var selfUpdates = new LauncherSelfUpdateManager(paths, artifactClient);
var updater = new LauncherUpdater(
manifestClient,
@@ -84,6 +91,7 @@ internal sealed class LauncherUpdateComposition : IDisposable
versions,
LauncherExecutableSet.FromCurrentVersionStore(versions),
updater,
+ manifestUri,
artifactClient,
manifestClient);
}
@@ -106,6 +114,7 @@ internal sealed class LauncherUpdateComposition : IDisposable
versions,
LauncherExecutableSet.Unavailable(status),
new UnavailableLauncherUpdater(status, resolution),
+ manifestUri,
artifactClient: null,
manifestClient: null);
}
@@ -117,6 +126,16 @@ internal sealed class LauncherUpdateComposition : IDisposable
_artifactClient?.Dispose();
}
+ private static ReleaseManifestClient CreateManifestClient(Uri manifestUri)
+ {
+ ArgumentNullException.ThrowIfNull(manifestUri);
+ return manifestUri == ReleaseManifestClient.ProductionManifestUri
+ ? new ReleaseManifestClient(TimeSpan.FromSeconds(15))
+ : ReleaseManifestClient.CreateLocalUpdateFeedOverride(
+ manifestUri,
+ TimeSpan.FromSeconds(15));
+ }
+
private static bool IsStorageFailure(Exception exception) => exception is
IOException
or UnauthorizedAccessException
diff --git a/src/AcDream.Launcher/Program.cs b/src/AcDream.Launcher/Program.cs
index 4bce034a..bb6b7c1f 100644
--- a/src/AcDream.Launcher/Program.cs
+++ b/src/AcDream.Launcher/Program.cs
@@ -1,5 +1,4 @@
using AcDream.Launcher.Core.Updates;
-using AcDream.Platform;
using Avalonia;
namespace AcDream.Launcher;
@@ -9,19 +8,18 @@ internal static class Program
[STAThread]
public static int Main(string[] args)
{
- if (args is ["--verify-publish"])
- {
- // A display-free execution probe for the packaged artifact. CI
- // runs this with DOTNET_ROOT pointing at a missing directory; a
- // framework-dependent publish cannot reach this return statement.
- return 0;
- }
-
try
{
- ApplicationPathSet paths = ApplicationPathSet.Resolve();
+ LauncherStartupOptions options = LauncherStartupOptions.Parse(args);
+ if (options.Mode == LauncherStartupMode.VerifyPublish)
+ {
+ // A display-free execution probe for the packaged artifact.
+ // Parsing above deliberately never resolves user paths.
+ return 0;
+ }
+
using var httpClient = new HttpClient();
- var selfUpdates = new LauncherSelfUpdateManager(paths, httpClient);
+ var selfUpdates = new LauncherSelfUpdateManager(options.Paths, httpClient);
string executable = Environment.ProcessPath
?? throw new InvalidOperationException(
"The launcher executable path is unavailable.");
@@ -37,8 +35,9 @@ internal static class Program
return startup.ExitCode;
}
- return BuildAvaloniaApp().StartWithClassicDesktopLifetime(
- startup.RemainingArguments);
+ RequireUnchangedPublicArguments(options, startup);
+
+ return BuildAvaloniaApp(options).StartWithClassicDesktopLifetime([]);
}
catch (Exception ex)
{
@@ -47,7 +46,25 @@ internal static class Program
}
}
- public static AppBuilder BuildAvaloniaApp() =>
- AppBuilder.Configure()
+ internal static AppBuilder BuildAvaloniaApp(LauncherStartupOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ return AppBuilder.Configure(() => new App(options))
.UsePlatformDetect();
+ }
+
+ internal static void RequireUnchangedPublicArguments(
+ LauncherStartupOptions options,
+ SelfUpdateStartupResult startup)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentNullException.ThrowIfNull(startup);
+ if (!startup.RemainingArguments.SequenceEqual(
+ options.PublicArguments,
+ StringComparer.Ordinal))
+ {
+ throw new InvalidOperationException(
+ "The self-update bootstrap changed validated launcher arguments.");
+ }
+ }
}
diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs
index 8e3f45ac..18a98d1e 100644
--- a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs
+++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs
@@ -160,18 +160,23 @@ static async Task BootstrapProbeAsync(string[] arguments)
static int CanonicalProbe(string[] arguments)
{
- if (arguments.Length != 1)
+ if (arguments.Length < 1)
{
return 2;
}
+ string suffix = arguments.Length == 1
+ ? string.Empty
+ : Environment.NewLine
+ + string.Join(Environment.NewLine, arguments[1..]);
File.WriteAllText(
Path.GetFullPath(arguments[0]),
Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)
+ "|"
+ Path.GetFullPath(
Environment.ProcessPath
- ?? throw new InvalidOperationException("Process path is unavailable.")));
+ ?? throw new InvalidOperationException("Process path is unavailable."))
+ + suffix);
return 0;
}
diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs
index e2c9b4b3..40fe89f3 100644
--- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs
+++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs
@@ -279,25 +279,43 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
public async Task BootstrapConfirmationAndOrdinaryStartupDoNotUseShellParsing()
{
using var harness = new Harness(_root);
+ string[] publicArguments =
+ [
+ "--config-dir", Path.Combine(_root, "config with spaces"),
+ "--data-dir", Path.Combine(_root, "data & literal"),
+ "--cache-dir", Path.Combine(_root, "cache"),
+ "--update-manifest-uri", "http://127.0.0.1:43119/manifest.json",
+ ];
SelfUpdateStartupResult ordinary = await LauncherSelfUpdateBootstrap.HandleAsync(
- ["--literal", "argument with spaces & metacharacters"],
+ publicArguments,
harness.Manager,
harness.Target,
harness.LauncherPath);
Assert.False(ordinary.ShouldExit);
- Assert.Equal(["--literal", "argument with spaces & metacharacters"],
- ordinary.RemainingArguments);
+ Assert.Equal(publicArguments, ordinary.RemainingArguments);
+
+ SelfUpdateStartupResult deferred = await LauncherSelfUpdateBootstrap.HandleAsync(
+ [LauncherSelfUpdateBootstrap.DeferredArgument, .. publicArguments],
+ harness.Manager,
+ harness.Target,
+ harness.LauncherPath);
+ Assert.False(deferred.ShouldExit);
+ Assert.Equal(publicArguments, deferred.RemainingArguments);
_ = await harness.StageAsync();
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
SelfUpdateStartupResult confirmation = await LauncherSelfUpdateBootstrap.HandleAsync(
- [LauncherSelfUpdateBootstrap.ConfirmArgument, applied.TransactionId],
+ [
+ LauncherSelfUpdateBootstrap.ConfirmArgument,
+ applied.TransactionId,
+ .. publicArguments,
+ ],
harness.Manager,
harness.Target,
harness.LauncherPath);
Assert.False(confirmation.ShouldExit);
- Assert.Empty(confirmation.RemainingArguments);
+ Assert.Equal(publicArguments, confirmation.RemainingArguments);
Assert.False(File.Exists(harness.Manager.PendingPlanPath));
Assert.False(harness.Manager.IsConfirmed(applied.TransactionId));
}
diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs
index 1c67efc3..5185fc7d 100644
--- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs
+++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs
@@ -34,6 +34,13 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
string ready = Path.Combine(_root, "crash.ready");
string launched = Path.Combine(_root, "replacement.ready");
string helperPidPath = Path.Combine(_root, "helper.pid");
+ string[] processLocalSuffix =
+ [
+ "--config-dir", Path.Combine(_root, "isolated config"),
+ "--data-dir", Path.Combine(_root, "isolated data"),
+ "--cache-dir", Path.Combine(_root, "isolated cache"),
+ "--update-manifest-uri", "http://127.0.0.1:43119/manifest.json",
+ ];
Directory.CreateDirectory(_root);
string rid = LauncherRuntimeIdentity.DetectRid();
PreparedLauncher prepared = PrepareLauncherClosure(target, rid);
@@ -76,7 +83,7 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
};
using Process canonical = StartProcess(
prepared.CanonicalPath,
- ["canonical-probe", launched],
+ ["canonical-probe", launched, .. processLocalSuffix],
environment);
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
Assert.Equal(0, canonical.ExitCode);
@@ -100,7 +107,12 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
SearchOption.TopDirectoryOnly));
Assert.Null(await manager.LoadPendingAsync());
- int replacementPid = ParsePid(await File.ReadAllTextAsync(launched));
+ string launchMarker = await File.ReadAllTextAsync(launched);
+ Assert.EndsWith(
+ Environment.NewLine + string.Join(Environment.NewLine, processLocalSuffix),
+ launchMarker,
+ StringComparison.Ordinal);
+ int replacementPid = ParsePid(launchMarker);
int helperPid = int.Parse(
await File.ReadAllTextAsync(helperPidPath),
System.Globalization.CultureInfo.InvariantCulture);
diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs
index b3d80e11..0208d60d 100644
--- a/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs
+++ b/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs
@@ -44,6 +44,26 @@ public sealed class LauncherVersionTests
public sealed class ReleaseManifestClientTests
{
+ [Theory]
+ [InlineData("https://updates.example.test/manifest.json")]
+ [InlineData("http://127.0.0.1:43119/manifest.json")]
+ [InlineData("http://localhost:43119/manifest.json")]
+ public void LocalUpdateFeedOverrideAcceptsOnlySecureOrLoopbackFeeds(string value)
+ {
+ using ReleaseManifestClient source =
+ ReleaseManifestClient.CreateLocalUpdateFeedOverride(new Uri(value));
+ }
+
+ [Theory]
+ [InlineData("http://updates.example.test/manifest.json")]
+ [InlineData("file:///tmp/manifest.json")]
+ [InlineData("https://user:secret@updates.example.test/manifest.json")]
+ [InlineData("https://updates.example.test/manifest.json?token=secret")]
+ [InlineData("https://updates.example.test/manifest.json#fragment")]
+ public void LocalUpdateFeedOverrideRejectsRemoteHttpAndCredentialLikeUris(string value) =>
+ Assert.Throws(() =>
+ ReleaseManifestClient.CreateLocalUpdateFeedOverride(new Uri(value)));
+
[Fact]
public async Task FetchesStrictManifestFromLoopbackAndPinsProductionFeed()
{
diff --git a/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs b/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs
index ff3bb24c..d7f223ce 100644
--- a/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs
+++ b/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs
@@ -146,6 +146,78 @@ public sealed class LauncherStartupOptionsTests
Assert.Equal(helperOptions.UpdateManifestUri, confirmationOptions.UpdateManifestUri);
}
+ [Fact]
+ public void DeferredSelfUpdateRestartRetainsIsolationWithoutResolvingDefaults()
+ {
+ string root = Path.GetFullPath(
+ Path.Combine(Path.GetTempPath(), "acdream-la11-deferred"));
+ string[] suffix =
+ [
+ "--config-dir", Path.Combine(root, "config"),
+ "--data-dir", Path.Combine(root, "data"),
+ "--cache-dir", Path.Combine(root, "cache"),
+ "--update-manifest-uri", "http://127.0.0.1:43119/manifest.json",
+ ];
+
+ LauncherStartupOptions options = LauncherStartupOptions.Parse(
+ ["--acdream-self-update-deferred-v1", .. suffix],
+ () => throw new InvalidOperationException(
+ "canonical path resolver was touched"));
+
+ Assert.Equal(LauncherStartupMode.SelfUpdateDeferred, options.Mode);
+ Assert.Equal(suffix, options.PublicArguments);
+ Assert.Equal(Path.Combine(root, "config"), options.Paths.ConfigDirectory);
+ Assert.Equal(Path.Combine(root, "data"), options.Paths.DataDirectory);
+ Assert.Equal(Path.Combine(root, "cache"), options.Paths.CacheDirectory);
+ }
+
+ [Fact]
+ public void AvaloniaCompositionRetainsTheExactParsedOptionsAndPathSet()
+ {
+ string root = Path.GetFullPath(
+ Path.Combine(Path.GetTempPath(), "acdream-la11-app-composition"));
+ LauncherStartupOptions options = LauncherStartupOptions.Parse(
+ [
+ "--config-dir", Path.Combine(root, "config"),
+ "--data-dir", Path.Combine(root, "data"),
+ "--cache-dir", Path.Combine(root, "cache"),
+ "--update-manifest-uri", "https://updates.example.test/manifest.json",
+ ],
+ () => throw new InvalidOperationException(
+ "canonical path resolver was touched"));
+
+ var app = new App(options);
+
+ Assert.Same(options, app.StartupOptions);
+ Assert.Same(options.Paths, app.StartupOptions.Paths);
+ Assert.Equal(
+ new Uri("https://updates.example.test/manifest.json"),
+ app.StartupOptions.UpdateManifestUri);
+ }
+
+ [Fact]
+ public void CompositionRejectsAnyBootstrapArgumentDrift()
+ {
+ var paths = new ApplicationPathSet("config", "data", "cache", null);
+ LauncherStartupOptions options = LauncherStartupOptions.Parse(
+ ["--update-manifest-uri", "https://updates.example.test/manifest.json"],
+ () => paths);
+
+ Program.RequireUnchangedPublicArguments(
+ options,
+ new SelfUpdateStartupResult(
+ false,
+ 0,
+ options.PublicArguments.ToArray()));
+ Assert.Throws(() =>
+ Program.RequireUnchangedPublicArguments(
+ options,
+ new SelfUpdateStartupResult(
+ false,
+ 0,
+ ["--update-manifest-uri", "https://other.example.test/manifest.json"])));
+ }
+
public static TheoryData InvalidArguments()
{
string absolute = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "acdream-la11"));
@@ -171,6 +243,10 @@ public sealed class LauncherStartupOptionsTests
data.Add(["--update-manifest-uri", "file:///tmp/manifest.json"]);
data.Add(
["--update-manifest-uri", "https://user:secret@example.test/manifest.json"]);
+ data.Add(
+ ["--update-manifest-uri", "https://example.test/manifest.json?token=secret"]);
+ data.Add(
+ ["--update-manifest-uri", "https://example.test/manifest.json#fragment"]);
data.Add(["--update-manifest-uri", "not-a-uri"]);
data.Add(
[
diff --git a/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs b/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs
index 37365efe..d2f2ed98 100644
--- a/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs
+++ b/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs
@@ -62,4 +62,44 @@ public sealed class LauncherUpdateCompositionTests : IDisposable
() => composition.Updater.CheckAsync());
Assert.Contains(exception.Message, updateError.Message, StringComparison.Ordinal);
}
+
+ [Theory]
+ [InlineData("https://updates.example.test/manifest.json")]
+ [InlineData("http://127.0.0.1:43119/manifest.json")]
+ public void ProcessLocalManifestOverrideReachesOnlyUpdateComposition(string value)
+ {
+ Directory.CreateDirectory(_root);
+ var paths = new ApplicationPathSet(
+ Path.Combine(_root, "config"),
+ Path.Combine(_root, "data"),
+ Path.Combine(_root, "cache"),
+ null);
+ var manifestUri = new Uri(value);
+
+ using LauncherUpdateComposition composition = LauncherUpdateComposition.Create(
+ paths,
+ LauncherRuntimeIdentity.DetectRid(),
+ LauncherVersion.Parse("1.0.0"),
+ _root,
+ () => false,
+ initialize: (_, _) => new ClientVersionResolution(
+ ClientVersionState.Missing,
+ "No client version is installed.",
+ null,
+ null,
+ null,
+ null),
+ updateManifestUri: manifestUri);
+
+ Assert.Same(manifestUri, composition.UpdateManifestUri);
+ Assert.Equal(
+ Path.Combine(paths.DataDirectory, "app"),
+ composition.Versions.AppDirectory);
+ Assert.False(File.Exists(
+ Path.Combine(paths.ConfigDirectory, "launcher-profiles.json")));
+ Assert.Empty(Directory.EnumerateFiles(
+ _root,
+ "*",
+ SearchOption.AllDirectories));
+ }
}
diff --git a/tools/new-campaign-la-update-fixture.ps1 b/tools/new-campaign-la-update-fixture.ps1
index 98d129eb..52108e71 100644
--- a/tools/new-campaign-la-update-fixture.ps1
+++ b/tools/new-campaign-la-update-fixture.ps1
@@ -85,9 +85,11 @@ foreach ($release in @('A', 'B')) {
Require-PayloadFile "$release-client-win-x64" 'AcDream.App.exe'
Require-PayloadFile "$release-client-win-x64" 'acdream-headless.exe'
Require-PayloadFile "$release-launcher-win-x64" 'acdream-launcher.exe'
+ Require-PayloadFile "$release-launcher-win-x64" 'acdream-bake.exe'
Require-PayloadFile "$release-client-linux-x64" 'AcDream.App'
Require-PayloadFile "$release-client-linux-x64" 'acdream-headless'
Require-PayloadFile "$release-launcher-linux-x64" 'acdream-launcher'
+ Require-PayloadFile "$release-launcher-linux-x64" 'acdream-bake'
}
if (Test-Path -LiteralPath $OutputDirectory) {
@@ -155,6 +157,7 @@ function New-DeterministicZip(
$executable = $relative -ceq 'AcDream.App' -or
$relative -ceq 'acdream-headless' -or
$relative -ceq 'acdream-launcher' -or
+ $relative -ceq 'acdream-bake' -or
$relative.EndsWith('.sh', [StringComparison]::Ordinal)
$mode = if ($executable) { 0x81ED } else { 0x81A4 }
$entry.ExternalAttributes = $mode -shl 16
@@ -239,16 +242,26 @@ $server = @'
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Root,
- [Parameter(Mandatory = $true)][int]$Port
+ [Parameter(Mandatory = $true)][int]$Port,
+ [ValidateRange(0, 1000000)][int]$MaximumRequests = 0
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
+$expectedRoot = [IO.Path]::TrimEndingDirectorySeparator(
+ [IO.Path]::GetFullPath($PSScriptRoot))
$Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root))
+$pathComparison = if ($IsWindows) {
+ [StringComparison]::OrdinalIgnoreCase
+} else { [StringComparison]::Ordinal }
+if (-not [string]::Equals($Root, $expectedRoot, $pathComparison)) {
+ throw '-Root must be the directory containing serve-fixture.ps1.'
+}
$prefix = "http://127.0.0.1:$Port/"
$listener = [Net.HttpListener]::new()
$listener.Prefixes.Add($prefix)
$listener.Start()
Write-Host "Campaign LA fixture listening on $prefix"
+$servedRequests = 0
try {
while ($listener.IsListening) {
$context = $listener.GetContext()
@@ -287,7 +300,13 @@ try {
$context.Response.StatusCode = 500
Write-Error $_
}
- finally { $context.Response.Close() }
+ finally {
+ $context.Response.Close()
+ $servedRequests++
+ }
+ if ($MaximumRequests -gt 0 -and $servedRequests -ge $MaximumRequests) {
+ break
+ }
}
}
finally { $listener.Close() }
@@ -301,8 +320,24 @@ param(
[string]$Root = $PSScriptRoot
)
Set-StrictMode -Version Latest
-$path = Join-Path ([IO.Path]::GetFullPath($Root)) 'active-release.txt'
-Set-Content -LiteralPath $path -Value $Release -Encoding ascii -NoNewline
+$expectedRoot = [IO.Path]::TrimEndingDirectorySeparator(
+ [IO.Path]::GetFullPath($PSScriptRoot))
+$Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root))
+$pathComparison = if ($IsWindows) {
+ [StringComparison]::OrdinalIgnoreCase
+} else { [StringComparison]::Ordinal }
+if (-not [string]::Equals($Root, $expectedRoot, $pathComparison)) {
+ throw '-Root must be the directory containing set-active-release.ps1.'
+}
+$path = Join-Path $Root 'active-release.txt'
+$temporary = "$path.$([Guid]::NewGuid().ToString('N')).tmp"
+try {
+ [IO.File]::WriteAllText($temporary, $Release, [Text.Encoding]::ASCII)
+ [IO.File]::Move($temporary, $path, $true)
+}
+finally {
+ if ([IO.File]::Exists($temporary)) { [IO.File]::Delete($temporary) }
+}
Write-Host "Campaign LA fixture active release: $Release"
'@
$selector | Set-Content -LiteralPath (Join-Path $OutputDirectory 'set-active-release.ps1') -Encoding utf8NoBOM
diff --git a/tools/run-campaign-la-preflight.ps1 b/tools/run-campaign-la-preflight.ps1
index 0a676df1..13b376e1 100644
--- a/tools/run-campaign-la-preflight.ps1
+++ b/tools/run-campaign-la-preflight.ps1
@@ -68,16 +68,22 @@ $startedUtc = [DateTime]::UtcNow
function Protect-Text([string]$Text) {
if ($null -eq $Text) { return '' }
$protected = $Text
- foreach ($name in @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET')) {
- $value = [Environment]::GetEnvironmentVariable($name)
- if (-not [string]::IsNullOrEmpty($value)) {
- $protected = $protected.Replace($value, '', [StringComparison]::Ordinal)
- }
- }
$protected = [Text.RegularExpressions.Regex]::Replace(
$protected,
'(?i)(--password|-password)(\s+|=)([^\s"'']+)',
'$1$2')
+ $protected = [Text.RegularExpressions.Regex]::Replace(
+ $protected,
+ '(?i)\b(password|passwd|secret|token|credential|api[_-]?key)(\s*[:=]\s*)([^\s,;]+)',
+ '$1$2')
+ $protected = [Text.RegularExpressions.Regex]::Replace(
+ $protected,
+ '(?i)(https?://)[^/\s:@]+:[^@\s/]+@',
+ '$1@')
+ $protected = [Text.RegularExpressions.Regex]::Replace(
+ $protected,
+ '(?i)([?&](?:token|secret|password|credential|api[_-]?key)=)[^&\s]+',
+ '$1')
$protected = [Text.RegularExpressions.Regex]::Replace(
$protected,
'(?i)("(?:password|credential|secret|token)"\s*:\s*")[^"]*(")',
@@ -142,6 +148,11 @@ function Invoke-GateCommand(
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
foreach ($argument in $Arguments) { $startInfo.ArgumentList.Add($argument) }
+ foreach ($key in @($startInfo.Environment.Keys)) {
+ if ($key.StartsWith('ACDREAM_', [StringComparison]::OrdinalIgnoreCase)) {
+ $startInfo.Environment.Remove($key)
+ }
+ }
foreach ($entry in $Environment.GetEnumerator()) {
$startInfo.Environment[[string]$entry.Key] = [string]$entry.Value
}
@@ -258,6 +269,34 @@ try {
'--', 'RunConfiguration.MaxCpuCount=1')
}
+ $headlessValidationConfig = Join-Path $OutputDirectory 'headless-k0.json'
+ Add-InternalCheck 'portable-headless-write-empty-config' {
+ [IO.File]::WriteAllText(
+ $headlessValidationConfig,
+ '{"version":1,"sessions":[]}',
+ [Text.UTF8Encoding]::new($false))
+ }
+ Invoke-DotNet 'portable-headless-help-no-connect' @(
+ 'run', '--project', 'src/AcDream.Headless/AcDream.Headless.csproj',
+ '-c', 'Release', '--no-build', '--', '--help')
+ Invoke-DotNet 'portable-headless-validate-empty-no-connect' @(
+ 'run', '--project', 'src/AcDream.Headless/AcDream.Headless.csproj',
+ '-c', 'Release', '--no-build', '--',
+ 'validate', '--config', $headlessValidationConfig)
+ Add-InternalCheck 'portable-headless-native-permission' {
+ if (-not $IsWindows) {
+ $headlessExecutable = Join-Path `
+ $Repository 'src/AcDream.Headless/bin/Release/net10.0/acdream-headless'
+ if (-not (Test-Path -LiteralPath $headlessExecutable -PathType Leaf)) {
+ throw 'The native Headless build output is missing.'
+ }
+ $mode = [IO.File]::GetUnixFileMode($headlessExecutable)
+ if (($mode -band [IO.UnixFileMode]::UserExecute) -eq 0) {
+ throw 'The native Headless build output is not executable.'
+ }
+ }
+ }
+
foreach ($rid in @('win-x64', 'linux-x64')) {
$destination = Join-Path $publishDirectory $rid
Invoke-DotNet "publish-launcher-$rid" @(
@@ -382,7 +421,8 @@ finally {
failures = @($failures)
redaction = [ordered]@{
applied = $true
- environmentValuesNeverReported = @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET')
+ inheritedAcdreamEnvironmentCleared = $true
+ inheritedEnvironmentValuesRead = $false
credentialArgumentsAllowed = $false
}
artifacts = $artifacts
diff --git a/tools/test-campaign-la-session-status.ps1 b/tools/test-campaign-la-session-status.ps1
index 834ff031..8f3b2c61 100644
--- a/tools/test-campaign-la-session-status.ps1
+++ b/tools/test-campaign-la-session-status.ps1
@@ -137,7 +137,7 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) {
}
$eventName = Assert-String $root 'e'
if (-not $exactFields.ContainsKey($eventName)) {
- throw "event '$eventName' is not in the v1 vocabulary"
+ throw 'event name is not in the v1 vocabulary'
}
$expected = $exactFields[$eventName]
if ($names.Count -ne $expected.Count -or
@@ -221,7 +221,7 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) {
if ($code -ne 0) { throw "terminal exit code is $code, expected 0" }
$expectedReason = if ($Mode -eq 'probe') { 'probe' } else { 'graceful' }
if ($reason -cne $expectedReason) {
- throw "terminal reason is '$reason', expected '$expectedReason'"
+ throw "terminal reason does not match mode '$Mode'"
}
$terminalSeen = $true
}