Summary
When HKCU\Environment\Path (a REG_EXPAND_SZ value) contains a reference to a user-defined variable such as %PNPM_HOME%\bin, that entry reaches the SSH session unexpanded, and every executable in that directory becomes unreachable.
The same value expands correctly on a local interactive logon, so this is specific to sshd.
The failure is narrow and easy to miss:
- References to profile-derived variables (
%LOCALAPPDATA%, %USERPROFILE%, …) inside PATH do expand.
- References to user-defined variables from
HKCU\Environment inside PATH do not expand.
- The very same user-defined variable, read on its own (
$env:PNPM_HOME), is present and correct in the session.
This bites anyone whose installer writes the variable form into PATH. pnpm setup / npx get-pnpm does exactly that (%PNPM_HOME%\bin), so pnpm and every globally installed CLI silently vanish over SSH while working fine at the console.
Environment
OpenSSH_for_Windows_10.0p2 Win32-OpenSSH-GitHub, LibreSSL 4.2.0
- Windows 11 Pro 26200 (x64)
- Login shell:
pwsh (also reproduces with the default shell)
- Auth: public key
Repro
On the Windows host, add one user-defined variable and two PATH entries, keeping the value type REG_EXPAND_SZ:
$k = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
$k.SetValue('MYTOOL_HOME', 'C:\mytool', [Microsoft.Win32.RegistryValueKind]::ExpandString)
$raw = $k.GetValue('Path', '', 'DoNotExpandEnvironmentNames')
$k.SetValue('Path', $raw + ';%MYTOOL_HOME%\bin;%LOCALAPPDATA%\probe',
[Microsoft.Win32.RegistryValueKind]::ExpandString)
$k.Close()
SSH into the host and inspect:
$env:Path -split ';' | Select-Object -Last 2
"MYTOOL_HOME = [$env:MYTOOL_HOME]"
Expected
C:\mytool\bin
C:\Users\<user>\AppData\Local\probe
MYTOOL_HOME = [C:\mytool]
Actual
%MYTOOL_HOME%\bin <-- NOT expanded
C:\Users\<user>\AppData\Local\probe <-- expanded
MYTOOL_HOME = [C:\mytool] <-- the variable itself is set correctly
A local interactive logon expands both entries.
Root cause
setup_session_user_vars() in contrib/win32/win32compat/w32-doexec.c builds PATH by reading the HKLM and HKCU values and concatenating them:
/* PATH is a special case. The System Path value is prepended to the User Path value */
wchar_t* hklm_path = get_registry_key_value(HKEY_LOCAL_MACHINE, L"SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment", L"PATH", &hklm_path_sz);
wchar_t* hkcu_path = get_registry_key_value(HKEY_CURRENT_USER, L"Environment", L"PATH", &hkcu_path_sz);
...
SetEnvironmentVariableW(L"PATH", user_path);
get_registry_key_value() uses RegGetValueW(..., RRF_RT_REG_SZ, ...). RRF_NOEXPAND is not passed, so expansion is attempted — but RegGetValue expands against the environment of the calling process, which here is sshd-session.exe.
The ordering in setup_session_env() is the problem:
setup_session_user_vars(pw_dir_w); /* PATH is assembled and expanded HERE */
env = do_setup_env_proxy(ssh, s, s->pw->pw_shell);
while (env_name = env[i]) {
...
/* SKIP, if not applicable on WINDOWS
PATH is already set. */
if ((0 == strncmp(env_name, "PATH=", strlen("PATH="))) || ...)
continue;
...
SetEnvironmentVariableW(env_name_w, env_value_w); /* user vars land HERE, too late */
}
At the moment PATH is expanded, the process environment holds the profile-derived variables (via LoadUserProfileW and the user token) but not the user-defined values from HKCU\Environment — those are only injected by the loop that runs afterwards. ExpandEnvironmentStrings leaves unresolved references untouched, so %MYTOOL_HOME%\bin survives verbatim. And because the loop explicitly skips PATH=, nothing ever revisits it.
Consistent with this, sshd-session.exe imports only LoadUserProfileW from USERENV.dll; it never calls CreateEnvironmentBlock, which is what a local logon uses and which resolves these references correctly.
Suggested fix
Any of:
- Assemble
PATH after the user-defined variables have been applied to the process environment, and drop the PATH= skip.
- Read the HKCU
PATH with RRF_NOEXPAND and expand it explicitly once the user variables are in place.
- Use
ExpandEnvironmentStringsForUserW() with the user token instead of relying on the service process environment.
Workaround
Write absolute paths into HKCU\Environment\Path instead of variable references, preserving the REG_EXPAND_SZ type:
$k = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
$raw = $k.GetValue('Path', '', 'DoNotExpandEnvironmentNames')
$k.SetValue('Path', $raw.Replace('%PNPM_HOME%\bin', "$env:LOCALAPPDATA\pnpm\bin"),
[Microsoft.Win32.RegistryValueKind]::ExpandString)
$k.Close()
Note that setx truncates PATH at 1024 characters and [Environment]::SetEnvironmentVariable(..., 'User') rewrites the value as REG_SZ, so neither is safe here.
Summary
When
HKCU\Environment\Path(aREG_EXPAND_SZvalue) contains a reference to a user-defined variable such as%PNPM_HOME%\bin, that entry reaches the SSH session unexpanded, and every executable in that directory becomes unreachable.The same value expands correctly on a local interactive logon, so this is specific to
sshd.The failure is narrow and easy to miss:
%LOCALAPPDATA%,%USERPROFILE%, …) insidePATHdo expand.HKCU\EnvironmentinsidePATHdo not expand.$env:PNPM_HOME), is present and correct in the session.This bites anyone whose installer writes the variable form into
PATH.pnpm setup/npx get-pnpmdoes exactly that (%PNPM_HOME%\bin), sopnpmand every globally installed CLI silently vanish over SSH while working fine at the console.Environment
OpenSSH_for_Windows_10.0p2 Win32-OpenSSH-GitHub, LibreSSL 4.2.0pwsh(also reproduces with the default shell)Repro
On the Windows host, add one user-defined variable and two
PATHentries, keeping the value typeREG_EXPAND_SZ:SSH into the host and inspect:
Expected
Actual
A local interactive logon expands both entries.
Root cause
setup_session_user_vars()incontrib/win32/win32compat/w32-doexec.cbuildsPATHby reading the HKLM and HKCU values and concatenating them:get_registry_key_value()usesRegGetValueW(..., RRF_RT_REG_SZ, ...).RRF_NOEXPANDis not passed, so expansion is attempted — butRegGetValueexpands against the environment of the calling process, which here issshd-session.exe.The ordering in
setup_session_env()is the problem:At the moment
PATHis expanded, the process environment holds the profile-derived variables (viaLoadUserProfileWand the user token) but not the user-defined values fromHKCU\Environment— those are only injected by the loop that runs afterwards.ExpandEnvironmentStringsleaves unresolved references untouched, so%MYTOOL_HOME%\binsurvives verbatim. And because the loop explicitly skipsPATH=, nothing ever revisits it.Consistent with this,
sshd-session.exeimports onlyLoadUserProfileWfromUSERENV.dll; it never callsCreateEnvironmentBlock, which is what a local logon uses and which resolves these references correctly.Suggested fix
Any of:
PATHafter the user-defined variables have been applied to the process environment, and drop thePATH=skip.PATHwithRRF_NOEXPANDand expand it explicitly once the user variables are in place.ExpandEnvironmentStringsForUserW()with the user token instead of relying on the service process environment.Workaround
Write absolute paths into
HKCU\Environment\Pathinstead of variable references, preserving theREG_EXPAND_SZtype:Note that
setxtruncatesPATHat 1024 characters and[Environment]::SetEnvironmentVariable(..., 'User')rewrites the value asREG_SZ, so neither is safe here.