<# Desktop Agent - device onboarding (pairing). NON-DESTRUCTIVE BY ITSELF. Run at OOBE (Shift+F10) or on a Windows machine that has no agent yet: powershell irm https://osd.desktopagent.com | iex It downloads the runner and starts PAIRING: the machine shows a QR code. A technician scans it at m.desktopagent.com to bind this device to the right org and assign a task sequence. Pairing does NOT image, wipe, partition, or reboot this machine. If, after admission, the platform assigns this machine a reimage plan, the runner stages that plan locally and THIS script then offers - with an explicit typed confirmation - to prepare the reboot into WinPE that applies it: copy the machine's own WinRE, inject the runner and the shared signed boot-driver library, and arm a ONE-TIME boot. Nothing is wiped from the running OS; imaging happens only in WinPE, only from the per-device plan the technician admitted, and only after the confirmations here. A failed PE boot falls straight back to Windows. DEPLOYMENT (this file is the SOURCE OF TRUTH; the live copies are built from it - never edit those in place): - Served at https://osd.desktopagent.com/ by the Cloudflare Pages project `osd-bootstrap` (this file's body deployed as index.html) and as the R2 object `pe` on cdn.desktopagent.com (uncached). - The staging scripts it downloads are Device/runner/staging/*.ps1 uploaded to R2 under the versioned `staging/v13/` prefix. cdn.desktopagent.com CACHES R2 objects and the API token cannot purge, so on any staging-script change bump the prefix (v2, v3...) here AND re-upload. UNATTENDED MODE (#49, #35). The two prompts below (prepare the reboot, then reboot to image) exist so a human at the keyboard confirms a destructive step. -Unattended answers them automatically, but ONLY when an organization enrollment token was actually used to pair (below, or DA_ENROLLMENT_TOKEN in the environment) -- that token is itself the admission decision an org made ahead of time, so the confirmation it stands in for already happened. -Unattended with NO token present is a no-op: this script still prompts, exactly as the ordinary human QR flow does. That asymmetry is deliberate and must not be relaxed; the scripted E2E harness (Device/e2e) is the only intended caller of -Unattended. #> [CmdletBinding()] param( # Organization enrollment token for headless pairing (#49). Prefer # -EnrollTokenFile over this: a value here is visible to any process # listing on the machine for as long as this script runs. [string] $EnrollToken = '', # Path to a file whose contents are the enrollment token. Read once and # trimmed, matching the runner's own precedence rule. [string] $EnrollTokenFile = '', # Answer the prepare/reboot prompts with Y automatically. Only takes # effect when a token resolved above or from DA_ENROLLMENT_TOKEN -- see # the UNATTENDED MODE note above. [switch] $Unattended, # Release-pinned Autopilot support. Custom overrides are accepted only as # an explicit base/hash pair so the release hash is never reused for a # different origin. [string] $AutopilotSupportBase = 'https://cdn.desktopagent.com/autopilot-support/v1', [string] $AutopilotSupportManifestSha256 = '6de5936bfaf8868b5d7c0028b05ade70e12461a6a66cdf053e0d13e4d2f58def', [string] $BootDriverBase = 'https://cdn.desktopagent.com/boot-driver-library/v1', [string] $BootDriverManifestSha256 = '8f7c448ed357924b1eb9d0459226feeb68602b2d11229a552305d6a324558919', [string] $VendorDriverSupportBase = 'https://cdn.desktopagent.com/vendor-driver-support/v2', [string] $VendorDriverSupportManifestSha256 = '782e616ac72e52197574fa57689c4e491b5d26edee46734b6d2ce7de1f8c4869', # Release-pinned WinPE diagnostic support. Overrides must supply both the # origin and manifest pin so a hash cannot be reused for another origin. [string] $PowerShellSupportBase = 'https://cdn.desktopagent.com/powershell-support/v1', [string] $PowerShellSupportManifestSha256 = '8ad91c3dbfa41a24ac10efd9a9b130dceea362d7a5731a95706851c164a1c599' ) $ErrorActionPreference = 'Stop' [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 try { Set-ExecutionPolicy Bypass -Scope Process -Force } catch {} $base = 'https://cdn.desktopagent.com' # Versioned keys: cdn.desktopagent.com caches R2 objects and the token cannot # purge, so every runner rebuild ships under a new runner/vN/ prefix and this # file is republished pointing at it. v2 = SingleInternal device-plan target # (a reimage may wipe the machine's own one internal disk). v3 = enrollment # token support (#49): DA_ENROLLMENT_TOKEN / config, headless auto-admit. # v4 = click-proof console (QuickEdit off) + patient non-interactive PE # network bring-up for device plans (#84). v5 = first-boot applications # engine staged into the applied image (P5, #5/#35). v9 = custom application # progress during specialize, including resume after explicit restart steps. # v10 = animated activity while applications run, without implying installer percent. # v11 = the release with registration/removal sequencing and launcher-ready # Autopilot support. staging/v2 includes the guarded optional-plan fix. # v12 = published PowerShell 5.1 scripts in the first-boot application sequence. # v13 = ordinary managed-driver delivery. # v14 = Wi-Fi continuity, AC preflight and live deployment events. # v15 = verified inbox boot drivers and recoverable network selection. # v16 = native WLAN service diagnostics and authenticated preparation events. # v17 = direct vendor model drivers and complete portable extraction in recovery. # v18 = a separate F8 recovery command prompt with temporary network diagnostics. # v19 = full Dell EXE extraction and recovery held open after imaging failure. # v20 = connected-device health after first boot with bounded event delivery. # v21 = authored driver-health checks and unsupported-action refusal before imaging. # v22 = accept the catalog's single-file application package kind. # v23 = runtime questions, variables and validated Windows computer names. # v24 = direct Active Directory join with bound post-setup verification. # v25 = optional sequence-scoped local account creation during OOBE. # v26 = audited leading and restart-only first-boot restart preservation. # v27 = v26 restart preservation plus recognized virtual-machine vendor-driver skip. # v28 = v27 plus proceeding through OOBE whether a local account is requested or not. # v29 = v28 plus carrying the connected Wi-Fi profile from the running OS into # WinPE so imaging reconnects automatically without re-typing the key. # v30 = v29 plus exporting the stored WLAN profile (not only a live-connected # one) so the Wi-Fi carry-over works from OOBE, with a WinPE diagnostic. # v31 = v30 plus a run condition on the Join step: the device evaluates it (for # example "IsVirtual is true") and skips the directory join when unmet. # v32 = v31 plus the plan guard accepting the Join step's condition field # (v31 refused the plan as tampered before the resolver could evaluate it). # v33 = v32 plus skipping the Join step's OOBE local account when the join is # gated out (a local account with no join left the plan unbound and refused). # v34 = v33 plus connecting the carried-over Wi-Fi profile even when Windows 11 # blocks `netsh wlan show interfaces` with the location-permission gate # (error 5) in WinPE, and waiting persistently for its address. # v35 = v34 plus retrying the required-services check for up to 90 seconds once # an address is present, so a fresh Wi-Fi join whose DNS has not settled # (WinHttp 12007) is not treated as a failure and dropped to the picker. # v36 = v35 plus BitLocker pre-provisioning (Phase 1): when the sequence enables # the step, the runner checks the TPM before the wipe and pre-provisions the # Windows volume (used space only, temporary clear key) before Apply OS. No # recovery key is created, shown, or escrowed. # v37 = v36 plus offline cumulative-update injection: when the sequence enables an # update channel, the runner downloads the Windows/.NET .msu from Microsoft # and injects it (dism /Add-Package) after Apply OS and before drivers. # v38 = v37 plus a longer (120s) wireless-adapter wait with a periodic PnP re-scan # and a Net-class device diagnostic, so a slow or flaky Wi-Fi adapter in # WinPE is recovered and, if not, the reason is captured. # staging/v16 (runner binary unchanged at v38) = Inject-Runner restores THIS # machine's own exact disk/Ethernet/Wi-Fi driver injection (offline DISM, # trusted catalog) alongside the shared library. dbe0698 had dropped it, # which broke Wi-Fi whose adapter driver moved forward (Intel AX210). # staging/v17 (runner binary unchanged at v38) = the per-device network driver # injection's hand-rolled catalog pre-check is now ADVISORY, not a gate: # Add-WindowsDriver runs without -ForceUnsigned so DISM is the real signing # authority. v16 still skipped the AX210 because that pre-check threw on a # multi-catalog / chain-unvalidated WHQL INF before DISM ever ran, leaving # only the frozen library driver. v17 also writes a retrievable # \DA-Runner\exact-driver-diag.txt breadcrumb of each adapter's outcome. # Keep every runner at a versioned # local path so promoting the fresh-pair release never overwrites bytes bound # into an existing prepared-run receipt. # v39 = v38 plus an automatic force-load of this machine's own injected network # drivers at PE start: staging injects each device's current Wi-Fi and # Ethernet driver offline (trusted catalog), but PnP can leave the adapter # parked on an older library driver and not switch to it. When no interface # is present, the runner now drvloads every Net-class driver in the store # (selected by the universal Net class GUID, so it is device-agnostic) and # re-scans, which binds the adapter to its own driver. This is what the # AX210 needed done by hand; it is now automatic for every model. # v40 = v39 plus staging offline cumulative updates on the TARGET volume (W:) # instead of the WinPE ramdisk (X:). A Windows LCU is several GB and the # ramdisk is tiny, so the download filled it and failed with a disk-full # error (os error 112) after the image applied. W: is the freshly imaged # Windows volume with the whole disk free. The .msu files are removed after # a successful inject so they do not ship in the deployed OS. # v41 = v40 plus making an offline-update failure NON-fatal once the OS image has # already applied and verified: the failure is recorded and surfaced, then # imaging finalizes so the device still boots to OOBE (SUCCESS WITH A # WARNING) instead of going to recovery unbootable and forcing a USB # reinstall. An update hiccup no longer bricks an otherwise-good machine. # v42 = exact-length image downloads with bounded, validated Range resume; # required offline-update failures hold recovery and never continue to OOBE. # v43 = explicit restart from active recovery with fresh QR admission and preserved failure proof. # v44 = required target-MSU servicing fixes, published with staging/v17. # v45 = recovery-supervisor lifetime handling plus staging/v18 PowerShell # recovery diagnostics. # v46 = authored first-boot restart lifecycle, admitted runtime identity binding, # and rendered progress-UI readiness gating. # v47 = current-assignment validation before a staged deployment can mutate a disk. $currentRunnerVersion = 'v47' $currentRunnerBase = "$base/runner/v47" $currentRunnerHash = 'c9ee3b1edf0be45d9f3b1ecb5d9470129cd35f1fa98f46a96beee30407beb58f' $stagingV18Base = "$base/staging/v18" $stagingV17Base = "$base/staging/v17" $work = 'C:\DA-Pair' $configHash = 'ac2d479c10d59210f7e765d51494aa058d7c2bf39dad02d81c032b2ef36dbd84' $winReStageDir = 'C:\DA-Runner-Stage' $winReLogDir = 'C:\DA-Runner-Stage-Logs' $winReMountDir = 'C:\DA-Runner-Mount' $currentRunnerDir = Join-Path $work ('runner-' + $currentRunnerVersion) $currentRunnerPath = Join-Path $currentRunnerDir 'da-runner.exe' $currentConfigPath = Join-Path $currentRunnerDir 'runner.config.json' $knownRunnerReleases = @( [pscustomobject]@{ Version='v12'; Base="$base/runner/v12"; Sha256='1af2c40dcf09477966b1059b6a32daec553aeecb641844405d04c367b80bb41b' ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\da-run.exe','C:\DA-Pair\runner-v12\da-runner.exe') }, [pscustomobject]@{ Version='v13'; Base="$base/runner/v13"; Sha256='6f1c8f9d3a772286f3cf855efd6d1b6059cc4ed786a485c5803e6628bf3926e3'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v13\da-runner.exe','C:\DA-P17-Driver-Candidate\runner\da-runner.exe') }, [pscustomobject]@{ Version='v14'; Base="$base/runner/v14"; Sha256='ab72cec21848acf63fc29443dcfbb38303c6d39535ee353138cf144add228d0a'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v14\da-runner.exe') }, [pscustomobject]@{ Version='v15'; Base="$base/runner/v15"; Sha256='d9e9568a8d32dc5b606b1a18611e0880c01f9673cf70bf70dc7af07ad70fd364'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v15\da-runner.exe') }, [pscustomobject]@{ Version='v16'; Base="$base/runner/v16"; Sha256='f90d98021a9c8092ec0874178ac343b62fc68ac65613dfe10dac076abd2d2cc0'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v16\da-runner.exe') }, [pscustomobject]@{ Version='v17'; Base="$base/runner/v17"; Sha256='ba8d32bd6d1fed8e91e5c6a55edd7836894b8648a030b38160c3f15a3534e970'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v17\da-runner.exe') }, [pscustomobject]@{ Version='v18'; Base="$base/runner/v18"; Sha256='c96cf0d8a4ef863bf572d5f2138cf98966b5d3301f5e2ef521160588eb3f26c4'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v18\da-runner.exe') }, [pscustomobject]@{ Version='v19'; Base="$base/runner/v19"; Sha256='b4fa24c7b9ad7ee1703d784facc1185f5873e9b36e15598f31e7da3b915bc14c'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v19\da-runner.exe') } [pscustomobject]@{ Version='v20'; Base="$base/runner/v20"; Sha256='5101843465c3cb67eb0ce40d99b70de44723b599a3c6910026e908c8b89afa3f'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v20\da-runner.exe') } [pscustomobject]@{ Version='v21'; Base="$base/runner/v21"; Sha256='6cec976632efbe469d42d0d88a97d844a82bd9020c5cf1c4eaf99bcb34dcda7c'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v21\da-runner.exe') } [pscustomobject]@{ Version='v22'; Base="$base/runner/v22"; Sha256='2eb3078f4490f06995a86c77148875c96f1557743fc94d3b8446e65ff60df1a6'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v22\da-runner.exe') } [pscustomobject]@{ Version='v23'; Base="$base/runner/v23"; Sha256='a6bf4ae80d6928a2c3602d6aba4b473d86c767883cbe9ba00ccec5aafcfeb763'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v23\da-runner.exe') } [pscustomobject]@{ Version='v24'; Base="$base/runner/v24"; Sha256='adb70a708bc800a8a7a7f13d54fb09e46684f81879d1dc9e588a71efa729f099'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v24\da-runner.exe') } [pscustomobject]@{ Version='v25'; Base="$base/runner/v25"; Sha256='b55167291866673de71e86db9b5816036ee911f47fd86dcac92f775afd839e43'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v25\da-runner.exe') } [pscustomobject]@{ Version='v26'; Base="$base/runner/v26"; Sha256='942ffec879ce32e9e0cb4baedc319a381de40b7e38b73013291e9cb5b5a1830d'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v26\da-runner.exe') } [pscustomobject]@{ Version='v27'; Base="$base/runner/v27"; Sha256='b096ac93937c98e8aba8b143e863c9357c0805f8c3b42a104e12d9d913249978'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v27\da-runner.exe') } [pscustomobject]@{ Version='v28'; Base="$base/runner/v28"; Sha256='bdeb0042e5e0f4bd4d83c3e2d9eb14da25355edf1748a9044cad93a6fb94454e'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v28\da-runner.exe') } [pscustomobject]@{ Version='v29'; Base="$base/runner/v29"; Sha256='3ef616853848fe1ce224b37ded4ac584a4dcda55e9f9baa3ff681a3ee82fe638'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v29\da-runner.exe') } [pscustomobject]@{ Version='v30'; Base="$base/runner/v30"; Sha256='f19517e61ff80e4509ce34f347a4e882bdd1f6e5bbad35ba91fb526b22c696ca'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v30\da-runner.exe') } [pscustomobject]@{ Version='v31'; Base="$base/runner/v31"; Sha256='852a681559f48a8f86a3868f47545aae4ac5a974f66cc63d32ef15e2df499849'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v31\da-runner.exe') } [pscustomobject]@{ Version='v32'; Base="$base/runner/v32"; Sha256='f31332c59220d9f5e02034bf394a659d30be16b5c77123c3328ad63bf1041abc'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v32\da-runner.exe') } [pscustomobject]@{ Version='v33'; Base="$base/runner/v33"; Sha256='9702ebb06763d47c6c2a98753da9c7172ed584c9b5a7fbf341317b9aeca38d09'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v33\da-runner.exe') } [pscustomobject]@{ Version='v34'; Base="$base/runner/v34"; Sha256='773c5c253b99532a09c6a94c7af0a0249c75ba0c83c7d8cd872d54f57a4a0057'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v34\da-runner.exe') } [pscustomobject]@{ Version='v35'; Base="$base/runner/v35"; Sha256='d9f15ea3f0b3040c50697fe7f84cb93185232ca0a43c9b5d1cff336da9276c3d'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v35\da-runner.exe') } [pscustomobject]@{ Version='v36'; Base="$base/runner/v36"; Sha256='c5999ca4ac95cb053b48ae666e9e7121b30eb72ca5e64d630a742a4382ea1038'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v36\da-runner.exe') } [pscustomobject]@{ Version='v37'; Base="$base/runner/v37"; Sha256='0953f89b0365e18da7630e87102680734b06d5c24fa8937aa2d7940c078b330d'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v37\da-runner.exe') } [pscustomobject]@{ Version='v38'; Base="$base/runner/v38"; Sha256='40de3a5549ee8d3fe6e7c85883fcc28c0f318709abde96c64c8fc9fc43089753'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v38\da-runner.exe') } [pscustomobject]@{ Version='v39'; Base="$base/runner/v39"; Sha256='b015fa451a053d9851d96d52cae2c7004556e5c58de6fae1eabcf585ba64fb47'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v39\da-runner.exe') } [pscustomobject]@{ Version='v40'; Base="$base/runner/v40"; Sha256='7e84ff79d4867442b2ec5e68a4868c709fdbe06480f85de2e5e813bf9da6db26'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v40\da-runner.exe') } [pscustomobject]@{ Version='v41'; Base="$base/runner/v41"; Sha256='0893b9419196785236f5cb8c42a661a13d5e7f9722dd7f5c67c7652761871e37'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v41\da-runner.exe') } [pscustomobject]@{ Version='v42'; Base="$base/runner/v42"; Sha256='20fbce310deb3bd76b8ff52cee0ac7ccffa58924ca561f124f6c9184c1c2f0d0'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v42\da-runner.exe') } [pscustomobject]@{ Version='v43'; Base="$base/runner/v43"; Sha256='b7158fa1c090e4cfce3ea9ac5d702020f078dfb26abd2d9a3c1271c9d15cdceb'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v43\da-runner.exe') } [pscustomobject]@{ Version='v44'; Base="$base/runner/v44"; Sha256='c50cae2a0113c3cb8f6430e892ffd06aba830530c8be234d3ed3bad428a7a107'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v44\da-runner.exe') } [pscustomobject]@{ Version='v45'; Base="$base/runner/v45"; Sha256='5339bd11fae1630ce0ab32f26cd8d2c39823a93571c6592342ea35d1476cdfbc'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v45\da-runner.exe') } [pscustomobject]@{ Version='v46'; Base="$base/runner/v46"; Sha256='a150e3278b44d15dfb8ae64a2deacfc527e4bd720ff5e953b38ad7d698cf8e41'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v46\da-runner.exe') } [pscustomobject]@{ Version='v47'; Base="$base/runner/v47"; Sha256='c9ee3b1edf0be45d9f3b1ecb5d9470129cd35f1fa98f46a96beee30407beb58f'; ConfigSha256=$configHash AllowedRunnerPaths=@('C:\DA-Pair\runner-v47\da-runner.exe') } ) $stagingV17Hashes = [ordered]@{ 'BootNetworkDrivers.ps1' = '2221a6a660783ce7a5ff37f16205efb698a4da41e7f1bd42afaf502ff59a482e' 'BootDriverPackages.ps1' = '2cdb434e6f733aeb43206b92eab863417dc93da2a7fada53acb4d6ee9c97351d' 'BootDriverLibrary.ps1' = '6afe840417ba4ceef5702aa61c6657b9b093bc0639ac532df049e45d1a985031' 'AutopilotSupport.ps1' = '2b8c208184dc4e5d86d99e49621a302fb86a0e7937286e83cbd6faebabe02fd5' 'DriverPackages.ps1' = '1eaafa894569b6315a7025567118e023dfe0f1054b47642a0d8874c9f9a13c5e' 'Harvest-WinRE.ps1' = '8ae27903ea3da46fa9a3c08398b110b77ee6c2dfcb9ef8e113ecf012574e36d6' 'Inject-Runner.ps1' = 'a699a5b4622f1adfa9e83a4f68aa1e60b962eaf4f65fa7b50a68584992a5c2d6' 'Verify-Return.ps1' = '3c689bae17a69087e85391b31ad7c7886c53c3ceba1930422ffd7ebd9bf3b5bc' 'RunState.ps1' = 'bec6c07b57660793ef54772674b88008250e57e93c9b4a1ac6bbe28710cafac1' 'Stage-Runner.ps1' = 'fb83dafba67f67ba83ae0fb1082c3eb6ab1025e636e690124fd610b662919ea5' 'VendorDriverSupport.ps1' = 'e055d37c283d63823c42db1c746477e284c68bff63d4e3ce90ff03c0065733d3' 'Stage-And-Arm-Bcd.ps1' = '48c23388753d811836a1ce03059f2af8a068730d355fe25db025cca9332b6b32' 'Remove-Staging.ps1' = '1fb845b35331ff5721fc05bd0dd8596b696014cfbf1f51289cdaac6a29bcfd35' 'RunEvents.ps1' = '0e450f0caa1791e00dbe487b9de4cdd7c4be692a46dd27b937b992c696c6245d' } $stagingV18Hashes = [ordered]@{ 'AutopilotSupport.ps1' = '2b8c208184dc4e5d86d99e49621a302fb86a0e7937286e83cbd6faebabe02fd5' 'BootDriverLibrary.ps1' = '6afe840417ba4ceef5702aa61c6657b9b093bc0639ac532df049e45d1a985031' 'BootDriverPackages.ps1' = '2cdb434e6f733aeb43206b92eab863417dc93da2a7fada53acb4d6ee9c97351d' 'BootNetworkDrivers.ps1' = '2221a6a660783ce7a5ff37f16205efb698a4da41e7f1bd42afaf502ff59a482e' 'DriverPackages.ps1' = '1eaafa894569b6315a7025567118e023dfe0f1054b47642a0d8874c9f9a13c5e' 'Harvest-WinRE.ps1' = '8ae27903ea3da46fa9a3c08398b110b77ee6c2dfcb9ef8e113ecf012574e36d6' 'Inject-Runner.ps1' = '3efc63068707e41c61f22af29998c2b3bcce778e8eb5fb1e405be80071740eef' 'PowerShellSupport.ps1' = '0492201653592871d69e41af14c371d35524944be856bc9c8db6948f701b154a' 'Remove-Staging.ps1' = '1fb845b35331ff5721fc05bd0dd8596b696014cfbf1f51289cdaac6a29bcfd35' 'RunEvents.ps1' = '0e450f0caa1791e00dbe487b9de4cdd7c4be692a46dd27b937b992c696c6245d' 'RunState.ps1' = '4f84c4b39cf1966b2b541bbde8c66a26b55782e2a33cc727b3043405a7bcb810' 'Stage-And-Arm-Bcd.ps1' = '48c23388753d811836a1ce03059f2af8a068730d355fe25db025cca9332b6b32' 'Stage-Runner.ps1' = 'f9bd954b2c1690d63b0d1f4b864de556e9f4f40e55ac0dcbbf81551bc625538d' 'VendorDriverSupport.ps1' = 'e055d37c283d63823c42db1c746477e284c68bff63d4e3ce90ff03c0065733d3' 'Verify-Return.ps1' = '3c689bae17a69087e85391b31ad7c7886c53c3ceba1930422ffd7ebd9bf3b5bc' } $bootDriverBaseOverridden = $PSBoundParameters.ContainsKey('BootDriverBase') $bootDriverPinOverridden = $PSBoundParameters.ContainsKey('BootDriverManifestSha256') if ($bootDriverBaseOverridden -xor $bootDriverPinOverridden) { throw 'Override -BootDriverBase and -BootDriverManifestSha256 together.' } if ($BootDriverBase -notmatch '^https://[^?#]+$' -or $BootDriverManifestSha256 -notmatch '^(?i)[0-9a-f]{64}$') { throw 'Boot drivers require an HTTPS base and pinned manifest SHA-256.' } $vendorSupportBaseOverridden = $PSBoundParameters.ContainsKey('VendorDriverSupportBase') $vendorSupportPinOverridden = $PSBoundParameters.ContainsKey('VendorDriverSupportManifestSha256') if ($vendorSupportBaseOverridden -xor $vendorSupportPinOverridden) { throw 'Override -VendorDriverSupportBase and -VendorDriverSupportManifestSha256 together.' } if ($VendorDriverSupportBase -notmatch '^https://[^?#]+$' -or $VendorDriverSupportManifestSha256 -notmatch '^(?i)[0-9a-f]{64}$') { throw 'Vendor driver support requires an HTTPS base and pinned manifest SHA-256.' } $powerShellSupportBaseOverridden = $PSBoundParameters.ContainsKey('PowerShellSupportBase') $powerShellSupportPinOverridden = $PSBoundParameters.ContainsKey('PowerShellSupportManifestSha256') if ($powerShellSupportBaseOverridden -xor $powerShellSupportPinOverridden) { throw 'Override -PowerShellSupportBase and -PowerShellSupportManifestSha256 together.' } $powerShellSupportEnabled = $true if ($PowerShellSupportBase -notmatch '^https://[^?#]+$' -or $PowerShellSupportManifestSha256 -notmatch '^(?i)[0-9a-f]{64}$') { throw 'PowerShell recovery support requires an HTTPS base and pinned manifest SHA-256.' } $supportBaseOverridden = $PSBoundParameters.ContainsKey('AutopilotSupportBase') $supportPinOverridden = $PSBoundParameters.ContainsKey('AutopilotSupportManifestSha256') if ($supportBaseOverridden -xor $supportPinOverridden) { throw 'Override -AutopilotSupportBase and -AutopilotSupportManifestSha256 together.' } if ([string]::IsNullOrWhiteSpace($AutopilotSupportBase) -or $AutopilotSupportBase -notmatch '^https://[^?#]+$' -or $AutopilotSupportManifestSha256 -notmatch '^(?i)[0-9a-f]{64}$') { throw 'Autopilot support requires an HTTPS base and a pinned manifest SHA-256.' } function Assert-DaBootstrapLocalPath { param([Parameter(Mandatory)][string] $Path) if ([string]::IsNullOrWhiteSpace($Path) -or $Path -match '[\*\?\[]' -or $Path -match '^(?i)(\\\\|//)' -or -not [IO.Path]::IsPathRooted($Path) -or $Path -notmatch '^(?i)[a-z]:\\') { throw "Bootstrap paths must be absolute local Windows paths: $Path" } $full = [IO.Path]::GetFullPath($Path) $current = $full while ($true) { if (Test-Path -LiteralPath $current) { $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing a reparse-point bootstrap path: $current" } } $parent = [IO.Directory]::GetParent($current) if (-not $parent -or $parent -eq $current) { break } $current = $parent } $root = [IO.Path]::GetPathRoot($full) if ($root -and $root.TrimEnd('\','/') -eq $full.TrimEnd('\','/')) { throw "Refusing to protect a filesystem root: $full" } return $full } function Protect-DaBootstrapAcl { param( [Parameter(Mandatory)][string] $Path, [switch] $Directory, [switch] $Create ) $full = Assert-DaBootstrapLocalPath $Path if (-not (Test-Path -LiteralPath $full)) { if (-not $Create -or -not $Directory) { throw "Protected bootstrap path is missing: $full" } New-Item -ItemType Directory -Force -Path $full | Out-Null $full = Assert-DaBootstrapLocalPath $full } $item = Get-Item -LiteralPath $full -Force -ErrorAction Stop if ($Directory -and -not $item.PSIsContainer) { throw "Expected a bootstrap directory: $full" } if (-not $Directory -and $item.PSIsContainer) { throw "Expected a bootstrap file: $full" } $acl = Get-Acl -LiteralPath $full -ErrorAction Stop $acl.SetAccessRuleProtection($true, $false) foreach ($existing in @($acl.Access)) { [void]$acl.RemoveAccessRuleSpecific($existing) } $inheritance = [Security.AccessControl.InheritanceFlags]::None if ($Directory) { $inheritance = [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit } $adminsSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') $acl.SetOwner($adminsSid) foreach ($sidText in @('S-1-5-18','S-1-5-32-544')) { $sid = [System.Security.Principal.SecurityIdentifier]::new($sidText) $rule = [System.Security.AccessControl.FileSystemAccessRule]::new($sid, [Security.AccessControl.FileSystemRights]::FullControl, $inheritance, [Security.AccessControl.PropagationFlags]::None, [Security.AccessControl.AccessControlType]::Allow) [void]$acl.AddAccessRule($rule) } Set-Acl -LiteralPath $full -AclObject $acl -ErrorAction Stop $verified = Get-Acl -LiteralPath $full -ErrorAction Stop if (-not $verified.AreAccessRulesProtected) { throw "Bootstrap ACL inheritance remained enabled: $full" } if ([string]($verified.GetOwner([System.Security.Principal.SecurityIdentifier]).Value) -ne 'S-1-5-32-544') { throw "Administrators is not the bootstrap path owner: $full" } $rules = @($verified.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) if ($rules.Count -ne 2) { throw "Bootstrap path did not have exactly two explicit ACL rules: $full" } $allowed = @('S-1-5-18','S-1-5-32-544') foreach ($rule in $rules) { $sid = [string]$rule.IdentityReference.Value if ($rule.IsInherited -or $rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or $allowed -notcontains $sid -or (($rule.FileSystemRights -band [Security.AccessControl.FileSystemRights]::FullControl) -ne [Security.AccessControl.FileSystemRights]::FullControl)) { throw "Unexpected bootstrap ACL remained: $full" } $allowed = @($allowed | Where-Object { $_ -ne $sid }) } if ($allowed.Count -ne 0) { throw "Bootstrap path is missing a SYSTEM or Administrators ACL: $full" } return $full } function Get-DaBootstrapArtifact { param([string]$Uri, [string]$Destination, [string]$Sha256) if ($Uri -notmatch '^https://' -or $Sha256 -notmatch '^[a-f0-9]{64}$') { throw 'The launcher release is not fully pinned.' } $Destination = Assert-DaBootstrapLocalPath $Destination Protect-DaBootstrapAcl -Path (Split-Path -Parent $Destination) -Directory -Create | Out-Null if (Test-Path -LiteralPath $Destination -PathType Leaf) { Protect-DaBootstrapAcl -Path $Destination | Out-Null if ((Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash.ToLowerInvariant() -ceq $Sha256) { return } } $temporary = Assert-DaBootstrapLocalPath ($Destination + '.download') if (Test-Path -LiteralPath $temporary) { Protect-DaBootstrapAcl -Path $temporary | Out-Null } Write-Host "Downloading $([IO.Path]::GetFileName($Destination))." Invoke-WebRequest -Uri $Uri -OutFile $temporary -UseBasicParsing -ErrorAction Stop Protect-DaBootstrapAcl -Path $temporary | Out-Null if ((Get-FileHash -LiteralPath $temporary -Algorithm SHA256).Hash.ToLowerInvariant() -cne $Sha256) { throw 'A downloaded file did not match this release. Run the launcher again to retry.' } Move-Item -LiteralPath $temporary -Destination $Destination -Force -ErrorAction Stop Protect-DaBootstrapAcl -Path $Destination | Out-Null Unblock-File -LiteralPath $Destination -ErrorAction SilentlyContinue } function Get-DaBootstrapStagingDirectory { param([Parameter(Mandatory)][string]$Base,[Parameter(Mandatory)]$Hashes,[Parameter(Mandatory)][string]$CacheName) $directory = Join-Path $work $CacheName Protect-DaBootstrapAcl -Path $directory -Directory -Create | Out-Null foreach ($file in $Hashes.Keys) { Get-DaBootstrapArtifact -Uri "$Base/$file" -Destination (Join-Path $directory $file) -Sha256 $Hashes[$file] } return $directory } function Get-DaKnownRunnerRelease { param( [Parameter(Mandatory)][string] $Sha256, [Parameter(Mandatory)][object[]] $Releases, [string] $RunnerPath = '' ) $normalized = $Sha256.Trim().ToLowerInvariant() if ($normalized -notmatch '^[0-9a-f]{64}$') { throw 'A prepared run contains an invalid runner SHA-256.' } $matches = @($Releases | Where-Object { ([string]$_.Sha256).ToLowerInvariant() -ceq $normalized }) if (-not [string]::IsNullOrWhiteSpace($RunnerPath)) { $matches = @($matches | Where-Object { @($_.AllowedRunnerPaths | Where-Object { Test-DaSameBootstrapPath $_ $RunnerPath }).Count -eq 1 }) } if ($matches.Count -ne 1) { throw 'The prepared run uses a runner release that this launcher does not recognize.' } return $matches[0] } function Restore-DaBoundBootstrapArtifact { param( [Parameter(Mandatory)][string] $Uri, [Parameter(Mandatory)][string] $Destination, [Parameter(Mandatory)][string] $Sha256 ) $full = Assert-DaBootstrapLocalPath $Destination if (Test-Path -LiteralPath $full) { if (-not (Test-Path -LiteralPath $full -PathType Leaf)) { throw "A receipt-bound input is not a file: $full" } Protect-DaBootstrapAcl -Path $full | Out-Null $actual = (Get-FileHash -LiteralPath $full -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() if ($actual -cne $Sha256.ToLowerInvariant()) { throw "A receipt-bound input changed and was preserved: $full" } return $full } Get-DaBootstrapArtifact -Uri $Uri -Destination $full -Sha256 $Sha256.ToLowerInvariant() return $full } function Test-DaSameBootstrapPath { param([Parameter(Mandatory)][string] $Left, [Parameter(Mandatory)][string] $Right) $leftFull = (Assert-DaBootstrapLocalPath $Left).TrimEnd('\') $rightFull = (Assert-DaBootstrapLocalPath $Right).TrimEnd('\') return [string]::Equals($leftFull, $rightFull, [StringComparison]::OrdinalIgnoreCase) } function Assert-DaReceiptRecoveryMetadata { param( [Parameter(Mandatory)] $State, [Parameter(Mandatory)] $Candidate, [Parameter(Mandatory)][object[]] $KnownRunnerReleases, [Parameter(Mandatory)][string] $ExpectedConfigSha256 ) if (-not $State.binding -or -not $State.binding.paths -or -not $State.binding.inputs) { throw 'Prepared-run receipt is missing path or input bindings.' } foreach ($name in @('stageDir','logDir','mountDir')) { $value = [string]$State.binding.paths.$name if ([string]::IsNullOrWhiteSpace($value) -or -not (Test-DaSameBootstrapPath $value ([string]$Candidate.$name))) { throw "Prepared-run receipt has an unexpected $name binding." } } $runnerPath = [string]$State.binding.inputs.runnerPath $runnerSha = ([string]$State.binding.inputs.runnerSha256).ToLowerInvariant() $configPath = [string]$State.binding.inputs.configPath $configSha = ([string]$State.binding.inputs.configSha256).ToLowerInvariant() $planPath = [string]$State.binding.inputs.planPath $planSha = ([string]$State.binding.inputs.planSha256).ToLowerInvariant() foreach ($entry in @( @{ Name='runner'; Path=$runnerPath; Hash=$runnerSha }, @{ Name='configuration'; Path=$configPath; Hash=$configSha }, @{ Name='plan'; Path=$planPath; Hash=$planSha })) { if ([string]::IsNullOrWhiteSpace($entry.Path) -or $entry.Hash -notmatch '^[0-9a-f]{64}$') { throw "Prepared-run receipt has an incomplete $($entry.Name) binding." } Assert-DaBootstrapLocalPath $entry.Path | Out-Null } $release = Get-DaKnownRunnerRelease -Sha256 $runnerSha -Releases $KnownRunnerReleases -RunnerPath $runnerPath $allowedRunner = @($release.AllowedRunnerPaths | Where-Object { Test-DaSameBootstrapPath $_ $runnerPath }) if ($allowedRunner.Count -ne 1) { throw 'The receipt-bound runner path is not approved for its recorded release.' } $expectedConfigPath = Join-Path (Split-Path -Parent $runnerPath) 'runner.config.json' if (-not (Test-DaSameBootstrapPath $configPath $expectedConfigPath) -or $configSha -cne $ExpectedConfigSha256.ToLowerInvariant()) { throw 'The receipt-bound runner configuration is not the approved pinned file beside the runner.' } if (-not (Test-DaSameBootstrapPath $planPath ([string]$Candidate.PlanPath))) { throw 'The receipt-bound source plan path is unexpected.' } if (-not (Test-Path -LiteralPath $planPath -PathType Leaf)) { throw 'The receipt-bound source plan is missing.' } Protect-DaBootstrapAcl -Path $planPath | Out-Null if ((Get-FileHash -LiteralPath $planPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $planSha) { throw 'The receipt-bound source plan changed and was preserved.' } return [pscustomobject][ordered]@{ HasReceipt=$true; AdoptPrepared=$false; State=$State; Release=$release StageDir=[string]$Candidate.StageDir; LogDir=[string]$Candidate.LogDir; MountDir=[string]$Candidate.MountDir RunnerPath=$runnerPath; ConfigPath=$configPath; PlanPath=$planPath } } function Get-DaLegacyPreparedImageHashes { param( [Parameter(Mandatory)][string] $PayloadPath, [Parameter(Mandatory)][string] $MountDir ) if (-not (Test-Path -LiteralPath $PayloadPath -PathType Leaf)) { throw 'Legacy prepared payload is missing.' } Test-DaReparseFreePath $PayloadPath | Out-Null $mountFull = Assert-DaLocalStagingPath $MountDir if (@(Get-DaMountedAtExactPath $mountFull).Count -ne 0) { throw 'The legacy verification mount path is already in use.' } if (Test-Path -LiteralPath $mountFull) { $mountItem = Get-Item -LiteralPath $mountFull -Force -ErrorAction Stop if (($mountItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or @(Get-ChildItem -LiteralPath $mountFull -Force -ErrorAction Stop).Count -ne 0) { throw 'The legacy verification mount path is not an empty owned directory.' } } Protect-DaDirectory $mountFull | Out-Null $mounted = $false try { Mount-WindowsImage -ImagePath $PayloadPath -Index 1 -Path $mountFull -ReadOnly -ErrorAction Stop | Out-Null $mounted = $true $records = @(Get-DaMountedAtExactPath $mountFull) if ($records.Count -ne 1 -or (Get-DaFullPath ([string]$records[0].ImagePath)).TrimEnd('\','/') -cne (Get-DaFullPath $PayloadPath).TrimEnd('\','/')) { throw 'The read-only legacy mount could not be bound to the selected payload.' } $runner = Join-Path $mountFull 'DA-Runner\da-runner.exe' $config = Join-Path $mountFull 'DA-Runner\runner.config.json' $plan = Join-Path $mountFull 'DA-Runner\runner-plan.json' foreach ($path in @($runner,$config,$plan)) { if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw 'The legacy prepared image is missing a pinned runner input.' } } $winpeshl = Join-Path $mountFull 'Windows\System32\winpeshl.ini' if (-not (Test-Path -LiteralPath $winpeshl -PathType Leaf)) { throw 'The legacy prepared image has no winpeshl.ini.' } # A DISM mount can itself report as a reparse attribute. Reject links # inside the image while stopping at the exact mount root whose image # ownership was verified above. foreach ($embeddedPath in @($runner,$config,$plan,$winpeshl)) { $cursor = Get-DaFullPath $embeddedPath while (-not [string]::Equals($cursor.TrimEnd('\'),$mountFull.TrimEnd('\'),[StringComparison]::OrdinalIgnoreCase)) { $item = Get-Item -LiteralPath $cursor -Force -ErrorAction Stop if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { $isPinnedLeaf = [string]::Equals((Get-DaFullPath $item.FullName),$embeddedPath,[StringComparison]::OrdinalIgnoreCase) $isDismMaterializedFile = $isPinnedLeaf -and -not $item.PSIsContainer -and (($item.Attributes -band [IO.FileAttributes]::SparseFile) -ne 0) -and [string]::IsNullOrEmpty([string]$item.LinkType) if (-not $isDismMaterializedFile) { throw 'The legacy prepared image contains a link in a pinned launch path.' } } $parent = [IO.Directory]::GetParent($cursor) if (-not $parent) { throw 'A legacy prepared image path escaped its verified mount.' } $cursor = $parent.FullName } } $launchLines = @(Get-Content -LiteralPath $winpeshl -ErrorAction Stop | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) if ($launchLines.Count -ne 2 -or $launchLines[0] -cne '[LaunchApps]' -or $launchLines[1] -cne '%SYSTEMDRIVE%\DA-Runner\da-runner.exe, --image') { throw 'The legacy prepared image does not launch exactly its embedded runner in imaging mode.' } return [pscustomobject][ordered]@{ runnerSha256=Get-DaRunSha256 $runner configSha256=Get-DaRunSha256 $config planSha256=Get-DaRunSha256 $plan } } finally { if ($mounted) { Clear-DaOwnedMount -MountDir $mountFull -ExpectedImage $PayloadPath } elseif ((Test-Path -LiteralPath $mountFull -PathType Container) -and @(Get-ChildItem -LiteralPath $mountFull -Force -ErrorAction Stop).Count -eq 0) { Remove-Item -LiteralPath $mountFull -Force -ErrorAction Stop } } } function Resolve-DaLegacyRecoveryContext { param( [Parameter(Mandatory)] $Candidate, [Parameter(Mandatory)][object[]] $KnownRunnerReleases, [Parameter(Mandatory)][string] $ExpectedConfigSha256, [Parameter(Mandatory)][string] $WorkDir, [scriptblock] $ImageProbe ) foreach ($name in @('da-payload.wim','boot.sdi','bcd-ids.json')) { $path = Join-Path ([string]$Candidate.StageDir) $name if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Legacy preparation is incomplete: $name is missing." } Test-DaReparseFreePath $path | Out-Null } $planPath = [string]$Candidate.PlanPath if (-not (Test-Path -LiteralPath $planPath -PathType Leaf)) { throw 'Legacy preparation has no source plan.' } Test-DaReparseFreePath $planPath | Out-Null if (-not $ImageProbe) { $ImageProbe = ${function:Get-DaLegacyPreparedImageHashes} } $embedded = & $ImageProbe (Join-Path ([string]$Candidate.StageDir) 'da-payload.wim') ([string]$Candidate.MountDir) if (-not $embedded -or [string]$embedded.runnerSha256 -notmatch '^(?i)[0-9a-f]{64}$' -or [string]$embedded.configSha256 -notmatch '^(?i)[0-9a-f]{64}$' -or [string]$embedded.planSha256 -notmatch '^(?i)[0-9a-f]{64}$') { throw 'The legacy prepared image probe returned incomplete hashes.' } $release = Get-DaKnownRunnerRelease -Sha256 ([string]$embedded.runnerSha256) -Releases $KnownRunnerReleases if ([string]$embedded.configSha256 -cne $ExpectedConfigSha256.ToLowerInvariant() -or [string]$embedded.planSha256 -cne (Get-DaRunSha256 $planPath)) { throw 'The legacy prepared image does not match its pinned configuration and source plan.' } $runnerDir = Assert-DaBootstrapLocalPath (Join-Path $WorkDir ('runner-' + [string]$release.Version)) return [pscustomobject][ordered]@{ HasReceipt=$false; AdoptPrepared=$true; State=$null; Release=$release StageDir=[string]$Candidate.StageDir; LogDir=[string]$Candidate.LogDir; MountDir=[string]$Candidate.MountDir RunnerPath=(Join-Path $runnerDir 'da-runner.exe'); ConfigPath=(Join-Path $runnerDir 'runner.config.json'); PlanPath=$planPath } } function Find-DaBootstrapRecoveryContext { param( [Parameter(Mandatory)][object[]] $Candidates, [Parameter(Mandatory)][object[]] $KnownRunnerReleases, [Parameter(Mandatory)][string] $ExpectedConfigSha256, [Parameter(Mandatory)][string] $WorkDir, [scriptblock] $ImageProbe ) $active = @() foreach ($candidate in $Candidates) { $stage = Assert-DaBootstrapLocalPath ([string]$candidate.StageDir) $markers = @('prepared-run.json','da-payload.wim','boot.sdi','bcd-ids.json') | Where-Object { Test-Path -LiteralPath (Join-Path $stage $_) } if (@($markers).Count -gt 0) { $active += $candidate } } if ($active.Count -gt 1) { throw 'More than one prepared-run location is active. Nothing was changed.' } if ($active.Count -eq 0) { return $null } $selected = $active[0] $receiptPath = Get-DaRunStatePath -StageDir ([string]$selected.StageDir) if (Test-Path -LiteralPath $receiptPath -PathType Leaf) { $state = Read-DaRunState -StageDir ([string]$selected.StageDir) return Assert-DaReceiptRecoveryMetadata -State $state -Candidate $selected -KnownRunnerReleases $KnownRunnerReleases -ExpectedConfigSha256 $ExpectedConfigSha256 } return Resolve-DaLegacyRecoveryContext -Candidate $selected -KnownRunnerReleases $KnownRunnerReleases -ExpectedConfigSha256 $ExpectedConfigSha256 -WorkDir $WorkDir -ImageProbe $ImageProbe } function Invoke-DaBootstrapRecoveryChoice { param( [Parameter(Mandatory)] $Context, [Parameter(Mandatory)][ValidateSet('R','S','C')][string] $Choice, [Parameter(Mandatory)][string] $StageRunnerPath, [scriptblock] $StageInvoker, [scriptblock] $AutopilotSupportResolver, [scriptblock] $VendorDriverSupportResolver, [scriptblock] $PowerShellSupportResolver, [scriptblock] $BootDriverLibraryResolver, [scriptblock] $RebootConfirmer ) if (-not $StageInvoker) { $StageInvoker = { param($scriptPath, $arguments) & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $scriptPath @arguments 2>&1 | Out-Host $childExitCode = $LASTEXITCODE return [int]$childExitCode } } $arguments = @('-RunnerExe',[string]$Context.RunnerPath,'-ConfigFile',[string]$Context.ConfigPath, '-PlanFile',[string]$Context.PlanPath,'-StageDir',[string]$Context.StageDir, '-LogDir',[string]$Context.LogDir,'-MountDir',[string]$Context.MountDir) if ($Context.AdoptPrepared) { $arguments += '-AdoptPrepared' } if ($Choice -in @('S','C')) { $arguments += '-CancelPrepared' } if ($Choice -ceq 'R' -and $Context.HasReceipt -and [string]$Context.State.phase -ceq 'Preparing' -and $AutopilotSupportResolver) { $supportDir = & $AutopilotSupportResolver ([string]$Context.PlanPath) if (-not [string]::IsNullOrWhiteSpace([string]$supportDir)) { $arguments += @('-AutopilotSupportDir',[string]$supportDir) } } # Staging verifies the vendor manifest as part of the prepared-run binding # before resume, adoption, or cancellation. S and C need the same support # inputs as R so that they can safely cancel the previous preparation. if ($VendorDriverSupportResolver) { $vendorSupportDir = & $VendorDriverSupportResolver ([string]$Context.PlanPath) ([string]$Context.Release.Version) if (-not [string]::IsNullOrWhiteSpace([string]$vendorSupportDir)) { $arguments += @('-VendorDriverSupportDir',[string]$vendorSupportDir) } } $powerShellManifest = $null $stateProperty = $Context.PSObject.Properties['State'] if ($Context.HasReceipt -and $null -ne $stateProperty -and $null -ne $stateProperty.Value) { $bindingProperty = $stateProperty.Value.PSObject.Properties['binding'] if ($null -ne $bindingProperty -and $null -ne $bindingProperty.Value) { $inputsProperty = $bindingProperty.Value.PSObject.Properties['inputs'] if ($null -ne $inputsProperty -and $null -ne $inputsProperty.Value) { $powerShellManifest = $inputsProperty.Value.PSObject.Properties['powerShellSupportManifestSha256'] } } } if ($null -ne $powerShellManifest -and -not [string]::IsNullOrWhiteSpace([string]$powerShellManifest.Value)) { if (-not $PowerShellSupportResolver) { throw 'Previous preparation requires its pinned PowerShell recovery support bundle.' } $powerShellSupportDir = & $PowerShellSupportResolver if ([string]::IsNullOrWhiteSpace([string]$powerShellSupportDir)) { throw 'Previous preparation requires its pinned PowerShell recovery support bundle.' } $arguments += @('-PowerShellSupportDir',[string]$powerShellSupportDir) } # Completed payloads are already receipt-bound. Cancellation needs no new # driver download; only an incomplete preparation may rebuild the image. if ($Choice -ceq 'R' -and $Context.HasReceipt -and [string]$Context.State.phase -ceq 'Preparing' -and $BootDriverLibraryResolver) { $libraryDir = & $BootDriverLibraryResolver if ([string]::IsNullOrWhiteSpace([string]$libraryDir)) { throw 'Recovery preparation requires the shared boot-driver library.' } $arguments += @('-BootDriverDirectory',[string]$libraryDir) } $invocationResult = @(& $StageInvoker $StageRunnerPath $arguments) if ($invocationResult.Count -eq 0) { throw 'Previous preparation returned no exit status. Its files were preserved.' } if ($invocationResult.Count -gt 1) { $invocationResult[0..($invocationResult.Count - 2)] | Out-Host } [int]$exitCode = 0 if (-not [int]::TryParse([string]$invocationResult[-1], [ref]$exitCode) -or $exitCode -ne 0) { throw 'Previous preparation could not be verified. Its files were preserved. Review the reported recovery error.' } if ($Choice -ceq 'R') { if ($RebootConfirmer) { & $RebootConfirmer | Out-Null } return 'Resumed' } if ($Choice -ceq 'C') { return 'Cancelled' } return 'StartFresh' } function Get-DaBootstrapAutopilotSupport { param([Parameter(Mandatory)][string]$PlanPath) $autopilotSupportDir = '' $planForStaging = Get-Content -LiteralPath $PlanPath -Raw | ConvertFrom-Json $autopilotProperty = $planForStaging.PSObject.Properties['autopilot'] $autopilotRegistrationRequested = $null -ne $autopilotProperty -and $null -ne $autopilotProperty.Value if ($autopilotRegistrationRequested) { if ([string]::IsNullOrWhiteSpace($AutopilotSupportBase) -or $AutopilotSupportBase -notmatch '^https://[^?#]+$') { throw 'This deployment requests Autopilot registration but no approved HTTPS Autopilot support bundle base was supplied.' } if ($AutopilotSupportManifestSha256 -notmatch '^(?i)[0-9a-f]{64}$') { throw 'This deployment requests Autopilot registration but no pinned support manifest SHA-256 was supplied.' } $autopilotSupportDir = Join-Path $work 'autopilot-support' Protect-DaBootstrapAcl -Path $autopilotSupportDir -Directory -Create | Out-Null $supportBase = $AutopilotSupportBase.TrimEnd('/') $manifestDownload = Join-Path $autopilotSupportDir 'manifest.json' $manifestHashDownload = Join-Path $autopilotSupportDir 'manifest.sha256' if (Test-Path -LiteralPath $manifestDownload -PathType Leaf) { Protect-DaBootstrapAcl -Path $manifestDownload | Out-Null } if (Test-Path -LiteralPath $manifestHashDownload -PathType Leaf) { Protect-DaBootstrapAcl -Path $manifestHashDownload | Out-Null } Invoke-WebRequest "$supportBase/manifest.json" -OutFile $manifestDownload -UseBasicParsing Invoke-WebRequest "$supportBase/manifest.sha256" -OutFile $manifestHashDownload -UseBasicParsing $actualManifestHash = (Get-FileHash -LiteralPath (Join-Path $autopilotSupportDir 'manifest.json') -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualManifestHash -ne $AutopilotSupportManifestSha256.ToLowerInvariant()) { throw 'The downloaded Autopilot support manifest did not match the operator-supplied SHA-256.' } $sidecarHash = ((Get-Content -LiteralPath (Join-Path $autopilotSupportDir 'manifest.sha256') -Raw).Trim() -split '\s+')[0].ToLowerInvariant() if ($sidecarHash -ne $actualManifestHash) { throw 'The downloaded Autopilot manifest sidecar hash does not match the manifest.' } $supportManifest = Get-Content -LiteralPath (Join-Path $autopilotSupportDir 'manifest.json') -Raw | ConvertFrom-Json foreach ($supportFile in @($supportManifest.files)) { $name = [string]$supportFile.name if ($name -notmatch '^(?i)(OA3Tool\.exe|PCPKsp\.dll)$' -or $name -ne [IO.Path]::GetFileName($name)) { throw "The Autopilot support manifest contains an unsafe or unsupported file name: $name" } $supportPath = Join-Path $autopilotSupportDir $name if (Test-Path -LiteralPath $supportPath -PathType Leaf) { Protect-DaBootstrapAcl -Path $supportPath | Out-Null } Invoke-WebRequest "$supportBase/$name" -OutFile $supportPath -UseBasicParsing } foreach ($supportPath in @(Get-ChildItem -LiteralPath $autopilotSupportDir -File -Force)) { Protect-DaBootstrapAcl -Path $supportPath.FullName | Out-Null } } return $autopilotSupportDir } function Expand-DaBootstrapBootDriverArchive { param( [Parameter(Mandatory)][string] $ArchivePath, [Parameter(Mandatory)][string] $PackageRoot, [Parameter(Mandatory)] $Package ) $PackageRoot = Assert-DaBootstrapLocalPath $PackageRoot if (Test-Path -LiteralPath $PackageRoot) { throw "Refusing to replace an existing boot-driver package cache: $PackageRoot" } $parent = Split-Path -Parent $PackageRoot Protect-DaBootstrapAcl -Path $parent -Directory -Create | Out-Null $extracting = Assert-DaBootstrapLocalPath ($PackageRoot + '.extracting-' + [Guid]::NewGuid().ToString('N')) Protect-DaBootstrapAcl -Path $extracting -Directory -Create | Out-Null try { Add-Type -AssemblyName System.IO.Compression -ErrorAction SilentlyContinue Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue $expected = @{} foreach ($file in @($Package.files)) { $expected[([string]$file.path).ToLowerInvariant()] = $file } $seen = @{} $zip = [IO.Compression.ZipFile]::OpenRead($ArchivePath) try { foreach ($entry in @($zip.Entries)) { $name = [string]$entry.Name $fullName = [string]$entry.FullName if ([string]::IsNullOrWhiteSpace($name) -or $fullName -cne $name -or $name -match '[\\/]' -or -not $expected.ContainsKey($name.ToLowerInvariant()) -or $seen.ContainsKey($name.ToLowerInvariant()) -or $entry.Length -ne [long]$expected[$name.ToLowerInvariant()].size) { throw 'Boot-driver archive contains a traversal, directory, duplicate, or unsigned extra file.' } $seen[$name.ToLowerInvariant()] = $true $destination = Assert-DaBootstrapLocalPath (Join-Path $extracting $name) if (-not $destination.StartsWith(($extracting.TrimEnd('\') + '\'), [StringComparison]::OrdinalIgnoreCase)) { throw 'Boot-driver archive entry escaped its package directory.' } $output = [IO.File]::Open($destination, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) try { $input = $entry.Open(); try { $input.CopyTo($output) } finally { $input.Dispose() } } finally { $output.Dispose() } if ((Get-Item -LiteralPath $destination -Force).Length -ne [long]$expected[$name.ToLowerInvariant()].size) { throw "Boot-driver archive entry has an unexpected size: $name" } if ((Get-FileHash -LiteralPath $destination -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() -cne ([string]$expected[$name.ToLowerInvariant()].sha256).ToLowerInvariant()) { throw "Boot-driver archive entry does not match its signed hash: $name" } Protect-DaBootstrapAcl -Path $destination | Out-Null } } finally { $zip.Dispose() } if ($seen.Count -ne $expected.Count) { throw 'Boot-driver archive omits a signed manifest file.' } Move-Item -LiteralPath $extracting -Destination $PackageRoot -ErrorAction Stop Protect-DaBootstrapAcl -Path $PackageRoot -Directory | Out-Null } catch { # This directory is created by this invocation and accepts only flat # files. Verify that invariant before removing an incomplete cache. $safeExtracting = Assert-DaBootstrapLocalPath $extracting $safeParent = (Assert-DaBootstrapLocalPath $parent).TrimEnd('\') + '\' if ($safeExtracting.StartsWith($safeParent, [StringComparison]::OrdinalIgnoreCase) -and (Test-Path -LiteralPath $safeExtracting -PathType Container)) { $children = @(Get-ChildItem -LiteralPath $safeExtracting -Force -ErrorAction Stop) if (@($children | Where-Object { $_.PSIsContainer -or (($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) }).Count -eq 0) { Remove-Item -LiteralPath $safeExtracting -Recurse -Force -ErrorAction SilentlyContinue } } throw } } function Get-DaBootstrapBootDriverDirectory { $root = Join-Path $work ('boot-driver-library-v1\' + $BootDriverManifestSha256.ToLowerInvariant()) Protect-DaBootstrapAcl -Path $root -Directory -Create | Out-Null $manifestPath = Join-Path $root 'manifest.json' Get-DaBootstrapArtifact -Uri "$($BootDriverBase.TrimEnd('/'))/manifest.json" -Destination $manifestPath -Sha256 $BootDriverManifestSha256.ToLowerInvariant() | Out-Null $manifest = Get-DaBootDriverLibraryManifest -ManifestPath $manifestPath $payloadRoot = Join-Path $root 'payload' if (Test-Path -LiteralPath $payloadRoot) { Test-DaBootDriverLibrary -Root $payloadRoot -Manifest $manifest | Out-Null return $root } $archiveDirectory = Join-Path $work ('boot-library-downloads-v1\' + $BootDriverManifestSha256.ToLowerInvariant()) Protect-DaBootstrapAcl -Path $archiveDirectory -Directory -Create | Out-Null $archive = Join-Path $archiveDirectory ([string]$manifest.archive.file) Write-Host 'Preparing the shared storage, Ethernet and Wi-Fi driver library.' -ForegroundColor Cyan Get-DaBootstrapArtifact -Uri "$($BootDriverBase.TrimEnd('/'))/$([string]$manifest.archive.file)" -Destination $archive -Sha256 ([string]$manifest.archive.sha256).ToLowerInvariant() | Out-Null Expand-DaBootDriverLibrary -ArchivePath $archive -Destination $payloadRoot -Manifest $manifest | Out-Null Test-DaBootDriverLibrary -Root $payloadRoot -Manifest $manifest | Out-Null return $root } function Get-DaBootstrapVendorSupportRelease { param([Parameter(Mandatory)][string] $RunnerVersion) if ($RunnerVersion -in @('v17','v18')) { $release = [pscustomobject]@{ Base='https://cdn.desktopagent.com/vendor-driver-support/v1' Sha256='103ad5e0b72dc5518600d8a3357e4962d870a6acf17b0807b853f49111f4ce23' Recipe='vendor-extract-v1'; CacheVersion='v1' Files=@('innoextract.exe','libbz2-1.dll','7za.exe') } } elseif ($RunnerVersion -in @('v19','v20','v21','v22','v23','v24','v25','v26','v27','v28','v29','v30','v31','v32','v33','v34','v35','v36','v37','v38','v39','v40','v41','v42','v43','v44','v45','v46','v47')) { $release = [pscustomobject]@{ Base='https://cdn.desktopagent.com/vendor-driver-support/v2' Sha256='782e616ac72e52197574fa57689c4e491b5d26edee46734b6d2ce7de1f8c4869' Recipe='vendor-extract-v2'; CacheVersion='v2' Files=@('innoextract.exe','libbz2-1.dll','7z.exe','7z.dll') } } else { throw 'The recorded runner has no approved vendor extraction support release.' } if ($vendorSupportBaseOverridden -and $vendorSupportPinOverridden) { $release.Base = $VendorDriverSupportBase $release.Sha256 = $VendorDriverSupportManifestSha256 } return $release } function Get-DaBootstrapVendorDriverSupport { param([Parameter(Mandatory)][string] $PlanPath, [string] $RunnerVersion = $currentRunnerVersion) $plan = Get-Content -LiteralPath $PlanPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop $property = $plan.PSObject.Properties['vendorDriverPlan'] if ($null -eq $property) { return '' } if ($null -eq $property.Value -or $property.Value -isnot [pscustomobject]) { throw 'vendorDriverPlan must be an object when supplied.' } $release = Get-DaBootstrapVendorSupportRelease -RunnerVersion $RunnerVersion $root = Join-Path $work ('vendor-driver-support-' + $release.CacheVersion + '\' + $release.Sha256.ToLowerInvariant()) Protect-DaBootstrapAcl -Path $root -Directory -Create | Out-Null $supportBase = $release.Base.TrimEnd('/') $manifestPath = Join-Path $root 'manifest.json' Get-DaBootstrapArtifact -Uri "$supportBase/manifest.json" -Destination $manifestPath -Sha256 $release.Sha256.ToLowerInvariant() $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json if ([string]$manifest.recipe -cne $release.Recipe) { throw 'Vendor extraction support does not match the recorded runner release.' } foreach ($name in $release.Files) { Get-DaBootstrapArtifact -Uri "$supportBase/$name" -Destination (Join-Path $root $name) -Sha256 (Get-DaBootstrapVendorSupportFileHash -ManifestPath $manifestPath -Name $name) } Get-DaVendorDriverSupportManifest -SupportDir $root | Out-Null return $root } function Get-DaBootstrapPowerShellSupport { if (-not $powerShellSupportEnabled) { return '' } $root = Join-Path $work ('powershell-recovery-support-v1\' + $PowerShellSupportManifestSha256.ToLowerInvariant()) Protect-DaBootstrapAcl -Path $root -Directory -Create | Out-Null $supportBase = $PowerShellSupportBase.TrimEnd('/') $manifestPath = Join-Path $root 'manifest.json' Get-DaBootstrapArtifact -Uri "$supportBase/manifest.json" -Destination $manifestPath -Sha256 $PowerShellSupportManifestSha256.ToLowerInvariant() try { $manifest = Get-Content -LiteralPath $manifestPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop } catch { throw "PowerShell recovery support manifest is invalid JSON: $($_.Exception.Message)" } $expectedNames = @( 'WinPE-WMI.cab','WinPE-WMI_en-us.cab','WinPE-NetFX.cab','WinPE-NetFX_en-us.cab', 'WinPE-Scripting.cab','WinPE-Scripting_en-us.cab','WinPE-PowerShell.cab','WinPE-PowerShell_en-us.cab' ) if ([string]$manifest.schema -cne 'da-powershell-support.v1' -or [string]$manifest.architecture -cne 'amd64' -or [string]$manifest.language -cne 'en-US' -or [int]$manifest.imageBuild -ne 26100 -or [string]$manifest.packageVersion -cne '10.0.26100.1') { throw 'PowerShell recovery support manifest has an unsupported compatibility contract.' } $packages = @($manifest.packages) if ($packages.Count -ne $expectedNames.Count) { throw 'PowerShell recovery support manifest must pin exactly eight packages.' } for ($i = 0; $i -lt $expectedNames.Count; $i++) { $entry = $packages[$i] $name = [string]$entry.file if ($name -cne $expectedNames[$i] -or [string]$entry.sha256 -notmatch '^(?i)[0-9a-f]{64}$' -or $null -eq $entry.length -or [long]$entry.length -lt 1) { throw "PowerShell recovery support package contract is invalid at $($expectedNames[$i])." } Get-DaBootstrapArtifact -Uri "$supportBase/$name" -Destination (Join-Path $root $name) -Sha256 ([string]$entry.sha256).ToLowerInvariant() } Get-DaPowerShellSupportManifest -SupportDir $root | Out-Null return $root } function Get-DaBootstrapVendorSupportFileHash { param([Parameter(Mandatory)][string] $ManifestPath, [Parameter(Mandatory)][string] $Name) $manifest = Get-Content -LiteralPath $ManifestPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop $validContract = ([string]$manifest.schema -ceq 'da-vendor-driver-support.v1' -and [string]$manifest.recipe -ceq 'vendor-extract-v1') -or ([string]$manifest.schema -ceq 'da-vendor-driver-support.v2' -and [string]$manifest.recipe -ceq 'vendor-extract-v2') if (-not $validContract) { throw 'Vendor driver support manifest is unsupported.' } $entries = @($manifest.files | Where-Object { [string]$_.path -ceq $Name }) if ($entries.Count -ne 1 -or [string]$entries[0].sha256 -cnotmatch '^[0-9a-f]{64}$') { throw "Vendor driver support manifest has no valid hash for $Name." } return ([string]$entries[0].sha256).ToLowerInvariant() } function Assert-DaBootstrapAcPower { if (-not ('DesktopAgent.BootstrapPowerStatus' -as [type])) { Add-Type @' using System.Runtime.InteropServices; namespace DesktopAgent { [StructLayout(LayoutKind.Sequential)] public struct BootstrapPowerStatus { public byte ACLineStatus; public byte BatteryFlag; public byte BatteryLifePercent; public byte SystemStatusFlag; public uint BatteryLifeTime; public uint BatteryFullLifeTime; } public static class BootstrapPowerStatusApi { [DllImport("kernel32.dll")] public static extern bool GetSystemPowerStatus(out BootstrapPowerStatus status); } } '@ } $status = New-Object DesktopAgent.BootstrapPowerStatus if (-not [DesktopAgent.BootstrapPowerStatusApi]::GetSystemPowerStatus([ref]$status)) { throw 'Windows could not confirm AC power. Preparation was refused; connect AC power and retry.' } $decision = Get-DaBootstrapPowerDecision -AcLineStatus $status.ACLineStatus -BatteryFlag $status.BatteryFlag if ($decision -eq 'ProceedOnAc') { return } if ($decision -eq 'RefuseOnBattery') { throw 'This machine is running on battery power. Preparation was refused; connect AC power and retry.' } throw 'The machine power source could not be confirmed as AC. Preparation was refused; connect AC power and retry.' } function Get-DaBootstrapPowerDecision { param([int] $AcLineStatus, [int] $BatteryFlag) if ($AcLineStatus -eq 1 -or ($AcLineStatus -eq 255 -and $BatteryFlag -eq 128)) { return 'ProceedOnAc' } if ($AcLineStatus -eq 0 -and $BatteryFlag -ne 128) { return 'RefuseOnBattery' } return 'RefuseUnknown' } function Invoke-DaApprovedReboot { param([scriptblock] $NativeReboot) if (-not $NativeReboot) { $NativeReboot = { & "$env:SystemRoot\System32\shutdown.exe" /r /t 0 /f *> $null return $LASTEXITCODE } } $exitCode = [int](& $NativeReboot) if ($exitCode -ne 0) { throw "Windows did not accept the approved reboot request (exit $exitCode). The one-time recovery boot remains armed; run this launcher again to resume or cancel preparation." } } function Confirm-DaBootstrapReboot { Write-Host '' Write-Host 'The one-time recovery boot is ready. Keep AC power connected and ensure Ethernet or the approved Wi-Fi path is available before restarting.' -ForegroundColor Yellow $answer = Read-Host 'Reboot (2/2) now to image this computer? Type Y to reboot' if ($answer -match '^[Yy]$') { Add-DaPreparationEvent -Stage 'Preparation' -Message 'Reboot was approved; handing off to the one-time recovery boot.' -Code 'osd.reboot.approved' Invoke-DaApprovedReboot } else { Add-DaPreparationEvent -Stage 'Preparation' -Message 'Reboot was declined; the prepared recovery boot remains available.' -Code 'osd.reboot.declined' Write-Host 'The next restart will enter recovery. Run this launcher again to resume or cancel preparation.' } } Write-Host '' Write-Host ' Desktop Agent device pairing' -ForegroundColor Cyan Write-Host ' This shows a QR to pair the machine. It does not image or change it.' -ForegroundColor Yellow Write-Host '' $pr = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) if (-not $pr.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Run this elevated. At OOBE use Shift+F10 (that prompt is SYSTEM).' } Protect-DaBootstrapAcl -Path $work -Directory -Create | Out-Null if (Test-Path -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\MiniNT') { throw 'Run this preparation launcher from Windows.' } $lockPath = Assert-DaBootstrapLocalPath (Join-Path $work 'launcher.lock') try { $launcherLock = [IO.File]::Open($lockPath,[IO.FileMode]::OpenOrCreate,[IO.FileAccess]::ReadWrite,[IO.FileShare]::None) } catch { throw 'Desktop Agent preparation is already running. Return to that window or close it before retrying.' } try { if ($env:DA_NETWORK_INTERACTIVE -and $env:DA_NETWORK_INTERACTIVE -notmatch '^(?i)(0|1|true|false)$') { throw 'DA_NETWORK_INTERACTIVE must be 0, 1, true, or false.' } # ---- enrollment token resolution (#49) -------------------------------------- # Mirrors the runner's own precedence (config::pick_enrollment_token): an # explicit value beats a file beats the environment. Every candidate is # trimmed, and an all-whitespace one counts as absent, so a stray newline left # in a minted token file cannot silently read as "no token". $resolvedToken = '' if (-not [string]::IsNullOrWhiteSpace($EnrollToken)) { $resolvedToken = $EnrollToken.Trim() } elseif ($EnrollTokenFile) { $tokenPath = Assert-DaBootstrapLocalPath $EnrollTokenFile if (Test-Path -LiteralPath $tokenPath -PathType Leaf) { Protect-DaBootstrapAcl -Path $tokenPath | Out-Null $resolvedToken = (Get-Content -LiteralPath $tokenPath -Raw).Trim() } } elseif (-not [string]::IsNullOrWhiteSpace($env:DA_ENROLLMENT_TOKEN)) { $resolvedToken = $env:DA_ENROLLMENT_TOKEN.Trim() } $usingToken = -not [string]::IsNullOrWhiteSpace($resolvedToken) # The safety asymmetry: -Unattended only ever auto-answers a prompt when a # token was actually used to admit this machine. Requesting it with no token # present is a no-op, not an error, so a mistaken flag cannot silently skip a # human confirmation in the ordinary QR flow. $autoAnswer = $Unattended -and $usingToken if ($Unattended -and -not $usingToken) { Write-Host ' -Unattended was requested but no enrollment token is present; prompting as normal.' -ForegroundColor Yellow } if ($autoAnswer) { Write-Host ' Unattended mode: an enrollment token is present, so the prompts below answer themselves.' -ForegroundColor Yellow } # Staging helpers are fetched first. RunState is the only code allowed to # interpret an existing receipt, so no current runner download can replace # bytes that a previous preparation still binds. $stagingDir = Get-DaBootstrapStagingDirectory -Base $stagingV18Base -Hashes $stagingV18Hashes -CacheName 'staging-v18' . (Join-Path $stagingDir 'PowerShellSupport.ps1') . (Join-Path $stagingDir 'AutopilotSupport.ps1') . (Join-Path $stagingDir 'BootNetworkDrivers.ps1') . (Join-Path $stagingDir 'BootDriverPackages.ps1') . (Join-Path $stagingDir 'BootDriverLibrary.ps1') . (Join-Path $stagingDir 'VendorDriverSupport.ps1') . (Join-Path $stagingDir 'RunState.ps1') . (Join-Path $stagingDir 'RunEvents.ps1') Add-DaPreparationEvent -Stage 'Launcher' -Message 'Launcher started.' -Code 'osd.launcher.started' $recoveryCandidates = @( [pscustomobject]@{ StageDir='C:\DA-Runner-Stage'; LogDir='C:\DA-Runner-Stage-Logs'; MountDir='C:\DA-Runner-Mount'; PlanPath='C:\DA-Runner\runner-plan.json' }, [pscustomobject]@{ StageDir='C:\DA-P17-Driver-Stage'; LogDir='C:\DA-P17-Driver-Stage-Logs'; MountDir='C:\DA-Runner-Mount'; PlanPath='C:\DA-Runner\runner-plan.json' } ) $recovery = Find-DaBootstrapRecoveryContext -Candidates $recoveryCandidates -KnownRunnerReleases $knownRunnerReleases ` -ExpectedConfigSha256 $configHash -WorkDir $work if ($recovery) { $release = $recovery.Release Restore-DaBoundBootstrapArtifact -Uri "$([string]$release.Base)/da-runner.exe" -Destination ([string]$recovery.RunnerPath) -Sha256 ([string]$release.Sha256) | Out-Null Restore-DaBoundBootstrapArtifact -Uri "$([string]$release.Base)/runner.config.json" -Destination ([string]$recovery.ConfigPath) -Sha256 ([string]$release.ConfigSha256) | Out-Null Initialize-DaPreparationEvents -ConfigPath $recovery.ConfigPath -PlanPath $recovery.PlanPath Add-DaPreparationEvent -Stage 'Recovery' -Message 'An existing preparation was found and is being verified.' -Code 'osd.recovery.found' Write-Host '' Write-Host 'Previous OSD preparation was found. Its device, files, and boot entry will be verified.' -ForegroundColor Cyan $choice = Read-Host 'R: Resume. S: Start over with fresh approval. C: Cancel preparation. Enter: Leave as is' if ($choice -notmatch '^[RrSsCc]$') { return } $normalizedChoice = $choice.ToUpperInvariant() $recoveryStagingDir = $stagingDir if ([string]$recovery.Release.Version -in @('v42','v43','v44')) { $recoveryStagingDir = Get-DaBootstrapStagingDirectory -Base $stagingV17Base -Hashes $stagingV17Hashes -CacheName 'staging-v17' } $recoveryResult = Invoke-DaBootstrapRecoveryChoice -Context $recovery -Choice $normalizedChoice ` -StageRunnerPath (Join-Path $recoveryStagingDir 'Stage-Runner.ps1') ` -AutopilotSupportResolver { param($planPath) Get-DaBootstrapAutopilotSupport -PlanPath $planPath } ` -VendorDriverSupportResolver { param($planPath, $runnerVersion) Get-DaBootstrapVendorDriverSupport -PlanPath $planPath -RunnerVersion $runnerVersion } ` -PowerShellSupportResolver { Get-DaBootstrapPowerShellSupport } ` -BootDriverLibraryResolver { Get-DaBootstrapBootDriverDirectory } ` -RebootConfirmer ${function:Confirm-DaBootstrapReboot} if ($recoveryResult -ceq 'Resumed') { return } if ($recoveryResult -ceq 'Cancelled') { Write-Host 'Preparation was cancelled. Windows remains the normal boot target.'; return } Write-Host 'Starting again with fresh device approval. Verified downloads will be reused.' # The cancelled receipt belongs to the old attempt. Buffer the next # admission under its new plan instead of posting through that old context. $script:DaPreparationCookie = '' $script:DaPreparationAttemptId = '' $script:DaPreparationApiBase = '' $script:DaPreparationEvents.Clear() } # Fresh admission always uses the promoted runner at its immutable versioned # local path. Existing bytes at that path must already match; mismatch is # preserved for review instead of being repaired in place. $runnerPath = Restore-DaBoundBootstrapArtifact -Uri "$currentRunnerBase/da-runner.exe" -Destination $currentRunnerPath -Sha256 $currentRunnerHash $configPath = Restore-DaBoundBootstrapArtifact -Uri "$currentRunnerBase/runner.config.json" -Destination $currentConfigPath -Sha256 $configHash $winReStageDir = 'C:\DA-Runner-Stage' $winReLogDir = 'C:\DA-Runner-Stage-Logs' $winReMountDir = 'C:\DA-Runner-Mount' # A receipt is the reusable admission handoff. A bare plan from an older # interrupted pairing has no device binding, so refresh that admission. $cfgPeek = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json -ErrorAction Stop $peekDir = if ($cfgPeek.stageDir) { $cfgPeek.stageDir } else { 'C:\DA-Runner' } Protect-DaBootstrapAcl -Path $peekDir -Directory -Create | Out-Null $peek = Assert-DaBootstrapLocalPath (Join-Path $peekDir 'runner-plan.json') $skipPairing = $false if (-not $skipPairing) { # A failed or cancelled fresh admission must never fall through to an old # plan. Remove that exact protected handoff before launching pairing. if (Test-Path -LiteralPath $peek) { Remove-Item -LiteralPath $peek -Force -ErrorAction Stop } Write-Host '' Add-DaPreparationEvent -Stage 'Pairing' -Message 'Waiting for device admission.' -Code 'osd.pairing.started' if ($usingToken) { Write-Host ' Starting pairing with the organization enrollment token (no QR needed).' -ForegroundColor Cyan } else { Write-Host ' Starting pairing. Scan the QR below on your phone at m.desktopagent.com.' -ForegroundColor Cyan } Write-Host '' # Forward the token through the environment, never on the command line, so # it is not visible to a process listing for the life of this script (the # runner reads DA_ENROLLMENT_TOKEN itself; see config::pick_enrollment_token). $priorTokenEnv = $env:DA_ENROLLMENT_TOKEN $priorNetworkInteractive = $env:DA_NETWORK_INTERACTIVE if ($usingToken) { $env:DA_ENROLLMENT_TOKEN = $resolvedToken } $env:DA_NETWORK_INTERACTIVE = $(if ($autoAnswer) { '0' } else { '1' }) try { & $runnerPath --pair if ($LASTEXITCODE -ne 0) { throw "Pairing failed (exit $LASTEXITCODE). Imaging was stopped." } } finally { $env:DA_ENROLLMENT_TOKEN = $priorTokenEnv $env:DA_NETWORK_INTERACTIVE = $priorNetworkInteractive } } # ---- imaging hand-off ------------------------------------------------------ # When the machine was admitted AND a published sequence is deployed to it, # the runner stages the mapped reimage plan into its stageDir and exits (it # never reboots or wipes from the running OS). Pick that plan up here and # offer to finish the job, so the technician types one command total. $stageDir = 'C:\DA-Runner' try { $cfg = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json if ($cfg.stageDir) { $stageDir = $cfg.stageDir } } catch {} Protect-DaBootstrapAcl -Path $stageDir -Directory -Create | Out-Null $plan = Join-Path $stageDir 'runner-plan.json' if (-not (Test-Path -LiteralPath $plan -PathType Leaf)) { return } Protect-DaBootstrapAcl -Path $plan | Out-Null $admittedPlan = Get-Content -LiteralPath $plan -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop if ($autoAnswer -and (($admittedPlan.PSObject.Properties.Name -notcontains 'networkInteractive') -or $admittedPlan.networkInteractive -ne $false)) { # A refused enrollment token may fall back to QR admission. Only the # runner's confirmed token-admission path may retain unattended consent. $autoAnswer = $false } Initialize-DaPreparationEvents -ConfigPath $configPath -PlanPath $plan Add-DaPreparationEvent -Stage 'Admission' -Message 'Device admission was accepted and a deployment plan was received.' -Code 'osd.admission.accepted' $vendorDriverSupportDir = Get-DaBootstrapVendorDriverSupport -PlanPath $plan $powerShellSupportDir = Get-DaBootstrapPowerShellSupport $preparationBinding = Get-DaRunBinding -StageDir $winReStageDir -LogDir $winReLogDir -MountDir $winReMountDir ` -RunnerExe $runnerPath -ConfigFile $configPath -PlanFile $plan -VendorDriverSupportDir $vendorDriverSupportDir -PowerShellSupportDir $powerShellSupportDir if (-not (Test-Path -LiteralPath (Get-DaRunStatePath -StageDir $winReStageDir))) { if ((Test-Path -LiteralPath $winReStageDir) -and @(Get-ChildItem -LiteralPath $winReStageDir -Force).Count -gt 0) { throw 'Unrecognized recovery files already exist. They were preserved. Review their ownership before preparing another run.' } Write-DaRunState -StageDir $winReStageDir -State (New-DaPreparingRunState -Binding $preparationBinding) | Out-Null } Write-Host '' Write-Host ' A reimage plan is staged for this machine, assigned by your organization.' -ForegroundColor Cyan Write-Host ' Preparing the reboot into WinPE lets it apply. This step does not wipe' -ForegroundColor Yellow Write-Host ' the running OS, and a failed PE boot falls straight back to Windows.' -ForegroundColor Yellow Assert-DaBootstrapAcPower if ($autoAnswer) { Write-Host ' Unattended: preparing the reboot into WinPE now.' } else { $answer = Read-Host ' Prepare the reboot into WinPE now (1/2)? Type Y to continue' if ($answer -notmatch '^[Yy]$') { Write-Host ' Left as is. Run this command again to continue later.' return } } Add-DaPreparationEvent -Stage 'Preparation' -Message 'Preparing the one-time recovery boot.' -Code 'osd.preparation.started' $autopilotSupportDir = Get-DaBootstrapAutopilotSupport -PlanPath $plan $bootDriverDirectory = Get-DaBootstrapBootDriverDirectory $stageArgs = @('-RunnerExe',$runnerPath,'-ConfigFile',$configPath,'-PlanFile',$plan, '-StageDir',$winReStageDir,'-LogDir',$winReLogDir,'-MountDir',$winReMountDir) if ($autopilotSupportDir -ne '') { $stageArgs += @('-AutopilotSupportDir', $autopilotSupportDir) } if ($bootDriverDirectory -ne '') { $stageArgs += @('-BootDriverDirectory', $bootDriverDirectory) } if ($vendorDriverSupportDir -ne '') { $stageArgs += @('-VendorDriverSupportDir', $vendorDriverSupportDir) } if ($powerShellSupportDir -ne '') { $stageArgs += @('-PowerShellSupportDir', $powerShellSupportDir) } & powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $stagingDir 'Stage-Runner.ps1') @stageArgs if ($LASTEXITCODE -ne 0) { throw "Staging failed (exit $LASTEXITCODE). Nothing destructive happened; see C:\DA-Runner-Stage-Logs." } Add-DaPreparationEvent -Stage 'Preparation' -Message 'The one-time recovery boot is armed.' -Code 'osd.preparation.armed' Write-Host '' if ($autoAnswer) { Write-Host ' Unattended: staging is complete and the one-time boot is armed. Rebooting now to image.' Invoke-DaApprovedReboot } else { Add-DaPreparationEvent -Stage 'Preparation' -Message 'Waiting for explicit reboot approval.' -Code 'osd.reboot.awaiting' Confirm-DaBootstrapReboot } } finally { $launcherLock.Dispose() }