Last active
October 14, 2026 02:10
-
-
Save jborean93/f5605dc05d9395831800753014222b7b to your computer and use it in GitHub Desktop.
Opens a PSSession with support for local callback operations.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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() | |
| } | |
| } | |
| } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
An example of this that mimics the file editing functionality of https://gist.github.com/SeeminglyScience/1b72f0fde3840b275dd23b4104776c5d
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 clipboardsOut-LocalGridView- callsOut-GridViewlocally from remote resultsSend-ToClient/Receive-FromClient- Send files to and from within the PSSessionOpen-LocalBrowser- Opens the browser locally to the provided URL