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.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 — before the guards, matching Legacy boot order. The ghost-restart guard keys on session.id absence to detect "self-destruct ran but Scheduled Task survived"; creating the session after the guard would misdiagnose every first-run-after-install as a ghost restart and trigger self-destruct before the remote-config fetch.
/// - — ghost-restart 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 DefaultV2StateSubdirectory = "V2";
private const string DefaultTransportSubdirectory = "V2Transport";
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);
// 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()}).");
logger.Info($"Command line: {FormatArgsForLog(args)}");
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, DefaultV2StateSubdirectory);
var transportDir = Path.Combine(dataDirectory, DefaultTransportSubdirectory);
// Previous-exit classification (for the agent_started event + observability).
var previousExit = DetectPreviousExit(dataDirectory, logDirectory);
if (previousExit.ExitType != "first_run")
{
var crashSuffix = previousExit.CrashExceptionType != null ? $" ({previousExit.CrashExceptionType})" : "";
logger.Info($"Previous exit: {previousExit.ExitType}{crashSuffix}");
}
// Merge persisted bootstrap-config.json + await-enrollment.json into CLI args early.
var bootstrapConfig = TryReadBootstrapConfig(dataDirectory, logger);
var awaitConfig = TryReadAwaitEnrollmentConfig(dataDirectory, logger);
var tenantIdFromRegistry = TenantIdResolver.ResolveFromEnrollmentRegistry(logger);
var tenantId = !string.IsNullOrEmpty(tenantIdFromRegistry)
? tenantIdFromRegistry
: bootstrapConfig?.TenantId;
// --install time may have written a bootstrap config that holds a tenantId even when
// the registry is not yet populated (bootstrap token path). Honour that.
var agentConfig = BuildAgentConfiguration(args, tenantId, sessionId: null, bootstrapConfig, awaitConfig);
// Create/recover SessionId BEFORE the startup guards. Legacy-parity boot order —
// the ghost-restart guard below keys on session.id absence to detect
// "self-destruct ran but Scheduled Task survived". Creating the session after the
// guard would misdiagnose every first-run-after-install (Deployed registry marker
// set by --install, no session.id yet) as a ghost restart and trigger
// ExecuteSelfDestruct before the remote-config fetch, deleting the ProgramData
// directory on every fresh deployment.
if (args.Contains("--new-session"))
{
new SessionIdPersistence(dataDirectory).Delete(logger);
logger.Info("--new-session: cleared persisted SessionId.");
}
agentConfig.SessionId = new SessionIdPersistence(dataDirectory).GetOrCreate(logger);
// Build a cleanup-service factory used by guards: instantiated lazily and with the
// current agentConfig so command-line overrides (e.g. --no-cleanup) take effect.
Func cleanupServiceFactory = () => new CleanupService(agentConfig, logger);
if (CheckEnrollmentCompleteMarker(
dataDirectory, stateSubdir,
agentConfig.SelfDestructOnComplete, cleanupServiceFactory, logger, consoleMode))
{
logger.Info("Enrollment-complete marker handled — agent exiting.");
return 0;
}
if (CheckSessionAgeEmergencyBreak(
dataDirectory, stateSubdir,
agentConfig.AbsoluteMaxSessionHours, agentConfig.SelfDestructOnComplete,
cleanupServiceFactory, logger, consoleMode))
{
logger.Info("Emergency break fired — agent exiting.");
return 0;
}
if (agentConfig.AwaitEnrollment)
{
logger.Info($"--await-enrollment: polling for MDM certificate (timeout: {agentConfig.AwaitEnrollmentTimeoutMinutes}min).");
using (var cts = new CancellationTokenSource())
{
var cert = EnrollmentAwaiter
.WaitForMdmCertificateAsync(
thumbprint: null,
timeoutMinutes: agentConfig.AwaitEnrollmentTimeoutMinutes,
logger: logger,
cancellationToken: cts.Token)
.GetAwaiter().GetResult();
if (cert == null)
{
logger.Error("Await-enrollment: timed out waiting for MDM certificate — exiting.");
return 3;
}
}
// Re-resolve TenantId — enrollment typically writes the registry key alongside the cert.
if (string.IsNullOrEmpty(agentConfig.TenantId))
{
agentConfig.TenantId = TenantIdResolver.ResolveFromEnrollmentRegistry(logger);
if (!string.IsNullOrEmpty(agentConfig.TenantId))
logger.Info($"Await-enrollment: TenantId discovered from registry: {agentConfig.TenantId}");
}
// Await-enrollment is one-shot — remove the persisted config so subsequent restarts proceed normally.
DeleteAwaitEnrollmentConfig(dataDirectory, logger);
}
if (string.IsNullOrEmpty(agentConfig.TenantId))
{
logger.Error("V2 agent cannot start: TenantId not available (registry empty + no bootstrap config).");
return 2;
}
var backendApiClient = new BackendApiClient(
baseUrl: agentConfig.ApiBaseUrl,
configuration: agentConfig,
logger: logger,
agentVersion: GetAgentVersion());
// M4.6.γ — Emergency + Distress reporters. Plumbed into RemoteConfigService so
// Config-fetch failures (auth vs network) flow to the correct channel. Also fires
// an initial AuthCertificateMissing distress when the MDM cert was expected but
// not found (Legacy parity — surfaces pre-MDM-enrollment dead-ends to the backend
// via the cert-less distress channel).
var hardwareForReporters = HardwareInfo.GetHardwareInfo(logger);
var distressReporter = new DistressReporter(
baseUrl: agentConfig.ApiBaseUrl,
tenantId: agentConfig.TenantId,
manufacturer: hardwareForReporters.Manufacturer,
model: hardwareForReporters.Model,
serialNumber: hardwareForReporters.SerialNumber,
agentVersion: GetAgentVersion(),
logger: logger);
var emergencyReporter = new EmergencyReporter(
apiClient: backendApiClient,
sessionId: agentConfig.SessionId,
tenantId: agentConfig.TenantId,
agentVersion: GetAgentVersion(),
logger: logger);
if (agentConfig.UseClientCertAuth && backendApiClient.ClientCertificate == null)
{
_ = distressReporter.TrySendAsync(
DistressErrorType.AuthCertificateMissing,
"MDM certificate not found in LocalMachine or CurrentUser store");
}
// Central observer for consecutive 401/403 responses. Initialised with the CLI/bootstrap
// defaults on AgentConfiguration; UpdateLimits is called after RemoteConfigMerger.Merge
// so tenant-policy overrides take effect. ThresholdExceeded is wired below, once the
// shutdown signal is available, to trigger a clean agent exit when the limit is hit.
var authFailureTracker = new AuthFailureTracker(
maxFailures: agentConfig.MaxAuthFailures,
timeoutMinutes: agentConfig.AuthFailureTimeoutMinutes,
clock: SystemClock.Instance,
logger: logger);
var remoteConfigService = new RemoteConfigService(
backendApiClient, agentConfig.TenantId, logger, emergencyReporter, distressReporter, authFailureTracker);
var remoteConfig = remoteConfigService.FetchConfigAsync().GetAwaiter().GetResult();
// Project remote tenant-controlled knobs onto the runtime AgentConfiguration so that
// downstream consumers (CleanupService, SummaryDialogLauncher, StartupEnvironmentProbes,
// DiagnosticsPackageService, EnrollmentTerminationHandler, the logger, the watchdog)
// actually respect the tenant admin settings. CLI flags stay authoritative over remote.
RemoteConfigMerger.Merge(agentConfig, remoteConfig, args);
// Refresh tracker ceilings with the tenant-specific values we just merged in.
authFailureTracker.UpdateLimits(agentConfig.MaxAuthFailures, agentConfig.AuthFailureTimeoutMinutes);
logger.SetLogLevel(agentConfig.LogLevel);
// Propagate the backend-expected SHA so the runtime hash-mismatch trigger (M4.6.α
// continues this wire; actual runtime trigger will be wired via ServerActionDispatcher
// in M4.6.β) has the up-to-date integrity hash. Also refresh AllowAgentDowngrade.
if (!string.IsNullOrEmpty(remoteConfig.LatestAgentSha256))
SelfUpdater.BackendExpectedSha256 = remoteConfig.LatestAgentSha256;
// Post-config binary-integrity check: verify the running EXE's SHA-256 against the
// value advertised by the backend. Mismatch does not terminate (SelfUpdater's own
// hash-based trigger covers the "wrong EXE" case at next startup) — we just emit an
// IntegrityCheckFailed error report so operators see the drift.
var integrityResult = BinaryIntegrityVerifier.Check(remoteConfig.LatestAgentExeSha256, logger);
if (integrityResult.IsMismatch)
{
_ = emergencyReporter.TrySendAsync(
AgentErrorType.IntegrityCheckFailed,
$"Running exe SHA-256 differs from backend-advertised hash. actual={integrityResult.ActualSha256}, expected={integrityResult.ExpectedSha256}");
}
// H-2 mitigation: delete the persisted bootstrap-config.json once the MDM cert
// proves it can authenticate. Non-blocking — any failure leaves the file for retry.
try
{
BootstrapConfigCleanup
.TryDeleteIfCertReadyAsync(agentConfig, logger, GetAgentVersion())
.GetAwaiter().GetResult();
}
catch (Exception ex)
{
logger.Debug($"BootstrapConfigCleanup outer exception: {ex.Message}");
}
HttpClient mtlsHttpClient;
BackendTelemetryUploader uploader;
try
{
mtlsHttpClient = MtlsHttpClientFactory.Create(
resolver: new DefaultCertificateResolver(),
logger: logger);
}
catch (InvalidOperationException ex)
{
logger.Error("mTLS HttpClient creation failed — cannot upload telemetry.", ex);
return 4;
}
try
{
uploader = new BackendTelemetryUploader(
httpClient: mtlsHttpClient,
baseUrl: agentConfig.ApiBaseUrl,
tenantId: agentConfig.TenantId,
manufacturer: hardwareForReporters.Manufacturer,
model: hardwareForReporters.Model,
serialNumber: hardwareForReporters.SerialNumber,
bootstrapToken: agentConfig.UseBootstrapTokenAuth ? agentConfig.BootstrapToken : null,
agentVersion: GetAgentVersion(),
authFailureTracker: authFailureTracker);
}
catch (Exception ex)
{
logger.Error("BackendTelemetryUploader construction failed.", ex);
return 5;
}
var classifiers = new IClassifier[]
{
new WhiteGloveSealingClassifier(),
new WhiteGlovePart2CompletionClassifier(),
};
var componentFactory = new DefaultComponentFactory(
agentConfig: agentConfig,
remoteConfig: remoteConfig,
networkMetrics: backendApiClient.NetworkMetrics,
agentVersion: GetAgentVersion(),
stateDirectory: stateSubdir);
var whiteGloveSealingPatternIds = (System.Collections.Generic.IReadOnlyCollection)remoteConfig.WhiteGloveSealingPatternIds
?? Array.Empty();
var agentMaxLifetime = agentConfig.AgentMaxLifetimeMinutes > 0
? (TimeSpan?)TimeSpan.FromMinutes(agentConfig.AgentMaxLifetimeMinutes)
: null;
// Diagnostics upload delegate — wraps the production DiagnosticsPackageService.
// Instantiated lazily + per-invocation (cheap) so we always pick up current config.
var diagnosticsService = new DiagnosticsPackageService(agentConfig, logger, backendApiClient);
Func> uploadDiagnosticsAsync =
(succeeded, suffix) => diagnosticsService.CreateAndUploadAsync(succeeded, suffix);
var agentStartTimeUtc = DateTime.UtcNow;
using (var orchestrator = new EnrollmentOrchestrator(
sessionId: agentConfig.SessionId,
tenantId: agentConfig.TenantId,
stateDirectory: stateSubdir,
transportDirectory: transportDir,
clock: SystemClock.Instance,
logger: logger,
uploader: uploader,
classifiers: classifiers,
componentFactory: componentFactory,
whiteGloveSealingPatternIds: whiteGloveSealingPatternIds,
agentMaxLifetime: agentMaxLifetime))
{
using (var shutdown = new ManualResetEventSlim(false))
{
// M4.6.δ — Analyzer manager. Emits through the live orchestrator's event
// emitter. RunStartup fires after orchestrator.Start; RunShutdown is wired
// into the termination handler so it runs before diagnostics upload.
var analyzerManager = new AgentAnalyzerManager(
configuration: agentConfig,
logger: logger,
emitEvent: evt => { orchestrator.EventEmitter.Emit(evt); },
analyzerConfig: remoteConfig.Analyzers);
// M4.6.β — the peripheral termination sequence (FinalStatus + SummaryDialog
// + diagnostics upload + enrollment-complete.marker + CleanupService) lives
// in Program.cs, not in the kernel. We compose it here and hook it onto the
// orchestrator's typed Terminated event.
var terminationHandler = new EnrollmentTerminationHandler(
configuration: agentConfig,
logger: logger,
stateDirectory: stateSubdir,
agentStartTimeUtc: agentStartTimeUtc,
currentStateAccessor: () => orchestrator.CurrentState,
packageStatesAccessor: () => componentFactory.ImePackageStates,
cleanupServiceFactory: () => new CleanupService(agentConfig, logger),
uploadDiagnosticsAsync: uploadDiagnosticsAsync,
signalShutdown: () => shutdown.Set(),
analyzerManager: analyzerManager);
// ServerActionDispatcher — handlers are live and the M4.6.ε response-parse
// path below feeds it. RotateConfig refetches remote config;
// RequestDiagnostics triggers the same diagnostics pipeline as enrollment-end;
// TerminateSession routes to the termination handler (reason: server-requested).
var serverActionDispatcher = new ServerActionDispatcher(
configuration: agentConfig,
logger: logger,
rotateConfigAsync: async () =>
{
try { var _ = await remoteConfigService.FetchConfigAsync(); return true; }
catch (Exception ex) { logger.Warning($"ServerAction rotate_config failed: {ex.Message}"); return false; }
},
uploadDiagnosticsAsync: async (suffix) =>
await diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded: false, fileNameSuffix: suffix),
onTerminateRequested: action =>
{
var forceSelfDestruct = action?.Params != null
&& action.Params.TryGetValue("forceSelfDestruct", out var f)
&& string.Equals(f, "true", StringComparison.OrdinalIgnoreCase);
if (forceSelfDestruct && !agentConfig.SelfDestructOnComplete)
{
logger.Warning("ServerAction terminate_session: forceSelfDestruct=true overrides SelfDestructOnComplete=false.");
agentConfig.SelfDestructOnComplete = true;
}
// Codex Finding 2: forward adminOutcome from the ServerAction params so
// a portal Mark-Succeeded really lands as Succeeded locally (was
// hard-coded to Failed before, masquerading every admin override as an
// error in SummaryDialog + firing spurious diagnostics uploads).
var mappedOutcome = MapAdminOutcome(action?.Params);
logger.Warning($"ServerAction terminate_session received (ruleId={action?.RuleId}, reason={action?.Reason}, outcome={mappedOutcome}) — invoking termination handler.");
// Synthesise a Terminated event as if the kernel fired it.
terminationHandler.Handle(
sender: null,
args: new EnrollmentTerminatedEventArgs(
reason: EnrollmentTerminationReason.DecisionTerminalStage,
outcome: mappedOutcome,
stageName: orchestrator.CurrentState?.Stage.ToString(),
terminatedAtUtc: DateTime.UtcNow,
details: $"Server-requested termination: ruleId={action?.RuleId}, reason={action?.Reason}"));
return Task.CompletedTask;
},
emitEvent: evt => { orchestrator.EventEmitter.Emit(evt); });
ConsoleCancelEventHandler cancelHandler = (s, e) =>
{
e.Cancel = true;
logger.Info("Ctrl+C received — initiating graceful shutdown.");
shutdown.Set();
};
EventHandler processExitHandler = (s, e) =>
{
logger.Info("ProcessExit — initiating graceful shutdown.");
shutdown.Set();
};
Console.CancelKeyPress += cancelHandler;
AppDomain.CurrentDomain.ProcessExit += processExitHandler;
orchestrator.Terminated += terminationHandler.Handle;
// Auth-failure watchdog: when MaxAuthFailures / AuthFailureTimeoutMinutes
// is exceeded the agent must shut down cleanly instead of hammering a
// backend that has definitely said no. Event fires at most once.
EventHandler authThresholdHandler = (s, e) =>
{
logger.Error($"Auth-failure threshold exceeded ({e.Reason}) — initiating shutdown.");
shutdown.Set();
};
authFailureTracker.ThresholdExceeded += authThresholdHandler;
try
{
orchestrator.Start();
// M4.6.ε — BackendTelemetryUploader response-plumbing. The orchestrator parses
// DeviceBlocked / DeviceKillSignal / AdminAction / Actions out of the 2xx
// response body (see BackendTelemetryUploader.TryReadControlSignalsAsync) and
// raises ServerResponseReceived. We translate those into ServerActions and
// dispatch. Legacy parity with IngestEventsResponse synthesis in
// EventUploadOrchestrator.OnEventsUploaded().
//
// MUST be wired AFTER Start() — orchestrator.Transport throws
// InvalidOperationException before Start() because the
// TelemetryUploadOrchestrator is constructed inside Start() at step 311.
WireTelemetryServerResponse(orchestrator, serverActionDispatcher, logger);
// Emit the agent_version_check event now that the EventEmitter is alive.
// VersionCheckEventBuilder.TryBuild is a no-op when no markers are present.
EmitVersionCheckEventIfAny(orchestrator, agentConfig, logger);
// M4.6.δ — fire-and-forget startup analyzers (LocalAdmin / SoftwareInventory /
// IntegrityBypass). Runs on a background task inside AgentAnalyzerManager.
try { analyzerManager.RunStartup(); }
catch (Exception ex) { logger.Warning($"AnalyzerManager.RunStartup threw: {ex.Message}"); }
// M4.6.γ — fire-and-forget startup probes (geo / timezone / NTP). Runs on
// the ThreadPool so a slow network never delays the critical path.
_ = Task.Run(async () =>
{
try
{
await StartupEnvironmentProbes
.RunAsync(agentConfig, logger, orchestrator.EventEmitter)
.ConfigureAwait(false);
}
catch (Exception ex)
{
logger.Warning($"StartupEnvironmentProbes: outer exception: {ex.Message}");
}
});
logger.Info($"V2 agent runtime ready (session={agentConfig.SessionId}, tenant={agentConfig.TenantId}).");
if (consoleMode)
Console.Out.WriteLine("AutopilotMonitor.Agent.V2 running. Press Ctrl+C to stop.");
shutdown.Wait();
}
finally
{
Console.CancelKeyPress -= cancelHandler;
AppDomain.CurrentDomain.ProcessExit -= processExitHandler;
orchestrator.Terminated -= terminationHandler.Handle;
authFailureTracker.ThresholdExceeded -= authThresholdHandler;
try { orchestrator.Stop(); }
catch (Exception ex) { logger.Error("Orchestrator stop failed.", ex); }
try { mtlsHttpClient.Dispose(); } catch { }
try { backendApiClient.Dispose(); } catch { }
}
}
}
logger.Info("AutopilotMonitor.Agent.V2 stopped cleanly.");
return 0;
}
///
/// M4.6.ε — routes
/// control signals through the . Synthesises the
/// same terminate_session s that Legacy built in
/// EventUploadOrchestrator.OnEventsUploaded (kill-signal → force self-destruct;
/// admin-action → soft terminate respecting SelfDestructOnComplete).
///
private static void WireTelemetryServerResponse(
EnrollmentOrchestrator orchestrator,
ServerActionDispatcher dispatcher,
AgentLogger logger)
{
orchestrator.Transport.ServerResponseReceived += (sender, upload) =>
{
var toDispatch = new System.Collections.Generic.List();
if (upload.DeviceKillSignal)
{
logger.Warning("Backend signalled DeviceKillSignal — synthesising terminate_session (force self-destruct).");
toDispatch.Add(new ServerAction
{
Type = ServerActionTypes.TerminateSession,
Reason = "DeviceKillSignal from administrator",
QueuedAt = DateTime.UtcNow,
Params = new System.Collections.Generic.Dictionary
{
{ "forceSelfDestruct", "true" },
{ "gracePeriodSeconds", "0" },
{ "origin", "kill_signal" },
},
});
}
else if (!string.IsNullOrEmpty(upload.AdminAction))
{
logger.Warning($"Backend signalled AdminAction={upload.AdminAction} — synthesising terminate_session (soft).");
toDispatch.Add(new ServerAction
{
Type = ServerActionTypes.TerminateSession,
Reason = $"Admin marked session as {upload.AdminAction}",
QueuedAt = DateTime.UtcNow,
Params = new System.Collections.Generic.Dictionary
{
{ "adminOutcome", upload.AdminAction },
{ "gracePeriodSeconds", "0" },
{ "origin", "admin_action" },
},
});
}
if (upload.Actions != null && upload.Actions.Count > 0)
{
foreach (var a in upload.Actions) toDispatch.Add(a);
}
if (upload.DeviceBlocked)
{
// DeviceBlocked is non-terminal: the transport already paused its drain
// loop in TelemetryUploadOrchestrator.ApplyControlSignals; we just log it
// here so it surfaces on the console / agent log.
var until = upload.UnblockAt.HasValue ? $"until {upload.UnblockAt.Value:O}" : "indefinitely";
logger.Warning($"Backend signalled DeviceBlocked {until} — uploads paused, session remains alive.");
}
if (toDispatch.Count == 0) return;
try { dispatcher.DispatchAsync(toDispatch).GetAwaiter().GetResult(); }
catch (Exception ex) { logger.Error("ServerActionDispatcher.DispatchAsync threw during server-response wiring.", ex); }
};
}
private static void EmitVersionCheckEventIfAny(
EnrollmentOrchestrator orchestrator,
AgentConfiguration agentConfig,
AgentLogger logger)
{
try
{
var buildResult = VersionCheckEventBuilder.TryBuild(
sessionId: agentConfig.SessionId,
tenantId: agentConfig.TenantId,
agentStartTimeUtc: DateTime.UtcNow);
if (!string.IsNullOrEmpty(buildResult?.ParseError))
logger.Warning($"VersionCheckEventBuilder parse error: {buildResult.ParseError}");
if (buildResult?.Event != null)
{
orchestrator.EventEmitter.Emit(buildResult.Event);
logger.Info($"agent_version_check emitted (outcome={buildResult.Outcome}).");
}
else if (buildResult?.Deduped == true)
{
logger.Debug($"agent_version_check deduped (outcome={buildResult.Outcome}).");
}
}
catch (Exception ex)
{
logger.Warning($"VersionCheckEventBuilder emission failed: {ex.Message}");
}
}
// ---------------------------------------------------------------- Configuration
private 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 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;
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,
RebootOnComplete = rebootOnComplete,
EnableGeoLocation = !disableGeoLocation,
ImeLogPathOverride = imeLogPathOverride,
ImeMatchLogPath = imeMatchLogPath,
KeepLogFile = keepLogFile,
SelfDestructOnComplete = !noCleanup,
CommandLineArgs = FormatArgsForLog(args),
};
}
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.V2.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("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)");
}
private static void PrintVersion()
{
Console.Out.WriteLine(GetAgentVersion());
}
private 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";
}
}
}
}