fix(shopfloor): S: mapper review fixes - headless task, drift guard, pre-flight, re-seed

Independent review in the win11 VM found four things worth fixing before
the fleet sync:

- The task action ran powershell -WindowStyle Hidden, which on Win11
  (Windows Terminal as console host) flashes a window and steals
  foreground for ~0.5 s on every fire. Action is now
  conhost.exe --headless powershell.exe ..., verified silent.
- If the share copy of Map-SfldShare.ps1 changes without the manifest
  DetectionValue being bumped, the engine calls the registrar every
  cycle. The registrar now takes an idempotent fast path (staged hash
  matches, task present with our action -> re-stamp and exit, no task
  churn) and logs one WARN per hash when -ExpectedHash disagrees with
  the share file.
- Replacing a wrong or unreadable mapping now pre-flights the new
  mapping on a spare letter and keeps the old S: when that fails
  (except on 1219, where the old session is what blocks the new one).
- Credential Manager is re-seeded whenever the HKLM credential changes
  (password rotation) and after every repair, instead of once per
  profile. Set-State no longer wipes the stored hash on suspect writes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011boHjE3EeexwszdiKDZ7iX
This commit is contained in:
cproudlock
2026-09-04 13:37:37 -04:00
parent ca9b0de89c
commit 2ec097069c
2 changed files with 95 additions and 16 deletions

View File

