diff --git a/Install-Store.cmd b/Install-Store.cmd
index 0c7c95e..bdad234 100644
--- a/Install-Store.cmd
+++ b/Install-Store.cmd
@@ -52,8 +52,9 @@ if %_build% lss 19041 (
rem Packages: local ones next to this script if present (-Source Auto), otherwise downloaded from Microsoft.
rem Options (-Source, -WorkDir, -DownloadDir, -DownloadOnly, -NoProvision, -SkipWinget, ...) pass through.
+rem Match -Source only as a whole option (" -Source " or " -Source:"), not inside a path such as D:\my-source.
set "_src=-Source Auto"
-echo( %_args% | findstr /i /c:"-Source" 1>nul && set "_src="
+echo( %_args% _| findstr /i /r /c:" -Source[ :]" 1>nul && set "_src="
"%_psexe%" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%_ps1%" %_src% %_args%
set "_rc=%ERRORLEVEL%"
if defined _pause (
diff --git a/README.md b/README.md
index bcfbc84..bf2f3f5 100644
--- a/README.md
+++ b/README.md
@@ -81,7 +81,7 @@ In online mode, the script looks up the apps in Microsoft's Store catalog and re
For each download, the script checks the file size and the hash supplied by Microsoft over HTTPS. It uses SHA-256 when available and falls back to SHA-1. It also requires a valid Microsoft Corporation code signature before replacing the destination file. Failed attempts remove their temporary files, and the script retries before reporting a failure.
-For local packages, the script reads identities and dependencies from package manifests. It filters unsupported architectures and Windows versions before selecting apps. Missing dependencies and framework dependency cycles stop the run before installation.
+For local packages, the script reads identities and dependencies from package manifests. It filters unsupported architectures and Windows versions before selecting apps. Missing Microsoft Store dependencies and framework dependency cycles stop the run before installation. A companion app with missing dependencies is skipped and reported as a partial failure.
The script installs frameworks in dependency order, followed by Microsoft Store and the companion apps. By default, it provisions packages in the Windows image and registers them for the current user. `-NoProvision` skips provisioning. Missing requested companion apps produce a partial-failure result, including in download-only mode.
diff --git a/docs/installer-reliability.md b/docs/installer-reliability.md
index 669bd4c..cd7b5f5 100644
--- a/docs/installer-reliability.md
+++ b/docs/installer-reliability.md
@@ -26,7 +26,9 @@ Legacy folders without a JSON manifest remain supported. They do not receive the
Local selection skips packages with an unsupported architecture or Windows version before choosing the newest usable app. Equal versions prefer the native architecture. Bundles use their highest-ranked compatible architecture payload for dependency and Windows-version checks.
-Frameworks install in dependency order. Before deployment, the script checks required names, publishers, versions, and architectures against the supplied frameworks and applicable installed or provisioned packages. Missing dependencies and cycles stop the run before installation. Keep the complete framework set in an offline folder.
+Frameworks install in dependency order. Before deployment, the script checks required names, publishers, versions, and architectures against the supplied frameworks and applicable installed or provisioned packages. Missing Microsoft Store dependencies and framework cycles stop the run before installation. A companion or additional app with missing dependencies is skipped, and the run reports a partial failure, so it cannot block the Store. Keep the complete framework set in an offline folder.
+
+Relative `-WorkDir`, `-LogDir`, and `-DownloadDir` paths resolve against the current PowerShell location before any relaunch. `-Source Local` without `-WorkDir` uses the current PowerShell location. The elevated or 64-bit child receives the resolved paths.
## Exit codes
@@ -41,6 +43,6 @@ powershell.exe -NoProfile -NonInteractive -File .\tests\Run-Tests.ps1
pwsh.exe -NoProfile -NonInteractive -File .\tests\Run-Tests.ps1
```
-The tests use the installer's actual functions and main orchestration with fixture packages and mocked deployment calls. They cover caller scope, relaunch snapshots and arguments, cleanup ownership, package selection, dependencies, offline manifests, incomplete requested sets, and cancellation of a stalled loopback HTTP transfer. GitHub Actions runs the suite under both PowerShell engines.
+The tests use the installer's actual functions and main orchestration with fixture packages and mocked deployment calls. They cover caller scope, relaunch snapshots and arguments, relative path resolution, the command launcher's `-Source` default, cleanup ownership, package selection, dependencies, offline manifests, incomplete requested sets, and cancellation of a stalled loopback HTTP transfer. GitHub Actions runs the suite under both PowerShell engines.
These tests do not install packages or exercise UAC. They do not prove that Microsoft's live delivery service offers an installable package set for a particular LTSC image. Before release, test online and offline installation on disposable LTSC 2021 and LTSC 2024 systems, including SYSTEM provisioning and a new user's first sign-in. Check that Microsoft Store opens, winget runs, and the expected packages register for the intended users.
diff --git a/install.ps1 b/install.ps1
index bf887f4..85cbde0 100644
--- a/install.ps1
+++ b/install.ps1
@@ -212,7 +212,10 @@ function Invoke-Relaunch {
$file = Join-Path $tempDir 'install.ps1'
if (-not $script:ScriptText) { throw 'The executing script content is unavailable for relaunch.' }
[IO.File]::WriteAllText($file, $script:ScriptText, [Text.UTF8Encoding]::new($true))
- if (-not $bound.ContainsKey('WorkDir')) { $bound['WorkDir'] = $WorkDir }
+ # Pass resolved paths: the child can start elsewhere (elevated processes start in System32).
+ $bound['WorkDir'] = $WorkDir
+ if ($bound.ContainsKey('LogDir')) { $bound['LogDir'] = $LogDir }
+ if ($bound.ContainsKey('DownloadDir')) { $bound['DownloadDir'] = $DownloadDir }
if (-not $bound.ContainsKey('LogDir')) { $bound['LogDir'] = Join-Path ([IO.Path]::GetTempPath()) 'StoreInstall-Logs' }
foreach ($key in @($bound.Keys)) {
@@ -242,6 +245,13 @@ function Invoke-Relaunch {
}
}
+function Resolve-UserPath {
+ # Resolve relative paths against the PowerShell location, not the process directory .NET uses.
+ param([AllowNull()][AllowEmptyString()][string] $Path)
+ if (-not $Path) { return $Path }
+ return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
+}
+
function Get-OsArch {
# The machine-wide value reflects the native arch even from WOW64/x64-emulated processes.
$native = $null
@@ -375,6 +385,42 @@ function Get-PackageMetadata {
finally { $zip.Dispose() }
}
+function Get-PackageDependencyState {
+ # Splits a package's dependencies into supplied frameworks it needs and dependencies nothing satisfies.
+ param([Parameter(Mandatory)] $Package, [object[]] $Frameworks)
+ $required = @()
+ $missing = @()
+ foreach ($dep in $Package.Identity.Dependencies) {
+ $supplied = @($Frameworks | Where-Object { $_.Identity.Name -eq $dep.Name -and $_.Identity.Version -ge $dep.MinVersion -and (-not $dep.Publisher -or $_.Identity.Publisher -eq $dep.Publisher) -and ($_.Identity.Arch -eq 'neutral' -or $_.Identity.Arch -eq $dep.Arch) })
+ if ($supplied.Count -gt 0) { $required += $supplied; continue }
+ if ($script:DoProvision) {
+ $expectedFamily = if ($dep.Publisher) { $dep.Name + '_' + (Get-PublisherId $dep.Publisher) } else { $null }
+ $installed = @(Get-AppxProvisionedPackage -Online -ErrorAction Stop | Where-Object {
+ $provisionedIdentity = Get-PackageIdentity -FileName ($_.PackageName + '.appx')
+ $provisionedIdentity.Name -eq $dep.Name -and $provisionedIdentity.Version -ge $dep.MinVersion -and (-not $expectedFamily -or $provisionedIdentity.Family -eq $expectedFamily) -and ($provisionedIdentity.Arch -eq 'neutral' -or $provisionedIdentity.Arch -eq $dep.Arch)
+ })
+ }
+ else { $installed = @(Get-AppxPackage -Name $dep.Name -ErrorAction SilentlyContinue | Where-Object { [version]$_.Version -ge $dep.MinVersion -and (-not $dep.Publisher -or $_.Publisher -eq $dep.Publisher) -and ($_.Architecture.ToString() -eq 'Neutral' -or $_.Architecture.ToString() -eq $dep.Arch) }) }
+ if ($installed.Count -eq 0) { $missing += $dep }
+ }
+ return [pscustomobject]@{ Required = $required; Missing = $missing }
+}
+
+function Format-MissingDependency {
+ param([Parameter(Mandatory)] $Dependency, [Parameter(Mandatory)] $Package)
+ return "Missing dependency '$($Dependency.Name)' version $($Dependency.MinVersion) or newer for '$($Package.Identity.Name)' ($($Dependency.Arch))."
+}
+
+function Test-OptionalAppDependency {
+ # Returns $true when an optional app's dependencies are satisfied; otherwise logs them and counts a partial failure.
+ param([Parameter(Mandatory)] $Package, [object[]] $Frameworks)
+ $missing = @((Get-PackageDependencyState -Package $Package -Frameworks $Frameworks).Missing)
+ if ($missing.Count -eq 0) { return $true }
+ foreach ($dep in $missing) { Write-Log "Skipping $($Package.File.Name): $(Format-MissingDependency $dep $Package)" 'WARN' }
+ $script:FailCount++
+ return $false
+}
+
function Get-DependencyInstallOrder {
param([object[]] $Frameworks, [object[]] $Applications)
$ordered = New-Object System.Collections.Generic.List[object]
@@ -383,21 +429,9 @@ function Get-DependencyInstallOrder {
$allPackages = @($Frameworks) + @($Applications)
$edges = @{}
foreach ($pkg in $allPackages) {
- $required = @()
- foreach ($dep in $pkg.Identity.Dependencies) {
- $matches = @($Frameworks | Where-Object { $_.Identity.Name -eq $dep.Name -and $_.Identity.Version -ge $dep.MinVersion -and (-not $dep.Publisher -or $_.Identity.Publisher -eq $dep.Publisher) -and ($_.Identity.Arch -eq 'neutral' -or $_.Identity.Arch -eq $dep.Arch) })
- if ($matches.Count -gt 0) { $required += $matches; continue }
- if ($script:DoProvision) {
- $expectedFamily = if ($dep.Publisher) { $dep.Name + '_' + (Get-PublisherId $dep.Publisher) } else { $null }
- $installed = @(Get-AppxProvisionedPackage -Online -ErrorAction Stop | Where-Object {
- $provisionedIdentity = Get-PackageIdentity -FileName ($_.PackageName + '.appx')
- $provisionedIdentity.Name -eq $dep.Name -and $provisionedIdentity.Version -ge $dep.MinVersion -and (-not $expectedFamily -or $provisionedIdentity.Family -eq $expectedFamily) -and ($provisionedIdentity.Arch -eq 'neutral' -or $provisionedIdentity.Arch -eq $dep.Arch)
- })
- }
- else { $installed = @(Get-AppxPackage -Name $dep.Name -ErrorAction SilentlyContinue | Where-Object { [version]$_.Version -ge $dep.MinVersion -and (-not $dep.Publisher -or $_.Publisher -eq $dep.Publisher) -and ($_.Architecture.ToString() -eq 'Neutral' -or $_.Architecture.ToString() -eq $dep.Arch) }) }
- if ($installed.Count -eq 0) { throw "Missing dependency '$($dep.Name)' version $($dep.MinVersion) or newer for '$($pkg.Identity.Name)' ($($dep.Arch)). Add the matching framework package to the package directory." }
- }
- $edges[$pkg.File.FullName] = $required
+ $state = Get-PackageDependencyState -Package $pkg -Frameworks $Frameworks
+ if (@($state.Missing).Count -gt 0) { throw "$(Format-MissingDependency @($state.Missing)[0] $pkg) Add the matching framework package to the package directory." }
+ $edges[$pkg.File.FullName] = @($state.Required)
}
while ($pending.Count -gt 0) {
$next = @($pending | Where-Object { $blocked = $false; foreach ($dependency in $edges[$_.File.FullName]) { if ($pending.Contains($dependency)) { $blocked = $true } }; -not $blocked } | Sort-Object { $_.File.Name } | Select-Object -First 1)
@@ -912,6 +946,14 @@ try {
Write-Host 'ERROR: -DownloadOnly cannot be combined with -Source Local.'
return 7
}
+
+ # Resolve paths before any relaunch, which may start in another directory.
+ # WorkDir is empty when run via irm | iex; local packages then come from the current folder only with -Source Local.
+ if (-not $WorkDir -and $Source -eq 'Local') { $WorkDir = (Get-Location).ProviderPath }
+ $WorkDir = Resolve-UserPath $WorkDir
+ $LogDir = Resolve-UserPath $LogDir
+ $DownloadDir = Resolve-UserPath $DownloadDir
+
if (-not $DownloadOnly -and -not (Test-IsElevated)) {
if (-not (Test-CanPromptForElevation)) {
Write-Host 'ERROR: Run this script as Administrator.'
@@ -938,10 +980,7 @@ try {
return 2
}
- # WorkDir is empty when run via irm | iex; local packages then come from the current folder only with -Source Local.
- if (-not $WorkDir -and $Source -eq 'Local') { $WorkDir = (Get-Location).ProviderPath }
if ($WorkDir) {
- $WorkDir = [IO.Path]::GetFullPath($WorkDir)
if (-not (Test-Path -LiteralPath $WorkDir -PathType Container)) {
Write-Host "ERROR: WorkDir not found: $WorkDir"
return 7
@@ -959,7 +998,7 @@ try {
$tempLogDir = Join-Path ([IO.Path]::GetTempPath()) 'StoreInstall-Logs'
$logName = 'StoreInstall-{0:yyyyMMdd-HHmmss}-{1}.log' -f (Get-Date), ([guid]::NewGuid().ToString('N'))
$logCandidates = @(
- $(if ($LogDir) { [IO.Path]::GetFullPath($LogDir) } elseif ($WorkDir) { Join-Path $WorkDir 'Logs' } else { $tempLogDir }),
+ $(if ($LogDir) { $LogDir } elseif ($WorkDir) { Join-Path $WorkDir 'Logs' } else { $tempLogDir }),
$tempLogDir
) | Select-Object -Unique
foreach ($dir in $logCandidates) {
@@ -1014,7 +1053,7 @@ try {
if ($online) {
if ($DownloadDir) {
- $pkgDir = [IO.Path]::GetFullPath($DownloadDir)
+ $pkgDir = $DownloadDir
}
else {
$runToken = [guid]::NewGuid().ToString('N')
@@ -1141,8 +1180,14 @@ try {
$uniqueFw[$key] = Select-NewerPackage $uniqueFw[$key] $fw
}
try {
+ # Optional apps with unmet dependencies are skipped (partial failure) so they cannot block the Store.
+ $fwCandidates = @($uniqueFw.Values)
+ if ($purchase -and -not (Test-OptionalAppDependency $purchase $fwCandidates)) { $purchase = $null }
+ if ($xboxId -and -not (Test-OptionalAppDependency $xboxId $fwCandidates)) { $xboxId = $null }
+ if ($winget -and -not (Test-OptionalAppDependency $winget $fwCandidates)) { $winget = $null }
+ $other = @($other | Where-Object { Test-OptionalAppDependency $_ $fwCandidates })
$apps = @(@($store, $purchase, $xboxId, $winget) | Where-Object { $null -ne $_ })
- $sortedFw = @(Get-DependencyInstallOrder -Frameworks @($uniqueFw.Values) -Applications @($apps + @($other.ToArray())))
+ $sortedFw = @(Get-DependencyInstallOrder -Frameworks $fwCandidates -Applications @($apps + $other))
}
catch { Write-Log $_.Exception.Message 'ERROR'; return (Complete-Run 4) }
diff --git a/tests/Test-CmdLauncher.ps1 b/tests/Test-CmdLauncher.ps1
new file mode 100644
index 0000000..30aad30
--- /dev/null
+++ b/tests/Test-CmdLauncher.ps1
@@ -0,0 +1,27 @@
+#Requires -Version 5.1
+$ErrorActionPreference = 'Stop'
+function Assert-True($Value, $Message) { if (-not $Value) { throw $Message } }
+
+# Run the launcher's actual -Source detection line in an isolated batch file.
+$launcher = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\Install-Store.cmd')
+$detection = @($launcher | Where-Object { $_ -match '^echo\( %_args% .*findstr' })
+Assert-True ($detection.Count -eq 1) 'Launcher -Source detection line not found.'
+$temp = Join-Path ([IO.Path]::GetTempPath()) ('store-cmd-test-' + [guid]::NewGuid().ToString('N'))
+$null = New-Item -ItemType Directory -Path $temp
+try {
+ $probe = Join-Path $temp 'probe.cmd'
+ # Arguments arrive through the environment, avoiding engine-specific native quoting of embedded quotes.
+ [IO.File]::WriteAllText($probe, ((@('@echo off', 'set "_args=%STORE_LTSC_TEST_ARGS%"', 'set "_src=-Source Auto"') + $detection + 'echo [%_src%]') -join "`r`n") + "`r`n")
+ function Get-SourceDefault([string] $Arguments) {
+ $env:STORE_LTSC_TEST_ARGS = $Arguments
+ try { & cmd.exe /d /c $probe | Select-Object -Last 1 } finally { Remove-Item Env:STORE_LTSC_TEST_ARGS -ErrorAction SilentlyContinue }
+ }
+ Assert-True ((Get-SourceDefault '') -eq '[-Source Auto]') 'No options must default to -Source Auto.'
+ Assert-True ((Get-SourceDefault '-WorkDir D:\my-source-pkgs') -eq '[-Source Auto]') 'A path containing "-source" must not suppress -Source Auto.'
+ Assert-True ((Get-SourceDefault '-SkipWinget -Sourcery') -eq '[-Source Auto]') 'A longer option name must not suppress -Source Auto.'
+ Assert-True ((Get-SourceDefault '-Source Online') -eq '[]') 'An explicit -Source must replace the default.'
+ Assert-True ((Get-SourceDefault '-SkipWinget -source:Local') -eq '[]') 'An explicit -Source:value must replace the default.'
+ Assert-True ((Get-SourceDefault '-WorkDir "D:\Store Pkgs" -Source Local') -eq '[]') 'An explicit -Source after a quoted path must replace the default.'
+ Write-Host 'PASS: launcher applies -Source Auto unless -Source is given as an option.'
+}
+finally { Remove-Item -LiteralPath $temp -Recurse -Force }
diff --git a/tests/Test-InstallerFlow.ps1 b/tests/Test-InstallerFlow.ps1
index 7ec3fec..1b57812 100644
--- a/tests/Test-InstallerFlow.ps1
+++ b/tests/Test-InstallerFlow.ps1
@@ -40,9 +40,9 @@ function New-TestPackage($Name, $Arch = 'x64', $Version = '1.0.0.0', $Dependenci
} finally { $archive.Dispose() }
$path
}
-function Invoke-Flow {
+function Invoke-Flow($WorkDir = $testRoot) {
$global:ReviewInstallCalls = 0
- $invocation = @{ Params = @{ WorkDir=$testRoot; LogDir=(Join-Path $testRoot 'Logs'); Source='Local'; DownloadDir=''; DownloadTimeoutSeconds=1; DownloadOnly=$false; KeepDownloads=$false; NoProvision=$true; SkipWinget=$false }; Bound=@{}; ScriptPath=$null; ScriptText='' }
+ $invocation = @{ Params = @{ WorkDir=$WorkDir; LogDir=(Join-Path $testRoot 'Logs'); Source='Local'; DownloadDir=''; DownloadTimeoutSeconds=1; DownloadOnly=$false; KeepDownloads=$false; NoProvision=$true; SkipWinget=$false }; Bound=@{}; ScriptPath=$null; ScriptText='' }
$result = @(New-Module -Name StoreFlowTest -ArgumentList $invocation -ReturnResult -ScriptBlock $moduleScript)
return $result[-1]
}
@@ -54,10 +54,20 @@ try {
$null = New-TestPackage Microsoft.WindowsStore arm64 9.0.0.0
$code = Invoke-Flow
if ($code -ne 0 -or $global:ReviewInstallCalls -ne 4) { throw "Complete mixed-architecture flow expected code0/four installs, got $code / $global:ReviewInstallCalls" }
+ # Relative and implicit -Source Local folders resolve against the PowerShell location, not the process directory.
+ Push-Location -LiteralPath (Split-Path -Parent $testRoot)
+ try { $code = Invoke-Flow (Split-Path -Leaf $testRoot) } finally { Pop-Location }
+ if ($code -ne 0 -or $global:ReviewInstallCalls -ne 4) { throw "Relative WorkDir expected code0/four installs, got $code / $global:ReviewInstallCalls" }
+ Push-Location -LiteralPath $testRoot
+ try { $code = Invoke-Flow '' } finally { Pop-Location }
+ if ($code -ne 0 -or $global:ReviewInstallCalls -ne 4) { throw "Local source without WorkDir expected the current folder, got $code / $global:ReviewInstallCalls" }
+ $null = New-TestPackage Microsoft.XboxIdentityProvider x64 2.0.0.0 ''
+ $code = Invoke-Flow
+ if ($code -ne 5 -or $global:ReviewInstallCalls -ne 3) { throw "Optional app with a missing dependency must be skipped without blocking the Store, got $code / $global:ReviewInstallCalls" }
$null = New-TestPackage Microsoft.WindowsStore x64 2.0.0.0 ''
$code = Invoke-Flow
if ($code -ne 4 -or $global:ReviewInstallCalls -ne 0) { throw "Missing dependency should reject before any install, got $code / $global:ReviewInstallCalls" }
- Write-Host 'PASS: actual installer orchestration reports partial sets, selects usable apps, and rejects missing dependencies before installation.'
+ Write-Host 'PASS: actual installer orchestration resolves relative folders, reports partial sets, selects usable apps, skips optional apps with missing dependencies, and rejects missing Store dependencies before installation.'
} finally {
$resolved = [IO.Path]::GetFullPath($testRoot)
if ($resolved.StartsWith([IO.Path]::GetFullPath([IO.Path]::GetTempPath()), [StringComparison]::OrdinalIgnoreCase) -and (Split-Path $resolved -Leaf) -like 'store-flow-test-*') { Remove-Item -LiteralPath $resolved -Recurse -Force }
diff --git a/tests/Test-PackageSelection.ps1 b/tests/Test-PackageSelection.ps1
index 98cb5d1..cb5999a 100644
--- a/tests/Test-PackageSelection.ps1
+++ b/tests/Test-PackageSelection.ps1
@@ -4,7 +4,7 @@ $source = Join-Path $PSScriptRoot '..\install.ps1'
$tokens = $null; $errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($source, [ref]$tokens, [ref]$errors)
if ($errors.Count) { throw ($errors | Out-String) }
-$names = @('Get-PackageIdentity', 'Get-PublisherId', 'Read-PackageManifest', 'Get-PackageMetadata', 'Get-ArchRank', 'Get-DependencyInstallOrder', 'Select-NewerPackage')
+$names = @('Get-PackageIdentity', 'Get-PublisherId', 'Read-PackageManifest', 'Get-PackageMetadata', 'Get-ArchRank', 'Get-PackageDependencyState', 'Format-MissingDependency', 'Get-DependencyInstallOrder', 'Select-NewerPackage')
foreach ($node in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) {
if ($node.Name -in $names) { Invoke-Expression $node.Extent.Text }
}
diff --git a/tests/Test-ScopeAndCleanup.ps1 b/tests/Test-ScopeAndCleanup.ps1
index 1dbb7e7..2eb666c 100644
--- a/tests/Test-ScopeAndCleanup.ps1
+++ b/tests/Test-ScopeAndCleanup.ps1
@@ -63,14 +63,24 @@ function Start-Process {
Assert-True ((Get-Content -LiteralPath $snapshotPath -Raw) -ceq $installerText) 'Relaunch changed the executing revision.'
Assert-True ($bootstrap -match "FromBase64String\('([^']+)'\)") 'Missing serialized parameters.'
$parameters = [Management.Automation.PSSerializer]::Deserialize([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($Matches[1])))
- Assert-True ($parameters.WorkDir -eq 'C:\offline packages') 'Relaunch lost a path containing spaces.'
+ $script:CapturedParameters = $parameters
$script:CapturedSnapshot = $snapshotPath
[pscustomobject]@{ ExitCode = 5 }
}
$result = Invoke-Relaunch -Elevate
Assert-True ($result -eq 5) 'Relaunch lost the child exit code.'
+Assert-True ($script:CapturedParameters.WorkDir -eq 'C:\offline packages') 'Relaunch lost a path containing spaces.'
Assert-True (-not (Test-Path -LiteralPath $script:CapturedSnapshot)) 'Relaunch snapshot was not cleaned up.'
+# The elevated child starts in System32, so it must receive the already-resolved paths, not the typed relative ones.
+$script:BoundParams = @{ Source = 'Local'; WorkDir = '.\pkgs'; LogDir = 'logs'; DownloadDir = 'downloads' }
+$WorkDir = 'C:\resolved\pkgs'; $LogDir = 'C:\resolved\logs'; $DownloadDir = 'C:\resolved\downloads'
+$null = Invoke-Relaunch -Elevate
+Assert-True ($script:CapturedParameters.WorkDir -eq 'C:\resolved\pkgs' -and $script:CapturedParameters.LogDir -eq 'C:\resolved\logs' -and $script:CapturedParameters.DownloadDir -eq 'C:\resolved\downloads') 'Relaunch forwarded unresolved relative paths.'
+$script:BoundParams = @{ Source = 'Local' }
+$null = Invoke-Relaunch -Elevate
+Assert-True ($script:CapturedParameters.WorkDir -eq 'C:\resolved\pkgs' -and -not $script:CapturedParameters.ContainsKey('DownloadDir')) 'Relaunch must forward the resolved WorkDir without inventing unbound options.'
+
# Exercise the native process boundary too. The invalid combination exits before any
# OS, admin, network, log, or package operation in the real Windows PowerShell child.
$script:BoundParams = @{ Source = 'Local'; DownloadOnly = $true; WorkDir = ''; SkipWinget = $false; ErrorAction = 'Stop' }