Install-ScriptWorkerIsolation.ps1 fails on Windows (PowerShell 7: no DirectoryInfo.GetAccessControl; 5.1: StrictMode .Count on scalar) and -Uninstall fails too #883

Closed
opened 2026-11-12 10:19:10 -05:00 by dohertj2 · 1 comment
Owner

Found by the #800 Windows service gate on windev, 2026-11-12. This was the first real run of the #780 installer: master 438484fd, self-contained win-x64 Host installed as the OtOpcUaHost service under NT SERVICE\OtOpcUaHost. Its dry-run harness (Test-ScriptWorkerIsolation.ps1) only asserts the plan data, so neither defect below was caught.

Install-ScriptWorkerIsolation.ps1 fails on both PowerShell hosts. Its documented rollback (-Uninstall) fails too.

  1. PowerShell 7.6 (pwsh):
    • -WhatIf lists all 7 steps and changes nothing.
    • The real run creates the anchor service OtOpcUaScriptWorker and the PE-cache directory, then stops at the PeCacheDir ACL with Method invocation failed because [System.IO.DirectoryInfo] does not contain a method named 'GetAccessControl'.
    • DirectoryInfo.GetAccessControl() / SetAccessControl() are .NET Framework instance methods; on .NET they live in System.IO.FileSystemAclExtensions. The code is ScriptWorkerIsolation.psm1 Set-OtOpcUaExplicitAce (line 192) and the PeCacheDir step (line 256).
  2. Windows PowerShell 5.1:
    • AnchorService and PeCacheDir apply.
    • It then stops at PeEntriesDir with The property 'Count' cannot be found on this object (PropertyNotFoundStrict).
    • Cause: $wanted = if ($Remove) { @() } else { , [FileSystemAccessRule]::new(...) }. An if used as an expression unrolls its output, so $wanted is a single rule, or $null on -Remove. .Count on that throws under Set-StrictMode -Version Latest in 5.1.
  3. -Uninstall (the rollback in docs/ServiceHosting.md and #794) fails the same way on 5.1, at its first ACE step after HostServiceEnv/FirewallBlockOutbound. So a partial install cannot be rolled back with the tool.

Patch, validated on windev: use Get-Acl/Set-Acl instead of the instance methods, and wrap the rule in @(if (-not $Remove) { ... }).

@@ -188,16 +188,17 @@
         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,7 +206,7 @@
     if ($same) { return 'unchanged' }
     $acl.PurgeAccessRules($identity)
     foreach ($rule in $wanted) { $acl.AddAccessRule($rule) }
-    $item.SetAccessControl($acl)
+    Set-Acl -LiteralPath $Path -AclObject $acl
     'changed'
 }
 
@@ -252,12 +253,11 @@
             }
             $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' })

With this patch the #800 run passed every step:

  • install under pwsh 7;
  • re-apply under 5.1, with all 7 steps unchanged;
  • runtime checks;
  • -Uninstall under 5.1, then again under pwsh, with all steps unchanged.

Details are on #800. Fix the repo copy and add a Windows-executed test of apply → re-apply → uninstall. The plan-only harness cannot see these.

This blocks #794 (QA gate) as written: step 3 "apply, then run again" fails.

Found by the #800 Windows service gate on windev, 2026-11-12. This was the first real run of the #780 installer: master `438484fd`, self-contained win-x64 Host installed as the `OtOpcUaHost` service under `NT SERVICE\OtOpcUaHost`. Its dry-run harness (`Test-ScriptWorkerIsolation.ps1`) only asserts the plan data, so neither defect below was caught. **`Install-ScriptWorkerIsolation.ps1` fails on both PowerShell hosts. Its documented rollback (`-Uninstall`) fails too.** 1. **PowerShell 7.6 (pwsh):** - `-WhatIf` lists all 7 steps and changes nothing. - The real run creates the anchor service `OtOpcUaScriptWorker` and the PE-cache directory, then stops at the PeCacheDir ACL with `Method invocation failed because [System.IO.DirectoryInfo] does not contain a method named 'GetAccessControl'`. - `DirectoryInfo.GetAccessControl()` / `SetAccessControl()` are .NET Framework instance methods; on .NET they live in `System.IO.FileSystemAclExtensions`. The code is `ScriptWorkerIsolation.psm1` `Set-OtOpcUaExplicitAce` (line 192) and the `PeCacheDir` step (line 256). 2. **Windows PowerShell 5.1:** - AnchorService and PeCacheDir apply. - It then stops at `PeEntriesDir` with `The property 'Count' cannot be found on this object` (PropertyNotFoundStrict). - Cause: `$wanted = if ($Remove) { @() } else { , [FileSystemAccessRule]::new(...) }`. An `if` used as an expression unrolls its output, so `$wanted` is a single rule, or `$null` on `-Remove`. `.Count` on that throws under `Set-StrictMode -Version Latest` in 5.1. 3. **`-Uninstall`** (the rollback in docs/ServiceHosting.md and #794) fails the same way on 5.1, at its first ACE step after HostServiceEnv/FirewallBlockOutbound. So a partial install cannot be rolled back with the tool. **Patch, validated on windev:** use `Get-Acl`/`Set-Acl` instead of the instance methods, and wrap the rule in `@(if (-not $Remove) { ... })`. ```diff @@ -188,16 +188,17 @@ 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,7 +206,7 @@ if ($same) { return 'unchanged' } $acl.PurgeAccessRules($identity) foreach ($rule in $wanted) { $acl.AddAccessRule($rule) } - $item.SetAccessControl($acl) + Set-Acl -LiteralPath $Path -AclObject $acl 'changed' } @@ -252,12 +253,11 @@ } $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' }) ``` With this patch the #800 run passed every step: - install under pwsh 7; - re-apply under 5.1, with all 7 steps `unchanged`; - runtime checks; - `-Uninstall` under 5.1, then again under pwsh, with all steps `unchanged`. Details are on #800. **Fix the repo copy and add a Windows-executed test of apply → re-apply → uninstall.** The plan-only harness cannot see these. This blocks #794 (QA gate) as written: step 3 "apply, then run again" fails.
dohertj2 added the type:bugpriority:p1 labels 2026-11-12 10:19:10 -05:00
Author
Owner