@@ -11,7 +11,10 @@
param( param(
[int]$IntervalMinutes = 3, [int]$IntervalMinutes = 3,
[string]$InstallRoot = 'C:\Program Files\GE\SfldShare' [string]$InstallRoot = 'C:\Program Files\GE\SfldShare',
# SHA256 the manifest entry detects on. When it differs from the share
# file the manifest was not bumped with the script; warn loudly once.
[string]$ExpectedHash = ''
) )
$ErrorActionPreference = 'Continue' $ErrorActionPreference = 'Continue'
@@ -32,8 +35,6 @@ function Write-RegLog {
Write-Host $line Write-Host $line
} }
Write-RegLog '=== Register-MapSfldShare start ==='
# Self-contained PC types never get S: (Display kiosk: local accounts, no # Self-contained PC types never get S: (Display kiosk: local accounts, no
# SFLD creds, mapping could only fail). Gate here as well as at the call # SFLD creds, mapping could only fail). Gate here as well as at the call
# site - see the 579C144 note in the previous version. # site - see the 579C144 note in the previous version.
@@ -53,6 +54,36 @@ if ($pcType -and ($selfContainedTypes -contains $pcType)) {
# --- stage the script ---------------------------------------------------- # --- stage the script ----------------------------------------------------
$src = Join-Path $PSScriptRoot 'lib\Map-SfldShare.ps1' $src = Join-Path $PSScriptRoot 'lib\Map-SfldShare.ps1'
if (-not (Test-Path $src)) { Write-RegLog "Map-SfldShare.ps1 not found at $src - cannot register"; exit 1 } if (-not (Test-Path $src)) { Write-RegLog "Map-SfldShare.ps1 not found at $src - cannot register"; exit 1 }
$srcHash = (Get-FileHash -Path $src -Algorithm SHA256).Hash
$stampKey = 'HKLM:\SOFTWARE\GE\SfldShare'
# Manifest / share drift guard. The GE-Enforce entry detects on
# MapperHash == DetectionValue, so a share edit without a DetectionValue bump
# makes the engine call this every cycle. Say so once per hash, and take the
# fast path below so that loop costs nothing and never churns the task.
if ($ExpectedHash -and ($ExpectedHash -ne $srcHash)) {
$warned = (Get-ItemProperty -Path $stampKey -Name DriftWarnedHash -EA SilentlyContinue).DriftWarnedHash
if ($warned -ne $srcHash) {
Write-RegLog "WARN: share Map-SfldShare.ps1 is $srcHash but the manifest DetectionValue is $ExpectedHash - bump DetectionValue in common/manifest.json or the engine re-runs this every cycle"
if (-not (Test-Path $stampKey)) { New-Item -Path $stampKey -Force | Out-Null }
Set-ItemProperty -Path $stampKey -Name DriftWarnedHash -Value $srcHash -Type String -EA SilentlyContinue
}
}
# Fast path: staged copy already matches the share and the task exists with
# our action -> nothing to (re)do. Re-stamp and leave (no Register -Force,
# so a live task is never torn down for no reason).
$taskNow = Get-ScheduledTask -TaskName $taskName -EA SilentlyContinue
if ($taskNow -and (Test-Path $mapScript) -and
((Get-FileHash -Path $mapScript -Algorithm SHA256).Hash -eq $srcHash) -and
($taskNow.Actions[0].Execute -like '*conhost.exe') -and
($taskNow.Actions[0].Arguments -like "*$mapScript*")) {
if (-not (Test-Path $stampKey)) { New-Item -Path $stampKey -Force | Out-Null }
Set-ItemProperty -Path $stampKey -Name MapperHash -Value $srcHash -Type String -EA SilentlyContinue
exit 0
}
Write-RegLog '=== Register-MapSfldShare start ==='
if (-not (Test-Path $InstallRoot)) { New-Item -Path $InstallRoot -ItemType Directory -Force | Out-Null } if (-not (Test-Path $InstallRoot)) { New-Item -Path $InstallRoot -ItemType Directory -Force | Out-Null }
Copy-Item -Path $src -Destination $mapScript -Force Copy-Item -Path $src -Destination $mapScript -Force
Write-RegLog "Staged $src -> $mapScript" Write-RegLog "Staged $src -> $mapScript"
@@ -69,9 +100,14 @@ if (Get-ScheduledTask -TaskName $legacyTask -EA SilentlyContinue) {
# --- task ------------------------------------------------------------------ # --- task ------------------------------------------------------------------
try { try {
# conhost --headless, not powershell -WindowStyle Hidden: on Win11 the
# default console host is Windows Terminal, which ignores the hosted
# app's Hidden style and flashes a window + steals foreground for
# ~0.5 s on every fire (measured in the VM). Headless conhost shows
# nothing and moves focus nowhere.
$ps = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" $ps = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
$action = New-ScheduledTaskAction -Execute $ps ` $action = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\conhost.exe" `
-Argument ('-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "{0}"' -f $mapScript) -Argument ('--headless "{0}" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{1}"' -f $ps, $mapScript)
$ns = 'Root/Microsoft/Windows/TaskScheduler' $ns = 'Root/Microsoft/Windows/TaskScheduler'
$triggers = New-Object System.Collections.ArrayList $triggers = New-Object System.Collections.ArrayList

View File

@@ -59,15 +59,23 @@ function Get-State {
Key = $k Key = $k
FailCount = [int]($p.FailCount) FailCount = [int]($p.FailCount)
LastState = [string]$p.LastState LastState = [string]$p.LastState
Seeded = [int]($p.CredSeeded) CredHash = [string]$p.CredHash # SHA256(user + password) last seeded
} }
} }
function Set-State { function Set-State {
param([string]$Key, [int]$FailCount, [string]$LastState, [int]$Seeded = -1) param([string]$Key, [int]$FailCount, [string]$LastState, [string]$CredHash = '')
Set-ItemProperty -Path $Key -Name FailCount -Value $FailCount -Type DWord -EA SilentlyContinue Set-ItemProperty -Path $Key -Name FailCount -Value $FailCount -Type DWord -EA SilentlyContinue
Set-ItemProperty -Path $Key -Name LastState -Value $LastState -Type String -EA SilentlyContinue Set-ItemProperty -Path $Key -Name LastState -Value $LastState -Type String -EA SilentlyContinue
Set-ItemProperty -Path $Key -Name LastRun -Value (Get-Date -Format 's') -Type String -EA SilentlyContinue Set-ItemProperty -Path $Key -Name LastRun -Value (Get-Date -Format 's') -Type String -EA SilentlyContinue
if ($Seeded -ge 0) { Set-ItemProperty -Path $Key -Name CredSeeded -Value $Seeded -Type DWord -EA SilentlyContinue } # only overwrite the seeded-credential hash when the caller supplies one
if ($CredHash) { Set-ItemProperty -Path $Key -Name CredHash -Value $CredHash -Type String -EA SilentlyContinue }
}
function Get-CredHash {
param([string]$User, [string]$Password)
$sha = [System.Security.Cryptography.SHA256]::Create()
$b = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes("$User`n$Password"))
$sha.Dispose()
return ([BitConverter]::ToString($b) -replace '-', '')
} }
# --- probes ------------------------------------------------------------- # --- probes -------------------------------------------------------------
@@ -147,8 +155,34 @@ public static class GeCredStore {
# --- mapping ------------------------------------------------------------ # --- mapping ------------------------------------------------------------
function New-Mapping { function New-Mapping {
param([string]$Drive, [string]$Unc, [string]$User, [string]$Password, [string]$HostName) param([string]$Drive, [string]$Unc, [string]$User, [string]$Password, [string]$HostName, [bool]$PreFlight = $false)
$letter = $Drive.TrimEnd(':') $letter = $Drive.TrimEnd(':')
# When a mapping (wrong or unreadable) is being replaced, prove the new
# one can be made BEFORE deleting the old: map the target to a spare
# letter, then drop it. A failed pre-flight leaves the old mapping in
# place. 1219 is the exception - it means the old mapping's own session
# is what blocks the new identity, so deleting it is the only way.
if ($PreFlight) {
$used = @((Get-PSDrive -PSProvider FileSystem -EA SilentlyContinue).Name) + @((Get-SmbMapping -EA SilentlyContinue).LocalPath | ForEach-Object { $_.TrimEnd(':') })
$spare = $null
foreach ($c in [char[]]'ZYXVUTQPONMLKJIHG') { if ($used -notcontains [string]$c) { $spare = "${c}:"; break } }
if ($spare) {
try {
New-SmbMapping -LocalPath $spare -RemotePath $Unc -UserName $User -Password $Password -Persistent $false -ErrorAction Stop | Out-Null
$ok = Test-DriveReadable -Drive $spare -TimeoutSec 8
& net use $spare /delete /y 2>&1 | Out-Null
if ($ok -ne 'OK') { return "pre-flight via $spare mapped but unreadable ($ok) - old $Drive left in place" }
} catch {
$m = $_.Exception.Message
if ($m -notmatch '1219|more than one user name|Multiple connections') {
return "pre-flight via $spare failed: $m - old $Drive left in place"
}
Write-MapLog "pre-flight hit 1219 - the existing $Drive session blocks the new identity; replacing it" 'WARN'
}
}
}
& net use $Drive /delete /y 2>&1 | Out-Null & net use $Drive /delete /y 2>&1 | Out-Null
Remove-Item -Path "HKCU:\Network\$letter" -Recurse -Force -EA SilentlyContinue Remove-Item -Path "HKCU:\Network\$letter" -Recurse -Force -EA SilentlyContinue
try { try {
@@ -200,11 +234,16 @@ foreach ($e in $entries) {
$target = if ($mapping) { [string]$mapping.RemotePath } else { '' } $target = if ($mapping) { [string]$mapping.RemotePath } else { '' }
$wrong = $present -and ($target.TrimEnd('\') -ne $e.Unc) $wrong = $present -and ($target.TrimEnd('\') -ne $e.Unc)
# Seed Credential Manager once per profile (idempotent, cheap). # Seed Credential Manager whenever the HKLM credential differs from what
if ($st.Seeded -ne 1) { # this profile last seeded (first run, or a password rotation), and again
# after every repair. Keeps Windows' own logon-time restore authenticating.
$credHash = Get-CredHash -User $e.User -Password $e.Pass
if ($st.CredHash -ne $credHash) {
$rc = Set-StoredDomainCredential -Target $e.Host -User $e.User -Password $e.Pass $rc = Set-StoredDomainCredential -Target $e.Host -User $e.User -Password $e.Pass
if ($rc -eq 0) { Write-MapLog "Seeded Credential Manager for $($e.Host) as $($e.User)"; Set-State -Key $st.Key -FailCount $st.FailCount -LastState $st.LastState -Seeded 1 } if ($rc -eq 0) {
else { Write-MapLog "CredWrite for $($e.Host) failed (win32 $rc)" 'WARN' } Write-MapLog "Seeded Credential Manager for $($e.Host) as $($e.User)$(if ($st.CredHash) { ' (credential changed)' })"
Set-State -Key $st.Key -FailCount $st.FailCount -LastState $st.LastState -CredHash $credHash
} else { Write-MapLog "CredWrite for $($e.Host) failed (win32 $rc)" 'WARN' }
} }
$probe = 'SKIPPED' $probe = 'SKIPPED'
@@ -239,8 +278,8 @@ foreach ($e in $entries) {
continue continue
} }
Write-MapLog "Repairing $($e.Drive) -> $($e.Unc)" Write-MapLog "Repairing $($e.Drive) -> $($e.Unc)$(if ($present) { ' (pre-flight first, existing mapping kept on failure)' })"
$err = New-Mapping -Drive $e.Drive -Unc $e.Unc -User $e.User -Password $e.Pass -HostName $e.Host $err = New-Mapping -Drive $e.Drive -Unc $e.Unc -User $e.User -Password $e.Pass -HostName $e.Host -PreFlight $present
if ($err) { if ($err) {
Write-MapLog "Repair FAILED $($e.Drive): $err" 'ERROR' Write-MapLog "Repair FAILED $($e.Drive): $err" 'ERROR'
Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'failed' Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'failed'
@@ -250,7 +289,11 @@ foreach ($e in $entries) {
$verify = Test-DriveReadable -Drive $e.Drive -TimeoutSec $ProbeTimeoutSec $verify = Test-DriveReadable -Drive $e.Drive -TimeoutSec $ProbeTimeoutSec
if ($verify -eq 'OK') { if ($verify -eq 'OK') {
Write-MapLog "Repaired $($e.Drive) -> $($e.Unc) (verified readable)" Write-MapLog "Repaired $($e.Drive) -> $($e.Unc) (verified readable)"
Set-State -Key $st.Key -FailCount 0 -LastState 'healthy' # re-seed after every repair so the stored credential matches what
# just proved to work
$rc = Set-StoredDomainCredential -Target $e.Host -User $e.User -Password $e.Pass
if ($rc -ne 0) { Write-MapLog "CredWrite after repair failed (win32 $rc)" 'WARN' }
Set-State -Key $st.Key -FailCount 0 -LastState 'healthy' -CredHash $credHash
} else { } else {
Write-MapLog "Mapped $($e.Drive) but verify failed: $verify" 'ERROR' Write-MapLog "Mapped $($e.Drive) but verify failed: $verify" 'ERROR'
Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'failed' Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'failed'