Creating PSSession-Based PowerShell Tools
Let's continue our exploration of creating and working with PSSession
objects built from scratch. While I would normally use New-PSSession
, using the .NET Framework shaves a few milliseconds off the process because I don't have the overhead of a cmdlet, and I can control the process more directly.
Today, I want to continue our exploration, including demonstrating how to make an SSH-based PSSession
. And I want to give you a sample scripting project.
Opening the Remote Runspace
Before we get to those topics, I want to briefly revisit working with the runspace that is part of the PSSession
object. I shared this script sample that will create a PSSession
.
#requires -version 7.4
#mkpssession.ps1
# You could modify to support credentials and/or SSL
Param([string]$ComputerName = $env:COMPUTERNAME)
$ci = [System.Management.Automation.Runspaces.WSManConnectionInfo]::new()
$ci.ComputerName = $ComputerName
$rs = [RunSpaceFactory]::CreateRunspace($ci)
$rs.ApartmentState = 'STA'
$rs.ThreadOptions = 'ReuseThread'
#script output
[System.Management.Automation.Runspaces.PSSession]::Create($rs, 'WSMan', $PSCmdlet)
When you run this, the connection to the remote computer is not open.
PS C:\> $a = c:\scripts\mkpssession.ps1 -computername dom1
PS C:\> $a
Id Name Transport ComputerName ComputerType State ConfigurationName Availability
-- ---- --------- ------------ ------------ ----- ----------------- ------------
13 Runspace13 WSMan dom1 RemoteMachine BeforeOpen Microsoft.PowerShell None
PS C:\> $a.Runspace
Id Name ComputerName Type State Availability
-- ---- ------------ ---- ----- ------------
19 Runspace19 dom1 Remote BeforeOpen None
```
Before I can invoke any code, the runspace must be opened. In the previous article I called the `Open()` method.
```powershell
$a.Runspace.Open()
This blocks your prompt while all the setup and authentication is happening. You can speed this process up by opening the runspace asynchronously.
$a.Runspace.OpenAsync()
This will make your code feel more responsive. I will demonstrate how I incorporate this into my PowerShell tooling a little bit later.
Because the code I'm using to create the PSSession
requires PowerShell 7, we might as well see how to setup an SSH-based PSSession
. I'm going to assume you've already configured PowerShell remoting with SSH in your environment.
Creating the SSH-based connection isn't that much different than using WSMan.
Creating an SSH-Based PSSession
The only difference in the process that I have been using, is that instead of using WSManConnectionInfo
, I use SSHConnectionInfo
.

Let's see what construction options we have

