diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/MonitoringServiceConfigValidationTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/MonitoringServiceConfigValidationTests.cs
new file mode 100644
index 00000000..55a725b5
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/MonitoringServiceConfigValidationTests.cs
@@ -0,0 +1,112 @@
+using System;
+using System.IO;
+using AutopilotMonitor.Agent.Core.Configuration;
+using AutopilotMonitor.Agent.Core.Logging;
+using AutopilotMonitor.Agent.Core.Monitoring.Runtime;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.Core.Tests.Core
+{
+ ///
+ /// Verifies that MonitoringService rejects an incomplete AgentConfiguration with a
+ /// diagnostic message that names the missing fields. Customer-impact regression
+ /// (gardnermedia, 2026-04-30): a TenantId-less startup crashed with an opaque
+ /// "Invalid agent configuration" message that defied forensic analysis.
+ ///
+ public class MonitoringServiceConfigValidationTests : IDisposable
+ {
+ private readonly string _tempDir;
+
+ public MonitoringServiceConfigValidationTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "autopilot-cfg-validation-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_tempDir, true); } catch { }
+ }
+
+ private AgentConfiguration BuildValidConfig() => new AgentConfiguration
+ {
+ ApiBaseUrl = "https://example.invalid",
+ SessionId = Guid.NewGuid().ToString(),
+ TenantId = Guid.NewGuid().ToString(),
+ SpoolDirectory = Path.Combine(_tempDir, "spool"),
+ LogDirectory = Path.Combine(_tempDir, "logs")
+ };
+
+ private AgentLogger BuildLogger() => new AgentLogger(Path.Combine(_tempDir, "logs"));
+
+ [Fact]
+ public void Constructor_throws_with_field_name_when_TenantId_missing()
+ {
+ var config = BuildValidConfig();
+ config.TenantId = null;
+
+ var ex = Assert.Throws(
+ () => new MonitoringService(config, BuildLogger(), agentVersion: "test"));
+
+ Assert.Contains("missing", ex.Message);
+ Assert.Contains(nameof(AgentConfiguration.TenantId), ex.Message);
+ }
+
+ [Fact]
+ public void Constructor_throws_with_field_name_when_SessionId_missing()
+ {
+ var config = BuildValidConfig();
+ config.SessionId = "";
+
+ var ex = Assert.Throws(
+ () => new MonitoringService(config, BuildLogger(), agentVersion: "test"));
+
+ Assert.Contains(nameof(AgentConfiguration.SessionId), ex.Message);
+ }
+
+ [Fact]
+ public void Constructor_throws_with_field_name_when_ApiBaseUrl_missing()
+ {
+ var config = BuildValidConfig();
+ config.ApiBaseUrl = null;
+
+ var ex = Assert.Throws(
+ () => new MonitoringService(config, BuildLogger(), agentVersion: "test"));
+
+ Assert.Contains(nameof(AgentConfiguration.ApiBaseUrl), ex.Message);
+ }
+
+ [Fact]
+ public void Constructor_throws_with_all_field_names_when_multiple_missing()
+ {
+ var config = new AgentConfiguration
+ {
+ ApiBaseUrl = null,
+ SessionId = null,
+ TenantId = null,
+ SpoolDirectory = Path.Combine(_tempDir, "spool"),
+ LogDirectory = Path.Combine(_tempDir, "logs")
+ };
+
+ var ex = Assert.Throws(
+ () => new MonitoringService(config, BuildLogger(), agentVersion: "test"));
+
+ Assert.Contains(nameof(AgentConfiguration.ApiBaseUrl), ex.Message);
+ Assert.Contains(nameof(AgentConfiguration.SessionId), ex.Message);
+ Assert.Contains(nameof(AgentConfiguration.TenantId), ex.Message);
+ }
+
+ [Fact]
+ public void Constructor_message_does_not_change_for_valid_config()
+ {
+ // Sanity: a fully-populated config should NOT throw the validation exception.
+ // It may throw later (cert load, spool init) — we only assert the validation
+ // path itself does not fire on a complete config.
+ var config = BuildValidConfig();
+
+ var ex = Record.Exception(() => new MonitoringService(config, BuildLogger(), agentVersion: "test"));
+ if (ex is InvalidOperationException ioe)
+ Assert.DoesNotContain("Invalid agent configuration:", ioe.Message);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/MonitoringService.cs
index b6804718..69854fdc 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/MonitoringService.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/MonitoringService.cs
@@ -101,9 +101,14 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Runtime
_previousCrashException = previousCrashException;
_lastBootTimeUtc = lastBootTimeUtc;
- if (!_configuration.IsValid())
+ var missingFields = new List();
+ if (string.IsNullOrEmpty(_configuration.ApiBaseUrl)) missingFields.Add(nameof(_configuration.ApiBaseUrl));
+ if (string.IsNullOrEmpty(_configuration.SessionId)) missingFields.Add(nameof(_configuration.SessionId));
+ if (string.IsNullOrEmpty(_configuration.TenantId)) missingFields.Add(nameof(_configuration.TenantId));
+ if (missingFields.Count > 0)
{
- throw new InvalidOperationException("Invalid agent configuration");
+ throw new InvalidOperationException(
+ "Invalid agent configuration: missing " + string.Join(", ", missingFields));
}
_spool = new EventSpool(_configuration.SpoolDirectory);
diff --git a/src/Agent/AutopilotMonitor.Agent/Program.cs b/src/Agent/AutopilotMonitor.Agent/Program.cs
index bec4aa02..f92ec6ac 100644
--- a/src/Agent/AutopilotMonitor.Agent/Program.cs
+++ b/src/Agent/AutopilotMonitor.Agent/Program.cs
@@ -255,6 +255,20 @@ namespace AutopilotMonitor.Agent
}
}
+ // Pre-flight: bail cleanly if TenantId is missing instead of crashing inside
+ // MonitoringService.IsValid(). Mirrors V2's AgentBootstrap guard. Common cause:
+ // agent scheduled before MDM enrollment populated HKLM\SOFTWARE\Microsoft\Enrollments
+ // and no bootstrap-config.json present. The Scheduled Task will retry on next trigger.
+ if (string.IsNullOrEmpty(config.TenantId))
+ {
+ logger.Error("Agent cannot start: TenantId not available (registry empty + no bootstrap config). " +
+ "This usually means the agent was scheduled before MDM enrollment populated the registry. " +
+ "Will retry on next scheduled trigger.");
+ if (consoleMode)
+ Console.WriteLine("ERROR: TenantId not available. Agent will exit cleanly (will retry on next trigger).");
+ return;
+ }
+
// Evaluate previous exit signals before starting the monitoring service.
// clean-exit.marker present → previous run exited cleanly (OS shutdown, reboot, normal exit)
// crash_*.log present → unhandled exception crash (with stack trace)
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index 9e3410c0..9d63033f 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-1219
+1222