diff --git a/AcDream.slnx b/AcDream.slnx
index 6080bb7f..b003cfca 100644
--- a/AcDream.slnx
+++ b/AcDream.slnx
@@ -28,6 +28,8 @@
+
+
diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 881ed834..421731ab 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -26,53 +26,41 @@ What does NOT go here:
## #397 — Windows: LauncherProcessSupervisor.Stop has no reliable graceful-stop signal for a no-window console host
-**Status:** OPEN
+**Status:** IN-PROGRESS — the isolated process-group implementation and real
+Windows fixtures are complete; the LA11 connected acceptance row remains
+required before closure.
**Severity:** MODERATE (a hard-killed `AcDream.Headless` leaves the ACE
account session stuck for several minutes — a documented project landmine;
see CLAUDE.md "Logout-before-reconnect")
**Filed:** 2026-08-14 (Campaign LA plan §LA3 review-fix round, finding F3)
**Component:** Launcher.Core / process supervision
-**Description.** `LauncherProcessSupervisor.Stop` now attempts a graceful
-stop signal (`ILauncherChildProcess.TryRequestGracefulStop`) BEFORE
-`CloseMainWindow`. On Linux this sends `SIGINT` via a `libc` P/Invoke
-(`kill(pid, 2)`), which the K4-proven headless host already turns into an
-ACE-confirmed graceful logout. On Windows there is no equivalent today for a
-console process with no message-pump window: `CloseMainWindow` is a no-op
-for a console host (there is no `HWND` to target), and
-`GenerateConsoleCtrlEvent` cannot usefully target an arbitrary child process
-today — Windows delivers console control events to every process attached
-to the SAME console as the calling process, so an unscoped call would also
-signal the launcher itself (and anything else sharing that console), not
-just the intended child. `TryRequestGracefulStop` therefore returns `false`
-on Windows unconditionally, and `Stop` degrades straight to `CloseMainWindow`
-(still a no-op for a console child) and then the timeout-driven `Kill()` —
-exactly the hard-kill behavior this finding was written to describe, just
-with a documented (rather than silent) gap.
+**Implementation checkpoint.** `LauncherProcessSupervisor.Stop` attempts
+`ILauncherChildProcess.TryRequestGracefulStop` before `CloseMainWindow` and
+the timeout/kill fallback. Linux retains its K4-proven targeted `SIGINT`.
+On Windows, console-capable launcher specs now use a narrow no-shell
+`CreateProcessW` seam with `CREATE_NEW_PROCESS_GROUP`, a suspended start, and
+an explicit inherited-handle list that preserves only redirected stdin plus
+stdout/stderr. A consoleless Avalonia parent briefly allocates and hides a
+console for the creation transaction, detaches after the new group inherits
+it, and later attaches only long enough to send
+`GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, childProcessGroupId)`. Each such
+child is therefore both the root of its own process group and, for the normal
+Explorer-launched case, attached to its own console. Graphical children opt
+out and retain the ordinary `Process`/`WM_CLOSE` path.
-**Known fix direction (not yet implemented).** Spawn the Windows child with
-the `CREATE_NEW_PROCESS_GROUP` creation flag (available via a native
-`CreateProcess` call or by setting it on the `ProcessStartInfo`/`Process`
-plumbing in `SystemChildProcess`) so the child gets its own console process
-group, detached from the launcher's own group. Then
-`TryRequestGracefulStop` on Windows calls
-`GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, childProcessGroupId)` —
-`CTRL_BREAK` (unlike `CTRL_C`) can target a specific process group ID and,
-unlike `CTRL_CLOSE`/`CTRL_LOGOFF`/`CTRL_SHUTDOWN`, is deliverable to a
-process that has installed no console-control handler at all (the default
-CRT handler treats it as a terminating signal, so `AcDream.Headless` doesn't
-strictly need new code to receive SOME form of shutdown from it) — though
-wiring a real `SetConsoleCtrlHandler` handler that routes `CTRL_BREAK` into
-the same graceful-shutdown path K4 already built for Linux SIGINT is the
-better long-term target, so a Windows headless launch gets the identical
-ACE-confirmed graceful logout instead of just "exits somehow."
+Two real Windows fixture gates cover both a console parent and a consoleless
+WinExe parent. They prove exact complex argv, redirected stdin, receipt of a
+targeted CTRL_BREAK marker, exit code 0 before timeout, no supervisor `Kill`,
+and a sibling process group that remains running until it receives its own
+targeted break. Safe-handle cleanup, early-failure termination, and the
+Linux SIGINT gate remain covered by the Launcher.Core suite.
-**Acceptance for closing this issue:** `SystemChildProcess` spawns Windows
-children with `CREATE_NEW_PROCESS_GROUP`; `TryRequestGracefulStop` sends
-`CTRL_BREAK_EVENT` to that child's process group on Windows; a live
-connected gate proves `AcDream.Headless` exits gracefully (ACE clears the
-session immediately, not after the ~3-minute stale-session window) when
-stopped via `LauncherProcessSupervisor.Stop` on Windows, matching the
+**Acceptance for closing this issue:** automated process-group and targeted-
+signal coverage is complete. Keep the issue IN-PROGRESS until the LA11 live
+connected row proves `AcDream.Headless` exits gracefully and ACE clears the
+session immediately (not after the ~3-minute stale-session window) when
+stopped through `LauncherProcessSupervisor.Stop` on Windows, matching the
Linux SIGINT behavior.
## #396 — Configure Keyboard: no capture-instruction dialog on a mapping-button click
diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index e910244a..184e26e4 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -322,7 +322,10 @@ src/
AcDream.Launcher.Core/ BCL-only launcher state/orchestration owner
Profiles/ -> sole credential/profile document + CRUD owner
- Launching/ -> config composition and supervised process seams
+ Launching/ -> config composition and supervised process seams;
+ Windows console hosts are no-shell, redirected-
+ stdin process-group leaders receiving targeted
+ CTRL_BREAK, while Linux hosts receive SIGINT
Status/ -> incremental host-status parsing/tailing
Orchestration/ -> immutable UI snapshots, typed actions,
capability gates, and running-session lifetime
@@ -350,6 +353,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 5f6f292d..059db843 100644
--- a/docs/plans/2026-08-14-launcher-campaign.md
+++ b/docs/plans/2026-08-14-launcher-campaign.md
@@ -697,7 +697,10 @@ forms `COM¹`/`COM²`/`COM³` and `LPT¹`/`LPT²`/`LPT³`, including extensions.
## LA11 — closeout
-- One connected-gate script `docs/research/2026-XX-XX-campaign-la-test-script.md`
+- One exact operator script
+ `docs/research/2026-08-14-campaign-la-test-script.md`, fronted by the
+ connection-free `tools/run-campaign-la-preflight.ps1` and followed by
+ serial user rows,
covering: all three launch modes vs local ACE, probe round-trip ×2 (no
lingering session), char-select visual matrix + delete flow, login-commands
+ plugin behavior on both hosts, add-server/add-account purely in UI,
@@ -729,4 +732,4 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny.
| LA8 | **DONE + MERGED 2026-08-14** | `6cfab727`, `aeac874d`, `1dd5706e`, merge `fe63ce18` | Initial retail/architecture review found 4 issues; first narrow re-review left 2 retry-transaction/order gaps; final narrow re-review PASS | Installed DAT enum table 5 proves `0x10000005 -> 0x21000004`, root `0x1000039A`, exact flat list/buttons/templates/dialog assets, and no viewport. Runtime remains the only selection owner; row sizing, modal priority/retry, restore ordering, reset/disposal, and explicit live-DAT skip/probe are covered. Branch full suite 13,796+5 skip; LA11 owns physical visual/live-ACE acceptance. |
| LA9 | **DONE + MERGED 2026-08-14** | `ff6ebb6a`, `3f688951`, `208a70ac`, merge `2198a0cc` | Initial integrity review found 5 issues; narrow re-review left one orphan-child publication race; final narrow re-review PASS | First-run installer validates four DATs, consumes strict v1 Bake JSONL, preserves/reverifies SHA+size+tool-version records, and co-publishes self-contained launcher+Bake. Cross-process install/publish locks plus durable nonce prevent post-recovery mutation across real parent-only hard kills on Windows/Linux. Branch full suite 13,799+4 skip; real retail-DAT bake remains LA11. |
| LA10 | **DONE + MERGED 2026-08-14** | `2d2a5b50`, `1955ca8a`, `09d84387`, merge `da4fb3de` | Initial architecture/security review found 10 crash, trust, integrity, cleanup, and lifecycle issues; first narrow re-review left one rollback-source P1; final narrow re-review PASS | Production feeds and redirects are HTTPS-only, fixture loopback trust is explicit, downloads and archives are bounded and verified, version activation and rollback are atomic, active sessions hold the cross-process update lease, and schema-v3 self-update recovery verifies every prior/replacement file before apply, rollback, or restart. Real Windows/Linux process tests cover kill boundaries, staging races, lease deferral, corrupt backups, junctions/symlinks, and fail-closed recovery. Branch gates: Core 302/302 and Launcher 29/29 on Windows/WSL, full Release 13,945+4 skip, win/linux self-contained publishes. Integrated LA0–LA10 gate: 13,972+5 skip. |
-| LA11 | — | | | |
+| LA11 | **AUTOMATED CLOSEOUT REVIEW-CLOSED 2026-08-15 — USER GATE PENDING** | `f881e5b4`, `134edabe`, `accd01a0`, `9f9c1167`; merge pending | Initial dual-lens review found 7 startup/evidence/safety issues; first narrow re-review left 2 PID-reuse/ZIP-mode gaps; final narrow re-review PASS | Strict isolated roots and process-local feed override compose one exact launcher path graph. Windows targeted CTRL_BREAK is group-isolated and preserves stdin; exact-PID/start-identity status validation, credential-value scanning, deterministic Unix-mode A/B fixtures, Windows/native-Linux helper safety, and the exact A–I operator script are implemented. Clean branch preflight passed 32/32 with 13,985 tests + 4 skips. No connected/UI/real-DAT row has run; campaign shipment and #397 closure remain pending the user gate. |
diff --git a/docs/research/2026-08-14-campaign-la-test-script.md b/docs/research/2026-08-14-campaign-la-test-script.md
new file mode 100644
index 00000000..cd8f68b0
--- /dev/null
+++ b/docs/research/2026-08-14-campaign-la-test-script.md
@@ -0,0 +1,631 @@
+# Campaign LA11 — automated preflight and connected user gate
+
+**Status:** implementation checkpoint only. Run this script after the reviewed
+LA10/LA11 commits are integrated and the campaign branch is clean. Campaign LA,
+the Linux graphical client, and issue #397 remain open until the user records a
+verdict for every applicable row below.
+
+This is the single Campaign LA operator script. The automated section is
+display-free and connection-free. Rows A–I are deliberately manual and serial:
+they use real retail DATs, a local ACE server, user-entered credentials, and
+visual judgment that automation cannot supply.
+
+## 1. Safety boundary and required inputs
+
+Use placeholders throughout; never paste a password into a terminal, this
+document, a screenshot, or a gate report.
+
+- ``: a clean Campaign LA worktree at the exact commit
+ under test.
+- ``: a read-only source containing
+ `client_portal.dat`, `client_cell_1.dat`, `client_highres.dat`, and
+ `client_local_English.dat`.
+- ``, ``, and ``: a local ACE endpoint and
+ account. Enter the account password only in the launcher's masked Password
+ field. The launcher intentionally stores it as plaintext in the **isolated**
+ `launcher-profiles.json`; children receive it through redirected stdin.
+- ``: a second user-controlled character that can observe a
+ private `/tell` from each play mode.
+- ``: a server-operator-approved disposable character.
+ Never substitute a primary character. If none exists, provision one with the
+ local server's normal admin procedure before row G.
+- Windows 11 x64, PowerShell 7, .NET 10 SDK, a local ACE server, and a supported
+ Vulkan Windows machine for rows A–H. Ubuntu x64 with PowerShell 7 and a Linux
+ desktop/WSLg is required for row I. The Avalonia launcher is supported on
+ Linux; `gui` and `guiSelect` **client** actions must remain disabled with the
+ Modern Runtime Slice-L explanation.
+
+Close every unrelated `AcDream.App`, `acdream-headless`, and acdream launcher
+before starting. Do not run another acdream gate in parallel. All generated
+files must stay below one new gate directory; the canonical `%APPDATA%`,
+`%LOCALAPPDATA%`, and XDG acdream roots are out of scope.
+
+## 2. Automated preflight — no UI, connection, credential, or bake
+
+In PowerShell 7 on Windows:
+
+```powershell
+$Repo = [IO.Path]::GetFullPath('')
+$Stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss')
+$Gate = Join-Path $Repo "logs/campaign-la-user-gate-$Stamp"
+$Preflight = Join-Path $Gate 'automated-preflight'
+New-Item -ItemType Directory -Path $Gate | Out-Null
+
+pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') `
+ -Repository $Repo `
+ -AllowedOutputRoot $Gate `
+ -OutputDirectory $Preflight
+
+$Report = Get-Content -LiteralPath (Join-Path $Preflight 'report.json') -Raw |
+ ConvertFrom-Json
+if (-not $Report.success -or $Report.dirty) {
+ throw 'Stop: automated preflight failed or recorded a dirty worktree.'
+}
+if ($Report.head -cne (git -C $Repo rev-parse HEAD).Trim()) {
+ throw 'Stop: preflight HEAD does not equal the current HEAD.'
+}
+```
+
+The expected matrix is:
+
+| Platform | Automated command group | Required result | Typical time |
+|---|---|---|---:|
+| 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 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 32 rows, including the connection-free PID/status/redaction and script-
+safety contract suites. `-AllowedOutputRoot` must be a fresh, explicit
+`campaign-la-*` gate root (or the repository `logs` root), and output must be a
+fresh, empty, non-reparse strict descendant; repository, home, source, payload,
+nonempty, and arbitrary existing directories are rejected. The helper 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
+
+This is not a bake and must not replace row A. Add the switches below only when
+the DAT directory may be read by tests:
+
+```powershell
+pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') `
+ -Repository $Repo `
+ -AllowedOutputRoot $Gate `
+ -OutputDirectory (Join-Path $Gate 'automated-preflight-with-dat') `
+ -IncludeInstalledDat `
+ -InstalledDatDirectory ''
+```
+
+The mandatory installed-DAT result is
+`CharacterManagementLiveDatTests` with both `ACDREAM_PROBE_LIVE_MOUNT=1` and
+`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: 36 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
+`wsl.exe` as the Linux row.
+
+## 3. Prepare the deterministic local A/B feed
+
+Build distinct, version-stamped payloads so the staged launcher really changes
+from A to B. These commands write only below `$Gate` (normal project `obj/bin`
+incremental outputs are the already-authorized build outputs):
+
+```powershell
+$VersionA = '1.0.1-la11.a'
+$VersionB = '1.0.1-la11.b'
+$Payloads = Join-Path $Gate 'update-payloads'
+$Fixture = Join-Path $Gate 'update-fixture'
+
+function Publish-LaRelease([string]$Version, [string]$Label) {
+ $ClientWin = Join-Path $Payloads "$Label/client-win-x64"
+ $LauncherWin = Join-Path $Payloads "$Label/launcher-win-x64"
+ $ClientLinux = Join-Path $Payloads "$Label/client-linux-x64"
+ $LauncherLinux = Join-Path $Payloads "$Label/launcher-linux-x64"
+
+ dotnet publish (Join-Path $Repo 'src/AcDream.App/AcDream.App.csproj') `
+ -c Release -r win-x64 --self-contained true -p:Version=$Version `
+ -o $ClientWin --nologo
+ if ($LASTEXITCODE) { throw "App win-x64 publish failed: $Label" }
+ dotnet publish (Join-Path $Repo 'src/AcDream.Headless/AcDream.Headless.csproj') `
+ -c Release -r win-x64 --self-contained true -p:Version=$Version `
+ -o $ClientWin --nologo
+ if ($LASTEXITCODE) { throw "Headless win-x64 publish failed: $Label" }
+ dotnet publish (Join-Path $Repo 'src/AcDream.Launcher/AcDream.Launcher.csproj') `
+ -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true `
+ -p:Version=$Version -o $LauncherWin --nologo
+ if ($LASTEXITCODE) { throw "Launcher win-x64 publish failed: $Label" }
+
+ dotnet publish (Join-Path $Repo 'src/AcDream.App/AcDream.App.csproj') `
+ -c Release -r linux-x64 --self-contained true -p:Version=$Version `
+ -o $ClientLinux --nologo
+ if ($LASTEXITCODE) { throw "App linux-x64 publish failed: $Label" }
+ dotnet publish (Join-Path $Repo 'src/AcDream.Headless/AcDream.Headless.csproj') `
+ -c Release -r linux-x64 --self-contained true -p:Version=$Version `
+ -o $ClientLinux --nologo
+ if ($LASTEXITCODE) { throw "Headless linux-x64 publish failed: $Label" }
+ dotnet publish (Join-Path $Repo 'src/AcDream.Launcher/AcDream.Launcher.csproj') `
+ -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true `
+ -p:Version=$Version -o $LauncherLinux --nologo
+ if ($LASTEXITCODE) { throw "Launcher linux-x64 publish failed: $Label" }
+}
+
+Publish-LaRelease $VersionA 'A'
+Publish-LaRelease $VersionB 'B'
+
+pwsh -NoProfile -File (Join-Path $Repo 'tools/new-campaign-la-update-fixture.ps1') `
+ -OutputDirectory $Fixture `
+ -ClientWinX64DirectoryA (Join-Path $Payloads 'A/client-win-x64') `
+ -LauncherWinX64DirectoryA (Join-Path $Payloads 'A/launcher-win-x64') `
+ -ClientLinuxX64DirectoryA (Join-Path $Payloads 'A/client-linux-x64') `
+ -LauncherLinuxX64DirectoryA (Join-Path $Payloads 'A/launcher-linux-x64') `
+ -ClientWinX64DirectoryB (Join-Path $Payloads 'B/client-win-x64') `
+ -LauncherWinX64DirectoryB (Join-Path $Payloads 'B/launcher-win-x64') `
+ -ClientLinuxX64DirectoryB (Join-Path $Payloads 'B/client-linux-x64') `
+ -LauncherLinuxX64DirectoryB (Join-Path $Payloads 'B/launcher-linux-x64')
+```
+
+The helper rejects nonempty output, invalid or non-monotonic versions, missing
+root executables (including the co-deployed Bake CLI), nonabsolute inputs,
+output/source overlap in either direction, and any reparse point in source or
+output ancestry. It enumerates normalized relative paths with ordinal ordering,
+never its own output, and normalizes ZIP origin to Unix on both hosts so
+Windows/Linux hashes are identical under multiple cultures while native Linux
+extraction retains 0755 for App/Headless/Launcher/Bake and 0644 for ordinary
+files. It writes fixed-timestamp sorted ZIPs,
+the exact LA10 v1 SHA/size manifest, `fixture-report.json`, a loopback-only
+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:
+
+```powershell
+$ServerInfo = [Diagnostics.ProcessStartInfo]::new()
+$ServerInfo.FileName = (Get-Command pwsh).Source
+$ServerInfo.UseShellExecute = $false
+$ServerInfo.CreateNoWindow = $true
+foreach ($Value in @(
+ '-NoProfile', '-File', (Join-Path $Fixture 'serve-fixture.ps1'),
+ '-Root', $Fixture, '-Port', '43119')) {
+ $ServerInfo.ArgumentList.Add($Value)
+}
+$FixtureServer = [Diagnostics.Process]::Start($ServerInfo)
+$ManifestUri = 'http://127.0.0.1:43119/manifest.json'
+if ((Invoke-RestMethod -Uri $ManifestUri).version -cne $VersionA) {
+ throw 'Stop: local fixture did not begin on release A.'
+}
+```
+
+## 4. Windows isolated launcher command and evidence rule
+
+```powershell
+$WinRoot = Join-Path $Gate 'windows-roots'
+$WinConfig = Join-Path $WinRoot 'config'
+$WinData = Join-Path $WinRoot 'data'
+$WinCache = Join-Path $WinRoot 'cache'
+$Evidence = Join-Path $Gate 'evidence'
+New-Item -ItemType Directory -Path $Evidence | Out-Null
+
+$LauncherA = Join-Path $Payloads 'A/launcher-win-x64/acdream-launcher.exe'
+$LauncherArguments = @(
+ '--config-dir', $WinConfig,
+ '--data-dir', $WinData,
+ '--cache-dir', $WinCache,
+ '--update-manifest-uri', $ManifestUri)
+& $LauncherA @LauncherArguments
+```
+
+All four options are process-local. The three roots are an indivisible set;
+the local feed reaches only the updater and is never persisted. A self-update
+must preserve the same validated suffix through helper and confirmation
+restarts. The launcher, profiles, installer, current-version store, updater,
+session composer, and orchestrator must all use this one exact path set.
+
+For every play/probe row, start this gate-only PID watcher immediately before
+clicking Refresh/Play. It correlates only the unique isolated session-config
+path, records neither raw command line nor config contents, and must finish
+while the child is still live. Its safe sidecar contains the normalized config
+path, a sanitized command fingerprint, and PID plus an OS-native process-start
+identity so later PID reuse cannot become a false leak:
+
+```powershell
+$CapturePath = Join-Path $Evidence '-process.capture.json'
+$CaptureStart = [DateTimeOffset]::UtcNow
+$CaptureInfo = [Diagnostics.ProcessStartInfo]::new()
+$CaptureInfo.FileName = (Get-Command pwsh).Source
+$CaptureInfo.UseShellExecute = $false
+$CaptureInfo.CreateNoWindow = $true
+foreach ($Value in @(
+ '-NoProfile', '-File', (Join-Path $Repo 'tools/capture-campaign-la-session-process.ps1'),
+ '-SessionsDirectory', (Join-Path $WinCache 'launcher/sessions'),
+ '-CreatedAfterUtc', $CaptureStart.ToString('O'),
+ '-ReportPath', $CapturePath, '-WaitSeconds', '60')) {
+ $CaptureInfo.ArgumentList.Add($Value)
+}
+$CaptureProcess = [Diagnostics.Process]::Start($CaptureInfo)
+# Click exactly one Refresh/Play action now, then wait for capture.
+$CaptureProcess.WaitForExit()
+if ($CaptureProcess.ExitCode) { throw 'Stop: live child PID capture failed.' }
+$Capture = Get-Content -LiteralPath $CapturePath -Raw | ConvertFrom-Json
+$SessionConfig = Join-Path $WinCache "launcher/sessions/$($Capture.sessionId)/session.json"
+$Status = Join-Path $WinCache "launcher/sessions/$($Capture.sessionId)/status.jsonl"
+
+# After Stop and terminal status:
+pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') `
+ -StatusFile $Status `
+ -Mode '' `
+ -ProcessCapturePath $CapturePath `
+ -CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') `
+ -ExpectedSessionId $Capture.sessionId `
+ -ReportPath (Join-Path $Evidence '-status.validation.json')
+```
+
+Add `-ExpectedPlugin acdream.smoke` to rows D–F. The validator enforces exact
+v1 fields **and property order**, one session id, UTC monotonic timestamps,
+mode-specific lifecycle order, exit code 0/reason, no unexpected plugin/login
+command failure, exact terminal `disconnected.reason == stopped`, credential
+redaction, and that exact captured process instance is gone. Independent exact
+config-path correlation uses Windows CIM or Linux `/proc/*/cmdline`; it never
+globally scans a process name, treats a different start identity on a reused PID
+as a different process, and is unaffected by unrelated same-name processes or
+Linux's 15-character names. The validator verifies owner-only profile access,
+reads only password/secret fields in memory, recursively checks every allowed status
+string (including command/error text), and reports only the forbidden-value
+count and status hash—never credential content or a credential hash. Keep the
+profile, raw `session.json`/`status.jsonl`, and raw process-capture sidecar
+(which contains the absolute isolated path) local; never upload them.
+
+## 5. Serial Windows user rows A–H
+
+### A — isolated first run, real DAT bake, and release-A client baseline
+
+1. Confirm the launcher opens First-run setup and all launch buttons are
+ unavailable. Save a redacted screenshot as `A-first-run-required.png`.
+2. Enter `` in the wizard, select a sensible
+ worker count, and click **Validate**. Confirm all four DATs pass.
+3. Click **Build and install**. Do not cancel or close the launcher. The real
+ bake may take 30–180 minutes. Confirm every phase reaches **Completed** and
+ the status says `Client content installed and verified. Launch is enabled.`
+4. Open **Check for updates**. Confirm available release A, click **Install
+ client**, and wait for `Client update installed and activated.` Do not stage
+ launcher A; the test launcher already has version A.
+5. Confirm these exact isolated artifacts exist and no `.previous-install`
+ remains after success:
+
+```powershell
+$RequiredA = @(
+ (Join-Path $WinData 'install.json'),
+ (Join-Path $WinData 'pak/acdream.pak'),
+ (Join-Path $WinData 'app/current.json'))
+foreach ($Path in $RequiredA) {
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Missing $Path" }
+}
+$RequiredA | ForEach-Object {
+ $Item = Get-Item -LiteralPath $_
+ [ordered]@{
+ name = $Item.Name
+ size = $Item.Length
+ sha256 = (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash.ToLowerInvariant()
+ }
+} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Evidence 'A-install-hashes.json')
+```
+
+Do not copy `install.json` into shared evidence because it records the local DAT
+path. Expected time: 45–200 minutes including bake.
+
+### B — server/account CRUD entirely through the UI
+
+1. Add `` at `127.0.0.1:`, edit its name
+ and port, then remove it. Confirm Cancel/Escape makes no mutation.
+2. Add `` at `127.0.0.1:`.
+3. Under it add `` with a user-invented throwaway field
+ value, edit its account name/value, then remove it. Do not reuse a real
+ password for this temporary row.
+4. Add `` and enter its real password only in the masked field.
+5. Close and reopen the launcher with the **same** `$LauncherArguments`. Confirm
+ only the real server/account persisted. Save redacted before/reopen images as
+ `B-crud-before-reopen.png` and `B-crud-after-reopen.png`.
+6. Record only the profile file's size/hash, never its contents:
+
+```powershell
+$Profile = Join-Path $WinConfig 'launcher-profiles.json'
+$Item = Get-Item -LiteralPath $Profile
+[ordered]@{
+ size = $Item.Length
+ sha256 = (Get-FileHash -LiteralPath $Profile -Algorithm SHA256).Hash.ToLowerInvariant()
+} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Evidence 'B-profile-hash.json')
+```
+
+Expected time: 10–15 minutes.
+
+### C — live character probe twice, no stale ACE session
+
+1. Select ``, click **Refresh characters**, and wait for the
+ probe row to finish. Confirm the roster appears without entering world.
+2. Run the validator in `probe` mode for its session id. Confirm its exact
+ event order is `started, connected, characterList, disconnected, exited`,
+ with no `enteredWorld`, terminal code 0, and terminal reason `probe`.
+3. In the ACE console/session administration view, confirm the account is no
+ longer logged in. Save a redacted `C-probe-1-ace-cleared.png`.
+4. Repeat steps 1–3 immediately, producing a different session id,
+ `C-probe-2-status.validation.json`, and `C-probe-2-ace-cleared.png`.
+5. Confirm Refresh is re-enabled and no `acdream-headless` process remains.
+
+Expected time: 5–10 minutes. A stale ACE account or timeout is a gate failure;
+do not wait three minutes and call the next attempt a pass.
+
+### D — `guiSelect`, retail character screen, plugin, and login command
+
+1. Select one non-disposable roster character. Set its mode to `guiSelect`,
+ Plugins to exactly `acdream.smoke`, and its one login command to
+ `/tell , LA11-D-`. Save settings.
+2. Click **GUI — character select**. Confirm the flat retail character list,
+ selection highlight, Enter button, Delete/Restore swap state, dialogs, and
+ absence of any invented rotating 3D preview. Save redacted
+ `D-character-select.png`.
+3. Select the configured character and enter world. Confirm the observer gets
+ the exact D nonce once. Save `D-observer-tell.png` with names redacted.
+4. Click **Stop** in the launcher. Confirm the game closes gracefully and ACE
+ releases the account. Validate `guiSelect` with
+ `-ExpectedPlugin acdream.smoke`.
+
+Expected time: 5–10 minutes.
+
+### E — direct `gui`, plugin, and login command
+
+1. Change the same character to `gui`, retain `acdream.smoke`, and change the
+ command nonce to `LA11-E-`.
+2. Click **GUI — enter world**. Confirm it selects the exact cached character,
+ reaches the world, loads the plugin once, and the observer gets the E nonce
+ once.
+3. Stop from the launcher, confirm ACE logout, and validate `gui` with the
+ expected plugin. Save `E-world.png`, `E-observer-tell.png`, and
+ `E-status.validation.json` with identifying text redacted.
+
+Expected time: 5–10 minutes.
+
+### F — headless, plugin/login command, and connected #397 acceptance
+
+1. Change the same character to `headless`, retain `acdream.smoke`, and use
+ `LA11-F-`.
+2. Click **Headless**. Confirm `pluginLoaded(acdream.smoke)`, `enteredWorld`,
+ and the observer's single exact F nonce.
+3. Click **Stop** once. On Windows this must target that child's distinct
+ process group with `CTRL_BREAK`; it must reach `disconnected` then
+ `exited(code:0, reason:graceful)` before the timeout, without a hard kill.
+ ACE must release the account immediately and the launcher must stay open.
+4. Validate `headless` with the expected plugin and save
+ `F-status.validation.json` plus redacted ACE-clear evidence.
+
+The real automated fixture separately proves complex argv and redirected stdin
+survive native `CreateProcessW`, the target receives `CTRL_BREAK`, a sibling
+process group receives nothing, exit 0 precedes timeout, and `Kill` is never
+called. This connected row proves the actual ACE graceful-logout half. Issue
+#397 remains open if either half is missing. Expected time: 5–10 minutes.
+
+### G — disposable delete and restore
+
+1. Launch `guiSelect` for ``. Do not enter world.
+2. Confirm ordinary selection enables Enter/Delete and disables Restore. Click
+ Delete, inspect the retail confirmation dialog, cancel once, and confirm no
+ state change.
+3. Delete again and confirm. Verify the wait dialog, greyed/pending-delete
+ roster state, constant boolean-ish nonzero `secondsGreyedOut`, disabled
+ Enter/Delete, and enabled Restore. The UI must display no countdown. Save
+ `G-deleted.png`.
+4. Click Restore and confirm the same GUID returns to ordinary state with
+ Enter/Delete enabled and Restore disabled. Save `G-restored.png`.
+5. Close through launcher **Stop**, confirm graceful terminal status and ACE
+ release. Validate with:
+
+```powershell
+pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') `
+ -StatusFile (Join-Path $WinCache 'launcher/sessions//status.jsonl') `
+ -Mode guiSelect `
+ -ProcessCapturePath (Join-Path $Evidence 'G-process.capture.json') `
+ -CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') `
+ -ExpectNoEnteredWorld `
+ -ExpectedSessionId '' `
+ -ExpectedPlugin acdream.smoke `
+ -ReportPath (Join-Path $Evidence 'G-status.validation.json')
+```
+
+If restore fails, stop the row, preserve evidence, and restore only that
+disposable character with the server's normal admin recovery. Never continue
+with another character. Expected time: 5–10 minutes.
+
+### H — local A→B client update, active-session refusal, rollback, self-update
+
+1. Record release A's `app/current.json`. Start one headless session and wait
+ for `enteredWorld`.
+2. Switch the fixture atomically to B:
+
+```powershell
+pwsh -NoProfile -File (Join-Path $Fixture 'set-active-release.ps1') `
+ -Release B -Root $Fixture
+if ((Invoke-RestMethod -Uri $ManifestUri).version -cne $VersionB) {
+ throw 'Stop: fixture did not switch to B.'
+}
+```
+
+3. Open **Check for updates** and **Check again**. While the session is active,
+ confirm Install client, Rollback client, and Stage launcher are disabled or
+ refuse without changing `app/current.json`. Save `H-active-refusal.png`.
+4. Stop the headless session and validate its graceful status. Install client
+ B. Confirm `app/current.json` names B, A is previous, all installed-file
+ hashes verify, and new sessions resolve from the B directory.
+5. Click **Rollback client**. Confirm A becomes current and B becomes previous.
+ Check again and install B once more, leaving B current. Save sanitized copies
+ of the three pointer states as `H-pointer-a.json`, `H-pointer-b.json`, and
+ `H-pointer-rollback-a.json`; they contain no credentials.
+6. Click **Stage launcher**. Confirm restart is required, then close the
+ launcher normally. The copied helper must apply B and restart the launcher
+ with the same config/data/cache/feed suffix.
+7. Confirm profiles, install record, and update state still come from the
+ isolated roots; `campaign-la-fixture-release.txt` beside the relaunched
+ executable says `release=B`; `launcher-update/pending.json` is gone; and no
+ transaction backup remains. Check again and confirm launcher B is current.
+ Save `H-self-update-confirmed.png` and a hash-only post-state inventory.
+
+Never edit a manifest to force this row and never point the launcher at a
+non-loopback HTTP endpoint. Expected time: 15–30 minutes.
+
+## 6. Row I — native Ubuntu/WSL launcher, XDG-shaped isolated roots
+
+Stop the Windows launcher and fixture server only after every Windows session
+is terminal:
+
+```powershell
+if (-not $FixtureServer.HasExited) {
+ $FixtureServer.Kill()
+ $FixtureServer.WaitForExit()
+}
+```
+
+In a native Ubuntu/WSL PowerShell 7 terminal, set Linux paths. The repository
+and fixture may be read from a mounted Windows path, but roots must live on the
+Linux filesystem. Run the generated server natively so its `127.0.0.1` URLs
+cannot escape the Linux environment:
+
+```powershell
+$RepoLinux = [IO.Path]::GetFullPath('')
+$FixtureLinux = [IO.Path]::GetFullPath('')
+$PayloadsLinux = [IO.Path]::GetFullPath('')
+$LinuxGate = [IO.Path]::GetFullPath('')
+$env:XDG_CONFIG_HOME = Join-Path $LinuxGate 'xdg-config-home'
+$env:XDG_DATA_HOME = Join-Path $LinuxGate 'xdg-data-home'
+$env:XDG_CACHE_HOME = Join-Path $LinuxGate 'xdg-cache-home'
+$LinuxConfig = Join-Path $env:XDG_CONFIG_HOME 'acdream'
+$LinuxData = Join-Path $env:XDG_DATA_HOME 'acdream'
+$LinuxCache = Join-Path $env:XDG_CACHE_HOME 'acdream'
+$LinuxEvidence = Join-Path $LinuxGate 'evidence'
+New-Item -ItemType Directory -Path $LinuxEvidence | Out-Null
+
+pwsh -NoProfile -File (Join-Path $FixtureLinux 'set-active-release.ps1') `
+ -Release A -Root $FixtureLinux
+```
+
+Start `serve-fixture.ps1 -Root $FixtureLinux -Port 43119` in a dedicated native
+terminal and leave it running. In another terminal:
+
+```powershell
+$LauncherLinuxA = Join-Path $PayloadsLinux 'A/launcher-linux-x64/acdream-launcher'
+& $LauncherLinuxA `
+ --config-dir $LinuxConfig `
+ --data-dir $LinuxData `
+ --cache-dir $LinuxCache `
+ --update-manifest-uri 'http://127.0.0.1:43119/manifest.json'
+```
+
+Complete this exact serial matrix:
+
+1. **Manual-DAT first run:** enter ``;
+ auto-detection may be empty by design. Validate, bake to
+ `$LinuxData/pak/acdream.pak`, verify, then install release-A client.
+2. **CRUD:** add/edit/remove a temporary server and account entirely in the
+ launcher, then add the real Linux-reachable ACE profile. Enter its password
+ only in the masked field. Restart and confirm persistence. Run
+ `stat -c '%a' "$LinuxConfig/launcher-profiles.json"`; the exact result must
+ be `600`.
+3. **Probe twice:** run Refresh twice, validate both status streams in `probe`
+ mode with native `pwsh`, using the same pre-action watcher and exact PID,
+ Linux session-config path, and `$LinuxConfig/launcher-profiles.json`; confirm
+ ACE clears the account after each.
+4. **Platform posture:** confirm GUI and GUI-select client buttons are disabled
+ and show the explicit Modern Runtime Slice-L message. Do not bypass this
+ disablement and do not claim a Linux graphical-client gate.
+5. **Headless:** configure `acdream.smoke` and
+ `/tell , LA11-I-`, launch, observe
+ the tell, click Stop, and validate `headless` + expected plugin. Native Linux
+ sends SIGINT and must reach graceful terminal status with no process leak.
+6. **Update:** switch the native fixture to B, prove update actions refuse while
+ a headless session is active, stop it gracefully, install B, rollback to A,
+ reinstall B, stage launcher B, and close normally. Confirm the relaunched
+ binary's B marker, preserved explicit roots/feed, cleaned pending journal,
+ and executable owner bits on App, Headless, Launcher, and Bake.
+
+Copy only redacted screenshots, validation reports, pointer JSON, hashes, and
+file-mode results into `$LinuxEvidence`. Keep the Linux profile and raw session
+files local. Expected time: 60–220 minutes, dominated by the real bake.
+
+## 7. Evidence, redaction, verdict, and cleanup
+
+Expected evidence tree:
+
+```text
+logs/campaign-la-user-gate-/
+ automated-preflight/report.json
+ automated-preflight/commands/*.log
+ automated-preflight/publish/{win-x64,linux-x64}/...
+ update-fixture/fixture-report.json
+ update-fixture/{A,B}/manifest.json
+ evidence/A-install-hashes.json
+ evidence/B-*.png
+ evidence/C-probe-{1,2}-status.validation.json
+ evidence/*-process.capture.json
+ evidence/D-*.png + D-status.validation.json
+ evidence/E-*.png + E-status.validation.json
+ evidence/F-*.png + F-status.validation.json
+ evidence/G-*.png + G-status.validation.json
+ evidence/H-*.png + H-pointer-*.json
+ evidence/I-*.png + I-status.validation.json + I-modes.txt
+ verdict.json
+```
+
+Before sharing evidence:
+
+- remove or mask account names, character names, DAT paths, hostnames other than
+ loopback, and server-admin identifiers from screenshots;
+- never copy `launcher-profiles.json`, raw session configs/status streams,
+ stdout/stderr that may contain user text, or environment values;
+- search the shareable evidence for the exact user-entered password and any
+ gate-only sentinel secret; the match count must be zero;
+- retain SHA-256 and sizes so local raw artifacts remain auditable.
+
+No additional raw child/plugin diagnostic sink is required: `pluginLoaded`,
+the strict terminal status, the observer's redacted tell evidence, and the
+automated targeted-signal fixture cover the acceptance questions without
+capturing credentials or arbitrary chat.
+
+Create `verdict.json` manually with schema version 1, exact tested HEAD, rows
+A–I as `pass`, `fail`, or `notApplicable`, a short redacted note per row, and
+the user's overall verdict. Row I is not applicable only when no native
+Ubuntu/WSL desktop is available; it blocks Campaign LA shipping under the
+current Linux requirement, so it cannot be silently omitted.
+
+Cleanup is graceful-first and serial:
+
+1. Restore `` and verify it is ordinary before closing
+ its session.
+2. Stop every launcher session once; require a passing validator and ACE-clear
+ observation. If a child survives the timeout, record the gate failure and
+ its PID before any emergency termination.
+3. Close each launcher normally, then stop only the fixture-server process
+ created above. Do not kill ACE as a substitute for logout evidence.
+4. Leave update pointers on B or roll the **isolated** client back to A through
+ the UI; never edit pointers or journals by hand.
+5. Remove the real account through the isolated launcher UI. After review,
+ delete only the explicitly recorded `$WinConfig`/`$LinuxConfig` gate roots
+ that held plaintext passwords, or change the test account password. Do not
+ recursively delete a computed, empty, canonical, home, repository, or XDG
+ parent path.
+6. Preserve the redacted evidence and reports. The large isolated pak/payload
+ trees may be removed only after resolving and checking their full paths are
+ descendants of the recorded gate roots.
+
+Estimated total: 3–7 hours, primarily the two real DAT bakes and full serial
+test suites. A failure stops the current row; restore/stop/collect evidence,
+then diagnose before advancing. Do not mark LA11, Campaign LA, or #397 shipped
+until the user accepts the complete applicable matrix.
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 db477433..0224a7f7 100644
--- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md
+++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md
@@ -331,7 +331,12 @@ the process supervisor executes.
Campaign V); visuals settle at the user gate.
- **Headless plugin host:** fixture plugin in the Headless suite
(load, capability flag, teardown).
-- **Connected gates (user-driven):** every launch mode against local ACE
+- **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. 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/Launching/ILauncherChildProcess.cs b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs
index 872c0130..b8816ab7 100644
--- a/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs
+++ b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs
@@ -41,11 +41,10 @@ public interface ILauncherChildProcess : IDisposable
/// documented project landmine; see CLAUDE.md
/// "Logout-before-reconnect"). On Linux this sends SIGINT (K4 proved
/// the headless host's SIGINT handler produces an ACE-confirmed
- /// graceful logout). On Windows there is no reliable cross-console
- /// mechanism for an arbitrary no-window child process today — see
- /// docs/ISSUES.md for the tracked gap and fix direction; this
- /// returns false there. Returns true only when the signal was
- /// actually delivered; never throws.
+ /// graceful logout). On Windows, console-capable children are started
+ /// as distinct process-group leaders and receive a targeted
+ /// CTRL_BREAK_EVENT. Returns true only when the signal was actually
+ /// delivered; never throws.
///
bool TryRequestGracefulStop();
@@ -72,7 +71,9 @@ public interface ILauncherChildProcessFactory
public sealed class SystemChildProcessFactory : ILauncherChildProcessFactory
{
public ILauncherChildProcess Create(LauncherProcessSpec spec) =>
- new SystemChildProcess(spec);
+ OperatingSystem.IsWindows() && spec.SupportsConsoleGracefulStop
+ ? new WindowsSystemChildProcess(spec)
+ : new SystemChildProcess(spec);
}
internal sealed partial class SystemChildProcess : ILauncherChildProcess
@@ -86,11 +87,13 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess
private static partial int kill(int pid, int sig);
private readonly Process _process;
+ private readonly bool _supportsConsoleGracefulStop;
private bool _raisingEnabled;
internal SystemChildProcess(LauncherProcessSpec spec)
{
ArgumentNullException.ThrowIfNull(spec);
+ _supportsConsoleGracefulStop = spec.SupportsConsoleGracefulStop;
var startInfo = new ProcessStartInfo
{
@@ -130,11 +133,11 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess
public bool TryRequestGracefulStop()
{
- if (!OperatingSystem.IsLinux())
+ if (!OperatingSystem.IsLinux() || !_supportsConsoleGracefulStop)
{
- // No reliable cross-console mechanism exists for an
- // arbitrary no-window Windows child process — tracked gap,
- // see docs/ISSUES.md.
+ // Windows console-capable children use
+ // WindowsSystemChildProcess. Graphical/non-console children
+ // deliberately retain the Process/WM_CLOSE path.
return false;
}
diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs
index 11b59dee..599a932d 100644
--- a/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs
+++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs
@@ -7,9 +7,13 @@ namespace AcDream.Launcher.Core.Launching;
/// Deliberately carries no credential field — the password is a separate
/// transient parameter to
/// that flows only to the child's stdin, never into this spec, an
-/// argument list, or a process environment.
+/// argument list, or a process environment. Console-capable specs set
+/// so Windows starts them
+/// as isolated process-group leaders for targeted CTRL_BREAK_EVENT and
+/// Linux sends SIGINT; graphical specs leave it false and use WM_CLOSE.
///
public sealed record LauncherProcessSpec(
string ExecutablePath,
IReadOnlyList Arguments,
- string? WorkingDirectory = null);
+ string? WorkingDirectory = null,
+ bool SupportsConsoleGracefulStop = true);
diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs
index 3a4c2923..252847c5 100644
--- a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs
+++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs
@@ -171,8 +171,8 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
///
/// Requests a graceful stop — first
/// (SIGINT
- /// on Linux; a no-op on Windows today, see
- /// 's docs),
+ /// on Linux; targeted CTRL_BREAK_EVENT for supported Windows console
+ /// children),
/// then — falling
/// back to if the process has
/// not exited within . A no-op if
diff --git a/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs b/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs
new file mode 100644
index 00000000..00138c30
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs
@@ -0,0 +1,843 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Text;
+using Microsoft.Win32.SafeHandles;
+
+namespace AcDream.Launcher.Core.Launching;
+
+///
+/// Windows launcher child created without a shell as a true console process-
+/// group leader. The native start is deliberately narrow: it exists only
+/// because does not expose
+/// CREATE_NEW_PROCESS_GROUP while the launcher must retain redirected stdin.
+///
+internal sealed class WindowsSystemChildProcess : ILauncherChildProcess
+{
+ private readonly LauncherProcessSpec _spec;
+ private readonly IWindowsConsoleControl _consoleControl;
+ private Process? _process;
+ private TextWriter? _standardInput;
+ private int _processGroupId;
+ private bool _raisingEnabled;
+
+ internal WindowsSystemChildProcess(
+ LauncherProcessSpec spec,
+ IWindowsConsoleControl? consoleControl = null)
+ {
+ _spec = spec ?? throw new ArgumentNullException(nameof(spec));
+ _consoleControl = consoleControl ?? WindowsConsoleControl.Instance;
+ }
+
+ public bool HasExited => RequireProcess().HasExited;
+
+ public int ExitCode => RequireProcess().ExitCode;
+
+ public TextWriter StandardInput => _standardInput
+ ?? throw new InvalidOperationException("The child process has not started.");
+
+ public event EventHandler? Exited;
+
+ public void Start()
+ {
+ if (_process is not null)
+ {
+ throw new InvalidOperationException("The child process already started.");
+ }
+
+ WindowsProcessStartResult started = WindowsProcessNative.Start(_spec);
+ try
+ {
+ _process = Process.GetProcessById(started.ProcessId);
+ _process.EnableRaisingEvents = true;
+ _process.Exited += OnExited;
+ _raisingEnabled = true;
+ _standardInput = started.TakeStandardInput();
+ _processGroupId = started.ProcessId;
+ started.Resume();
+ }
+ catch
+ {
+ started.Terminate();
+ _standardInput?.Dispose();
+ _standardInput = null;
+ if (_process is not null)
+ {
+ if (_raisingEnabled)
+ {
+ _process.Exited -= OnExited;
+ }
+
+ _process.Dispose();
+ _process = null;
+ }
+
+ throw;
+ }
+ finally
+ {
+ started.Dispose();
+ }
+ }
+
+ public bool TryRequestGracefulStop()
+ {
+ try
+ {
+ if (!_spec.SupportsConsoleGracefulStop
+ || _process is not { HasExited: false } process
+ || _processGroupId <= 0)
+ {
+ return false;
+ }
+
+ return _consoleControl.TrySendBreak(process.Id, _processGroupId);
+ }
+ catch
+ {
+ // The process may have exited between the state check and the
+ // control request. Graceful-stop attempts never escape Stop().
+ return false;
+ }
+ }
+
+ public bool CloseMainWindow() => RequireProcess().CloseMainWindow();
+
+ public void Kill() => RequireProcess().Kill(entireProcessTree: true);
+
+ public bool WaitForExit(TimeSpan timeout) => RequireProcess().WaitForExit(timeout);
+
+ public void Dispose()
+ {
+ _standardInput?.Dispose();
+ _standardInput = null;
+ if (_process is not null)
+ {
+ if (_raisingEnabled)
+ {
+ _process.Exited -= OnExited;
+ }
+
+ _process.Dispose();
+ _process = null;
+ }
+ }
+
+ private Process RequireProcess() => _process
+ ?? throw new InvalidOperationException("The child process has not started.");
+
+ private void OnExited(object? sender, EventArgs e) =>
+ Exited?.Invoke(this, EventArgs.Empty);
+}
+
+internal interface IWindowsConsoleControl
+{
+ bool TrySendBreak(int childProcessId, int childProcessGroupId);
+}
+
+internal sealed class WindowsConsoleControl : IWindowsConsoleControl
+{
+ private const uint CtrlBreakEvent = 1;
+
+ internal static WindowsConsoleControl Instance { get; } = new();
+
+ private WindowsConsoleControl()
+ {
+ }
+
+ public bool TrySendBreak(int childProcessId, int childProcessGroupId)
+ {
+ if (!OperatingSystem.IsWindows()
+ || childProcessId <= 0
+ || childProcessGroupId <= 0)
+ {
+ return false;
+ }
+
+ lock (WindowsConsoleSynchronization.Gate)
+ {
+ bool attachedHere = false;
+ try
+ {
+ uint[] processes = new uint[1];
+ if (Native.GetConsoleProcessList(processes, 1) == 0)
+ {
+ if (!Native.AttachConsole((uint)childProcessId))
+ {
+ return false;
+ }
+
+ attachedHere = true;
+ }
+
+ return Native.GenerateConsoleCtrlEvent(
+ CtrlBreakEvent,
+ (uint)childProcessGroupId);
+ }
+ catch
+ {
+ return false;
+ }
+ finally
+ {
+ if (attachedHere)
+ {
+ _ = Native.FreeConsole();
+ }
+ }
+ }
+ }
+
+ private static class Native
+ {
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static extern bool AttachConsole(uint processId);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static extern bool FreeConsole();
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static extern bool GenerateConsoleCtrlEvent(
+ uint controlEvent,
+ uint processGroupId);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ internal static extern uint GetConsoleProcessList(
+ [Out] uint[] processList,
+ uint processCount);
+ }
+}
+
+///
+/// A process can be attached to only one console. Child creation and targeted
+/// control-event attachment therefore share one process-wide gate.
+///
+internal static class WindowsConsoleSynchronization
+{
+ internal static object Gate { get; } = new();
+}
+
+internal sealed class WindowsProcessStartResult : IDisposable
+{
+ private readonly SafeKernelHandle _processHandle;
+ private readonly SafeKernelHandle _threadHandle;
+ private SafeFileHandle? _standardInput;
+ private bool _resumed;
+
+ internal WindowsProcessStartResult(
+ int processId,
+ SafeKernelHandle processHandle,
+ SafeKernelHandle threadHandle,
+ SafeFileHandle standardInput)
+ {
+ ProcessId = processId;
+ _processHandle = processHandle;
+ _threadHandle = threadHandle;
+ _standardInput = standardInput;
+ }
+
+ internal int ProcessId { get; }
+
+ internal TextWriter TakeStandardInput()
+ {
+ SafeFileHandle handle = _standardInput
+ ?? throw new InvalidOperationException("Standard input was already claimed.");
+ var stream = new FileStream(handle, FileAccess.Write, 4096, isAsync: false);
+ _standardInput = null;
+ try
+ {
+ return new StreamWriter(
+ stream,
+ new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
+ {
+ AutoFlush = true,
+ };
+ }
+ catch
+ {
+ stream.Dispose();
+ throw;
+ }
+ }
+
+ internal void Resume()
+ {
+ if (WindowsProcessNative.ResumeThread(_threadHandle) == uint.MaxValue)
+ {
+ throw new Win32Exception(Marshal.GetLastWin32Error(),
+ "The Windows launcher child could not be resumed.");
+ }
+
+ _resumed = true;
+ }
+
+ internal void Terminate()
+ {
+ if (!_processHandle.IsInvalid)
+ {
+ _ = WindowsProcessNative.TerminateProcess(_processHandle, 74);
+ }
+ }
+
+ public void Dispose()
+ {
+ if (!_resumed)
+ {
+ Terminate();
+ }
+
+ _standardInput?.Dispose();
+ _threadHandle.Dispose();
+ _processHandle.Dispose();
+ }
+}
+
+internal static class WindowsProcessNative
+{
+ private const uint CreateSuspended = 0x00000004;
+ private const uint CreateNewProcessGroup = 0x00000200;
+ private const uint ExtendedStartupInfoPresent = 0x00080000;
+ private const uint StartfUseStdHandles = 0x00000100;
+ private const short SwHide = 0;
+ private const uint HandleFlagInherit = 0x00000001;
+ private const uint DuplicateSameAccess = 0x00000002;
+ private const uint GenericWrite = 0x40000000;
+ private const uint FileShareRead = 0x00000001;
+ private const uint FileShareWrite = 0x00000002;
+ private const uint OpenExisting = 3;
+ private const uint FileAttributeNormal = 0x00000080;
+ private const int StdOutputHandle = -11;
+ private const int StdErrorHandle = -12;
+ private static readonly IntPtr ProcThreadAttributeHandleList = new(0x00020002);
+
+ internal static WindowsProcessStartResult Start(LauncherProcessSpec spec)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(spec.ExecutablePath);
+ ArgumentNullException.ThrowIfNull(spec.Arguments);
+
+ lock (WindowsConsoleSynchronization.Gate)
+ {
+ bool allocatedConsole = false;
+ try
+ {
+ // An Avalonia launcher started from Explorer has no console.
+ // CREATE_NEW_PROCESS_GROUP alone does not allocate one, and a
+ // console-less group cannot receive GenerateConsoleCtrlEvent.
+ // Allocate one only for the creation transaction, hide it,
+ // let the group leader inherit it, then detach the launcher.
+ // Each such child consequently owns a distinct console as
+ // well as a distinct process group.
+ if (!HasConsole())
+ {
+ if (!AllocConsole())
+ {
+ throw new Win32Exception(Marshal.GetLastWin32Error(),
+ "The Windows launcher could not allocate the child console.");
+ }
+
+ allocatedConsole = true;
+ IntPtr consoleWindow = GetConsoleWindow();
+ if (consoleWindow != IntPtr.Zero)
+ {
+ _ = ShowWindow(consoleWindow, SwHide);
+ }
+ }
+
+ return StartCore(spec);
+ }
+ finally
+ {
+ if (allocatedConsole)
+ {
+ _ = FreeConsole();
+ }
+ }
+ }
+ }
+
+ private static WindowsProcessStartResult StartCore(LauncherProcessSpec spec)
+ {
+ SafeFileHandle? parentInput = null;
+ try
+ {
+ using SafeFileHandle childInput = CreateChildInputPipe(
+ out SafeFileHandle createdParentInput);
+ parentInput = createdParentInput;
+ using SafeKernelHandle childOutput = DuplicateOrOpenNull(StdOutputHandle);
+ using SafeKernelHandle childError = DuplicateOrOpenNull(StdErrorHandle);
+ using var attributes = new ProcessThreadAttributeList(
+ childInput.DangerousGetHandle(),
+ childOutput.DangerousGetHandle(),
+ childError.DangerousGetHandle());
+
+ var startup = new StartupInfoEx
+ {
+ StartupInfo = new StartupInfo
+ {
+ Size = Marshal.SizeOf(),
+ Flags = StartfUseStdHandles,
+ StandardInput = childInput.DangerousGetHandle(),
+ StandardOutput = childOutput.DangerousGetHandle(),
+ StandardError = childError.DangerousGetHandle(),
+ },
+ AttributeList = attributes.Pointer,
+ };
+ string executable = ResolveExecutable(spec.ExecutablePath);
+ string commandLineText = BuildCommandLine(executable, spec.Arguments);
+ var commandLine = new StringBuilder(commandLineText, commandLineText.Length + 1);
+ string? workingDirectory = string.IsNullOrWhiteSpace(spec.WorkingDirectory)
+ ? null
+ : Path.GetFullPath(spec.WorkingDirectory);
+
+ if (!CreateProcessW(
+ executable,
+ commandLine,
+ IntPtr.Zero,
+ IntPtr.Zero,
+ inheritHandles: true,
+ CreateSuspended | CreateNewProcessGroup | ExtendedStartupInfoPresent,
+ IntPtr.Zero,
+ workingDirectory,
+ ref startup,
+ out ProcessInformation information))
+ {
+ throw new Win32Exception(Marshal.GetLastWin32Error(),
+ "The Windows launcher child could not be created.");
+ }
+
+ var processHandle = new SafeKernelHandle(
+ information.Process,
+ ownsHandle: true);
+ var threadHandle = new SafeKernelHandle(
+ information.Thread,
+ ownsHandle: true);
+ try
+ {
+ var result = new WindowsProcessStartResult(
+ checked((int)information.ProcessId),
+ processHandle,
+ threadHandle,
+ parentInput);
+ parentInput = null;
+ return result;
+ }
+ catch
+ {
+ _ = TerminateProcess(processHandle, 74);
+ threadHandle.Dispose();
+ processHandle.Dispose();
+ throw;
+ }
+ }
+ finally
+ {
+ parentInput?.Dispose();
+ }
+ }
+
+ private static bool HasConsole()
+ {
+ uint[] processes = new uint[1];
+ return GetConsoleProcessList(processes, 1) != 0;
+ }
+
+ internal static uint ResumeThread(SafeKernelHandle thread) =>
+ NativeResumeThread(thread);
+
+ internal static bool TerminateProcess(SafeKernelHandle process, uint exitCode) =>
+ NativeTerminateProcess(process, exitCode);
+
+ internal static string BuildCommandLine(
+ string executable,
+ IReadOnlyList arguments)
+ {
+ var builder = new StringBuilder();
+ AppendQuotedArgument(builder, executable);
+ foreach (string argument in arguments)
+ {
+ ArgumentNullException.ThrowIfNull(argument);
+ builder.Append(' ');
+ AppendQuotedArgument(builder, argument);
+ }
+
+ return builder.ToString();
+ }
+
+ private static void AppendQuotedArgument(StringBuilder builder, string value)
+ {
+ builder.Append('"');
+ int backslashes = 0;
+ foreach (char character in value)
+ {
+ if (character == '\\')
+ {
+ backslashes++;
+ continue;
+ }
+
+ if (character == '"')
+ {
+ builder.Append('\\', backslashes * 2 + 1);
+ builder.Append('"');
+ backslashes = 0;
+ continue;
+ }
+
+ builder.Append('\\', backslashes);
+ backslashes = 0;
+ builder.Append(character);
+ }
+
+ builder.Append('\\', backslashes * 2);
+ builder.Append('"');
+ }
+
+ private static SafeFileHandle CreateChildInputPipe(out SafeFileHandle parentInput)
+ {
+ var security = new SecurityAttributes
+ {
+ Length = Marshal.SizeOf(),
+ InheritHandle = true,
+ };
+ if (!CreatePipe(out IntPtr read, out IntPtr write, ref security, 0))
+ {
+ throw new Win32Exception(Marshal.GetLastWin32Error(),
+ "The launcher child stdin pipe could not be created.");
+ }
+
+ var child = new SafeFileHandle(read, ownsHandle: true);
+ parentInput = new SafeFileHandle(write, ownsHandle: true);
+ if (!SetHandleInformation(
+ parentInput,
+ HandleFlagInherit,
+ 0))
+ {
+ int error = Marshal.GetLastWin32Error();
+ child.Dispose();
+ parentInput.Dispose();
+ throw new Win32Exception(error,
+ "The launcher child stdin pipe could not be isolated.");
+ }
+
+ return child;
+ }
+
+ private static SafeKernelHandle DuplicateOrOpenNull(int standardHandle)
+ {
+ IntPtr source = GetStdHandle(standardHandle);
+ if (source != IntPtr.Zero && source != new IntPtr(-1))
+ {
+ IntPtr current = GetCurrentProcess();
+ if (DuplicateHandle(
+ current,
+ source,
+ current,
+ out IntPtr duplicate,
+ 0,
+ inheritHandle: true,
+ DuplicateSameAccess))
+ {
+ return new SafeKernelHandle(duplicate, ownsHandle: true);
+ }
+ }
+
+ IntPtr nul = CreateFileW(
+ "NUL",
+ GenericWrite,
+ FileShareRead | FileShareWrite,
+ IntPtr.Zero,
+ OpenExisting,
+ FileAttributeNormal,
+ IntPtr.Zero);
+ if (nul == IntPtr.Zero || nul == new IntPtr(-1))
+ {
+ throw new Win32Exception(Marshal.GetLastWin32Error(),
+ "The launcher child fallback output handle could not be opened.");
+ }
+
+ var handle = new SafeKernelHandle(nul, ownsHandle: true);
+ if (!SetHandleInformation(handle, HandleFlagInherit, HandleFlagInherit))
+ {
+ int error = Marshal.GetLastWin32Error();
+ handle.Dispose();
+ throw new Win32Exception(error,
+ "The launcher child fallback output handle could not be inherited.");
+ }
+
+ return handle;
+ }
+
+ private static string ResolveExecutable(string executable)
+ {
+ if (Path.IsPathFullyQualified(executable))
+ {
+ return Path.GetFullPath(executable);
+ }
+
+ var buffer = new StringBuilder(32_768);
+ uint length = SearchPathW(
+ null,
+ executable,
+ null,
+ (uint)buffer.Capacity,
+ buffer,
+ IntPtr.Zero);
+ if (length == 0 || length >= buffer.Capacity)
+ {
+ throw new Win32Exception(Marshal.GetLastWin32Error(),
+ $"Launcher child executable '{executable}' was not found.");
+ }
+
+ return Path.GetFullPath(buffer.ToString());
+ }
+
+ private sealed class ProcessThreadAttributeList : IDisposable
+ {
+ private IntPtr _pointer;
+ private IntPtr _handles;
+ private bool _initialized;
+
+ internal ProcessThreadAttributeList(params IntPtr[] handles)
+ {
+ nuint size = 0;
+ _ = InitializeProcThreadAttributeList(
+ IntPtr.Zero,
+ 1,
+ 0,
+ ref size);
+ _pointer = Marshal.AllocHGlobal(checked((nint)size));
+ if (!InitializeProcThreadAttributeList(_pointer, 1, 0, ref size))
+ {
+ int error = Marshal.GetLastWin32Error();
+ Dispose();
+ throw new Win32Exception(error,
+ "The launcher child handle list could not be initialized.");
+ }
+ _initialized = true;
+
+ _handles = Marshal.AllocHGlobal(handles.Length * IntPtr.Size);
+ for (int index = 0; index < handles.Length; index++)
+ {
+ Marshal.WriteIntPtr(_handles, index * IntPtr.Size, handles[index]);
+ }
+
+ if (!UpdateProcThreadAttribute(
+ _pointer,
+ 0,
+ ProcThreadAttributeHandleList,
+ _handles,
+ checked((nuint)(handles.Length * IntPtr.Size)),
+ IntPtr.Zero,
+ IntPtr.Zero))
+ {
+ int error = Marshal.GetLastWin32Error();
+ Dispose();
+ throw new Win32Exception(error,
+ "The launcher child inherited-handle list could not be set.");
+ }
+ }
+
+ internal IntPtr Pointer => _pointer;
+
+ public void Dispose()
+ {
+ if (_pointer != IntPtr.Zero)
+ {
+ if (_initialized)
+ {
+ DeleteProcThreadAttributeList(_pointer);
+ _initialized = false;
+ }
+ Marshal.FreeHGlobal(_pointer);
+ _pointer = IntPtr.Zero;
+ }
+
+ if (_handles != IntPtr.Zero)
+ {
+ Marshal.FreeHGlobal(_handles);
+ _handles = IntPtr.Zero;
+ }
+ }
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct SecurityAttributes
+ {
+ internal int Length;
+ internal IntPtr SecurityDescriptor;
+ [MarshalAs(UnmanagedType.Bool)] internal bool InheritHandle;
+ }
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct StartupInfo
+ {
+ internal int Size;
+ internal string? Reserved;
+ internal string? Desktop;
+ internal string? Title;
+ internal int X;
+ internal int Y;
+ internal int XSize;
+ internal int YSize;
+ internal int XCountChars;
+ internal int YCountChars;
+ internal int FillAttribute;
+ internal uint Flags;
+ internal short ShowWindow;
+ internal short Reserved2Size;
+ internal IntPtr Reserved2;
+ internal IntPtr StandardInput;
+ internal IntPtr StandardOutput;
+ internal IntPtr StandardError;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct StartupInfoEx
+ {
+ internal StartupInfo StartupInfo;
+ internal IntPtr AttributeList;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct ProcessInformation
+ {
+ internal IntPtr Process;
+ internal IntPtr Thread;
+ internal uint ProcessId;
+ internal uint ThreadId;
+ }
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CreateProcessW(
+ string applicationName,
+ StringBuilder commandLine,
+ IntPtr processAttributes,
+ IntPtr threadAttributes,
+ [MarshalAs(UnmanagedType.Bool)] bool inheritHandles,
+ uint creationFlags,
+ IntPtr environment,
+ string? currentDirectory,
+ ref StartupInfoEx startupInfo,
+ out ProcessInformation processInformation);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CreatePipe(
+ out IntPtr readPipe,
+ out IntPtr writePipe,
+ ref SecurityAttributes pipeAttributes,
+ uint size);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool SetHandleInformation(
+ SafeHandle handle,
+ uint mask,
+ uint flags);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool AllocConsole();
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool FreeConsole();
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern uint GetConsoleProcessList(
+ [Out] uint[] processList,
+ uint processCount);
+
+ [DllImport("kernel32.dll")]
+ private static extern IntPtr GetConsoleWindow();
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool ShowWindow(IntPtr window, int commandShow);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern IntPtr GetStdHandle(int standardHandle);
+
+ [DllImport("kernel32.dll")]
+ private static extern IntPtr GetCurrentProcess();
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool DuplicateHandle(
+ IntPtr sourceProcess,
+ IntPtr sourceHandle,
+ IntPtr targetProcess,
+ out IntPtr targetHandle,
+ uint desiredAccess,
+ [MarshalAs(UnmanagedType.Bool)] bool inheritHandle,
+ uint options);
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern IntPtr CreateFileW(
+ string fileName,
+ uint desiredAccess,
+ uint shareMode,
+ IntPtr securityAttributes,
+ uint creationDisposition,
+ uint flagsAndAttributes,
+ IntPtr templateFile);
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern uint SearchPathW(
+ string? path,
+ string fileName,
+ string? extension,
+ uint bufferLength,
+ StringBuilder buffer,
+ IntPtr filePart);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool InitializeProcThreadAttributeList(
+ IntPtr attributeList,
+ int attributeCount,
+ int flags,
+ ref nuint size);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool UpdateProcThreadAttribute(
+ IntPtr attributeList,
+ uint flags,
+ IntPtr attribute,
+ IntPtr value,
+ nuint size,
+ IntPtr previousValue,
+ IntPtr returnSize);
+
+ [DllImport("kernel32.dll")]
+ private static extern void DeleteProcThreadAttributeList(IntPtr attributeList);
+
+ [DllImport("kernel32.dll", EntryPoint = "ResumeThread", SetLastError = true)]
+ private static extern uint NativeResumeThread(SafeKernelHandle thread);
+
+ [DllImport("kernel32.dll", EntryPoint = "TerminateProcess", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool NativeTerminateProcess(
+ SafeKernelHandle process,
+ uint exitCode);
+}
+
+internal sealed class SafeKernelHandle : SafeHandleZeroOrMinusOneIsInvalid
+{
+ internal SafeKernelHandle(IntPtr handle, bool ownsHandle)
+ : base(ownsHandle)
+ {
+ SetHandle(handle);
+ }
+
+ protected override bool ReleaseHandle() => CloseHandle(handle);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CloseHandle(IntPtr handle);
+}
diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs
index 4c3ffe16..5a98b795 100644
--- a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs
+++ b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs
@@ -104,7 +104,8 @@ public sealed class LauncherExecutableSet
: new LauncherProcessSpec(
paths.GraphicalHostPath,
["--session-config", configFilePath],
- paths.WorkingDirectory);
+ paths.WorkingDirectory,
+ SupportsConsoleGracefulStop: false);
}
public LauncherProcessSpec CreateProbeSpec(string configFilePath)
diff --git a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs
index c37b51e8..6decb4b9 100644
--- a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs
+++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs
@@ -15,10 +15,9 @@ public static class LauncherSelfUpdateBootstrap
{
public const string HelperArgument = "--acdream-self-update-helper-v1";
public const string ConfirmArgument = "--acdream-self-update-confirm-v1";
- internal const string DeferredArgument = "--acdream-self-update-deferred-v1";
- internal const int DeferredLeaseExitCode = 73;
+ internal const int UpdateLeaseBusyExitCode = 73;
+ private const string InternalArgumentPrefix = "--acdream-self-update-";
private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30);
- private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5);
public static async Task HandleAsync(
string[] args,
@@ -33,12 +32,6 @@ public static class LauncherSelfUpdateBootstrap
Path.GetFullPath(launcherBaseDirectory));
string executable = Path.GetFullPath(currentExecutablePath);
- if (args.Length > 0
- && string.Equals(args[0], DeferredArgument, StringComparison.Ordinal))
- {
- return new SelfUpdateStartupResult(false, 0, args[1..]);
- }
-
if (args.Length > 0
&& string.Equals(args[0], HelperArgument, StringComparison.Ordinal))
{
@@ -55,6 +48,8 @@ public static class LauncherSelfUpdateBootstrap
int exitCode = await RunHelperAsync(
manager,
+ baseDirectory,
+ executable,
parentPid,
args[2],
args[3],
@@ -72,26 +67,72 @@ public static class LauncherSelfUpdateBootstrap
return new SelfUpdateStartupResult(true, 64, []);
}
+ if (manager.Barrier.TryAcquireSession(
+ out UpdateSessionBarrier.SessionLease? unexpectedSharedLease))
+ {
+ unexpectedSharedLease?.Dispose();
+ throw new LauncherUpdateException(
+ "Self-update confirmation is trusted only while its helper owns "
+ + "the exclusive update lease.");
+ }
+
await manager.ConfirmAsync(
args[1],
baseDirectory,
executable,
cancellationToken)
.ConfigureAwait(false);
- await FinishConfirmedCleanupAsync(
- manager,
- baseDirectory,
- cancellationToken)
- .ConfigureAwait(false);
+ // The helper that owns the exclusive lease observes this durable
+ // receipt and performs authoritative completion. A later ordinary
+ // startup also completes it if that helper crashes after receipt.
return new SelfUpdateStartupResult(false, 0, args[2..]);
}
+ if (args.Length > 0
+ && args[0].StartsWith(InternalArgumentPrefix, StringComparison.Ordinal))
+ {
+ // Internal modes are an exact vocabulary. In particular, an old
+ // deferred-restart marker must never become an authorization to
+ // skip a pending recovery state.
+ return new SelfUpdateStartupResult(true, 64, []);
+ }
+
+ // Load first: an invalid/ambiguous journal must fail closed even when
+ // another process currently owns the update barrier.
+ _ = await manager.LoadPendingAsync(cancellationToken).ConfigureAwait(false);
+
if (!manager.Barrier.TryAcquireExclusive(
out UpdateSessionBarrier.ExclusiveLease? startupLease))
{
- // A running session or another launcher is staging. Reading the
- // plan is safe, but cleanup or starting a competing helper is not.
- return new SelfUpdateStartupResult(false, 0, args);
+ if (!manager.Barrier.TryAcquireSession(
+ out UpdateSessionBarrier.SessionLease? sharedLease))
+ {
+ throw new LauncherUpdateException(
+ "Launcher startup is blocked by an active update or recovery transaction.");
+ }
+
+ using (sharedLease
+ ?? throw new InvalidOperationException("Shared startup lease is missing."))
+ {
+ SelfUpdatePlan? blockedPlan = await manager.LoadPendingAsync(cancellationToken)
+ .ConfigureAwait(false);
+ if (blockedPlan is null)
+ {
+ return new SelfUpdateStartupResult(false, 0, args);
+ }
+
+ ValidateCanonicalStartup(blockedPlan, baseDirectory, executable);
+ if (blockedPlan.State != SelfUpdatePlanState.Staged)
+ {
+ throw new LauncherUpdateException(
+ $"Self-update state '{blockedPlan.State}' requires exclusive recovery.");
+ }
+
+ // A verified staged update may wait while an already-running
+ // session holds the shared lease. No helper is spawned, so a
+ // late session lease cannot create a restart loop.
+ return new SelfUpdateStartupResult(false, 0, args);
+ }
}
using (UpdateSessionBarrier.ExclusiveLease lease = startupLease
@@ -108,11 +149,7 @@ public static class LauncherSelfUpdateBootstrap
return new SelfUpdateStartupResult(false, 0, args);
}
- if (!PathsEqual(plan.TargetDirectory, baseDirectory))
- {
- throw new LauncherUpdateException(
- "The pending self-update targets a different launcher directory.");
- }
+ ValidateCanonicalStartup(plan, baseDirectory, executable);
if (plan.State == SelfUpdatePlanState.AwaitingConfirmation)
{
@@ -138,13 +175,40 @@ public static class LauncherSelfUpdateBootstrap
return new SelfUpdateStartupResult(false, 0, args);
}
- string expectedExecutable = ClientVersionStore.ResolveContained(
- baseDirectory,
- GetLauncherFileName(plan.Rid));
- if (!PathsEqual(executable, expectedExecutable))
+ if (plan.State is SelfUpdatePlanState.Applying
+ or SelfUpdatePlanState.RolledBack)
+ {
+ if (plan.State == SelfUpdatePlanState.Applying)
+ {
+ plan = await manager.RecoverApplyingAsync(
+ baseDirectory,
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ if (plan.State != SelfUpdatePlanState.RolledBack)
+ {
+ throw new LauncherUpdateException(
+ "The interrupted self-update did not produce a rollback receipt.");
+ }
+
+ await manager.CompleteRolledBackAsync(
+ plan.TransactionId,
+ baseDirectory,
+ lease,
+ cancellationToken)
+ .ConfigureAwait(false);
+ _ = manager.CleanupOwnedResidueUnderLease(
+ pending: null,
+ baseDirectory,
+ lease);
+ return new SelfUpdateStartupResult(false, 0, args);
+ }
+
+ if (plan.State != SelfUpdatePlanState.Staged)
{
throw new LauncherUpdateException(
- "Self-update can start only from the published acdream-launcher executable.");
+ $"Self-update state '{plan.State}' cannot start a helper.");
}
string helperPath = manager.GetStagedLauncherPath(plan);
@@ -173,6 +237,8 @@ public static class LauncherSelfUpdateBootstrap
private static async Task RunHelperAsync(
LauncherSelfUpdateManager manager,
+ string helperBaseDirectory,
+ string currentExecutablePath,
int parentPid,
string targetDirectory,
string transactionId,
@@ -182,10 +248,11 @@ public static class LauncherSelfUpdateBootstrap
SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken)
.ConfigureAwait(false)
?? throw new LauncherUpdateException("The helper found no pending self-update.");
- if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal))
+ if (plan.State != SelfUpdatePlanState.Staged
+ || !string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal))
{
throw new LauncherUpdateException(
- "The helper transaction does not match the pending self-update.");
+ "The helper mode does not match a staged self-update transaction.");
}
if (!PathsEqual(plan.TargetDirectory, targetDirectory))
@@ -194,6 +261,15 @@ public static class LauncherSelfUpdateBootstrap
"The helper target does not match the pending self-update.");
}
+ string expectedHelperDirectory = manager.GetPayloadDirectory(plan.TransactionId);
+ string expectedHelperPath = manager.GetStagedLauncherPath(plan);
+ if (!PathsEqual(helperBaseDirectory, expectedHelperDirectory)
+ || !PathsEqual(currentExecutablePath, expectedHelperPath))
+ {
+ throw new LauncherUpdateException(
+ "Self-update helper mode is trusted only from the staged launcher payload.");
+ }
+
string launcherPath = ClientVersionStore.ResolveContained(
targetDirectory,
GetLauncherFileName(plan.Rid));
@@ -215,9 +291,10 @@ public static class LauncherSelfUpdateBootstrap
{
// Do not restart the canonical launcher: it would immediately see
// the same staged plan and create an unbounded helper loop.
- return DeferredLeaseExitCode;
+ return UpdateLeaseBusyExitCode;
}
+ ProcessStartInfo? restoredStart = null;
using (UpdateSessionBarrier.ExclusiveLease lease = updateLease
?? throw new InvalidOperationException("Exclusive update lease is missing."))
{
@@ -225,7 +302,8 @@ public static class LauncherSelfUpdateBootstrap
.ConfigureAwait(false)
?? throw new LauncherUpdateException(
"The helper found no pending self-update after acquiring the lease.");
- if (!string.Equals(
+ if (plan.State != SelfUpdatePlanState.Staged
+ || !string.Equals(
plan.TransactionId,
transactionId,
StringComparison.Ordinal)
@@ -315,78 +393,59 @@ public static class LauncherSelfUpdateBootstrap
return 75;
}
- var restored = new ProcessStartInfo(launcherPath)
+ restoredStart = new ProcessStartInfo(launcherPath)
{
UseShellExecute = false,
WorkingDirectory = Path.GetFullPath(targetDirectory),
};
- restored.ArgumentList.Add(DeferredArgument);
foreach (string argument in publicArguments)
{
- restored.ArgumentList.Add(argument);
+ restoredStart.ArgumentList.Add(argument);
}
-
- _ = Process.Start(restored);
- return 74;
}
finally
{
replacement?.Dispose();
}
}
- }
- private static async Task FinishConfirmedCleanupAsync(
- LauncherSelfUpdateManager manager,
- string targetDirectory,
- CancellationToken cancellationToken)
- {
- DateTimeOffset deadline = DateTimeOffset.UtcNow + CleanupTimeout;
- do
+ // Release the helper's exclusive barrier before restarting the
+ // restored canonical launcher. It will observe the durable RolledBack
+ // receipt through the ordinary startup path, re-verify it, finalize
+ // recovery, and continue with no privileged bypass argument.
+ if (restoredStart is null || Process.Start(restoredStart) is null)
{
- cancellationToken.ThrowIfCancellationRequested();
- if (manager.Barrier.TryAcquireExclusive(
- out UpdateSessionBarrier.ExclusiveLease? lease))
- {
- using (UpdateSessionBarrier.ExclusiveLease acquiredLease = lease
- ?? throw new InvalidOperationException(
- "Exclusive cleanup lease is missing."))
- {
- SelfUpdatePlan? pending = await manager.LoadPendingAsync(cancellationToken)
- .ConfigureAwait(false);
- if (pending is
- {
- State: SelfUpdatePlanState.AwaitingConfirmation,
- }
- && manager.IsConfirmed(pending.TransactionId))
- {
- await manager.CompleteConfirmedAsync(
- pending.TransactionId,
- targetDirectory,
- cancellationToken)
- .ConfigureAwait(false);
- pending = null;
- }
-
- if (manager.CleanupOwnedResidueUnderLease(
- pending,
- targetDirectory,
- acquiredLease))
- {
- return;
- }
- }
- }
-
- await Task.Delay(50, cancellationToken).ConfigureAwait(false);
+ return 75;
}
- while (DateTimeOffset.UtcNow < deadline);
+
+ return 74;
}
private static string GetLauncherFileName(string rid) =>
"acdream-launcher"
+ (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty);
+ private static void ValidateCanonicalStartup(
+ SelfUpdatePlan plan,
+ string baseDirectory,
+ string executable)
+ {
+ if (!PathsEqual(plan.TargetDirectory, baseDirectory))
+ {
+ throw new LauncherUpdateException(
+ "The pending self-update targets a different launcher directory.");
+ }
+
+ string expectedExecutable = ClientVersionStore.ResolveContained(
+ baseDirectory,
+ GetLauncherFileName(plan.Rid));
+ if (!PathsEqual(executable, expectedExecutable))
+ {
+ throw new LauncherUpdateException(
+ "Self-update can run only from the published acdream-launcher executable.");
+ }
+ }
+
private static async Task WaitForParentExitAsync(
int parentPid,
CancellationToken cancellationToken)
diff --git a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs
index 4d36a2bb..e5f1d467 100644
--- a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs
+++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs
@@ -483,6 +483,39 @@ public sealed class LauncherSelfUpdateManager
SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId));
}
+ ///
+ /// Finalizes a durable rollback only after the prior owned launcher set
+ /// has been freshly re-verified while the caller holds the update
+ /// barrier. A failed self-update is abandoned rather than silently
+ /// re-staged, so an ordinary restart cannot enter an automatic retry
+ /// loop.
+ ///
+ internal async Task CompleteRolledBackAsync(
+ string transactionId,
+ string expectedTargetDirectory,
+ UpdateSessionBarrier.ExclusiveLease lease,
+ CancellationToken cancellationToken = default)
+ {
+ Barrier.RequireOwned(lease);
+ string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
+ SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
+ .ConfigureAwait(false)
+ ?? throw new LauncherUpdateException("There is no rolled-back self-update.");
+ ValidatePlan(plan, expectedTarget);
+ if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal)
+ || plan.State != SelfUpdatePlanState.RolledBack)
+ {
+ throw new LauncherUpdateException(
+ "The self-update does not have the expected rollback receipt.");
+ }
+
+ await VerifyRestoredPriorAsync(plan, expectedTarget, cancellationToken)
+ .ConfigureAwait(false);
+ File.Delete(PendingPlanPath);
+ SafeZipExtractor.TryDeleteDirectory(GetTargetTransactionDirectory(plan));
+ SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId));
+ }
+
public async Task RollbackAwaitingConfirmationAsync(
string expectedTargetDirectory,
CancellationToken cancellationToken = default)
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.Core/Updates/UpdateSessionBarrier.cs b/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs
index 1ea640fb..fbb21197 100644
--- a/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs
+++ b/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs
@@ -28,6 +28,43 @@ public sealed class UpdateSessionBarrier
return new SessionLease(stream);
}
+ ///
+ /// Non-blocking shared-lease probe used only by launcher startup after an
+ /// exclusive probe observed contention. Success proves that no updater
+ /// owns the exclusive lease at that instant; permission and path failures
+ /// remain hard errors.
+ ///
+ public bool TryAcquireSession(out SessionLease? lease)
+ {
+ Directory.CreateDirectory(
+ Path.GetDirectoryName(_lockPath)
+ ?? throw new InvalidOperationException(
+ "The update/session lock path has no parent directory."));
+ try
+ {
+ lease = new SessionLease(
+ new FileStream(
+ _lockPath,
+ FileMode.OpenOrCreate,
+ FileAccess.ReadWrite,
+ FileShare.ReadWrite,
+ bufferSize: 1,
+ FileOptions.None));
+ return true;
+ }
+ catch (IOException)
+ {
+ lease = null;
+ return false;
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ throw new LauncherUpdateException(
+ $"The update/session lease could not be opened: {ex.Message}",
+ ex);
+ }
+ }
+
public ExclusiveLease AcquireExclusive()
{
FileStream stream = Open(
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
new file mode 100644
index 00000000..b3364cbc
--- /dev/null
+++ b/src/AcDream.Launcher/LauncherStartupOptions.cs
@@ -0,0 +1,255 @@
+using AcDream.Launcher.Core.Updates;
+using AcDream.Platform;
+
+namespace AcDream.Launcher;
+
+internal enum LauncherStartupMode
+{
+ Desktop,
+ VerifyPublish,
+ SelfUpdateHelper,
+ SelfUpdateConfirmation,
+}
+
+///
+/// Immutable, process-local launcher inputs. Parsing happens before any
+/// launcher owner is constructed so every owner receives the same exact path
+/// set and the test-feed URI can reach only the updater composition.
+///
+internal sealed class LauncherStartupOptions
+{
+ private readonly IReadOnlyList _publicArguments;
+
+ private LauncherStartupOptions(
+ LauncherStartupMode mode,
+ ApplicationPathSet paths,
+ Uri updateManifestUri,
+ IReadOnlyList publicArguments)
+ {
+ Mode = mode;
+ Paths = paths;
+ UpdateManifestUri = updateManifestUri;
+ _publicArguments = Array.AsReadOnly(publicArguments.ToArray());
+ }
+
+ internal LauncherStartupMode Mode { get; }
+
+ internal ApplicationPathSet Paths { get; }
+
+ internal Uri UpdateManifestUri { get; }
+
+ ///
+ /// The validated public option suffix. LA10 passes this suffix through its
+ /// helper and confirmation processes so an isolated self-update cannot
+ /// fall back to canonical user roots or the production feed.
+ ///
+ internal IReadOnlyList PublicArguments => _publicArguments;
+
+ internal static LauncherStartupOptions Parse(
+ IReadOnlyList arguments,
+ Func? resolveDefaultPaths = null)
+ {
+ ArgumentNullException.ThrowIfNull(arguments);
+ resolveDefaultPaths ??= () => ApplicationPathSet.Resolve();
+
+ (LauncherStartupMode mode, int publicStart) = ReadMode(arguments);
+ string[] publicArguments = arguments.Skip(publicStart).ToArray();
+
+ if (publicArguments.Contains("--verify-publish", StringComparer.Ordinal))
+ {
+ if (mode != LauncherStartupMode.Desktop
+ || publicArguments.Length != 1
+ || !string.Equals(
+ publicArguments[0],
+ "--verify-publish",
+ StringComparison.Ordinal))
+ {
+ throw new LauncherStartupOptionsException(
+ "--verify-publish must be the only launcher argument.");
+ }
+
+ return new LauncherStartupOptions(
+ LauncherStartupMode.VerifyPublish,
+ // The publish probe returns before this value is observed. A
+ // non-resolving sentinel keeps the probe display- and
+ // user-profile-free even under a deliberately broken runtime.
+ new ApplicationPathSet(string.Empty, string.Empty, string.Empty, null),
+ ReleaseManifestClient.ProductionManifestUri,
+ publicArguments);
+ }
+
+ string? configDirectory = null;
+ string? dataDirectory = null;
+ string? cacheDirectory = null;
+ Uri? updateManifestUri = null;
+
+ for (int index = 0; index < publicArguments.Length; index += 2)
+ {
+ string name = publicArguments[index];
+ if (index + 1 >= publicArguments.Length
+ || publicArguments[index + 1].StartsWith("--", StringComparison.Ordinal))
+ {
+ throw new LauncherStartupOptionsException(
+ $"Launcher option '{name}' requires a value.");
+ }
+
+ string value = publicArguments[index + 1];
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new LauncherStartupOptionsException(
+ $"Launcher option '{name}' requires a non-empty value.");
+ }
+
+ switch (name)
+ {
+ case "--config-dir":
+ SetDirectoryOnce(ref configDirectory, value, name);
+ break;
+ case "--data-dir":
+ SetDirectoryOnce(ref dataDirectory, value, name);
+ break;
+ case "--cache-dir":
+ SetDirectoryOnce(ref cacheDirectory, value, name);
+ break;
+ case "--update-manifest-uri":
+ if (updateManifestUri is not null)
+ {
+ throw new LauncherStartupOptionsException(
+ "Launcher options cannot be repeated.");
+ }
+
+ if (!Uri.TryCreate(value, UriKind.Absolute, out Uri? parsed))
+ {
+ throw new LauncherStartupOptionsException(
+ "--update-manifest-uri must be an absolute URI.");
+ }
+
+ if (parsed.Scheme != Uri.UriSchemeHttps
+ && !(parsed.Scheme == Uri.UriSchemeHttp && parsed.IsLoopback))
+ {
+ throw new LauncherStartupOptionsException(
+ "The update manifest URI must use HTTPS "
+ + "(loopback HTTP is test-only).");
+ }
+
+ if (!string.IsNullOrEmpty(parsed.UserInfo))
+ {
+ throw new LauncherStartupOptionsException(
+ "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:
+ throw new LauncherStartupOptionsException(
+ $"Unknown launcher option '{name}'.");
+ }
+ }
+
+ int suppliedRoots = new[] { configDirectory, dataDirectory, cacheDirectory }
+ .Count(path => path is not null);
+ if (suppliedRoots is > 0 and < 3)
+ {
+ throw new LauncherStartupOptionsException(
+ "--config-dir, --data-dir, and --cache-dir must be supplied together.");
+ }
+
+ ApplicationPathSet paths = suppliedRoots == 3
+ ? new ApplicationPathSet(
+ configDirectory!,
+ dataDirectory!,
+ cacheDirectory!,
+ LegacyConfigDirectory: null)
+ : resolveDefaultPaths();
+ return new LauncherStartupOptions(
+ mode,
+ paths,
+ updateManifestUri ?? ReleaseManifestClient.ProductionManifestUri,
+ publicArguments);
+ }
+
+ private static (LauncherStartupMode Mode, int PublicStart) ReadMode(
+ IReadOnlyList arguments)
+ {
+ if (arguments.Count == 0)
+ {
+ return (LauncherStartupMode.Desktop, 0);
+ }
+
+ if (string.Equals(
+ arguments[0],
+ LauncherSelfUpdateBootstrap.HelperArgument,
+ StringComparison.Ordinal))
+ {
+ // Malformed internal invocations are rejected by the bootstrap
+ // with EX_USAGE. Do not reinterpret their operands as public
+ // options while resolving the manager they need to report that.
+ return (
+ LauncherStartupMode.SelfUpdateHelper,
+ arguments.Count >= 4 ? 4 : arguments.Count);
+ }
+
+ if (string.Equals(
+ arguments[0],
+ LauncherSelfUpdateBootstrap.ConfirmArgument,
+ StringComparison.Ordinal))
+ {
+ return (
+ LauncherStartupMode.SelfUpdateConfirmation,
+ arguments.Count >= 2 ? 2 : arguments.Count);
+ }
+
+ return (LauncherStartupMode.Desktop, 0);
+ }
+
+ private static void SetDirectoryOnce(
+ ref string? destination,
+ string value,
+ string option)
+ {
+ if (destination is not null)
+ {
+ throw new LauncherStartupOptionsException(
+ "Launcher options cannot be repeated.");
+ }
+
+ if (!Path.IsPathFullyQualified(value))
+ {
+ throw new LauncherStartupOptionsException(
+ $"Launcher option '{option}' must be an absolute path.");
+ }
+
+ try
+ {
+ destination = Path.TrimEndingDirectorySeparator(Path.GetFullPath(value));
+ }
+ catch (Exception ex) when (ex is ArgumentException
+ or IOException
+ or NotSupportedException)
+ {
+ throw new LauncherStartupOptionsException(
+ $"Launcher option '{option}' is not a valid absolute path.",
+ ex);
+ }
+ }
+}
+
+internal sealed class LauncherStartupOptionsException : Exception
+{
+ internal LauncherStartupOptionsException(string message)
+ : base(message)
+ {
+ }
+
+ internal LauncherStartupOptionsException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
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.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj
new file mode 100644
index 00000000..e5c9a2c7
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj
@@ -0,0 +1,10 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ latest
+ true
+
+
diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs
new file mode 100644
index 00000000..dc047cdf
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs
@@ -0,0 +1,54 @@
+using System.Text.Json;
+
+if (args.Length < 4
+ || args[0] != "wait-for-break"
+ || string.IsNullOrWhiteSpace(args[1])
+ || string.IsNullOrWhiteSpace(args[2]))
+{
+ return 64;
+}
+
+string readyPath = Path.GetFullPath(args[1]);
+string breakPath = Path.GetFullPath(args[2]);
+string label = args[3];
+string[] payloadArguments = args.Skip(4).ToArray();
+using var stopped = new ManualResetEventSlim(false);
+ConsoleCancelEventHandler handler = (_, eventArgs) =>
+{
+ if (eventArgs.SpecialKey != ConsoleSpecialKey.ControlBreak)
+ {
+ return;
+ }
+
+ eventArgs.Cancel = true;
+ try
+ {
+ File.WriteAllText(breakPath, label);
+ }
+ finally
+ {
+ stopped.Set();
+ }
+};
+Console.CancelKeyPress += handler;
+try
+{
+ string stdin = Console.In.ReadToEnd();
+ Directory.CreateDirectory(Path.GetDirectoryName(readyPath)!);
+ File.WriteAllText(
+ readyPath,
+ JsonSerializer.Serialize(new
+ {
+ processId = Environment.ProcessId,
+ label,
+ arguments = payloadArguments,
+ stdinLength = stdin.Length,
+ stdinLineCount = stdin.Count(character => character == '\n'),
+ }));
+
+ return stopped.Wait(TimeSpan.FromSeconds(30)) ? 0 : 75;
+}
+finally
+{
+ Console.CancelKeyPress -= handler;
+}
diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj
new file mode 100644
index 00000000..441bc38a
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj
@@ -0,0 +1,14 @@
+
+
+ WinExe
+ net10.0
+ enable
+ enable
+ latest
+ true
+ true
+
+
+
+
+
diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/Program.cs
new file mode 100644
index 00000000..22ad9230
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/Program.cs
@@ -0,0 +1,135 @@
+using System.Runtime.InteropServices;
+using System.Text.Json;
+using AcDream.Launcher.Core.Launching;
+
+if (args.Length != 3)
+{
+ return 64;
+}
+
+string resultPath = Path.GetFullPath(args[0]);
+string dotnetPath = args[1];
+string childAssembly = Path.GetFullPath(args[2]);
+string root = Path.Combine(
+ Path.GetDirectoryName(resultPath)!,
+ "consoleless-children");
+Directory.CreateDirectory(root);
+string targetReady = Path.Combine(root, "target.ready.json");
+string targetBreak = Path.Combine(root, "target.break");
+string siblingReady = Path.Combine(root, "sibling.ready.json");
+string siblingBreak = Path.Combine(root, "sibling.break");
+
+bool parentHadConsoleBefore = HasConsole();
+using var target = new LauncherProcessSupervisor();
+using var sibling = new LauncherProcessSupervisor();
+try
+{
+ target.Start(
+ Spec(dotnetPath, childAssembly, targetReady, targetBreak, "target"),
+ "stdin-from-consoleless-parent");
+ sibling.Start(
+ Spec(dotnetPath, childAssembly, siblingReady, siblingBreak, "sibling"),
+ password: null);
+ WaitForFile(targetReady, target);
+ WaitForFile(siblingReady, sibling);
+ bool parentHadConsoleAfterStarts = HasConsole();
+
+ target.Stop(TimeSpan.FromSeconds(10));
+ bool siblingUnaffected = sibling.State != LauncherSessionState.Exited
+ && !File.Exists(siblingBreak);
+ sibling.Stop(TimeSpan.FromSeconds(10));
+
+ Directory.CreateDirectory(Path.GetDirectoryName(resultPath)!);
+ File.WriteAllText(
+ resultPath,
+ JsonSerializer.Serialize(new
+ {
+ parentHadConsoleBefore,
+ parentHadConsoleAfterStarts,
+ targetExitCode = target.ExitCode,
+ siblingExitCode = sibling.ExitCode,
+ targetBreakObserved = File.Exists(targetBreak),
+ siblingBreakObserved = File.Exists(siblingBreak),
+ siblingUnaffected,
+ }));
+ return 0;
+}
+catch (Exception error)
+{
+ Directory.CreateDirectory(Path.GetDirectoryName(resultPath)!);
+ File.WriteAllText(
+ resultPath,
+ JsonSerializer.Serialize(new
+ {
+ parentHadConsoleBefore,
+ error = error.GetType().Name + ": " + error.Message,
+ }));
+ return 1;
+}
+finally
+{
+ ForceStop(target);
+ ForceStop(sibling);
+}
+
+static LauncherProcessSpec Spec(
+ string dotnetPath,
+ string childAssembly,
+ string ready,
+ string breakMarker,
+ string label) =>
+ new(
+ dotnetPath,
+ [
+ childAssembly,
+ "wait-for-break",
+ ready,
+ breakMarker,
+ label,
+ "argument with spaces",
+ ]);
+
+static void WaitForFile(string path, LauncherProcessSupervisor supervisor)
+{
+ DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
+ while (!File.Exists(path))
+ {
+ if (supervisor.State == LauncherSessionState.Exited)
+ {
+ throw new InvalidOperationException(
+ $"Child exited early with {supervisor.ExitCode}.");
+ }
+
+ if (DateTime.UtcNow >= deadline)
+ {
+ throw new TimeoutException("Child did not become ready.");
+ }
+
+ Thread.Sleep(20);
+ }
+}
+
+static void ForceStop(LauncherProcessSupervisor supervisor)
+{
+ try
+ {
+ supervisor.Stop(TimeSpan.Zero);
+ }
+ catch
+ {
+ }
+}
+
+static bool HasConsole()
+{
+ uint[] processes = new uint[1];
+ return Native.GetConsoleProcessList(processes, 1) != 0;
+}
+
+internal static partial class Native
+{
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ internal static partial uint GetConsoleProcessList(
+ [Out] uint[] processList,
+ uint processCount);
+}
diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs
index 8e3f45ac..f847916b 100644
--- a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs
+++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs
@@ -31,7 +31,7 @@ if (!string.IsNullOrWhiteSpace(selfUpdateData)
SelfUpdateStartupResult startup = await LauncherSelfUpdateBootstrap.HandleAsync(
effectiveArgs,
manager,
- Path.GetFullPath(selfUpdateTarget),
+ Path.GetFullPath(AppContext.BaseDirectory),
Path.GetFullPath(
Environment.ProcessPath
?? throw new InvalidOperationException("Process path is unavailable.")));
@@ -53,6 +53,8 @@ return effectiveArgs.FirstOrDefault() switch
"stage-self-update" => await StageSelfUpdateAsync(effectiveArgs[1..]),
"bootstrap-probe" => await BootstrapProbeAsync(effectiveArgs[1..]),
"canonical-probe" => CanonicalProbe(effectiveArgs[1..]),
+ "hold-campaign-la-process" =>
+ await HoldCampaignLaProcessAsync(effectiveArgs[1..]),
_ => 2,
};
@@ -60,7 +62,7 @@ static bool IsBootstrapInvocation(string[] arguments) =>
arguments.Length > 0
&& arguments[0] is LauncherSelfUpdateBootstrap.HelperArgument
or LauncherSelfUpdateBootstrap.ConfirmArgument
- or LauncherSelfUpdateBootstrap.DeferredArgument
+ or "--acdream-self-update-deferred-v1"
or "canonical-probe";
static ApplicationPathSet Paths(string dataDirectory)
@@ -160,18 +162,51 @@ 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;
+}
+
+static async Task HoldCampaignLaProcessAsync(string[] arguments)
+{
+ if (arguments.Length != 4
+ || arguments[0] is not ("--config" or "--session-config"))
+ {
+ return 2;
+ }
+
+ string configPath = Path.GetFullPath(arguments[1]);
+ string readyPath = Path.GetFullPath(arguments[2]);
+ string releasePath = Path.GetFullPath(arguments[3]);
+ if (!File.Exists(configPath))
+ {
+ return 3;
+ }
+
+ File.WriteAllText(
+ readyPath,
+ Environment.ProcessId.ToString(
+ System.Globalization.CultureInfo.InvariantCulture));
+ while (!File.Exists(releasePath))
+ {
+ await Task.Delay(10);
+ }
+
return 0;
}
diff --git a/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj
index 78fa3e4f..f1677cc9 100644
--- a/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj
+++ b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj
@@ -25,5 +25,17 @@
false
true
+
+
+ false
+ true
+
+
+
+ false
+ true
+
diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs
index 4ec1431e..ca55ff60 100644
--- a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs
+++ b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs
@@ -1,4 +1,6 @@
using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Text.Json;
using System.Threading;
using AcDream.Launcher.Core.Launching;
@@ -6,6 +8,27 @@ namespace AcDream.Launcher.Core.Tests.Launching;
public sealed class LauncherProcessSupervisorTests
{
+ [Fact]
+ public void WindowsFactoryUsesNativeProcessGroupsOnlyForConsoleChildren()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ var factory = new SystemChildProcessFactory();
+ using ILauncherChildProcess console = factory.Create(
+ new LauncherProcessSpec("headless.exe", []));
+ using ILauncherChildProcess graphical = factory.Create(
+ new LauncherProcessSpec(
+ "graphical.exe",
+ [],
+ SupportsConsoleGracefulStop: false));
+
+ Assert.IsType(console);
+ Assert.IsType(graphical);
+ }
+
[Fact]
public void StartWritesPasswordThenClosesStdinAndTransitionsToRunning()
{
@@ -197,6 +220,158 @@ public sealed class LauncherProcessSupervisorTests
}
}
+ [Fact]
+ public async Task WindowsCtrlBreakStopsOnlyTheTargetProcessGroupWithoutKill()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ string root = Path.Combine(
+ Path.GetTempPath(),
+ "acdream-la11-ctrl-break",
+ Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ var targetFactory = new RecordingRealChildProcessFactory();
+ var siblingFactory = new RecordingRealChildProcessFactory();
+ using var target = new LauncherProcessSupervisor(targetFactory);
+ using var sibling = new LauncherProcessSupervisor(siblingFactory);
+ string targetReady = Path.Combine(root, "target.ready.json");
+ string targetBreak = Path.Combine(root, "target.break");
+ string siblingReady = Path.Combine(root, "sibling.ready.json");
+ string siblingBreak = Path.Combine(root, "sibling.break");
+ string[] exactArguments =
+ [
+ "plain",
+ "contains spaces",
+ "quoted-\"value",
+ "ends-with-backslash\\",
+ string.Empty,
+ ];
+
+ try
+ {
+ target.Start(
+ ConsoleFixtureSpec(
+ targetReady,
+ targetBreak,
+ "target",
+ exactArguments),
+ "fixture-input");
+ sibling.Start(
+ ConsoleFixtureSpec(
+ siblingReady,
+ siblingBreak,
+ "sibling",
+ ["sibling"]),
+ password: null);
+ await WaitForFileAsync(targetReady, targetFactory.LastCreated!);
+ await WaitForFileAsync(siblingReady, siblingFactory.LastCreated!);
+
+ using (JsonDocument ready = JsonDocument.Parse(
+ await File.ReadAllTextAsync(targetReady)))
+ {
+ string[] observed = ready.RootElement
+ .GetProperty("arguments")
+ .EnumerateArray()
+ .Select(value => value.GetString()!)
+ .ToArray();
+ Assert.Equal(exactArguments, observed);
+ Assert.Equal(
+ "fixture-input\n".Length,
+ ready.RootElement.GetProperty("stdinLength").GetInt32());
+ Assert.Equal(
+ 1,
+ ready.RootElement.GetProperty("stdinLineCount").GetInt32());
+ }
+
+ target.Stop(TimeSpan.FromSeconds(10));
+
+ Assert.Equal(0, target.ExitCode);
+ Assert.True(File.Exists(targetBreak),
+ "the target fixture did not observe CTRL_BREAK");
+ Assert.Equal(0, targetFactory.LastCreated!.KillCallCount);
+ Assert.False(siblingFactory.LastCreated!.HasExited);
+ Assert.False(File.Exists(siblingBreak),
+ "CTRL_BREAK spilled into the sibling process group");
+
+ sibling.Stop(TimeSpan.FromSeconds(10));
+ Assert.Equal(0, sibling.ExitCode);
+ Assert.True(File.Exists(siblingBreak));
+ Assert.Equal(0, siblingFactory.LastCreated!.KillCallCount);
+ }
+ finally
+ {
+ targetFactory.LastCreated?.ForceCleanup();
+ siblingFactory.LastCreated?.ForceCleanup();
+ try
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ catch (IOException)
+ {
+ }
+ }
+ }
+
+ [Fact]
+ public async Task WindowsConsolelessParentStillTargetsDistinctChildProcessGroups()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ string root = Path.Combine(
+ Path.GetTempPath(),
+ "acdream-la11-consoleless-parent",
+ Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ string resultPath = Path.Combine(root, "result.json");
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = GetConsolelessParentFixturePath(),
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+ startInfo.ArgumentList.Add(resultPath);
+ startInfo.ArgumentList.Add(FindDotnetExecutable());
+ startInfo.ArgumentList.Add(GetConsoleFixturePath());
+
+ try
+ {
+ using Process process = Process.Start(startInfo)
+ ?? throw new InvalidOperationException(
+ "The consoleless supervisor fixture did not start.");
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(40));
+ await process.WaitForExitAsync(timeout.Token);
+
+ Assert.True(File.Exists(resultPath),
+ "the consoleless supervisor fixture did not write its result");
+ using JsonDocument result = JsonDocument.Parse(
+ await File.ReadAllTextAsync(resultPath));
+ Assert.Equal(0, process.ExitCode);
+ Assert.False(result.RootElement.GetProperty("parentHadConsoleBefore").GetBoolean());
+ Assert.False(result.RootElement.GetProperty("parentHadConsoleAfterStarts").GetBoolean());
+ Assert.Equal(0, result.RootElement.GetProperty("targetExitCode").GetInt32());
+ Assert.Equal(0, result.RootElement.GetProperty("siblingExitCode").GetInt32());
+ Assert.True(result.RootElement.GetProperty("targetBreakObserved").GetBoolean());
+ Assert.True(result.RootElement.GetProperty("siblingBreakObserved").GetBoolean());
+ Assert.True(result.RootElement.GetProperty("siblingUnaffected").GetBoolean());
+ }
+ finally
+ {
+ try
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ catch (IOException)
+ {
+ }
+ }
+ }
+
[Fact]
public void StartKillsAndDisposesTheChildWhenFeedingStdinThrowsAfterTheProcessHasStarted()
{
@@ -371,6 +546,22 @@ public sealed class LauncherProcessSupervisorTests
private static LauncherProcessSpec Spec() =>
new("fake-host", ["--session-config", "session.json"]);
+ private static LauncherProcessSpec ConsoleFixtureSpec(
+ string ready,
+ string breakMarker,
+ string label,
+ IReadOnlyList exactArguments) =>
+ new(
+ FindDotnetExecutable(),
+ [
+ GetConsoleFixturePath(),
+ "wait-for-break",
+ ready,
+ breakMarker,
+ label,
+ .. exactArguments,
+ ]);
+
private static string FindDotnetExecutable() =>
// PATH-based resolution: .NET Core's Process.Start searches PATH
// for a bare filename when UseShellExecute is false, on both
@@ -378,6 +569,133 @@ public sealed class LauncherProcessSupervisorTests
// because this test is itself running under `dotnet test`.
OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet";
+ private static string GetConsoleFixturePath()
+ {
+ string configuration = new DirectoryInfo(AppContext.BaseDirectory)
+ .Parent?.Name ?? "Release";
+ return Path.Combine(
+ FindRepositoryRoot(),
+ "tests",
+ "AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild",
+ "bin",
+ configuration,
+ "net10.0",
+ "AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.dll");
+ }
+
+ private static string GetConsolelessParentFixturePath()
+ {
+ string configuration = new DirectoryInfo(AppContext.BaseDirectory)
+ .Parent?.Name ?? "Release";
+ return Path.Combine(
+ FindRepositoryRoot(),
+ "tests",
+ "AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent",
+ "bin",
+ configuration,
+ "net10.0",
+ "AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.exe");
+ }
+
+ private static string FindRepositoryRoot()
+ {
+ for (var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ directory is not null;
+ directory = directory.Parent)
+ {
+ if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
+ {
+ return directory.FullName;
+ }
+ }
+
+ throw new InvalidOperationException("Repository root was not found.");
+ }
+
+ private static async Task WaitForFileAsync(
+ string path,
+ RecordingChildProcess child)
+ {
+ DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10);
+ while (!File.Exists(path))
+ {
+ if (child.HasExited)
+ {
+ throw new InvalidOperationException(
+ $"Console fixture exited early with {child.ExitCode}.");
+ }
+
+ if (DateTimeOffset.UtcNow >= deadline)
+ {
+ throw new TimeoutException("Console fixture did not become ready.");
+ }
+
+ await Task.Delay(20);
+ }
+ }
+
+ private sealed class RecordingRealChildProcessFactory : ILauncherChildProcessFactory
+ {
+ private readonly SystemChildProcessFactory _inner = new();
+
+ internal RecordingChildProcess? LastCreated { get; private set; }
+
+ public ILauncherChildProcess Create(LauncherProcessSpec spec)
+ {
+ LastCreated = new RecordingChildProcess(_inner.Create(spec));
+ return LastCreated;
+ }
+ }
+
+ private sealed class RecordingChildProcess(ILauncherChildProcess inner)
+ : ILauncherChildProcess
+ {
+ public int KillCallCount { get; private set; }
+
+ public bool HasExited => inner.HasExited;
+
+ public int ExitCode => inner.ExitCode;
+
+ public TextWriter StandardInput => inner.StandardInput;
+
+ public event EventHandler? Exited
+ {
+ add => inner.Exited += value;
+ remove => inner.Exited -= value;
+ }
+
+ public void Start() => inner.Start();
+
+ public bool TryRequestGracefulStop() => inner.TryRequestGracefulStop();
+
+ public bool CloseMainWindow() => inner.CloseMainWindow();
+
+ public void Kill()
+ {
+ KillCallCount++;
+ inner.Kill();
+ }
+
+ public bool WaitForExit(TimeSpan timeout) => inner.WaitForExit(timeout);
+
+ public void ForceCleanup()
+ {
+ try
+ {
+ if (!HasExited)
+ {
+ inner.Kill();
+ _ = inner.WaitForExit(TimeSpan.FromSeconds(5));
+ }
+ }
+ catch
+ {
+ }
+ }
+
+ public void Dispose() => inner.Dispose();
+ }
+
private sealed class FakeChildProcessFactory(
bool exitsWithinStopTimeout,
bool exitDuringStart = false,
diff --git a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs
index 3d146ffd..a8442f65 100644
--- a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs
+++ b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs
@@ -44,6 +44,16 @@ public sealed class LauncherExecutableSetTests : IDisposable
Assert.Equal(
headless,
set.CreateProbeSpec("session.json").ExecutablePath);
+ Assert.False(
+ set.CreatePlaySpec(LaunchMode.Gui, "session.json")
+ .SupportsConsoleGracefulStop);
+ Assert.False(
+ set.CreatePlaySpec(LaunchMode.GuiSelect, "session.json")
+ .SupportsConsoleGracefulStop);
+ Assert.True(
+ set.CreatePlaySpec(LaunchMode.Headless, "session.json")
+ .SupportsConsoleGracefulStop);
+ Assert.True(set.CreateProbeSpec("session.json").SupportsConsoleGracefulStop);
}
[Fact]
diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs
index e2c9b4b3..4463dd1a 100644
--- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs
+++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs
@@ -279,27 +279,244 @@ 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(
+ ["--acdream-self-update-deferred-v1", .. publicArguments],
+ harness.Manager,
+ harness.Target,
+ harness.LauncherPath);
+ Assert.True(deferred.ShouldExit);
+ Assert.Equal(64, deferred.ExitCode);
+ Assert.Empty(deferred.RemainingArguments);
_ = await harness.StageAsync();
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
- SelfUpdateStartupResult confirmation = await LauncherSelfUpdateBootstrap.HandleAsync(
- [LauncherSelfUpdateBootstrap.ConfirmArgument, applied.TransactionId],
+ SelfUpdateStartupResult confirmation;
+ using (UpdateSessionBarrier.ExclusiveLease helperLease =
+ harness.Manager.Barrier.AcquireExclusive())
+ {
+ confirmation = await LauncherSelfUpdateBootstrap.HandleAsync(
+ [
+ LauncherSelfUpdateBootstrap.ConfirmArgument,
+ applied.TransactionId,
+ .. publicArguments,
+ ],
+ harness.Manager,
+ harness.Target,
+ harness.LauncherPath);
+ }
+
+ Assert.False(confirmation.ShouldExit);
+ Assert.Equal(publicArguments, confirmation.RemainingArguments);
+ Assert.True(File.Exists(harness.Manager.PendingPlanPath));
+ Assert.True(harness.Manager.IsConfirmed(applied.TransactionId));
+ await harness.Manager.CompleteConfirmedAsync(applied.TransactionId, harness.Target);
+ Assert.False(File.Exists(harness.Manager.PendingPlanPath));
+ }
+
+ [Fact]
+ public async Task ContendedOrdinaryStartupAllowsOnlyNoPlanOrValidatedStagedPlan()
+ {
+ using var harness = new Harness(_root);
+ using (UpdateSessionBarrier.SessionLease session =
+ harness.Manager.Barrier.AcquireSession())
+ {
+ SelfUpdateStartupResult empty = await LauncherSelfUpdateBootstrap.HandleAsync(
+ ["ordinary"],
+ harness.Manager,
+ harness.Target,
+ harness.LauncherPath);
+ Assert.False(empty.ShouldExit);
+ }
+
+ _ = await harness.StageAsync();
+ using (UpdateSessionBarrier.SessionLease session =
+ harness.Manager.Barrier.AcquireSession())
+ {
+ SelfUpdateStartupResult staged = await LauncherSelfUpdateBootstrap.HandleAsync(
+ ["ordinary"],
+ harness.Manager,
+ harness.Target,
+ harness.LauncherPath);
+ Assert.False(staged.ShouldExit);
+ }
+
+ SelfUpdatePlan awaiting = await harness.Manager.ApplyPendingAsync(harness.Target);
+ using (UpdateSessionBarrier.SessionLease session =
+ harness.Manager.Barrier.AcquireSession())
+ {
+ await Assert.ThrowsAsync(() =>
+ LauncherSelfUpdateBootstrap.HandleAsync(
+ ["ordinary"],
+ harness.Manager,
+ harness.Target,
+ harness.LauncherPath));
+ }
+
+ SelfUpdatePlan rolledBack = await harness.Manager
+ .RollbackAwaitingConfirmationAsync(harness.Target);
+ using (UpdateSessionBarrier.SessionLease session =
+ harness.Manager.Barrier.AcquireSession())
+ {
+ await Assert.ThrowsAsync(() =>
+ LauncherSelfUpdateBootstrap.HandleAsync(
+ ["ordinary"],
+ harness.Manager,
+ harness.Target,
+ harness.LauncherPath));
+ }
+
+ await SetPlanStateAsync(harness.Manager.PendingPlanPath, "applying");
+ using (UpdateSessionBarrier.SessionLease session =
+ harness.Manager.Barrier.AcquireSession())
+ {
+ await Assert.ThrowsAsync(() =>
+ LauncherSelfUpdateBootstrap.HandleAsync(
+ ["ordinary"],
+ harness.Manager,
+ harness.Target,
+ harness.LauncherPath));
+ }
+
+ Assert.Equal(SelfUpdatePlanState.AwaitingConfirmation, awaiting.State);
+ Assert.Equal(SelfUpdatePlanState.RolledBack, rolledBack.State);
+ }
+
+ [Fact]
+ public async Task OrdinaryStartupRecoversApplyingAndFinalizesVerifiedRollback()
+ {
+ using var harness = new Harness(_root);
+ _ = await harness.StageAsync();
+ _ = await harness.Manager.ApplyPendingAsync(harness.Target);
+ await SetPlanStateAsync(harness.Manager.PendingPlanPath, "applying");
+
+ SelfUpdateStartupResult result = await LauncherSelfUpdateBootstrap.HandleAsync(
+ ["ordinary"],
harness.Manager,
harness.Target,
harness.LauncherPath);
- Assert.False(confirmation.ShouldExit);
- Assert.Empty(confirmation.RemainingArguments);
- Assert.False(File.Exists(harness.Manager.PendingPlanPath));
- Assert.False(harness.Manager.IsConfirmed(applied.TransactionId));
+ Assert.False(result.ShouldExit);
+ Assert.Equal(["ordinary"], result.RemainingArguments);
+ Assert.Null(await harness.Manager.LoadPendingAsync());
+ Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
+ Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath));
+ }
+
+ [Fact]
+ public async Task InternalPrefixSpoofsCannotCrossPlanStateOrExecutableTrust()
+ {
+ using var harness = new Harness(_root);
+ _ = await harness.StageAsync();
+ SelfUpdatePlan staged = Assert.IsType(
+ await harness.Manager.LoadPendingAsync());
+
+ await Assert.ThrowsAsync(() =>
+ LauncherSelfUpdateBootstrap.HandleAsync(
+ [
+ LauncherSelfUpdateBootstrap.HelperArgument,
+ int.MaxValue.ToString(
+ System.Globalization.CultureInfo.InvariantCulture),
+ harness.Target,
+ staged.TransactionId,
+ ],
+ harness.Manager,
+ harness.Target,
+ harness.LauncherPath));
+ await Assert.ThrowsAsync(() =>
+ LauncherSelfUpdateBootstrap.HandleAsync(
+ [LauncherSelfUpdateBootstrap.ConfirmArgument, staged.TransactionId],
+ harness.Manager,
+ harness.Target,
+ harness.LauncherPath));
+
+ SelfUpdatePlan awaiting = await harness.Manager.ApplyPendingAsync(harness.Target);
+ await Assert.ThrowsAsync(() =>
+ LauncherSelfUpdateBootstrap.HandleAsync(
+ [LauncherSelfUpdateBootstrap.ConfirmArgument, awaiting.TransactionId],
+ harness.Manager,
+ harness.Target,
+ Path.Combine(harness.Target, "spoof-launcher")));
+ await Assert.ThrowsAsync(() =>
+ LauncherSelfUpdateBootstrap.HandleAsync(
+ [
+ LauncherSelfUpdateBootstrap.HelperArgument,
+ int.MaxValue.ToString(
+ System.Globalization.CultureInfo.InvariantCulture),
+ harness.Target,
+ awaiting.TransactionId,
+ ],
+ harness.Manager,
+ harness.Target,
+ harness.LauncherPath));
+
+ SelfUpdatePlan rolledBack = await harness.Manager
+ .RollbackAwaitingConfirmationAsync(harness.Target);
+ foreach (string prefix in new[]
+ {
+ LauncherSelfUpdateBootstrap.HelperArgument,
+ LauncherSelfUpdateBootstrap.ConfirmArgument,
+ })
+ {
+ string[] arguments = prefix == LauncherSelfUpdateBootstrap.HelperArgument
+ ? [prefix, int.MaxValue.ToString(), harness.Target, rolledBack.TransactionId]
+ : [prefix, rolledBack.TransactionId];
+ await Assert.ThrowsAsync(() =>
+ LauncherSelfUpdateBootstrap.HandleAsync(
+ arguments,
+ harness.Manager,
+ harness.Target,
+ harness.LauncherPath));
+ }
+
+ SelfUpdateStartupResult deferred = await LauncherSelfUpdateBootstrap.HandleAsync(
+ ["--acdream-self-update-deferred-v1"],
+ harness.Manager,
+ harness.Target,
+ harness.LauncherPath);
+ Assert.True(deferred.ShouldExit);
+ Assert.Equal(64, deferred.ExitCode);
+
+ await File.WriteAllTextAsync(harness.Manager.PendingPlanPath, "{ambiguous");
+ await Assert.ThrowsAsync(() =>
+ LauncherSelfUpdateBootstrap.HandleAsync(
+ [LauncherSelfUpdateBootstrap.ConfirmArgument, rolledBack.TransactionId],
+ harness.Manager,
+ harness.Target,
+ harness.LauncherPath));
+ await Assert.ThrowsAsync(() =>
+ LauncherSelfUpdateBootstrap.HandleAsync(
+ [
+ LauncherSelfUpdateBootstrap.HelperArgument,
+ int.MaxValue.ToString(),
+ harness.Target,
+ rolledBack.TransactionId,
+ ],
+ harness.Manager,
+ harness.Target,
+ harness.LauncherPath));
+ }
+
+ private static async Task SetPlanStateAsync(string path, string state)
+ {
+ JsonObject plan = Assert.IsType(JsonNode.Parse(
+ await File.ReadAllTextAsync(path)));
+ plan["state"] = state;
+ await File.WriteAllTextAsync(path, plan.ToJsonString());
}
private sealed class Harness : IDisposable
diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs
index 1c67efc3..f63b5638 100644
--- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs
+++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs
@@ -27,13 +27,20 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
}
[Fact]
- public async Task KilledAfterCanonicalReplaceCanInvokeCanonicalAndConvergeAutomatically()
+ public async Task KilledApplyingPlanRecoversPriorAndContinuesCanonicalWithoutRetryLoop()
{
string data = Path.Combine(_root, "data");
string target = Path.Combine(_root, "launcher");
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);
@@ -87,25 +94,35 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
"The self-update journal did not converge.");
Assert.Equal(
- prepared.NewCanonicalHash,
+ oldHash,
await FileIntegrity.ComputeSha256HexAsync(prepared.CanonicalPath));
- Assert.True(File.Exists(Path.Combine(
+ Assert.False(File.Exists(Path.Combine(
target,
LauncherSelfUpdateManager.InstallRecordFileName)));
Assert.False(Directory.Exists(manager.GetTransactionDirectory(
plan.TransactionId)));
+ using (UpdateSessionBarrier.ExclusiveLease cleanupLease =
+ manager.Barrier.AcquireExclusive())
+ {
+ Assert.True(manager.CleanupOwnedResidueUnderLease(
+ pending: null,
+ target,
+ cleanupLease));
+ }
Assert.Empty(Directory.EnumerateDirectories(
target,
".acdream-self-update-*",
SearchOption.TopDirectoryOnly));
Assert.Null(await manager.LoadPendingAsync());
- int replacementPid = ParsePid(await File.ReadAllTextAsync(launched));
- int helperPid = int.Parse(
- await File.ReadAllTextAsync(helperPidPath),
- System.Globalization.CultureInfo.InvariantCulture);
+ string launchMarker = await File.ReadAllTextAsync(launched);
+ Assert.EndsWith(
+ Environment.NewLine + string.Join(Environment.NewLine, processLocalSuffix),
+ launchMarker,
+ StringComparison.Ordinal);
+ int replacementPid = ParsePid(launchMarker);
await WaitForProcessExitAsync(replacementPid, TimeSpan.FromSeconds(10));
- await WaitForProcessExitAsync(helperPid, TimeSpan.FromSeconds(10));
+ Assert.False(File.Exists(helperPidPath));
if (OperatingSystem.IsLinux())
{
Assert.True(
@@ -144,13 +161,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
["canonical-probe", launched],
BootstrapEnvironment(crashed, helperPidPath));
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
- Assert.Equal(0, canonical.ExitCode);
- await WaitForFileAsync(helperPidPath, process: null, TimeSpan.FromSeconds(20));
- await WaitForProcessExitAsync(
- int.Parse(
- await File.ReadAllTextAsync(helperPidPath),
- System.Globalization.CultureInfo.InvariantCulture),
- TimeSpan.FromSeconds(20));
+ Assert.NotEqual(0, canonical.ExitCode);
+ Assert.False(File.Exists(helperPidPath));
Assert.False(File.Exists(launched));
SelfUpdatePlan preserved = Assert.IsType(
@@ -192,13 +204,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
["canonical-probe", launched],
BootstrapEnvironment(crashed, helperPidPath));
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
- Assert.Equal(0, canonical.ExitCode);
- await WaitForFileAsync(helperPidPath, process: null, TimeSpan.FromSeconds(20));
- await WaitForProcessExitAsync(
- int.Parse(
- await File.ReadAllTextAsync(helperPidPath),
- System.Globalization.CultureInfo.InvariantCulture),
- TimeSpan.FromSeconds(20));
+ Assert.NotEqual(0, canonical.ExitCode);
+ Assert.False(File.Exists(helperPidPath));
Assert.False(File.Exists(launched));
Assert.True(File.Exists(outsideCanonical));
@@ -285,8 +292,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
["bootstrap-probe", data, target, canonical, resultPath]);
await startup.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
- Assert.Equal(0, startup.ExitCode);
- Assert.Equal("ordinary", await File.ReadAllTextAsync(resultPath));
+ Assert.NotEqual(0, startup.ExitCode);
+ Assert.False(File.Exists(resultPath));
Assert.True(Directory.Exists(transaction));
Assert.False(File.Exists(observer.PendingPlanPath));
@@ -316,9 +323,9 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
string helperPid = Path.Combine(_root, "helper.pid");
Directory.CreateDirectory(target);
string rid = LauncherRuntimeIdentity.DetectRid();
- string canonical = Path.Combine(target, LauncherName(rid));
- await File.WriteAllTextAsync(canonical, "old-launcher");
- byte[] archive = UpdateTestData.LauncherZip(rid, "new-launcher");
+ PreparedLauncher prepared = PrepareLauncherClosure(target, rid);
+ string canonical = prepared.CanonicalPath;
+ byte[] archive = prepared.NewArchive;
using var server = new LocalHttpFixture();
server.Add("launcher.zip", archive);
using var http = new HttpClient();
@@ -342,7 +349,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
[HelperPidEnvironment] = helperPid,
};
- using Process helper = StartFixture(
+ using Process helper = StartProcess(
+ manager.GetStagedLauncherPath(plan),
[
LauncherSelfUpdateBootstrap.HelperArgument,
int.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture),
@@ -353,16 +361,151 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
], environment);
await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
- Assert.Equal(LauncherSelfUpdateBootstrap.DeferredLeaseExitCode, helper.ExitCode);
+ string helperError = await helper.StandardError.ReadToEndAsync();
+ string helperOutput = await helper.StandardOutput.ReadToEndAsync();
+ Assert.True(
+ helper.ExitCode == LauncherSelfUpdateBootstrap.UpdateLeaseBusyExitCode,
+ $"helper exit {helper.ExitCode}; stdout: {helperOutput}; stderr: {helperError}");
Assert.True(File.Exists(helperPid));
Assert.False(File.Exists(unexpectedLaunch));
- Assert.Equal("old-launcher", await File.ReadAllTextAsync(canonical));
+ Assert.NotEqual(
+ prepared.NewCanonicalHash,
+ await FileIntegrity.ComputeSha256HexAsync(canonical));
SelfUpdatePlan deferred = Assert.IsType(
await manager.LoadPendingAsync());
Assert.Equal(SelfUpdatePlanState.Staged, deferred.State);
Assert.Equal(plan.TransactionId, deferred.TransactionId);
}
+ [Fact]
+ public async Task SpoofedInternalPrefixesCannotBypassAnyDurablePlanState()
+ {
+ string data = Path.Combine(_root, "data");
+ string target = Path.Combine(_root, "launcher");
+ string rid = LauncherRuntimeIdentity.DetectRid();
+ PreparedLauncher prepared = PrepareLauncherClosure(target, rid);
+ using var server = new LocalHttpFixture();
+ server.Add("launcher.zip", prepared.NewArchive);
+ using var http = new HttpClient();
+ var manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(_root), http);
+ _ = await manager.StageAsync(
+ LauncherVersion.Parse("2.0.0"),
+ rid,
+ new ReleaseArtifact(
+ server.UriFor("launcher.zip"),
+ UpdateTestData.Sha256(prepared.NewArchive),
+ prepared.NewArchive.LongLength),
+ target,
+ progress: null,
+ CancellationToken.None);
+ SelfUpdatePlan plan = Assert.IsType(await manager.LoadPendingAsync());
+ var environment = new Dictionary
+ {
+ [DataEnvironment] = data,
+ [TargetEnvironment] = target,
+ };
+
+ await AssertInternalSpoofsRejectedAsync(
+ prepared.CanonicalPath,
+ target,
+ plan.TransactionId,
+ environment,
+ "staged");
+
+ plan = await manager.ApplyPendingAsync(target);
+ await SetPlanStateAsync(manager.PendingPlanPath, "applying");
+ await AssertInternalSpoofsRejectedAsync(
+ prepared.CanonicalPath,
+ target,
+ plan.TransactionId,
+ environment,
+ "applying");
+
+ plan = await manager.RecoverApplyingAsync(target);
+ plan = await manager.ApplyPendingAsync(target);
+ Assert.Equal(SelfUpdatePlanState.AwaitingConfirmation, plan.State);
+ await AssertInternalSpoofsRejectedAsync(
+ prepared.CanonicalPath,
+ target,
+ plan.TransactionId,
+ environment,
+ "awaitingConfirmation");
+
+ plan = await manager.RollbackAwaitingConfirmationAsync(target);
+ await AssertInternalSpoofsRejectedAsync(
+ prepared.CanonicalPath,
+ target,
+ plan.TransactionId,
+ environment,
+ "rolledBack");
+
+ await File.WriteAllTextAsync(manager.PendingPlanPath, "{ambiguous");
+ await AssertInternalSpoofsRejectedAsync(
+ prepared.CanonicalPath,
+ target,
+ plan.TransactionId,
+ environment,
+ "ambiguous");
+ }
+
+ private static async Task AssertInternalSpoofsRejectedAsync(
+ string canonicalPath,
+ string targetDirectory,
+ string transactionId,
+ IReadOnlyDictionary environment,
+ string state)
+ {
+ (string Name, string[] Arguments, int? ExactExit)[] attempts =
+ [
+ (
+ "deferred",
+ ["--acdream-self-update-deferred-v1"],
+ 64),
+ (
+ "helper",
+ [
+ LauncherSelfUpdateBootstrap.HelperArgument,
+ int.MaxValue.ToString(
+ System.Globalization.CultureInfo.InvariantCulture),
+ targetDirectory,
+ transactionId,
+ ],
+ null),
+ (
+ "confirm",
+ [LauncherSelfUpdateBootstrap.ConfirmArgument, transactionId],
+ null),
+ ];
+
+ foreach ((string name, string[] arguments, int? exactExit) in attempts)
+ {
+ using Process process = StartProcess(canonicalPath, arguments, environment);
+ await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
+ string stderr = await process.StandardError.ReadToEndAsync();
+ if (exactExit.HasValue)
+ {
+ Assert.True(
+ process.ExitCode == exactExit.Value,
+ $"{state}/{name} exited {process.ExitCode}: {stderr}");
+ }
+ else
+ {
+ Assert.True(
+ process.ExitCode != 0,
+ $"{state}/{name} unexpectedly succeeded.");
+ }
+ }
+ }
+
+ private static async Task SetPlanStateAsync(string path, string state)
+ {
+ System.Text.Json.Nodes.JsonObject plan = Assert.IsType<
+ System.Text.Json.Nodes.JsonObject>(
+ System.Text.Json.Nodes.JsonNode.Parse(await File.ReadAllTextAsync(path)));
+ plan["state"] = state;
+ await File.WriteAllTextAsync(path, plan.ToJsonString());
+ }
+
private PreparedLauncher PrepareLauncherClosure(string target, string rid)
{
string fixtureDirectory = GetFixtureDirectory();
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
new file mode 100644
index 00000000..7b8f5b07
--- /dev/null
+++ b/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs
@@ -0,0 +1,253 @@
+using AcDream.Launcher.Core.Updates;
+using AcDream.Platform;
+
+namespace AcDream.Launcher.Tests;
+
+public sealed class LauncherStartupOptionsTests
+{
+ [Fact]
+ public void ExplicitIsolationRootsAreNormalizedAndNeverResolveCanonicalPaths()
+ {
+ string root = Path.Combine(Path.GetTempPath(), "acdream-la11 options", "..", "isolation");
+ string config = Path.Combine(root, "config") + Path.DirectorySeparatorChar;
+ string data = Path.Combine(root, "data", ".", "state");
+ string cache = Path.Combine(root, "cache") + Path.DirectorySeparatorChar;
+ bool defaultResolverCalled = false;
+
+ LauncherStartupOptions options = LauncherStartupOptions.Parse(
+ [
+ "--config-dir", config,
+ "--data-dir", data,
+ "--cache-dir", cache,
+ "--update-manifest-uri", "http://127.0.0.1:43119/manifest.json",
+ ],
+ () =>
+ {
+ defaultResolverCalled = true;
+ throw new InvalidOperationException("canonical path resolver was touched");
+ });
+
+ Assert.False(defaultResolverCalled);
+ Assert.Equal(
+ Path.TrimEndingDirectorySeparator(Path.GetFullPath(config)),
+ options.Paths.ConfigDirectory);
+ Assert.Equal(
+ Path.TrimEndingDirectorySeparator(Path.GetFullPath(data)),
+ options.Paths.DataDirectory);
+ Assert.Equal(
+ Path.TrimEndingDirectorySeparator(Path.GetFullPath(cache)),
+ options.Paths.CacheDirectory);
+ Assert.Null(options.Paths.LegacyConfigDirectory);
+ Assert.Equal(
+ "http://127.0.0.1:43119/manifest.json",
+ options.UpdateManifestUri.AbsoluteUri);
+ Assert.Equal(LauncherStartupMode.Desktop, options.Mode);
+ }
+
+ [Fact]
+ public void NoOverridesResolveDefaultsExactlyOnce()
+ {
+ var expected = new ApplicationPathSet("config", "data", "cache", "legacy");
+ int calls = 0;
+
+ LauncherStartupOptions options = LauncherStartupOptions.Parse(
+ [],
+ () =>
+ {
+ calls++;
+ return expected;
+ });
+
+ Assert.Same(expected, options.Paths);
+ Assert.Equal(1, calls);
+ Assert.Equal(ReleaseManifestClient.ProductionManifestUri, options.UpdateManifestUri);
+ }
+
+ [Fact]
+ public void VerifyPublishIsExclusiveAndDoesNotResolvePaths()
+ {
+ int calls = 0;
+
+ LauncherStartupOptions options = LauncherStartupOptions.Parse(
+ ["--verify-publish"],
+ () =>
+ {
+ calls++;
+ throw new InvalidOperationException();
+ });
+
+ Assert.Equal(LauncherStartupMode.VerifyPublish, options.Mode);
+ Assert.Equal(0, calls);
+ Assert.Throws(() =>
+ LauncherStartupOptions.Parse(
+ ["--verify-publish", "--cache-dir", Path.GetTempPath()]));
+ }
+
+ [Theory]
+ [MemberData(nameof(InvalidArguments))]
+ public void RejectsInvalidPublicArguments(string[] arguments)
+ {
+ Assert.Throws(() =>
+ LauncherStartupOptions.Parse(
+ arguments,
+ () => new ApplicationPathSet("c", "d", "x", null)));
+ }
+
+ [Theory]
+ [InlineData("https://updates.example.test/manifest.json")]
+ [InlineData("http://localhost:8123/manifest.json")]
+ [InlineData("http://[::1]:8123/manifest.json")]
+ public void AcceptsHttpsAndLoopbackHttpFeeds(string value)
+ {
+ LauncherStartupOptions options = LauncherStartupOptions.Parse(
+ ["--update-manifest-uri", value],
+ () => new ApplicationPathSet("c", "d", "x", null));
+
+ Assert.Equal(new Uri(value), options.UpdateManifestUri);
+ }
+
+ [Fact]
+ public void SelfUpdatePrefixesRetainOnlyTheValidatedPublicSuffix()
+ {
+ string root = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "acdream-la11-self"));
+ string[] suffix =
+ [
+ "--config-dir", Path.Combine(root, "config"),
+ "--data-dir", Path.Combine(root, "data"),
+ "--cache-dir", Path.Combine(root, "cache"),
+ "--update-manifest-uri", "http://localhost:8123/manifest.json",
+ ];
+ string[] helper =
+ [
+ LauncherSelfUpdateBootstrap.HelperArgument,
+ "123",
+ root,
+ "0123456789abcdef0123456789abcdef",
+ .. suffix,
+ ];
+ string[] confirmation =
+ [
+ LauncherSelfUpdateBootstrap.ConfirmArgument,
+ "0123456789abcdef0123456789abcdef",
+ .. suffix,
+ ];
+
+ LauncherStartupOptions helperOptions = LauncherStartupOptions.Parse(helper);
+ LauncherStartupOptions confirmationOptions =
+ LauncherStartupOptions.Parse(confirmation);
+
+ Assert.Equal(LauncherStartupMode.SelfUpdateHelper, helperOptions.Mode);
+ Assert.Equal(
+ LauncherStartupMode.SelfUpdateConfirmation,
+ confirmationOptions.Mode);
+ Assert.Equal(suffix, helperOptions.PublicArguments);
+ Assert.Equal(suffix, confirmationOptions.PublicArguments);
+ Assert.Equal(helperOptions.Paths, confirmationOptions.Paths);
+ Assert.Equal(helperOptions.UpdateManifestUri, confirmationOptions.UpdateManifestUri);
+ }
+
+ [Fact]
+ public void LegacyDeferredSelfUpdatePrefixIsRejectedAsUntrustedInput()
+ {
+ 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",
+ ];
+
+ Assert.Throws(() =>
+ LauncherStartupOptions.Parse(
+ ["--acdream-self-update-deferred-v1", .. suffix],
+ () => throw new InvalidOperationException(
+ "canonical path resolver was touched")));
+ }
+
+ [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"));
+ var data = new TheoryData();
+ data.Add(["--unknown", "value"]);
+ data.Add(["--config-dir"]);
+ data.Add(["--config-dir", "relative"]);
+ data.Add(["--config-dir", absolute]);
+ data.Add(
+ [
+ "--config-dir", absolute,
+ "--data-dir", absolute,
+ ]);
+ data.Add(
+ [
+ "--config-dir", absolute,
+ "--data-dir", absolute,
+ "--cache-dir", absolute,
+ "--cache-dir", absolute,
+ ]);
+ data.Add(
+ ["--update-manifest-uri", "http://updates.example.test/manifest.json"]);
+ 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(
+ [
+ "--update-manifest-uri", "https://example.test/a",
+ "--update-manifest-uri", "https://example.test/b",
+ ]);
+ return data;
+ }
+}
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/CampaignLaProcessCorrelation.ps1 b/tools/CampaignLaProcessCorrelation.ps1
new file mode 100644
index 00000000..25864a2d
--- /dev/null
+++ b/tools/CampaignLaProcessCorrelation.ps1
@@ -0,0 +1,232 @@
+Set-StrictMode -Version Latest
+
+function Get-CampaignLaSha256([string]$Text) {
+ $bytes = [Text.Encoding]::UTF8.GetBytes($Text)
+ return [Convert]::ToHexString(
+ [Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant()
+}
+
+function Get-CampaignLaCommandLineFingerprint(
+ [string]$ExecutablePath,
+ [string]$ConfigArgument,
+ [string]$SessionConfigPath) {
+ if (-not [IO.Path]::IsPathFullyQualified($ExecutablePath) -or
+ -not [IO.Path]::IsPathFullyQualified($SessionConfigPath) -or
+ $ConfigArgument -cnotin @('--config', '--session-config')) {
+ throw 'Cannot fingerprint an incomplete launcher-child command line.'
+ }
+ $executable = [IO.Path]::GetFullPath($ExecutablePath)
+ $config = [IO.Path]::GetFullPath($SessionConfigPath)
+ # Only the executable and the recognized config argument are retained in
+ # this projection. Launcher credentials use stdin; unrelated argv is
+ # deliberately excluded so an accidental secret can never enter evidence.
+ return Get-CampaignLaSha256(
+ "campaign-la-child-command-v1`n$executable`n$ConfigArgument`n$config")
+}
+
+function Get-CampaignLaLinuxProcessIdentity(
+ [string]$ProcessDirectory,
+ [string]$BootId) {
+ $stat = [IO.File]::ReadAllText((Join-Path $ProcessDirectory 'stat'))
+ $commandEnd = $stat.LastIndexOf(')')
+ if ($commandEnd -lt 2 -or $commandEnd + 2 -ge $stat.Length) {
+ throw 'Linux process stat record is malformed.'
+ }
+ # The tail begins at field 3 (state); field 22 (starttime) is index 19.
+ $tail = @($stat.Substring($commandEnd + 2).Split(
+ ' ',
+ [StringSplitOptions]::RemoveEmptyEntries))
+ if ($tail.Count -le 19) { throw 'Linux process stat record has no starttime.' }
+ $startTicks = [uint64]::Parse(
+ $tail[19],
+ [Globalization.NumberStyles]::None,
+ [Globalization.CultureInfo]::InvariantCulture)
+ return "linux-proc-start-v1:$BootId`:$startTicks"
+}
+
+function Get-CampaignLaProcessInstanceIdentity {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [ValidateRange(1, 2147483647)][int]$ProcessId)
+
+ if ($IsWindows) {
+ $candidate = Get-CimInstance Win32_Process `
+ -Filter "ProcessId=$ProcessId" -ErrorAction Stop
+ if ($null -eq $candidate) { return $null }
+ if ($null -eq $candidate.CreationDate) {
+ throw "Windows process $ProcessId has no creation time."
+ }
+ return "windows-creation-v1:$($candidate.CreationDate.ToUniversalTime().Ticks)"
+ }
+ if ($IsLinux) {
+ $directory = "/proc/$ProcessId"
+ if (-not [IO.Directory]::Exists($directory)) { return $null }
+ try {
+ $bootId = [IO.File]::ReadAllText(
+ '/proc/sys/kernel/random/boot_id').Trim().ToLowerInvariant()
+ if ($bootId -notmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') {
+ throw 'Linux boot id is malformed.'
+ }
+ return Get-CampaignLaLinuxProcessIdentity $directory $bootId
+ }
+ catch [IO.FileNotFoundException] { return $null }
+ catch [IO.DirectoryNotFoundException] { return $null }
+ catch [IO.IOException] {
+ if (-not [IO.Directory]::Exists($directory)) { return $null }
+ throw
+ }
+ }
+ throw 'Campaign LA process identity supports Windows and Linux only.'
+}
+
+function Get-CampaignLaSessionProcessCorrelations {
+ [CmdletBinding()]
+ param()
+
+ $correlations = [Collections.Generic.List[object]]::new()
+ if ($IsWindows) {
+ $pattern = '(?i)(?:^|\s)(--config|--session-config)\s+(?:"([^"]+)"|(\S+))'
+ foreach ($candidate in @(Get-CimInstance Win32_Process -ErrorAction Stop)) {
+ $commandLine = [string]$candidate.CommandLine
+ $executablePath = [string]$candidate.ExecutablePath
+ if ([string]::IsNullOrWhiteSpace($commandLine) -or
+ -not [IO.Path]::IsPathFullyQualified($executablePath) -or
+ $null -eq $candidate.CreationDate) {
+ continue
+ }
+ $identity = "windows-creation-v1:$($candidate.CreationDate.ToUniversalTime().Ticks)"
+ foreach ($match in [Text.RegularExpressions.Regex]::Matches(
+ $commandLine,
+ $pattern)) {
+ $argument = $match.Groups[1].Value.ToLowerInvariant()
+ $value = if ($match.Groups[2].Success) {
+ $match.Groups[2].Value
+ } else { $match.Groups[3].Value }
+ if ([IO.Path]::IsPathFullyQualified($value)) {
+ $configPath = [IO.Path]::GetFullPath($value)
+ $correlations.Add([pscustomobject]@{
+ ProcessId = [int]$candidate.ProcessId
+ ProcessInstanceIdentity = $identity
+ SessionConfigPath = $configPath
+ CommandLineFingerprintSha256 =
+ Get-CampaignLaCommandLineFingerprint `
+ $executablePath $argument $configPath
+ })
+ }
+ }
+ }
+ }
+ elseif ($IsLinux) {
+ $bootId = [IO.File]::ReadAllText('/proc/sys/kernel/random/boot_id').Trim().ToLowerInvariant()
+ if ($bootId -notmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') {
+ throw 'Linux boot id is malformed.'
+ }
+ foreach ($directory in [IO.Directory]::EnumerateDirectories('/proc')) {
+ $leaf = [IO.Path]::GetFileName($directory)
+ $processId = 0
+ if (-not [int]::TryParse(
+ $leaf,
+ [Globalization.NumberStyles]::None,
+ [Globalization.CultureInfo]::InvariantCulture,
+ [ref]$processId)) {
+ continue
+ }
+ try {
+ $identityBefore = Get-CampaignLaLinuxProcessIdentity $directory $bootId
+ $bytes = [IO.File]::ReadAllBytes((Join-Path $directory 'cmdline'))
+ if ($bytes.Length -eq 0) { continue }
+ $arguments = @([Text.Encoding]::UTF8.GetString($bytes).Split(
+ [char]0,
+ [StringSplitOptions]::RemoveEmptyEntries))
+ $identityAfter = Get-CampaignLaLinuxProcessIdentity $directory $bootId
+ if ($identityBefore -cne $identityAfter -or $arguments.Count -eq 0 -or
+ -not [IO.Path]::IsPathFullyQualified($arguments[0])) {
+ continue
+ }
+ for ($index = 0; $index + 1 -lt $arguments.Count; $index++) {
+ if ($arguments[$index] -cin @('--config', '--session-config') -and
+ [IO.Path]::IsPathFullyQualified($arguments[$index + 1])) {
+ $configPath = [IO.Path]::GetFullPath($arguments[$index + 1])
+ $correlations.Add([pscustomobject]@{
+ ProcessId = $processId
+ ProcessInstanceIdentity = $identityBefore
+ SessionConfigPath = $configPath
+ CommandLineFingerprintSha256 =
+ Get-CampaignLaCommandLineFingerprint `
+ $arguments[0] $arguments[$index] $configPath
+ })
+ }
+ }
+ }
+ catch [IO.IOException] {
+ # A process may exit between /proc enumeration and either read.
+ }
+ catch [UnauthorizedAccessException] {
+ # Other-user processes cannot be the owner-readable gate child.
+ }
+ }
+ }
+ else {
+ throw 'Campaign LA process correlation supports Windows and Linux only.'
+ }
+
+ return @($correlations)
+}
+
+function Get-CampaignLaCorrelatedProcessIds {
+ [CmdletBinding()]
+ param([Parameter(Mandatory = $true)][string]$SessionConfigPath)
+
+ if (-not [IO.Path]::IsPathFullyQualified($SessionConfigPath)) {
+ throw 'Session-config correlation requires an absolute path.'
+ }
+ $SessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath)
+ $comparison = if ($IsWindows) {
+ [StringComparison]::OrdinalIgnoreCase
+ } else { [StringComparison]::Ordinal }
+ $processIds = [Collections.Generic.HashSet[int]]::new()
+ foreach ($candidate in @(Get-CampaignLaSessionProcessCorrelations)) {
+ if ([string]::Equals(
+ $candidate.SessionConfigPath,
+ $SessionConfigPath,
+ $comparison)) {
+ $null = $processIds.Add([int]$candidate.ProcessId)
+ }
+ }
+
+ return @($processIds | Sort-Object)
+}
+
+function Test-CampaignLaCapturedProcessState {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)][int]$ProcessId,
+ [Parameter(Mandatory = $true)][string]$ProcessInstanceIdentity,
+ [Parameter(Mandatory = $true)][string]$SessionConfigPath,
+ [AllowNull()][string]$CurrentProcessInstanceIdentity,
+ [Parameter(Mandatory = $true)]
+ [AllowEmptyCollection()][object[]]$Correlations)
+
+ $comparison = if ($IsWindows) {
+ [StringComparison]::OrdinalIgnoreCase
+ } else { [StringComparison]::Ordinal }
+ $sameInstanceAlive = -not [string]::IsNullOrEmpty($CurrentProcessInstanceIdentity) -and
+ $CurrentProcessInstanceIdentity -ceq $ProcessInstanceIdentity
+ $exactConfigPathAlive = $false
+ $pidReused = -not [string]::IsNullOrEmpty($CurrentProcessInstanceIdentity) -and
+ $CurrentProcessInstanceIdentity -cne $ProcessInstanceIdentity
+ foreach ($candidate in $Correlations) {
+ if ([string]::Equals(
+ [string]$candidate.SessionConfigPath,
+ $SessionConfigPath,
+ $comparison)) {
+ $exactConfigPathAlive = $true
+ }
+ }
+ return [pscustomobject]@{
+ SameInstanceAlive = $sameInstanceAlive
+ ExactConfigPathAlive = $exactConfigPathAlive
+ PidReused = $pidReused
+ }
+}
diff --git a/tools/capture-campaign-la-session-process.ps1 b/tools/capture-campaign-la-session-process.ps1
new file mode 100644
index 00000000..4b0fd74a
--- /dev/null
+++ b/tools/capture-campaign-la-session-process.ps1
@@ -0,0 +1,117 @@
+<#
+.SYNOPSIS
+ Captures one launcher child PID by its unique isolated session-config path.
+
+.DESCRIPTION
+ Writes a sanitized gate-only sidecar. It never reads the session-config
+ contents and records no command line, account, character, or credential.
+#>
+[CmdletBinding(DefaultParameterSetName = 'Path')]
+param(
+ [Parameter(Mandatory = $true, ParameterSetName = 'Path')]
+ [string]$SessionConfigPath,
+ [Parameter(Mandatory = $true, ParameterSetName = 'Directory')]
+ [string]$SessionsDirectory,
+ [Parameter(ParameterSetName = 'Directory')]
+ [DateTimeOffset]$CreatedAfterUtc = [DateTimeOffset]::MinValue,
+ [Parameter(Mandatory = $true)][string]$ReportPath,
+ [ValidateRange(1, 60)][int]$WaitSeconds = 10
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+if ($PSVersionTable.PSVersion.Major -lt 7) {
+ throw 'Campaign LA PID capture requires PowerShell 7 or newer.'
+}
+. (Join-Path $PSScriptRoot 'CampaignLaProcessCorrelation.ps1')
+
+if ($PSCmdlet.ParameterSetName -eq 'Path') {
+ if (-not [IO.Path]::IsPathFullyQualified($SessionConfigPath)) {
+ throw '-SessionConfigPath must be absolute.'
+ }
+ $SessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath)
+ if (-not (Test-Path -LiteralPath $SessionConfigPath -PathType Leaf)) {
+ throw "Session config does not exist: $SessionConfigPath"
+ }
+}
+else {
+ if (-not [IO.Path]::IsPathFullyQualified($SessionsDirectory)) {
+ throw '-SessionsDirectory must be absolute.'
+ }
+ $SessionsDirectory = [IO.Path]::TrimEndingDirectorySeparator(
+ [IO.Path]::GetFullPath($SessionsDirectory))
+ if (-not (Test-Path -LiteralPath $SessionsDirectory -PathType Container)) {
+ throw "Sessions directory does not exist: $SessionsDirectory"
+ }
+}
+if (-not [IO.Path]::IsPathFullyQualified($ReportPath)) {
+ throw '-ReportPath must be absolute.'
+}
+$ReportPath = [IO.Path]::GetFullPath($ReportPath)
+if (Test-Path -LiteralPath $ReportPath) {
+ throw '-ReportPath must be fresh.'
+}
+
+$deadline = [DateTime]::UtcNow.AddSeconds($WaitSeconds)
+do {
+ if ($PSCmdlet.ParameterSetName -eq 'Path') {
+ $correlations = @(Get-CampaignLaSessionProcessCorrelations |
+ Where-Object {
+ $comparison = if ($IsWindows) {
+ [StringComparison]::OrdinalIgnoreCase
+ } else { [StringComparison]::Ordinal }
+ [string]::Equals(
+ $_.SessionConfigPath,
+ $SessionConfigPath,
+ $comparison)
+ })
+ }
+ else {
+ $comparison = if ($IsWindows) {
+ [StringComparison]::OrdinalIgnoreCase
+ } else { [StringComparison]::Ordinal }
+ $prefix = $SessionsDirectory + [IO.Path]::DirectorySeparatorChar
+ $correlations = @(Get-CampaignLaSessionProcessCorrelations |
+ Where-Object {
+ $_.SessionConfigPath.StartsWith($prefix, $comparison) -and
+ [IO.Path]::GetFileName($_.SessionConfigPath) -ceq 'session.json' -and
+ (Test-Path -LiteralPath $_.SessionConfigPath -PathType Leaf) -and
+ (Get-Item -LiteralPath $_.SessionConfigPath).LastWriteTimeUtc -ge
+ $CreatedAfterUtc.UtcDateTime
+ })
+ }
+ if ($correlations.Count -eq 1) { break }
+ if ($correlations.Count -gt 1) {
+ throw "More than one process uses the isolated session config."
+ }
+ Start-Sleep -Milliseconds 100
+} while ([DateTime]::UtcNow -lt $deadline)
+if ($correlations.Count -ne 1) {
+ throw 'No live process uses the isolated session config.'
+}
+$SessionConfigPath = [IO.Path]::GetFullPath($correlations[0].SessionConfigPath)
+$processIdentity = [string]$correlations[0].ProcessInstanceIdentity
+$commandFingerprint = [string]$correlations[0].CommandLineFingerprintSha256
+if ($processIdentity -notmatch '^(windows-creation-v1:[0-9]{15,19}|linux-proc-start-v1:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9]+)$' -or
+ $commandFingerprint -notmatch '^[0-9a-f]{64}$') {
+ throw 'The correlated process instance evidence is malformed.'
+}
+
+$directory = Split-Path -Parent $ReportPath
+if (-not [string]::IsNullOrEmpty($directory)) {
+ $null = New-Item -ItemType Directory -Force -Path $directory
+}
+$report = [ordered]@{
+ schemaVersion = 2
+ kind = 'campaign-la-session-process-capture'
+ processId = [int]$correlations[0].ProcessId
+ processInstanceIdentity = $processIdentity
+ sessionId = [IO.Path]::GetFileName(
+ [IO.Path]::GetDirectoryName($SessionConfigPath))
+ sessionConfigPath = $SessionConfigPath
+ commandLineFingerprintSha256 = $commandFingerprint
+ capturedUtc = [DateTime]::UtcNow.ToString('O')
+}
+$report | ConvertTo-Json -Depth 3 |
+ Set-Content -LiteralPath $ReportPath -Encoding utf8NoBOM
+Write-Host "Campaign LA process capture: $ReportPath"
diff --git a/tools/new-campaign-la-update-fixture.ps1 b/tools/new-campaign-la-update-fixture.ps1
new file mode 100644
index 00000000..d6609f47
--- /dev/null
+++ b/tools/new-campaign-la-update-fixture.ps1
@@ -0,0 +1,500 @@
+<#
+.SYNOPSIS
+ Creates deterministic isolated Campaign LA A/B update feeds.
+
+.DESCRIPTION
+ Packages caller-supplied published client and launcher roots for win-x64
+ and linux-x64, adds a deterministic release marker, calculates the exact
+ LA10 SHA-256/size manifest fields, and emits a loopback-only static server
+ plus a local A/B selector. It never downloads, connects, edits a payload
+ source, or writes outside -OutputDirectory.
+#>
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)][string]$OutputDirectory,
+ [Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryA,
+ [Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryA,
+ [Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryA,
+ [Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryA,
+ [Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryB,
+ [Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryB,
+ [Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryB,
+ [Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryB,
+ [string]$VersionA = '1.0.1-la11.a',
+ [string]$VersionB = '1.0.1-la11.b',
+ [string]$MinimumLauncherVersion = '1.0.0',
+ [int]$Port = 43119,
+ [switch]$DryRun
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+if ($PSVersionTable.PSVersion.Major -lt 7) {
+ throw 'Campaign LA update fixture creation requires PowerShell 7 or newer.'
+}
+if (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) {
+ throw '-OutputDirectory must be absolute.'
+}
+$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator(
+ [IO.Path]::GetFullPath($OutputDirectory))
+
+function Assert-NoReparseAncestry([string]$Path, [string]$Description) {
+ $cursor = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Path))
+ while (-not (Test-Path -LiteralPath $cursor)) {
+ $parent = [IO.Path]::GetDirectoryName($cursor)
+ if ([string]::IsNullOrEmpty($parent) -or $parent -ceq $cursor) { break }
+ $cursor = $parent
+ }
+ while (-not [string]::IsNullOrEmpty($cursor)) {
+ $item = Get-Item -LiteralPath $cursor -Force
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw "$Description has a reparse point in its ancestry."
+ }
+ $parent = [IO.Directory]::GetParent($cursor)
+ if ($null -eq $parent) { break }
+ $cursor = $parent.FullName
+ }
+}
+
+function Test-SameOrDescendant([string]$Path, [string]$Ancestor) {
+ $comparison = if ($IsWindows) {
+ [StringComparison]::OrdinalIgnoreCase
+ } else { [StringComparison]::Ordinal }
+ if ([string]::Equals($Path, $Ancestor, $comparison)) { return $true }
+ $prefix = $Ancestor + [IO.Path]::DirectorySeparatorChar
+ return $Path.StartsWith($prefix, $comparison)
+}
+
+Assert-NoReparseAncestry $OutputDirectory 'Output directory'
+if ($Port -lt 1024 -or $Port -gt 65535) { throw '-Port must be 1024..65535.' }
+$semver = '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$'
+if ($VersionA -notmatch $semver -or $VersionB -notmatch $semver -or
+ $MinimumLauncherVersion -notmatch $semver -or $VersionA -ceq $VersionB) {
+ throw 'VersionA, VersionB, and MinimumLauncherVersion must be SemVer 2.0; A and B must differ.'
+}
+$parsedVersionA = [semver]$VersionA
+$parsedVersionB = [semver]$VersionB
+$parsedMinimumLauncherVersion = [semver]$MinimumLauncherVersion
+if ($parsedVersionB.CompareTo($parsedVersionA) -le 0) {
+ throw 'VersionB must be newer than VersionA.'
+}
+if ($parsedMinimumLauncherVersion.CompareTo($parsedVersionA) -gt 0) {
+ throw 'MinimumLauncherVersion must not be newer than VersionA.'
+}
+
+$sources = [ordered]@{
+ 'A-client-win-x64' = $ClientWinX64DirectoryA
+ 'A-launcher-win-x64' = $LauncherWinX64DirectoryA
+ 'A-client-linux-x64' = $ClientLinuxX64DirectoryA
+ 'A-launcher-linux-x64' = $LauncherLinuxX64DirectoryA
+ 'B-client-win-x64' = $ClientWinX64DirectoryB
+ 'B-launcher-win-x64' = $LauncherWinX64DirectoryB
+ 'B-client-linux-x64' = $ClientLinuxX64DirectoryB
+ 'B-launcher-linux-x64' = $LauncherLinuxX64DirectoryB
+}
+foreach ($key in @($sources.Keys)) {
+ $source = [string]$sources[$key]
+ if (-not [IO.Path]::IsPathFullyQualified($source)) {
+ throw "Payload source '$key' must be absolute."
+ }
+ $source = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($source))
+ $sources[$key] = $source
+ if (-not $DryRun -and -not (Test-Path -LiteralPath $source -PathType Container)) {
+ throw "Payload source '$key' does not exist: $source"
+ }
+ Assert-NoReparseAncestry $source "Payload source '$key'"
+ if ((Test-SameOrDescendant $OutputDirectory $source) -or
+ (Test-SameOrDescendant $source $OutputDirectory)) {
+ throw "Output directory and payload source '$key' must not overlap."
+ }
+}
+
+function Require-PayloadFile([string]$Key, [string]$Name) {
+ if ($DryRun) { return }
+ if (-not (Test-Path -LiteralPath (Join-Path $sources[$Key] $Name) -PathType Leaf)) {
+ throw "Payload source '$Key' is missing root file '$Name'."
+ }
+}
+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) {
+ if (@(Get-ChildItem -LiteralPath $OutputDirectory -Force).Count -gt 0) {
+ throw '-OutputDirectory must not already contain files.'
+ }
+}
+else { $null = New-Item -ItemType Directory -Path $OutputDirectory }
+Assert-NoReparseAncestry $OutputDirectory 'Output directory'
+
+if ($DryRun) {
+ $plan = [ordered]@{
+ schemaVersion = 1
+ kind = 'campaign-la-update-fixture-plan'
+ outputDirectory = $OutputDirectory
+ versions = @($VersionA, $VersionB)
+ minimumLauncherVersion = $MinimumLauncherVersion
+ port = $Port
+ sources = $sources
+ writesOutsideOutputDirectory = $false
+ externalNetwork = $false
+ }
+ $plan | ConvertTo-Json -Depth 5 |
+ Set-Content -LiteralPath (Join-Path $OutputDirectory 'dry-run.json') -Encoding utf8NoBOM
+ Write-Host "Campaign LA update fixture dry run: $OutputDirectory"
+ return
+}
+
+Add-Type -AssemblyName System.IO.Compression
+Add-Type -AssemblyName System.IO.Compression.FileSystem
+$fixedTimestamp = [DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [TimeSpan]::Zero)
+
+function Get-LittleEndianUInt16([byte[]]$Bytes, [int]$Offset) {
+ return [int]$Bytes[$Offset] -bor ([int]$Bytes[$Offset + 1] -shl 8)
+}
+
+function Get-LittleEndianUInt32([byte[]]$Bytes, [int]$Offset) {
+ return [uint32]([uint32]$Bytes[$Offset] -bor
+ ([uint32]$Bytes[$Offset + 1] -shl 8) -bor
+ ([uint32]$Bytes[$Offset + 2] -shl 16) -bor
+ ([uint32]$Bytes[$Offset + 3] -shl 24))
+}
+
+function Set-DeterministicZipHostPlatform([string]$Path) {
+ [byte[]]$bytes = [IO.File]::ReadAllBytes($Path)
+ $minimumEocdSize = 22
+ if ($bytes.Length -lt $minimumEocdSize) {
+ throw "Generated ZIP is too short: $Path"
+ }
+
+ $eocd = -1
+ $minimumOffset = [Math]::Max(0, $bytes.Length - 65557)
+ for ($offset = $bytes.Length - $minimumEocdSize; $offset -ge $minimumOffset; $offset--) {
+ if ((Get-LittleEndianUInt32 $bytes $offset) -eq 0x06054b50) {
+ $commentLength = Get-LittleEndianUInt16 $bytes ($offset + 20)
+ if ($offset + $minimumEocdSize + $commentLength -eq $bytes.Length) {
+ $eocd = $offset
+ break
+ }
+ }
+ }
+ if ($eocd -lt 0) { throw "Generated ZIP has no valid end record: $Path" }
+ if ((Get-LittleEndianUInt16 $bytes ($eocd + 4)) -ne 0 -or
+ (Get-LittleEndianUInt16 $bytes ($eocd + 6)) -ne 0) {
+ throw "Generated ZIP unexpectedly spans multiple disks: $Path"
+ }
+
+ $entriesOnDisk = Get-LittleEndianUInt16 $bytes ($eocd + 8)
+ $entryCount = Get-LittleEndianUInt16 $bytes ($eocd + 10)
+ if ($entriesOnDisk -ne $entryCount) {
+ throw "Generated ZIP central-directory count is inconsistent: $Path"
+ }
+ $centralSize = Get-LittleEndianUInt32 $bytes ($eocd + 12)
+ $centralOffset = Get-LittleEndianUInt32 $bytes ($eocd + 16)
+ if ([uint64]$centralOffset + [uint64]$centralSize -ne [uint64]$eocd) {
+ throw "Generated ZIP central-directory bounds are inconsistent: $Path"
+ }
+
+ [uint64]$cursor = $centralOffset
+ for ($index = 0; $index -lt $entryCount; $index++) {
+ if ($cursor + 46 -gt $eocd -or
+ (Get-LittleEndianUInt32 $bytes ([int]$cursor)) -ne 0x02014b50) {
+ throw "Generated ZIP central-directory entry is invalid: $Path"
+ }
+ # ZipArchive stamps the creating host (FAT on Windows, Unix on Linux)
+ # in the upper byte of "version made by". Normalize to Unix so native
+ # extraction honors the explicit regular-file type and 0755/0644 mode
+ # bits already stored in ExternalAttributes.
+ $bytes[[int]$cursor + 5] = 3
+ $nameLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 28)
+ $extraLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 30)
+ $commentLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 32)
+ $cursor += 46 + $nameLength + $extraLength + $commentLength
+ }
+ if ($cursor -ne $eocd) {
+ throw "Generated ZIP central-directory length is inconsistent: $Path"
+ }
+ [IO.File]::WriteAllBytes($Path, $bytes)
+}
+
+function New-DeterministicZip(
+ [string]$SourceDirectory,
+ [string]$Destination,
+ [string]$ReleaseLabel,
+ [string]$PayloadKind,
+ [string]$Rid) {
+ $destinationDirectory = Split-Path -Parent $Destination
+ $null = New-Item -ItemType Directory -Force -Path $destinationDirectory
+ $stream = [IO.FileStream]::new(
+ $Destination,
+ [IO.FileMode]::CreateNew,
+ [IO.FileAccess]::ReadWrite,
+ [IO.FileShare]::None)
+ try {
+ $archive = [IO.Compression.ZipArchive]::new(
+ $stream,
+ [IO.Compression.ZipArchiveMode]::Create,
+ $true,
+ [Text.Encoding]::UTF8)
+ try {
+ $allEntries = @(Get-ChildItem -LiteralPath $SourceDirectory -Force -Recurse)
+ foreach ($item in $allEntries) {
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw "Payload contains a reparse point: $($item.FullName)"
+ }
+ }
+ [string[]]$files = @($allEntries |
+ Where-Object { -not $_.PSIsContainer } |
+ ForEach-Object {
+ [IO.Path]::GetRelativePath(
+ $SourceDirectory,
+ $_.FullName).Replace('\', '/')
+ })
+ [Array]::Sort($files, [StringComparer]::Ordinal)
+ $caseFolded = [Collections.Generic.HashSet[string]]::new(
+ [StringComparer]::OrdinalIgnoreCase)
+ foreach ($relative in $files) {
+ if ($relative.StartsWith('../', [StringComparison]::Ordinal) -or
+ [IO.Path]::IsPathRooted($relative) -or
+ -not $caseFolded.Add($relative)) {
+ throw "Payload path escaped its root: $relative"
+ }
+ $file = Get-Item -LiteralPath (
+ Join-Path $SourceDirectory $relative.Replace('/', [IO.Path]::DirectorySeparatorChar))
+ $entry = $archive.CreateEntry($relative, [IO.Compression.CompressionLevel]::Optimal)
+ $entry.LastWriteTime = $fixedTimestamp
+ $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
+ $input = [IO.File]::OpenRead($file.FullName)
+ $output = $entry.Open()
+ try { $input.CopyTo($output) }
+ finally { $output.Dispose(); $input.Dispose() }
+ }
+ $marker = $archive.CreateEntry(
+ 'campaign-la-fixture-release.txt',
+ [IO.Compression.CompressionLevel]::Optimal)
+ $marker.LastWriteTime = $fixedTimestamp
+ $marker.ExternalAttributes = 0x81A4 -shl 16
+ $writer = [IO.StreamWriter]::new(
+ $marker.Open(),
+ [Text.UTF8Encoding]::new($false))
+ try {
+ $writer.NewLine = "`n"
+ $writer.Write("release=$ReleaseLabel`npayload=$PayloadKind`nrid=$Rid`n")
+ }
+ finally { $writer.Dispose() }
+ }
+ finally { $archive.Dispose() }
+ }
+ finally { $stream.Dispose() }
+ Set-DeterministicZipHostPlatform $Destination
+}
+
+function Get-Artifact([string]$Path, [string]$Url) {
+ $item = Get-Item -LiteralPath $Path
+ return [ordered]@{
+ url = $Url
+ sha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
+ size = $item.Length
+ }
+}
+
+$releaseDefinitions = @(
+ [pscustomobject]@{ Label = 'A'; Version = $VersionA },
+ [pscustomobject]@{ Label = 'B'; Version = $VersionB }
+)
+foreach ($release in $releaseDefinitions) {
+ $releaseRoot = Join-Path $OutputDirectory $release.Label
+ foreach ($rid in @('win-x64', 'linux-x64')) {
+ New-DeterministicZip `
+ $sources["$($release.Label)-client-$rid"] `
+ (Join-Path $releaseRoot "client-$rid.zip") `
+ $release.Label 'client' $rid
+ New-DeterministicZip `
+ $sources["$($release.Label)-launcher-$rid"] `
+ (Join-Path $releaseRoot "launcher-$rid.zip") `
+ $release.Label 'launcher' $rid
+ }
+ $baseUri = "http://127.0.0.1:$Port/$($release.Label)"
+ $manifest = [ordered]@{
+ schemaVersion = 1
+ version = $release.Version
+ minimumLauncherVersion = $MinimumLauncherVersion
+ clients = [ordered]@{
+ 'win-x64' = Get-Artifact `
+ (Join-Path $releaseRoot 'client-win-x64.zip') `
+ "$baseUri/client-win-x64.zip"
+ 'linux-x64' = Get-Artifact `
+ (Join-Path $releaseRoot 'client-linux-x64.zip') `
+ "$baseUri/client-linux-x64.zip"
+ }
+ launchers = [ordered]@{
+ 'win-x64' = Get-Artifact `
+ (Join-Path $releaseRoot 'launcher-win-x64.zip') `
+ "$baseUri/launcher-win-x64.zip"
+ 'linux-x64' = Get-Artifact `
+ (Join-Path $releaseRoot 'launcher-linux-x64.zip') `
+ "$baseUri/launcher-linux-x64.zip"
+ }
+ }
+ [IO.File]::WriteAllText(
+ (Join-Path $releaseRoot 'manifest.json'),
+ ($manifest | ConvertTo-Json -Depth 8 -Compress),
+ [Text.UTF8Encoding]::new($false))
+}
+[IO.File]::WriteAllText(
+ (Join-Path $OutputDirectory 'active-release.txt'),
+ 'A',
+ [Text.Encoding]::ASCII)
+
+$server = @'
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)][string]$Root,
+ [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()
+ try {
+ if ($context.Request.HttpMethod -cne 'GET') {
+ $context.Response.StatusCode = 405
+ continue
+ }
+ $relative = [Uri]::UnescapeDataString($context.Request.Url.AbsolutePath.TrimStart('/'))
+ if ($relative -ceq 'manifest.json') {
+ $active = (Get-Content -LiteralPath (Join-Path $Root 'active-release.txt') -Raw).Trim()
+ if ($active -notin @('A', 'B')) { throw 'active-release.txt must contain A or B.' }
+ $relative = "$active/manifest.json"
+ }
+ if ([string]::IsNullOrWhiteSpace($relative) -or $relative.Contains('..')) {
+ $context.Response.StatusCode = 404
+ continue
+ }
+ $path = [IO.Path]::GetFullPath((Join-Path $Root $relative))
+ if (-not $path.StartsWith($Root + [IO.Path]::DirectorySeparatorChar, [StringComparison]::Ordinal) -or
+ -not (Test-Path -LiteralPath $path -PathType Leaf)) {
+ $context.Response.StatusCode = 404
+ continue
+ }
+ $context.Response.ContentType = if ($path.EndsWith('.json', [StringComparison]::Ordinal)) {
+ 'application/json'
+ } else { 'application/zip' }
+ $context.Response.StatusCode = 200
+ $context.Response.Headers['Cache-Control'] = 'no-store'
+ $context.Response.ContentLength64 = (Get-Item -LiteralPath $path).Length
+ $input = [IO.File]::OpenRead($path)
+ try { $input.CopyTo($context.Response.OutputStream) }
+ finally { $input.Dispose() }
+ }
+ catch {
+ $context.Response.StatusCode = 500
+ Write-Error $_
+ }
+ finally {
+ $context.Response.Close()
+ $servedRequests++
+ }
+ if ($MaximumRequests -gt 0 -and $servedRequests -ge $MaximumRequests) {
+ break
+ }
+ }
+}
+finally { $listener.Close() }
+'@
+[IO.File]::WriteAllText(
+ (Join-Path $OutputDirectory 'serve-fixture.ps1'),
+ $server.Replace("`r`n", "`n"),
+ [Text.UTF8Encoding]::new($false))
+
+$selector = @'
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)][ValidateSet('A', 'B')][string]$Release,
+ [string]$Root = $PSScriptRoot
+)
+Set-StrictMode -Version Latest
+$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"
+'@
+[IO.File]::WriteAllText(
+ (Join-Path $OutputDirectory 'set-active-release.ps1'),
+ $selector.Replace("`r`n", "`n"),
+ [Text.UTF8Encoding]::new($false))
+
+$inventoryPaths = [string[]]@(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse |
+ Where-Object { $_.Name -ne 'fixture-report.json' } |
+ ForEach-Object {
+ [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/')
+ })
+[Array]::Sort($inventoryPaths, [StringComparer]::Ordinal)
+$inventory = @($inventoryPaths | ForEach-Object {
+ $fullPath = Join-Path $OutputDirectory $_.Replace('/', [IO.Path]::DirectorySeparatorChar)
+ $item = Get-Item -LiteralPath $fullPath
+ [ordered]@{
+ path = $_
+ size = $item.Length
+ sha256 = (Get-FileHash -LiteralPath $fullPath -Algorithm SHA256).Hash.ToLowerInvariant()
+ }
+ })
+$report = [ordered]@{
+ schemaVersion = 1
+ kind = 'campaign-la-update-fixture'
+ versions = [ordered]@{ A = $VersionA; B = $VersionB }
+ minimumLauncherVersion = $MinimumLauncherVersion
+ manifestUri = "http://127.0.0.1:$Port/manifest.json"
+ loopbackOnly = $true
+ initialRelease = 'A'
+ sourceDirectories = $sources
+ artifacts = $inventory
+}
+$report | ConvertTo-Json -Depth 8 |
+ Set-Content -LiteralPath (Join-Path $OutputDirectory 'fixture-report.json') -Encoding utf8NoBOM
+Write-Host "Campaign LA update fixture: $OutputDirectory"
diff --git a/tools/run-campaign-la-preflight.ps1 b/tools/run-campaign-la-preflight.ps1
new file mode 100644
index 00000000..2966ed59
--- /dev/null
+++ b/tools/run-campaign-la-preflight.ps1
@@ -0,0 +1,530 @@
+<#
+.SYNOPSIS
+ Campaign LA11 display-free, connection-free automated preflight.
+
+.DESCRIPTION
+ Runs the exact Release and portability ladder used before the launcher
+ user gate. It never starts App/Headless in connected mode, never opens a
+ window, never reads credentials, and never bakes retail DATs. All logs and
+ publishes are contained beneath one logs/campaign-la-gate-
+ directory. Use -DryRun to emit the complete command matrix without
+ executing it.
+#>
+[CmdletBinding()]
+param(
+ [string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
+ [Parameter(Mandatory = $true)][string]$AllowedOutputRoot,
+ [string]$OutputDirectory,
+ [switch]$DryRun,
+ [switch]$IncludeInstalledDat,
+ [string]$InstalledDatDirectory
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+if ($PSVersionTable.PSVersion.Major -lt 7) {
+ throw 'Campaign LA preflight requires PowerShell 7 or newer.'
+}
+
+$Repository = [IO.Path]::TrimEndingDirectorySeparator(
+ [IO.Path]::GetFullPath($Repository))
+if (-not (Test-Path -LiteralPath (Join-Path $Repository 'AcDream.slnx') -PathType Leaf)) {
+ throw "Repository does not contain AcDream.slnx: $Repository"
+}
+
+function Assert-NoReparseAncestry([string]$Path, [string]$Description) {
+ $cursor = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Path))
+ while (-not (Test-Path -LiteralPath $cursor)) {
+ $parent = [IO.Path]::GetDirectoryName($cursor)
+ if ([string]::IsNullOrEmpty($parent) -or $parent -ceq $cursor) { break }
+ $cursor = $parent
+ }
+ while (-not [string]::IsNullOrEmpty($cursor)) {
+ $item = Get-Item -LiteralPath $cursor -Force
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw "$Description has a reparse point in its ancestry."
+ }
+ $parent = [IO.Directory]::GetParent($cursor)
+ if ($null -eq $parent) { break }
+ $cursor = $parent.FullName
+ }
+}
+
+function Test-SameOrDescendant([string]$Path, [string]$Ancestor) {
+ $comparison = if ($IsWindows) {
+ [StringComparison]::OrdinalIgnoreCase
+ } else { [StringComparison]::Ordinal }
+ if ([string]::Equals($Path, $Ancestor, $comparison)) { return $true }
+ return $Path.StartsWith(
+ $Ancestor + [IO.Path]::DirectorySeparatorChar,
+ $comparison)
+}
+
+if (-not [IO.Path]::IsPathFullyQualified($AllowedOutputRoot)) {
+ throw '-AllowedOutputRoot must be absolute.'
+}
+$AllowedOutputRoot = [IO.Path]::TrimEndingDirectorySeparator(
+ [IO.Path]::GetFullPath($AllowedOutputRoot))
+if (-not (Test-Path -LiteralPath $AllowedOutputRoot -PathType Container)) {
+ throw '-AllowedOutputRoot must be an existing campaign gate/log directory.'
+}
+Assert-NoReparseAncestry $AllowedOutputRoot 'Allowed output root'
+$comparison = if ($IsWindows) {
+ [StringComparison]::OrdinalIgnoreCase
+} else { [StringComparison]::Ordinal }
+$homeDirectory = [IO.Path]::TrimEndingDirectorySeparator(
+ [IO.Path]::GetFullPath([Environment]::GetFolderPath(
+ [Environment+SpecialFolder]::UserProfile)))
+if ([string]::Equals($AllowedOutputRoot, $Repository, $comparison) -or
+ [string]::Equals($AllowedOutputRoot, $homeDirectory, $comparison)) {
+ throw '-AllowedOutputRoot cannot be the repository root or user home.'
+}
+$repositoryLogs = [IO.Path]::TrimEndingDirectorySeparator(
+ [IO.Path]::GetFullPath((Join-Path $Repository 'logs')))
+$allowedLeaf = [IO.Path]::GetFileName($AllowedOutputRoot)
+$allowedInRepository = Test-SameOrDescendant $AllowedOutputRoot $Repository
+if ($allowedInRepository -and
+ -not (Test-SameOrDescendant $AllowedOutputRoot $repositoryLogs)) {
+ throw '-AllowedOutputRoot inside the repository must be below its logs directory.'
+}
+if (-not [string]::Equals($AllowedOutputRoot, $repositoryLogs, $comparison) -and
+ -not $allowedLeaf.StartsWith('campaign-la-', [StringComparison]::Ordinal)) {
+ throw '-AllowedOutputRoot must be the repository logs root or a campaign-la-* gate root.'
+}
+if ($IncludeInstalledDat) {
+ if ([string]::IsNullOrWhiteSpace($InstalledDatDirectory) -or
+ -not [IO.Path]::IsPathFullyQualified($InstalledDatDirectory)) {
+ throw '-IncludeInstalledDat requires an absolute -InstalledDatDirectory.'
+ }
+ $InstalledDatDirectory = [IO.Path]::TrimEndingDirectorySeparator(
+ [IO.Path]::GetFullPath($InstalledDatDirectory))
+ foreach ($file in @(
+ 'client_portal.dat',
+ 'client_cell_1.dat',
+ 'client_highres.dat',
+ 'client_local_English.dat')) {
+ if (-not (Test-Path -LiteralPath (Join-Path $InstalledDatDirectory $file) -PathType Leaf)) {
+ throw "Installed DAT directory is missing $file."
+ }
+ }
+}
+
+$stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss')
+if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
+ $OutputDirectory = Join-Path $AllowedOutputRoot "campaign-la-preflight-$stamp"
+}
+elseif (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) {
+ throw '-OutputDirectory must be absolute when supplied.'
+}
+$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator(
+ [IO.Path]::GetFullPath($OutputDirectory))
+if (-not (Test-SameOrDescendant $OutputDirectory $AllowedOutputRoot) -or
+ [string]::Equals($OutputDirectory, $AllowedOutputRoot, $comparison)) {
+ throw '-OutputDirectory must be a strict descendant of -AllowedOutputRoot.'
+}
+if ([string]::Equals($OutputDirectory, $Repository, $comparison) -or
+ [string]::Equals($OutputDirectory, $homeDirectory, $comparison)) {
+ throw '-OutputDirectory cannot be the repository root or user home.'
+}
+if (Test-Path -LiteralPath $OutputDirectory) {
+ throw '-OutputDirectory must be fresh and must not already exist.'
+}
+Assert-NoReparseAncestry $OutputDirectory 'Output directory'
+$logsDirectory = Join-Path $OutputDirectory 'commands'
+$publishDirectory = Join-Path $OutputDirectory 'publish'
+$null = New-Item -ItemType Directory -Path $logsDirectory
+
+$commandResults = [Collections.Generic.List[object]]::new()
+$failures = [Collections.Generic.List[string]]::new()
+$startedUtc = [DateTime]::UtcNow
+
+function Protect-Text([string]$Text) {
+ if ($null -eq $Text) { return '' }
+ $protected = $Text
+ $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*")[^"]*(")',
+ '$1$2')
+ return $protected
+}
+
+function Format-Command([string]$FilePath, [string[]]$Arguments) {
+ $parts = [Collections.Generic.List[string]]::new()
+ $parts.Add($FilePath)
+ foreach ($argument in $Arguments) {
+ if ($argument -match '[\s"]') {
+ $parts.Add('"' + $argument.Replace('"', '\"') + '"')
+ }
+ else { $parts.Add($argument) }
+ }
+ return $parts -join ' '
+}
+
+function Add-PlannedCommand(
+ [string]$Name,
+ [string]$FilePath,
+ [string[]]$Arguments,
+ [Collections.IDictionary]$Environment = @{}) {
+ $commandResults.Add([ordered]@{
+ name = $Name
+ command = Format-Command $FilePath $Arguments
+ status = 'planned'
+ startedUtc = $null
+ durationSeconds = 0
+ exitCode = $null
+ stdout = $null
+ stderr = $null
+ environmentKeys = @($Environment.Keys | Sort-Object)
+ })
+}
+
+function Invoke-GateCommand(
+ [string]$Name,
+ [string]$FilePath,
+ [string[]]$Arguments,
+ [Collections.IDictionary]$Environment = @{}) {
+ if ($DryRun) {
+ Add-PlannedCommand $Name $FilePath $Arguments $Environment
+ return
+ }
+
+ $safeName = $Name -replace '[^A-Za-z0-9_.-]', '-'
+ $stdoutRelative = "commands/$safeName.out.log"
+ $stderrRelative = "commands/$safeName.err.log"
+ $stdoutPath = Join-Path $OutputDirectory $stdoutRelative
+ $stderrPath = Join-Path $OutputDirectory $stderrRelative
+ $begin = [DateTime]::UtcNow
+ $watch = [Diagnostics.Stopwatch]::StartNew()
+ $exitCode = 74
+ try {
+ $startInfo = [Diagnostics.ProcessStartInfo]::new()
+ $startInfo.FileName = $FilePath
+ $startInfo.WorkingDirectory = $Repository
+ $startInfo.UseShellExecute = $false
+ $startInfo.CreateNoWindow = $true
+ $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
+ }
+ $process = [Diagnostics.Process]::new()
+ $process.StartInfo = $startInfo
+ if (-not $process.Start()) { throw "Could not start $FilePath." }
+ $stdoutTask = $process.StandardOutput.ReadToEndAsync()
+ $stderrTask = $process.StandardError.ReadToEndAsync()
+ $process.WaitForExit()
+ $stdout = $stdoutTask.GetAwaiter().GetResult()
+ $stderr = $stderrTask.GetAwaiter().GetResult()
+ $exitCode = $process.ExitCode
+ $process.Dispose()
+ [IO.File]::WriteAllText($stdoutPath, (Protect-Text $stdout))
+ [IO.File]::WriteAllText($stderrPath, (Protect-Text $stderr))
+ }
+ catch {
+ [IO.File]::WriteAllText($stderrPath, (Protect-Text ($_ | Out-String)))
+ }
+ finally {
+ $watch.Stop()
+ $commandResults.Add([ordered]@{
+ name = $Name
+ command = Format-Command $FilePath $Arguments
+ status = if ($exitCode -eq 0) { 'passed' } else { 'failed' }
+ startedUtc = $begin.ToString('O')
+ durationSeconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3)
+ exitCode = $exitCode
+ stdout = $stdoutRelative
+ stderr = $stderrRelative
+ environmentKeys = @($Environment.Keys | Sort-Object)
+ })
+ }
+ if ($exitCode -ne 0) {
+ throw "Preflight command '$Name' failed with exit code $exitCode."
+ }
+}
+
+function Add-InternalCheck([string]$Name, [scriptblock]$Action) {
+ if ($DryRun) {
+ $commandResults.Add([ordered]@{
+ name = $Name; command = ''; status = 'planned'
+ startedUtc = $null; durationSeconds = 0; exitCode = $null
+ stdout = $null; stderr = $null; environmentKeys = @()
+ })
+ return
+ }
+ $begin = [DateTime]::UtcNow
+ $watch = [Diagnostics.Stopwatch]::StartNew()
+ $exitCode = 0
+ try { & $Action }
+ catch { $exitCode = 1; throw }
+ finally {
+ $watch.Stop()
+ $commandResults.Add([ordered]@{
+ name = $Name; command = ''
+ status = if ($exitCode -eq 0) { 'passed' } else { 'failed' }
+ startedUtc = $begin.ToString('O')
+ durationSeconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3)
+ exitCode = $exitCode; stdout = $null; stderr = $null; environmentKeys = @()
+ })
+ }
+}
+
+function Invoke-DotNet([string]$Name, [string[]]$Arguments) {
+ Invoke-GateCommand $Name 'dotnet' $Arguments
+}
+
+$portableBuildProjects = @(
+ 'src/AcDream.Platform/AcDream.Platform.csproj',
+ 'src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj',
+ 'src/AcDream.Bake/AcDream.Bake.csproj',
+ 'src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj',
+ 'src/AcDream.Core/AcDream.Core.csproj',
+ 'src/AcDream.Core.Net/AcDream.Core.Net.csproj',
+ 'src/AcDream.Content/AcDream.Content.csproj',
+ 'src/AcDream.Runtime/AcDream.Runtime.csproj',
+ 'src/AcDream.Headless/AcDream.Headless.csproj'
+)
+$portableTestProjects = @(
+ 'tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj',
+ 'tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj',
+ 'tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj',
+ 'tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj',
+ 'tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj',
+ 'tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj',
+ 'tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj'
+)
+
+try {
+ Invoke-DotNet 'release-build' @(
+ 'build', 'AcDream.slnx', '-c', 'Release', '--nologo', '-m:1')
+ Invoke-GateCommand 'campaign-la-gate-helper-contracts' `
+ ([Environment]::ProcessPath ??
+ $(throw 'The PowerShell process path is unavailable.')) `
+ @(
+ '-NoProfile',
+ '-File', 'tools/test-campaign-la-gate-helpers.ps1',
+ '-Repository', $Repository,
+ '-OutputDirectory', (Join-Path $OutputDirectory 'helper-contracts'))
+ Invoke-GateCommand 'campaign-la-script-safety-contracts' `
+ ([Environment]::ProcessPath ??
+ $(throw 'The PowerShell process path is unavailable.')) `
+ @(
+ '-NoProfile',
+ '-File', 'tools/test-campaign-la-script-safety.ps1',
+ '-Repository', $Repository,
+ '-OutputDirectory', (Join-Path $OutputDirectory 'script-safety'))
+ Invoke-DotNet 'release-tests-serial' @(
+ 'test', 'AcDream.slnx', '-c', 'Release', '--no-build', '--nologo', '-m:1',
+ '--', 'RunConfiguration.MaxCpuCount=1')
+ Invoke-DotNet 'focused-launcher-updater-core' @(
+ 'test', 'tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj',
+ '-c', 'Release', '--no-build', '--nologo',
+ '--filter', 'FullyQualifiedName~Updates')
+ Invoke-DotNet 'focused-launcher-updater-ui' @(
+ 'test', 'tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj',
+ '-c', 'Release', '--no-build', '--nologo',
+ '--filter', 'FullyQualifiedName~LauncherUpdateViewModelTests|FullyQualifiedName~LauncherStartupOptionsTests')
+
+ foreach ($project in $portableBuildProjects) {
+ $leaf = [IO.Path]::GetFileNameWithoutExtension($project)
+ Invoke-DotNet "portable-build-$leaf" @(
+ 'build', $project, '-c', 'Release', '--no-restore', '--nologo', '-m:1')
+ }
+ foreach ($project in $portableTestProjects) {
+ $leaf = [IO.Path]::GetFileNameWithoutExtension($project)
+ Invoke-DotNet "portable-test-$leaf" @(
+ 'test', $project, '-c', 'Release', '--no-build', '--nologo',
+ '--', '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" @(
+ 'publish', 'src/AcDream.Launcher/AcDream.Launcher.csproj',
+ '-c', 'Release', '-r', $rid, '--self-contained', 'true',
+ '-p:PublishSingleFile=true', '-o', $destination, '--nologo')
+ Add-InternalCheck "publish-contract-$rid" {
+ $suffix = if ($rid.StartsWith('win-', [StringComparison]::Ordinal)) { '.exe' } else { '' }
+ foreach ($name in @("acdream-launcher$suffix", "acdream-bake$suffix")) {
+ if (-not (Test-Path -LiteralPath (Join-Path $destination $name) -PathType Leaf)) {
+ throw "$rid publish is missing $name."
+ }
+ }
+ if (Test-Path -LiteralPath (Join-Path $destination 'acdream-launcher.dll')) {
+ throw "$rid launcher publish is not single-file."
+ }
+ if (Test-Path -LiteralPath (Join-Path $destination 'acdream-bake.dll')) {
+ throw "$rid bake publish is not single-file."
+ }
+ if (-not $IsWindows -and $rid -eq 'linux-x64') {
+ $mode = [IO.File]::GetUnixFileMode((Join-Path $destination 'acdream-launcher'))
+ if (($mode -band [IO.UnixFileMode]::UserExecute) -eq 0) {
+ throw 'linux-x64 launcher is not executable.'
+ }
+ }
+ }
+ }
+
+ $nativeRid = if ($IsWindows) { 'win-x64' } else { 'linux-x64' }
+ $nativeSuffix = if ($IsWindows) { '.exe' } else { '' }
+ $nativeRoot = Join-Path $publishDirectory $nativeRid
+ $bogusRoot = if ($IsWindows) { 'Z:\definitely-not-installed' } else { '/definitely-not-installed' }
+ $bogusEnvironment = @{
+ DOTNET_ROOT = $bogusRoot
+ DOTNET_ROOT_X64 = $bogusRoot
+ DOTNET_MULTILEVEL_LOOKUP = '0'
+ }
+ Invoke-GateCommand 'native-launcher-bogus-dotnet-root' `
+ (Join-Path $nativeRoot "acdream-launcher$nativeSuffix") `
+ @('--verify-publish') $bogusEnvironment
+ Invoke-GateCommand 'native-bake-bogus-dotnet-root' `
+ (Join-Path $nativeRoot "acdream-bake$nativeSuffix") `
+ @('--help') $bogusEnvironment
+
+ if ($IncludeInstalledDat) {
+ $datEnvironment = @{
+ ACDREAM_DAT_DIR = $InstalledDatDirectory
+ ACDREAM_PROBE_LIVE_MOUNT = '1'
+ }
+ $datResults = Join-Path $OutputDirectory 'installed-dat-results'
+ Invoke-GateCommand 'installed-dat-character-management-readonly' 'dotnet' @(
+ 'test', 'tests/AcDream.App.Tests/AcDream.App.Tests.csproj',
+ '-c', 'Release', '--no-build', '--nologo',
+ '--filter', 'FullyQualifiedName~CharacterManagementLiveDatTests',
+ '--results-directory', $datResults,
+ '--logger', 'trx;LogFileName=character-management.trx') $datEnvironment
+ Add-InternalCheck 'installed-dat-character-management-require-pass' {
+ $trx = Join-Path $datResults 'character-management.trx'
+ if (-not (Test-Path -LiteralPath $trx -PathType Leaf)) {
+ throw 'CharacterManagementLiveDatTests did not produce a TRX result.'
+ }
+ [xml]$result = Get-Content -LiteralPath $trx -Raw
+ $outcomes = @($result.TestRun.Results.UnitTestResult | ForEach-Object { $_.outcome })
+ if ($outcomes.Count -eq 0 -or $outcomes -ccontains 'NotExecuted' -or
+ @($outcomes | Where-Object { $_ -cne 'Passed' }).Count -gt 0) {
+ throw "CharacterManagementLiveDatTests must pass (not skip): $($outcomes -join ',')."
+ }
+ }
+ Invoke-GateCommand 'installed-dat-action-map-readonly' 'dotnet' @(
+ 'test', 'tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj',
+ '-c', 'Release', '--no-build', '--nologo',
+ '--filter', 'FullyQualifiedName~RetailActionMapReader_LiveDatTests') $datEnvironment
+ Invoke-GateCommand 'installed-dat-portal-assets-readonly' 'dotnet' @(
+ 'test', 'tests/AcDream.App.Tests/AcDream.App.Tests.csproj',
+ '-c', 'Release', '--no-build', '--nologo',
+ '--filter', 'FullyQualifiedName~PortalTunnelAssetTests.InstalledDat_ResolvesRetailPortalSetupAndAnimation') $datEnvironment
+ }
+}
+catch {
+ $failures.Add((Protect-Text ($_ | Out-String)).Trim())
+}
+finally {
+ $finishedUtc = [DateTime]::UtcNow
+ $head = (& git -C $Repository rev-parse HEAD).Trim()
+ $dirtyLines = @(& git -C $Repository status --porcelain=v1 --untracked-files=all)
+ $artifacts = @()
+ if (-not $DryRun) {
+ [string[]]$artifactPaths = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse |
+ Where-Object { $_.FullName -ne (Join-Path $OutputDirectory 'report.json') } |
+ ForEach-Object {
+ [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/')
+ })
+ [Array]::Sort($artifactPaths, [StringComparer]::Ordinal)
+ $artifacts = @($artifactPaths | ForEach-Object {
+ $fullPath = Join-Path $OutputDirectory $_.Replace(
+ '/', [IO.Path]::DirectorySeparatorChar)
+ $item = Get-Item -LiteralPath $fullPath
+ [ordered]@{
+ path = $_
+ size = $item.Length
+ sha256 = (Get-FileHash -LiteralPath $fullPath -Algorithm SHA256).Hash.ToLowerInvariant()
+ }
+ })
+ }
+ $failedCommands = @($commandResults | Where-Object { $_.status -eq 'failed' })
+ $report = [ordered]@{
+ schemaVersion = 1
+ kind = 'campaign-la-automated-preflight'
+ dryRun = [bool]$DryRun
+ success = ($failures.Count -eq 0 -and $failedCommands.Count -eq 0)
+ repository = $Repository
+ allowedOutputRoot = $AllowedOutputRoot
+ head = $head
+ dirty = ($dirtyLines.Count -gt 0)
+ dirtyPaths = @($dirtyLines | ForEach-Object { Protect-Text $_ })
+ platform = [ordered]@{
+ os = [Runtime.InteropServices.RuntimeInformation]::OSDescription
+ architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()
+ processArchitecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString()
+ rid = [Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier
+ framework = [Runtime.InteropServices.RuntimeInformation]::FrameworkDescription
+ powershell = $PSVersionTable.PSVersion.ToString()
+ }
+ startedUtc = $startedUtc.ToString('O')
+ finishedUtc = $finishedUtc.ToString('O')
+ durationSeconds = [Math]::Round(($finishedUtc - $startedUtc).TotalSeconds, 3)
+ installedDatIncluded = [bool]$IncludeInstalledDat
+ commands = @($commandResults)
+ failures = @($failures)
+ redaction = [ordered]@{
+ applied = $true
+ inheritedAcdreamEnvironmentCleared = $true
+ inheritedEnvironmentValuesRead = $false
+ credentialArgumentsAllowed = $false
+ }
+ artifacts = $artifacts
+ }
+ $reportPath = Join-Path $OutputDirectory 'report.json'
+ $report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $reportPath -Encoding utf8NoBOM
+ Write-Host "Campaign LA preflight report: $reportPath"
+ if (-not $report.success) { exit 1 }
+}
diff --git a/tools/test-campaign-la-gate-helpers.ps1 b/tools/test-campaign-la-gate-helpers.ps1
new file mode 100644
index 00000000..f1751988
--- /dev/null
+++ b/tools/test-campaign-la-gate-helpers.ps1
@@ -0,0 +1,367 @@
+<#
+.SYNOPSIS
+ Connection-free contract tests for Campaign LA gate evidence helpers.
+#>
+[CmdletBinding()]
+param(
+ [string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
+ [Parameter(Mandatory = $true)][string]$OutputDirectory
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+if ($PSVersionTable.PSVersion.Major -lt 7) {
+ throw 'Campaign LA helper tests require PowerShell 7 or newer.'
+}
+$Repository = [IO.Path]::GetFullPath($Repository)
+if (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) {
+ throw '-OutputDirectory must be absolute.'
+}
+$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
+if (Test-Path -LiteralPath $OutputDirectory) {
+ throw '-OutputDirectory must be fresh.'
+}
+$null = New-Item -ItemType Directory -Path $OutputDirectory
+$pwsh = [Environment]::ProcessPath
+if ([string]::IsNullOrWhiteSpace($pwsh)) {
+ throw 'The PowerShell process path is unavailable.'
+}
+$validator = Join-Path $Repository 'tools/test-campaign-la-session-status.ps1'
+$capture = Join-Path $Repository 'tools/capture-campaign-la-session-process.ps1'
+. (Join-Path $Repository 'tools/CampaignLaProcessCorrelation.ps1')
+
+function Write-Profile([string]$Path, [string]$Secret) {
+ $document = [ordered]@{
+ version = 1
+ servers = @([ordered]@{
+ name = 'fixture'
+ host = '127.0.0.1'
+ port = 9000
+ accounts = @([ordered]@{
+ account = 'fixture-account'
+ password = $Secret
+ characters = @()
+ })
+ })
+ }
+ [IO.File]::WriteAllText(
+ $Path,
+ ($document | ConvertTo-Json -Depth 8),
+ [Text.UTF8Encoding]::new($false))
+ if ($IsLinux) {
+ [IO.File]::SetUnixFileMode(
+ $Path,
+ [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite)
+ }
+}
+
+function New-GuiEvents {
+ $begin = [DateTimeOffset]::ParseExact(
+ '2026-08-15T10:00:00.0000000+00:00',
+ 'O',
+ [Globalization.CultureInfo]::InvariantCulture)
+ $session = 'fixture-session'
+ return @(
+ [ordered]@{ v = 1; e = 'started'; t = $begin.ToString('O'); sessionId = $session },
+ [ordered]@{ v = 1; e = 'pluginLoaded'; t = $begin.AddSeconds(1).ToString('O'); sessionId = $session; plugin = 'smoke' },
+ [ordered]@{ v = 1; e = 'pluginFailed'; t = $begin.AddSeconds(2).ToString('O'); sessionId = $session; plugin = 'optional'; error = 'allowed fixture failure' },
+ [ordered]@{ v = 1; e = 'connected'; t = $begin.AddSeconds(3).ToString('O'); sessionId = $session },
+ [ordered]@{
+ v = 1; e = 'characterList'; t = $begin.AddSeconds(4).ToString('O')
+ sessionId = $session; accountName = 'fixture-account'; slotCount = 1
+ characters = @([ordered]@{ id = 1342177290; name = 'Fixture'; secondsGreyedOut = 0 })
+ },
+ [ordered]@{ v = 1; e = 'enteredWorld'; t = $begin.AddSeconds(5).ToString('O'); sessionId = $session; characterId = 1342177290; characterName = 'Fixture' },
+ [ordered]@{ v = 1; e = 'loginCommandFailed'; t = $begin.AddSeconds(6).ToString('O'); sessionId = $session; commandIndex = 0; command = '/fixture'; error = 'allowed fixture failure' },
+ [ordered]@{ v = 1; e = 'disconnected'; t = $begin.AddSeconds(7).ToString('O'); sessionId = $session; reason = 'stopped' },
+ [ordered]@{ v = 1; e = 'exited'; t = $begin.AddSeconds(8).ToString('O'); sessionId = $session; code = 0; reason = 'graceful' }
+ )
+}
+
+function Write-Events([string]$Path, [object[]]$Events) {
+ $lines = @($Events | ForEach-Object { $_ | ConvertTo-Json -Depth 8 -Compress })
+ [IO.File]::WriteAllLines($Path, $lines, [Text.UTF8Encoding]::new($false))
+}
+
+function Invoke-Validator(
+ [string]$Status,
+ [string]$Profile,
+ [string]$Report,
+ [string]$ProcessCapture,
+ [bool]$ShouldPass) {
+ $arguments = [Collections.Generic.List[string]]::new()
+ foreach ($value in @(
+ '-NoProfile', '-File', $validator,
+ '-StatusFile', $Status,
+ '-Mode', 'gui',
+ '-ProcessCapturePath', $ProcessCapture,
+ '-CredentialProfilePath', $Profile,
+ '-ExpectedPlugin', 'smoke',
+ '-AllowPluginFailure',
+ '-AllowLoginCommandFailure',
+ '-ReportPath', $Report)) {
+ $arguments.Add($value)
+ }
+ $start = [Diagnostics.ProcessStartInfo]::new($pwsh)
+ $start.UseShellExecute = $false
+ $start.CreateNoWindow = $true
+ $start.RedirectStandardOutput = $true
+ $start.RedirectStandardError = $true
+ foreach ($argument in $arguments) { $start.ArgumentList.Add($argument) }
+ $process = [Diagnostics.Process]::Start($start)
+ if ($null -eq $process) { throw 'Could not start status validator.' }
+ $stdout = $process.StandardOutput.ReadToEndAsync()
+ $stderr = $process.StandardError.ReadToEndAsync()
+ $process.WaitForExit()
+ $outText = $stdout.GetAwaiter().GetResult()
+ $errorText = $stderr.GetAwaiter().GetResult()
+ $exitCode = $process.ExitCode
+ $process.Dispose()
+ if (($exitCode -eq 0) -ne $ShouldPass) {
+ throw "Validator result mismatch (exit $exitCode). $outText $errorText"
+ }
+}
+
+function Write-ProcessCapture(
+ [string]$Path,
+ [int]$ProcessId,
+ [string]$ProcessInstanceIdentity,
+ [string]$SessionConfigPath,
+ [string]$CommandFingerprint = ('a' * 64)) {
+ $sessionId = [IO.Path]::GetFileName(
+ [IO.Path]::GetDirectoryName($SessionConfigPath))
+ $document = [ordered]@{
+ schemaVersion = 2
+ kind = 'campaign-la-session-process-capture'
+ processId = $ProcessId
+ processInstanceIdentity = $ProcessInstanceIdentity
+ sessionId = $sessionId
+ sessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath)
+ commandLineFingerprintSha256 = $CommandFingerprint
+ capturedUtc = [DateTime]::UtcNow.ToString('O')
+ }
+ [IO.File]::WriteAllText(
+ $Path,
+ ($document | ConvertTo-Json -Depth 4),
+ [Text.UTF8Encoding]::new($false))
+}
+
+$quickInfo = [Diagnostics.ProcessStartInfo]::new($pwsh)
+$quickInfo.UseShellExecute = $false
+$quickInfo.ArgumentList.Add('-NoProfile')
+$quickInfo.ArgumentList.Add('-Command')
+$quickInfo.ArgumentList.Add('exit 0')
+$quick = [Diagnostics.Process]::Start($quickInfo)
+if ($null -eq $quick) { throw 'Could not create an exited PID fixture.' }
+$goneProcessId = $quick.Id
+$quick.WaitForExit()
+$quick.Dispose()
+
+$sessionRoot = Join-Path $OutputDirectory 'fixture-session'
+$null = New-Item -ItemType Directory -Path $sessionRoot
+$sessionConfig = Join-Path $sessionRoot 'session.json'
+[IO.File]::WriteAllText($sessionConfig, '{}', [Text.UTF8Encoding]::new($false))
+$syntheticIdentity = if ($IsWindows) {
+ 'windows-creation-v1:638000000000000000'
+} else { 'linux-proc-start-v1:00000000-0000-0000-0000-000000000001:1' }
+$goneCapture = Join-Path $OutputDirectory 'gone-process.capture.json'
+Write-ProcessCapture `
+ $goneCapture $goneProcessId $syntheticIdentity $sessionConfig
+
+$profile = Join-Path $OutputDirectory 'launcher-profiles.json'
+Write-Profile $profile 'la11-positive-secret-7E477A2D'
+$positiveStatus = Join-Path $OutputDirectory 'positive.jsonl'
+Write-Events $positiveStatus (New-GuiEvents)
+Invoke-Validator `
+ $positiveStatus $profile (Join-Path $OutputDirectory 'positive.validation.json') `
+ $goneCapture $true
+
+$malformedCapture = Join-Path $OutputDirectory 'malformed-process.capture.json'
+[IO.File]::WriteAllText(
+ $malformedCapture,
+ '{"schemaVersion":2}',
+ [Text.UTF8Encoding]::new($false))
+Invoke-Validator `
+ $positiveStatus $profile (Join-Path $OutputDirectory 'malformed.validation.json') `
+ $malformedCapture $false
+
+foreach ($reason in @('transport', 'reconnect', 'other')) {
+ $events = @(New-GuiEvents)
+ $events[7].reason = $reason
+ $path = Join-Path $OutputDirectory "reason-$reason.jsonl"
+ $report = Join-Path $OutputDirectory "reason-$reason.validation.json"
+ Write-Events $path $events
+ Invoke-Validator $path $profile $report $goneCapture $false
+ $result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json
+ if (-not ($result.failures -match 'disconnected reason')) {
+ throw "Disconnected reason '$reason' was not rejected by its exact assertion."
+ }
+}
+
+$secretCases = @(
+ 'eventName', 'timestamp', 'sessionId', 'accountName', 'characterName',
+ 'enteredCharacterName', 'loadedPlugin', 'failedPlugin', 'pluginError',
+ 'command', 'commandError', 'disconnectedReason', 'exitReason')
+foreach ($case in $secretCases) {
+ $secret = "la11-secret-$case-5A7D"
+ $caseProfile = Join-Path $OutputDirectory "secret-$case.profile.json"
+ Write-Profile $caseProfile $secret
+ $events = @(New-GuiEvents)
+ switch ($case) {
+ 'eventName' { $events[0].e = $secret }
+ 'timestamp' { $events[0].t = $secret }
+ 'sessionId' { foreach ($event in $events) { $event.sessionId = $secret } }
+ 'accountName' { $events[4].accountName = $secret }
+ 'characterName' { $events[4].characters[0].name = $secret }
+ 'enteredCharacterName' { $events[5].characterName = $secret }
+ 'loadedPlugin' { $events[1].plugin = $secret }
+ 'failedPlugin' { $events[2].plugin = $secret }
+ 'pluginError' { $events[2].error = $secret }
+ 'command' { $events[6].command = $secret }
+ 'commandError' { $events[6].error = $secret }
+ 'disconnectedReason' { $events[7].reason = $secret }
+ 'exitReason' { $events[8].reason = $secret }
+ }
+ $path = Join-Path $OutputDirectory "secret-$case.jsonl"
+ $report = Join-Path $OutputDirectory "secret-$case.validation.json"
+ Write-Events $path $events
+ Invoke-Validator $path $caseProfile $report $goneCapture $false
+ $result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json
+ if (-not ($result.failures -match 'credential value')) {
+ throw "Credential echo case '$case' was not rejected by recursive scanning."
+ }
+}
+
+$fixtureSource = Join-Path `
+ $Repository 'tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/bin/Release/net10.0'
+$fixtureRoot = Join-Path $OutputDirectory 'process-fixture'
+Copy-Item -LiteralPath $fixtureSource -Destination $fixtureRoot -Recurse
+$sourceBase = 'AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder'
+$suffix = if ($IsWindows) { '.exe' } else { '' }
+$sourceHost = Join-Path $fixtureRoot "$sourceBase$suffix"
+$sameNameHost = Join-Path $fixtureRoot "acdream-headless$suffix"
+Copy-Item -LiteralPath $sourceHost -Destination $sameNameHost
+foreach ($extension in @('.runtimeconfig.json', '.deps.json')) {
+ Copy-Item -LiteralPath (Join-Path $fixtureRoot "$sourceBase$extension") `
+ -Destination (Join-Path $fixtureRoot "acdream-headless$extension")
+}
+if ($IsLinux) {
+ [IO.File]::SetUnixFileMode(
+ $sameNameHost,
+ [IO.File]::GetUnixFileMode($sourceHost))
+}
+
+$targetReady = Join-Path $OutputDirectory 'target.ready'
+$targetRelease = Join-Path $OutputDirectory 'target.release'
+$unrelatedReady = Join-Path $OutputDirectory 'unrelated.ready'
+$unrelatedRelease = Join-Path $OutputDirectory 'unrelated.release'
+$unrelatedSessionRoot = Join-Path $OutputDirectory 'unrelated-session'
+$null = New-Item -ItemType Directory -Path $unrelatedSessionRoot
+$unrelatedConfig = Join-Path $unrelatedSessionRoot 'session.json'
+[IO.File]::WriteAllText($unrelatedConfig, '{}', [Text.UTF8Encoding]::new($false))
+
+function Start-Fixture([string[]]$Arguments) {
+ $start = [Diagnostics.ProcessStartInfo]::new($sameNameHost)
+ $start.UseShellExecute = $false
+ $start.CreateNoWindow = $true
+ foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) }
+ return [Diagnostics.Process]::Start($start)
+}
+
+$target = Start-Fixture @(
+ 'hold-campaign-la-process', '--config', $sessionConfig, $targetReady, $targetRelease)
+$unrelated = Start-Fixture @(
+ 'hold-campaign-la-process', '--config', $unrelatedConfig,
+ $unrelatedReady, $unrelatedRelease)
+if ($null -eq $target -or $null -eq $unrelated) {
+ throw 'Could not start process-correlation fixtures.'
+}
+try {
+ $deadline = [DateTime]::UtcNow.AddSeconds(10)
+ while ((-not (Test-Path -LiteralPath $targetReady) -or
+ -not (Test-Path -LiteralPath $unrelatedReady)) -and
+ [DateTime]::UtcNow -lt $deadline) {
+ Start-Sleep -Milliseconds 50
+ }
+ if (-not (Test-Path -LiteralPath $targetReady) -or
+ -not (Test-Path -LiteralPath $unrelatedReady)) {
+ throw 'Process-correlation fixtures did not become ready.'
+ }
+
+ $captureReport = Join-Path $OutputDirectory 'process-capture.json'
+ & $pwsh -NoProfile -File $capture `
+ -SessionConfigPath $sessionConfig -ReportPath $captureReport
+ if ($LASTEXITCODE -ne 0) { throw 'Process capture failed.' }
+ $captured = Get-Content -LiteralPath $captureReport -Raw | ConvertFrom-Json
+ if ([int]$captured.processId -ne $target.Id -or
+ [string]$captured.sessionConfigPath -cne $sessionConfig -or
+ [string]$captured.commandLineFingerprintSha256 -cnotmatch '^[0-9a-f]{64}$') {
+ throw 'Process capture did not return exact sanitized instance evidence.'
+ }
+
+ $liveReport = Join-Path $OutputDirectory 'live-pid.validation.json'
+ Invoke-Validator `
+ $positiveStatus $profile $liveReport $captureReport $false
+ $liveResult = Get-Content -LiteralPath $liveReport -Raw | ConvertFrom-Json
+ if (-not ($liveResult.failures -match 'process instance.*remains alive') -or
+ $liveResult.capturedProcessInstanceExited) {
+ throw 'A live exact child instance was not rejected by the terminal validator.'
+ }
+
+ Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline
+ $target.WaitForExit()
+ Invoke-Validator `
+ $positiveStatus $profile `
+ (Join-Path $OutputDirectory 'unrelated-same-name.validation.json') `
+ $captureReport $true
+
+ $reusedCapture = Join-Path $OutputDirectory 'reused-pid.capture.json'
+ $capturedIdentity = [string]$captured.processInstanceIdentity
+ $identitySeparator = $capturedIdentity.LastIndexOf(':')
+ $capturedStartValue = [uint64]::Parse(
+ $capturedIdentity.Substring($identitySeparator + 1),
+ [Globalization.CultureInfo]::InvariantCulture)
+ $reusedPriorIdentity = $capturedIdentity.Substring(0, $identitySeparator + 1) `
+ + ($capturedStartValue + 1).ToString(
+ [Globalization.CultureInfo]::InvariantCulture)
+ Write-ProcessCapture `
+ $reusedCapture `
+ $unrelated.Id `
+ $reusedPriorIdentity `
+ $sessionConfig `
+ ([string]$captured.commandLineFingerprintSha256)
+ $reusedReport = Join-Path $OutputDirectory 'reused-pid.validation.json'
+ Invoke-Validator $positiveStatus $profile $reusedReport $reusedCapture $true
+ $reusedResult = Get-Content -LiteralPath $reusedReport -Raw | ConvertFrom-Json
+ if (-not $reusedResult.capturedPidReused -or
+ -not $reusedResult.capturedProcessInstanceExited -or
+ -not $reusedResult.sessionConfigProcessExited) {
+ throw 'A reused PID was not distinguished from the exited captured instance.'
+ }
+}
+finally {
+ Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline
+ Set-Content -LiteralPath $unrelatedRelease -Value 'release' -NoNewline
+ if (-not $target.HasExited) { $target.WaitForExit() }
+ if (-not $unrelated.HasExited) { $unrelated.WaitForExit() }
+ $target.Dispose()
+ $unrelated.Dispose()
+}
+
+$summary = [ordered]@{
+ schemaVersion = 1
+ kind = 'campaign-la-gate-helper-tests'
+ success = $true
+ disconnectedReasonNegatives = 3
+ credentialStringFieldNegatives = $secretCases.Count
+ exactPidCapture = $true
+ stableProcessInstanceCapture = $true
+ liveProcessInstanceRejected = $true
+ malformedProcessCaptureRejected = $true
+ injectedPidReuseIgnored = $true
+ unrelatedSameNameIgnored = $true
+ platform = if ($IsWindows) { 'windows' } else { 'linux' }
+}
+$summary | ConvertTo-Json -Depth 4 |
+ Set-Content -LiteralPath (Join-Path $OutputDirectory 'summary.json') -Encoding utf8NoBOM
+Write-Host "Campaign LA gate helper tests: $OutputDirectory"
diff --git a/tools/test-campaign-la-script-safety.ps1 b/tools/test-campaign-la-script-safety.ps1
new file mode 100644
index 00000000..d7f93c90
--- /dev/null
+++ b/tools/test-campaign-la-script-safety.ps1
@@ -0,0 +1,378 @@
+<#
+.SYNOPSIS
+ Connection-free negative and determinism tests for Campaign LA scripts.
+#>
+[CmdletBinding()]
+param(
+ [string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
+ [Parameter(Mandatory = $true)][string]$OutputDirectory
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+if ($PSVersionTable.PSVersion.Major -lt 7) {
+ throw 'Campaign LA script-safety tests require PowerShell 7 or newer.'
+}
+$Repository = [IO.Path]::GetFullPath($Repository)
+$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
+if (Test-Path -LiteralPath $OutputDirectory) {
+ throw '-OutputDirectory must be fresh.'
+}
+$null = New-Item -ItemType Directory -Path $OutputDirectory
+$pwsh = [Environment]::ProcessPath
+if ([string]::IsNullOrWhiteSpace($pwsh)) {
+ throw 'The PowerShell process path is unavailable.'
+}
+$preflight = Join-Path $Repository 'tools/run-campaign-la-preflight.ps1'
+$fixture = Join-Path $Repository 'tools/new-campaign-la-update-fixture.ps1'
+$negativeCount = 0
+
+function Invoke-Expected(
+ [string]$Script,
+ [string[]]$Arguments,
+ [bool]$ShouldPass,
+ [string]$Name) {
+ $start = [Diagnostics.ProcessStartInfo]::new($pwsh)
+ $start.UseShellExecute = $false
+ $start.CreateNoWindow = $true
+ $start.RedirectStandardOutput = $true
+ $start.RedirectStandardError = $true
+ $start.ArgumentList.Add('-NoProfile')
+ $start.ArgumentList.Add('-File')
+ $start.ArgumentList.Add($Script)
+ foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) }
+ $process = [Diagnostics.Process]::Start($start)
+ if ($null -eq $process) { throw "Could not start safety case '$Name'." }
+ $stdout = $process.StandardOutput.ReadToEndAsync()
+ $stderr = $process.StandardError.ReadToEndAsync()
+ $process.WaitForExit()
+ $outText = $stdout.GetAwaiter().GetResult()
+ $errorText = $stderr.GetAwaiter().GetResult()
+ $exitCode = $process.ExitCode
+ $process.Dispose()
+ if (($exitCode -eq 0) -ne $ShouldPass) {
+ throw "Safety case '$Name' result mismatch (exit $exitCode). $outText $errorText"
+ }
+ if (-not $ShouldPass) { $script:negativeCount++ }
+}
+
+$allowed = Join-Path $OutputDirectory 'campaign-la-preflight-safety'
+$null = New-Item -ItemType Directory -Path $allowed
+Invoke-Expected $preflight @(
+ '-Repository', $Repository,
+ '-AllowedOutputRoot', $allowed,
+ '-OutputDirectory', (Join-Path $allowed 'positive'),
+ '-DryRun') $true 'preflight-positive'
+
+$existingEmpty = Join-Path $allowed 'existing-empty'
+$null = New-Item -ItemType Directory -Path $existingEmpty
+Invoke-Expected $preflight @(
+ '-Repository', $Repository,
+ '-AllowedOutputRoot', $allowed,
+ '-OutputDirectory', $existingEmpty,
+ '-DryRun') $false 'preflight-existing-empty'
+
+$existingNonempty = Join-Path $allowed 'existing-nonempty'
+$null = New-Item -ItemType Directory -Path $existingNonempty
+Set-Content -LiteralPath (Join-Path $existingNonempty 'owner') -Value 'preserve'
+Invoke-Expected $preflight @(
+ '-Repository', $Repository,
+ '-AllowedOutputRoot', $allowed,
+ '-OutputDirectory', $existingNonempty,
+ '-DryRun') $false 'preflight-existing-nonempty'
+
+$payloadRootRefusal = Join-Path $OutputDirectory 'update-payloads'
+$null = New-Item -ItemType Directory -Path $payloadRootRefusal
+foreach ($case in @(
+ [pscustomobject]@{ Name = 'preflight-root'; Allowed = $Repository; Output = (Join-Path $Repository 'blocked') },
+ [pscustomobject]@{ Name = 'preflight-home'; Allowed = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile); Output = (Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)) 'blocked') },
+ [pscustomobject]@{ Name = 'preflight-source'; Allowed = (Join-Path $Repository 'src'); Output = (Join-Path $Repository 'src/blocked') },
+ [pscustomobject]@{ Name = 'preflight-payload'; Allowed = $payloadRootRefusal; Output = (Join-Path $payloadRootRefusal 'blocked') },
+ [pscustomobject]@{ Name = 'preflight-outside'; Allowed = $allowed; Output = (Join-Path $OutputDirectory 'outside') },
+ [pscustomobject]@{ Name = 'preflight-allowed-root-itself'; Allowed = $allowed; Output = $allowed })) {
+ Invoke-Expected $preflight @(
+ '-Repository', $Repository,
+ '-AllowedOutputRoot', $case.Allowed,
+ '-OutputDirectory', $case.Output,
+ '-DryRun') $false $case.Name
+}
+
+$reparseTarget = Join-Path $OutputDirectory 'campaign-la-reparse-target'
+$reparseRoot = Join-Path $OutputDirectory 'campaign-la-reparse-link'
+$null = New-Item -ItemType Directory -Path $reparseTarget
+if ($IsWindows) {
+ $null = New-Item -ItemType Junction -Path $reparseRoot -Target $reparseTarget
+}
+else {
+ $null = New-Item -ItemType SymbolicLink -Path $reparseRoot -Target $reparseTarget
+}
+Invoke-Expected $preflight @(
+ '-Repository', $Repository,
+ '-AllowedOutputRoot', $reparseRoot,
+ '-OutputDirectory', (Join-Path $reparseRoot 'blocked'),
+ '-DryRun') $false 'preflight-reparse-root'
+
+$source = Join-Path $OutputDirectory 'payload-source'
+$null = New-Item -ItemType Directory -Path $source
+function Fixture-DryArguments([string]$Destination, [string]$PayloadSource) {
+ return @(
+ '-OutputDirectory', $Destination,
+ '-ClientWinX64DirectoryA', $PayloadSource,
+ '-LauncherWinX64DirectoryA', $PayloadSource,
+ '-ClientLinuxX64DirectoryA', $PayloadSource,
+ '-LauncherLinuxX64DirectoryA', $PayloadSource,
+ '-ClientWinX64DirectoryB', $PayloadSource,
+ '-LauncherWinX64DirectoryB', $PayloadSource,
+ '-ClientLinuxX64DirectoryB', $PayloadSource,
+ '-LauncherLinuxX64DirectoryB', $PayloadSource,
+ '-DryRun')
+}
+Invoke-Expected $fixture (Fixture-DryArguments (Join-Path $source 'child') $source) `
+ $false 'fixture-output-inside-source'
+Invoke-Expected $fixture (Fixture-DryArguments $source (Join-Path $source 'child-source')) `
+ $false 'fixture-source-inside-output'
+Invoke-Expected $fixture (Fixture-DryArguments $source $source) `
+ $false 'fixture-output-equals-source'
+$nearMatch = Join-Path $OutputDirectory 'payload-source-near'
+Invoke-Expected $fixture (Fixture-DryArguments $nearMatch $source) `
+ $true 'fixture-near-match'
+
+$sourceLink = Join-Path $OutputDirectory 'payload-source-link'
+if ($IsWindows) {
+ $null = New-Item -ItemType Junction -Path $sourceLink -Target $source
+}
+else {
+ $null = New-Item -ItemType SymbolicLink -Path $sourceLink -Target $source
+}
+Invoke-Expected $fixture (
+ Fixture-DryArguments (Join-Path $OutputDirectory 'reparse-source-output') $sourceLink) `
+ $false 'fixture-reparse-source'
+$outputTarget = Join-Path $OutputDirectory 'fixture-output-target'
+$outputLink = Join-Path $OutputDirectory 'fixture-output-link'
+$null = New-Item -ItemType Directory -Path $outputTarget
+if ($IsWindows) {
+ $null = New-Item -ItemType Junction -Path $outputLink -Target $outputTarget
+}
+else {
+ $null = New-Item -ItemType SymbolicLink -Path $outputLink -Target $outputTarget
+}
+Invoke-Expected $fixture (Fixture-DryArguments $outputLink $source) `
+ $false 'fixture-reparse-output'
+
+function Write-PayloadFile([string]$Root, [string]$Name, [string]$Content) {
+ $path = Join-Path $Root $Name
+ $directory = Split-Path -Parent $path
+ $null = New-Item -ItemType Directory -Force -Path $directory
+ [IO.File]::WriteAllText($path, $Content, [Text.UTF8Encoding]::new($false))
+}
+
+function Get-ZipUInt16([byte[]]$Bytes, [int]$Offset) {
+ return [int]$Bytes[$Offset] -bor ([int]$Bytes[$Offset + 1] -shl 8)
+}
+function Get-ZipUInt32([byte[]]$Bytes, [int]$Offset) {
+ return [uint32]([uint32]$Bytes[$Offset] -bor
+ ([uint32]$Bytes[$Offset + 1] -shl 8) -bor
+ ([uint32]$Bytes[$Offset + 2] -shl 16) -bor
+ ([uint32]$Bytes[$Offset + 3] -shl 24))
+}
+function Test-ZipExecutableName([string]$Name) {
+ return $Name -cin @(
+ 'AcDream.App', 'acdream-headless', 'acdream-launcher', 'acdream-bake')
+}
+function Assert-ZipUnixMetadata([string]$Path) {
+ [byte[]]$bytes = [IO.File]::ReadAllBytes($Path)
+ $eocd = $bytes.Length - 22
+ if ($eocd -lt 0 -or (Get-ZipUInt32 $bytes $eocd) -ne 0x06054b50 -or
+ (Get-ZipUInt16 $bytes ($eocd + 20)) -ne 0) {
+ throw "Fixture ZIP end record is invalid: $Path"
+ }
+ $entryCount = Get-ZipUInt16 $bytes ($eocd + 10)
+ $centralSize = Get-ZipUInt32 $bytes ($eocd + 12)
+ [uint64]$cursor = Get-ZipUInt32 $bytes ($eocd + 16)
+ $centralEnd = $cursor + $centralSize
+ if ($centralEnd -ne $eocd) { throw "Fixture ZIP central bounds are invalid: $Path" }
+ $rawModes = @{}
+ for ($index = 0; $index -lt $entryCount; $index++) {
+ if ($cursor + 46 -gt $centralEnd -or
+ (Get-ZipUInt32 $bytes ([int]$cursor)) -ne 0x02014b50) {
+ throw "Fixture ZIP central entry is invalid: $Path"
+ }
+ if ($bytes[[int]$cursor + 5] -ne 3) {
+ throw "Fixture ZIP entry origin is not Unix: $Path"
+ }
+ $nameLength = Get-ZipUInt16 $bytes ([int]$cursor + 28)
+ $extraLength = Get-ZipUInt16 $bytes ([int]$cursor + 30)
+ $commentLength = Get-ZipUInt16 $bytes ([int]$cursor + 32)
+ $name = [Text.Encoding]::UTF8.GetString(
+ $bytes,
+ [int]$cursor + 46,
+ $nameLength)
+ $expectedMode = if (Test-ZipExecutableName $name) { 0x81ED } else { 0x81A4 }
+ $external = Get-ZipUInt32 $bytes ([int]$cursor + 38)
+ $expectedExternal = [uint32](([uint64]$expectedMode) -shl 16)
+ if ($external -ne $expectedExternal) {
+ throw "Fixture ZIP entry '$name' has wrong raw type/mode bits."
+ }
+ $rawModes[$name] = $expectedMode
+ $cursor += 46 + $nameLength + $extraLength + $commentLength
+ }
+ if ($cursor -ne $centralEnd) { throw "Fixture ZIP central length is invalid: $Path" }
+
+ Add-Type -AssemblyName System.IO.Compression
+ $stream = [IO.File]::OpenRead($Path)
+ try {
+ $archive = [IO.Compression.ZipArchive]::new(
+ $stream,
+ [IO.Compression.ZipArchiveMode]::Read,
+ $false,
+ [Text.Encoding]::UTF8)
+ try {
+ if ($archive.Entries.Count -ne $rawModes.Count) {
+ throw "Fixture ZIP entry count changed through ZipArchive: $Path"
+ }
+ foreach ($entry in $archive.Entries) {
+ $mode = ($entry.ExternalAttributes -shr 16) -band 0xffff
+ if (-not $rawModes.ContainsKey($entry.FullName) -or
+ $mode -ne $rawModes[$entry.FullName]) {
+ throw "ZipArchive reports wrong type/mode for '$($entry.FullName)'."
+ }
+ }
+ }
+ finally { $archive.Dispose() }
+ }
+ finally { $stream.Dispose() }
+}
+
+$payloadRoot = Join-Path $OutputDirectory 'deterministic-payloads'
+$payloads = [ordered]@{
+ ClientWin = Join-Path $payloadRoot 'client-win'
+ LauncherWin = Join-Path $payloadRoot 'launcher-win'
+ ClientLinux = Join-Path $payloadRoot 'client-linux'
+ LauncherLinux = Join-Path $payloadRoot 'launcher-linux'
+}
+foreach ($directory in $payloads.Values) {
+ foreach ($entry in @(
+ @('nested/I.txt', 'I'), @('nested/Z.txt', 'Z'),
+ @('nested/ä.txt', 'a-umlaut'), @('nested/ı.txt', 'dotless-i'))) {
+ Write-PayloadFile $directory $entry[0] $entry[1]
+ }
+}
+Write-PayloadFile $payloads.ClientWin 'AcDream.App.exe' 'client-win-gui'
+Write-PayloadFile $payloads.ClientWin 'acdream-headless.exe' 'client-win-headless'
+Write-PayloadFile $payloads.LauncherWin 'acdream-launcher.exe' 'launcher-win'
+Write-PayloadFile $payloads.LauncherWin 'acdream-bake.exe' 'bake-win'
+Write-PayloadFile $payloads.ClientLinux 'AcDream.App' 'client-linux-gui'
+Write-PayloadFile $payloads.ClientLinux 'acdream-headless' 'client-linux-headless'
+Write-PayloadFile $payloads.LauncherLinux 'acdream-launcher' 'launcher-linux'
+Write-PayloadFile $payloads.LauncherLinux 'acdream-bake' 'bake-linux'
+
+$fixtureParameters = @{
+ ClientWinX64DirectoryA = $payloads.ClientWin
+ LauncherWinX64DirectoryA = $payloads.LauncherWin
+ ClientLinuxX64DirectoryA = $payloads.ClientLinux
+ LauncherLinuxX64DirectoryA = $payloads.LauncherLinux
+ ClientWinX64DirectoryB = $payloads.ClientWin
+ LauncherWinX64DirectoryB = $payloads.LauncherWin
+ ClientLinuxX64DirectoryB = $payloads.ClientLinux
+ LauncherLinuxX64DirectoryB = $payloads.LauncherLinux
+}
+$inventories = [Collections.Generic.List[object]]::new()
+$originalCulture = [Globalization.CultureInfo]::CurrentCulture
+$originalUiCulture = [Globalization.CultureInfo]::CurrentUICulture
+try {
+ foreach ($cultureName in @('en-US', 'tr-TR', 'sv-SE')) {
+ $culture = [Globalization.CultureInfo]::GetCultureInfo($cultureName)
+ [Globalization.CultureInfo]::CurrentCulture = $culture
+ [Globalization.CultureInfo]::CurrentUICulture = $culture
+ $destination = Join-Path $OutputDirectory "fixture-$cultureName"
+ & $fixture -OutputDirectory $destination @fixtureParameters
+ foreach ($zip in @(Get-ChildItem -LiteralPath $destination -Filter '*.zip' -File -Recurse)) {
+ Assert-ZipUnixMetadata $zip.FullName
+ }
+ $relativePaths = [string[]]@(Get-ChildItem -LiteralPath $destination -File -Recurse |
+ Where-Object { $_.Name -ne 'fixture-report.json' } |
+ ForEach-Object {
+ [IO.Path]::GetRelativePath($destination, $_.FullName).Replace('\', '/')
+ })
+ [Array]::Sort($relativePaths, [StringComparer]::Ordinal)
+ $inventory = @($relativePaths | ForEach-Object {
+ $path = Join-Path $destination $_.Replace('/', [IO.Path]::DirectorySeparatorChar)
+ "$_|$((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant())"
+ })
+ $inventories.Add($inventory)
+ }
+}
+finally {
+ [Globalization.CultureInfo]::CurrentCulture = $originalCulture
+ [Globalization.CultureInfo]::CurrentUICulture = $originalUiCulture
+}
+$firstInventory = [string]::Join("`n", [string[]]$inventories[0])
+foreach ($inventory in $inventories) {
+ if ([string]::Join("`n", [string[]]$inventory) -cne $firstInventory) {
+ throw 'Fixture hashes changed with the current culture.'
+ }
+}
+$digestBytes = [Security.Cryptography.SHA256]::HashData(
+ [Text.Encoding]::UTF8.GetBytes($firstInventory))
+$deterministicDigest = [Convert]::ToHexString($digestBytes).ToLowerInvariant()
+$expectedCrossPlatformDigest =
+ 'cc58d5717de6686690b7f01213c9d52a99aef49447ff645e134f8c97ec8e3a76'
+if ($deterministicDigest -cne $expectedCrossPlatformDigest) {
+ throw "Fixture artifact hashes differ from the pinned Windows/Linux contract: actual $deterministicDigest."
+}
+
+$nativeExtractionModesValidated = $false
+if ($IsLinux) {
+ $unzip = @(Get-Command unzip -CommandType Application -ErrorAction Stop)[0].Source
+ $extractClient = Join-Path $OutputDirectory 'native-extract-client'
+ $extractLauncher = Join-Path $OutputDirectory 'native-extract-launcher'
+ $null = New-Item -ItemType Directory -Path $extractClient
+ $null = New-Item -ItemType Directory -Path $extractLauncher
+ & $unzip -qq (Join-Path $OutputDirectory 'fixture-en-US/A/client-linux-x64.zip') `
+ -d $extractClient
+ if ($LASTEXITCODE -ne 0) { throw 'Native client ZIP extraction failed.' }
+ & $unzip -qq (Join-Path $OutputDirectory 'fixture-en-US/A/launcher-linux-x64.zip') `
+ -d $extractLauncher
+ if ($LASTEXITCODE -ne 0) { throw 'Native launcher ZIP extraction failed.' }
+ $mode755 = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor
+ [IO.UnixFileMode]::UserExecute -bor [IO.UnixFileMode]::GroupRead -bor
+ [IO.UnixFileMode]::GroupExecute -bor [IO.UnixFileMode]::OtherRead -bor
+ [IO.UnixFileMode]::OtherExecute
+ $mode644 = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor
+ [IO.UnixFileMode]::GroupRead -bor [IO.UnixFileMode]::OtherRead
+ foreach ($path in @(
+ (Join-Path $extractClient 'AcDream.App'),
+ (Join-Path $extractClient 'acdream-headless'),
+ (Join-Path $extractLauncher 'acdream-launcher'),
+ (Join-Path $extractLauncher 'acdream-bake'))) {
+ if ([IO.File]::GetUnixFileMode($path) -ne $mode755) {
+ throw "Native extraction did not retain mode 0755: $path"
+ }
+ }
+ foreach ($path in @(
+ (Join-Path $extractClient 'nested/I.txt'),
+ (Join-Path $extractClient 'campaign-la-fixture-release.txt'),
+ (Join-Path $extractLauncher 'nested/Z.txt'),
+ (Join-Path $extractLauncher 'campaign-la-fixture-release.txt'))) {
+ if ([IO.File]::GetUnixFileMode($path) -ne $mode644) {
+ throw "Native extraction did not retain mode 0644: $path"
+ }
+ }
+ $nativeExtractionModesValidated = $true
+}
+
+$summary = [ordered]@{
+ schemaVersion = 1
+ kind = 'campaign-la-script-safety-tests'
+ success = $true
+ negativeCases = $negativeCount
+ cultures = @('en-US', 'tr-TR', 'sv-SE')
+ fixtureArtifactSetSha256 = $deterministicDigest
+ crossPlatformExpectedSha256 = $expectedCrossPlatformDigest
+ zipOrigin = 'unix'
+ zipModesValidated = $true
+ nativeExtractionModesValidated = $nativeExtractionModesValidated
+}
+$summary | ConvertTo-Json -Depth 5 |
+ Set-Content -LiteralPath (Join-Path $OutputDirectory 'summary.json') -Encoding utf8NoBOM
+Write-Host "Campaign LA script safety tests: $OutputDirectory"
diff --git a/tools/test-campaign-la-session-status.ps1 b/tools/test-campaign-la-session-status.ps1
new file mode 100644
index 00000000..ce89fcd2
--- /dev/null
+++ b/tools/test-campaign-la-session-status.ps1
@@ -0,0 +1,536 @@
+<#
+.SYNOPSIS
+ Strict Campaign LA v1 session-status and terminal-process validator.
+
+.DESCRIPTION
+ Validates exact JSONL property sets and property order, lifecycle order for
+ probe/guiSelect/gui/headless, terminal semantics, plugin expectations,
+ credential redaction, and absence of launcher child-process leaks. The
+ report contains hashes and event names only; it does not copy account,
+ character, command, plugin-error, or other payload text.
+#>
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)][string]$StatusFile,
+ [Parameter(Mandatory = $true)]
+ [ValidateSet('probe', 'guiSelect', 'gui', 'headless')][string]$Mode,
+ [Parameter(Mandatory = $true)][string]$ProcessCapturePath,
+ [Parameter(Mandatory = $true)][string]$CredentialProfilePath,
+ [string]$ExpectedSessionId,
+ [string[]]$ExpectedPlugin = @(),
+ [switch]$ExpectNoEnteredWorld,
+ [switch]$AllowPluginFailure,
+ [switch]$AllowLoginCommandFailure,
+ [string]$ReportPath,
+ [int]$ProcessExitWaitSeconds = 5
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+if ($PSVersionTable.PSVersion.Major -lt 7) {
+ throw 'Campaign LA status validation requires PowerShell 7 or newer.'
+}
+if ($ExpectNoEnteredWorld -and $Mode -ne 'guiSelect') {
+ throw '-ExpectNoEnteredWorld is valid only for a guiSelect row.'
+}
+. (Join-Path $PSScriptRoot 'CampaignLaProcessCorrelation.ps1')
+if (-not [IO.Path]::IsPathFullyQualified($StatusFile)) {
+ $StatusFile = [IO.Path]::GetFullPath($StatusFile)
+}
+if (-not (Test-Path -LiteralPath $StatusFile -PathType Leaf)) {
+ throw "Status file does not exist: $StatusFile"
+}
+if (-not [IO.Path]::IsPathFullyQualified($ProcessCapturePath)) {
+ throw '-ProcessCapturePath must be absolute.'
+}
+$ProcessCapturePath = [IO.Path]::GetFullPath($ProcessCapturePath)
+if (-not (Test-Path -LiteralPath $ProcessCapturePath -PathType Leaf)) {
+ throw "Process capture does not exist: $ProcessCapturePath"
+}
+$captureItem = Get-Item -LiteralPath $ProcessCapturePath -Force
+if (($captureItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw 'Process capture must not be a reparse point.'
+}
+$captureDocument = [Text.Json.JsonDocument]::Parse(
+ [IO.File]::ReadAllText($ProcessCapturePath))
+try {
+ $captureRoot = $captureDocument.RootElement
+ if ($captureRoot.ValueKind -ne [Text.Json.JsonValueKind]::Object) {
+ throw 'Process capture root must be an object.'
+ }
+ $captureNames = @($captureRoot.EnumerateObject() | ForEach-Object { $_.Name })
+ $expectedCaptureNames = @(
+ 'schemaVersion', 'kind', 'processId', 'processInstanceIdentity',
+ 'sessionId', 'sessionConfigPath', 'commandLineFingerprintSha256',
+ 'capturedUtc')
+ if ([string]::Join("`n", $captureNames) -cne
+ [string]::Join("`n", $expectedCaptureNames)) {
+ throw 'Process capture fields/order do not match schema v2.'
+ }
+ if ($captureRoot.GetProperty('schemaVersion').GetInt32() -ne 2 -or
+ $captureRoot.GetProperty('kind').GetString() -cne
+ 'campaign-la-session-process-capture') {
+ throw 'Process capture schema/kind is invalid.'
+ }
+ $capturedProcessId = $captureRoot.GetProperty('processId').GetInt32()
+ if ($capturedProcessId -le 0) { throw 'Process capture PID is invalid.' }
+ $capturedProcessIdentity = $captureRoot.GetProperty(
+ 'processInstanceIdentity').GetString()
+ $expectedIdentityPattern = if ($IsWindows) {
+ '^windows-creation-v1:[0-9]{15,19}$'
+ } else { '^linux-proc-start-v1:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9]+$' }
+ if ($capturedProcessIdentity -notmatch $expectedIdentityPattern) {
+ throw 'Process capture instance identity is invalid for this platform.'
+ }
+ $capturedSessionId = $captureRoot.GetProperty('sessionId').GetString()
+ if ([string]::IsNullOrWhiteSpace($capturedSessionId) -or
+ $capturedSessionId.IndexOfAny([IO.Path]::GetInvalidFileNameChars()) -ge 0) {
+ throw 'Process capture session id is invalid.'
+ }
+ $capturedSessionConfigPath = $captureRoot.GetProperty(
+ 'sessionConfigPath').GetString()
+ if (-not [IO.Path]::IsPathFullyQualified($capturedSessionConfigPath)) {
+ throw 'Process capture session-config path is not absolute.'
+ }
+ $normalizedCapturedConfigPath = [IO.Path]::GetFullPath(
+ $capturedSessionConfigPath)
+ if ($capturedSessionConfigPath -cne $normalizedCapturedConfigPath -or
+ [IO.Path]::GetFileName($capturedSessionConfigPath) -cne 'session.json' -or
+ [IO.Path]::GetFileName([IO.Path]::GetDirectoryName(
+ $capturedSessionConfigPath)) -cne $capturedSessionId) {
+ throw 'Process capture session-config path is not the exact normalized session path.'
+ }
+ $capturedCommandFingerprint = $captureRoot.GetProperty(
+ 'commandLineFingerprintSha256').GetString()
+ if ($capturedCommandFingerprint -cnotmatch '^[0-9a-f]{64}$') {
+ throw 'Process capture command-line fingerprint is invalid.'
+ }
+ $capturedUtcText = $captureRoot.GetProperty('capturedUtc').GetString()
+ $capturedUtc = [DateTimeOffset]::MinValue
+ if (-not [DateTimeOffset]::TryParseExact(
+ $capturedUtcText,
+ 'O',
+ [Globalization.CultureInfo]::InvariantCulture,
+ [Globalization.DateTimeStyles]::RoundtripKind,
+ [ref]$capturedUtc) -or $capturedUtc.Offset -ne [TimeSpan]::Zero) {
+ throw 'Process capture timestamp is not exact UTC round-trip form.'
+ }
+}
+finally { $captureDocument.Dispose() }
+if (-not [string]::IsNullOrWhiteSpace($ExpectedSessionId) -and
+ $ExpectedSessionId -cne $capturedSessionId) {
+ throw 'Process capture session id does not match -ExpectedSessionId.'
+}
+if (-not [IO.Path]::IsPathFullyQualified($CredentialProfilePath)) {
+ throw '-CredentialProfilePath must be absolute.'
+}
+$CredentialProfilePath = [IO.Path]::GetFullPath($CredentialProfilePath)
+if (-not (Test-Path -LiteralPath $CredentialProfilePath -PathType Leaf)) {
+ throw "Credential profile does not exist: $CredentialProfilePath"
+}
+$credentialItem = Get-Item -LiteralPath $CredentialProfilePath -Force
+if (($credentialItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw 'Credential profile must not be a reparse point.'
+}
+if ($IsLinux) {
+ $ownerOnly = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite
+ if ([IO.File]::GetUnixFileMode($CredentialProfilePath) -ne $ownerOnly) {
+ throw 'Credential profile must have exact owner-only mode 0600.'
+ }
+}
+elseif ($IsWindows) {
+ $broadSids = @(
+ 'S-1-1-0', # Everyone
+ 'S-1-5-11', # Authenticated Users
+ 'S-1-5-32-545', # Builtin Users
+ 'S-1-5-32-546') # Guests
+ $acl = Get-Acl -LiteralPath $CredentialProfilePath
+ if ($null -eq $acl.Owner) { throw 'Credential profile has no ACL owner.' }
+ foreach ($rule in $acl.Access) {
+ if ($rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow) {
+ continue
+ }
+ try {
+ $sid = $rule.IdentityReference.Translate(
+ [Security.Principal.SecurityIdentifier]).Value
+ }
+ catch { $sid = [string]$rule.IdentityReference.Value }
+ if ($sid -in $broadSids -and $rule.FileSystemRights -ne 0) {
+ throw 'Credential profile grants access to a broad Windows identity.'
+ }
+ }
+}
+else { throw 'Campaign LA status validation supports Windows and Linux only.' }
+if ([string]::IsNullOrWhiteSpace($ReportPath)) {
+ $ReportPath = "$StatusFile.validation.json"
+}
+elseif (-not [IO.Path]::IsPathFullyQualified($ReportPath)) {
+ $ReportPath = [IO.Path]::GetFullPath($ReportPath)
+}
+
+$exactFields = @{
+ started = @('v', 'e', 't', 'sessionId')
+ connected = @('v', 'e', 't', 'sessionId')
+ characterList = @('v', 'e', 't', 'sessionId', 'accountName', 'slotCount', 'characters')
+ enteredWorld = @('v', 'e', 't', 'sessionId', 'characterId', 'characterName')
+ pluginLoaded = @('v', 'e', 't', 'sessionId', 'plugin')
+ pluginFailed = @('v', 'e', 't', 'sessionId', 'plugin', 'error')
+ loginCommandFailed = @('v', 'e', 't', 'sessionId', 'commandIndex', 'command', 'error')
+ disconnected = @('v', 'e', 't', 'sessionId', 'reason')
+ exited = @('v', 'e', 't', 'sessionId', 'code', 'reason')
+}
+$failures = [Collections.Generic.List[string]]::new()
+$eventNames = [Collections.Generic.List[string]]::new()
+$loadedPlugins = [Collections.Generic.List[string]]::new()
+$sessionId = $null
+$previousTimestamp = [DateTimeOffset]::MinValue
+$terminalSeen = $false
+$forbiddenValues = [Collections.Generic.HashSet[string]]::new(
+ [StringComparer]::Ordinal)
+
+function Add-CredentialValues([Text.Json.JsonElement]$Element) {
+ if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Object) {
+ foreach ($property in $Element.EnumerateObject()) {
+ if ($property.Name -imatch '^(password|secret)$' -and
+ $property.Value.ValueKind -eq [Text.Json.JsonValueKind]::String) {
+ $value = $property.Value.GetString()
+ if (-not [string]::IsNullOrEmpty($value)) {
+ $null = $forbiddenValues.Add($value)
+ }
+ }
+ Add-CredentialValues $property.Value
+ }
+ }
+ elseif ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Array) {
+ foreach ($item in $Element.EnumerateArray()) { Add-CredentialValues $item }
+ }
+}
+
+$credentialDocument = [Text.Json.JsonDocument]::Parse(
+ [IO.File]::ReadAllText($CredentialProfilePath))
+try { Add-CredentialValues $credentialDocument.RootElement }
+finally { $credentialDocument.Dispose() }
+if ($forbiddenValues.Count -eq 0) {
+ throw 'Credential profile contains no non-empty password/secret value.'
+}
+
+function Test-CredentialEcho([Text.Json.JsonElement]$Element) {
+ if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::String) {
+ [string]$text = $Element.GetString()
+ foreach ($secret in $forbiddenValues) {
+ if ($text.Contains($secret, [StringComparison]::Ordinal)) { return $true }
+ }
+ return $false
+ }
+ if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Object) {
+ foreach ($property in $Element.EnumerateObject()) {
+ if (Test-CredentialEcho $property.Value) { return $true }
+ }
+ }
+ elseif ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Array) {
+ foreach ($item in $Element.EnumerateArray()) {
+ if (Test-CredentialEcho $item) { return $true }
+ }
+ }
+ return $false
+}
+
+function Get-Properties([Text.Json.JsonElement]$Element) {
+ $properties = [Collections.Generic.List[object]]::new()
+ foreach ($property in $Element.EnumerateObject()) { $properties.Add($property) }
+ return @($properties)
+}
+
+function Assert-String(
+ [Text.Json.JsonElement]$Root,
+ [string]$Name,
+ [bool]$AllowEmpty = $false) {
+ $value = $Root.GetProperty($Name)
+ if ($value.ValueKind -ne [Text.Json.JsonValueKind]::String) {
+ throw "field '$Name' is not a string"
+ }
+ $text = $value.GetString()
+ if (-not $AllowEmpty -and [string]::IsNullOrWhiteSpace($text)) {
+ throw "field '$Name' is empty"
+ }
+ return $text
+}
+
+function Assert-Int32([Text.Json.JsonElement]$Root, [string]$Name) {
+ $value = $Root.GetProperty($Name)
+ if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) {
+ throw "field '$Name' is not a number"
+ }
+ return $value.GetInt32()
+}
+
+function Assert-UInt32([Text.Json.JsonElement]$Root, [string]$Name) {
+ $value = $Root.GetProperty($Name)
+ if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) {
+ throw "field '$Name' is not a number"
+ }
+ return $value.GetUInt32()
+}
+
+$lines = @(Get-Content -LiteralPath $StatusFile)
+if ($lines.Count -eq 0) { $failures.Add('status stream is empty') }
+for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) {
+ $lineNumber = $lineIndex + 1
+ $line = $lines[$lineIndex]
+ if ([string]::IsNullOrWhiteSpace($line)) {
+ $failures.Add("line $lineNumber is empty")
+ continue
+ }
+ if ($line -match '(?i)"(?:password|credential|secret|token)"\s*:') {
+ $failures.Add("line $lineNumber contains a credential-like JSON field")
+ }
+
+ $document = $null
+ try {
+ $document = [Text.Json.JsonDocument]::Parse($line)
+ $root = $document.RootElement
+ if ($root.ValueKind -ne [Text.Json.JsonValueKind]::Object) {
+ throw 'root is not an object'
+ }
+ if (Test-CredentialEcho $root) {
+ throw 'an allowed string field contains an exact credential value'
+ }
+ $properties = @(Get-Properties $root)
+ $names = @($properties | ForEach-Object { $_.Name })
+ if (@($names | Sort-Object -Unique).Count -ne $names.Count) {
+ throw 'object contains duplicate fields'
+ }
+ $eventName = Assert-String $root 'e'
+ if (-not $exactFields.ContainsKey($eventName)) {
+ throw 'event name is not in the v1 vocabulary'
+ }
+ $expected = $exactFields[$eventName]
+ if ($names.Count -ne $expected.Count -or
+ [string]::Join("`n", $names) -cne [string]::Join("`n", $expected)) {
+ throw "event '$eventName' fields/order are '$($names -join ',')'; expected '$($expected -join ',')'"
+ }
+ if ((Assert-Int32 $root 'v') -ne 1) { throw 'field v is not 1' }
+ $timestampText = Assert-String $root 't'
+ $timestamp = [DateTimeOffset]::MinValue
+ if (-not [DateTimeOffset]::TryParseExact(
+ $timestampText,
+ 'O',
+ [Globalization.CultureInfo]::InvariantCulture,
+ [Globalization.DateTimeStyles]::RoundtripKind,
+ [ref]$timestamp) -or $timestamp.Offset -ne [TimeSpan]::Zero) {
+ throw 'field t is not an exact UTC round-trip timestamp'
+ }
+ if ($timestamp -lt $previousTimestamp) {
+ throw 'timestamp order moved backwards'
+ }
+ $previousTimestamp = $timestamp
+ $lineSessionId = Assert-String $root 'sessionId'
+ if ($null -eq $sessionId) { $sessionId = $lineSessionId }
+ if ($lineSessionId -cne $sessionId) { throw 'sessionId changed within the stream' }
+ if (-not [string]::IsNullOrWhiteSpace($ExpectedSessionId) -and
+ $lineSessionId -cne $ExpectedSessionId) {
+ throw 'sessionId does not match -ExpectedSessionId'
+ }
+ if ($terminalSeen) { throw 'an event appears after terminal exited' }
+
+ switch ($eventName) {
+ 'characterList' {
+ $null = Assert-String $root 'accountName' $true
+ $slotCount = Assert-Int32 $root 'slotCount'
+ if ($slotCount -lt 0) { throw 'slotCount is negative' }
+ $characters = $root.GetProperty('characters')
+ if ($characters.ValueKind -ne [Text.Json.JsonValueKind]::Array) {
+ throw 'characters is not an array'
+ }
+ foreach ($character in $characters.EnumerateArray()) {
+ if ($character.ValueKind -ne [Text.Json.JsonValueKind]::Object) {
+ throw 'a character is not an object'
+ }
+ $characterNames = @((Get-Properties $character) | ForEach-Object { $_.Name })
+ $characterExpected = @('id', 'name', 'secondsGreyedOut')
+ if ([string]::Join("`n", $characterNames) -cne
+ [string]::Join("`n", $characterExpected)) {
+ throw 'a character fields/order is not id,name,secondsGreyedOut'
+ }
+ $null = Assert-UInt32 $character 'id'
+ $null = Assert-String $character 'name'
+ $null = Assert-UInt32 $character 'secondsGreyedOut'
+ }
+ }
+ 'enteredWorld' {
+ $null = Assert-UInt32 $root 'characterId'
+ $null = Assert-String $root 'characterName'
+ }
+ 'pluginLoaded' {
+ $loadedPlugins.Add((Assert-String $root 'plugin'))
+ }
+ 'pluginFailed' {
+ $null = Assert-String $root 'plugin'
+ $null = Assert-String $root 'error'
+ if (-not $AllowPluginFailure) { throw 'pluginFailed is not allowed for this row' }
+ }
+ 'loginCommandFailed' {
+ if ((Assert-Int32 $root 'commandIndex') -lt 0) {
+ throw 'commandIndex is negative'
+ }
+ $null = Assert-String $root 'command' $true
+ $null = Assert-String $root 'error'
+ if (-not $AllowLoginCommandFailure) {
+ throw 'loginCommandFailed is not allowed for this row'
+ }
+ }
+ 'disconnected' {
+ $reason = Assert-String $root 'reason'
+ if ($reason -cne 'stopped') {
+ throw "terminal disconnected reason is '$reason', expected 'stopped'"
+ }
+ }
+ 'exited' {
+ $code = Assert-Int32 $root 'code'
+ $reason = Assert-String $root 'reason'
+ 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 does not match mode '$Mode'"
+ }
+ $terminalSeen = $true
+ }
+ }
+ $eventNames.Add($eventName)
+ }
+ catch {
+ $failures.Add("line ${lineNumber}: $($_.Exception.Message)")
+ }
+ finally { if ($null -ne $document) { $document.Dispose() } }
+}
+
+function Require-Count([string]$EventName, [int]$Count) {
+ $actual = @($eventNames | Where-Object { $_ -ceq $EventName }).Count
+ if ($actual -ne $Count) {
+ $failures.Add("event '$EventName' count is $actual, expected $Count")
+ }
+}
+function First-Index([string]$EventName) {
+ for ($index = 0; $index -lt $eventNames.Count; $index++) {
+ if ($eventNames[$index] -ceq $EventName) { return $index }
+ }
+ return -1
+}
+
+Require-Count 'started' 1
+Require-Count 'connected' 1
+Require-Count 'characterList' 1
+Require-Count 'disconnected' 1
+Require-Count 'exited' 1
+$expectEnteredWorld = $Mode -ne 'probe' -and -not $ExpectNoEnteredWorld
+Require-Count 'enteredWorld' $(if ($expectEnteredWorld) { 1 } else { 0 })
+if ($eventNames.Count -gt 0 -and $eventNames[0] -cne 'started') {
+ $failures.Add('started is not the first event')
+}
+if ($eventNames.Count -gt 0 -and $eventNames[-1] -cne 'exited') {
+ $failures.Add('exited is not the final event')
+}
+$orderedRequired = if (-not $expectEnteredWorld) {
+ @('started', 'connected', 'characterList', 'disconnected', 'exited')
+} else {
+ @('started', 'connected', 'characterList', 'enteredWorld', 'disconnected', 'exited')
+}
+$last = -1
+foreach ($name in $orderedRequired) {
+ $next = First-Index $name
+ if ($next -ge 0 -and $next -le $last) {
+ $failures.Add("event '$name' is out of lifecycle order")
+ }
+ $last = $next
+}
+$connectedIndex = First-Index 'connected'
+foreach ($index in 0..([Math]::Max(0, $eventNames.Count - 1))) {
+ if ($eventNames.Count -eq 0) { break }
+ if ($eventNames[$index] -in @('pluginLoaded', 'pluginFailed') -and
+ ($index -le 0 -or $index -ge $connectedIndex)) {
+ $failures.Add("plugin event at index $index is outside started-to-connected startup")
+ }
+}
+foreach ($plugin in $ExpectedPlugin) {
+ if (-not ($loadedPlugins -ccontains $plugin)) {
+ $failures.Add("expected plugin '$plugin' did not emit pluginLoaded")
+ }
+}
+$expectedPluginSet = @($ExpectedPlugin | Sort-Object -Unique)
+$loadedPluginSet = @($loadedPlugins | Sort-Object -Unique)
+if ($loadedPlugins.Count -ne $loadedPluginSet.Count) {
+ $failures.Add('a plugin emitted pluginLoaded more than once')
+}
+if ([string]::Join("`n", $loadedPluginSet) -cne
+ [string]::Join("`n", $expectedPluginSet)) {
+ $failures.Add(
+ "loaded plugin set has $($loadedPluginSet.Count) member(s), expected $($expectedPluginSet.Count)")
+}
+$enteredWorldIndex = First-Index 'enteredWorld'
+for ($index = 0; $index -lt $eventNames.Count; $index++) {
+ if ($eventNames[$index] -ceq 'loginCommandFailed' -and
+ ($enteredWorldIndex -lt 0 -or $index -le $enteredWorldIndex)) {
+ $failures.Add("loginCommandFailed at index $index did not follow enteredWorld")
+ }
+}
+
+$deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds)
+$capturedState = $null
+do {
+ $currentProcessIdentity = Get-CampaignLaProcessInstanceIdentity `
+ -ProcessId $capturedProcessId
+ $correlations = @(Get-CampaignLaSessionProcessCorrelations)
+ $capturedState = Test-CampaignLaCapturedProcessState `
+ -ProcessId $capturedProcessId `
+ -ProcessInstanceIdentity $capturedProcessIdentity `
+ -SessionConfigPath $capturedSessionConfigPath `
+ -CurrentProcessInstanceIdentity $currentProcessIdentity `
+ -Correlations $correlations
+ if (-not $capturedState.SameInstanceAlive -and
+ -not $capturedState.ExactConfigPathAlive) {
+ break
+ }
+ Start-Sleep -Milliseconds 100
+} while ([DateTime]::UtcNow -lt $deadline)
+if ($capturedState.SameInstanceAlive) {
+ $failures.Add(
+ "captured launcher child process instance PID $capturedProcessId remains alive")
+}
+if ($capturedState.ExactConfigPathAlive) {
+ $failures.Add('a launcher child remains correlated to the exact session-config path')
+}
+
+$reportDirectory = Split-Path -Parent $ReportPath
+if (-not [string]::IsNullOrEmpty($reportDirectory)) {
+ $null = New-Item -ItemType Directory -Force -Path $reportDirectory
+}
+$report = [ordered]@{
+ schemaVersion = 1
+ kind = 'campaign-la-session-status-validation'
+ success = ($failures.Count -eq 0)
+ mode = $Mode
+ enteredWorldExpected = $expectEnteredWorld
+ statusFile = [IO.Path]::GetFileName($StatusFile)
+ statusSize = (Get-Item -LiteralPath $StatusFile).Length
+ statusSha256 = (Get-FileHash -LiteralPath $StatusFile -Algorithm SHA256).Hash.ToLowerInvariant()
+ lineCount = $lines.Count
+ eventNames = @($eventNames)
+ loadedPluginCount = $loadedPlugins.Count
+ terminalObserved = $terminalSeen
+ capturedProcessId = $capturedProcessId
+ capturedProcessInstanceExited = (-not $capturedState.SameInstanceAlive)
+ capturedPidReused = [bool]$capturedState.PidReused
+ sessionConfigCorrelationChecked = $true
+ sessionConfigProcessExited = (-not $capturedState.ExactConfigPathAlive)
+ processCaptureSha256 = (Get-FileHash -LiteralPath $ProcessCapturePath -Algorithm SHA256).Hash.ToLowerInvariant()
+ credentialPermissionsValidated = $true
+ forbiddenCredentialValueCount = $forbiddenValues.Count
+ failures = @($failures)
+ validatedUtc = [DateTime]::UtcNow.ToString('O')
+}
+$report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $ReportPath -Encoding utf8NoBOM
+Write-Host "Campaign LA status validation report: $ReportPath"
+if (-not $report.success) {
+ $failures | ForEach-Object { Write-Error $_ }
+ exit 1
+}