Files
pxe-server/playbook/shopfloor-setup/Shopfloor/Register-MapSfldShare.ps1
cproudlock 2ec097069c 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
2026-09-04 13:37:37 -04:00

178 lines
8.7 KiB
PowerShell

# Register-MapSfldShare.ps1 - stage Map-SfldShare.ps1 and register the
# 'GE Map SFLD Share' scheduled task that keeps S: alive for every
# interactive BUILTIN\Users member (ShopFloor, SupportUser, ...).
#
# Replaces the HKLM\Run entry of the same name. Run fired once per logon and
# had no way to repair S: mid-shift; a task can fire at logon, on an
# interval, on unlock / console connect and when the network comes up.
# Principal is the Users GROUP with RunLevel Limited, so it runs in each
# logged-on member's own session with a non-elevated token: the mapping
# lands where Explorer and the apps look for it.
param(
[int]$IntervalMinutes = 3,
[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'
$taskName = 'GE Map SFLD Share'
$runKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'
$runValue = 'GE Map SFLD Share'
$legacyTask = 'GE Shopfloor Map S: Drive'
$mapScript = Join-Path $InstallRoot 'Map-SfldShare.ps1'
$logDir = 'C:\Logs\SFLD'
$logFile = Join-Path $logDir 'register-mapshare.log'
if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null }
function Write-RegLog {
param([string]$Message)
$line = '[{0}] [INFO] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
Write-Host $line
}
# 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
# site - see the 579C144 note in the previous version.
$selfContainedTypes = @('Display', 'gea-shopfloor-display')
$pcType = ''
if (Test-Path -LiteralPath 'C:\Enrollment\pc-type.txt') {
$pcType = (Get-Content -LiteralPath 'C:\Enrollment\pc-type.txt' -First 1 -EA SilentlyContinue)
if ($pcType) { $pcType = $pcType.Trim() }
}
if ($pcType -and ($selfContainedTypes -contains $pcType)) {
Write-RegLog "PC type '$pcType' is self-contained - not registering the S: mapper."
Remove-ItemProperty -Path $runKey -Name $runValue -Force -EA SilentlyContinue
if (Get-ScheduledTask -TaskName $taskName -EA SilentlyContinue) { Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -EA SilentlyContinue }
exit 0
}
# --- stage the script ----------------------------------------------------
$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 }
$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 }
Copy-Item -Path $src -Destination $mapScript -Force
Write-RegLog "Staged $src -> $mapScript"
# --- retire the older mechanisms -----------------------------------------
if (Get-ItemProperty -Path $runKey -Name $runValue -EA SilentlyContinue) {
Remove-ItemProperty -Path $runKey -Name $runValue -Force -EA SilentlyContinue
Write-RegLog "Removed HKLM\Run '$runValue' (superseded by the scheduled task)"
}
if (Get-ScheduledTask -TaskName $legacyTask -EA SilentlyContinue) {
Unregister-ScheduledTask -TaskName $legacyTask -Confirm:$false -EA SilentlyContinue
Write-RegLog "Removed legacy task '$legacyTask'"
}
# --- task ------------------------------------------------------------------
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"
$action = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\conhost.exe" `
-Argument ('--headless "{0}" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{1}"' -f $ps, $mapScript)
$ns = 'Root/Microsoft/Windows/TaskScheduler'
$triggers = New-Object System.Collections.ArrayList
# 1. logon (any user)
[void]$triggers.Add((New-ScheduledTaskTrigger -AtLogOn))
# 2. interval, forever
$rep = New-ScheduledTaskTrigger -Once -At (Get-Date).Date -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes)
[void]$triggers.Add($rep)
# 3. console connect (1) + session unlock (8)
$ssCls = Get-CimClass -ClassName MSFT_TaskSessionStateChangeTrigger -Namespace $ns
foreach ($state in 1, 8) {
$t = New-CimInstance -CimClass $ssCls -ClientOnly
$t.StateChange = $state
$t.Delay = 'PT10S'
[void]$triggers.Add($t)
}
# 4. network profile connected (NetworkProfile/Operational 10000) - wifi
# roam / cable back / VPN up. Short delay so DNS is usable.
$evCls = Get-CimClass -ClassName MSFT_TaskEventTrigger -Namespace $ns
$ev = New-CimInstance -CimClass $evCls -ClientOnly
$ev.Subscription = '<QueryList><Query Id="0" Path="Microsoft-Windows-NetworkProfile/Operational"><Select Path="Microsoft-Windows-NetworkProfile/Operational">*[System[(EventID=10000)]]</Select></Query></QueryList>'
$ev.Delay = 'PT15S'
[void]$triggers.Add($ev)
$principal = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-545' -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet `
-MultipleInstances IgnoreNew `
-ExecutionTimeLimit (New-TimeSpan -Minutes 5) `
-StartWhenAvailable `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-Hidden
Register-ScheduledTask -TaskName $taskName `
-Action $action -Trigger $triggers.ToArray() -Principal $principal -Settings $settings `
-Force -ErrorAction Stop | Out-Null
Write-RegLog "Registered task '$taskName' (BUILTIN\Users, Limited; logon + every $IntervalMinutes min + unlock/connect + network-up)"
} catch {
Write-RegLog "FAILED to register task: $_"
exit 1
}
# --- stamp -----------------------------------------------------------------
# Written only after staging + registration both succeeded. The GE-Enforce
# manifest entry detects on MapperHash == SHA256 of Map-SfldShare.ps1, so a
# new script version (new hash) or a failed registration (no stamp) re-fires
# this registrar on the next cycle.
try {
$hash = (Get-FileHash -Path $mapScript -Algorithm SHA256).Hash
$stampKey = 'HKLM:\SOFTWARE\GE\SfldShare'
if (-not (Test-Path $stampKey)) { New-Item -Path $stampKey -Force | Out-Null }
Set-ItemProperty -Path $stampKey -Name MapperHash -Value $hash -Type String
Set-ItemProperty -Path $stampKey -Name RegisteredAt -Value (Get-Date -Format 's') -Type String
Set-ItemProperty -Path $stampKey -Name TaskName -Value $taskName -Type String
Write-RegLog "Stamped $stampKey MapperHash=$hash"
} catch {
Write-RegLog "Stamp write failed (registrar will re-run next cycle): $_"
}
Write-RegLog '=== Register-MapSfldShare end ==='
exit 0