diff --git a/scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1
index c070ba14..0653b5b4 100644
--- a/scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1
+++ b/scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1
@@ -295,9 +295,18 @@ try {
}
}
+ # TenantId-wait timeout (seconds). When the registry probe finds no TenantId on
+ # first try, the agent registers a RegistryWatcher on the Enrollments + CloudDomainJoin
+ # keys and waits up to this long for AAD/MDM enrollment to populate before bailing.
+ # 0 = no wait (legacy fast-fail). Increase here (and re-deploy via Intune) for tenants
+ # where hybrid join + auto-MDM enrollment regularly needs longer than 10 min.
+ # Field evidence (GardnerMedia, hybrid-AAD-joined, 2026-04-30): the AAD device cert
+ # landed ~5 min after the agent fired, so 600 s is comfortably sufficient.
+ $tenantIdWaitSec = 600
+
# Let the agent install/deploy itself and manage its own Scheduled Task
- Write-Log "Calling agent install mode (--install)..."
- & $agentExePath --install
+ Write-Log "Calling agent install mode (--install --tenant-id-wait $tenantIdWaitSec)..."
+ & $agentExePath --install --tenant-id-wait $tenantIdWaitSec
$installExitCode = $LASTEXITCODE
if ($installExitCode -ne 0) {
throw "Agent install failed with exit code $installExitCode"
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/RegistryWatcherIntegrationTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/RegistryWatcherIntegrationTests.cs
new file mode 100644
index 00000000..ad04cf72
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/RegistryWatcherIntegrationTests.cs
@@ -0,0 +1,168 @@
+using System;
+using System.Threading;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
+using Microsoft.Win32;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
+{
+ [CollectionDefinition(nameof(RegistryWatcherIntegrationTestCollection), DisableParallelization = true)]
+ public sealed class RegistryWatcherIntegrationTestCollection { }
+
+ ///
+ /// Real integration test against
+ /// HKCU\SOFTWARE\AutopilotMonitor.Tests\WatcherIntegration. Exercises the
+ /// Win32 RegNotifyChangeKeyValue plumbing end-to-end:
+ ///
+ /// - opens the watched key, registers async notification
+ /// - another thread writes a value / creates a sub-key
+ /// - watcher thread receives the kernel signal and raises Changed
+ ///
+ ///
+ /// HKCU is used (not HKLM) so the test runs without elevation — anyone with a user
+ /// session can write into their own hive. The watcher itself is hive-agnostic, so
+ /// proving the plumbing on HKCU also proves it on HKLM (where production watches
+ /// MDM enrollment + AAD-join keys).
+ ///
+ ///
+ /// All test data lives under the dedicated test sub-key and is deleted on teardown.
+ /// Tests run single-threaded — the 150 ms sleep that lets the watcher thread enter
+ /// RegNotifyChangeKeyValue before the test thread writes is unreliable on a
+ /// box busy with parallel xUnit work.
+ ///
+ ///
+ [Collection(nameof(RegistryWatcherIntegrationTestCollection))]
+ public sealed class RegistryWatcherIntegrationTests : IDisposable
+ {
+ private const string TestSubKey = @"SOFTWARE\AutopilotMonitor.Tests\WatcherIntegration";
+
+ public RegistryWatcherIntegrationTests()
+ {
+ // Ensure a clean slate — leftover state from a prior aborted run would skew
+ // the test (e.g. existing values changing the watcher's "what's new" notion).
+ CleanupTestKey();
+ using (var key = Registry.CurrentUser.CreateSubKey(TestSubKey))
+ {
+ Assert.NotNull(key);
+ }
+ }
+
+ public void Dispose() => CleanupTestKey();
+
+ private static void CleanupTestKey()
+ {
+ try
+ {
+ Registry.CurrentUser.DeleteSubKeyTree(TestSubKey, throwOnMissingSubKey: false);
+ }
+ catch
+ {
+ // Cleanup-best-effort — never fail the test on teardown noise.
+ }
+ }
+
+ [Fact]
+ public void Watcher_fires_when_value_is_written()
+ {
+ using var fired = new ManualResetEventSlim(false);
+
+ using var watcher = new RegistryWatcher(
+ hive: RegistryHive.CurrentUser,
+ subKey: TestSubKey,
+ watchSubtree: false,
+ view: RegistryView.Default,
+ filter: RegistryNativeMethods.RegChangeNotifyFilter.LastSet
+ | RegistryNativeMethods.RegChangeNotifyFilter.ThreadAgnostic,
+ trace: null);
+
+ watcher.Changed += (_, __) => fired.Set();
+ watcher.Start();
+
+ // Give the watcher a moment to enter RegNotifyChangeKeyValue. Without this,
+ // the write below can race ahead of the registration and the kernel won't
+ // record an "outstanding" change for our handle.
+ Thread.Sleep(150);
+
+ using (var key = Registry.CurrentUser.OpenSubKey(TestSubKey, writable: true))
+ {
+ Assert.NotNull(key);
+ key!.SetValue("ProbeValue", "hello");
+ }
+
+ Assert.True(fired.Wait(TimeSpan.FromSeconds(5)),
+ "RegistryWatcher.Changed did not fire within 5s of value write");
+ }
+
+ [Fact]
+ public void Watcher_fires_when_subtree_subkey_is_created()
+ {
+ using var fired = new ManualResetEventSlim(false);
+
+ using var watcher = new RegistryWatcher(
+ hive: RegistryHive.CurrentUser,
+ subKey: TestSubKey,
+ watchSubtree: true, // recursive — subkey writes anywhere below count
+ view: RegistryView.Default,
+ filter: RegistryNativeMethods.RegChangeNotifyFilter.Name
+ | RegistryNativeMethods.RegChangeNotifyFilter.LastSet
+ | RegistryNativeMethods.RegChangeNotifyFilter.ThreadAgnostic,
+ trace: null);
+
+ watcher.Changed += (_, __) => fired.Set();
+ watcher.Start();
+
+ Thread.Sleep(150);
+
+ // Mirrors the production case: CloudDomainJoin\TenantInfo doesn't exist
+ // yet at agent start; AAD join later writes a sub-key under the parent.
+ using (var sub = Registry.CurrentUser.CreateSubKey($@"{TestSubKey}\NewChild"))
+ {
+ Assert.NotNull(sub);
+ sub!.SetValue("Marker", 1);
+ }
+
+ Assert.True(fired.Wait(TimeSpan.FromSeconds(5)),
+ "RegistryWatcher.Changed did not fire within 5s of sub-key creation");
+ }
+
+ [Fact]
+ public void Watcher_fires_repeatedly_for_successive_changes()
+ {
+ int fireCount = 0;
+ using var fired = new AutoResetEvent(false);
+
+ using var watcher = new RegistryWatcher(
+ hive: RegistryHive.CurrentUser,
+ subKey: TestSubKey,
+ watchSubtree: false,
+ view: RegistryView.Default,
+ filter: RegistryNativeMethods.RegChangeNotifyFilter.LastSet
+ | RegistryNativeMethods.RegChangeNotifyFilter.ThreadAgnostic,
+ trace: null);
+
+ watcher.Changed += (_, __) =>
+ {
+ Interlocked.Increment(ref fireCount);
+ fired.Set();
+ };
+ watcher.Start();
+
+ Thread.Sleep(150);
+
+ using (var key = Registry.CurrentUser.OpenSubKey(TestSubKey, writable: true))
+ {
+ key!.SetValue("V1", "a");
+ Assert.True(fired.WaitOne(TimeSpan.FromSeconds(5)), "first change");
+
+ // RegNotifyChangeKeyValue is single-shot — the watcher loop re-arms
+ // after each Changed. Verify the second write also wakes us.
+ Thread.Sleep(50);
+ key.SetValue("V2", "b");
+ Assert.True(fired.WaitOne(TimeSpan.FromSeconds(5)), "second change");
+ }
+
+ Assert.True(fireCount >= 2, $"expected ≥2 fire events, saw {fireCount}");
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/TenantIdAwaiterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/TenantIdAwaiterTests.cs
new file mode 100644
index 00000000..d9366cbe
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/TenantIdAwaiterTests.cs
@@ -0,0 +1,262 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Agent.V2.Core.Security;
+using Xunit;
+
+// xUnit1031 (no blocking Task.Wait in tests) intentionally suppressed — the API
+// under test is synchronous; we run it via Task.Run on a worker thread so the
+// xUnit thread can manipulate scripted state and trigger the fake signal,
+// then Wait verifies completion. Async would require turning a sync API into a
+// Task-returning one purely for test ergonomics, which is the wrong
+// trade-off.
+#pragma warning disable xUnit1031
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
+{
+ [CollectionDefinition(nameof(TenantIdAwaiterTestCollection), DisableParallelization = true)]
+ public sealed class TenantIdAwaiterTestCollection { }
+
+ ///
+ /// Drives through every interesting
+ /// path with a fake + scripted probe so the
+ /// orchestration logic is verified without touching the registry. The public
+ /// wrapper isn't covered here — the
+ /// HKCU integration test in RegistryWatcherIntegrationTests proves the
+ /// CompositeRegistryChangeSignal / RegNotifyChangeKeyValue plumbing separately.
+ ///
+ /// Single-threaded collection: these tests use Thread.Sleep as a synchronisation
+ /// primitive (waiting for the awaiter to enter its WaitAny loop before triggering the
+ /// fake signal). Under the default parallel pool the sleep is unreliable on a busy
+ /// box, so the class opts out of parallel execution.
+ ///
+ ///
+ [Collection(nameof(TenantIdAwaiterTestCollection))]
+ public sealed class TenantIdAwaiterTests
+ {
+ private sealed class FakeSignal : IRegistryChangeSignal
+ {
+ public event EventHandler? Changed;
+ public bool StartCalled { get; private set; }
+ public bool DisposeCalled { get; private set; }
+
+ public void Start() => StartCalled = true;
+ public void Trigger() => Changed?.Invoke(this, EventArgs.Empty);
+ public void Dispose() => DisposeCalled = true;
+ }
+
+ [Fact]
+ public void Timeout_with_no_signals_returns_null_after_about_one_second()
+ {
+ using var signal = new FakeSignal();
+ var sw = Stopwatch.StartNew();
+
+ var result = TenantIdAwaiter.WaitForTenantIdCore(
+ probe: () => null!,
+ signal: signal,
+ timeoutSeconds: 1,
+ periodicReprobeMs: 5_000, // larger than timeout, so this never fires
+ debounceMs: 0,
+ logger: null!,
+ ct: CancellationToken.None);
+
+ sw.Stop();
+ Assert.Null(result);
+ Assert.True(sw.Elapsed.TotalSeconds >= 0.9 && sw.Elapsed.TotalSeconds < 2.5,
+ $"expected ~1s, was {sw.Elapsed.TotalSeconds:F2}s");
+ Assert.True(signal.StartCalled);
+ }
+
+ [Fact]
+ public void Signal_fires_and_probe_hits_returns_value()
+ {
+ using var signal = new FakeSignal();
+ string? scriptedReturn = null;
+
+ var task = Task.Run(() => TenantIdAwaiter.WaitForTenantIdCore(
+ probe: () => scriptedReturn!,
+ signal: signal,
+ timeoutSeconds: 10,
+ periodicReprobeMs: 60_000, // never via periodic
+ debounceMs: 5,
+ logger: null!,
+ ct: CancellationToken.None));
+
+ // Let the awaiter enter its wait loop.
+ Thread.Sleep(100);
+
+ // Flip probe to "hit" then signal.
+ scriptedReturn = "tenant-aaa-bbb";
+ signal.Trigger();
+
+ Assert.True(task.Wait(TimeSpan.FromSeconds(3)),
+ "WaitForTenantIdCore did not return within 3s after signal");
+ Assert.Equal("tenant-aaa-bbb", task.Result);
+ }
+
+ [Fact]
+ public void Multiple_signals_with_late_probe_hit_returns_value()
+ {
+ using var signal = new FakeSignal();
+ int probeCount = 0;
+ string? scriptedReturn = null;
+
+ var task = Task.Run(() => TenantIdAwaiter.WaitForTenantIdCore(
+ probe: () =>
+ {
+ Interlocked.Increment(ref probeCount);
+ return scriptedReturn!;
+ },
+ signal: signal,
+ timeoutSeconds: 10,
+ periodicReprobeMs: 60_000,
+ debounceMs: 5,
+ logger: null!,
+ ct: CancellationToken.None));
+
+ Thread.Sleep(80);
+ // Two signal fires that yield no hit
+ signal.Trigger(); Thread.Sleep(40);
+ signal.Trigger(); Thread.Sleep(40);
+ // Now flip the probe and fire again
+ scriptedReturn = "tenant-late";
+ signal.Trigger();
+
+ Assert.True(task.Wait(TimeSpan.FromSeconds(3)));
+ Assert.Equal("tenant-late", task.Result);
+ Assert.True(probeCount >= 3, $"expected ≥3 probes (3 signals), saw {probeCount}");
+ }
+
+ [Fact]
+ public void Cancellation_returns_null_promptly()
+ {
+ using var signal = new FakeSignal();
+ using var cts = new CancellationTokenSource();
+ var sw = Stopwatch.StartNew();
+
+ var task = Task.Run(() => TenantIdAwaiter.WaitForTenantIdCore(
+ probe: () => null!,
+ signal: signal,
+ timeoutSeconds: 60, // would otherwise wait a minute
+ periodicReprobeMs: 60_000,
+ debounceMs: 0,
+ logger: null!,
+ ct: cts.Token));
+
+ Thread.Sleep(100);
+ cts.Cancel();
+
+ Assert.True(task.Wait(TimeSpan.FromSeconds(3)),
+ "expected prompt return on cancellation");
+ Assert.Null(task.Result);
+ sw.Stop();
+ Assert.True(sw.Elapsed.TotalSeconds < 2.0,
+ $"cancellation took {sw.Elapsed.TotalSeconds:F2}s — expected <2s");
+ }
+
+ [Fact]
+ public void Periodic_reprobe_picks_up_change_when_no_signal_arrives()
+ {
+ using var signal = new FakeSignal();
+ string? scriptedReturn = null;
+
+ var task = Task.Run(() => TenantIdAwaiter.WaitForTenantIdCore(
+ probe: () => scriptedReturn!,
+ signal: signal,
+ timeoutSeconds: 10,
+ periodicReprobeMs: 100, // re-probe every 100 ms
+ debounceMs: 0,
+ logger: null!,
+ ct: CancellationToken.None));
+
+ Thread.Sleep(80);
+ scriptedReturn = "tenant-via-periodic";
+ // No signal — only the periodic timer should pick this up.
+
+ Assert.True(task.Wait(TimeSpan.FromSeconds(3)),
+ "periodic re-probe did not catch the registry change");
+ Assert.Equal("tenant-via-periodic", task.Result);
+ }
+
+ [Fact]
+ public void Empty_or_whitespace_probe_result_is_treated_as_miss()
+ {
+ using var signal = new FakeSignal();
+ var probeReturns = new[] { "", " ", null!, "tenant-ok" };
+ int callIdx = 0;
+
+ var task = Task.Run(() => TenantIdAwaiter.WaitForTenantIdCore(
+ probe: () => probeReturns[Math.Min(callIdx++, probeReturns.Length - 1)],
+ signal: signal,
+ timeoutSeconds: 10,
+ periodicReprobeMs: 50,
+ debounceMs: 0,
+ logger: null!,
+ ct: CancellationToken.None));
+
+ Assert.True(task.Wait(TimeSpan.FromSeconds(3)));
+ Assert.Equal("tenant-ok", task.Result);
+ // empty + whitespace + null + "tenant-ok" => callIdx >= 4 by then
+ Assert.True(callIdx >= 4, $"expected ≥4 probe calls, saw {callIdx}");
+ }
+
+ [Fact]
+ public void Zero_or_negative_timeout_short_circuits_to_null()
+ {
+ using var signal = new FakeSignal();
+ var sw = Stopwatch.StartNew();
+
+ var resultZero = TenantIdAwaiter.WaitForTenantIdCore(
+ probe: () => "tenant-would-have-hit",
+ signal: signal,
+ timeoutSeconds: 0,
+ periodicReprobeMs: 100,
+ debounceMs: 0,
+ logger: null!,
+ ct: CancellationToken.None);
+
+ var resultNeg = TenantIdAwaiter.WaitForTenantIdCore(
+ probe: () => "tenant-would-have-hit",
+ signal: signal,
+ timeoutSeconds: -5,
+ periodicReprobeMs: 100,
+ debounceMs: 0,
+ logger: null!,
+ ct: CancellationToken.None);
+
+ sw.Stop();
+ Assert.Null(resultZero);
+ Assert.Null(resultNeg);
+ // Should be near-instantaneous (no Start, no waits).
+ Assert.True(sw.Elapsed.TotalMilliseconds < 200,
+ $"short-circuit took {sw.Elapsed.TotalMilliseconds:F0}ms");
+ Assert.False(signal.StartCalled, "signal must not be started for zero-timeout");
+ }
+
+ [Fact]
+ public void Null_arguments_throw_ArgumentNullException()
+ {
+ using var signal = new FakeSignal();
+ Assert.Throws(() =>
+ TenantIdAwaiter.WaitForTenantIdCore(
+ probe: null!,
+ signal: signal,
+ timeoutSeconds: 1,
+ periodicReprobeMs: 100,
+ debounceMs: 0,
+ logger: null!,
+ ct: CancellationToken.None));
+
+ Assert.Throws(() =>
+ TenantIdAwaiter.WaitForTenantIdCore(
+ probe: () => null!,
+ signal: null!,
+ timeoutSeconds: 1,
+ periodicReprobeMs: 100,
+ debounceMs: 0,
+ logger: null!,
+ ct: CancellationToken.None));
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/AgentConfiguration.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/AgentConfiguration.cs
index 2f6f25f5..83e4ba56 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/AgentConfiguration.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/AgentConfiguration.cs
@@ -258,6 +258,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
///
public int AwaitEnrollmentTimeoutMinutes { get; set; } = 480;
+ ///
+ /// Maximum time in seconds the agent waits for a TenantId to become resolvable
+ /// before bailing. When > 0 and the initial registry probe finds no TenantId,
+ /// the agent registers a RegistryWatcher on the relevant Enrollments and
+ /// CloudDomainJoin keys and re-probes on every change until either resolution
+ /// or this timeout. 0 = no wait, fast-fail on miss (legacy behaviour).
+ ///
+ /// Set via the install-time CLI flag --tenant-id-wait <sec>, persisted
+ /// to bootstrap-config.json. The PS1 bootstrap script owns the production
+ /// default; the agent itself never assumes one.
+ ///
+ ///
+ public int TenantIdWaitSeconds { get; set; } = 0;
+
///
/// Validates the configuration
///
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/BootstrapConfigFile.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/BootstrapConfigFile.cs
index 6778b1cd..5dd95c1c 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/BootstrapConfigFile.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/BootstrapConfigFile.cs
@@ -12,5 +12,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
{
public string BootstrapToken { get; set; }
public string TenantId { get; set; }
+
+ ///
+ /// TenantId-wait timeout in seconds, persisted from the --tenant-id-wait
+ /// install-time arg. Read by AgentBootstrap when the registry probe finds no
+ /// TenantId on first try. 0 / missing = no wait, legacy fast-fail.
+ ///
+ public int TenantIdWaitSeconds { get; set; }
}
}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/CompositeRegistryChangeSignal.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/CompositeRegistryChangeSignal.cs
new file mode 100644
index 00000000..b6b4ac86
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/CompositeRegistryChangeSignal.cs
@@ -0,0 +1,111 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
+using Microsoft.Win32;
+
+namespace AutopilotMonitor.Agent.V2.Core.Security
+{
+ ///
+ /// Production wired to two
+ /// instances:
+ ///
+ /// - HKLM\SOFTWARE\Microsoft\Enrollments (recursive) — catches
+ /// new enrollment GUID sub-keys and AADTenantID writes inside them.
+ /// - HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin
+ /// (recursive, watching the parent because TenantInfo / JoinInfo
+ /// might not exist yet at start-up) — catches AAD-join completion.
+ ///
+ /// Both watchers fan their Changed events into a single
+ /// edge so the awaiter doesn't have to know which path moved.
+ ///
+ public sealed class CompositeRegistryChangeSignal : IRegistryChangeSignal
+ {
+ private const RegistryNativeMethods.RegChangeNotifyFilter Filter =
+ RegistryNativeMethods.RegChangeNotifyFilter.Name |
+ RegistryNativeMethods.RegChangeNotifyFilter.LastSet |
+ RegistryNativeMethods.RegChangeNotifyFilter.ThreadAgnostic;
+
+ private readonly List _watchers = new List();
+ private readonly AgentLogger _logger;
+ private bool _started;
+ private bool _disposed;
+
+ public event EventHandler Changed;
+
+ ///
+ /// Constructs the production signal — two watchers on the registry locations the
+ /// probes. Use the protected constructor in tests
+ /// when you don't want real Win32 watchers.
+ ///
+ public CompositeRegistryChangeSignal(AgentLogger logger)
+ {
+ _logger = logger;
+ _watchers.Add(BuildWatcher(
+ RegistryHive.LocalMachine,
+ @"SOFTWARE\Microsoft\Enrollments",
+ "Enrollments"));
+ _watchers.Add(BuildWatcher(
+ RegistryHive.LocalMachine,
+ @"SYSTEM\CurrentControlSet\Control\CloudDomainJoin",
+ "CloudDomainJoin"));
+ }
+
+ private RegistryWatcher BuildWatcher(RegistryHive hive, string subKey, string label)
+ {
+ var watcher = new RegistryWatcher(
+ hive: hive,
+ subKey: subKey,
+ watchSubtree: true,
+ view: RegistryView.Registry64,
+ filter: Filter,
+ trace: null);
+
+ watcher.Changed += (sender, args) =>
+ {
+ _logger?.Debug($"CompositeRegistryChangeSignal: {label} change detected.");
+ Changed?.Invoke(this, EventArgs.Empty);
+ };
+ watcher.Error += (sender, ex) =>
+ {
+ _logger?.Warning($"CompositeRegistryChangeSignal: {label} watcher error — {ex.GetType().Name}: {ex.Message}");
+ };
+ return watcher;
+ }
+
+ public void Start()
+ {
+ if (_disposed) throw new ObjectDisposedException(nameof(CompositeRegistryChangeSignal));
+ if (_started) return;
+ _started = true;
+
+ foreach (var w in _watchers)
+ {
+ try
+ {
+ w.Start();
+ }
+ catch (Exception ex)
+ {
+ // RegOpenKeyEx can fail if a watched root happens to not exist on this
+ // device variant. Log + keep going — the other watcher (and the
+ // belt-and-suspenders re-probe in TenantIdAwaiter) cover the gap.
+ _logger?.Warning($"CompositeRegistryChangeSignal: failed to start a watcher — {ex.GetType().Name}: {ex.Message}");
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+
+ foreach (var w in _watchers)
+ {
+ try { w.Dispose(); } catch { /* never throw from Dispose */ }
+ }
+ _watchers.Clear();
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/IRegistryChangeSignal.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/IRegistryChangeSignal.cs
new file mode 100644
index 00000000..6d81e7fa
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/IRegistryChangeSignal.cs
@@ -0,0 +1,19 @@
+using System;
+
+namespace AutopilotMonitor.Agent.V2.Core.Security
+{
+ ///
+ /// Abstraction over a (possibly composite) registry change source. Lets
+ /// be unit-tested without Win32 dependencies —
+ /// production wires this to one or more
+ /// instances, tests wire a fake that raises on demand.
+ ///
+ public interface IRegistryChangeSignal : IDisposable
+ {
+ /// Raised whenever any underlying watched key reports a change.
+ event EventHandler Changed;
+
+ /// Begins observing. Idempotent: calling twice on a started signal is a no-op.
+ void Start();
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/TenantIdAwaiter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/TenantIdAwaiter.cs
new file mode 100644
index 00000000..4a673eb2
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/TenantIdAwaiter.cs
@@ -0,0 +1,145 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+
+namespace AutopilotMonitor.Agent.V2.Core.Security
+{
+ ///
+ /// Event-driven TenantId resolution wait. When the initial
+ /// probe finds nothing, the awaiter subscribes to
+ /// a registry-change signal (production:
+ /// over Enrollments + CloudDomainJoin), re-probes silently on every change, and
+ /// returns the TenantId as soon as the registry catches up — or null on
+ /// timeout. A 30 s belt-and-suspenders periodic re-probe protects against a
+ /// missed Win32 notification.
+ ///
+ /// Used to bridge the OOBE / hybrid-AAD-join race window where the agent fires
+ /// before AADTenantID / CloudDomainJoin\TenantInfo are written.
+ /// Field evidence (GardnerMedia, 2026-04-30): the agent fired ~5 minutes before
+ /// the AAD device certificate landed; an event-driven wait of 10 minutes would
+ /// have recovered the run.
+ ///
+ ///
+ public static class TenantIdAwaiter
+ {
+ private const int DefaultPeriodicReprobeMs = 30_000;
+ private const int DefaultDebounceMs = 100;
+
+ ///
+ /// Production entry point. Returns the resolved TenantId, or null when
+ /// timed out / cancelled. <= 0 short-circuits
+ /// to null immediately (legacy fast-fail behaviour — caller should also
+ /// gate on the same condition).
+ ///
+ public static string WaitForTenantId(int timeoutSeconds, AgentLogger logger, CancellationToken ct)
+ {
+ if (timeoutSeconds <= 0)
+ return null;
+
+ // Initial probe with the real logger so the resolver emits its source-tag
+ // Info line on hit / its full diagnostic block on miss exactly once.
+ var initial = TenantIdResolver.Resolve(logger);
+ if (!string.IsNullOrWhiteSpace(initial))
+ return initial;
+
+ logger?.Info($"TenantIdAwaiter: TenantId not yet resolvable — waiting up to {timeoutSeconds}s for registry signal.");
+
+ using (var signal = new CompositeRegistryChangeSignal(logger))
+ {
+ var hit = WaitForTenantIdCore(
+ probe: () => TenantIdResolver.Resolve(null), // silent during wait — initial probe already logged
+ signal: signal,
+ timeoutSeconds: timeoutSeconds,
+ periodicReprobeMs: DefaultPeriodicReprobeMs,
+ debounceMs: DefaultDebounceMs,
+ logger: logger,
+ ct: ct);
+
+ if (!string.IsNullOrWhiteSpace(hit))
+ {
+ // Re-probe once with the real logger so the source-tag Info line
+ // (e.g. "resolved TenantId=… from cloud_domain_join_tenant_info")
+ // lands in the log alongside the awaiter's "resolved after Xs".
+ TenantIdResolver.Resolve(logger);
+ }
+ return hit;
+ }
+ }
+
+ ///
+ /// Test-visible orchestration core. Takes a silent probe delegate and an
+ /// abstract signal so xUnit can drive the loop deterministically without
+ /// touching the registry. Production wires to
+ /// TenantIdResolver.Resolve(null) and to
+ /// .
+ ///
+ internal static string WaitForTenantIdCore(
+ Func probe,
+ IRegistryChangeSignal signal,
+ int timeoutSeconds,
+ int periodicReprobeMs,
+ int debounceMs,
+ AgentLogger logger,
+ CancellationToken ct)
+ {
+ if (probe == null) throw new ArgumentNullException(nameof(probe));
+ if (signal == null) throw new ArgumentNullException(nameof(signal));
+ if (timeoutSeconds <= 0) return null;
+
+ using (var signalEvent = new ManualResetEvent(initialState: false))
+ using (var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct))
+ {
+ EventHandler handler = (_, __) => signalEvent.Set();
+ signal.Changed += handler;
+ try
+ {
+ signal.Start();
+ timeoutCts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
+
+ var sw = Stopwatch.StartNew();
+ var handles = new WaitHandle[] { signalEvent, timeoutCts.Token.WaitHandle };
+
+ while (!timeoutCts.IsCancellationRequested)
+ {
+ var idx = WaitHandle.WaitAny(handles, periodicReprobeMs);
+ if (idx == 1) break; // cancelled / timed out
+
+ bool signalFired = idx == 0;
+ signalEvent.Reset();
+
+ if (signalFired && debounceMs > 0)
+ {
+ // Brief settling so multi-value transactions land before re-probe.
+ // Returns true if cancelled during the debounce → exit promptly.
+ if (timeoutCts.Token.WaitHandle.WaitOne(debounceMs))
+ break;
+ signalEvent.Reset();
+ }
+
+ var hit = probe();
+ if (!string.IsNullOrWhiteSpace(hit))
+ {
+ logger?.Info(
+ $"TenantIdAwaiter: resolved after {sw.Elapsed.TotalSeconds:F0}s " +
+ $"({(signalFired ? "registry signal" : "periodic re-probe")}).");
+ return hit;
+ }
+
+ if (signalFired)
+ logger?.Debug("TenantIdAwaiter: registry change observed but TenantId still unresolvable.");
+ }
+
+ logger?.Warning(
+ $"TenantIdAwaiter: timeout after {sw.Elapsed.TotalSeconds:F0}s — " +
+ "TenantId still not available, agent will exit cleanly and retry on next trigger.");
+ return null;
+ }
+ finally
+ {
+ signal.Changed -= handler;
+ }
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.InstallMode.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.InstallMode.cs
index 0924d91d..7ed20878 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/Program.InstallMode.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.InstallMode.cs
@@ -62,11 +62,22 @@ namespace AutopilotMonitor.Agent.V2
logger.Info("Agent already running from target install directory; payload copy not required.");
}
- // Persist bootstrap config if --bootstrap-token was provided. The Scheduled Task
- // command line has no args — the agent picks this up on the first post-install run.
+ // 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.
var bootstrapTokenArg = GetArgValue(args, "--bootstrap-token");
var tenantIdArg = GetArgValue(args, "--tenant-id");
- if (!string.IsNullOrEmpty(bootstrapTokenArg))
+ var tenantIdWaitArg = GetArgValue(args, "--tenant-id-wait");
+ int tenantIdWaitSeconds = 0;
+ if (!string.IsNullOrEmpty(tenantIdWaitArg) && !int.TryParse(tenantIdWaitArg, out tenantIdWaitSeconds))
+ {
+ logger.Warning($"Install: --tenant-id-wait '{tenantIdWaitArg}' is not a valid integer — ignoring.");
+ tenantIdWaitSeconds = 0;
+ }
+
+ if (!string.IsNullOrEmpty(bootstrapTokenArg) ||
+ !string.IsNullOrEmpty(tenantIdArg) ||
+ tenantIdWaitSeconds > 0)
{
var bootstrapConfigPath = Path.Combine(
Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory),
@@ -75,9 +86,14 @@ namespace AutopilotMonitor.Agent.V2
{
BootstrapToken = bootstrapTokenArg,
TenantId = tenantIdArg,
+ TenantIdWaitSeconds = tenantIdWaitSeconds,
};
File.WriteAllText(bootstrapConfigPath, JsonConvert.SerializeObject(bootstrapConfig));
- logger.Info("Bootstrap config persisted for Scheduled Task.");
+ logger.Info(
+ $"Bootstrap config persisted for Scheduled Task " +
+ $"(token={(string.IsNullOrEmpty(bootstrapTokenArg) ? "no" : "yes")}, " +
+ $"tenantId={(string.IsNullOrEmpty(tenantIdArg) ? "no" : "yes")}, " +
+ $"tenantIdWait={tenantIdWaitSeconds}s).");
}
// Persist await-enrollment config if requested.
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
index 6e945ea3..0e81067d 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
@@ -278,6 +278,15 @@ namespace AutopilotMonitor.Agent.V2
else if (awaitConfig != null)
awaitTimeoutMinutes = awaitConfig.TimeoutMinutes;
+ // TenantId wait — CLI wins over persisted bootstrap-config.json. Default 0
+ // (no wait, legacy fast-fail) — the PS1 bootstrap owns the production default.
+ var tenantIdWaitRaw = GetArgValue(args, "--tenant-id-wait");
+ int tenantIdWaitSeconds = 0;
+ if (!string.IsNullOrEmpty(tenantIdWaitRaw) && int.TryParse(tenantIdWaitRaw, out var parsedTenantIdWait))
+ tenantIdWaitSeconds = parsedTenantIdWait;
+ else if (bootstrapConfig != null)
+ tenantIdWaitSeconds = bootstrapConfig.TenantIdWaitSeconds;
+
var useBootstrapTokenAuth = !string.IsNullOrEmpty(bootstrapToken);
var cliLogLevel = GetArgValue(args, "--log-level");
@@ -300,6 +309,7 @@ namespace AutopilotMonitor.Agent.V2
UseBootstrapTokenAuth = useBootstrapTokenAuth,
AwaitEnrollment = awaitEnrollment,
AwaitEnrollmentTimeoutMinutes = awaitTimeoutMinutes,
+ TenantIdWaitSeconds = tenantIdWaitSeconds,
RebootOnComplete = rebootOnComplete,
EnableGeoLocation = !disableGeoLocation,
ImeLogPathOverride = imeLogPathOverride,
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentBootstrap.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentBootstrap.cs
index 7509e1d1..26344be6 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentBootstrap.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentBootstrap.cs
@@ -120,6 +120,22 @@ namespace AutopilotMonitor.Agent.V2.Runtime
Program.DeleteAwaitEnrollmentConfig(dataDirectory, logger);
}
+ // Event-driven TenantId wait — kicks in only when the user opted-in via
+ // --tenant-id-wait (CLI or persisted bootstrap-config.json). Bridges
+ // the OOBE / hybrid-AAD-join race where the agent fires before the registry
+ // catches up. No-op when TenantIdWaitSeconds == 0 (legacy fast-fail) or when
+ // the await-enrollment branch already resolved a TenantId.
+ if (string.IsNullOrEmpty(agentConfig.TenantId) && agentConfig.TenantIdWaitSeconds > 0)
+ {
+ using (var waitCts = new CancellationTokenSource())
+ {
+ agentConfig.TenantId = TenantIdAwaiter.WaitForTenantId(
+ timeoutSeconds: agentConfig.TenantIdWaitSeconds,
+ logger: logger,
+ ct: waitCts.Token);
+ }
+ }
+
if (string.IsNullOrEmpty(agentConfig.TenantId))
{
logger.Error("V2 agent cannot start: TenantId not available (registry empty + no bootstrap config).");
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
index 055b6671..1c599d4f 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
@@ -1 +1 @@
-507
+516
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index 53d108b9..939b0cbc 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-1231
+1233