using System; using System.Diagnostics; using System.IO; using System.Linq; using System.Management; using System.Reflection; using System.Security; using System.Text; using AutopilotMonitor.Agent.V2.Core.Configuration; using AutopilotMonitor.Agent.V2.Core.Logging; using AutopilotMonitor.Agent.V2.Core.Security; using AutopilotMonitor.Shared; using Microsoft.Win32; using Newtonsoft.Json; namespace AutopilotMonitor.Agent.V2 { /// /// --install mode: deploys the agent payload to the canonical install directory, /// persists bootstrap and await-enrollment config for the Scheduled Task, registers + starts /// the task, and writes the deployment registry marker. Plan §4.x M4.6.α. /// /// Ported 1:1 from Legacy Program.InstallMode.cs — the OOBE bootstrap script and the /// Intune Platform Script contract expect exactly this sequence and these file locations. /// The only delta is the V2 exe name / build-output directory. /// /// /// Exit code contract: 0 means deployment succeeded and the BootTrigger Scheduled /// Task is registered, i.e. the agent is guaranteed to come up no later than the next /// reboot. Whether the immediate runtime-spawn (WMI Win32_Process.Create or schtasks /Run /// fallback) actually succeeded is logged as INFO / WARNING but does not change the exit /// code — the bootstrap script's Get-Process probe is the canonical /// "no runtime came up" signal. Returning 1 when only the immediate spawn failed /// would trip the bootstrap's pre-flight on the next IME run (SKIP: Agent already /// installed) and leave the device stuck until manual intervention. /// /// public static partial class Program { // Shared with the Core reader (DeploymentMarker) — the marker's UTC instant anchors the // pre-agent IME log era, so writer and reader must agree on key, value and format. internal const string DeploymentRegistryKey = AutopilotMonitor.Agent.V2.Core.Security.DeploymentMarker.RegistryKeyPath; internal const string DeploymentRegistryValue = AutopilotMonitor.Agent.V2.Core.Security.DeploymentMarker.RegistryValueName; internal const string BootstrapConfigFileName = "bootstrap-config.json"; internal const string AwaitEnrollmentConfigFileName = "await-enrollment.json"; private const string InstalledAgentExeName = "AutopilotMonitor.Agent.exe"; internal static int RunInstallMode(string[] args) { var logDir = Environment.ExpandEnvironmentVariables(Constants.LogDirectory); var logger = new AgentLogger(logDir, AgentLogLevel.Debug); var consoleMode = args.Contains("--console") || Environment.UserInteractive; logger.EnableConsoleOutput = consoleMode; try { logger.Info("======================= Agent install mode (--install) ======================="); EnsureAgentDirectories(logger); var sourceExePath = Assembly.GetExecutingAssembly().Location; var sourceDir = Path.GetDirectoryName(sourceExePath) ?? string.Empty; var targetAgentDir = Environment.ExpandEnvironmentVariables( Path.Combine(Constants.AgentDataDirectory, DefaultAgentSubdirectory)); var targetExePath = Path.Combine(targetAgentDir, InstalledAgentExeName); if (!string.Equals( Path.GetFullPath(sourceDir).TrimEnd('\\'), Path.GetFullPath(targetAgentDir).TrimEnd('\\'), StringComparison.OrdinalIgnoreCase)) { logger.Info($"Install mode called from '{sourceDir}'. Deploying payload to '{targetAgentDir}'."); CopyDirectory(sourceDir, targetAgentDir, logger); } else { logger.Info("Agent already running from target install directory; payload copy not required."); } // Persist bootstrap config if any of --bootstrap-token / --tenant-id / // --tenant-id-wait was provided. The Scheduled Task command line has no args — // the agent picks the persisted values up on the first post-install run. // // Read-merge-write: only fields explicitly set on this install invocation // overwrite the persisted file. This protects against a redeploy that passes // only --tenant-id-wait clobbering an existing BootstrapToken / TenantId, and // lets an admin opt out of the wait by passing --tenant-id-wait 0 explicitly // (which writes 0 over an old non-zero value). var bootstrapTokenArg = GetArgValue(args, "--bootstrap-token"); var tenantIdArg = GetArgValue(args, "--tenant-id"); var tenantIdWaitArg = GetArgValue(args, "--tenant-id-wait"); bool bootstrapTokenGiven = !string.IsNullOrEmpty(bootstrapTokenArg); bool tenantIdGiven = !string.IsNullOrEmpty(tenantIdArg); bool tenantIdWaitGiven = !string.IsNullOrEmpty(tenantIdWaitArg); int tenantIdWaitSeconds = 0; if (tenantIdWaitGiven && !int.TryParse(tenantIdWaitArg, out tenantIdWaitSeconds)) { logger.Warning($"Install: --tenant-id-wait '{tenantIdWaitArg}' is not a valid integer — ignoring."); tenantIdWaitGiven = false; tenantIdWaitSeconds = 0; } if (bootstrapTokenGiven || tenantIdGiven || tenantIdWaitGiven) { var bootstrapConfigPath = Path.Combine( Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory), BootstrapConfigFileName); BootstrapConfigFile existing = null; if (File.Exists(bootstrapConfigPath)) { try { existing = JsonConvert.DeserializeObject( File.ReadAllText(bootstrapConfigPath)); } catch (Exception ex) { logger.Warning( $"Existing bootstrap-config.json could not be parsed " + $"({ex.GetType().Name}: {ex.Message}) — overwriting fresh."); } } var merged = MergeBootstrapConfig( existing, bootstrapTokenArg, bootstrapTokenGiven, tenantIdArg, tenantIdGiven, tenantIdWaitSeconds, tenantIdWaitGiven); File.WriteAllText(bootstrapConfigPath, JsonConvert.SerializeObject(merged)); logger.Info( $"Bootstrap config persisted for Scheduled Task " + $"(token={(string.IsNullOrEmpty(merged.BootstrapToken) ? "no" : "yes")}" + $"{(bootstrapTokenGiven ? "*" : "")}, " + $"tenantId={(string.IsNullOrEmpty(merged.TenantId) ? "no" : "yes")}" + $"{(tenantIdGiven ? "*" : "")}, " + $"tenantIdWait={(merged.TenantIdWaitSeconds is int w ? $"{w}s" : "default")}" + $"{(tenantIdWaitGiven ? "*" : "")})." + " Star = explicitly set on this --install invocation, others were preserved from prior config."); } // Persist await-enrollment config if requested. if (args.Contains("--await-enrollment")) { var awaitConfigPath = Path.Combine( Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory), AwaitEnrollmentConfigFileName); var awaitTimeoutArg = GetArgValue(args, "--await-enrollment-timeout"); var timeoutMinutes = 480; if (!string.IsNullOrEmpty(awaitTimeoutArg) && int.TryParse(awaitTimeoutArg, out var parsed)) timeoutMinutes = parsed; var awaitConfig = new AwaitEnrollmentConfigFile { TimeoutMinutes = timeoutMinutes }; File.WriteAllText(awaitConfigPath, JsonConvert.SerializeObject(awaitConfig)); logger.Info($"Await-enrollment config persisted for Scheduled Task (timeout: {timeoutMinutes}min)."); } var taskName = Constants.ScheduledTaskName; logger.Info($"Registering Scheduled Task '{taskName}' for executable: {targetExePath}"); // PR2: register the task from a hardened XML definition rather than the // schtasks /Create CLI. The CLI defaults inherit DisallowStartIfOnBatteries // = true and StopIfGoingOnBatteries = true, which queue the BootTrigger run // indefinitely on a laptop that boots on battery in OOBE / WinPE (observed // 2026-05-04, Event 325 'Launch request queued', task status = Queued). // The XML below disables both, sets StartWhenAvailable, and pins // ExecutionTimeLimit = PT0S so the long-running agent is never killed at // 72h (the schtasks CLI default). CreateScheduledTaskFromXml(taskName, targetExePath, logger); logger.Info($"Scheduled Task '{taskName}' created/updated successfully."); // Two-tier runtime-launch fallback chain (order is token-aware, see below): // // 1. WMI Win32_Process.Create (first choice on a full token). Spawns the // process from the WBEM service context, outside the install-mode // process tree and outside Task Scheduler's queue (which defers /Run // on battery / OOBE — observed WinPE 2026-05-04, // project_v2_install_runtime_handoff.md). // 2. schtasks /Run on the registered BootTrigger task (fallback). // Used when WMI is blocked, typically Defender ASR rule // d1e49aac-8f56-4280-b9ba-993a6d77406c ('Block process creations // originating from PSExec and WMI commands') — Access Denied at // WBEM ExecMethod, returnValue=2. Customer-observed 2026-05-11. // Task Scheduler spawns via its own service context, which ASR / // typical EDR hooks do not block. // 3. Defer to BootTrigger on next reboot (final safety net). The // hardened Scheduled Task is already registered above and starts // the runtime via the Task Scheduler service — different // ATT&CK signature than either WMI or a manual schtasks /Run. // // Token-aware ordering (2026-08-17): WMI duplicates the CALLER's token onto // the new process. MDM-originated install chains (Bootstrap MSI via // EnterpriseDesktopAppManagement CSP → msiexec) carry a SYSTEM token with // privileges REMOVED (SeTimeZonePrivilege, SeSystemEnvironmentPrivilege) — // a WMI-launched runtime then runs degraded for its whole life (tzutil // exit 5, firmware reads privilege_denied). The Task Scheduler service // starts the task with a fresh full SYSTEM token, so on a stripped token // the order flips: schtasks /Run first (verified via process poll, since // /Run only reports "queued"), WMI as degraded-but-alive fallback. The // battery/OOBE queue-defer that motivated WMI-first is mitigated by the // hardened task XML (PR2: battery limits off, StartWhenAvailable, PT0S). // // Critical contract: --install returns 0 if deployment + task creation // succeeded, even if both immediate launch paths failed. Returning 1 // here would make the bootstrap script throw, the next IME run would // SKIP via pre-flight (agent already installed), and the device would // sit without a runtime until manual intervention. The bootstrap // script's Get-Process probe is the canonical "no runtime came up" // signal — see Install-AutopilotMonitor.ps1 ~line 280. RuntimeLaunchResult launch; if (!TokenPrivilegeProbe.IsPrivilegeHeld(TokenPrivilegeProbe.TimeZonePrivilege)) { logger.Warning( "Install-mode process token does not hold SeTimeZonePrivilege — privilege-stripped " + "caller chain (typical: MDM LOB MSI install). WMI Win32_Process.Create would duplicate " + "this token onto the runtime, so the Scheduled Task launch (fresh full SYSTEM token from " + "the Task Scheduler service) is preferred for this install."); launch = DecideRestrictedTokenLaunchOutcome( trySchtasks: () => TryStartRuntimeViaSchtasks(taskName, logger), verifyRuntimePid: () => WaitForDetachedRuntimePid(targetExePath, logger), tryWmi: () => TryStartRuntimeViaWmi(targetExePath, logger)); } else { var wmi = TryStartRuntimeViaWmi(targetExePath, logger); launch = DecideRuntimeLaunchOutcome( wmiReturnValue: wmi.ReturnValue, wmiPid: wmi.Pid, trySchtasks: () => TryStartRuntimeViaSchtasks(taskName, logger)); } switch (launch.Method) { case RuntimeLaunchMethod.Wmi: logger.Info( $"Runtime process launched (WMI-detached). PID={launch.Pid}, exe={targetExePath}"); break; case RuntimeLaunchMethod.Schtasks: logger.Info( $"Runtime start via schtasks /Run on task '{taskName}'. exe={targetExePath}. {launch.Diagnostic}"); break; case RuntimeLaunchMethod.Deferred: logger.Warning( $"Runtime handoff deferred to BootTrigger Scheduled Task '{taskName}'. " + $"Runtime will start on next reboot. Check 'Microsoft-Windows-Windows Defender/Operational' " + $"event 1121/1122 and AV/EDR logs for '{InstalledAgentExeName}' if this repeats. " + $"Details: {launch.Diagnostic}"); break; } TryWriteDeploymentMarker(logger); if (consoleMode) { Console.WriteLine("Installation completed successfully."); Console.WriteLine($"Task: {taskName}"); Console.WriteLine($"Executable: {targetExePath}"); Console.WriteLine($"Log: {logDir}"); } return 0; } catch (Exception ex) { logger.Error("Install mode failed.", ex); if (consoleMode) Console.Error.WriteLine($"ERROR: {ex.Message}"); return 1; } } private static void EnsureAgentDirectories(AgentLogger logger) { var basePath = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory); var paths = new[] { basePath, Path.Combine(basePath, DefaultAgentSubdirectory), Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory), Environment.ExpandEnvironmentVariables(Constants.LogDirectory), }; foreach (var path in paths) { if (!Directory.Exists(path)) { Directory.CreateDirectory(path); logger.Info($"Created directory: {path}"); } else { logger.Debug($"Directory already exists: {path}"); } } } private static int RunProcess(string fileName, string arguments, AgentLogger logger) { var psi = new ProcessStartInfo { FileName = fileName, Arguments = arguments, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, }; logger.Info($"Executing: {fileName} {arguments}"); // PR3-A3: prior `Process exit code: 0` line had no context — here we add the // process name + duration so log readers can correlate the line with the // command issued without scrolling back. var sw = System.Diagnostics.Stopwatch.StartNew(); using (var process = Process.Start(psi)) { if (process == null) throw new InvalidOperationException($"Failed to start process: {fileName}"); var stdout = process.StandardOutput.ReadToEnd(); var stderr = process.StandardError.ReadToEnd(); process.WaitForExit(); sw.Stop(); if (!string.IsNullOrWhiteSpace(stdout)) logger.Info($"Process output: {stdout.Trim()}"); if (!string.IsNullOrWhiteSpace(stderr)) logger.Warning($"Process error output: {stderr.Trim()}"); var processName = System.IO.Path.GetFileName(fileName); logger.Info($"Process completed: {processName} -> exit={process.ExitCode}, durationMs={sw.ElapsedMilliseconds}"); return process.ExitCode; } } private static void CopyDirectory(string sourceDir, string targetDir, AgentLogger logger) { if (!Directory.Exists(sourceDir)) throw new DirectoryNotFoundException($"Source directory not found: {sourceDir}"); Directory.CreateDirectory(targetDir); foreach (var dir in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories)) { var rel = dir.Substring(sourceDir.Length).TrimStart('\\'); Directory.CreateDirectory(Path.Combine(targetDir, rel)); } foreach (var file in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories)) { var rel = file.Substring(sourceDir.Length).TrimStart('\\'); var dest = Path.Combine(targetDir, rel); Directory.CreateDirectory(Path.GetDirectoryName(dest) ?? targetDir); try { File.Copy(file, dest, overwrite: true); } catch (Exception ex) { // Keep going on locked destinations — the Scheduled Task will pick up the // latest available payload, and the runtime self-update path will retry later. if (File.Exists(dest)) { logger.Warning($"Could not overwrite '{dest}': {ex.Message}. Keeping existing file."); continue; } throw; } } logger.Info($"Payload deployment completed: '{sourceDir}' -> '{targetDir}'"); } /// /// Pure-function merge for bootstrap-config.json. Each ...Given flag tells /// us whether the corresponding CLI arg was explicitly set on this --install /// invocation. When given, the new value wins (including explicit zero / empty) — this /// is what makes --tenant-id-wait 0 a real opt-out. When not given, the prior /// persisted value is preserved — this is what stops a redeploy that only carries /// --tenant-id-wait from clobbering a previously-set BootstrapToken / TenantId. /// internal static BootstrapConfigFile MergeBootstrapConfig( BootstrapConfigFile existing, string bootstrapTokenArg, bool bootstrapTokenGiven, string tenantIdArg, bool tenantIdGiven, int tenantIdWaitSeconds, bool tenantIdWaitGiven) { return new BootstrapConfigFile { BootstrapToken = bootstrapTokenGiven ? bootstrapTokenArg : existing?.BootstrapToken, TenantId = tenantIdGiven ? tenantIdArg : existing?.TenantId, // Not given → carry the prior value through, INCLUDING null. Persisting a // number here would freeze the agent-side default into the file; persisting // null keeps "not configured" distinguishable from an explicit opt-out (0). TenantIdWaitSeconds = tenantIdWaitGiven ? tenantIdWaitSeconds : existing?.TenantIdWaitSeconds, }; } private static void TryWriteDeploymentMarker(AgentLogger logger) { try { using (var regKey = Registry.LocalMachine.CreateSubKey(DeploymentRegistryKey)) { regKey.SetValue(DeploymentRegistryValue, DateTime.UtcNow.ToString("O")); } logger.Info($"Deployment registry marker written (HKLM\\{DeploymentRegistryKey}\\{DeploymentRegistryValue})."); } catch (Exception ex) { logger.Warning($"Failed to write deployment registry marker: {ex.Message}"); } } /// /// PR2: registers the BootTrigger Scheduled Task from a hardened XML definition /// rather than via schtasks /Create /TR ... /SC ONSTART /RU SYSTEM /RL HIGHEST. /// The CLI form inherits XML schema defaults that queue the task indefinitely on a /// laptop booting on battery (Event 325 'Launch request queued', observed /// 2026-05-04 in WinPE) and kill long-running tasks at 72 h. This XML disables both /// behaviours and makes the BootTrigger run robust across the OOBE → first-login /// power transitions. /// private static void CreateScheduledTaskFromXml(string taskName, string exePath, AgentLogger logger) { var taskXml = BuildScheduledTaskXml(exePath); var tempXmlPath = Path.Combine( Path.GetTempPath(), $"AutopilotMonitor-Task-{Guid.NewGuid():N}.xml"); try { // schtasks /XML expects UTF-16 LE with BOM (matches Task Scheduler exports). File.WriteAllText(tempXmlPath, taskXml, Encoding.Unicode); var createExitCode = RunProcess( SystemPaths.Schtasks, $"/Create /TN \"{taskName}\" /XML \"{tempXmlPath}\" /F", logger); if (createExitCode != 0) throw new InvalidOperationException( $"Failed to create/update Scheduled Task '{taskName}' from XML " + $"(exit code {createExitCode}, xml='{tempXmlPath}')."); } finally { try { if (File.Exists(tempXmlPath)) File.Delete(tempXmlPath); } catch (Exception ex) { logger.Warning($"Failed to delete temp task XML '{tempXmlPath}': {ex.Message}"); } } } /// /// Pure builder for the Task Scheduler 1.2 XML used by /// . Kept internal static so unit /// tests can pin the hardened settings — disabling these silently in a future /// refactor would re-introduce the OOBE battery-queue failure mode. /// internal static string BuildScheduledTaskXml(string exePath) { if (string.IsNullOrEmpty(exePath)) throw new ArgumentException("exePath must be set.", nameof(exePath)); var escapedExe = SecurityElement.Escape(exePath); return $@" Autopilot Monitor enrollment observability agent. AutopilotMonitor true S-1-5-18 HighestAvailable IgnoreNew false false true true false false false true true false false false PT0S 7 {escapedExe} "; } /// /// First-choice runtime launch path: spawn the runtime out-of-band of the /// install-mode process tree via WMI Win32_Process.Create. The WBEM /// service spawns the new process from its own service context, giving the /// runtime Job-Object isolation without paying Task Scheduler's queue defer /// (battery / OOBE) that motivated the move away from schtasks /Run. /// /// Returns a result instead of throwing. The schtasks-fallback + /// defer-to-BootTrigger orchestration in /// needs to react to the WMI return code (esp. 2 = Access Denied, which is /// the AV/EDR/ASR block signature we saw 2026-05-11), not be aborted by it. /// Only genuinely unexpected WMI states (no out-params, pid=0 on returnValue=0) /// still throw — those indicate WBEM itself misbehaving, not a policy block. /// /// private static WmiLaunchAttempt TryStartRuntimeViaWmi(string exePath, AgentLogger logger) { try { using (var processClass = new ManagementClass("Win32_Process")) using (var inParams = processClass.GetMethodParameters("Create")) { inParams["CommandLine"] = $"\"{exePath}\""; inParams["CurrentDirectory"] = Path.GetDirectoryName(exePath) ?? string.Empty; using (var outParams = processClass.InvokeMethod("Create", inParams, null)) { if (outParams == null) throw new InvalidOperationException( $"WMI Win32_Process.Create returned no out-parameters for '{exePath}'."); // WMI return codes: 0=ok, 2=access denied, 3=insufficient privilege, // 8=unknown failure, 9=path not found, 21=invalid parameter, 22=invalid name // (https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/create-method-in-class-win32-process). var returnValue = Convert.ToUInt32(outParams["ReturnValue"]); var pid = returnValue == 0u ? Convert.ToInt32(outParams["ProcessId"]) : 0; return new WmiLaunchAttempt(returnValue, pid); } } } catch (ManagementException ex) { logger.Warning($"WMI Win32_Process.Create threw ManagementException for '{exePath}': {ex.Message}. Treating as Access Denied for fallback purposes."); return new WmiLaunchAttempt(2u, 0); } catch (System.Runtime.InteropServices.COMException ex) { logger.Warning($"WMI Win32_Process.Create threw COMException (HRESULT 0x{ex.HResult:X8}) for '{exePath}': {ex.Message}. Treating as Unknown failure for fallback purposes."); return new WmiLaunchAttempt(8u, 0); } } /// /// Second-choice runtime launch path. Queues the registered Scheduled Task /// via schtasks /Run /TN "". /// Returns the schtasks exit code; -1 means the schtasks process /// itself could not be launched. /// /// This is the fallback when WMI is blocked (e.g. Defender ASR rule /// d1e49aac-8f56-4280-b9ba-993a6d77406c, *"Block process creations /// originating from PSExec and WMI commands"*). Task Scheduler spawns the /// runtime via its own service context — a different ATT&CK signature /// that ASR / typical EDR hooks do not block. The known downside (queue /// defer on battery / OOBE that originally motivated PR1) is acceptable as /// a fallback because the third tier (BootTrigger on next reboot) is also /// in place. /// /// private static int TryStartRuntimeViaSchtasks(string taskName, AgentLogger logger) { try { return RunProcess( SystemPaths.Schtasks, $"/Run /TN \"{taskName}\"", logger); } catch (Exception ex) { logger.Warning($"schtasks /Run could not be launched: {ex.GetType().Name}: {ex.Message}"); return -1; } } /// /// Pure orchestration of the two-tier runtime-launch fallback chain. Decides /// which outcome to report based on the WMI attempt and (lazily) the schtasks /// fallback. Exposed internal for unit tests, in the same vein as /// and . /// /// Contract: /// /// If WMI succeeded with a valid pid, return and DO NOT invoke . /// Otherwise invoke ; on exit code 0 return . /// If both paths fail, return — the BootTrigger Scheduled Task is the safety net on next reboot. /// /// The string is the single line /// support reads from agent_*.log; when WMI returns 2 it explicitly /// names the ASR rule GUID so the typical AV/EDR root cause is one log line away. /// /// internal static RuntimeLaunchResult DecideRuntimeLaunchOutcome( uint wmiReturnValue, int wmiPid, Func trySchtasks) { if (trySchtasks == null) throw new ArgumentNullException(nameof(trySchtasks)); if (wmiReturnValue == 0u && wmiPid > 0) { return new RuntimeLaunchResult( method: RuntimeLaunchMethod.Wmi, pid: wmiPid, wmiReturnValue: 0u, schtasksExitCode: 0, diagnostic: $"Runtime process launched via WMI Win32_Process.Create. PID={wmiPid}."); } var wmiHint = wmiReturnValue == 2u ? "WMI returnValue=2 (Access Denied) — likely AV/EDR or Defender ASR rule d1e49aac-8f56-4280-b9ba-993a6d77406c ('Block process creations originating from PSExec and WMI commands')." : $"WMI returnValue={wmiReturnValue} pid={wmiPid}."; var schtasksExit = trySchtasks(); if (schtasksExit == 0) { return new RuntimeLaunchResult( method: RuntimeLaunchMethod.Schtasks, pid: 0, wmiReturnValue: wmiReturnValue, schtasksExitCode: 0, diagnostic: $"Runtime process queued via schtasks /Run fallback. {wmiHint}"); } return new RuntimeLaunchResult( method: RuntimeLaunchMethod.Deferred, pid: 0, wmiReturnValue: wmiReturnValue, schtasksExitCode: schtasksExit, diagnostic: $"Both immediate-launch paths failed; deferring runtime start to BootTrigger Scheduled Task on next reboot. {wmiHint} schtasks /Run exit={schtasksExit}."); } /// /// Restricted-token variant of the launch orchestration — used when the install-mode /// process token does not hold SeTimeZonePrivilege (privilege-stripped MDM/MSI /// caller chain, observed 2026-08-17). WMI would duplicate the stripped token onto the /// runtime, so the order flips: Scheduled Task first (fresh full SYSTEM token from the /// Task Scheduler service), WMI only as degraded-but-alive fallback. /// /// Contract: /// /// Invoke ; on exit 0 invoke (schtasks /Run only reports "queued", never "launched"). A verified pid returns without touching WMI. /// schtasks failed or no process appeared → invoke ; a valid pid returns with a diagnostic warning that the runtime runs on the restricted token. /// Both paths fail → ; the BootTrigger start on next reboot gets a full token anyway. /// is only invoked after a successful schtasks queue — it costs up to the full poll window. /// /// /// internal static RuntimeLaunchResult DecideRestrictedTokenLaunchOutcome( Func trySchtasks, Func verifyRuntimePid, Func tryWmi) { if (trySchtasks == null) throw new ArgumentNullException(nameof(trySchtasks)); if (verifyRuntimePid == null) throw new ArgumentNullException(nameof(verifyRuntimePid)); if (tryWmi == null) throw new ArgumentNullException(nameof(tryWmi)); var schtasksExit = trySchtasks(); if (schtasksExit == 0) { var pid = verifyRuntimePid(); if (pid > 0) { return new RuntimeLaunchResult( method: RuntimeLaunchMethod.Schtasks, pid: pid, wmiReturnValue: 0u, schtasksExitCode: 0, diagnostic: $"Runtime launched via Scheduled Task (restricted install token — task provides a full SYSTEM token). Verified PID={pid}."); } } var schtasksHint = schtasksExit == 0 ? "schtasks /Run was queued but no runtime process appeared within the verification window." : $"schtasks /Run exit={schtasksExit}."; var wmi = tryWmi(); if (wmi.ReturnValue == 0u && wmi.Pid > 0) { return new RuntimeLaunchResult( method: RuntimeLaunchMethod.Wmi, pid: wmi.Pid, wmiReturnValue: 0u, schtasksExitCode: schtasksExit, diagnostic: "Runtime launched via WMI fallback ON THE RESTRICTED TOKEN — privileged features " + "(timezone auto-set, firmware reads) stay degraded until the next BootTrigger start. " + $"{schtasksHint} PID={wmi.Pid}."); } return new RuntimeLaunchResult( method: RuntimeLaunchMethod.Deferred, pid: 0, wmiReturnValue: wmi.ReturnValue, schtasksExitCode: schtasksExit, diagnostic: "Restricted-token launch: both Scheduled Task and WMI paths failed; deferring to " + $"BootTrigger on next reboot (full token there). {schtasksHint} WMI returnValue={wmi.ReturnValue}."); } /// /// Polls for the detached runtime process after a schtasks /Run queue — same /// 10s / 500ms contract as the bootstrap script's Get-Process probe. The install-mode /// process shares the runtime's exe name, so the own pid is excluded; a pre-existing /// foreign runtime cannot be running here because install mode bails earlier on the /// Deployed marker. Same identity rule as the multi-instance guard: a same-named process /// in a user session is not the runtime and must not satisfy the verification. /// private static int WaitForDetachedRuntimePid(string exePath, AgentLogger logger) { var processName = Path.GetFileNameWithoutExtension(exePath); var ownPid = Process.GetCurrentProcess().Id; var deadline = DateTime.UtcNow.AddSeconds(10); while (DateTime.UtcNow < deadline) { try { var runtimePid = FindSiblingAgentPid(SnapshotProcessesByName(processName), ownPid); if (runtimePid != 0) return runtimePid; } catch (Exception ex) { logger.Debug($"Runtime verification poll failed transiently: {ex.GetType().Name}: {ex.Message}"); } System.Threading.Thread.Sleep(500); } return 0; } /// /// Result of a single WMI Win32_Process.Create attempt. /// is 0 on any non-zero . /// internal readonly struct WmiLaunchAttempt { public WmiLaunchAttempt(uint returnValue, int pid) { ReturnValue = returnValue; Pid = pid; } public uint ReturnValue { get; } public int Pid { get; } } /// Which path actually ended up (attempting to) start the runtime. internal enum RuntimeLaunchMethod { /// WMI Win32_Process.Create succeeded and reported a real PID. Wmi, /// WMI failed, schtasks /Run was queued successfully (exit 0). Schtasks, /// Both immediate paths failed; runtime will start on next boot via BootTrigger. Deferred, } /// /// Aggregate result of the runtime-launch orchestration. Carries enough state /// (both subsystem return codes + a one-line diagnostic) for support to triage /// from agent_*.log alone. /// internal readonly struct RuntimeLaunchResult { public RuntimeLaunchResult( RuntimeLaunchMethod method, int pid, uint wmiReturnValue, int schtasksExitCode, string diagnostic) { Method = method; Pid = pid; WmiReturnValue = wmiReturnValue; SchtasksExitCode = schtasksExitCode; Diagnostic = diagnostic ?? string.Empty; } public RuntimeLaunchMethod Method { get; } public int Pid { get; } public uint WmiReturnValue { get; } public int SchtasksExitCode { get; } public string Diagnostic { get; } } } }