fix(install): Install-ScriptWorkerIsolation.ps1 works on PowerShell 7 and 5.1, -Uninstall included (#883)

First real run of the #780 installer (windev, #800 gate) failed on both hosts and its rollback failed too:
- PowerShell 7: DirectoryInfo.GetAccessControl/SetAccessControl do not exist on .NET. Use Get-Acl / Set-Acl.
- Windows PowerShell 5.1: an if-expression unrolled the wanted ACE to a scalar ($null on -Remove), so .Count threw under
  StrictMode. Wrap it in @(...).
- Found on the re-run: an apply from 5.1 was not idempotent. 5.1 passes the literal quotes in sc.exe binPath= with legacy
  quoting, so the anchor path was stored unquoted and AnchorService reported 'changed' on every run. sc create/config now
  runs through Invoke-OtOpcUaSc with an explicit command line, identical on both hosts.

Adds scripts/install/tests/Test-ScriptWorkerIsolation.Windows.ps1: a Windows-executed dry run -> apply -> re-apply ->
uninstall -> uninstall test (scratch names, stand-in host service) for either host. The plan-only harness cannot see these.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-12 11:24:00 -05:00
parent e5b26f3772
commit 88e53f7f84
2 changed files with 160 additions and 12 deletions
+32 -12
View File
@@ -188,16 +188,17 @@ function Set-OtOpcUaExplicitAce {
if ($Remove) { return 'unchanged' }
throw "$Path does not exist"
}
$item = Get-Item -LiteralPath $Path
$acl = $item.GetAccessControl('Access')
$acl = Get-Acl -LiteralPath $Path # #800: DirectoryInfo.GetAccessControl does not exist on PowerShell 7 (.NET)
$identity = [System.Security.Principal.SecurityIdentifier]::new($Sid)
$explicit = @($acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
Where-Object { $_.IdentityReference -eq $identity })
$inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
$wanted = if ($Remove) { @() } else {
, [System.Security.AccessControl.FileSystemAccessRule]::new($identity, [System.Security.AccessControl.FileSystemRights]$Mask,
# #800: @() keeps $wanted an array; the if-expression unrolled it to a scalar ($null on -Remove), and .Count on that
# throws under Set-StrictMode -Version Latest on Windows PowerShell 5.1.
$wanted = @(if (-not $Remove) {
[System.Security.AccessControl.FileSystemAccessRule]::new($identity, [System.Security.AccessControl.FileSystemRights]$Mask,
$inherit, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]$Type)
}
})
$same = ($explicit.Count -eq $wanted.Count) -and (
$wanted.Count -eq 0 -or (
[int]$explicit[0].FileSystemRights -eq $Mask -and $explicit[0].AccessControlType -eq $wanted[0].AccessControlType -and
@@ -205,10 +206,27 @@ function Set-OtOpcUaExplicitAce {
if ($same) { return 'unchanged' }
$acl.PurgeAccessRules($identity)
foreach ($rule in $wanted) { $acl.AddAccessRule($rule) }
$item.SetAccessControl($acl)
Set-Acl -LiteralPath $Path -AclObject $acl
'changed'
}
function Invoke-OtOpcUaSc {
# Runs sc.exe with an explicitly quoted command line (CommandLineToArgvW rules), identical on PowerShell 5.1 and 7.
# Returns the exit code. Used where an argument contains literal double quotes.
param([string[]]$Arguments)
$quoted = foreach ($a in $Arguments) { '"' + ($a -replace '(\\*)"', '$1$1\"' -replace '(\\+)$', '$1$1') + '"' }
$psi = [System.Diagnostics.ProcessStartInfo]::new((Join-Path ([Environment]::SystemDirectory) 'sc.exe'), ($quoted -join ' '))
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$null = $proc.StandardOutput.ReadToEnd()
$null = $proc.StandardError.ReadToEnd()
$proc.WaitForExit()
$proc.ExitCode
}
function Set-OtOpcUaStepAce {
param([hashtable]$Data)
if ($Data.ContainsKey('Remove')) { return (Set-OtOpcUaExplicitAce -Path $Data.Path -Sid $Data.Sid -Remove) }
@@ -237,9 +255,12 @@ function Invoke-ScriptWorkerIsolationStep {
if ($cfg -and $cfg.StartMode -eq 'Disabled' -and $cfg.StartName -ieq $d.Identity -and $cfg.PathName -eq "`"$($d.BinPath)`"") {
return 'unchanged'
}
& sc.exe $verb $d.Name binPath= "`"$($d.BinPath)`"" start= disabled obj= $d.Identity `
DisplayName= 'OtOpcUa ScriptWorker identity (anchor, never started)' | Out-Null
if ($LASTEXITCODE -ne 0) { throw "sc.exe $verb $($d.Name) failed ($LASTEXITCODE)" }
# #883: the binPath value carries literal quotes. Windows PowerShell 5.1 passes native arguments with legacy quoting
# (embedded quotes are dropped or mangled, so the stored path was unquoted and every re-run reported 'changed'),
# PowerShell 7 escapes them. Build the command line explicitly so both hosts store the same value.
$scExit = Invoke-OtOpcUaSc @($verb, $d.Name, 'binPath=', "`"$($d.BinPath)`"", 'start=', 'disabled', 'obj=', $d.Identity,
'DisplayName=', 'OtOpcUa ScriptWorker identity (anchor, never started)')
if ($scExit -ne 0) { throw "sc.exe $verb $($d.Name) failed ($scExit)" }
& sc.exe sidtype $d.Name unrestricted | Out-Null
& sc.exe description $d.Name 'Anchors the NT SERVICE virtual account OtOpcUa ScriptWorker processes are restricted to (lmxopcua#780). Never started: the OtOpcUaHost launches workers itself under a restricted token.' | Out-Null
return 'changed'
@@ -252,12 +273,11 @@ function Invoke-ScriptWorkerIsolationStep {
}
$changed = $false
if (-not (Test-Path -LiteralPath $d.Path)) { New-Item -ItemType Directory -Path $d.Path -Force | Out-Null; $changed = $true }
$item = Get-Item -LiteralPath $d.Path
$current = $item.GetAccessControl('Access, Owner').GetSecurityDescriptorSddlForm('Access, Owner')
$current = (Get-Acl -LiteralPath $d.Path).GetSecurityDescriptorSddlForm('Access, Owner')
$desired = [System.Security.AccessControl.DirectorySecurity]::new()
$desired.SetSecurityDescriptorSddlForm($d.Sddl, 'Access, Owner')
if ($current -ne $desired.GetSecurityDescriptorSddlForm('Access, Owner')) {
$item.SetAccessControl($desired) # propagates to the key file, pe\ and workers\ (inherited ACEs)
Set-Acl -LiteralPath $d.Path -AclObject $desired # propagates to the key file, pe\ and workers\ (inherited ACEs)
$changed = $true
}
return $(if ($changed) { 'changed' } else { 'unchanged' })