using System; using System.IO; using System.Linq; using System.Net.Http; using System.Reflection; using System.Threading; using System.Threading.Tasks; using AutopilotMonitor.Agent.V2.Core.Configuration; using AutopilotMonitor.Agent.V2.Core.Logging; using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; using AutopilotMonitor.Agent.V2.Core.Orchestration; using AutopilotMonitor.Agent.V2.Core.Runtime; using AutopilotMonitor.Agent.V2.Core.Security; using AutopilotMonitor.Agent.V2.Core.Termination; using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; using AutopilotMonitor.DecisionCore.Classifiers; using AutopilotMonitor.DecisionCore.Engine; using AutopilotMonitor.DecisionCore.Signals; using AutopilotMonitor.DecisionCore.State; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models; namespace AutopilotMonitor.Agent.V2 { /// /// V2-Agent entry point. Plan §4.x M4.5.b + M4.6.α. /// /// Boot sequence (same order as Legacy for feature parity): /// /// /// --help / --version short-circuit /// --install forks to and exits /// Multi-instance guard — single-agent invariant /// Register ProcessExit → writes clean-exit.marker /// Ensure agent directories exist /// + /// Load cached remote-config.json → + AllowAgentDowngrade /// — on success restarts the process; on failure continues with current binary /// — reads markers + event log to classify last shutdown /// Resolve TenantId (registry → bootstrap-config.json fallback) /// Build (CLI args + persisted bootstrap / await-enrollment config) /// Get/create SessionId via /// — file-based enrollment-complete-marker detection + cleanup retry /// — absolute session-age watchdog /// (Optional) Wait for MDM certificate in --await-enrollment mode /// Build + → fetch config /// — H-2 mitigation post-cert /// Build mTLS → /// Build + /// orchestrator.Start() → emit -derived event /// Wait for Ctrl+C / ProcessExit / EnrollmentTerminated /// orchestrator.Stop(), exit 0 /// /// public static partial class Program { private const string DefaultStateDirectory = @"%ProgramData%\AutopilotMonitor"; private const string DefaultLogDirectory = @"%ProgramData%\AutopilotMonitor\Logs"; private const string DefaultAgentSubdirectory = "Agent"; private const string DefaultStateSubdirectory = "State"; private const string DefaultSpoolSubdirectory = "Spool"; private const string CachedRemoteConfigPath = @"%ProgramData%\AutopilotMonitor\Config\remote-config.json"; public static int Main(string[] args) { if (args.Contains("--help") || args.Contains("-h") || args.Contains("-?")) { PrintUsage(); return 0; } if (args.Contains("--version")) { PrintVersion(); return 0; } // --install forks to a separate flow that exits when done (never falls through into RunAgent). if (args.Contains("--install")) { return RunInstallMode(args); } // --run-gather-rules / --run-ime-matching: standalone diagnostic modes (M4.6.δ). // They neither touch the Scheduled Task nor the live agent's spool — safe to run // alongside a normal monitoring agent for troubleshooting. if (args.Contains("--run-gather-rules")) { return RunGatherRulesMode(args); } if (args.Contains("--run-ime-matching")) { return RunImeMatchingMode(args); } var consoleMode = args.Contains("--console") || Environment.UserInteractive; // Multi-instance guard — prevents a second agent process from running alongside one // that was started by the Scheduled Task. if (IsAnotherAgentInstanceRunning()) { var msg = "Another agent process is already running. This instance will exit."; if (consoleMode) Console.Error.WriteLine($"ERROR: {msg}"); try { var earlyLogger = new AgentLogger(Environment.ExpandEnvironmentVariables(DefaultLogDirectory)); earlyLogger.Warning(msg); } catch { /* best-effort */ } return 1; } var dataDirectory = Environment.ExpandEnvironmentVariables(DefaultStateDirectory); var logDirectory = Environment.ExpandEnvironmentVariables(DefaultLogDirectory); try { Directory.CreateDirectory(dataDirectory); } catch { } try { Directory.CreateDirectory(logDirectory); } catch { } // Register the clean-exit marker writer BEFORE any risky startup work so an OS // shutdown during self-update still produces a "clean" classification. RegisterCleanExitMarker(dataDirectory); // Register crash-dump handlers as early as possible — before SelfUpdate, before // any other startup work — so an unhandled exception anywhere downstream still // produces a MiniDump + CrashRecord. SessionId/TenantId are unknown at this point; // they're filled in later by AgentRuntimeHost via a second RegisterHandlers call. AutopilotMonitor.Agent.V2.Core.Diagnostics.CrashDumpCapture.RegisterHandlers( programDataDirectory: dataDirectory, sessionId: "(pre-runtime)", tenantId: "(pre-runtime)", agentVersion: GetAgentVersion()); // Startup self-update. Legacy parity: cleanup leftover .old files, load cached // backend hash / downgrade policy, then attempt the update. Failures here never // abort startup — we prefer to run the current version than delay. SelfUpdater.LogInit(GetAgentVersion()); var agentDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? string.Empty; SelfUpdater.CleanupPreviousUpdate(agentDir, msg => { if (consoleMode) Console.Out.WriteLine(msg); }); var allowAgentDowngrade = LoadCachedSelfUpdateContext(); try { SelfUpdater.CheckAndApplyUpdateAsync( currentVersion: GetAgentVersion(), agentDir: agentDir, consoleMode: consoleMode, allowDowngrade: allowAgentDowngrade).GetAwaiter().GetResult(); } catch (Exception selfUpdateEx) { // SelfUpdater is designed to swallow its own errors; catch anything that still // escapes so startup does not abort on an unexpected failure in the update path. SelfUpdater.Log($"Self-update outer exception: {selfUpdateEx.Message}"); } // At this point either (a) no update was applied, or (b) update applied but restart // didn't happen — continue startup with the current binary. var logger = new AgentLogger(logDirectory) { EnableConsoleOutput = consoleMode }; logger.Info($"AutopilotMonitor.Agent.V2 starting (version {GetAgentVersion()})."); // PR3-A3: empty args looked broken (`Command line: `). Make the no-args case explicit. var formattedArgs = FormatArgsForLog(args); logger.Info($"Command line: {(string.IsNullOrEmpty(formattedArgs) ? "(no args)" : formattedArgs)}"); try { return RunAgent(args, logger, dataDirectory, logDirectory, consoleMode); } catch (Exception ex) { logger.Error("V2 agent startup failed.", ex); WriteCrashLog(logDirectory, ex); if (consoleMode) Console.Error.WriteLine($"FATAL: {ex.Message}"); return 1; } } // ---------------------------------------------------------------- Orchestration private static int RunAgent( string[] args, AgentLogger logger, string dataDirectory, string logDirectory, bool consoleMode) { var stateSubdir = Path.Combine(dataDirectory, DefaultStateSubdirectory); var transportDir = Path.Combine(dataDirectory, DefaultSpoolSubdirectory); // Phase 1+2 (previous-exit, persisted-config, TenantId, AgentConfig, SessionId, // completion-marker / emergency-break guards, optional --await-enrollment cert wait) // is encapsulated in AgentBootstrap; see Runtime/AgentBootstrap.cs for the V1-parity // exit-code mapping (0 = guard handled, 2 = no TenantId, 3 = await timeout). var bootstrap = Runtime.AgentBootstrap.Run(args, logger, dataDirectory, logDirectory, stateSubdir, consoleMode); if (bootstrap.ShouldExit) { return bootstrap.ExitCode; } // Phase 3 (BackendApiClient + reporters + auth-failure tracker) is encapsulated // in BackendClientFactory; see Runtime/BackendClientFactory.cs. var auth = Runtime.BackendClientFactory.BuildAuthClients(bootstrap.AgentConfig, GetAgentVersion(), logger); // Phase 4 (RemoteConfig fetch + Merge + tracker/logger refresh + binary-integrity // verification + bootstrap-config cleanup) is encapsulated in AgentRuntimeConfig. // See Runtime/AgentRuntimeConfig.cs. var runtimeConfig = Runtime.AgentRuntimeConfig.Resolve(bootstrap.AgentConfig, auth, GetAgentVersion(), consoleMode, logger); // Control-channel kill-switch: a live-fetch DeviceKillSignal terminates the agent // BEFORE mTLS/registration spin-up. Covers agents the telemetry-channel kill can // never reach (paused drain, empty spool) at their next process start / boot. // Checked BEFORE any endpoint migration — a kill from the current backend is // terminal, not re-homed. if (CheckConfigKillSignal( runtimeConfig.RemoteConfig, runtimeConfig.RemoteConfigService.LastFetchOutcome, dataDirectory, stateSubdir, () => new CleanupService(bootstrap.AgentConfig, logger), logger, consoleMode)) { return 0; } // Endpoint migration (config-channel re-home): the backend served a validated // MigrateToApiBaseUrl on a live fetch. Rebuild Phase 3 clients against the new base // URL and re-run Phase 4 there (single hop — the second Resolve cannot migrate // again), then re-check the kill signal against the NEW backend's config. if (runtimeConfig.PendingMigrationTarget != null) { logger.Warning( $"Endpoint migration: backend re-homed this agent from {bootstrap.AgentConfig.ApiBaseUrl} " + $"to {runtimeConfig.PendingMigrationTarget} — rebuilding backend clients."); bootstrap.AgentConfig.ApiBaseUrl = runtimeConfig.PendingMigrationTarget; // Best-effort disposal of the old-URL client stack before it goes out of scope. try { auth.BackendApiClient?.Dispose(); } catch { } auth = Runtime.BackendClientFactory.BuildAuthClients(bootstrap.AgentConfig, GetAgentVersion(), logger); runtimeConfig = Runtime.AgentRuntimeConfig.Resolve( bootstrap.AgentConfig, auth, GetAgentVersion(), consoleMode, logger, allowEndpointMigration: false); if (CheckConfigKillSignal( runtimeConfig.RemoteConfig, runtimeConfig.RemoteConfigService.LastFetchOutcome, dataDirectory, stateSubdir, () => new CleanupService(bootstrap.AgentConfig, logger), logger, consoleMode)) { return 0; } } // Phase 5 (mTLS HttpClient + BackendTelemetryUploader) is encapsulated in // BackendClientFactory. Construction failures map to V1-parity exit codes // (4 = mTLS, 5 = uploader). See Runtime/BackendClientFactory.cs. var telemetry = Runtime.BackendClientFactory.BuildTelemetryClients(bootstrap.AgentConfig, auth, GetAgentVersion(), logger); if (telemetry.ShouldExit) { return telemetry.ExitCode; } // Phase 6 (POST /api/agent/register-session with retry + outcome-based exit-code // mapping + on-failure client disposal) is encapsulated in BackendSessionRegistration. // Exit codes: 6 = AuthFailed, 7 = anything else. See Runtime/BackendSessionRegistration.cs. var registration = Runtime.BackendSessionRegistration.Register( bootstrap.AgentConfig, auth, telemetry.MtlsHttpClient, GetAgentVersion(), consoleMode, logger, rotateSession: () => Runtime.BackendSessionRegistration.RotateSession( bootstrap.AgentConfig, bootstrap.SessionPersistence, auth, transportDir, logger)); if (registration.ShouldExit) { return registration.ExitCode; } // Phase 6.5 (config self-heal): when the Phase 4 fetch ran before the network link // was up, registration has just proven the backend reachable — retry the fetch once // so the session runs on the live tenant config instead of cache/defaults. // See AgentRuntimeConfig.TryRecoverAfterRegistration for what is deliberately // NOT re-run here (kill-signal check, endpoint migration). Runtime.AgentRuntimeConfig.TryRecoverAfterRegistration( bootstrap.AgentConfig, auth, runtimeConfig, GetAgentVersion(), consoleMode, logger); // Phase 7+8 (orchestrator + shutdown lifecycle + onIngressReady wiring + // initial signal posts + analyzer/probes startup + shutdown.Wait + finally) // is encapsulated in AgentRuntimeHost. See Runtime/AgentRuntimeHost.cs. return Runtime.AgentRuntimeHost.Run( bootstrap: bootstrap, auth: auth, runtimeConfig: runtimeConfig, telemetry: telemetry, registration: registration, dataDirectory: dataDirectory, stateSubdir: stateSubdir, transportDir: transportDir, agentVersion: GetAgentVersion(), consoleMode: consoleMode, logger: logger); } // ---------------------------------------------------------------- Configuration internal static AgentConfiguration BuildAgentConfiguration( string[] args, string tenantId, string sessionId, BootstrapConfigFile bootstrapConfig, AwaitEnrollmentConfigFile awaitConfig) { var apiBaseUrl = GetArgValue(args, "--api-url", "--backend-api") ?? Constants.ApiBaseUrl; var imeLogPathOverride = GetArgValue(args, "--ime-log-path"); var imeMatchLogPath = GetArgValue(args, "--ime-match-log"); var gatherDebugLogPath = GetArgValue(args, "--gather-debug-log"); // Dev / test — IME log replay (V1 compat mode). Feeds ImeLogTracker.SimulationMode // + SpeedFactor so recorded raw IME logs are replayed at an accelerated rate; signal // timestamps + ingress ordinals are regenerated as the replay runs. var replayLogDir = GetArgValue(args, "--replay-log-dir"); var replaySpeedFactorRaw = GetArgValue(args, "--replay-speed-factor"); var replaySpeedFactor = 50.0; if (!string.IsNullOrEmpty(replaySpeedFactorRaw) && double.TryParse(replaySpeedFactorRaw, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var parsedSpeed) && parsedSpeed > 0) { replaySpeedFactor = parsedSpeed; } var bootstrapToken = GetArgValue(args, "--bootstrap-token") ?? bootstrapConfig?.BootstrapToken; var awaitEnrollment = args.Contains("--await-enrollment") || awaitConfig != null; var rebootOnComplete = args.Contains("--reboot-on-complete"); var disableGeoLocation = args.Contains("--disable-geolocation"); var keepLogFile = args.Contains("--keep-logfile"); var noCleanup = args.Contains("--no-cleanup"); var awaitTimeoutRaw = GetArgValue(args, "--await-enrollment-timeout"); var awaitTimeoutMinutes = 480; if (!string.IsNullOrEmpty(awaitTimeoutRaw) && int.TryParse(awaitTimeoutRaw, out var parsedTimeout)) awaitTimeoutMinutes = parsedTimeout; else if (awaitConfig != null) awaitTimeoutMinutes = awaitConfig.TimeoutMinutes; // TenantId wait — CLI wins over persisted bootstrap-config.json which wins // over the agent-side default (600 s). Hybrid-AAD-joined devices typically // need ~5 min for the AAD device cert to land in the registry; 600 s leaves // headroom. Pass `--tenant-id-wait 0` to opt out (legacy fast-fail). // A bootstrap-config.json whose TenantIdWaitSeconds is null (file predates the // property) is "not configured", NOT an opt-out — the default must still apply. var tenantIdWaitRaw = GetArgValue(args, "--tenant-id-wait"); const int tenantIdWaitDefaultSeconds = 600; int tenantIdWaitSeconds = tenantIdWaitDefaultSeconds; if (!string.IsNullOrEmpty(tenantIdWaitRaw) && int.TryParse(tenantIdWaitRaw, out var parsedTenantIdWait)) tenantIdWaitSeconds = parsedTenantIdWait; else if (bootstrapConfig?.TenantIdWaitSeconds != null) tenantIdWaitSeconds = bootstrapConfig.TenantIdWaitSeconds.Value; var useBootstrapTokenAuth = !string.IsNullOrEmpty(bootstrapToken); var cliLogLevel = GetArgValue(args, "--log-level"); var logLevel = AgentLogLevel.Info; if (!string.IsNullOrEmpty(cliLogLevel) && Enum.TryParse(cliLogLevel, ignoreCase: true, out var parsedLevel)) logLevel = parsedLevel; return new AgentConfiguration { ApiBaseUrl = apiBaseUrl, SessionId = sessionId, TenantId = tenantId, SpoolDirectory = Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory), LogDirectory = Environment.ExpandEnvironmentVariables(Constants.LogDirectory), UploadIntervalSeconds = Constants.DefaultUploadIntervalSeconds, MaxBatchSize = Constants.MaxBatchSize, LogLevel = logLevel, UseClientCertAuth = !useBootstrapTokenAuth, BootstrapToken = bootstrapToken, UseBootstrapTokenAuth = useBootstrapTokenAuth, AwaitEnrollment = awaitEnrollment, AwaitEnrollmentTimeoutMinutes = awaitTimeoutMinutes, TenantIdWaitSeconds = tenantIdWaitSeconds, RebootOnComplete = rebootOnComplete, EnableGeoLocation = !disableGeoLocation, ImeLogPathOverride = imeLogPathOverride, ImeMatchLogPath = imeMatchLogPath, GatherRuleDebugLogPath = gatherDebugLogPath, KeepLogFile = keepLogFile, SelfDestructOnComplete = !noCleanup, CommandLineArgs = FormatArgsForLog(args), ReplayLogDir = replayLogDir, ReplaySpeedFactor = replaySpeedFactor, }; } private static bool LoadCachedSelfUpdateContext() { try { var path = Environment.ExpandEnvironmentVariables(CachedRemoteConfigPath); if (!File.Exists(path)) return false; var json = File.ReadAllText(path); var cached = Newtonsoft.Json.JsonConvert.DeserializeObject(json); if (cached == null) return false; if (!string.IsNullOrEmpty(cached.LatestAgentSha256)) { SelfUpdater.BackendExpectedSha256 = cached.LatestAgentSha256; SelfUpdater.Log( $"Self-update: loaded backend integrity hash from cached config (sha256={cached.LatestAgentSha256.Substring(0, Math.Min(12, cached.LatestAgentSha256.Length))}...)"); } return cached.AllowAgentDowngrade; } catch (Exception ex) { SelfUpdater.Log($"Self-update: cached config read failed: {ex.Message}"); return false; } } internal static string GetArgValue(string[] args, params string[] names) { if (args == null || args.Length < 2 || names == null) return null; for (int i = 0; i < args.Length - 1; i++) { foreach (var name in names) { if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)) return args[i + 1]; } } return null; } private static string FormatArgsForLog(string[] args) { if (args == null || args.Length == 0) return string.Empty; var parts = new System.Collections.Generic.List(args.Length); for (int i = 0; i < args.Length; i++) { var a = args[i]; if (string.Equals(a, "--bootstrap-token", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length) { parts.Add(a); parts.Add("[redacted]"); i++; continue; } parts.Add(a); } return string.Join(" ", parts); } // ---------------------------------------------------------------- Info private static void PrintUsage() { var v = GetAgentVersion(); Console.Out.WriteLine($"Autopilot Monitor Agent V2 v{v}"); Console.Out.WriteLine(); Console.Out.WriteLine("Usage: AutopilotMonitor.Agent.exe [options]"); Console.Out.WriteLine(); Console.Out.WriteLine("Modes:"); Console.Out.WriteLine(" --install Deploy payload, create Scheduled Task, and start it"); Console.Out.WriteLine(" --tenant-id Tenant ID for bootstrap-config (used with --install)"); Console.Out.WriteLine(" (default) Run enrollment monitoring"); Console.Out.WriteLine(); Console.Out.WriteLine("General options:"); Console.Out.WriteLine(" --help, -h, -? Show this help message"); Console.Out.WriteLine(" --version Print version and exit"); Console.Out.WriteLine(" --console Enable console output (mirrors log to stdout)"); Console.Out.WriteLine(" --log-level Override log level (Info, Debug, Verbose, Trace)"); Console.Out.WriteLine(" --new-session Force a new session ID (delete persisted session)"); Console.Out.WriteLine(" --keep-logfile Preserve log directory after self-destruct cleanup"); Console.Out.WriteLine(" --no-cleanup Disable self-destruct on enrollment completion"); Console.Out.WriteLine(" --reboot-on-complete Reboot the device after enrollment completes"); Console.Out.WriteLine(" --disable-geolocation Skip geo-location detection"); Console.Out.WriteLine(); Console.Out.WriteLine("Authentication:"); Console.Out.WriteLine(" --bootstrap-token Use bootstrap token auth (pre-MDM OOBE phase)"); Console.Out.WriteLine(); Console.Out.WriteLine("Await-enrollment mode:"); Console.Out.WriteLine(" --await-enrollment Wait for MDM certificate before starting monitoring"); Console.Out.WriteLine(" --await-enrollment-timeout Timeout in minutes (default: 480)"); Console.Out.WriteLine(); Console.Out.WriteLine("TenantId resolution:"); Console.Out.WriteLine(" --tenant-id-wait On miss, wait up to N seconds for the registry to"); Console.Out.WriteLine(" populate (RegistryWatcher on Enrollments + CloudDomainJoin)."); Console.Out.WriteLine(" 0 = disabled (legacy fast-fail). At --install time the value"); Console.Out.WriteLine(" is persisted to bootstrap-config.json so the Scheduled Task"); Console.Out.WriteLine(" picks it up on each run."); Console.Out.WriteLine(); Console.Out.WriteLine("Overrides:"); Console.Out.WriteLine(" --api-url Override backend API base URL (alias: --backend-api)"); Console.Out.WriteLine(" --ime-log-path Override IME logs directory"); Console.Out.WriteLine(" --ime-match-log Write matched IME log lines to file (debug)"); Console.Out.WriteLine(" --gather-debug-log Write gather-rule evaluation trace to file (debug)"); Console.Out.WriteLine(); Console.Out.WriteLine("Dev / Test:"); Console.Out.WriteLine(" --replay-log-dir Replay IME logs from this directory (simulation mode)"); Console.Out.WriteLine(" --replay-speed-factor Time-compression factor for log replay (default: 50)"); } private static void PrintVersion() { Console.Out.WriteLine(GetAgentVersion()); } // internal (not private) so AgentBootstrap can stamp the best-effort emergency-break report // with the running agent version (tasks/enrollment-status-reclassification.md). internal static string GetAgentVersion() { try { var asm = Assembly.GetExecutingAssembly(); var info = asm.GetCustomAttribute()?.InformationalVersion; if (!string.IsNullOrWhiteSpace(info)) return info; var file = asm.GetCustomAttribute()?.Version; if (!string.IsNullOrWhiteSpace(file)) return file; return asm.GetName().Version?.ToString() ?? "0.0.0.0"; } catch { return "0.0.0.0"; } } } }