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