feat(agent): event-driven TenantId wait via RegistryWatcher

Bridges the OOBE / hybrid-AAD-join race window where the agent fires
before AADTenantID / CloudDomainJoin\TenantInfo are written to the
registry. Field evidence (GardnerMedia, 2026-04-30): the agent fired
~5 minutes before the AAD device cert landed; an event-driven wait of
10 minutes recovers the run instead of bailing and waiting for next
reboot.

Mechanism (V2 only — V1 keeps the fast-fail path):
* New TenantIdAwaiter.WaitForTenantId(timeoutSec, logger, ct) sets up
  a CompositeRegistryChangeSignal over HKLM\SOFTWARE\Microsoft\Enrollments
  and HKLM\SYSTEM\...\CloudDomainJoin (Registry64 view, recursive,
  Name+LastSet+ThreadAgnostic filter), re-probes silently on every
  change, and returns the resolved TenantId or null on timeout.
* 30 s belt-and-suspenders periodic re-probe protects against a missed
  RegNotifyChangeKeyValue notification.
* 100 ms debounce after each signal so multi-value enrollment
  transactions land before the re-probe.

Configuration:
* Opt-in via --tenant-id-wait <sec>. Default 0 = no wait (legacy
  fast-fail). The PS1 bootstrap script owns the production default;
  the agent itself never assumes one.
* CLI value persisted to bootstrap-config.json by --install so the
  Scheduled Task picks it up on each restart.
* Install-AutopilotMonitor-v2.ps1 sets $tenantIdWaitSec = 600. Admins
  override per tenant by editing this single line and re-deploying.

Testability:
* IRegistryChangeSignal abstraction lets unit tests drive the loop
  with a fake signal and scripted probe — 8 deterministic cases cover
  timeout, signal hit, multi-signal, cancellation, periodic re-probe,
  whitespace/empty/null handling, zero-timeout short-circuit, and
  argument validation.
* RegistryWatcherIntegrationTests exercise the real Win32 plumbing
  (RegNotifyChangeKeyValue → Changed event) against HKCU so no
  elevation is required and the test runs in standard CI.
* Both classes opt out of xUnit parallelisation — the timing-sensitive
  Thread.Sleep handshakes are unreliable on a busy parallel pool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-30 21:54:43 +02:00