I know I want to create a connection to the PowerShell subsystem, so I will try with this overload.
System.Management.Automation.Runspaces.SSHConnectionInfo new(string userName, string computerName, string keyFilePath, int port, string subsystem)
Let's try.
$ci = [System.Management.Automation.Runspaces.SSHConnectionInfo]::new($env:Username,"Thinkx1-jh","",0,"powershell")
Why did I use port 0? I created a PSSession
using New-PSSession
and looked at the connection info.
PS C:\> $x.Runspace.ConnectionInfo
UserName : jeff
KeyFilePath :
Port : 0
Subsystem : powershell
ConnectingTimeout : -1
ComputerName : thinkx1-jh
Credential :
AuthenticationMechanism : Default
CertificateThumbprint :
Culture : en-US
UICulture : en-US
OpenTimeout : 180000
CancelTimeout : 60000
OperationTimeout : 180000
IdleTimeout : -1
MaxIdleTimeout : 2147483647
You'll see I created the same thing using the .NET class.
PS C:\> $ci
UserName : Jeff
KeyFilePath :
Port : 0
Subsystem : powershell
ConnectingTimeout : -1
ComputerName : Thinkx1-jh
Credential :
AuthenticationMechanism : Default
CertificateThumbprint :
Culture : en-US
UICulture : en-US
OpenTimeout : 180000
CancelTimeout : 60000
OperationTimeout : 180000
IdleTimeout : -1
MaxIdleTimeout : 2147483647
I continue creating the runspace and the PSSession.
$rs = [RunspaceFactory]::CreateRunspace($ci)
$ps = [System.Management.Automation.Runspaces.PSSession]::Create($rs,"SSH",$PSCmdlet)
Notice I am using SSH
for the transport. Because I am not using a key ring with the computer, I have to enter the user password, so I don't open the runspace asynchronously.
$ps.Runspace.Open()
I can then define the pipeline and commands to run.
$pl = $ps.Runspace.CreatePipeline()
$pl.commands.AddScript({$PSVersionTable.Add("Computername",$env:ComputerName) ; $PSVersionTable})
$pl.invoke() | tee -Variable r
It works!
PS C:\> $r
Name Value
---- -----
SerializationVersion 1.1.0.1
WSManStackVersion 3.0
Computername THINKX1-JH
PSVersion 7.4.2
PSCompatibleVersions {1.0, 2.0, 3.0, 4.0…}
Platform Win32NT
GitCommitId 7.4.2
PSRemotingProtocolVersion 2.3
PSEdition Core
OS Microsoft Windows 10.0.22635
Sample Tooling
With this in mind, I can re-visit my proof-of-concept script. This is a quick and dirty alternative to New-PSSession
.
#requires -version 7.4
#mkpssession.ps1
# You could modify to support credentials and/or SSL
Param(
[string]$ComputerName = $env:COMPUTERNAME,
[switch]$SSH,
#The Username will only be used with SSH
[string]$Username = $env:USERNAME
)
if ($SSH) {
$ci = [System.Management.Automation.Runspaces.SSHConnectionInfo]::new($Username, $ComputerName, '', 0, 'powershell')
$Transport = 'SSH'
}
else {
$ci = [System.Management.Automation.Runspaces.WSManConnectionInfo]::new()
$ci.ComputerName = $ComputerName
$Transport = 'WSMan'
}
$rs = [RunSpaceFactory]::CreateRunspace($ci)
$rs.ApartmentState = 'STA'
$rs.ThreadOptions = 'ReuseThread'
#script output
$ps = [System.Management.Automation.Runspaces.PSSession]::Create($rs, $Transport, $PSCmdlet)
#open the session
$ps.Runspace.Open()
#write the session object to the pipeline
$ps
In this version, I am letting the user opt for an SSH connection, including specifying a different user name. This is not written as production-ready.
PS C:\> $t = c:\scripts\mkpssession2.ps1 -ComputerName thinkx1-jh -SSH
Jeff@thinkx1-jh's password:
PS C:\> $t.Runspace
Id Name ComputerName Type State Availability
-- ---- ------------ ---- ----- ------------
8 Runspace8 thinkx1-jh Remote Opened Available
PS C:\> $t.Runspace.ConnectionInfo
UserName : Jeff
KeyFilePath :
Port : 0
Subsystem : powershell
ConnectingTimeout : -1
ComputerName : thinkx1-jh
Credential :
AuthenticationMechanism : Default
CertificateThumbprint :
Culture : en-US
UICulture : en-US
OpenTimeout : 180000
CancelTimeout : 60000
OperationTimeout : 180000
IdleTimeout : -1
MaxIdleTimeout : 2147483647
Get-SystemStatusInfo
Let's wrap up with a demonstration function. I wanted to simplify the parameters so there is a single parameter that will accept strings or PSSession objects.
#requires -version 7.4
<#
This script will only create WSMan connections.
If you want to use SSH, create a session with New-PSSession
and pass it to the -PSSession parameter.
The code should be considered a proof of concept and is
not intended for production use.
#>
Function Get-SystemStatus {
[cmdletbinding()]
[OutputType('psSystemStatus')]
[alias('gss')]
Param(
[Parameter(
Position = 0,
ValueFromPipeline,
HelpMessage = 'Specify a computer name or an existing PSSession object'
)]
[ValidateNotNullOrEmpty()]
[object[]]$PSSession = $env:COMPUTERNAME
)
Begin {
#create a stopwatch and display running time in verbose messages
$sw = [System.Diagnostics.Stopwatch]::new()
$sw.Start()
Write-Verbose "[$($sw.Elapsed) BEGIN ] Starting $($MyInvocation.MyCommand)"
Write-Verbose "[$($sw.Elapsed) BEGIN ] Running under PowerShell version $($PSVersionTable.PSVersion)"
#the script block that will be executed remotely
$sb = {
$OS = Get-CimInstance -ClassName Win32_OperatingSystem -Property TotalVisibleMemorySize, Caption
Try {
#Testing the path if faster than waiting for Get-Command to fail
#(Get-Command -Name pwsh -CommandType Application -ErrorAction Stop).Name -eq 'pwsh.exe'
$PwshTest = Test-Path 'C:\Program Files\PowerShell\7\pwsh.exe'
}
Catch {
$PwshTest = $false
}
$SshTest = (Get-CimInstance win32_service -Filter "name='sshd'").name -eq 'sshd'
[PSCustomObject]@{
PSTypeName = 'psSystemStatus'
ComputerName = $env:COMPUTERNAME
OperatingSystem = $OS.Caption
WindowsPowerShell = $PSVersionTable.PSVersion
InstalledMemory = $OS.TotalVisibleMemorySize
ProcessCount = (Get-Process).Count
PowerShellInstalled = $PwshTest
SSHInstalled = $SshTest
}
} #close script block
#a private helper function
function mkpssession {
Param([string]$ComputerName = $env:COMPUTERNAME)
$ci = [System.Management.Automation.Runspaces.WSManConnectionInfo]::new()
$ci.ComputerName = $ComputerName.ToUpper()
$rs = [RunSpaceFactory]::CreateRunspace($ci)
$rs.ApartmentState = "STA"
$rs.ThreadOptions = "ReuseThread"
#this static method is not available in Windows PowerShell
[System.Management.Automation.Runspaces.PSSession]::Create($rs, 'WSMan', $PSCmdlet)
}
} #begin
Process {
Foreach ($remote in $PSSession) {
If ($remote -Is [String]) {
#create a PSSession runspace with the .NET Framework
Write-Verbose "[$($sw.Elapsed) PROCESS] Creating a temporary PSSession object"
$ps = mkpssession -ComputerName $remote
Write-Verbose "[$($sw.Elapsed) PROCESS] Opening the runspace"
$ps.Runspace.OpenAsync()
$Temporary = $True
}
elseif ($remote -Is [System.Management.Automation.Runspaces.PSSession]){
Write-Verbose "[$($sw.Elapsed) PROCESS] Using existing PSSession object"
$ps = $remote
}
else {
Write-Warning "Can't use the values provided for PSSession."
}
if ($ps) {
#Need to loop until the runspace is opened
while ($ps.Runspace.RunspaceStateInfo.State -ne 'Opened') {
Start-Sleep -Milliseconds 50
}
Write-Verbose "[$($sw.Elapsed) PROCESS] Processing $($ps.ComputerName.ToUpper())"
#Invoke-Command -ScriptBlock $sb -Session $ps
$pl = $ps.Runspace.CreatePipeline()
$pl.Commands.AddScript($sb)
Write-Verbose "[$($sw.Elapsed) PROCESS] Invoking the script block"
$pl.invoke()
if ($Temporary) {
Write-Verbose "[$($sw.Elapsed) PROCESS] Removing the temporary PSSession object"
$ps.Runspace.Close()
Remove-PSSession -Session $ps
}
}
}
} #process
End {
$sw.Stop()
Write-Verbose "[$($sw.Elapsed) END ] Ending $($MyInvocation.MyCommand)"
} #end
} #close Get-SystemStatus
The function has a nested helper function to create a WSMan PSSession
if the PSSession parameter is a String
Foreach ($remote in $PSSession) {
If ($remote -Is [String]) {
#create a PSSession runspace with the .NET Framework
Write-Verbose "[$($sw.Elapsed) PROCESS] Creating a temporary PSSession object"
$ps = mkpssession -ComputerName $remote
Write-Verbose "[$($sw.Elapsed) PROCESS] Opening the runspace"
$ps.Runspace.OpenAsync()
$Temporary = $True
}
elseif ($remote -Is [System.Management.Automation.Runspaces.PSSession]){
Write-Verbose "[$($sw.Elapsed) PROCESS] Using existing PSSession object"
$ps = $remote
}
else {
Write-Warning "Can't use the values provided for PSSession."
}
...
The function invokes the defined script block on each remote computer to return system status information.

One unrelated feature, but one you might be interested in, is that I am using a StopWatch
object.
Begin {
$sw = [System.Diagnostics.Stopwatch]::new()
$sw.Start
The verbose output shows the elapsed time.
Write-Verbose "[$($sw.Elapsed) BEGIN ] Starting $($MyInvocation.MyCommand)"
This let's me see how long each step in the process is taking to complete.
I wrote the function to also consume existing PSSessions
.
PS C:\> Get-PSSession | Select Computername,Transport
ComputerName Transport
------------ ---------
srv1 SSH
win10 WSMan
dom1 WSMan
srv2 WSMan
thinkx1-jh SSH
PS C:\> Get-PSSession | Get-SystemStatus | Select Computername,InstalledMemory,*Installed
ComputerName InstalledMemory PowerShellInstalled SSHInstalled
------------ --------------- ------------------- ------------
SRV1 1047496 True True
WIN10 4193224 True True
DOM1 1526728 False False
SRV2 1047496 False True
THINKX1-JH 33267032 True True
What I can't do is mix a computer name with an existing PSSession. But this is no different than if I was using parameter sets in a traditional solution so I'm OK with that.
Summary
As always, I hope you'll find some time to try the code samples. That's the best way at understanding how they work and why. Even if you never have a need to write custom tooling using the .NET Framework PSSession
class, I hope you have a better understanding of of PowerShell is doing when you use the traditional cmdlets.