Fixed and verified on windev, master 88e53f7f (evidence 1172687d). Install, re-apply and -Uninstall now work on both PowerShell 7.6 and Windows PowerShell 5.1.

What changed in scripts/install/ScriptWorkerIsolation.psm1:

  • The two defects from this issue are fixed as in the attached diff, after review: Get-Acl/Set-Acl replace the DirectoryInfo instance methods, and $wanted = @(if (...) { ... }) keeps the rule an array.
  • A third defect, found by the re-run: an apply from Windows PowerShell 5.1 was not idempotent. 5.1 passes the literal quotes in sc.exe ... binPath= "\"C:\...exe\"" with legacy quoting, so the anchor path was stored unquoted, and AnchorService reported changed on every re-run (a path with spaces, like C:\Program Files\OtOpcUa, is worse). sc.exe create|config now goes through Invoke-OtOpcUaSc with an explicit command line, so both hosts store the same quoted path.

Verification:

  • New Windows-executed test scripts/install/tests/Test-ScriptWorkerIsolation.Windows.ps1 (dry run, apply, re-apply, uninstall, uninstall; scratch worker/host-service names; refuses to run where the real isolation is installed). On windev with the final script: 24/24 on 5.1 and 24/24 on pwsh 7. Before the third fix it failed on 5.1 at the re-apply.
  • Real OtOpcUaHost service, repo script, no patch, four cycles (apply host / second host): pwsh/5.1, 5.1/pwsh (before the third fix: AnchorService changed on re-apply), 5.1/pwsh (final), pwsh/5.1 (final). With the final script every re-apply reported 7/7 unchanged and the second uninstall 6/6 unchanged; -WhatIf changed nothing on either host.
  • Runtime with the isolation on: restricted worker (1 privilege), pipe refuses the elevated admin, script-runner Healthy, strict-peer/API-key matrix green. See RESULTS.md.
  • The existing dry-run harness still passes 46/46.

Invoke-ServiceGate.ps1 no longer needs -PatchedIsolation (removed); it gained -ApplyHost and -PrepareFrom candidate.

https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi

**Fixed and verified on windev, master `88e53f7f` (evidence `1172687d`).** Install, re-apply and `-Uninstall` now work on both PowerShell 7.6 and Windows PowerShell 5.1. What changed in `scripts/install/ScriptWorkerIsolation.psm1`: - The two defects from this issue are fixed as in the attached diff, after review: `Get-Acl`/`Set-Acl` replace the `DirectoryInfo` instance methods, and `$wanted = @(if (...) { ... })` keeps the rule an array. - **A third defect, found by the re-run:** an apply from Windows PowerShell 5.1 was not idempotent. 5.1 passes the literal quotes in `sc.exe ... binPath= "\"C:\...exe\""` with legacy quoting, so the anchor path was stored unquoted, and `AnchorService` reported `changed` on every re-run (a path with spaces, like `C:\Program Files\OtOpcUa`, is worse). `sc.exe create|config` now goes through `Invoke-OtOpcUaSc` with an explicit command line, so both hosts store the same quoted path. Verification: - **New Windows-executed test** `scripts/install/tests/Test-ScriptWorkerIsolation.Windows.ps1` (dry run, apply, re-apply, uninstall, uninstall; scratch worker/host-service names; refuses to run where the real isolation is installed). On windev with the final script: **24/24 on 5.1 and 24/24 on pwsh 7**. Before the third fix it failed on 5.1 at the re-apply. - **Real `OtOpcUaHost` service, repo script, no patch**, four cycles (apply host / second host): pwsh/5.1, 5.1/pwsh (before the third fix: AnchorService `changed` on re-apply), 5.1/pwsh (final), pwsh/5.1 (final). With the final script every re-apply reported 7/7 `unchanged` and the second uninstall 6/6 `unchanged`; `-WhatIf` changed nothing on either host. - Runtime with the isolation on: restricted worker (1 privilege), pipe refuses the elevated admin, `script-runner` Healthy, strict-peer/API-key matrix green. See [RESULTS.md](https://gitea.dohertylan.com/dohertj2/lmxopcua/src/commit/1172687d/scripts/validation/opcua-windows-service/evidence/2026-11-12-windev-isolation-rerun/RESULTS.md). - The existing dry-run harness still passes 46/46. `Invoke-ServiceGate.ps1` no longer needs `-PatchedIsolation` (removed); it gained `-ApplyHost` and `-PrepareFrom candidate`. https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dohertj2/lmxopcua#883