added agent self-update mechanism (catch old agents in the wild), fix startup summary dialog (CHX process trigger)

This commit is contained in:
Oliver Kieselbach
2026-03-09 10:30:01 +01:00
parent 4fc80257f6
commit 4cbe879a70
15 changed files with 628 additions and 35 deletions
@@ -190,6 +190,13 @@ namespace AutopilotMonitor.Agent.Core.Configuration
/// </summary>
public string EnrollmentSummaryBrandingImageUrl { get; set; }
/// <summary>
/// Maximum time in seconds the agent retries launching the enrollment summary dialog
/// when the user's desktop is locked by a credential UI (e.g. Windows Hello).
/// 0 = no retry (single attempt). Default: 120.
/// </summary>
public int EnrollmentSummaryLaunchRetrySeconds { get; set; } = 120;
/// <summary>
/// Whether diagnostics upload is configured for this tenant.
/// When true, the agent requests a short-lived upload URL from the backend just before uploading.
@@ -663,7 +663,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
try { UploadEventsAsync().Wait(TimeSpan.FromSeconds(5)); } catch { }
// Launch in user session (fire-and-forget)
var launched = UserSessionProcessLauncher.LaunchInUserSession(tempDialogExe, args, _logger);
var launched = UserSessionProcessLauncher.LaunchInUserSession(
tempDialogExe, args, _logger,
_configuration.EnrollmentSummaryLaunchRetrySeconds);
if (!launched)
{
_logger.Warning("Could not launch summary dialog in user session (no interactive session found)");
@@ -332,6 +332,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
_configuration.ShowEnrollmentSummary = config.ShowEnrollmentSummary;
_configuration.EnrollmentSummaryTimeoutSeconds = config.EnrollmentSummaryTimeoutSeconds;
_configuration.EnrollmentSummaryBrandingImageUrl = config.EnrollmentSummaryBrandingImageUrl;
_configuration.EnrollmentSummaryLaunchRetrySeconds = config.EnrollmentSummaryLaunchRetrySeconds;
_configuration.SendTraceEvents = config.SendTraceEvents;
_enrollmentTracker?.UpdateSendTraceEvents(config.SendTraceEvents);
@@ -26,8 +26,10 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
/// Launches the specified executable in the logged-in user's desktop session.
/// Returns true if the process was launched successfully, false otherwise.
/// This is fire-and-forget — the caller does not wait for the process to exit.
/// If the process fails with 0x8007045A (desktop locked by credential UI),
/// retries for up to maxRetrySeconds.
/// </summary>
public static bool LaunchInUserSession(string exePath, string arguments, AgentLogger logger)
public static bool LaunchInUserSession(string exePath, string arguments, AgentLogger logger, int maxRetrySeconds = 120)
{
IntPtr userToken = IntPtr.Zero;
IntPtr environment = IntPtr.Zero;
@@ -89,45 +91,114 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
var threadSa = new NativeMethods.SECURITY_ATTRIBUTES();
threadSa.nLength = Marshal.SizeOf(threadSa);
if (!NativeMethods.CreateProcessAsUser(
userToken,
null, // lpApplicationName (null, use command line)
commandLine, // lpCommandLine
ref procSa,
ref threadSa,
false, // bInheritHandles
creationFlags,
environment,
workingDirectory,
ref si,
out var pi))
// During Autopilot enrollment the user's desktop may be locked by the
// CloudExperienceHost (CXH) process which hosts ESP + Windows Hello.
// GUI processes fail with 0x8007045A (DLL init) when the desktop isn't accessible.
//
// Strategy: retry loop with integrated CXH monitoring.
// - On 24H2+ and ≤22H2: CXH exits cleanly after Hello → we wake up immediately
// (Process.WaitForExit instead of blind Thread.Sleep).
// - On 23H2: CXH stays running even after Hello/desktop → WaitForExit times out
// after retryInterval, behaves like a normal retry sleep.
// This gives us instant wake-up when CXH cooperates, and a reliable fallback
// when it doesn't — all within a single unified loop.
const int retryIntervalSeconds = 10;
var retryStopwatch = System.Diagnostics.Stopwatch.StartNew();
int attempt = 0;
// One-time CXH lookup — track for the lifetime of the retry loop
Process cxhProcess = null;
if (maxRetrySeconds > 0)
{
logger.Warning($"UserSessionProcessLauncher: CreateProcessAsUser failed (error {Marshal.GetLastWin32Error()})");
return false;
try
{
cxhProcess = FindProcessInSession("CloudExperienceHost", sessionId);
if (cxhProcess != null)
logger.Info($"UserSessionProcessLauncher: CloudExperienceHost PID {cxhProcess.Id} running " +
$"in session {sessionId} — will monitor for exit during retries");
}
catch { }
}
// Close the thread handle — we don't need it
NativeMethods.CloseHandle(pi.hThread);
logger.Info($"UserSessionProcessLauncher: Created process PID {pi.dwProcessId} in user session — verifying startup...");
// Verify the process actually started (CLR initialized, no immediate crash).
// If the process dies within 3 seconds, the exit code tells us exactly why
// (e.g. 0xC0000135 = DLL not found, 0xC000007B = invalid image format,
// 0xE0434352 = .NET unhandled exception, 0x8007045A = DLL init failed).
var waitResult = NativeMethods.WaitForSingleObject(pi.hProcess, 3000);
if (waitResult != NativeMethods.WAIT_TIMEOUT)
try
{
while (true)
{
attempt++;
if (!NativeMethods.CreateProcessAsUser(
userToken,
null, // lpApplicationName (null, use command line)
commandLine, // lpCommandLine
ref procSa,
ref threadSa,
false, // bInheritHandles
creationFlags,
environment,
workingDirectory,
ref si,
out var pi))
{
logger.Warning($"UserSessionProcessLauncher: CreateProcessAsUser failed (error {Marshal.GetLastWin32Error()})");
return false;
}
NativeMethods.CloseHandle(pi.hThread);
logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — created PID {pi.dwProcessId}, verifying startup...");
// Wait 3s to verify the process survives DLL init + CLR bootstrap.
var waitResult = NativeMethods.WaitForSingleObject(pi.hProcess, 3000);
if (waitResult == NativeMethods.WAIT_TIMEOUT)
{
// Process is still running after 3s — success
NativeMethods.CloseHandle(pi.hProcess);
logger.Info($"UserSessionProcessLauncher: Successfully launched PID {pi.dwProcessId} in user session" +
(attempt > 1 ? $" (after {attempt} attempts, {retryStopwatch.Elapsed.TotalSeconds:F0}s)" : ""));
return true;
}
// Process exited within 3s — check why
NativeMethods.GetExitCodeProcess(pi.hProcess, out var exitCode);
NativeMethods.CloseHandle(pi.hProcess);
// 0x8007045A = ERROR_DLL_INIT_FAILED — desktop locked by credential UI.
// Retry until the desktop becomes accessible or we exceed the retry window.
if (exitCode == 0x8007045A && retryStopwatch.Elapsed.TotalSeconds < maxRetrySeconds)
{
// If CXH is tracked and still running, wait for its exit instead of blind sleep.
// On 24H2+/≤22H2: CXH exits → we wake up instantly.
// On 23H2: CXH never exits → WaitForExit times out → same as regular retry interval.
if (cxhProcess != null)
{
logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — 0x8007045A, waiting for CXH exit (up to {retryIntervalSeconds}s)...");
if (cxhProcess.WaitForExit(retryIntervalSeconds * 1000))
{
logger.Info("UserSessionProcessLauncher: CloudExperienceHost exited — desktop should be available");
cxhProcess.Dispose();
cxhProcess = null;
System.Threading.Thread.Sleep(500); // brief delay for desktop init
}
// else: CXH still running (23H2 behavior) — retry anyway
}
else
{
logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — 0x8007045A (desktop locked?) — retrying in {retryIntervalSeconds}s...");
System.Threading.Thread.Sleep(retryIntervalSeconds * 1000);
}
continue;
}
// Non-retryable error, or retry timeout exceeded
logger.Warning($"UserSessionProcessLauncher: Process PID {pi.dwProcessId} exited within 3s — " +
$"exit code 0x{exitCode:X8} (startup crash or missing dependency)");
$"exit code 0x{exitCode:X8}" +
(attempt > 1 ? $" (gave up after {attempt} attempts, {retryStopwatch.Elapsed.TotalSeconds:F0}s)" : ""));
return false;
}
NativeMethods.CloseHandle(pi.hProcess);
logger.Info($"UserSessionProcessLauncher: Successfully launched process PID {pi.dwProcessId} in user session");
return true;
}
finally
{
cxhProcess?.Dispose();
}
}
catch (Exception ex)
{
@@ -143,6 +214,41 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
}
}
/// <summary>
/// Finds a process by name running in a specific session.
/// Returns the first matching Process handle, or null if none found.
/// </summary>
private static Process FindProcessInSession(string processName, uint sessionId)
{
try
{
var processes = Process.GetProcessesByName(processName);
foreach (var proc in processes)
{
try
{
if ((uint)proc.SessionId == sessionId)
{
// Dispose the rest
foreach (var other in processes)
{
if (other.Id != proc.Id)
try { other.Dispose(); } catch { }
}
return proc;
}
proc.Dispose();
}
catch
{
proc.Dispose();
}
}
}
catch { }
return null;
}
/// <summary>
/// Finds an explorer.exe process running in a non-SYSTEM user session.
/// Returns the Process handle, or null if none found.
@@ -56,6 +56,15 @@
<Message Text="Auto-incremented build version to: $(Version)" Importance="high" />
</Target>
<!-- Generate version.json into build output for self-update and deployment upload -->
<Target Name="GenerateVersionJson" AfterTargets="Build">
<WriteLinesToFile
File="$(OutputPath)version.json"
Lines='{ "version": "$(Version)" }'
Overwrite="true" />
<Message Text="Generated version.json: $(Version)" Importance="high" />
</Target>
<ItemGroup>
<ProjectReference Include="..\AutopilotMonitor.Agent.Core\AutopilotMonitor.Agent.Core.csproj" />
<ProjectReference Include="..\..\Shared\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" />
@@ -65,6 +65,14 @@ namespace AutopilotMonitor.Agent
Console.WriteLine();
}
// Self-update: check for newer agent version in blob storage and apply if available.
// Priority: speed over update — 1s version check, 10s download timeout.
// Any failure → continue with current version (never block startup).
var agentDir = Environment.ExpandEnvironmentVariables(Constants.AgentDirectory);
SelfUpdater.CleanupPreviousUpdate(agentDir, msg => { if (consoleMode) Console.WriteLine(msg); });
SelfUpdater.CheckAndApplyUpdateAsync(GetAgentVersion(), agentDir, consoleMode).GetAwaiter().GetResult();
// If we reach here, no update was applied — continue normal startup
try
{
var config = LoadConfiguration(args);
@@ -0,0 +1,385 @@
using System;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using AutopilotMonitor.Shared;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Agent
{
/// <summary>
/// Fast self-update at agent startup: checks version.json in blob storage,
/// downloads the ZIP if newer, swaps files (rename-trick for locked binaries),
/// and restarts via PowerShell Wait-Process.
///
/// Design priority: speed over update — better to run the old version than delay startup.
/// </summary>
static class SelfUpdater
{
private const int VersionCheckTimeoutMs = 1000; // 1s — aggressive, skip if slow
private const int DownloadTimeoutMs = 10000; // 10s — abort if too slow
private const string OldFileSuffix = ".old";
/// <summary>
/// Deletes leftover .old files from a previous self-update.
/// Called early in startup before any other logic.
/// </summary>
public static void CleanupPreviousUpdate(string agentDir, Action<string> log)
{
try
{
if (!Directory.Exists(agentDir))
return;
foreach (var oldFile in Directory.GetFiles(agentDir, "*" + OldFileSuffix))
{
try
{
File.Delete(oldFile);
}
catch
{
// Best-effort: file may still be locked if previous process hasn't fully exited
}
}
// Also clean up any leftover staging directory
var stagingDir = Environment.ExpandEnvironmentVariables(Constants.AgentUpdateStagingDir);
if (Directory.Exists(stagingDir))
{
try { Directory.Delete(stagingDir, true); } catch { }
}
}
catch (Exception ex)
{
log?.Invoke($"Self-update cleanup warning: {ex.Message}");
}
}
/// <summary>
/// Checks for a newer agent version and applies the update if available.
/// On success, restarts the process and never returns.
/// On any failure, returns normally so the current version continues.
/// </summary>
public static async Task CheckAndApplyUpdateAsync(string currentVersion, string agentDir, bool consoleMode)
{
Action<string> log = msg =>
{
LogToFile(msg);
if (consoleMode) Console.WriteLine(msg);
};
try
{
// Step 1: Fetch version.json (1s timeout)
var latestVersion = await GetLatestVersionAsync(log);
if (latestVersion == null)
return; // Could not determine latest version — continue with current
// Step 2: Compare versions
if (!IsNewerVersion(currentVersion, latestVersion))
{
log($"Self-update: current version {currentVersion} is up to date (latest: {latestVersion})");
return;
}
log($"Self-update: newer version available — current={currentVersion}, latest={latestVersion}");
// Step 3: Download ZIP (10s timeout)
var zipPath = Path.Combine(Path.GetTempPath(), "AutopilotMonitor-Agent-Update.zip");
if (!await DownloadZipAsync(zipPath, log))
return;
// Step 4: Extract to staging directory
var stagingDir = Environment.ExpandEnvironmentVariables(Constants.AgentUpdateStagingDir);
if (!ExtractToStaging(zipPath, stagingDir, log))
return;
// Step 5: Validate staging
var stagedExe = Path.Combine(stagingDir, "AutopilotMonitor.Agent.exe");
if (!File.Exists(stagedExe))
{
log("Self-update: staging validation failed — AutopilotMonitor.Agent.exe not found in ZIP");
CleanupStaging(stagingDir, zipPath);
return;
}
// Step 6: Swap files (rename locked → .old, copy new)
if (!SwapFiles(agentDir, stagingDir, log))
{
CleanupStaging(stagingDir, zipPath);
return;
}
// Step 7: Clean up staging + temp ZIP
CleanupStaging(stagingDir, zipPath);
// Step 8: Restart via PowerShell Wait-Process
log($"Self-update: files swapped successfully, restarting agent (v{latestVersion})...");
RestartAgent(agentDir, log);
// RestartAgent calls Environment.Exit — we should never reach here
}
catch (Exception ex)
{
log($"Self-update: unexpected error — {ex.Message}. Continuing with current version.");
}
}
/// <summary>
/// Fetches version.json from blob storage and returns the version string.
/// Returns null if the check fails or times out.
/// </summary>
private static async Task<string> GetLatestVersionAsync(Action<string> log)
{
try
{
var versionUrl = $"{Constants.AgentBlobBaseUrl}/{Constants.AgentVersionFileName}";
using (var client = new WebClient())
{
var downloadTask = client.DownloadStringTaskAsync(versionUrl);
var timeoutTask = Task.Delay(VersionCheckTimeoutMs);
var completed = await Task.WhenAny(downloadTask, timeoutTask);
if (completed == timeoutTask)
{
log("Self-update: version check timed out (1s) — skipping update");
client.CancelAsync();
return null;
}
var json = await downloadTask;
var obj = JObject.Parse(json);
var version = obj["version"]?.ToString();
if (string.IsNullOrWhiteSpace(version))
{
log("Self-update: version.json has no 'version' field");
return null;
}
return version.Trim();
}
}
catch (WebException ex) when ((ex.Response as HttpWebResponse)?.StatusCode == HttpStatusCode.NotFound)
{
log("Self-update: version.json not found (404) — skipping update");
return null;
}
catch (Exception ex)
{
log($"Self-update: version check failed — {ex.Message}");
return null;
}
}
/// <summary>
/// Compares two version strings. Returns true if latest is newer than current.
/// </summary>
private static bool IsNewerVersion(string current, string latest)
{
if (!Version.TryParse(current, out var currentVer))
return false;
if (!Version.TryParse(latest, out var latestVer))
return false;
return latestVer > currentVer;
}
/// <summary>
/// Downloads the agent ZIP to a temp path. Returns false on failure.
/// </summary>
private static async Task<bool> DownloadZipAsync(string zipPath, Action<string> log)
{
try
{
var zipUrl = $"{Constants.AgentBlobBaseUrl}/{Constants.AgentZipFileName}";
// Clean up any previous download
if (File.Exists(zipPath))
File.Delete(zipPath);
using (var client = new WebClient())
{
var downloadTask = client.DownloadFileTaskAsync(zipUrl, zipPath);
var timeoutTask = Task.Delay(DownloadTimeoutMs);
var completed = await Task.WhenAny(downloadTask, timeoutTask);
if (completed == timeoutTask)
{
log("Self-update: ZIP download timed out (10s) — aborting update");
client.CancelAsync();
try { File.Delete(zipPath); } catch { }
return false;
}
await downloadTask; // Propagate any exception
}
log($"Self-update: ZIP downloaded ({new FileInfo(zipPath).Length / 1024}KB)");
return true;
}
catch (Exception ex)
{
log($"Self-update: ZIP download failed — {ex.Message}");
try { File.Delete(zipPath); } catch { }
return false;
}
}
/// <summary>
/// Extracts the ZIP to the staging directory. Returns false on failure.
/// </summary>
private static bool ExtractToStaging(string zipPath, string stagingDir, Action<string> log)
{
try
{
// Clean up any previous staging
if (Directory.Exists(stagingDir))
Directory.Delete(stagingDir, true);
ZipFile.ExtractToDirectory(zipPath, stagingDir);
log($"Self-update: ZIP extracted to staging ({Directory.GetFiles(stagingDir).Length} files)");
return true;
}
catch (Exception ex)
{
log($"Self-update: ZIP extraction failed — {ex.Message}");
return false;
}
}
/// <summary>
/// Swaps files from staging into the agent directory.
/// For locked files: rename to .old (Windows allows renaming locked files), then copy new.
/// Returns false if the swap fails critically.
/// </summary>
private static bool SwapFiles(string agentDir, string stagingDir, Action<string> log)
{
try
{
var stagedFiles = Directory.GetFiles(stagingDir);
int swapped = 0;
foreach (var stagedFile in stagedFiles)
{
var fileName = Path.GetFileName(stagedFile);
var targetPath = Path.Combine(agentDir, fileName);
try
{
if (File.Exists(targetPath))
{
// Try direct overwrite first
try
{
File.Copy(stagedFile, targetPath, overwrite: true);
swapped++;
continue;
}
catch (IOException)
{
// File is locked — use rename trick
}
// Rename locked file to .old (Windows allows this even for locked files)
var oldPath = targetPath + OldFileSuffix;
if (File.Exists(oldPath))
{
try { File.Delete(oldPath); } catch { }
}
File.Move(targetPath, oldPath);
}
File.Copy(stagedFile, targetPath);
swapped++;
}
catch (Exception ex)
{
log($"Self-update: failed to swap {fileName} — {ex.Message}");
// Continue with other files; partial update is better than no update
// for non-critical files (configs, etc.)
}
}
log($"Self-update: swapped {swapped}/{stagedFiles.Length} files");
// Critical check: the main exe must have been swapped
var mainExe = Path.Combine(agentDir, "AutopilotMonitor.Agent.exe");
if (!File.Exists(mainExe))
{
log("Self-update: CRITICAL — main exe missing after swap, aborting");
return false;
}
return true;
}
catch (Exception ex)
{
log($"Self-update: file swap failed — {ex.Message}");
return false;
}
}
/// <summary>
/// Restarts the agent using PowerShell Wait-Process (same pattern as SummaryDialog.SelfCleanup).
/// Wait-Process uses OS handles — zero polling, exits within milliseconds of process termination.
/// </summary>
private static void RestartAgent(string agentDir, Action<string> log)
{
var pid = Process.GetCurrentProcess().Id;
var agentExePath = Path.Combine(agentDir, "AutopilotMonitor.Agent.exe");
// PowerShell Wait-Process: waits for our process to actually exit (no polling),
// then immediately starts the new agent. 30s timeout as safety net.
var psScript = $"Wait-Process -Id {pid} -Timeout 30 -ErrorAction SilentlyContinue; " +
$"& '{agentExePath}'";
var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(psScript));
var psi = new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-NoProfile -NonInteractive -EncodedCommand {encoded}",
WindowStyle = ProcessWindowStyle.Hidden,
CreateNoWindow = true,
UseShellExecute = false
};
Process.Start(psi);
log("Self-update: restart script launched, exiting current process");
Environment.Exit(0);
}
/// <summary>
/// Cleans up staging directory and temp ZIP file.
/// </summary>
private static void CleanupStaging(string stagingDir, string zipPath)
{
try { if (Directory.Exists(stagingDir)) Directory.Delete(stagingDir, true); } catch { }
try { if (File.Exists(zipPath)) File.Delete(zipPath); } catch { }
}
/// <summary>
/// Appends a log line to the agent log file (logger isn't initialized yet at update time).
/// </summary>
private static void LogToFile(string message)
{
try
{
var logDir = Environment.ExpandEnvironmentVariables(Constants.LogDirectory);
Directory.CreateDirectory(logDir);
var logPath = Path.Combine(logDir, "agent.log");
File.AppendAllText(logPath, $"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}] INFO: {message}\r\n");
}
catch
{
// Best-effort logging — never block on log failure
}
}
}
}
@@ -1 +1 @@
347
358
@@ -150,6 +150,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
ShowEnrollmentSummary = tenantConfig.ShowEnrollmentSummary ?? false,
EnrollmentSummaryTimeoutSeconds = tenantConfig.EnrollmentSummaryTimeoutSeconds ?? 60,
EnrollmentSummaryBrandingImageUrl = tenantConfig.EnrollmentSummaryBrandingImageUrl,
EnrollmentSummaryLaunchRetrySeconds = tenantConfig.EnrollmentSummaryLaunchRetrySeconds ?? 120,
MaxBatchSize = tenantConfig.MaxBatchSize ?? 100,
DiagnosticsUploadEnabled = !string.IsNullOrEmpty(tenantConfig.DiagnosticsBlobSasUrl),
DiagnosticsUploadMode = tenantConfig.DiagnosticsUploadMode ?? "Off",
@@ -44,6 +44,35 @@ namespace AutopilotMonitor.Shared
/// </summary>
public const string ApiBaseUrl = "https://autopilotmonitor-api.azurewebsites.net";
// -----------------------------------------------------------------------
// Agent self-update
// -----------------------------------------------------------------------
/// <summary>
/// Base URL for agent binaries in Azure Blob Storage (public read)
/// </summary>
public const string AgentBlobBaseUrl = "https://autopilotmonitor.blob.core.windows.net/agent";
/// <summary>
/// Version manifest filename in blob storage (JSON: { "version": "1.0.350" })
/// </summary>
public const string AgentVersionFileName = "version.json";
/// <summary>
/// Agent ZIP filename in blob storage
/// </summary>
public const string AgentZipFileName = "AutopilotMonitor-Agent.zip";
/// <summary>
/// Staging directory for self-update extraction
/// </summary>
public const string AgentUpdateStagingDir = @"%ProgramData%\AutopilotMonitor\Agent-Update";
/// <summary>
/// Agent binary directory
/// </summary>
public const string AgentDirectory = @"%ProgramData%\AutopilotMonitor\Agent";
// -----------------------------------------------------------------------
// Upload / batching defaults
// -----------------------------------------------------------------------
@@ -106,6 +106,13 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public string EnrollmentSummaryBrandingImageUrl { get; set; }
/// <summary>
/// Maximum time in seconds the agent retries launching the enrollment summary dialog
/// when the user's desktop is locked by a credential UI (e.g. Windows Hello).
/// 0 = no retry (single attempt). Default: 120 (2 minutes).
/// </summary>
public int EnrollmentSummaryLaunchRetrySeconds { get; set; } = 120;
/// <summary>
/// Maximum number of events per upload batch.
/// Default: 100
@@ -242,6 +242,13 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public string EnrollmentSummaryBrandingImageUrl { get; set; }
/// <summary>
/// Maximum time in seconds the agent retries launching the enrollment summary dialog
/// when the user's desktop is locked by a credential UI (e.g. Windows Hello).
/// null = use agent default (120). 0 = no retry (single attempt).
/// </summary>
public int? EnrollmentSummaryLaunchRetrySeconds { get; set; }
/// <summary>
/// Whether to show PowerShell script stdout in the web UI.
/// When false, only stderr (error output) is visible for troubleshooting.
@@ -29,6 +29,8 @@ interface AgentSettingsSectionProps {
setEnrollmentSummaryTimeoutSeconds: (value: number) => void;
enrollmentSummaryBrandingImageUrl: string;
setEnrollmentSummaryBrandingImageUrl: (value: string) => void;
enrollmentSummaryLaunchRetrySeconds: number;
setEnrollmentSummaryLaunchRetrySeconds: (value: number) => void;
}
export default function AgentSettingsSection({
@@ -60,6 +62,8 @@ export default function AgentSettingsSection({
setEnrollmentSummaryTimeoutSeconds,
enrollmentSummaryBrandingImageUrl,
setEnrollmentSummaryBrandingImageUrl,
enrollmentSummaryLaunchRetrySeconds,
setEnrollmentSummaryLaunchRetrySeconds,
}: AgentSettingsSectionProps) {
return (
<>
@@ -302,6 +306,25 @@ export default function AgentSettingsSection({
</div>
</div>
{/* Launch Retry Timeout */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Launch Retry Timeout</p>
<p className="text-sm text-gray-500">How long the agent retries launching the dialog when the desktop is locked by a credential UI (e.g. Windows Hello). 0 = no retry.</p>
</div>
<div className="flex items-center gap-2 ml-4">
<input
type="number"
min={0}
max={3600}
value={enrollmentSummaryLaunchRetrySeconds}
onChange={(e) => setEnrollmentSummaryLaunchRetrySeconds(Math.min(3600, Math.max(0, parseInt(e.target.value) || 0)))}
className="w-20 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 text-right focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
<span className="text-sm text-gray-500 whitespace-nowrap">seconds</span>
</div>
</div>
{/* Branding Image URL */}
<div className="p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div className="mb-2">
@@ -62,6 +62,7 @@ export interface TenantConfiguration {
showEnrollmentSummary?: boolean;
enrollmentSummaryTimeoutSeconds?: number;
enrollmentSummaryBrandingImageUrl?: string;
enrollmentSummaryLaunchRetrySeconds?: number;
// Script output visibility
showScriptOutput?: boolean;
// Agent analyzer settings
@@ -141,6 +142,7 @@ export default function SettingsPage() {
const [showEnrollmentSummary, setShowEnrollmentSummary] = useState(false);
const [enrollmentSummaryTimeoutSeconds, setEnrollmentSummaryTimeoutSeconds] = useState(60);
const [enrollmentSummaryBrandingImageUrl, setEnrollmentSummaryBrandingImageUrl] = useState("");
const [enrollmentSummaryLaunchRetrySeconds, setEnrollmentSummaryLaunchRetrySeconds] = useState(120);
// Teams notifications state
const [teamsWebhookUrl, setTeamsWebhookUrl] = useState("");
@@ -185,6 +187,7 @@ export default function SettingsPage() {
showEnrollmentSummary !== (config.showEnrollmentSummary ?? false) ||
enrollmentSummaryTimeoutSeconds !== (config.enrollmentSummaryTimeoutSeconds ?? 60) ||
enrollmentSummaryBrandingImageUrl !== (config.enrollmentSummaryBrandingImageUrl ?? "") ||
enrollmentSummaryLaunchRetrySeconds !== (config.enrollmentSummaryLaunchRetrySeconds ?? 120) ||
teamsWebhookUrl !== (config.teamsWebhookUrl ?? "") ||
teamsNotifyOnSuccess !== (config.teamsNotifyOnSuccess ?? true) ||
teamsNotifyOnFailure !== (config.teamsNotifyOnFailure ?? true) ||
@@ -205,7 +208,8 @@ export default function SettingsPage() {
performanceCollectorInterval, helloWaitTimeoutSeconds, selfDestructOnComplete, keepLogFile,
rebootOnComplete, rebootDelaySeconds, enableGeoLocation, enableImeMatchLog,
logLevel, showScriptOutput, showEnrollmentSummary, enrollmentSummaryTimeoutSeconds,
enrollmentSummaryBrandingImageUrl, teamsWebhookUrl, teamsNotifyOnSuccess, teamsNotifyOnFailure,
enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds,
teamsWebhookUrl, teamsNotifyOnSuccess, teamsNotifyOnFailure,
diagnosticsBlobSasUrl, diagnosticsUploadMode, tenantDiagPaths,
enableLocalAdminAnalyzer, localAdminAllowedAccounts,
]);
@@ -250,6 +254,7 @@ export default function SettingsPage() {
setShowEnrollmentSummary(data.showEnrollmentSummary ?? false);
setEnrollmentSummaryTimeoutSeconds(data.enrollmentSummaryTimeoutSeconds ?? 60);
setEnrollmentSummaryBrandingImageUrl(data.enrollmentSummaryBrandingImageUrl ?? "");
setEnrollmentSummaryLaunchRetrySeconds(data.enrollmentSummaryLaunchRetrySeconds ?? 120);
setTeamsWebhookUrl(data.teamsWebhookUrl ?? "");
setTeamsNotifyOnSuccess(data.teamsNotifyOnSuccess ?? true);
setTeamsNotifyOnFailure(data.teamsNotifyOnFailure ?? true);
@@ -474,6 +479,7 @@ export default function SettingsPage() {
showEnrollmentSummary,
enrollmentSummaryTimeoutSeconds,
enrollmentSummaryBrandingImageUrl: enrollmentSummaryBrandingImageUrl || undefined,
enrollmentSummaryLaunchRetrySeconds,
teamsWebhookUrl: teamsWebhookUrl || undefined,
teamsNotifyOnSuccess,
teamsNotifyOnFailure,
@@ -1060,6 +1066,8 @@ export default function SettingsPage() {
setEnrollmentSummaryTimeoutSeconds={setEnrollmentSummaryTimeoutSeconds}
enrollmentSummaryBrandingImageUrl={enrollmentSummaryBrandingImageUrl}
setEnrollmentSummaryBrandingImageUrl={setEnrollmentSummaryBrandingImageUrl}
enrollmentSummaryLaunchRetrySeconds={enrollmentSummaryLaunchRetrySeconds}
setEnrollmentSummaryLaunchRetrySeconds={setEnrollmentSummaryLaunchRetrySeconds}
/>
)}
File diff suppressed because one or more lines are too long