parent 58be7d19c3
commit 3016c15919
13 changed files with 785 additions and 8 deletions
@@ -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"
@@ -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 { }
/// <summary>
/// Real <see cref="RegistryWatcher"/> integration test against
/// <c>HKCU\SOFTWARE\AutopilotMonitor.Tests\WatcherIntegration</c>. Exercises the
/// Win32 <c>RegNotifyChangeKeyValue</c> plumbing end-to-end:
/// <list type="bullet">
/// <item><description>opens the watched key, registers async notification</description></item>
/// <item><description>another thread writes a value / creates a sub-key</description></item>
/// <item><description>watcher thread receives the kernel signal and raises Changed</description></item>
/// </list>
/// <para>
/// 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).
/// </para>
/// <para>
/// 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
/// <c>RegNotifyChangeKeyValue</c> before the test thread writes is unreliable on a
/// box busy with parallel xUnit work.
/// </para>
/// </summary>
[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}");
}
}
}
@@ -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<string>-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 { }
/// <summary>
/// Drives <see cref="TenantIdAwaiter.WaitForTenantIdCore"/> through every interesting
/// path with a fake <see cref="IRegistryChangeSignal"/> + scripted probe so the
/// orchestration logic is verified without touching the registry. The public
/// <see cref="TenantIdAwaiter.WaitForTenantId"/> wrapper isn't covered here — the
/// HKCU integration test in <c>RegistryWatcherIntegrationTests</c> proves the
/// CompositeRegistryChangeSignal / RegNotifyChangeKeyValue plumbing separately.
/// <para>
/// Single-threaded collection: these tests use <c>Thread.Sleep</c> 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.
/// </para>
/// </summary>
[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<ArgumentNullException>(() =>
TenantIdAwaiter.WaitForTenantIdCore(
probe: null!,
signal: signal,
timeoutSeconds: 1,
periodicReprobeMs: 100,
debounceMs: 0,
logger: null!,
ct: CancellationToken.None));
Assert.Throws<ArgumentNullException>(() =>
TenantIdAwaiter.WaitForTenantIdCore(
probe: () => null!,
signal: null!,
timeoutSeconds: 1,
periodicReprobeMs: 100,
debounceMs: 0,
logger: null!,
ct: CancellationToken.None));
}
}
}
@@ -258,6 +258,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
/// </summary>
public int AwaitEnrollmentTimeoutMinutes { get; set; } = 480;
/// <summary>
/// Maximum time in seconds the agent waits for a TenantId to become resolvable
/// before bailing. When &gt; 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).
/// <para>
/// Set via the install-time CLI flag <c>--tenant-id-wait &lt;sec&gt;</c>, persisted
/// to <c>bootstrap-config.json</c>. The PS1 bootstrap script owns the production
/// default; the agent itself never assumes one.
/// </para>
/// </summary>
public int TenantIdWaitSeconds { get; set; } = 0;
/// <summary>
/// Validates the configuration
/// </summary>
@@ -12,5 +12,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
{
public string BootstrapToken { get; set; }
public string TenantId { get; set; }
/// <summary>
/// TenantId-wait timeout in seconds, persisted from the <c>--tenant-id-wait</c>
/// install-time arg. Read by AgentBootstrap when the registry probe finds no
/// TenantId on first try. 0 / missing = no wait, legacy fast-fail.
/// </summary>
public int TenantIdWaitSeconds { get; set; }
}
}
@@ -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
{
/// <summary>
/// Production <see cref="IRegistryChangeSignal"/> wired to two
/// <see cref="RegistryWatcher"/> instances:
/// <list type="bullet">
/// <item><description><c>HKLM\SOFTWARE\Microsoft\Enrollments</c> (recursive) — catches
/// new enrollment GUID sub-keys and <c>AADTenantID</c> writes inside them.</description></item>
/// <item><description><c>HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin</c>
/// (recursive, watching the parent because <c>TenantInfo</c> / <c>JoinInfo</c>
/// might not exist yet at start-up) — catches AAD-join completion.</description></item>
/// </list>
/// Both watchers fan their <c>Changed</c> events into a single <see cref="Changed"/>
/// edge so the awaiter doesn't have to know which path moved.
/// </summary>
public sealed class CompositeRegistryChangeSignal : IRegistryChangeSignal
{
private const RegistryNativeMethods.RegChangeNotifyFilter Filter =
RegistryNativeMethods.RegChangeNotifyFilter.Name |
RegistryNativeMethods.RegChangeNotifyFilter.LastSet |
RegistryNativeMethods.RegChangeNotifyFilter.ThreadAgnostic;
private readonly List<RegistryWatcher> _watchers = new List<RegistryWatcher>();
private readonly AgentLogger _logger;
private bool _started;
private bool _disposed;
public event EventHandler Changed;
/// <summary>
/// Constructs the production signal — two watchers on the registry locations the
/// <see cref="TenantIdResolver"/> probes. Use the protected constructor in tests
/// when you don't want real Win32 watchers.
/// </summary>
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();
}
}
}
@@ -0,0 +1,19 @@
using System;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// Abstraction over a (possibly composite) registry change source. Lets
/// <see cref="TenantIdAwaiter"/> be unit-tested without Win32 dependencies —
/// production wires this to one or more <see cref="Monitoring.Runtime.RegistryWatcher"/>
/// instances, tests wire a fake that raises <see cref="Changed"/> on demand.
/// </summary>
public interface IRegistryChangeSignal : IDisposable
{
/// <summary>Raised whenever any underlying watched key reports a change.</summary>
event EventHandler Changed;
/// <summary>Begins observing. Idempotent: calling twice on a started signal is a no-op.</summary>
void Start();
}
}
@@ -0,0 +1,145 @@
using System;
using System.Diagnostics;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// Event-driven TenantId resolution wait. When the initial
/// <see cref="TenantIdResolver"/> probe finds nothing, the awaiter subscribes to
/// a registry-change signal (production: <see cref="CompositeRegistryChangeSignal"/>
/// over Enrollments + CloudDomainJoin), re-probes silently on every change, and
/// returns the TenantId as soon as the registry catches up — or <c>null</c> on
/// timeout. A 30 s belt-and-suspenders periodic re-probe protects against a
/// missed Win32 notification.
/// <para>
/// Used to bridge the OOBE / hybrid-AAD-join race window where the agent fires
/// before <c>AADTenantID</c> / <c>CloudDomainJoin\TenantInfo</c> 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.
/// </para>
/// </summary>
public static class TenantIdAwaiter
{
private const int DefaultPeriodicReprobeMs = 30_000;
private const int DefaultDebounceMs = 100;
/// <summary>
/// Production entry point. Returns the resolved TenantId, or <c>null</c> when
/// timed out / cancelled. <paramref name="timeoutSeconds"/> &lt;= 0 short-circuits
/// to <c>null</c> immediately (legacy fast-fail behaviour — caller should also
/// gate on the same condition).
/// </summary>
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;
}
}
/// <summary>
/// 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 <paramref name="probe"/> to
/// <c>TenantIdResolver.Resolve(null)</c> and <paramref name="signal"/> to
/// <see cref="CompositeRegistryChangeSignal"/>.
/// </summary>
internal static string WaitForTenantIdCore(
Func<string> 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;
}
}
}
}
}
@@ -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.
@@ -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,
@@ -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 <sec> (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).");
@@ -1 +1 @@
507
516
@@ -1 +1 @@
1231
1233