diff --git a/src/PSProxmoxVE/Cmdlets/Connection/ConnectPveServerCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Connection/ConnectPveServerCmdlet.cs
index 83b6210..f60af2c 100644
--- a/src/PSProxmoxVE/Cmdlets/Connection/ConnectPveServerCmdlet.cs
+++ b/src/PSProxmoxVE/Cmdlets/Connection/ConnectPveServerCmdlet.cs
@@ -137,7 +137,7 @@ namespace PSProxmoxVE.Cmdlets.Connection
return;
}
- ModuleState.ActiveSession = session;
+ ModuleState.SetActiveSession(this, session);
if (SkipCertificateCheck.IsPresent)
WriteWarning("TLS certificate validation is disabled for this session. Connections are susceptible to man-in-the-middle attacks. Use only in trusted networks or test environments.");
diff --git a/src/PSProxmoxVE/Cmdlets/Connection/DisconnectPveServerCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Connection/DisconnectPveServerCmdlet.cs
index 1b9185b..40cf9fe 100644
--- a/src/PSProxmoxVE/Cmdlets/Connection/DisconnectPveServerCmdlet.cs
+++ b/src/PSProxmoxVE/Cmdlets/Connection/DisconnectPveServerCmdlet.cs
@@ -19,7 +19,8 @@ namespace PSProxmoxVE.Cmdlets.Connection
protected override void ProcessPveRecord()
{
bool explicitSessionSupplied = MyInvocation.BoundParameters.ContainsKey(nameof(Session));
- var sessionToDisconnect = explicitSessionSupplied ? Session : ModuleState.ActiveSession;
+ var moduleSession = ModuleState.GetActiveSession(this);
+ var sessionToDisconnect = explicitSessionSupplied ? Session : moduleSession;
if (sessionToDisconnect is null)
{
@@ -27,7 +28,7 @@ namespace PSProxmoxVE.Cmdlets.Connection
return;
}
- if (!ReferenceEquals(sessionToDisconnect, ModuleState.ActiveSession))
+ if (!ReferenceEquals(sessionToDisconnect, moduleSession))
{
var lifecycle = sessionToDisconnect.AuthMode == PveAuthMode.ApiToken
? "API tokens do not expire; revoke it with Remove-PveApiToken if it is no longer needed."
@@ -39,7 +40,7 @@ namespace PSProxmoxVE.Cmdlets.Connection
if (!ShouldProcess($"{sessionToDisconnect.Hostname}:{sessionToDisconnect.Port}", "Disconnect"))
return;
- ModuleState.ActiveSession = null;
+ ModuleState.SetActiveSession(this, null);
WriteVerbose($"Disconnected from {sessionToDisconnect.Hostname}:{sessionToDisconnect.Port}.");
}
}
diff --git a/src/PSProxmoxVE/Cmdlets/Connection/TestPveConnectionCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Connection/TestPveConnectionCmdlet.cs
index 7f50a18..cd3baf1 100644
--- a/src/PSProxmoxVE/Cmdlets/Connection/TestPveConnectionCmdlet.cs
+++ b/src/PSProxmoxVE/Cmdlets/Connection/TestPveConnectionCmdlet.cs
@@ -24,7 +24,7 @@ namespace PSProxmoxVE.Cmdlets.Connection
protected override void ProcessRecord()
{
- var session = ModuleState.ActiveSession;
+ var session = ModuleState.GetActiveSession(this);
var isConnected = session is not null && !session.IsExpired;
if (Detailed.IsPresent)
diff --git a/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs b/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs
index ec14290..4df1870 100644
--- a/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs
+++ b/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs
@@ -80,13 +80,13 @@ namespace PSProxmoxVE.Cmdlets
///
/// Returns the session to use for this cmdlet.
- /// Resolution order: -Session parameter → ModuleState.ActiveSession.
+ /// Resolution order: -Session parameter → the runspace's module session.
/// Throws if no session is available,
/// or if the session ticket has expired.
///
protected PveSession GetSession()
{
- var session = Session ?? ModuleState.ActiveSession;
+ var session = Session ?? ModuleState.GetActiveSession(this);
if (session is null)
throw new PveNotConnectedException();
diff --git a/src/PSProxmoxVE/ModuleState.cs b/src/PSProxmoxVE/ModuleState.cs
index 6c5700d..9885fe2 100644
--- a/src/PSProxmoxVE/ModuleState.cs
+++ b/src/PSProxmoxVE/ModuleState.cs
@@ -1,10 +1,46 @@
+using System.Management.Automation;
using PSProxmoxVE.Core.Authentication;
namespace PSProxmoxVE
{
- /// Module-scoped state container for the active PVE session
+ ///
+ /// Per-runspace storage for the session established by Connect-PveServer.
+ /// The value lives in the module's own session state, which PowerShell creates
+ /// once per runspace, so runspaces cannot see or overwrite each other's session.
+ ///
internal static class ModuleState
{
- internal static PveSession? ActiveSession { get; set; }
+ internal const string ActiveSessionVariable = "PSProxmoxVE.ActiveSession";
+
+ // Qualified so a nested scope in the module's session state (InModuleScope, a
+ // module-scoped scriptblock) neither writes a copy that dies with the scope nor reads
+ // through to a same-named global.
+ private const string ModuleActiveSessionVariable = "script:" + ActiveSessionVariable;
+
+ // Importing the assembly directly rather than through the manifest yields a module
+ // with no session state; the global scope is then the only per-runspace slot left.
+ private const string GlobalActiveSessionVariable = "global:" + ActiveSessionVariable;
+
+ internal static PveSession? GetActiveSession(PSCmdlet cmdlet)
+ {
+ var moduleState = ModuleSessionState(cmdlet);
+ return moduleState is null
+ ? cmdlet.SessionState.PSVariable.GetValue(GlobalActiveSessionVariable) as PveSession
+ : moduleState.PSVariable.GetValue(ModuleActiveSessionVariable) as PveSession;
+ }
+
+ internal static void SetActiveSession(PSCmdlet cmdlet, PveSession? session)
+ {
+ var moduleState = ModuleSessionState(cmdlet);
+ if (moduleState is null)
+ cmdlet.SessionState.PSVariable.Set(GlobalActiveSessionVariable, session);
+ else
+ moduleState.PSVariable.Set(ModuleActiveSessionVariable, session);
+ }
+
+ // PSCmdlet.SessionState resolves in the caller's scope, so it would store the session
+ // wherever the cmdlet happened to be invoked from.
+ private static SessionState? ModuleSessionState(PSCmdlet cmdlet) =>
+ cmdlet.MyInvocation?.MyCommand?.Module?.SessionState;
}
}
diff --git a/tests/PSProxmoxVE.Tests/Connection/Disconnect-PveServer.Tests.ps1 b/tests/PSProxmoxVE.Tests/Connection/Disconnect-PveServer.Tests.ps1
index e628f11..68a4063 100644
--- a/tests/PSProxmoxVE.Tests/Connection/Disconnect-PveServer.Tests.ps1
+++ b/tests/PSProxmoxVE.Tests/Connection/Disconnect-PveServer.Tests.ps1
@@ -44,19 +44,18 @@ Describe 'Disconnect-PveServer' {
Context 'Active session lifecycle' {
BeforeAll {
- $script:PveSessionType = [PSProxmoxVE.Core.Authentication.PveSession]
- $script:ModuleStateType = [System.AppDomain]::CurrentDomain.GetAssemblies() |
- Where-Object { $_.GetName().Name -eq 'PSProxmoxVE' } |
- ForEach-Object { $_.GetType('PSProxmoxVE.ModuleState') } |
- Select-Object -First 1
- $script:ModuleStateType | Should -Not -BeNullOrEmpty
+ $script:Module = Get-Module PSProxmoxVE
+ $script:Module | Should -Not -BeNullOrEmpty
- $script:ActiveSessionProperty = $script:ModuleStateType.GetProperty(
- 'ActiveSession',
- [System.Reflection.BindingFlags]'NonPublic, Static')
- $script:ActiveSessionProperty | Should -Not -BeNullOrEmpty
+ $script:SetActiveSession = {
+ param($Value)
+ $script:Module.SessionState.PSVariable.Set('PSProxmoxVE.ActiveSession', $Value)
+ }
+ $script:GetActiveSession = {
+ $script:Module.SessionState.PSVariable.GetValue('PSProxmoxVE.ActiveSession')
+ }
- $script:SessionCtor = $script:PveSessionType.GetConstructor(
+ $script:SessionCtor = [PSProxmoxVE.Core.Authentication.PveSession].GetConstructor(
[System.Reflection.BindingFlags]'NonPublic, Instance',
$null,
[type[]]@([string], [int], [bool], [string]),
@@ -65,7 +64,7 @@ Describe 'Disconnect-PveServer' {
}
AfterEach {
- $script:ActiveSessionProperty.SetValue($null, $null)
+ & $script:SetActiveSession $null
}
It 'Should report "no session" when nothing is active' {
@@ -75,11 +74,11 @@ Describe 'Disconnect-PveServer' {
It 'Should clear the active session and warn on a second disconnect' {
$activeSession = $script:SessionCtor.Invoke(@('active.example', 8006, $false, 'activetoken'))
- $script:ActiveSessionProperty.SetValue($null, $activeSession)
+ & $script:SetActiveSession $activeSession
Disconnect-PveServer -Confirm:$false -WarningVariable w -WarningAction SilentlyContinue
$w | Should -BeNullOrEmpty
- $script:ActiveSessionProperty.GetValue($null) | Should -BeNullOrEmpty
+ & $script:GetActiveSession | Should -BeNullOrEmpty
Disconnect-PveServer -Confirm:$false -WarningVariable w2 -WarningAction SilentlyContinue
$w2[0] | Should -Match 'No active Proxmox VE session'
@@ -87,33 +86,41 @@ Describe 'Disconnect-PveServer' {
It 'Should warn when disconnecting an explicit non-active session, and leave the active session untouched' {
$activeSession = $script:SessionCtor.Invoke(@('active.example', 8006, $false, 'activetoken'))
- $script:ActiveSessionProperty.SetValue($null, $activeSession)
+ & $script:SetActiveSession $activeSession
$mismatchedSession = $script:SessionCtor.Invoke(@('other.example', 8006, $false, 'othertoken'))
Disconnect-PveServer -Session $mismatchedSession -Confirm:$false -WarningVariable w -WarningAction SilentlyContinue
$w[0] | Should -Match 'not the module-level session'
$w[0] | Should -Match 'Remove-PveApiToken'
- [object]::ReferenceEquals($script:ActiveSessionProperty.GetValue($null), $activeSession) | Should -BeTrue
+ [object]::ReferenceEquals((& $script:GetActiveSession), $activeSession) | Should -BeTrue
}
It 'Should disconnect when -Session matches the active session' {
$activeSession = $script:SessionCtor.Invoke(@('active.example', 8006, $false, 'activetoken'))
- $script:ActiveSessionProperty.SetValue($null, $activeSession)
+ & $script:SetActiveSession $activeSession
Disconnect-PveServer -Session $activeSession -Confirm:$false -WarningVariable w -WarningAction SilentlyContinue
$w | Should -BeNullOrEmpty
- $script:ActiveSessionProperty.GetValue($null) | Should -BeNullOrEmpty
+ & $script:GetActiveSession | Should -BeNullOrEmpty
}
It 'Should not clear the active session under -WhatIf' {
$activeSession = $script:SessionCtor.Invoke(@('active.example', 8006, $false, 'activetoken'))
- $script:ActiveSessionProperty.SetValue($null, $activeSession)
+ & $script:SetActiveSession $activeSession
Disconnect-PveServer -WhatIf -ErrorAction Stop
- [object]::ReferenceEquals($script:ActiveSessionProperty.GetValue($null), $activeSession) | Should -BeTrue
+ [object]::ReferenceEquals((& $script:GetActiveSession), $activeSession) | Should -BeTrue
+ }
+
+ It 'Should see the session Test-PveConnection sees' {
+ $activeSession = $script:SessionCtor.Invoke(@('active.example', 8006, $false, 'activetoken'))
+ & $script:SetActiveSession $activeSession
+
+ Test-PveConnection | Should -BeTrue
+ [object]::ReferenceEquals((Test-PveConnection -Detailed), $activeSession) | Should -BeTrue
}
}
}
diff --git a/tests/PSProxmoxVE.Tests/Connection/SessionIsolation.Tests.ps1 b/tests/PSProxmoxVE.Tests/Connection/SessionIsolation.Tests.ps1
new file mode 100644
index 0000000..4a430e9
--- /dev/null
+++ b/tests/PSProxmoxVE.Tests/Connection/SessionIsolation.Tests.ps1
@@ -0,0 +1,111 @@
+#Requires -Module Pester
+<#
+.SYNOPSIS
+ Pester 5 tests proving the active PVE session is per-runspace state, not a
+ process-wide static shared by every runspace in the host.
+ Fully offline — no live Proxmox VE target is required.
+#>
+
+BeforeAll {
+ . $PSScriptRoot/../_TestHelper.ps1
+
+ # A dll import produces a module with no session state, so the runspaces import the
+ # manifest that sits beside it.
+ $script:ModulePath = Join-Path (Split-Path (Get-Module PSProxmoxVE).Path) 'PSProxmoxVE.psd1'
+
+ function New-PveTestRunspace {
+ param([string]$Path)
+
+ $ps = [powershell]::Create()
+ $null = $ps.AddScript({
+ param($p)
+ Import-Module $p -Force -ErrorAction Stop
+ }).AddArgument($Path)
+ $null = $ps.Invoke()
+ if ($ps.Streams.Error.Count -gt 0) {
+ $err = $ps.Streams.Error[0]
+ $ps.Dispose()
+ throw "Runspace module import failed: $err"
+ }
+ $ps.Commands.Clear()
+ $ps.Streams.Error.Clear()
+ $ps
+ }
+
+ function Invoke-InRunspace {
+ param([System.Management.Automation.PowerShell]$Runspace, [scriptblock]$Script, $Argument)
+
+ $Runspace.Commands.Clear()
+ $Runspace.Streams.Error.Clear()
+ $null = $Runspace.AddScript($Script)
+ if ($PSBoundParameters.ContainsKey('Argument')) { $null = $Runspace.AddArgument($Argument) }
+ $out = $Runspace.Invoke()
+ if ($Runspace.Streams.Error.Count -gt 0) {
+ throw "Runspace script failed: $($Runspace.Streams.Error[0])"
+ }
+ , $out
+ }
+}
+
+Describe 'Active session isolation between runspaces' {
+ BeforeAll {
+ $script:SetSession = {
+ param($hostname)
+ $ctor = [PSProxmoxVE.Core.Authentication.PveSession].GetConstructor(
+ [System.Reflection.BindingFlags]'NonPublic, Instance',
+ $null,
+ [type[]]@([string], [int], [bool], [string]),
+ $null)
+ $session = $ctor.Invoke(@($hostname, 8006, $false, 'token'))
+ (Get-Module PSProxmoxVE).SessionState.PSVariable.Set('PSProxmoxVE.ActiveSession', $session)
+ }
+
+ $script:ClearSession = {
+ (Get-Module PSProxmoxVE).SessionState.PSVariable.Set('PSProxmoxVE.ActiveSession', $null)
+ }
+
+ $script:GetHostname = {
+ $detailed = Test-PveConnection -Detailed
+ if ($null -eq $detailed) { '' } else { $detailed.Hostname }
+ }
+
+ $script:RunspaceA = New-PveTestRunspace -Path $script:ModulePath
+ $script:RunspaceB = New-PveTestRunspace -Path $script:ModulePath
+ }
+
+ AfterAll {
+ if ($script:RunspaceA) { $script:RunspaceA.Dispose() }
+ if ($script:RunspaceB) { $script:RunspaceB.Dispose() }
+ }
+
+ It 'Should not expose one runspace''s session to another' {
+ Invoke-InRunspace -Runspace $script:RunspaceB -Script $script:ClearSession
+ Invoke-InRunspace -Runspace $script:RunspaceA -Script $script:SetSession -Argument 'runspace-a.example'
+
+ $connectedInB = Invoke-InRunspace -Runspace $script:RunspaceB -Script { Test-PveConnection }
+ $connectedInB[0] | Should -BeFalse
+ }
+
+ It 'Should keep each runspace on its own server after both connect' {
+ Invoke-InRunspace -Runspace $script:RunspaceA -Script $script:SetSession -Argument 'runspace-a.example'
+ Invoke-InRunspace -Runspace $script:RunspaceB -Script $script:SetSession -Argument 'runspace-b.example'
+
+ $inA = Invoke-InRunspace -Runspace $script:RunspaceA -Script $script:GetHostname
+ $inB = Invoke-InRunspace -Runspace $script:RunspaceB -Script $script:GetHostname
+
+ $inA[0] | Should -Be 'runspace-a.example'
+ $inB[0] | Should -Be 'runspace-b.example'
+ }
+
+ It 'Should not let one runspace''s disconnect clear another''s session' {
+ Invoke-InRunspace -Runspace $script:RunspaceA -Script $script:SetSession -Argument 'runspace-a.example'
+ Invoke-InRunspace -Runspace $script:RunspaceB -Script $script:SetSession -Argument 'runspace-b.example'
+
+ Invoke-InRunspace -Runspace $script:RunspaceB -Script { Disconnect-PveServer -Confirm:$false }
+
+ $inA = Invoke-InRunspace -Runspace $script:RunspaceA -Script $script:GetHostname
+ $inB = Invoke-InRunspace -Runspace $script:RunspaceB -Script $script:GetHostname
+ $inA[0] | Should -Be 'runspace-a.example'
+ $inB[0] | Should -Be ''
+ }
+}