Skip to content

Instantly share code, notes, and snippets.

@jborean93
Last active October 14, 2026 02:10
Show Gist options
  • Select an option

  • Save jborean93/f5605dc05d9395831800753014222b7b to your computer and use it in GitHub Desktop.

Select an option

Save jborean93/f5605dc05d9395831800753014222b7b to your computer and use it in GitHub Desktop.
Opens a PSSession with support for local callback operations.
# Copyright: (c) 2026, Jordan Borean (@jborean93) <jborean93@gmail.com>
# MIT License (see LICENSE or https://opensource.org/licenses/MIT)
using namespace System.Management.Automation
# Requires New-PSSessionWithCallback.ps1 to be loaded first for New-PSSessionCallback
# and Enter-PSSessionWithCallback.
function New-PSSessionEditCallback {
<#
.SYNOPSIS
Creates a callback that edits a remote file in the client's editor.
.DESCRIPTION
Creates a callback for Enter-PSSessionWithCallback that defines a function to edit a
remote file with an editor on the client. The function reads the remote file's
bytes, the client edits a temporary copy and the result is written back to the
remote file if it changed. A file that doesn't exist is created when saved.
The editor is the first of:
-Editor parameter value
$env:VISUAL
$env:EDITOR
Windows: the file type's edit verb, otherwise notepad.exe
Other platforms: the first of editor, nano or vi that is found
-Editor, $env:VISUAL and $env:EDITOR can include arguments, like 'code --wait', and
the file path is added to the end. Quote the executable if its path has spaces.
The editor is resolved when the callback is created so a missing editor is an error
before entering the session. Changes to $env:VISUAL or $env:EDITOR afterwards don't
apply to the callback.
The editor command must not return until the file has been edited and closed, the
file is read back as soon as it does. GUI editors that pass the file to an existing
window return straight away, use an option that waits like 'code --wait'. A non-zero
exit code, like :cq in vim, cancels the edit.
.PARAMETER Name
The name of the function to define in the remote session. Defaults to PSEdit.
.PARAMETER Editor
The editor command to use instead of $env:VISUAL, $env:EDITOR or the platform
default.
.EXAMPLE
New-PSSessionEditCallback | Enter-PSSessionWithCallback -Target server
Enters a session on server where 'PSEdit file.txt' edits the remote file with the
client's default editor.
.EXAMPLE
New-PSSessionEditCallback -Editor 'code --wait' | Enter-PSSessionWithCallback server
Edits remote files with VS Code.
.INPUTS
None
.OUTPUTS
PSSessionCallback
The callback definition to pass to Enter-PSSessionWithCallback.
#>
[CmdletBinding()]
[OutputType('PSSessionCallback')]
param (
[Parameter(Position = 0)]
[ValidatePattern('^[\w-]+$')]
[string]
$Name = 'PSEdit',
[Parameter()]
[string]
$Editor
)
$editorCommand = @($Editor, $env:VISUAL, $env:EDITOR) |
Where-Object { $_ } |
Select-Object -First 1
# The callback gets the resolved editor through its state. On Windows without an
# editor command the edit verb is used, it depends on the file type so it's picked
# when the file is edited.
$editorInfo = if ($editorCommand) {
# The whole value is the executable if it resolves, like a path with spaces,
# otherwise a leading quoted value or the first word is.
$commandLine = $editorCommand.Trim()
$exe, $editorArgs = if (Get-Command -Name $commandLine -CommandType Application -ErrorAction Ignore) {
$commandLine, ''
}
elseif ($commandLine -match '^"([^"]+)"\s*(.*)$' -or $commandLine -match '^(\S+)\s*(.*)$') {
$Matches[1], $Matches[2]
}
$exeInfo = Get-Command -Name $exe -CommandType Application -ErrorAction Ignore |
Select-Object -First 1
if (-not $exeInfo) {
$e = [ErrorRecord]::new(
[ArgumentException]"The editor '$exe' from '$editorCommand' could not be found.",
"EditorNotFound",
[ErrorCategory]::ObjectNotFound,
$editorCommand)
$PSCmdlet.ThrowTerminatingError($e)
}
@{ FilePath = $exeInfo.Source; Arguments = $editorArgs }
}
elseif ($IsWindows -or $PSVersionTable.PSEdition -eq 'Desktop') {
# $IsWindows is not defined until 7.x so we also check the PSEdition for
# Desktop to cover Windows PowerShell.
@{ UseEditVerb = $true }
}
else {
$exeInfo = Get-Command -Name editor, nano, vi -CommandType Application -ErrorAction Ignore |
Select-Object -First 1
if (-not $exeInfo) {
$e = [ErrorRecord]::new(
[ArgumentException]"No editor found, set `$env:EDITOR or use -Editor.",
"EditorNotFound",
[ErrorCategory]::ObjectNotFound,
$null)
$PSCmdlet.ThrowTerminatingError($e)
}
@{ FilePath = $exeInfo.Source; Arguments = '' }
}
$newParams = @{
Name = $Name
State = $editorInfo
Pre = {
<#
.SYNOPSIS
Edits a file in this session with the client's editor.
.PARAMETER Path
The file to edit, it is created when saved if it doesn't exist.
#>
[CmdletBinding()]
param ([Parameter(Mandatory, Position = 0)][string]$Path)
$fullPath = $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
$content = [byte[]]@()
if (Test-Path -LiteralPath $fullPath -PathType Leaf) {
$content = [System.IO.File]::ReadAllBytes($fullPath)
}
[System.IO.Path]::GetFileName($fullPath)
# The unary comma outputs the byte array as one object rather than each
# byte.
, $content
}
Callback = {
param ($FileName, $Content)
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName())
$null = New-Item -ItemType Directory -Path $tempDir
try {
# Keep the file name so the editor detects the file type. GetFileName
# stops a malicious remote side from writing outside the temp directory
# with a name like '../../.bashrc'.
$tempFile = Join-Path $tempDir ([System.IO.Path]::GetFileName($FileName))
[System.IO.File]::WriteAllBytes($tempFile, $Content)
# The editor was resolved when the callback was created. Native commands
# run here have their stdout redirected, Start-Process keeps console
# editors like vim on the console.
$editor = $PSSessionCallbackContext.State
$proc = if (-not $editor.UseEditVerb) {
$editorArgs = "$($editor.Arguments) `"$tempFile`"".Trim()
Start-Process -FilePath $editor.FilePath -ArgumentList $editorArgs -NoNewWindow -Wait -PassThru
}
elseif ([System.Diagnostics.ProcessStartInfo]::new($tempFile).Verbs -contains 'edit') {
Start-Process -FilePath $tempFile -Verb Edit -Wait -PassThru
}
else {
Start-Process -FilePath notepad.exe -ArgumentList "`"$tempFile`"" -Wait -PassThru
}
if ($proc -and $proc.ExitCode -ne 0) {
# :cq in vim exits non-zero, treat it as cancelling the edit.
return
}
$newContent = [System.IO.File]::ReadAllBytes($tempFile)
if (-not [System.Linq.Enumerable]::SequenceEqual($newContent, [byte[]]$Content)) {
, $newContent
}
}
finally {
Remove-Item -LiteralPath $tempDir -Recurse -Force
}
}
Post = {
param ($Content)
if ($null -eq $Content) {
Write-Host "No changes made to '$fullPath'"
return
}
[System.IO.File]::WriteAllBytes($fullPath, $Content)
}
}
New-PSSessionCallback @newParams
}
# Copyright: (c) 2026, Jordan Borean (@jborean93) <jborean93@gmail.com>
# MIT License (see LICENSE or https://opensource.org/licenses/MIT)
using namespace System.Management.Automation
# Requires New-PSSessionWithCallback.ps1 to be loaded first for New-PSSessionCallback
# and Enter-PSSessionWithCallback.
function New-PSSessionToastCallback {
<#
.SYNOPSIS
Creates a callback that shows a toast notification on the client.
.DESCRIPTION
Creates a callback for Enter-PSSessionWithCallback that defines a function to show a
Windows toast notification on the client with the BurntToast module. This can be
used to get notified when a long running remote command finishes, for example
'Start-LongTask; Show-LocalToastNotification "Task finished"'.
The notifications are grouped under a header with the name of the remote computer
so notifications from different sessions can be told apart.
The client must be running on Windows with the BurntToast module installed, it is
resolved when the callback is created so a missing module is an error before
entering the session. The remote session doesn't need BurntToast.
.PARAMETER Name
The name of the function to define in the remote session. Defaults to
Show-LocalToastNotification.
.PARAMETER AppLogo
The path to an image on the client to use as the notification's logo instead of the
BurntToast default.
.EXAMPLE
New-PSSessionToastCallback | Enter-PSSessionWithCallback -Target server
Enters a session on server where 'Show-LocalToastNotification "Build done"' shows a
toast notification on the client.
.EXAMPLE
Show-LocalToastNotification 'Build failed', "Exit code $LASTEXITCODE" -Silent
In the remote session, shows a notification with a title and message without
playing a sound.
.INPUTS
None
.OUTPUTS
PSSessionCallback
The callback definition to pass to Enter-PSSessionWithCallback.
.LINK
https://github.com/Windos/BurntToast
#>
[CmdletBinding()]
[OutputType('PSSessionCallback')]
param (
[Parameter(Position = 0)]
[ValidatePattern('^[\w-]+$')]
[string]
$Name = 'Show-LocalToastNotification',
[Parameter()]
[string]
$AppLogo
)
# $IsWindows is not defined until 7.x so we also check the PSEdition for Desktop to
# cover Windows PowerShell.
if (-not ($IsWindows -or $PSVersionTable.PSEdition -eq 'Desktop')) {
$e = [ErrorRecord]::new(
[PlatformNotSupportedException]"Toast notifications are only supported on Windows.",
"PlatformNotSupported",
[ErrorCategory]::NotImplemented,
$null)
$PSCmdlet.ThrowTerminatingError($e)
}
$module = Get-Module -Name BurntToast -ListAvailable |
Sort-Object -Property Version -Descending |
Select-Object -First 1
if (-not $module) {
$e = [ErrorRecord]::new(
[ArgumentException]"The BurntToast module is not installed, install it with 'Install-Module BurntToast'.",
"BurntToastNotFound",
[ErrorCategory]::ObjectNotFound,
$null)
$PSCmdlet.ThrowTerminatingError($e)
}
$logoPath = $null
if ($AppLogo) {
$logoPath = $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($AppLogo)
if (-not (Test-Path -LiteralPath $logoPath -PathType Leaf)) {
$e = [ErrorRecord]::new(
[ArgumentException]"The app logo '$logoPath' could not be found.",
"AppLogoNotFound",
[ErrorCategory]::ObjectNotFound,
$AppLogo)
$PSCmdlet.ThrowTerminatingError($e)
}
}
$newParams = @{
Name = $Name
State = @{ ModulePath = $module.Path; AppLogo = $logoPath }
# Without a Pre this param block is the remote function's and is validated
# again here, the remote side can only choose the text and whether it's silent.
# The logo is a client path so it comes from the state rather than the remote.
Callback = {
<#
.SYNOPSIS
Shows a toast notification on the client.
.PARAMETER Text
The lines of text to show, up to 3. The first line is the title.
.PARAMETER Silent
Show the notification without playing a sound.
#>
[CmdletBinding()]
param (
[Parameter(Mandatory, Position = 0)]
[ValidateCount(1, 3)]
[string[]]
$Text,
[Parameter()]
[switch]
$Silent
)
$toast = $PSSessionCallbackContext.State
Import-Module -Name $toast.ModulePath -ErrorAction Stop
$computerName = $PSSessionCallbackContext.ConnectionInfo.ComputerName
$toastParams = @{
Text = $Text
Header = New-BTHeader -Id "PSSessionCallback-$computerName" -Title $computerName
}
if ($toast.AppLogo) {
$toastParams.AppLogo = $toast.AppLogo
}
if ($Silent) {
$toastParams.Silent = $true
}
New-BurntToastNotification @toastParams
}
}
New-PSSessionCallback @newParams
}
# Copyright: (c) 2026, Jordan Borean (@jborean93) <jborean93@gmail.com>
# MIT License (see LICENSE or https://opensource.org/licenses/MIT)
using namespace System.Collections.Generic
using namespace System.Management.Automation
using namespace System.Management.Automation.Host
using namespace System.Management.Automation.Language
using namespace System.Management.Automation.Runspaces
using namespace System.Linq.Expressions
using namespace System.Reflection
# Inspired by https://gist.github.com/SeeminglyScience/1b72f0fde3840b275dd23b4104776c5d
# I've tried to take it a step further and make it generic to support custom callbacks
# and not just editing.
function New-PSSessionCallback {
<#
.SYNOPSIS
Defines a function in a remote session that calls back to the client.
.DESCRIPTION
Creates a callback definition to pass to Enter-PSSessionWithCallback. It defines a
function with the given name in the remote session. When that function is called, it
runs Pre remotely, then Callback on the client with Pre's output as positional
arguments, then Post remotely with Callback's output as positional arguments. Post's
output is the function's output.
The remote function blocks while Callback runs, so the client console isn't reading
input and Callback can use it freely, for example to run an interactive TUI.
.PARAMETER Name
The name of the function to define in the remote session. Each callback passed to
Enter-PSSessionWithCallback needs a unique name.
.PARAMETER Callback
The scriptblock to run on the local client. It receives Pre's output as positional
arguments, or the remote function's parameters by name if there is no Pre, and its
output is passed to Post. If starting an interactive process, use Start-Process
-NoNewWindow -Wait instead of calling it as a command. This ensures the console's
TTYs are properly attached to the interactive process and not through PowerShell's
redirected stdout pipe.
The variable $PSSessionCallbackContext is available to the scriptblock with the
properties Name (the callback name), State (the -State object), and ConnectionInfo
(the RunspaceConnectionInfo) used to connect to the remote session, for example
$PSSessionCallbackContext.ConnectionInfo.ComputerName for a WSManConnectionInfo.
Treat its arguments as untrusted input, anything running in the remote session can
invoke it.
.PARAMETER Pre
The scriptblock to run in the remote session before Callback. Its param block
becomes the remote function's parameters and its output is passed to Callback as
positional arguments. Comment-based help before the param block becomes the remote
function's help.
If not set, Callback's param block, attributes and help become the remote function's
and the parameters bound remotely are passed to Callback by name. Any unbound
arguments are passed positionally. The param block is evaluated on both sides, so
its types and validation attributes need to work remotely as well.
.PARAMETER Post
The scriptblock to run in the remote session after Callback. It receives Callback's
output as positional arguments and its output is the remote function's output. It
runs in the same scope as Pre, so it can use variables Pre set. Defaults to
outputting Callback's output.
Pre and Post can use the variable $PSSessionCallbackContext with the properties
Name (the callback name) and State (the remote copy of -State).
.PARAMETER State
An object available to the scriptblocks as $PSSessionCallbackContext.State.
Callback gets this object, so changes it makes persist across calls and are visible
to the caller. Pre and Post get a copy serialized to the remote session once when
it's set up, changes they make persist across calls in that session but aren't
sent back to the client. Like any remoting value, the copy is only serialized to a
limited depth.
.EXAMPLE
New-PSSessionEditCallback | Enter-PSSessionWithCallback -Target server
Enters a session on server where 'PSEdit file.txt' edits the remote file with the
client's editor. New-PSSessionEditCallback creates the callback with a Pre that
reads the remote file, a Callback that edits it locally and a Post that writes it
back, its source is a complete example of using all three.
.EXAMPLE
New-PSSessionCallback Get-LocalName { [Environment]::MachineName } |
Enter-PSSessionWithCallback -Target server
Defines Get-LocalName in the remote session, which outputs the client's machine
name. Without Pre and Post the function's arguments go straight to Callback and
Callback's output is returned.
.INPUTS
None
.OUTPUTS
PSSessionCallback
The callback definition to pass to Enter-PSSessionWithCallback.
.NOTES
Pre and Post are sent to the remote session as text, so they can't see local
variables and $using: isn't supported. Values passed between the client and the
remote session are serialized like any remoting output. The remote function doesn't
support pipeline input.
If Callback throws, the remote function throws "Callback for '<Name>' failed:
<message>" and Post doesn't run.
#>
[CmdletBinding()]
[OutputType('PSSessionCallback')]
param (
[Parameter(Mandatory, Position = 0)]
[ValidatePattern('^[\w-]+$')]
[string]
$Name,
[Parameter(Mandatory, Position = 1)]
[scriptblock]
$Callback,
[Parameter()]
[scriptblock]
$Pre,
[Parameter()]
[scriptblock]
$Post = { $args },
[Parameter()]
[object]
$State
)
[PSCustomObject]@{
PSTypeName = 'PSSessionCallback'
Name = $Name
Pre = $Pre
Callback = $Callback
Post = $Post
State = $State
}
}
function Enter-PSSessionWithCallback {
<#
.SYNOPSIS
Starts an interactive session with a remote computer that can call back to the
client.
.DESCRIPTION
Enters an interactive session with a remote computer like Enter-PSSession, with the
functions defined by New-PSSessionCallback available in it. When one of those
functions runs remotely, it runs its Callback scriptblock on the client before
continuing in the remote session.
Exiting the session closes the remote connection.
.PARAMETER Target
The target to connect to, its meaning depends on -Transport:
WinRM: the computer name.
SSH: [user@]host[:port] like Enter-PSSession -HostName.
NamedPipe: the custom pipe name of a local pwsh process, like pwsh -CustomPipeName.
PwshProcess: the process id of a local pwsh process, like Enter-PSHostProcess.
-ComputerName is an alias of this parameter.
.PARAMETER Transport
The protocol used to connect to -Target, WinRM, SSH, NamedPipe or
PwshProcess. Defaults to WinRM. Use -ConnectionInfo for anything these don't cover,
like credentials or other transports.
.PARAMETER ConnectionInfo
The connection info to connect with, for example a WSManConnectionInfo with
credentials or an SSHConnectionInfo with a key file. It is available to the
callbacks as $PSSessionCallbackContext.ConnectionInfo.
.PARAMETER Callback
The callback definitions from New-PSSessionCallback to define in the remote session.
Accepts pipeline input. A definition with invalid values, or with the same name as
an earlier one, writes an error and is skipped.
.EXAMPLE
New-PSSessionCallback Get-LocalName { [Environment]::MachineName } |
Enter-PSSessionWithCallback -Target server
Enters a session on server where Get-LocalName outputs the client's machine name.
.EXAMPLE
$callbacks | Enter-PSSessionWithCallback admin@server:2222 -Transport SSH
Enters a session on server over SSH as admin on port 2222.
.EXAMPLE
$connInfo = [System.Management.Automation.Runspaces.WSManConnectionInfo]@{
ComputerName = 'server'
Credential = Get-Credential
}
$callbacks | Enter-PSSessionWithCallback -ConnectionInfo $connInfo
Enters a session on server over WinRM with explicit credentials.
.INPUTS
PSSessionCallback
The callback definitions from New-PSSessionCallback.
.OUTPUTS
None
.NOTES
The host must support pushing runspaces, like the pwsh console. Ctrl+C while a
Callback scriptblock runs stops the remote function, not the scriptblock, unless the
scriptblock reads Ctrl+C as input, for example by setting
[Console]::TreatControlCAsInput.
#>
[CmdletBinding(DefaultParameterSetName = 'Target')]
param (
[Parameter(Mandatory, Position = 0, ParameterSetName = 'Target')]
[Alias('ComputerName')]
[ValidateNotNullOrEmpty()]
[string]
$Target,
[Parameter(ParameterSetName = 'Target')]
[ValidateSet('WinRM', 'SSH', 'NamedPipe', 'PwshProcess')]
[string]
$Transport = 'WinRM',
[Parameter(Mandatory, ParameterSetName = 'ConnectionInfo')]
[RunspaceConnectionInfo]
$ConnectionInfo,
[Parameter(ValueFromPipeline)]
[PSTypeName('PSSessionCallback')]
[PSObject[]]
$Callback
)
begin {
if (-not ($Host -and $Host -is [IHostSupportsInteractiveSession])) {
$e = [ErrorRecord]::new(
[ArgumentException]"The host does not support the required user interface.",
"HostIsNotInteractive",
[ErrorCategory]::InvalidArgument,
$null)
$PSCmdlet.ThrowTerminatingError($e)
}
# Events forwarded from the remote side land in the remote runspace's own event
# queue. New-PSSession subscribes to that queue and re-queues each event locally
# so our subscribers see it, a runspace from RunspaceFactory doesn't. A
# scriptblock can't be the handler as it is raised on a remoting thread with no
# runspace. We could use Add-Type but as it's a simple enough delegate we use an
# expression tree to avoid the compilation overhead. The event's sender is set
# to an object holding the remote runspace and this session's local callbacks,
# so $OnInvoke knows what to run and how to tell the remote side it's done.
# Unlike New-PSSession, only our own event is forwarded so the remote side can't
# raise other local events, like PowerShell.Exiting or the user's own engine
# events. The C# equivalent is:
#
# PSEventManager target = ...;
# Func<object, PSEventReceivedEventHandler> newForwardEventHandler =
# session => (sender, e) =>
# {
# if (e.SourceIdentifier == "PSSessionCallback.Invoke")
# {
# target.GenerateEvent(
# e.SourceIdentifier, session, e.SourceArgs, e.MessageData);
# }
# };
$invokeEventName = 'PSSessionCallback.Invoke'
$sessionParam = [Expression]::Parameter([object], 'session')
$senderParam = [Expression]::Parameter([object], 'sender')
$eParam = [Expression]::Parameter([PSEventArgs], 'e')
$generateEvent = [PSEventManager].GetMethod(
'GenerateEvent',
[type[]]@([string], [object], [object[]], [psobject]))
$forwardEventHandler = [Expression]::Lambda(
[PSEventReceivedEventHandler],
[Expression]::IfThen(
[Expression]::Equal(
[Expression]::Property($eParam, 'SourceIdentifier'),
[Expression]::Constant($invokeEventName)),
[Expression]::Call(
[Expression]::Constant($Host.Runspace.Events),
$generateEvent,
[Expression]::Property($eParam, 'SourceIdentifier'),
$sessionParam,
[Expression]::Property($eParam, 'SourceArgs'),
[Expression]::Property($eParam, 'MessageData'))),
[ParameterExpression[]]@($senderParam, $eParam))
$newForwardEventHandler = [Expression]::Lambda(
[Func[object, PSEventReceivedEventHandler]],
$forwardEventHandler,
[ParameterExpression[]]@($sessionParam)).Compile()
# Replace any subscriber from an earlier call rather than reusing it, so a
# reloaded copy of this script takes effect without restarting the session. It
# holds no state of its own, everything per session comes from the sender.
foreach ($subscriber in $Host.Runspace.Events.GetEventSubscribers($invokeEventName)) {
$Host.Runspace.Events.UnsubscribeEvent($subscriber)
}
$OnInvoke = {
param ($Name, $Id, $Arguments, $NamedArguments)
# The remote function is blocked until we tell it we're done, so the
# console isn't reading input and Callback can use it freely.
$output = [List[object]]::new()
$errors = [List[string]]::new()
$errorMessage = $null
try {
# The callback is bound to the caller's session state so it can't
# see $Sender, give it the details it may need as a variable.
$entry = $Sender.Callbacks[$Name]
$context = [PSCustomObject]@{
PSTypeName = 'PSSessionCallbackContext'
Name = $Name
State = $entry.State
ConnectionInfo = $Sender.ConnectionInfo
}
$namedArgs = if ($NamedArguments) { $NamedArguments } else { @{} }
# The invoker runs in a new scope of the callback's session state with
# the context defined, so the callback inherits it as a child scope.
# The callback and its arguments are private, private variables aren't
# visible to child scopes so they can't conflict with anything the
# callback uses.
$variables = [List[PSVariable]]::new()
$variables.Add([PSVariable]::new('PSSessionCallbackContext', $context))
$variables.Add([PSVariable]::new('Callback', $entry.Callback, 'Private'))
$variables.Add([PSVariable]::new('NamedArguments', $namedArgs, 'Private'))
$variables.Add([PSVariable]::new('PositionalArguments', @($Arguments), 'Private'))
# Collect non-terminating errors so they can be written remotely,
# otherwise they only go to this event action and are easily missed.
# Their position isn't reliable when raised in an event action, so
# only the message is sent.
& { $entry.Invoker.InvokeWithContext($null, $variables) } 2>&1 | ForEach-Object {
if ($_ -is [ErrorRecord]) {
$errors.Add("$_")
}
else {
$output.Add($_)
}
}
}
catch {
# InvokeWithContext wraps errors from the callback, unwrap it so the
# message and position are the callback's own. Include where it failed
# as the remote side only sees this text.
$err = $_
if ($err.Exception -is [MethodInvocationException] -and
$err.Exception.InnerException -is [IContainsErrorRecord]) {
$err = $err.Exception.InnerException.ErrorRecord
}
$errorMessage = "$err`n$($err.InvocationInfo.PositionMessage)"
}
# The remote function still holds the remote runspace, we use a nested
# pipeline which is run in the while loop statements to trigger the loop
# to exit and the remote function to continue.
$complete = $Sender.Runspace.CreateNestedPipeline(
'param ($Id, $Output, $Errors, $ErrorMessage) & (Get-Module PSSessionCallback) { $script:Completed = @{ Id = $args[0]; Output = $args[1]; Errors = $args[2]; ErrorMessage = $args[3] } } $Id $Output $Errors $ErrorMessage',
$false)
try {
$complete.Commands[0].Parameters.Add('Id', $Id)
$complete.Commands[0].Parameters.Add('Output', $output.ToArray())
$complete.Commands[0].Parameters.Add('Errors', $errors.ToArray())
$complete.Commands[0].Parameters.Add('ErrorMessage', $errorMessage)
$null = $complete.Invoke()
}
finally {
$complete.Dispose()
}
}
# Subscribe to the event the remote functions raise to run their Callback
# scriptblock here. Every session shares this subscriber, the forwarded
# event's sender says which session it came from.
$null = $Host.Runspace.Events.SubscribeEvent(
$null, # source
$invokeEventName, # eventName
$invokeEventName, # sourceIdentifier
$null, # data
$OnInvoke, # action
$true, # supportEvent
$false) # forwardEvent
# Evaluated in the callback's session state to get an invoker bound to it, see
# the process block.
$invokerFactory = [ScriptBlock]::Create(
'{ & $Callback @NamedArguments @PositionalArguments }')
$localCallbacks = @{}
$definitions = [List[hashtable]]::new()
}
process {
foreach ($c in $Callback) {
# PSTypeName only checks the type name, so check the properties in case the
# definition wasn't made by New-PSSessionCallback.
$invalid = @(
foreach ($prop in 'Callback', 'Pre', 'Post') {
# Pre is optional, Callback's declaration is used without it.
if ($prop -eq 'Pre' -and $null -eq $c.Pre) {
continue
}
if ($c.$prop -isnot [scriptblock]) {
$prop
}
}
)
if ($invalid) {
$e = [ErrorRecord]::new(
[ArgumentException]"The callback definition has invalid values for $($invalid -join ', '). Create it with New-PSSessionCallback.",
"InvalidCallback",
[ErrorCategory]::InvalidArgument,
$c)
$PSCmdlet.WriteError($e)
continue
}
if ($localCallbacks.ContainsKey($c.Name)) {
$e = [ErrorRecord]::new(
[ArgumentException]"A callback named '$($c.Name)' was already provided, ignoring this one.",
"DuplicateCallbackName",
[ErrorCategory]::InvalidArgument,
$c)
$PSCmdlet.WriteError($e)
continue
}
# A scriptblock runs in the session state it was created in, so the
# callback can't see variables from this script's scopes. Evaluating the
# unbound factory in the callback's session state, its module or otherwise
# the caller's, gives an invoker bound to the same session state. Only the
# scriptblock literal is evaluated, nothing is defined there.
$sessionState = if ($c.Callback.Module) {
$c.Callback.Module.SessionState
}
else {
$PSCmdlet.SessionState
}
$localCallbacks[$c.Name] = @{
Callback = $c.Callback
State = $c.State
Invoker = $ExecutionContext.InvokeCommand.InvokeScript(
$sessionState,
$invokerFactory)[0]
}
# The remote function takes Pre's parameters, or Callback's without a Pre.
# Taking the text up to the end of the param block also keeps its
# attributes, like [CmdletBinding()], and any comment-based help before it.
# This allows us to define the wrapper function on the remote side with the
# same declaration for tab completion, help, etc.
# .Ast isn't used as its offsets are into the original script, and include
# the braces for a literal, not into the ToString() text.
$declarationText = if ($c.Pre) { $c.Pre.ToString() } else { $c.Callback.ToString() }
$declarationAst = [Parser]::ParseInput($declarationText, [ref]$null, [ref]$null)
$paramText = if ($declarationAst.ParamBlock) {
$declarationText.Substring(0, $declarationAst.ParamBlock.Extent.EndOffset)
}
# Assigned through a variable as an if expression without output stored
# directly in the hashtable serializes as an empty object, not $null.
$preText = $null
if ($c.Pre) {
$preText = $c.Pre.ToString()
}
$definitions.Add(@{
Name = $c.Name
ParamBlock = $paramText
Pre = $preText
Post = $c.Post.ToString()
State = $c.State
})
}
}
end {
$ErrorActionPreference = 'Stop'
$setupScript = {
param ($EventName, $Definitions)
# Setup event forwarding so the callback functions can raise the event on
# the client side.
$null = $Host.Runspace.Events.SubscribeEvent(
$null, # source
$EventName, # eventName
$EventName, # sourceIdentifier
$null, # data
$null, # action
$true, # supportEvent
$true) # forwardEvent
New-Module -Name PSSessionCallback -ScriptBlock {
param ($EventName, $Definitions)
$script:EventName = $EventName
$script:Callbacks = @{}
$script:Completed = $null
function Invoke-PSSessionCallback {
param ($Name, $BoundParameters, $UnboundArguments)
# $args is an array when the function has one and null in an
# advanced function. Splatting null passes a null argument.
if ($null -eq $UnboundArguments) {
$UnboundArguments = @()
}
# Pre and Post are dot-sourced into the top scope of a new, empty
# module so Post can use variables Pre set. Being a separate session
# state, they can't see or clobber the variables here or in this
# module, and nothing carries over to the next call.
$callback = $script:Callbacks[$Name]
$scope = [PSModuleInfo]::new($true)
$scope.SessionState.PSVariable.Set(
'PSSessionCallbackContext',
[PSCustomObject]@{
PSTypeName = 'PSSessionCallbackContext'
Name = $Name
State = $callback.State
})
$localOutput = @(if ($callback.Pre) {
$localArgs = @(. $scope.NewBoundScriptBlock($callback.Pre) @BoundParameters @UnboundArguments)
Invoke-LocalCallback -Name $Name -Arguments $localArgs
}
else {
# Without a Pre the function has Callback's parameters, so pass
# them by name. Switches are sent as a bool as a deserialized
# SwitchParameter is always truthy.
$named = @{}
foreach ($kvp in $BoundParameters.GetEnumerator()) {
$value = $kvp.Value
if ($value -is [switch]) {
$value = $value.IsPresent
}
$named[$kvp.Key] = $value
}
Invoke-LocalCallback -Name $Name -Arguments $UnboundArguments -NamedArguments $named
})
. $scope.NewBoundScriptBlock($callback.Post) @localOutput
}
function Invoke-LocalCallback {
param ($Name, $Arguments, $NamedArguments)
# Raise the event which runs Callback on the client, then block
# until it's done so the repl does not prompt for further input
# while the client is still processing the callback.
$id = [Guid]::NewGuid().ToString()
$script:Completed = $null
$null = $Host.Runspace.Events.GenerateEvent(
$script:EventName, # sourceIdentifier
$null, # sender
@($Name, $id, $Arguments, $NamedArguments), # args
$null) # extraData
# The client stores the result in $script:Completed through a nested
# pipeline. That only runs between statements here, so poll rather
# than block on a wait handle. While only one callback can be
# waiting at a time, the id check means we ignore a late result from
# a previous callback that was stopped with Ctrl+C.
while ($script:Completed.Id -ne $id) {
Start-Sleep -Milliseconds 200
}
$result = $script:Completed
$script:Completed = $null
foreach ($e in $result.Errors) {
Write-Error -Message "Callback for '$Name' error: $e" -ErrorId CallbackError
}
if ($result.ErrorMessage) {
throw "Callback for '$Name' failed: $($result.ErrorMessage)"
}
$result.Output
}
# Define each callback as a function taking Pre's parameters that hands
# them to Invoke-PSSessionCallback.
foreach ($d in $Definitions) {
$pre = $null
if ($d.Pre) {
$pre = [ScriptBlock]::Create($d.Pre)
}
$script:Callbacks[$d.Name] = @{
Pre = $pre
Post = [ScriptBlock]::Create($d.Post)
State = $d.State
}
# We bind the function to this module so that it runs inside the
# module's scope and not the global one where it would be called. It
# looks up its own name so the name is never written into the script
# text.
$function = $ExecutionContext.SessionState.Module.NewBoundScriptBlock(
[ScriptBlock]::Create(@"
$($d.ParamBlock)
Invoke-PSSessionCallback -Name `$MyInvocation.MyCommand.Name -BoundParameters `$PSBoundParameters -UnboundArguments `$args
"@))
Set-Item -LiteralPath "function:$($d.Name)" -Value $function
}
Export-ModuleMember -Function @($Definitions | ForEach-Object Name)
} -ArgumentList $EventName, $Definitions | Import-Module -Global
}
$connInfo = if ($ConnectionInfo) {
$ConnectionInfo
}
elseif ($Transport -eq 'SSH') {
# Parse [user@]host[:port] the same way Enter-PSSession -HostName does.
$uri = $null
if (-not [Uri]::TryCreate("ssh://$Target", [UriKind]::Absolute, [ref]$uri)) {
$e = [ErrorRecord]::new(
[ArgumentException]"'$Target' is not a valid SSH host, expecting [user@]host[:port].",
"InvalidSSHHost",
[ErrorCategory]::InvalidArgument,
$Target)
$PSCmdlet.ThrowTerminatingError($e)
}
$userName = if ($uri.UserInfo) { $uri.UserInfo }
if ($uri.Port -eq -1) {
[SSHConnectionInfo]::new($userName, $uri.Host, $null)
}
else {
[SSHConnectionInfo]::new($userName, $uri.Host, $null, $uri.Port)
}
}
elseif ($Transport -eq 'NamedPipe') {
[NamedPipeConnectionInfo]::new($Target)
}
elseif ($Transport -eq 'PwshProcess') {
$processId = $Target -as [int]
if ($null -eq $processId) {
$e = [ErrorRecord]::new(
[ArgumentException]"'$Target' is not a valid process id.",
"InvalidProcessId",
[ErrorCategory]::InvalidArgument,
$Target)
$PSCmdlet.ThrowTerminatingError($e)
}
[NamedPipeConnectionInfo]::new($processId)
}
else {
[WSManConnectionInfo]@{
ComputerName = $Target
}
}
$ps = $null
# Use the default type table like Enter-PSSession so deserialized objects are
# rehydrated the same way as a normal remote session.
$runspace = [RunspaceFactory]::CreateRunspace(
$connInfo,
$Host,
[TypeTable]::LoadDefaultTypeFiles())
try {
# Forward events from the remote runspace to the local one like
# New-PSSession does.
$session = [PSCustomObject]@{
Runspace = $runspace
ConnectionInfo = $connInfo
Callbacks = $localCallbacks
}
$runspace.Events.ReceivedEvents.add_PSEventReceived(
$newForwardEventHandler.Invoke($session))
$runspace.Open()
$ps = [PowerShell]::Create()
$ps.Runspace = $runspace
# Run it in a child scope so its parameters don't become global variables in
# the remote session.
$null = $ps.AddScript($setupScript, $true).
AddArgument($invokeEventName).
AddArgument(@($definitions))
try {
$null = $ps.Invoke()
}
catch {
# Unwrap the MethodInvocationException that .Invoke() adds and rethrow
# the original inner exception if $setupScript threw an error.
$e = if (-not ($e = $_.Exception.InnerException.ErrorRecord)) {
$e = $_
}
$e.ErrorDetails = "An error occurred while setting up the remote session: $e"
$PSCmdlet.ThrowTerminatingError($e)
}
# Check if any non-terminating errors occurred during the setup script.
if ($ps.HadErrors) {
foreach ($e in $ps.Streams.Error) {
$e.ErrorDetails = "An error occurred while setting up the remote session: $e"
$PSCmdlet.WriteError($e)
}
return
}
# No public way to do this, it ensures the runspace is closed when someone
# exits from the pushed runspace like how Enter-PSSession would. Without
# this the session stays open. We can't use a finally block as the push is
# not blocking and the script will continue out of the function like normal
# with the pushed runspace.
$closeOnPropPop = $runspace.GetType().GetProperty(
'ShouldCloseOnPop',
[BindingFlags]'Instance, NonPublic')
if (-not $closeOnPropPop) {
throw "Failed to retrieve ShouldCloseOnPop property."
}
$closeOnPropPop.SetValue($runspace, $true)
# This is what Enter-PSSession does, we push the remote runspace so the
# current UI is inside the remote session. The function will continue
# executing but the caller's host will be inside the remote session.
$Host.PushRunspace($runspace)
}
catch {
$runspace.Dispose()
$PSCmdlet.ThrowTerminatingError($_)
}
finally {
if ($ps) {
$ps.Dispose()
}
}
}
}
@jborean93

jborean93 commented Oct 13, 2026 •

Copy link
Copy Markdown
Author

An example of this that mimics the file editing functionality of https://gist.github.com/SeeminglyScience/1b72f0fde3840b275dd23b4104776c5d

New-PSSessionEditCallback | Enter-PSSessionWithCallback win-host

# PSEdit file.txt

This will create a temp copy of the remote file, open it in the platform editor or one explicitly set, then once it is saved it replaces the remote file.

Other things this could be used for

  • Get-LocalClipboard/Set-LocalClipboard - sync the remote and local clipboards
  • Out-LocalGridView - calls Out-GridView locally from remote results
  • Send-ToClient/Receive-FromClient - Send files to and from within the PSSession
  • Open-LocalBrowser - Opens the browser locally to the provided URL
  • Show-LocalToastNotification - Sends a toast notification to the client using BurntToast

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment