mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
fix(agent-v2): resolve desktop owner via WTS session query, WMI as fallback
Session 4d5a0b78: WMI Win32_Process.GetOwner failed on every single DAD poll
(wmiErrorCount == pollCount from the first poll on), so the explorer.exe
owner was never resolved, desktop_arrived never fired, and the Desktop half
of the completion AND-gate starved — the session sat in AwaitingDesktop
forever even though the gate-starvation synthesis (d3abbacd) had correctly
opened the AccountSetup gate.
Owner resolution is now WTSQuerySessionInformation(WTSUserName/WTSDomainName)
keyed by the process's session id as the primary path — a single kernel
round-trip with no WinMgmt dependency, and semantically the right question
anyway (explorer.exe runs as the session's logged-on user). The previous WMI
GetOwner path remains as fallback when WTS yields no user.
- New WtsNativeMethods interop (wtsapi32) with buffer-safe QuerySessionString
- DesktopArrivalDetector.ResolveOwner: WTS first, WMI fallback, injectable
test seams (SessionOwnerResolver/ProcessOwnerResolver)
- first_poll/no_candidate liveness payloads now carry wtsErrorCount alongside
wmiErrorCount so the broken path is visible per device
V2.Core 1327/1327 green; WTS path live-verified (AzureAD\user shape matches
the existing IsExcludedUser contract).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+88
@@ -178,6 +178,94 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
detector.ResetForRealUserSwitch();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Owner resolution: WTS primary, WMI fallback (session 4d5a0b78 fix, 2026-06-11)
|
||||
// ============================================================================
|
||||
// Session 4d5a0b78: WMI Win32_Process.GetOwner failed on EVERY poll on a device
|
||||
// (wmiErrorCount == pollCount), the owner was never resolved and desktop_arrived
|
||||
// never fired — the Desktop half of the completion AND-gate starved. WTS session
|
||||
// queries are now the primary path; WMI is only consulted when WTS yields no user.
|
||||
|
||||
private static DesktopArrivalDetector BuildDetector(TempDirectory tmp) =>
|
||||
new DesktopArrivalDetector(new AgentLogger(tmp.Path, AgentLogLevel.Info));
|
||||
|
||||
[Fact]
|
||||
public void ResolveOwner_uses_WTS_result_without_consulting_WMI()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var detector = BuildDetector(tmp);
|
||||
var wmiCalled = false;
|
||||
detector.SessionOwnerResolver = sessionId => "CONTOSO\\alice";
|
||||
detector.ProcessOwnerResolver = pid => { wmiCalled = true; return "CONTOSO\\bob"; };
|
||||
|
||||
var owner = detector.ResolveOwner(processId: 1234, sessionId: 2);
|
||||
|
||||
Assert.Equal("CONTOSO\\alice", owner);
|
||||
Assert.False(wmiCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveOwner_falls_back_to_WMI_when_WTS_returns_null()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var detector = BuildDetector(tmp);
|
||||
detector.SessionOwnerResolver = sessionId => null;
|
||||
detector.ProcessOwnerResolver = pid => "CONTOSO\\alice";
|
||||
|
||||
Assert.Equal("CONTOSO\\alice", detector.ResolveOwner(1234, 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveOwner_falls_back_to_WMI_when_WTS_returns_empty()
|
||||
{
|
||||
// WTSQuerySessionInformation can succeed with an empty user name (no user
|
||||
// associated with the session) — must be treated like a miss, not a real owner.
|
||||
using var tmp = new TempDirectory();
|
||||
using var detector = BuildDetector(tmp);
|
||||
detector.SessionOwnerResolver = sessionId => "";
|
||||
detector.ProcessOwnerResolver = pid => "CONTOSO\\alice";
|
||||
|
||||
Assert.Equal("CONTOSO\\alice", detector.ResolveOwner(1234, 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveOwner_falls_back_to_WMI_when_WTS_throws()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var detector = BuildDetector(tmp);
|
||||
detector.SessionOwnerResolver = sessionId => throw new System.ComponentModel.Win32Exception(5);
|
||||
detector.ProcessOwnerResolver = pid => "CONTOSO\\alice";
|
||||
|
||||
Assert.Equal("CONTOSO\\alice", detector.ResolveOwner(1234, 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveOwner_returns_null_when_both_paths_fail()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var detector = BuildDetector(tmp);
|
||||
detector.SessionOwnerResolver = sessionId => null;
|
||||
detector.ProcessOwnerResolver = pid => null;
|
||||
|
||||
Assert.Null(detector.ResolveOwner(1234, 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveOwner_passes_session_id_to_WTS_and_pid_to_WMI()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var detector = BuildDetector(tmp);
|
||||
int? seenSessionId = null;
|
||||
int? seenPid = null;
|
||||
detector.SessionOwnerResolver = sessionId => { seenSessionId = sessionId; return null; };
|
||||
detector.ProcessOwnerResolver = pid => { seenPid = pid; return null; };
|
||||
|
||||
detector.ResolveOwner(processId: 4711, sessionId: 3);
|
||||
|
||||
Assert.Equal(3, seenSessionId);
|
||||
Assert.Equal(4711, seenPid);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DAD-liveness telemetry (2026-05-15) — Started / FirstPoll / NoCandidate
|
||||
// ============================================================================
|
||||
|
||||
+80
-4
@@ -4,6 +4,7 @@ using System.Diagnostics;
|
||||
using System.Management;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
@@ -14,6 +15,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// Used for enrollment completion in no-ESP scenarios (WDP v2, ESP disabled) and
|
||||
/// as a backup signal for AccountSetup phase correction.
|
||||
/// <para>
|
||||
/// Owner resolution (session 4d5a0b78 fix, 2026-06-11): primary path is a WTS session
|
||||
/// query (<c>WTSQuerySessionInformation</c> with WTSUserName/WTSDomainName) — fast and
|
||||
/// independent of the WinMgmt service; WMI <c>Win32_Process.GetOwner</c> is the fallback.
|
||||
/// On the affected device class GetOwner failed on every poll, so the owner was never
|
||||
/// resolved and the Desktop half of the completion AND-gate starved.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Liveness telemetry</b> (state-change-only, max 3 events per detector lifetime, NOT periodic):
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
@@ -44,9 +52,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private bool _explorerEverSeen; // any explorer.exe observed at all (session 0 OR not)
|
||||
private bool _anyNonZeroSessionEverSeen; // explorer.exe in a non-SYSTEM session observed
|
||||
private int _wmiErrorCountSinceStart;
|
||||
private int _wtsErrorCountSinceStart;
|
||||
private bool _noCandidateFired;
|
||||
private readonly int _noCandidateTimeoutMinutes;
|
||||
|
||||
// Test seams (session 4d5a0b78 fix): owner resolution is injectable so unit tests can
|
||||
// drive the WTS-primary / WMI-fallback order without a live session manager or WinMgmt.
|
||||
// Null = production defaults (GetSessionOwnerViaWts / GetProcessOwnerViaWmi).
|
||||
internal Func<int, string> SessionOwnerResolver { get; set; }
|
||||
internal Func<int, string> ProcessOwnerResolver { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// System/service account names that should NOT be considered real users.
|
||||
/// </summary>
|
||||
@@ -132,6 +147,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_explorerEverSeen = false;
|
||||
_anyNonZeroSessionEverSeen = false;
|
||||
_wmiErrorCountSinceStart = 0;
|
||||
_wtsErrorCountSinceStart = 0;
|
||||
_noCandidateFired = false;
|
||||
|
||||
ArmTimer(startReason: "real-user-switch-reset");
|
||||
@@ -188,8 +204,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
_anyNonZeroSessionEverSeen = true;
|
||||
|
||||
var owner = GetProcessOwner(proc.Id);
|
||||
if (owner == null)
|
||||
var owner = ResolveOwner(proc.Id, proc.SessionId);
|
||||
if (string.IsNullOrEmpty(owner))
|
||||
continue;
|
||||
|
||||
// Check against exclusion list
|
||||
@@ -302,6 +318,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{ "explorerProcessCount", explorerProcessCount },
|
||||
{ "anyNonZeroSessionObserved", _anyNonZeroSessionEverSeen },
|
||||
{ "wmiErrorCount", _wmiErrorCountSinceStart },
|
||||
{ "wtsErrorCount", _wtsErrorCountSinceStart },
|
||||
});
|
||||
}
|
||||
catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: first-poll emit failed: {ex.Message}"); }
|
||||
@@ -337,6 +354,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{ "explorerEverSeen", _explorerEverSeen },
|
||||
{ "anyNonZeroSessionEverSeen", _anyNonZeroSessionEverSeen },
|
||||
{ "wmiErrorCount", _wmiErrorCountSinceStart },
|
||||
{ "wtsErrorCount", _wtsErrorCountSinceStart },
|
||||
});
|
||||
}
|
||||
catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: no-candidate emit failed: {ex.Message}"); }
|
||||
@@ -365,7 +383,65 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the owner of a process using WMI (Win32_Process.GetOwner).
|
||||
/// Resolves the owning user of an explorer.exe candidate. Session 4d5a0b78 fix
|
||||
/// (2026-06-11): the WMI <c>Win32_Process.GetOwner</c> call failed on every poll on
|
||||
/// some devices (wmiErrorCount == pollCount) while the agent ran for over an hour —
|
||||
/// the owner was never resolved, <c>desktop_arrived</c> never fired, and the Desktop
|
||||
/// half of the completion AND-gate starved. WTS is now the primary path: a single
|
||||
/// kernel round-trip against the session manager (no WinMgmt dependency, much
|
||||
/// cheaper than a WMI query), keyed by the process's session id — explorer.exe runs
|
||||
/// as the session's logged-on user. WMI remains the fallback when WTS returns no
|
||||
/// user for the session.
|
||||
/// <para>
|
||||
/// Returns "DOMAIN\User" or "User", or null when both paths fail. Each path's
|
||||
/// failures are counted separately (<see cref="_wtsErrorCountSinceStart"/> /
|
||||
/// <see cref="_wmiErrorCountSinceStart"/>) and surfaced via the first_poll /
|
||||
/// no_candidate liveness payloads.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal string ResolveOwner(int processId, int sessionId)
|
||||
{
|
||||
string owner = null;
|
||||
try
|
||||
{
|
||||
owner = (SessionOwnerResolver ?? GetSessionOwnerViaWts)(sessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"DesktopArrivalDetector: WTS owner resolution threw for session {sessionId}: {ex.Message}");
|
||||
}
|
||||
if (!string.IsNullOrEmpty(owner))
|
||||
return owner;
|
||||
|
||||
_wtsErrorCountSinceStart++;
|
||||
return (ProcessOwnerResolver ?? GetProcessOwnerViaWmi)(processId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Primary owner resolution: WTSQuerySessionInformation(WTSUserName/WTSDomainName)
|
||||
/// for the process's session. Returns null when the API fails or the session has no
|
||||
/// associated user (caller counts it and falls back to WMI).
|
||||
/// </summary>
|
||||
private string GetSessionOwnerViaWts(int sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = WtsNativeMethods.QuerySessionString(sessionId, WtsNativeMethods.WTSUserName);
|
||||
if (string.IsNullOrEmpty(user))
|
||||
return null;
|
||||
|
||||
var domain = WtsNativeMethods.QuerySessionString(sessionId, WtsNativeMethods.WTSDomainName);
|
||||
return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"DesktopArrivalDetector: WTS session query failed for session {sessionId}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fallback owner resolution: WMI (Win32_Process.GetOwner).
|
||||
/// Returns "DOMAIN\User" or "User" string, or null on failure.
|
||||
/// <para>
|
||||
/// P3 fix (2026-05-15): WMI exceptions are caught here AND bumped onto
|
||||
@@ -375,7 +451,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// leaving GetOwner-specific failures invisible.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private string GetProcessOwner(int processId)
|
||||
private string GetProcessOwnerViaWmi(int processId)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Win32 P/Invoke declarations for WTS (Remote Desktop Services) session queries.
|
||||
/// Used by <c>DesktopArrivalDetector</c> to resolve the logged-on user of an
|
||||
/// interactive session directly from the session manager — a single fast kernel
|
||||
/// round-trip, unlike the WMI <c>Win32_Process.GetOwner</c> path which depends on
|
||||
/// the WinMgmt service and fails persistently on some devices (session 4d5a0b78:
|
||||
/// GetOwner failed on every poll while WTS-level session data stayed available).
|
||||
/// </summary>
|
||||
internal static class WtsNativeMethods
|
||||
{
|
||||
/// <summary>WTS_INFO_CLASS.WTSUserName — user name associated with the session.</summary>
|
||||
public const int WTSUserName = 5;
|
||||
|
||||
/// <summary>WTS_INFO_CLASS.WTSDomainName — domain of the user associated with the session.</summary>
|
||||
public const int WTSDomainName = 7;
|
||||
|
||||
/// <summary>WTS_CURRENT_SERVER_HANDLE — query the local machine.</summary>
|
||||
public static readonly IntPtr WTS_CURRENT_SERVER_HANDLE = IntPtr.Zero;
|
||||
|
||||
[DllImport("wtsapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
public static extern bool WTSQuerySessionInformation(
|
||||
IntPtr hServer,
|
||||
int sessionId,
|
||||
int wtsInfoClass,
|
||||
out IntPtr ppBuffer,
|
||||
out int pBytesReturned);
|
||||
|
||||
[DllImport("wtsapi32.dll")]
|
||||
public static extern void WTSFreeMemory(IntPtr pMemory);
|
||||
|
||||
/// <summary>
|
||||
/// Queries a string-typed WTS session property (<see cref="WTSUserName"/> /
|
||||
/// <see cref="WTSDomainName"/>) for <paramref name="sessionId"/> on the local
|
||||
/// machine. Returns <c>null</c> when the API call fails; an empty string is a
|
||||
/// successful query with no value (e.g. no user associated with the session).
|
||||
/// Always frees the WTS buffer.
|
||||
/// </summary>
|
||||
public static string QuerySessionString(int sessionId, int wtsInfoClass)
|
||||
{
|
||||
var buffer = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
if (!WTSQuerySessionInformation(WTS_CURRENT_SERVER_HANDLE, sessionId, wtsInfoClass, out buffer, out var bytesReturned))
|
||||
return null;
|
||||
|
||||
// bytesReturned includes the trailing null terminator (2 bytes Unicode);
|
||||
// <= 2 means "no characters".
|
||||
return bytesReturned > 2 ? Marshal.PtrToStringUni(buffer) : string.Empty;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (buffer != IntPtr.Zero) WTSFreeMemory(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user