Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ release notes.

## [Unreleased]

### Fixed

- **`-Credential` found no session on Windows Home editions, and matched by name.** The session
list came from parsing `query user`, which Home editions do not ship (every run there fell back
to the stored-password task) and which prints localized text. Sessions now come from the owners
of each desktop's `explorer.exe` and `sihost.exe` through CIM, and the account is matched by
its SID when Windows resolves the credential's name, by the owner's name otherwise. Verified on
this Home machine and on the joined lab device with the Entra user signed in.

### Changed

- Validation kit: `Collect` moves its payload through the guest agent's file-read call in one
Expand Down
53 changes: 27 additions & 26 deletions Private/Get-IslLogonSession.ps1
Original file line number Diff line number Diff line change
@@ -1,45 +1,46 @@
function Get-IslLogonSession {
<#
.SYNOPSIS
Lists the interactive logon sessions on this machine: user, session name, id and state.
Lists the logon sessions with a desktop on this machine: user, SID and session id.

.DESCRIPTION
Parses "query user". The agent runs user-context scripts inside the signed-in user's own
session (session 2, UserInteractive true, on the lab device: REM-PROBE-USER64), so the
harness needs to know whether the account it is asked to run as holds a session before it
chooses between an interactive scheduled task and a stored-password one.
The agent runs user-context scripts inside the signed-in user's own session (session 2,
UserInteractive true, on the lab device: REM-PROBE-USER64), so the harness needs to know
whether the account it is asked to run as holds a session before it chooses between an
interactive scheduled task and a stored-password one.

The columns are separated by runs of spaces; a disconnected session prints no session
name, and the current session is marked with a leading ">".
A session with a desktop runs explorer.exe, or sihost.exe while the desktop is still
starting; the owner of that process is the session's user, and its SID is what the
credential's account is matched on. Earlier versions parsed "query user", which is not on
Windows Home editions and prints localized text. Reading another account's process owner
needs an elevated session, which the credential launch needs anyway; a process whose owner
cannot be read is left out.

.EXAMPLE
Get-IslLogonSession | Where-Object UserName -eq 'isl-user'
Get-IslLogonSession | Where-Object Sid -eq $account.Sid

The session of that account, if it has one.
#>
[CmdletBinding()]
[OutputType('IntuneScriptLab.LogonSession')]
param()

$lines = @(Get-IslQueryUserOutput)
$sessions = foreach ($line in ($lines | Select-Object -Skip 1)) {
if ([string]::IsNullOrWhiteSpace($line)) { continue }
$parts = @($line.Trim().TrimStart('>').Trim() -split '\s{2,}')
# user, session name, id, state, idle, logon time; a disconnected session has no name
if ($parts.Count -ge 6 -and $parts[2] -match '^\d+$') {
$userName, $sessionName, $id, $state = $parts[0], $parts[1], $parts[2], $parts[3]
}
elseif ($parts.Count -ge 5 -and $parts[1] -match '^\d+$') {
$userName, $sessionName, $id, $state = $parts[0], '', $parts[1], $parts[2]
}
else { continue }
$filter = "Name='explorer.exe' OR Name='sihost.exe'"
$shells = @(Get-CimInstance -ClassName Win32_Process -Filter $filter -ErrorAction SilentlyContinue)
$seen = @{}
foreach ($shell in ($shells | Sort-Object -Property SessionId, Name)) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CIM returns processes in no fixed order. Sorted so the output order is the same on both hosts and, where a session has both processes, the explorer.exe row is the one kept by the check below.

$owner = Invoke-CimMethod -InputObject $shell -MethodName GetOwner -ErrorAction SilentlyContinue
$sid = (Invoke-CimMethod -InputObject $shell -MethodName GetOwnerSid -ErrorAction SilentlyContinue).Sid
if (-not $sid -or -not $owner.User) { continue }
$key = "$($shell.SessionId)|$sid"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One session can hold several of these processes: sihost.exe next to explorer.exe, and a second explorer.exe when a folder window runs as its own process. One row per session and owner is what the caller counts.

if ($seen.ContainsKey($key)) { continue }
$seen[$key] = $true
[pscustomobject]@{
PSTypeName = 'IntuneScriptLab.LogonSession'
UserName = $userName
SessionName = $sessionName
Id = [int]$id
State = $state
PSTypeName = 'IntuneScriptLab.LogonSession'
UserName = $owner.User
Domain = $owner.Domain
Sid = $sid
Id = [int]$shell.SessionId
}
}
@($sessions)
}
23 changes: 0 additions & 23 deletions Private/Get-IslQueryUserOutput.ps1

