Set-MachineNumber rewrote one Devices.xml per variant and restarted the agent.
That is right for Okuma and eDNC, which keep the device name in that one file -
checked across 7 Okuma and 15 eDNC bay captures. Fanuc and Makino keep it in
several, and editing only the agent's copy leaves the adapter streaming under
the old identity.
It has already happened. Seven of thirty-four captured Fanuc bays have the
agent on the right machine number and the adapter still on the bay it was
imaged from: 4007 on a 7801 Toshulin, 3031 on a 7804, 2005 against 2006 on a
dual-spindle pair. Both captured Makino bays have Devices.xml on the machine
number and the other three files still on MAKINO-1.
Update-MTConnectVariantName renames across a declared set of files with the
owning services stopped first. Makino needs that ordering more than most: the
vendor's guide says the Adapter Manager rewrites the COMPLETE configuration
from memory as it stops, so an edit made while it runs is discarded, and that
the name must be identical in every file or the agent may not start.
It collects every name in play before rewriting rather than discovering one.
A bay half-renamed by the old code carries two at once, and converging on the
one that happened to be found leaves the other behind - which is the state this
is meant to end, not reproduce. Nothing is written if a service will not stop,
and whatever was stopped is started again.
Tested on Windows 11 against copies of the real captures: the 7801 bay (adapter
4007) converges to 7801 in two files and leaves the correct agent alone; the
7502 Makino bay converges all four; second runs report no changes. Service
ordering verified by process id, using stand-in services - sc.exe fakes named
after the real ones are not real services, so the service list is a parameter
defaulting to the production names.
Does not fix the bays already in this state; they need a run each.
Sixteen files that run on every shopfloor PC existed only on the SFLD share.
The cost showed up while debugging the NTLARS backup: the script that posts to
ShopDB could not be read, reviewed or diffed, so its behaviour was inferred
from log output for most of a day. It turned out to hold a silent fallback that
had been governing the whole fleet for months.
Imported as-is from tsgwp00525-v2, no edits:
lib/ShopdbBackupClient.psm1 the shared backup client
scripts/Backup-NtlarsSettings.ps1 converted to use it
scripts/Set-ShopdbCollectorKey.ps1 collector credential delivery
scripts/Test-RegExport.ps1 exercises the .reg codec with mocks
scripts/Set-EventSaver*.ps1 kiosk power / screensaver / disable
scripts/Setup-OpenText.* OpenText install + toolbar
scripts/Migrate-PCType.ps1, Select-KioskType.ps1, Set-FmsHostsEntry.ps1,
scripts/ensure-vnc-firewall.ps1, Install-AcroReader.cmd, Install-Oracle11r2.cmd
lib/Install-FromManifest.ps1 is also updated from the share, which was 37 lines
AHEAD of this repo and purely additive: the Add-EnforceResult reporting added
during the kiosk API cutover, done live and never committed back. Nothing was
removed.
Checked for embedded secrets before committing; there are none.
Set-ShopdbCollectorKey deliberately reads its token from a sibling file on the
share rather than holding it, so the script is safe to track.
The share remains what actually runs. This makes it reviewable, and makes the
next drift visible as a diff rather than a surprise.
The prune drops a *.log by LastWriteTime. A log written to a fixed filename is
appended every cycle, so its LastWriteTime is always now, it can never be older
than any cutoff, and it grows without limit. ntlars-backup.log, eventsaver.log
and shopdb-collector-key.log all do this. On one part-marker PC eventsaver.log
had reached 11,000 lines and ntlars-backup.log 3,234, of which 3,217 were the
same "Throttled" line - the four events that mattered were unfindable without
grep -v.
Undated logs are now rolled to <name>-YYYYMMDD.log before the prune runs, which
stops them being written to and lets them age out on the existing 30 day
retention. The owning scripts need no change: they append with Add-Content or
Tee-Object, which recreate a missing file on the next write. This matters
because those three scripts are live-share artifacts, not in this repo.
Rolled under the date of its FIRST LINE, so the stamp matches the contents.
That is also the only trustworthy signal: CreationTime is not, because NTFS
file tunneling gives a recreated file the old creation time when it reappears
within 15 seconds, so a busy log keyed on that would look stale the instant it
rolled and would roll again every cycle.
A log with no parseable timestamp is left alone until it passes 5MB, so an
unrecognised format still cannot grow forever. Empty files and already-stamped
files are skipped. Rolling onto an existing target appends rather than
overwriting, so a second roll on one day loses neither side.
Verified against real files under pwsh, including the append branch and the
owner recreating the file afterwards. The first attempt keyed on CreationTime
and a second used [datetime]::TryParse with an untyped $null, which throws "no
overload" and would have made the whole roll a silent no-op; the ref is now
pre-typed.
New section 9a - five design rules, each from a bug fixed on 2026-08-06 and each
cheap to honour in a new design but expensive to retrofit:
Absent and empty are different. Four scripts tested config as
"not null AND Count > 0", so an explicitly empty list fell through to a
hardcoded default - a kiosk configured with "desktopApps": [] was given the
full machine-tool set. Configuring "none" produced "everything".
Deny by default. preinstall.json had 16 of 21 entries at PCTypes ['*'] and the
SMB manifest 18 of 25 unfiltered. Both filters worked perfectly; they just were
not applied.
Gate at the point of action. Call-site gates on PC type were bypassed by the
finalization phase, silently.
The image carries configuration. WJ Shopfloor.lnk is inside the WIM, so no
pipeline change removes it.
The ppkg is a hard boundary. Chrome, RealVNC, Tanium, CyberArk and the rest come
from the package - thinning the image does not thin them.
Also corrects a premise in section 4.2: a first-boot selection UI cannot assume
the API is reachable, because ShopDB needs the AESFMA wifi SSID and the imaging
LAN has no route to it. Points at Install-ShopdbKiosk-WhenOnline.ps1 as the
reusable shape - arm a task at imaging, do nothing until the API answers, act
once, verify, self-remove.
Plus two risks, the diagnostics collector in the reference map, and a gotchas
section: HardwareDriver.json defeats ConvertFrom-Json on every PowerShell
version, Compress-Archive caps at 2 GB against an 8 GB package, and PowerShell
Core normalises backslashes on Linux so bay scripts can be tested here.
The driver check - the most valuable check in this script - has never actually
run. It died on:
Driver check failed: Cannot convert the JSON string because a dictionary that
was converted from the string contains the duplicated keys 'FileName' and
'FileName'
HardwareDriver.json carries both casings of the same fields - fileName and
FileName, destinationDir and DestinationDir. ConvertFrom-Json treats object keys
case-insensitively and refuses the document. -AsHashtable would handle it but
that is PowerShell 6+, and WinPE runs 5.1. Confirmed it throws on PowerShell 7
too, so no version of ConvertFrom-Json can read this file as-is.
Pulls the four needed fields out of each entry by regex instead, preferring the
lowercase key and falling back to the capitalised one, and unescaping the
backslashes in destinationDir.
Tested against the real 44-entry catalogue, all three outcomes:
OptiPlex Micro 7020, pack present -> OK, win11_optiplexd13mlk7020_a09.zip
same model, pack removed -> FAIL, names the missing path
Surface Laptop 7 -> FAIL, no pack matches
Worth noting the check was failing SAFE - a WARN that reads like a tooling
glitch rather than a missing driver pack. It would have stayed invisible until a
bay imaged with no NIC.
The post-fix capture from 579C144 reported three entries as unknown(0x04) -
SecurityHealth, RtkAudUService, WavesSvc. The decoder only knew 02/06 enabled and
03/07 disabled. 04 is also enabled and 05 also disabled; without them the report
says 'unknown' for entries that are perfectly ordinary.
PREINSTALL SCOPE
Sixteen entries carried PCTypes ['*'], so a Display kiosk installed Adobe
Acrobat, OpenText HostExplorer, WJF Defect Tracker, the shopfloor serial drivers
and twelve legacy VC++ redistributables. Those are now scoped to the nine
machine-tool types.
A Display now installs 3 of 21 entries:
PowerShell 7.5.4 SFLD-ApplyDSCConfig runs pwsh.exe 7
VC++ Redistributable 2015-2022 x64 modern x64 runtime Edge/WebView2/PS7 lean on
Display Kiosk App already Display-scoped
Kept as wildcards deliberately - removing the x64 runtime risks breaking the
kiosk itself, and PS7 is a hard dependency of the DSC task. Everything else a
kiosk has no use for. Edited as targeted text replacement, 16 line pairs, so the
file's formatting and its long _comment blocks are untouched.
THE LAST OF THE EMPTY-MEANS-DEFAULT BUGS
Configure-PC.ps1 and 07-TaskbarLayout.ps1 had the same test as the two fixed
earlier: $null -ne $cfg -AND .Count -gt 0, so an explicitly empty list fell
through to a hardcoded shopfloor default. Configure-PC is the one that actually
bit - it runs in the finalization phase and put these in the all-users Startup
folder on 579C144, timestamped mid-image:
Defect Tracker.lnk 16:46:03
Plant Apps.lnk 16:46:03
Plant Apps launching msedge --new-window on a kiosk. eDNC was in the same
hardcoded list and only escaped because its Test-Path guard found no DncMain.exe.
08-EdgeDefaultBrowser.ps1 had it too, with a Plant Apps + WJ Shopfloor + Dashboard
tab fallback. Harmless today because the Display profiles configure a real tab,
but one edit away from biting.
Get-ProfileValue returns $null only when a key is absent from BOTH the profile
and site-config, so $null is the only honest "not configured" signal and an
empty array means what it says.
VERIFIED against the post-fix capture: the S: mapper Run key and the machine
number prompt task are both gone, and 'ShopDB Kiosk Bootstrap' registered
correctly with boot and time triggers.
Also: the collector's StartupApproved decoder now handles 0x04/0x05, which that
same capture surfaced as unknown.
Still baked into the WIM and unaffected by any of this: WJ Shopfloor.lnk, dated
April, sitting in the all-users Startup folder.
Three fixes from the 579C144 diagnostics, all the same shape: a Display kiosk
being treated as a machine-tool bay.
desktopApps: configuring "none" produced "everything"
"Display-Dashboard": { "desktopApps": [] }
06-OrganizeDesktop.ps1 tested $null -ne $cfgApps -AND .Count -gt 0, so an
explicitly empty list fell through to the hardcoded fallback and the kiosk was
given UDC, eDNC, NTLARS, WJ Shopfloor and Defect_Tracker shortcuts.
Get-ProfileValue returns $null only when the key is ABSENT from both the profile
and site-config, so $null is the real "not configured" signal and an empty array
means what it says. startupItems carried the same test - harmless today because
its else branch has no fallback list, but commented so the two cannot drift.
Machine-number prompt on a machine with no machine number
Register-CheckMachineNumberTask.ps1 armed 'Prompt Machine Number' (AtLogOn,
BUILTIN\Users) whenever the number was the 9999 placeholder - which is always
true on a Display, because startnet only collects a real number for machine-tool
types. A logon dialog on a kiosk with no keyboard. Now skips PC types that have
no machine number by design, and clears any stale task.
S: mapper on a share-less PC
Displays are Entra-joined with local accounts and no SFLD credentials, so mapping
S: can only fail, once per logon, forever. Run-ShopfloorSetup already gated the
CALL on $noEnforceTypes, but the bay registered it anyway at 15:07:55 with no
"Skipping" line in the log - so something in the finalization phase reaches the
registrar past that gate. Rather than chase the caller, the registrar now gates
itself and removes a stale Run entry. The call-site gate stays; this makes the
outcome correct regardless of who invokes it.
That bypass is worth understanding separately - the same pattern would defeat any
call-site gate in the finalization phase.
All three parse clean and are deployed byte-identical to the share.
The bootstrap looked for C:\Enrollment\kiosk\Install-ShopdbKiosk.ps1, but
startnet copies the type-specific tree to
C:\Enrollment\shopfloor-setup\gea-shopfloor-display\ - so the payload lands
beside the bootstrap, not under C:\Enrollment.
As written it would have logged "Installer not staged" on every 15-minute cycle
and never installed anything, with no error anywhere. Exactly the silent-failure
shape this whole day has been about, and it would have looked like the API being
unreachable.
Resolves via a -KioskRoot parameter defaulting to kiosk\ next to the script, so
the bootstrap and its payload travel together regardless of where the tree is
staged. display-type.txt still comes from EnrollmentRoot, which is correct - that
one really is at C:\Enrollment.
A PXE-imaged display ends up with no GE-Enforce client at all. Confirmed on
579C144, 2026-08-06:
is the client installed? NOT FOUND
scheduled tasks that would run it? NONE
Not a broken configuration - nothing had ever tried. Install-ShopdbKiosk.ps1
downloads itself from {BaseUrl}/installers/kiosk over HTTPS, and ShopDB is only
reachable after the bay joins the AESFMA wifi SSID, so it cannot run during
imaging. Nothing was arranged to run it afterwards.
09-Setup-Display.ps1 now registers 'ShopDB Kiosk Bootstrap' as a SYSTEM task at
boot and every 15 minutes. Install-ShopdbKiosk-WhenOnline.ps1 does nothing until
ShopDB answers, then runs the vendor installer once, verifies BaseUrl and the
enforce task exist, deletes the staged key and unregisters itself. A bay cabled
up days later still self-configures.
It deliberately does not reimplement the installer - it waits, invokes, verifies
and cleans up, so the vendor script can be replaced wholesale without touching
this.
The key file is LABELLED (collector=, fetch=) rather than positional. The two
tokens are not interchangeable and a mix-up is silent: a fetch token in the
collector slot leaves asset reporting broken while everything looks configured.
A bare unlabelled line is ignored rather than guessed at. Missing keys are not
fatal - the fetch token is unnecessary on an IP-allowlisted subnet, and the
installer skips the asset-report task rather than failing.
Staged on the share: the bootstrap, the vendor installer under kiosk\, and the
updated display setup. The key itself is NOT staged yet - see the commit
discussion; it needs to be labelled with which scope it carries first.
Three defects found by the first real run, on 579C144.
16 GB collection, zip failed. run-enrollment harvests
C:\ProgramData\Microsoft\Provisioning into C:\Logs\PPKG, so the 8 GB
provisioning package existed TWICE under the trees being copied. Compress-Archive
cannot exceed 2 GB and died with "stream was too long". Copies now exclude
*.ppkg, *.wim and *.iso and cap individual files at 100 MB.
Base-URL detection matched the wrong value. The name pattern accepted anything
containing "base", so it picked up baseVersion=2.0.2 from
HKLM:\SOFTWARE\GE\SFLD\Credentials and then probed "2.0.2/api/docs" - making
both UNREACHABLE lines meaningless. Now matches the name exactly and requires the
value to start with http.
Unreachable was reported as a bare failure. ShopDB is only reachable once the bay
has joined the AESFMA wifi SSID, so on the imaging LAN or plain wired an
unreachable result is EXPECTED. It now says so, rather than looking like a fault.
The run still answered the question it was written for: no enforce client is
installed and nothing is scheduled to call the API, so this bay was never going
to reach GE-Enforce regardless of network.
Disabling a startup item through Task Manager or Settings does not remove the Run
key or the Startup shortcut - Windows writes a flag into StartupApproved instead.
So the collector would have listed those entries as present with no indication
they had been switched off, and a bay where someone had already tidied up would
look identical to one where nothing autostarts.
Reads all five StartupApproved keys, including Run32 for the WOW64 view, and
decodes the first byte: 02/06 enabled, 03/07 disabled.
This matters for the current investigation because turning an item off is a
per-machine workaround - the next imaged bay installs and starts it again. The
fix is not installing it, which needs the app-to-autostart mapping this section
preserves.
Every imaging investigation so far has meant walking to a bay and hand-copying
files, and each time discovering another thing we wished we had grabbed at the
same moment. This takes the lot. Read-only; changes nothing.
Collects: computer name (active AND pending, which is how you tell whether the
package's H<serial> -> F<serial> rename has landed or is still queued for the
next reboot), dsregcmd enrollment state, the package self-checks, installed
applications, everything that starts by itself, kiosk shortcut targets, Edge
policy, GE-Enforce config, drivers, disk, network, provisioning sessions, and the
whole of C:\Logs, Panther and the provisioning data plus the diagnostics evtx.
Two things it does deliberately:
Autostart is captured across all four surfaces - Run/RunOnce in BOTH registry
views, all Startup folders, non-Microsoft scheduled tasks, and auto-start
services outside C:\Windows. That combination is what identifies which installer
planted a given autostart entry. A 32-bit installer's Run key lands under
Wow6432Node where 64-bit tooling never looks, which is exactly how the old
Dashboard/Lobby autostart survived an earlier purge.
It TESTS the ShopDB GE-Enforce API rather than just reporting config. Config on
disk proves nothing - a client can be present, configured, and never once
succeed. It reports whether a client exists at all, whether anything is scheduled
to run it, the configured base URL, and then actually probes the endpoint. A 401
is a good result: it proves DNS, routing and TLS work and the service answered.
Only a timeout or DNS failure means unreachable. No token is sent.
Deliberately avoids Win32_Product - querying it triggers an MSI reconfigure of
every installed product, which is slow and can change the machine.
Run it BEFORE lockdown. What it captures is known-CURRENT, not known-good: a bay
straight off the line carries applications that should not be there, because
preinstall.json entries without a PCTypes filter install everywhere. The point is
to have an exact record of what imaging really produces so the unnecessary items
can be identified and filtered. After lockdown you cannot tell whether something
is absent because lockdown removed it or because imaging never installed it.
Staged on the enrollment share alongside the other shopfloor-setup scripts.
REFERENCES (section 13, was six lines, now a map)
Where every referenced thing lives, so the document is usable by someone without
today's context: this repo, the PXE server share, shopdb-flask, the on-bay log
locations, and the evidence bundles each claim came from. Plus re-derivation
recipes - pulling startnet out of boot.wim, extracting customizations.xml from an
8 GB .ppkg without unpacking it, reading the provisioning evtx, and the two
validation commands - and the 2026-08-06 commit list.
LTSC AUTOSTART - CORRECTED
Section 7.1 previously argued PESetup might already auto-start: AutoStartCountDown
is 31, disableauto.json is absent, and the operator on 579C144 clicked Next at 11
seconds, so nobody had waited long enough to find out.
Per the team's operational experience that is wrong for our media. LTSC builds
cannot autostart - the SELECT OS confirmation is mandatory regardless of the
countdown, and every image we produce is Windows 11 LTSC 24H2.
So the countdown is not a route to zero-touch. That promotes re-implementing the
imaging step from a contingency to the actual decision, and the risk table and
open questions now frame it as "re-implement, or accept one click per build" -
zero-touch being the only thing that requires it. The hands-off test is kept, but
only to confirm and record the behaviour first-hand.
Design proposal, nothing built. Two ideas in one architecture: the image becomes
thin (partition, apply, join Entra, install the enforce client - everything else
is data pulled from the API and enforced by GE-Enforce), and imaging becomes a
job rather than a menu (open a machine in ShopDB, click Create this PC, pick the
zeroed box waiting in WinPE, and it images start to finish reporting progress
onto that machine's record).
Every claim about current behaviour is sourced from 2026-08-06 evidence -
decompiled binaries, live logs off bay 579C144, and the live share - and where
something is inferred it says so.
Covers: why the current early-decision chain fails silently (five worked examples
from one day); what already exists to build on; per-machine configuration records
replacing 147 .reg files and two CSVs, referencing payloads rather than embedding
them; the dual-NIC design with the firewall work that gates it; whether PESetup
needs replacing at all; the BPRT token shelf-life constraint on pre-imaged stock;
an ownership table; six delivery phases each useful alone; risks; and seven open
questions.
Two findings in here are worth acting on before any of it:
disableauto.json is ABSENT from the media and AutoStartCountDown is 31, while
the operator on 579C144 clicked Next at 11 seconds. PESetup may already
auto-start and nobody has waited long enough to find out. One bay, hands off
the keyboard, count to 40.
dnsmasq is already safely bound (listen-address + bind-dynamic) so DHCP will
not leak onto a GE-side NIC - but Samba binds to all interfaces and every ufw
rule is ALLOW IN Anywhere, including SSH on a pxe/pxe account. That work gates
dual-homing.
The PXE server address appeared 14 times and the share credentials 13, so moving
the server or rotating the account meant editing every net use in the file and
rebuilding boot.wim - with no way to tell from a diff whether one had been
missed. Now set once at the top as PXESERVER / SHAREUSER / SHAREPASS.
Exactly one literal address remains, the definition on line 6. Prose in REM
comments is left alone so the paths stay readable.
This also reaches the post-boot self-heal for free. startnet already writes
C:\Enrollment\fetch-source.txt, and both Fetch-StagingPayload.ps1 and
Verify-And-Heal-Staging.ps1 read it (line1=UNC, line2=user, line3=pass) with
their own hardcoded values only as a fallback. Confirmed on 579C144:
[13:30:35] [INFO] fetch-source.txt: UNC=\172.16.9.1\enrollment user=pxe-upload
[13:30:36] [INFO] Mounting \172.16.9.1\enrollment as Z: (attempt 1/5)...
so the seeded file wins and the heal follows PXESERVER automatically. My earlier
claim that the heal scripts were hardcoded was wrong - they were already
config-driven and startnet was already seeding them. The hardcoding was all in
startnet itself.
Verified: set on line 13, first use on line 20; parens balance; every goto
resolves; 1005 CRLF lines with no bare LF; the deployed copy contains exactly one
literal address. boot.wim md5 7a9b832b.
Dell and GE manifests spell the same folder inconsistently - OptiPlex vs
Optiplex. Windows does not care, but the Samba share sits on a case-sensitive
filesystem, so a blind mkdir -p created a SECOND tree and the drivers split
between them.
That is exactly how the OptiPlex Micro 7020 pack went missing: the manifest asked
for OptiPlex/D13MLK while the 3.2 GB zip sat in Optiplex/D13MLK. PESetup found no
pack, logged a warning rather than an error, and the bay imaged with no network
drivers - so DNS failed at first boot and bulk enrollment could not reach the
CDN. Symptoms three steps from the cause.
mkdir_ci walks the path one component at a time and reuses whatever is already
there whatever its case, creating only genuinely new components. Callers must use
the RETURNED path, since it may differ in case from the requested one. Falls back
to plain mkdir -p rather than skipping a download if the resolve fails.
Both spellings still exist in the live catalogues; scripts/lint-driver-catalogue.py
reports a case mismatch as an error so the pair cannot silently drift again.
PC PURPOSE / TENANT FOR STANDARD AND ENGINEER
The capability was already there - the media carries all six MCL packages
(PERS/SH/LOAN x GCCH/RoW) and Start-BulkEnrollOrchestrator.ps1 picks one from
whatever Select-PCConfig.ps1 returns. The problem was WHEN it asks: a GUI at
first logon, so an otherwise unattended build stops at a dialog until someone
walks over.
Now startnet asks after Standard/Engineer is chosen, while the tech who knows
what the machine is for is standing at it, and writes {Tenant}_{Purpose} to
C:\Enrollment\pc-config.txt. A shim at the path the orchestrator already calls
returns that value and falls through to the vendor dialog when it is absent or
malformed - so "ask at first logon" stays available and bays imaged before this
are unaffected. The vendor script is preserved as Select-PCConfig-vendor.ps1;
replace THAT when the vendor ships a new one.
The tenant is not cosmetic. Insert-BPRTToPPKG.ps1 matches the bulk token on
Tenant AND Purpose, so this selects which token gets injected.
ONLY THE CHOICE IS PRE-SEEDED, deliberately. Injection stays at first logon
because it fetches an encrypted token table from mcl.dwcdn.geaerospace.com,
unreachable from the isolated imaging LAN - which is also why routing MCL
packages through run-enrollment.ps1 would not work: provtool would get a package
whose token is still the Exp_XXXXXXXX placeholder and join nothing.
Standard/engineer previously skipped staging entirely (no ppkg, no PCTYPE), so
the staging gate now also admits PCCONFIG.
3D PARTS KIOSK
The display submenu offered Dashboard and Lobby only. The GE-Enforce display
scope has always had a third entry - 3DPrintRoom -> /shopdb/parts-kiosk - so a
parts kiosk could only be set by editing display-type.txt by hand after imaging.
Added as option 3. The value is a KEY into that map, so the spelling matches
exactly.
Verified: startnet parens balance, every goto resolves, 994 CRLF lines with no
bare LF; shim parses clean. Deployed - boot.wim 4d16c946, shim staged with the
vendor dialog preserved.
OWNERSHIP.md
Every expensive bug in this pipeline has had one shape: two systems setting the
same thing, last writer winning silently. Four happened on 2026-08-06 alone -
computer name (package vs run-enrollment), drive letters (PESetup vs a volume
finder), enrollment (package vs an at-logon -ManualFallback task that syspreped
finished machines), kiosk URLs (GE-Enforce vs site-config). Each is written down
with the evidence so the next person deletes a writer instead of adding one.
share-drift.py
The share is production and the repo is meant to describe it, but drift runs both
ways: live hand-edits nobody committed, and repo fixes never deployed. The
unattend outage lived only on the share while the repo copy was fine, and nothing
compared them.
Each mapped pair is classified. git-owned means the repo wins and the pair must
match - those fail the run. unreconciled means the two have genuinely diverged
and nobody has decided; reported, not failed. The unattends are unreconciled on
purpose: live is ~17 KB against ~12 KB in the repo, so a blind push would regress
production. Reads over SSH via base64 so BOM and CRLF survive the hop.
First run: 8 git-owned pairs all match, 4 known-unreconciled.
Run-ShopfloorSetup.ps1
Corrects a comment that was actively misleading. It claimed shopfloor PCs are
"vanilla by design" and that the orchestrator runs -ManualFallback to skip BPRT
injection and the package entirely. Shopfloor bays DO enrol - the SFLD package
joins Entra with its BPRT token and a human assigns the device category in
Intune. -ManualFallback runs sysprep /oobe /reboot, which is why wiring it to an
at-logon task destroyed the deployment chain.
The absent Entra wait is still correct, for a different reason: at that point the
bay is on the isolated PXE LAN with no route to Entra (579C144 held 172.16.9.81
and 172.24.19.142, neither in the production ranges). sync_intune retries until
the tech re-cables. "Entra ID Joined: false" right after imaging is normal.
TWO SCRIPTS, DIFFERENT AUDIENCES
playbook/scripts/preflight.ps1 runs at the bay, called by startnet once the
media is mapped. It checks the four things that come straight out of PESetup's
own behaviour:
secure boot GatherDataSelection fails outright when SecurebootEnabled != 1
disk >= 120GB MinRequiredSpaceWithoutCompression is 128849018880
driver match reimplements GetDriverByModel - family filter, untrimmed
comma-separated substring tokens, first match wins - and checks
the pack is actually on the media
media age media expires 30 days after build; Media.tag's timestamp is the
local proxy
The driver check is the one that earns it. A miss is only a WARNING to PESetup,
so the bay images with no NIC, DNS fails at first boot, and enrollment cannot
reach the CDN - a symptom three steps removed from the cause. Advisory by
design: it reports and pauses on a blocker, the tech decides. Lives on the
enrollment share so it can be fixed without rebuilding boot.wim.
scripts/preflight.py runs on the server before a build day and aggregates
everything already built - driver catalogue lint, unattend lint, per-PCTYPE
media view verify - plus a new advisory firmware-coverage check that lists
catalogued models with no BIOS models.txt entry. That last one is how the
OptiPlex 7020 family sat uncovered: 127 catalogued models, 58 covered today.
First run: driver catalogues clean, all three unattends clean, firmware coverage
advisory only. PREFLIGHT PASSED.
Verified: both scripts parse clean (PowerShell parser / python), startnet parens
balance, every goto resolves, 915 CRLF lines with no bare LF. Deployed -
boot.wim md5 99fd3132, preflight.ps1 on the share.
Three changes aimed at the same failure mode: a bay that images green and is
silently unusable, diagnosed only by walking over and copying files off by hand.
VERIFY STAGING (startnet, at :pctype_done)
Checks pc-type.txt, Run-ShopfloorSetup.ps1, shopfloor-setup/common and
shopfloor-setup/<PCTYPE> exist on the applied volume before anything depends on
them, retries the small trees once, and prints a loud banner if the retry does
not fix it. The enrollment package is checked but never blindly re-pulled - it is
8 GB, so a miss is reported instead.
Done here because a tech is still at the machine: a short copy found in WinPE
costs 30 seconds, the same copy found at first logon costs 20 minutes, and found
never costs a rebuild. Four Display bays sat green at stage 2 for weeks.
HARVEST LOGS (startnet, after PESetup exits)
Collects X:\*.log, the generated X:\Unattend.xml, PESetup's own log from the
target's Panther directory and winpe-staging.log into
\<server>\enrollment\imaging-logs\<serial>\, plus a build-context.txt naming
PCTYPE, PPKG, machine number and media. All of it was being discarded at reboot.
Runs after PESetup exits so the logs are final, which means re-mapping Y: since
cleanup already dropped it. Best-effort throughout - a bay must never fail to
reboot because a log copy failed.
W: WAIT CAP 20 -> 45 MINUTES
270 polls instead of 120. Fine on NVMe either way, but a WIM apply can exceed 20
minutes on slow media, and the failure mode is the os_not_found banner plus
nothing staged. The loop still exits the moment the SYSTEM hive appears.
RUN-ENROLLMENT RUNS ONCE
Marker at C:\Enrollment\.ppkg-applied, written on exit 0 and also on 0x800700B7
ERROR_ALREADY_EXISTS. Observed running twice on 579C144; the second pass
re-applied a pending rename over the package's own and otherwise did nothing.
Verified: startnet parens balance, every goto resolves, 899 CRLF lines with no
bare LF; run-enrollment parses clean under the PowerShell parser. Deployed -
boot.wim md5 159c2a4d, live run-enrollment dce9d50a.
The package declares <DNSComputerName>F%SERIAL%</DNSComputerName>, so bays are
meant to come up as F<serial>. This script hardcoded a rename to E<serial>,
which is a second pending rename racing the package's own.
Observed on 579C144, 2026-08-06:
13:31:55 run-enrollment Rename-Computer -> E579C144 (pending)
13:32:17 ppkg Reboot/DeviceName -> F%SERIAL% (pending)
Provisioning-Diagnostics: "Reboot successfully scheduled.
Device name set successfully."
13:36:45 run-enrollment runs AGAIN -> E579C144 (overwrites F)
~13:37 reboot; E579C144 wins
Last writer wins at reboot, so the hardcoded prefix silently beat the package.
The bay came up E579C144 with the ppkg reporting no errors at all - 535
Provisioning-Diagnostics records, every one of them Level 4.
Nothing in this repo ever produced an F name; grep for a prefix rule or a
namePrefix setting finds nothing. The convention only works if naming is left to
the package, so the rename is removed rather than corrected to F. That also
makes it site-agnostic: a different package can name bays differently without a
script edit.
The double execution is a separate defect and is left alone for now. With the
rename gone a second run is harmless: provtool returns 0x800700B7
ERROR_ALREADY_EXISTS and changes nothing.
The dashboard moved to /shopdb/shopfloor. Every reference here still pointed at
/shopdb/shopfloor-dashboard/, which no longer resolves.
Display-Lobby was also pointing at the DASHBOARD url, not the lobby one, so lobby
screens showed the shop floor dashboard. The routes now follow the authoritative
map in shopdb-flask plugins/geenforce/seed_display_scope.py:
Dashboard -> /shopdb/shopfloor
Lobby -> /shopdb/tv
3DPrintRoom -> /shopdb/parts-kiosk (no imaging-time menu option yet)
Changed: urls.shopfloorDashboard, Display-Dashboard.edgeHomepage,
Display-Lobby.edgeHomepage, and the Shopfloor Dashboard fallback in
08-EdgeDefaultBrowser.ps1.
Note these values are now a backstop rather than the primary path. Displays are a
single image whose variant comes from C:\Enrollment\display-type.txt, and the
GE-Enforce display scope delivered over the shopdb API writes the kiosk Startup
shortcut itself - preferring the server-side role from Settings > Dashboard
Defaults and falling back to display-type.txt. That dispatcher also SWEEPS
shortcuts matching shopfloor-dashboard, so a stale value here would have been
deleted on the next enforce cycle rather than honoured.
Edited by line position, not string match: the two edgeHomepage values were
textually identical and belong to different display blocks. Formatting preserved
- a json.dumps round-trip reflowed 632 lines and was reverted.
PESETUP-INTERNALS.md was written from a decompiled 4.0.0.17. The media in
production reports 4.0.0.20 in its own log. Rather than restate the document as
4.0.0.20, which would claim a re-derivation that has not happened, it now names
both: line-level claims are 4.0.0.17, and the behaviour re-observed on bay
579C144 on 2026-08-06 is listed so a reader knows which parts are confirmed
current - media drive Z:, W: created by PrepareDisk and used for every copy
destination, the fallback Deploy\FlatUnattendW10.xml being the unattend that
loads, and driver selection by model.
FlatUnattendW10-shopfloor.xml carried LogonCount 7 while the live shopfloor
unattend has 12, and Run-ShopfloorSetup.ps1's comment about topping up the
autologon budget already said 12. The live value is the real one, so the repo
follows it.
Both files still lint clean under scripts/lint-unattend.py.
Brings the repo back in line with what is actually running in boot.wim, validated
end to end on bay 579C144 today.
WHAT THIS FILE NOW DOES DIFFERENTLY
Applied-volume detection. The volume finder and the diskpart letter reassignment
are gone. PESetup hardcodes W: in nine places and creates it during its own disk
preparation, so there is nothing to search for, and searching actively broke
staging: a scan run before PESetup finished disking returned the PREVIOUS
install, startnet relabelled that partition W:, and PESetup's own DISKPART then
erased it mid-copy. Timestamps from that failure are in the comment so nobody
rebuilds the finder from the same premise. The wait is back on
W:\Windows\System32\config\system, which only exists once the WIM apply has
written it.
Copy reporting. Eight sites echoed "Copied ..." unconditionally, so a run where
every robocopy exited 16 and moved zero bytes still printed six success lines.
They now branch on errorlevel 8 and say FAILED, including in the staging log.
%ERRORLEVEL% is no longer printed inside parenthesised blocks - it expands at
parse time there and showed a stale value. The WaxTrace test moved above its
mkdir, which sets its own errorlevel.
Per-PCTYPE media. Maps Z: to _media\<PCTYPE> and probes
Z:\Deploy\Control\HardwareDriver.json before trusting it, falling back to the
shared media with a warning.
findstr -> find. findstr.exe is not in this WinPE image. It failed with errorlevel
9009, which "if errorlevel 1" read as true, so an SFLD package took the non-SFLD
branch and never got its BPRT-tagged name.
VERIFIED ON A REAL BAY, from C:\Enrollment\winpe-staging.log:
PPKG=GCCH_Prod_SFLD_NoOffice_US_Exp_20260831_v4.16.ppkg
Copied PPKG GCCH_Prod_SFLD_v4.16.ppkg as GCCH_Prod_SFLD_NoOffice_US_Exp_...
Shopfloor 44 files / common 17 / _ntlars 147 / display 1 / preinstall 85
FAILED: 0 across every copy
Well-formed XML is not enough. Windows Setup validates against a schema and one
bad value invalidates the WHOLE answer file for its pass, so the machine stops at
a dialog with nothing configured and the only clue is an XPath buried in
C:\Windows\Panther\setupact.log.
On 2026-08-06 every shopfloor, standard and engineer build was failing exactly
that way:
/settings/RunSynchronous/RunSynchronousCommand/[Order="16"]/Path
Description = Value is invalid. hrResult = 0x80220005 pass = specialize
An inlined "powershell.exe -Command ..." had grown to 676 characters in a field
capped at 259, and had been broken since the previous evening. Every check here
is mechanical and would have caught it before a bay was booted:
path-too-long RunSynchronousCommand/Path > 259
cmdline-too-long SynchronousCommand/CommandLine > 1024
description-too-long Description > 256
duplicate-element a once-only element appearing twice
not-well-formed XML does not parse
bom UTF-8 BOM (the live files have none, and python's
utf-8-sig silently ADDS one when writing them back)
unknown-token %token% PESetup will not substitute; %WINDIR% and the
other shell variables are excluded or it cries wolf
Reads over SSH via base64 so a BOM or CRLF survives the hop unchanged. Exits
non-zero on any ERROR so it can gate a deploy.
Immediately found a second live defect the manual review had missed: a 329-char
Description on gea-engineer Order 11, which would have failed the oobeSystem pass
once specialize started passing. All three live image types and both repo copies
now lint clean.
An OptiPlex Micro 7020 reported "no update in catalog" at the WinPE menu. The
firmware was staged all along - OptiPlex_7020_1.22.1_SEMB.exe, 102 MB, sitting on
the share - but models.txt listed only "Tower Plus 7020". check-bios.cmd matches
with find /I, a substring test against the WMI model name, and "Tower Plus 7020"
is not a substring of "OptiPlex Micro 7020", so every Micro, SFF and Tower 7020
silently skipped its firmware update.
The 7010 family three lines above already shows the intended shape: one SEMB
package behind Micro, SFF Plus and Tower Plus entries. Dell packages 7020 the
same way, covering Micro, Micro Plus, SFF, SFF Plus, Tower and Tower Plus, so all
six now point at the one package and the stray Tower Plus line moves up to join
its family.
Verified by simulating the matcher over the manifest: all six variants resolve to
the 7020 package, no duplicate tokens, and 7010 still resolves to its own. Note
that "Small Form Factor 7020" spelled out does NOT match - the short form is
correct, since HardwareDriver.json's modelswminame field uses SFF 7020.
Deployed to the live share, where check-bios.cmd reads it directly off B:, so no
boot.wim rebuild is involved. Previous manifest kept at
/home/pxe/models-rollback-20260806.txt.
Maps Z: to _media\<PCTYPE> instead of the shared gea-shopfloor media, so
PESetup's unfiltered CopyPackages stages only this type's payload rather than
every shopfloor payload on every bay.
The mapping is validated rather than assumed: it probes
Z:\Deploy\Control\HardwareDriver.json, which resolves only when the view exists
AND its symlinks are intact, so a dangling view cannot image silently off a
half-mapped drive. On any failure it falls back to the shared media with a
console warning - the bay still images, it just stages the union, which is
exactly today's behaviour. That fallback also covers the second WJ PXE box,
which has no _media views yet.
Z: is freed before the mapping. A leftover mapping makes net use fail with
"device already in use", and the probe would then pass against the OLD media -
a silent wrong-payload build, which is the failure this change exists to
prevent.
Views are built by scripts/build-pctype-media.py.
CopyPackages copies the whole of <media>\Deploy\Applications to the target,
recursively, with no manifest and no filtering, so every bay built from the
shared gea-shopfloor media receives every shopfloor payload whatever its type.
The obvious fix - repoint a path inside the shared media as each bay picks its
type - races. Bays image concurrently and CopyPackages is fail-fast, so moving a
path while another bay is mid-copy either hands it the wrong payload or fails its
imaging outright, intermittently and unreproducibly.
Give each type its own media directory instead, built once and never mutated.
Everything in it is a symlink, so a type costs inodes rather than gigabytes and
the shared media stays untouched while bays read it. Samba already serves this
pattern: follow symlinks and wide links are on, and every image root reaches
Sources, Operating Systems, Out-of-box Drivers and Packages the same way.
Types come from the enrollment share's menu.json, the file the WinPE picker
already renders, so the media set cannot drift from the boot menu. Deliberately
NOT mirrored: the FlatUnattendW10.xml.pre-* backups and stray logs beside the
real unattend - the view is the clean set PESetup actually reads. Each type is
built into a scratch dir and swapped, so a bay mapping the path mid-refresh sees
the old tree or the new one, never a half-built one.
Applied on 172.16.9.1: 10 types, 190 links, all 60 critical paths resolve. An
SMB client sees ordinary directories and files - Sources, Control,
FlatUnattendW10.xml at 18078 bytes, HardwareDriver.json at 46113 - which is the
behaviour that actually matters, since PESetup reads this over SMB from WinPE.
Note for anyone extending this: passing the server-side script on stdin to
"echo pxe | sudo -S bash -s" is a silent no-op. Bash inherits the password pipe,
reads EOF, runs nothing and exits 0. It travels as a heredoc for that reason.
A driver miss is only a warning: GetDriverByModel returns null, PESetup logs
"driver for [MODEL] not found" and images the machine anyway. The bay comes up
with no NIC and no WiFi, DNS fails at first boot, and bulk enrollment cannot
reach the CDN - symptoms far enough from the cause that the OptiPlex Micro 7020
pack sat missing and the Display MicroPC failures were blamed on a drive letter.
Reimplements the matcher from the decompiled source (docs/PESETUP-INTERNALS.md)
and reports what silently breaks it:
virtual-platform one such entry flips the tool into virtual-only mode and
hard-fails every physical machine
empty-token a trailing comma yields "", and Contains("") is true for
every model, so that entry swallows the catalogue
token-whitespace Split(',') does not trim, so " OptiPlex 3010" needs the
space present in the model string too
case-mismatch the share is case-sensitive; Optiplex vs OptiPlex splits the
tree and the pack is never found
missing-zip referenced pack absent
family-mismatch the family filter runs first, so a token whose line
contradicts the family field can never match
shadowed first match wins, so a later entry may be unreachable
duplicate-token osId is not part of the match, so a win10 pack can land on a
win11 build purely by ordering
--models resolves real WMI model strings through the same code, which is the
check that actually predicts a no-driver build. Exits non-zero on ERROR or
CRITICAL so it can gate a deploy.
Verified both ways: a synthetic catalogue carrying each defect reports all eight
and exits 1; the three live catalogues on 172.16.9.1 come back clean at 0. The
file listing needs find -L and the same anchoring as destinationDir - without
either, every zip check silently passes.
Written from the decompiled assembly rather than from observation. Three
long-standing beliefs about this tool are wrong, and each has cost real
debugging time:
W: is not a guess. PESetup hardcodes it in nine places - every copy
destination, the DISM offline sessions, bcdboot, reagentc - and creates it
during its own disk preparation. startnet.cmd's volume finder, diskpart
reassignment and W: wait loop are machinery built around a problem that does
not exist.
The copy steps do not filter. CopyPackages copies the whole of
Deploy\Applications recursively to W:\Deploy\Applications - no manifest, no
unattend parsing, no extension or size rules. Anything dropped there reaches the
target, which is the basis for staging our own payload without the enrollment
share. It is also fail-fast: one unreadable file fails the whole step.
A missing driver match is a WARNING, not an error. GetDriverByModel does a
substring test of comma-separated tokens with first-match-wins, the family
filter knows only Latitude, OptiPlex and Precision, and a single 'virtual
platform' entry in the catalogue fails every physical machine. A miss lets
imaging finish with no drivers, so no NIC, so DNS failures at first boot -
symptoms far from the cause, and easily misattributed to the drive letter.
Includes the bundle-extraction recipe so the next person can re-derive all of
this instead of trusting this document.
Join-Path routes a drive-qualified path through the PowerShell provider, so
it returns null when that drive has gone away mid-cycle, and every consumer
then bound the null straight into Test-Path -LiteralPath and crashed the
entry with a message that names neither the entry nor the path.
Replace the six Join-Path $InstallerRoot sites with Join-InstallerPath, which
does plain string math, and have all six callers treat a null resolve as
"not found" and log it. Only the PS1 branch had a guard before, and it
covered a null Script value, not a null resolved path.
The helper deliberately avoids [IO.Path]::Combine: its separator and
rooted-path rules follow the host platform, so behaviour cannot be verified
off-Windows. It also rejects rooted values ("\x", "D:\x", "\\server\share"),
which Combine would have resolved outside the share root. Checked all 36
path values across the share manifests: none are rooted today.
Behaviour verified against the real manifest values (forward slashes,
backslashes, trailing-slash root, null, whitespace, rooted, UNC).
GE-Enforce mounts the SFLD share on W: and holds it for the whole enforce
cycle, passing W:\<pctype> as -InstallerRoot to Install-FromManifest. Two
scripts that run inside that cycle, as SYSTEM in the same drive namespace,
mapped W: for their own use and deleted it on exit:
Restore-UDCData.ps1 manifest PS1 entry, mounts the UDC backup share
Update-MachineNumber.ps1 "Apply Machine Number" task, same backup share
Once W: is gone, the next manifest entry's Join-Path resolves against a dead
drive qualifier. In Windows PowerShell 5.1 that emits nothing rather than
throwing, so the null lands in Test-Path -LiteralPath and the entry dies with
"Cannot bind argument to parameter 'LiteralPath' because it is null".
Observed on a collections bay: the controller-credential entry, which runs
immediately after UDC Data Restore, failed this way while the entry one line
earlier had resolved a W: path successfully one second before.
Restore-UDCData now uses R: and Update-MachineNumber uses N: for the UDC
backup share. Neither letter is referenced anywhere else in the tree; V: was
not an option because Restore-EDncReg and Update-MachineNumber's NTLARS
restore already use it.
The re-mount at GE-Enforce.ps1:239-244 does not cover this. It runs after the
manifest loop and blames idle SMB timeout, but the whole cycle took one
second, so timeout was never the cause.
The .NET3.5/VC++ prereqs block (11 RunSynchronousCommands with ~260-char doubled Paths + a long Description) made the answer file invalid for the specialize pass - 'Windows could not parse or process unattend answer file for pass [specialize]'. Rebuilt the template from the proven-working original with only the OpenText change (Order 19 -> Setup-OpenText.cmd, drop unattended.bat, short Description). Deployed to both live gea-standard + gea-engineer. The prereqs will be re-added correctly as a single short-Path wrapper cmd (install-prereqs.cmd) once imaging is confirmed.
Standard + Engineer imaged OpenText via the vendor Inno exe + unattended.bat, which left per-user profiles in SYSTEM's appdata (operators never saw keymaps/menus/macros) and only copied to Default+Shared. Switched their FlatUnattend to the shopfloor Setup-OpenText.cmd, which does the install via direct msiexec (OpenTextHostExplorer15x64.msi + ShopFloorx64.mst + SP1.msp) and fans the profile content (both office and shopfloor sets - Accessories/EB, Keymap, Menu, Profile) to ProgramData\Shared + Default user + every existing user. Kept both J2SE installs (Java). Dropped unattended.bat (its crude Default+Shared copy is superseded; NOTE its stale DNS SearchList reg add - old logon.ds.ge.com/rd.ds.ge.com domains - is no longer applied). Shopfloor opentext bundle staged into winpeapps/_shared/Applications/extra/opentext/.
The prior 'nest shopfloor menu as a tab' commit only captured the shopfloor_menu.html deletion - the git add also named the just-removed file, which errored the add and staged nothing else. This commits the actual integration that was already deployed to preview and prod: startnet_editor GET passes shopfloor_items/shopfloor_available, the Shopfloor Sub-Menu tab pane + sf-prefixed JS in startnet_editor.html, /shopfloor-menu redirects into the tab, and the sidebar 'startnet.cmd' -> 'Boot Menu' rename.
The shopfloor sub-menu editor is now a 'Shopfloor Sub-Menu' tab inside the startnet editor (it IS a sub-menu of the boot menu), not a separate page - sf-prefixed IDs avoid clashing with the top-level Boot Menu tab. /shopfloor-menu GET + save now redirect back into that tab; standalone shopfloor_menu.html removed. Sidebar 'startnet.cmd' renamed to 'Boot Menu'.
Replace the hardcoded GEA Shopfloor PC-type sub-menu with a data-driven one:
- menu.json on the enrollment share lists the shopfloor items {key=PCTYPE, label, hint, enabled}; key must match a shopfloor-setup/gea-shopfloor-* handler dir.
- select-shopfloor-type.ps1 renders it in WinPE and writes the chosen PCTYPE (mirrors the CMM bay picker); startnet.cmd runs it and falls back to the baked-in menu if the share/picker is unavailable.
- Webapp /shopfloor-menu editor: reorder/rename/hide/add items; the PC-type is a dropdown of existing handler dirs (can't wire a choice to a non-existent type); writes menu.json. Nav link under Tools.
Kills the duplicated-knowledge problem (menu list was hardcoded in startnet AND the handler dirs AND site-config); add a PC-type = drop in the handler dir + it appears in the menu.
A CRLF file re-CRLF'd (e.g. sed adding \r to already-CRLF lines) yields \r\r\n; the old _split_lines left a stray \r that split into a blank line between every line, so parse_boot_menu/settings/lint saw a garbled file and returned nothing. Now collapse any run of CR before a newline. Also re-deployed a clean-CRLF startnet.cmd into the live boot.wim (the earlier sed-based deploys had doubled the CR).
Rebuilt the /startnet editor (Fable/Opus 4-stage build) into tabs:
- Settings: server IP (+ menu timeout/default when a choice construct exists) as form fields; apply rewrites only the targeted tokens.
- Boot Menu: add/remove/reorder image entries; regenerates only the menu echo/dispatch + action blocks, refuses reorders that would desync the enrollment %choice% router.
- Raw: full-text editor (still source of truth) with line-number gutter, batch syntax highlighting, a lint panel (unmatched goto/label, CRLF), and diff-vs-current.
- History: timestamped snapshots on every save, per-row diff + restore.
New wim.py helpers (framework-free): parse/apply_settings, parse/apply_boot_menu, lint_startnet, save/list/read_snapshot, unified_diff; BACKUPS_DIR=/var/lib/pxe-webapp/startnet-backups.
Fable review fixed a CRITICAL pre-existing bug: update_startnet's newline=CRLF write retranslated posted CRLF into \r\r\n, corrupting boot.wim on every raw save; now normalizes to LF first. Also fixed a false CRLF lint warning (verbatim read) and menu payload validation. All JS inline (no CDN).
download-drivers.py rsyncs driver packs into _shared/Out-of-box Drivers over ssh as the pxe user, but the store was created root:root so new model folders failed with Permission denied. Added a task to own the store pxe:pxe 0775 (root and the webapp still write it fine). Fixed live on 172.16.9.1 too (chown -R).
startnet.cmd now sources SOURCE_PPKG/PPKG_VER/PPKG_EXP from \\<pxe>\enrollment\ppkg.conf (baked-in values kept as fallback if the file is missing). The webapp writes ppkg.conf on upload for standard GCCH_..._v<ver>.ppkg files: version parsed from the filename, optional expiry field (blank keeps current), so a new ppkg goes live at next boot with no startnet/boot.wim edit. Enrollment page shows the active ppkg. ppkg.conf seeded on the share (v4.16) and the ppkg.conf-aware startnet.cmd deployed into boot.wim.
parse_bios_catalog kept the 'latest' BIOS per model with a string compare, so e.g. '1.20.1' > '1.9.0' was False and it wrongly retained the older 1.9.0. Added _ver_tuple() and compare tuples of ints so the genuinely newest firmware wins.
Image import previously full-copied everything and rmtree'd existing target dirs on every run. Replaced the shutil copy/move/copytree/rmtree with deploy.sync_tree(), which shells out to rsync -a --checksum: files whose content already matches the target are skipped, only new or changed files are written, and existing target files not in the source are left untouched (merge, not mirror). move=True uses --remove-source-files (frees the SMB upload dir) and prunes emptied source dirs. Applies to the Deploy import, the _shared redirections, and the root-level items. Big re-imports now only rewrite what actually changed.
- Accessibility: dark ink (#00003d) on warning/success buttons, badges, and success alert - white text on #ff9500/#0ad64f failed WCAG AA (~2:1); dark-on-bright now passes.
- Heading hierarchy: sidebar brand h1 -> div.brand-title (with matching CSS selector), and standardized all page-header titles to a single <h1> per page (8 templates were h2).
- Prefix-safe modal JS: dashboard/backups/reports/enrollment delete+clone actions now build their form action from url_for(...'__K__'/'__F__').replace(...) instead of hardcoded paths, so they survive a URL-prefix mount.
Adopt the shopdb-flask visual language across the PXE webapp (presentation only, Flask/Jinja logic unchanged):
- New static/pxe-theme.css: GE Aerospace palette (atmosphere-blue sidebar, sky-blue primary, avionics-green), Inter font stack, light/dark theming via data-theme + localStorage (key pxe-theme) with system-pref fallback, and card/button/table/form/badge/alert component styles layered over Bootstrap.
- base.html: shopdb-style sidebar (logo + title, nav sections, footer light/dark toggle) + theme boot script.
- All 13 content templates restyled to the new page-header + card/table/badge vocabulary; unattend_editor grouped per unattend-UX research.
- Fixed a pre-existing CRITICAL bug found during review: nested <form>s in image_config.html made Adopt submit the delete form and Delete-selected post every orphan filename regardless of checkboxes; split into standalone forms wired via the form= attribute.
Built by a Fable-orchestrated Opus workflow (17 agents). All 14 templates parse clean under Jinja2.
The live PXE server carried working config the playbook would have clobbered on re-run. Committed back:
- dnsmasq: BIOS boot chain now the vendorclass PXEClient:Arch:00000 -> ipxe.pxe scheme (plus iPXE userclass HTTP chain), replacing the stale undionly.kpxe client-arch=0 scheme (undionly.kpxe was never staged in tftp-root). Bind by listen-address=172.16.9.1 + bind-dynamic instead of interface=+bind-interfaces.
- samba: add 'allow insecure wide links = yes' to the managed symlinks block (modern Samba silently disables wide links without it) and the [winpeapps_bios] read-only share.
- blancco iPXE menu: full BIOS/EFI arch split (direct-kernel vmlinuz-bde-linux / vmlinuz-ubuntu with ucode+kexec initrds) with a GRUB chain fallback, replacing the old single-line 'chain grubx64.efi'.
startnet.cmd was NOT a commit-back: repo is already ahead of the live boot.wim (deploy-pending).
Add RunSynchronous Orders 39-49: enable NetFx3 from staged sxs + install the VC++ 2005-2022 redistributables at specialize, before the first-logon engineering installer. Each command is if-exist guarded against the extra_engineering\prereqs payload, so gea-standard (which shares this template) skips them. Payloads staged on the share under gea-engineer Deploy\Applications\extra_engineering\prereqs.
Drop ge-standard/ge-engineer/ge-shopfloor-lockdown/ge-shopfloor-mce from image_types + standard_types, both startnet boot menus (choices + labels + net use), and the webapp IMAGE_TYPES/FRIENDLY_NAMES. Fleet is gea-* only; the empty ge-* winpeapps stub dirs were removed on the live share.