This file was deleted.

12 changes: 7 additions & 5 deletions Private/Invoke-IslProcess.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -140,14 +140,16 @@
}
else {
$userName = $Credential.UserName
# "query user" lists the name Windows gives the account, which for an Entra account is
# neither the sign-in name nor a part of it; ask Windows before taking the name apart
# The session is found by the account's SID when Windows resolves the name; an Entra
# account's Windows name is neither the sign-in name nor a part of it. A name Windows
# cannot resolve is matched as text against the session owner's name
$resolved = Resolve-IslAccount -Name $userName
$account = if ($resolved) { ($resolved.Name -split '\\')[-1] }
elseif ($userName -match '\\') { ($userName -split '\\')[-1] }
$account = if ($userName -match '\\') { ($userName -split '\\')[-1] }
elseif ($userName -match '@') { ($userName -split '@')[0] }
else { $userName }
$sessions = @(Get-IslLogonSession | Where-Object { $_.UserName -eq $account })
$sessions = @(Get-IslLogonSession | Where-Object {
if ($resolved) { $_.Sid -eq $resolved.Sid } else { $_.UserName -eq $account }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The text match is the path for a name Windows cannot translate, where Resolve-IslAccount returns nothing. It keeps the behaviour from before #6 there, and the scheduler then reports the real error for the name itself.

})
$logon = if ($LogonType -ne 'Auto') { $LogonType }
elseif ($sessions.Count -gt 0) { 'Interactive' }
else { 'Password' }
Expand Down
97 changes: 51 additions & 46 deletions Tests/Unit/Private/Get-IslLogonSession.Tests.ps1
Original file line number Diff line number Diff line change
@@ -1,21 +1,42 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.2.0' }

<#
The "query user" parser the credential launch relies on to tell whether the account it runs as
holds a session (the agent runs user-context scripts inside the signed-in user's session,
REM-PROBE-USER64). The tool's output is mocked; one test reads the real tool for shape.
The session list the credential launch relies on to tell whether the account it runs as holds a
session (the agent runs user-context scripts inside the signed-in user's session,
REM-PROBE-USER64). Sessions come from the owners of explorer.exe and sihost.exe through CIM,
mocked here; one test reads the real machine for shape.
#>

BeforeAll {
$script:ModuleRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
Import-Module (Join-Path $script:ModuleRoot 'IntuneScriptLab.psd1') -Force

function script:Get-SessionFromText {
param([string[]]$Lines)
InModuleScope IntuneScriptLab -Parameters @{ Lines = $Lines } {
Mock Get-IslQueryUserOutput { $Lines }
@(Get-IslLogonSession)
# The fake machine: shell processes by session, and what GetOwner / GetOwnerSid answer for each.
# Module-scoped mocks see this file's $script: variables
$script:EntraSid = 'S-1-12-1-1497552185-1263987200-3276725654-805488699'
$script:LocalSid = 'S-1-5-21-1-2-3-1001'
$script:Owners = @{
100 = @{ Domain = 'KRBETYP-AIEPVQ5'; User = 'isl-user'; Sid = $script:LocalSid }
101 = @{ Domain = 'KRBETYP-AIEPVQ5'; User = 'isl-user'; Sid = $script:LocalSid }
200 = @{ Domain = 'AzureAD'; User = 'IslVerylongdisplayna'; Sid = $script:EntraSid }
300 = $null # an owner this session may not read
}
function script:New-Shell {
param([string]$Name, [int]$SessionId, [int]$ProcessId)
[pscustomobject]@{ Name = $Name; SessionId = $SessionId; ProcessId = $ProcessId }
}
function script:Get-Session {
param([object[]]$Shells)
$script:Shells = $Shells
Mock Get-CimInstance -ModuleName IntuneScriptLab { $script:Shells }
# The fake shells are plain objects, not CimInstances, so the parameter's type is lifted
Mock Invoke-CimMethod -ModuleName IntuneScriptLab -RemoveParameterType InputObject {
$owner = $script:Owners[[int]$InputObject.ProcessId]
if (-not $owner) { return }
if ($MethodName -eq 'GetOwnerSid') { [pscustomobject]@{ Sid = $owner.Sid } }
else { [pscustomobject]@{ Domain = $owner.Domain; User = $owner.User } }
}
InModuleScope IntuneScriptLab { @(Get-IslLogonSession) }
}
}

Expand All @@ -25,57 +46,41 @@ AfterAll {

Describe 'Get-IslLogonSession' -Tag 'Unit', 'Private' {

It 'parses an active console session and a disconnected one without a session name' {
$sessions = Get-SessionFromText -Lines @(
' USERNAME SESSIONNAME ID STATE IDLE TIME LOGON TIME'
'>jeffstuhr console 2 Active none 9/23/2026 8:50 AM'
' isl-user 3 Disc 1:23 9/28/2026 1:02 AM'
It 'lists one session per desktop user, from the owner of its shell processes' {
$sessions = Get-Session @(
(New-Shell 'sihost.exe' 1 101), (New-Shell 'explorer.exe' 1 100), (New-Shell 'explorer.exe' 2 200)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sihost.exe is listed before explorer.exe, and with the higher process id, on purpose: the result must not depend on the order CIM hands the processes back.

)
$sessions.Count | Should-Be 2
$sessions[0].PSObject.TypeNames | Should-ContainCollection 'IntuneScriptLab.LogonSession'
$sessions[0].UserName | Should-Be 'jeffstuhr'
$sessions[0].SessionName | Should-Be 'console'
$sessions[0].Id | Should-Be 2
$sessions[0].State | Should-Be 'Active'
$sessions[1].UserName | Should-Be 'isl-user'
$sessions[1].SessionName | Should-Be ''
$sessions[1].Id | Should-Be 3
$sessions[1].State | Should-Be 'Disc'
$sessions[0].UserName | Should-Be 'isl-user'
$sessions[0].Domain | Should-Be 'KRBETYP-AIEPVQ5'
$sessions[0].Sid | Should-Be $script:LocalSid
$sessions[0].Id | Should-Be 1
# The Entra account by the name Windows gives it, not its sign-in name (VM 125)
$sessions[1].UserName | Should-Be 'IslVerylongdisplayna'
$sessions[1].Domain | Should-Be 'AzureAD'
$sessions[1].Sid | Should-Be $script:EntraSid
$sessions[1].Id | Should-Be 2
}

It 'reads a 20-character name, the longest Windows gives an account, as printed on VM 125' {
# An Entra user named "Isl Verylongdisplayname Testaccount" who signs in as
# isl-verylongusername-test01@...: Windows calls it AzureAD\IslVerylongdisplayna, the display
# name without spaces cut at 20 characters, and the column still ends in two spaces
$sessions = @(Get-SessionFromText -Lines @(
' USERNAME SESSIONNAME ID STATE IDLE TIME LOGON TIME'
' islverylongdisplayna console 2 Active none 10/5/2026 11:05 AM'
))
It 'lists a session whose desktop is still starting, from sihost.exe alone' {
$sessions = @(Get-Session @((New-Shell 'sihost.exe' 2 200)))
$sessions.Count | Should-Be 1
$sessions[0].UserName | Should-Be 'islverylongdisplayna'
$sessions[0].SessionName | Should-Be 'console'
$sessions[0].Id | Should-Be 2
}

It 'returns nothing when nobody is logged on, the tool is missing, or only the header prints' {
@(Get-SessionFromText -Lines @()).Count | Should-Be 0
@(Get-SessionFromText -Lines @(' USERNAME SESSIONNAME ID STATE IDLE TIME LOGON TIME')).Count |
Should-Be 0
$sessions[0].Sid | Should-Be $script:EntraSid
}

It 'skips lines it cannot read rather than failing' {
$sessions = @(Get-SessionFromText -Lines @(
' USERNAME SESSIONNAME ID STATE IDLE TIME LOGON TIME'
'garbage line'
' isl-user rdp-tcp#1 4 Active none 9/28/2026 1:02 AM'
))
It 'leaves out a process whose owner it may not read, and returns nothing with no desktop at all' {
$sessions = @(Get-Session @((New-Shell 'explorer.exe' 3 300), (New-Shell 'explorer.exe' 1 100)))
$sessions.Count | Should-Be 1
$sessions[0].SessionName | Should-Be 'rdp-tcp#1'
$sessions[0].Id | Should-Be 1
@(Get-Session @()).Count | Should-Be 0
}

It 'reads the real tool without error and names a user for every session it finds' {
It 'reads the real machine without error and gives every session a SID and an id' {
# A CI runner has no desktop session; this machine has at least the one running the tests
$real = @(InModuleScope IntuneScriptLab { Get-IslLogonSession })
foreach ($session in $real) {
$session.Sid | Should-MatchString '^S-1-'
$session.UserName | Should-NotBeWhiteSpaceString
$session.Id | Should-BeGreaterThanOrEqual 0
}
Expand Down
15 changes: 8 additions & 7 deletions Tests/Unit/Private/Invoke-IslProcess.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ Describe 'Invoke-IslProcess' -Tag 'Unit', 'Private' {

It 'registers an interactive task for the account when it holds a session (REM-PROBE-USER64)' {
Mock Get-IslLogonSession -ModuleName IntuneScriptLab {
[pscustomobject]@{ UserName = 'isl-user'; SessionName = 'console'; Id = 2; State = 'Active' }
[pscustomobject]@{ UserName = 'isl-user'; Domain = 'LAB'; Sid = 'S-1-5-21-1-2-3-1001'; Id = 2 }
}
$result = Invoke-Process $script:LaunchSplat
$result.ExitCode | Should-Be 0
Expand All @@ -219,7 +219,7 @@ Describe 'Invoke-IslProcess' -Tag 'Unit', 'Private' {

It 'lets -LogonType force the stored-password task even when the account has a session' {
Mock Get-IslLogonSession -ModuleName IntuneScriptLab {
[pscustomobject]@{ UserName = 'isl-user'; SessionName = 'console'; Id = 2; State = 'Active' }
[pscustomobject]@{ UserName = 'isl-user'; Domain = 'LAB'; Sid = 'S-1-5-21-1-2-3-1001'; Id = 2 }
}
$result = Invoke-Process ($script:LaunchSplat + @{ LogonType = 'Password' })
$result.LogonType | Should-Be 'Password'
Expand All @@ -230,7 +230,7 @@ Describe 'Invoke-IslProcess' -Tag 'Unit', 'Private' {

It 'matches the session by account name whatever the credential prefix' {
Mock Get-IslLogonSession -ModuleName IntuneScriptLab {
[pscustomobject]@{ UserName = 'isl-user'; SessionName = 'console'; Id = 2; State = 'Active' }
[pscustomobject]@{ UserName = 'isl-user'; Domain = 'LAB'; Sid = 'S-1-5-21-1-2-3-1001'; Id = 2 }
}
$launchSplat = $script:LaunchSplat.Clone()
$launchSplat.Credential = [pscredential]::new('isl-user@lab.local', $script:Credential.Password)
Expand All @@ -240,8 +240,8 @@ Describe 'Invoke-IslProcess' -Tag 'Unit', 'Private' {
}

It 'finds an Entra account''s session by the name Windows gives it, not by its sign-in name (VM 125)' {
# Signed in as isl-verylongusername-test01@..., listed by "query user" as
# islverylongdisplayna; the scheduler takes the Windows name and refuses the sign-in name
# Signed in as isl-verylongusername-test01@..., the session owned by the SID that name
# resolves to; the scheduler takes the Windows name and refuses the sign-in name
Mock Resolve-IslAccount -ModuleName IntuneScriptLab {
[pscustomobject]@{
Name = 'AzureAD\IslVerylongdisplayna'
Expand All @@ -250,7 +250,8 @@ Describe 'Invoke-IslProcess' -Tag 'Unit', 'Private' {
}
Mock Get-IslLogonSession -ModuleName IntuneScriptLab {
[pscustomobject]@{
UserName = 'islverylongdisplayna'; SessionName = 'console'; Id = 2; State = 'Active'
UserName = 'IslVerylongdisplayna'; Domain = 'AzureAD'
Sid = 'S-1-12-1-1497552185-1263987200-3276725654-805488699'; Id = 2
}
}
$launchSplat = $script:LaunchSplat.Clone()
Expand All @@ -274,7 +275,7 @@ Describe 'Invoke-IslProcess' -Tag 'Unit', 'Private' {
[pscustomobject]@{ Name = 'AzureAD\SomeoneElse'; Sid = 'S-1-12-1-1-2-3-4' }
}
Mock Get-IslLogonSession -ModuleName IntuneScriptLab {
[pscustomobject]@{ UserName = 'isl-user'; SessionName = 'console'; Id = 2; State = 'Active' }
[pscustomobject]@{ UserName = 'isl-user'; Domain = 'LAB'; Sid = 'S-1-5-21-1-2-3-1001'; Id = 2 }
}
$launchSplat = $script:LaunchSplat.Clone()
$launchSplat.Credential = [pscredential]::new('isl-user@lab.local', $script:Credential.Password)
Expand Down
13 changes: 11 additions & 2 deletions Validation/Findings.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,8 +391,17 @@ anywhere, and the launcher waited out its timeout. 0.26.0 treats five seconds of
apart as text, which can only work when the Windows name happens to equal that text. That holds
for a local account and fails for an Entra account whatever its length: the Windows name comes
from the display name. `Resolve-IslAccount` now asks Windows for the account's SID (trying
`AzureAD\` in front of a sign-in name) and for the name behind that SID; the session is matched on
that name, the interactive task is registered for it, and the run folder is granted by SID.
`AzureAD\` in front of a sign-in name) and for the name behind that SID; the interactive task is
registered for that name and the run folder is granted by SID.

2026-10-06, the same device and user: the session list no longer comes from `query user`, which
Windows Home editions do not ship and which prints localized text, but from the owners of each
desktop's `explorer.exe` and `sihost.exe` through CIM, and the credential is matched by SID. As
SYSTEM the device listed one session, `AzureAD\IslVerylongdisplayna`,
`S-1-12-1-1497552185-1263987200-3276725654-805488699`, id 2, the SID the sign-in name resolves to;
`-Credential` with the sign-in name, `AzureAD\<sign-in name>` and the Windows name each ran the
script inside session 2 (`interactive=True`) and left no task or run folder behind. On a Windows
11 Home machine without `query.exe` the same list named its one console session.

### Reporting latency, re-measured (2026-09-29)

Expand Down
Loading