fix(relay): honor the remote OpenSSH login shell for Windows SSH terminals (#9455)

* fix(relay): honor the remote OpenSSH login shell for Windows SSH terminals (#9327)

* Prove OpenSSH DefaultShell fallback stays safe on bad registry reads
This commit is contained in:
Rod Boev 2026-07-24 03:26:07 -04:00 committed by GitHub
parent 5a1ca24262
commit 26e48e4159
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 156 additions and 8 deletions

View File

@ -1,11 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileMock } = vi.hoisted(() => ({
execFileMock: vi.fn()
const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
execFileSyncMock: vi.fn()
}))
vi.mock('child_process', () => ({
execFile: execFileMock
execFile: execFileMock,
execFileSync: execFileSyncMock
}))
import { resetWindowsProcessRowsSnapshotForTests } from '../main/providers/windows-foreground-process-rows'
@ -49,7 +51,9 @@ async function withProcessPlatform<T>(
}
beforeEach(() => {
vi.resetModules()
execFileMock.mockReset()
execFileSyncMock.mockReset()
resetProcessTableSnapshotForTests()
resetWindowsProcessRowsSnapshotForTests()
})
@ -97,11 +101,124 @@ describe('resolveWindowsDefaultShell', () => {
SystemRoot: 'C:\\Windows',
ComSpec: 'C:\\Windows\\System32\\cmd.exe'
},
(path) => path === 'C:\\Tools\\pwsh.exe'
(path) => path === 'C:\\Tools\\pwsh.exe',
() => {
throw new Error('DefaultShell should not be read when SHELL wins')
}
)
).toBe('C:\\Tools\\pwsh.exe')
})
it('uses an existing OpenSSH DefaultShell path', () => {
const powershell7 = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
expect(
resolveWindowsDefaultShell(
{
SystemRoot: 'C:\\Windows',
ComSpec: 'C:\\Windows\\System32\\cmd.exe'
},
(path) => path === powershell7,
() => powershell7
)
).toBe(powershell7)
})
it('reads and memoizes the OpenSSH DefaultShell registry value', async () => {
execFileSyncMock.mockReturnValue(
[
'HKEY_LOCAL_MACHINE\\SOFTWARE\\OpenSSH',
' DefaultShell REG_SZ C:\\Program Files\\PowerShell\\7\\pwsh.exe'
].join('\n')
)
const { readOpenSshDefaultShell } = await import('./pty-shell-utils')
expect(readOpenSshDefaultShell()).toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe')
expect(readOpenSshDefaultShell()).toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe')
expect(execFileSyncMock).toHaveBeenCalledTimes(1)
expect(execFileSyncMock).toHaveBeenCalledWith(
'reg.exe',
['query', 'HKLM\\SOFTWARE\\OpenSSH', '/v', 'DefaultShell'],
{ encoding: 'utf8', timeout: 3000, windowsHide: true }
)
})
it('treats malformed OpenSSH DefaultShell output as empty and preserves the fallback chain', async () => {
execFileSyncMock.mockReturnValue(
[
'HKEY_LOCAL_MACHINE\\SOFTWARE\\OpenSSH',
' DefaultShellCommandOption REG_SZ /c'
].join('\n')
)
const { readOpenSshDefaultShell } = await import('./pty-shell-utils')
const powershell = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
expect(readOpenSshDefaultShell()).toBe('')
expect(
resolveWindowsDefaultShell(
{
SystemRoot: 'C:\\Windows',
ComSpec: 'C:\\Windows\\System32\\cmd.exe'
},
(path) => path === powershell || path === 'C:\\Windows\\System32\\cmd.exe',
readOpenSshDefaultShell
)
).toBe(powershell)
})
it('treats reg.exe failures as empty and preserves the fallback chain', async () => {
execFileSyncMock.mockImplementation(() => {
throw new Error('reg.exe failed')
})
const { readOpenSshDefaultShell } = await import('./pty-shell-utils')
const powershell = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
expect(readOpenSshDefaultShell()).toBe('')
expect(
resolveWindowsDefaultShell(
{
SystemRoot: 'C:\\Windows',
ComSpec: 'C:\\Windows\\System32\\cmd.exe'
},
(path) => path === powershell || path === 'C:\\Windows\\System32\\cmd.exe',
readOpenSshDefaultShell
)
).toBe(powershell)
})
it('preserves the fallback chain for an invalid OpenSSH DefaultShell', () => {
const powershell = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
expect(
resolveWindowsDefaultShell(
{
SystemRoot: 'C:\\Windows',
ComSpec: 'C:\\Windows\\System32\\cmd.exe'
},
(path) => path === powershell,
() => 'C:\\missing\\pwsh.exe'
)
).toBe(powershell)
})
it('honors a deliberate OpenSSH PowerShell 5.1 DefaultShell value', () => {
const powershell = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
expect(
resolveWindowsDefaultShell(
{
SystemRoot: 'C:\\Windows',
ComSpec: 'C:\\Windows\\System32\\cmd.exe'
},
(path) => path === powershell,
() => powershell
)
).toBe(powershell)
})
it('prefers inbox PowerShell before ComSpec for an interactive Windows PTY', () => {
const powershell = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
@ -111,7 +228,8 @@ describe('resolveWindowsDefaultShell', () => {
SystemRoot: 'C:\\Windows',
ComSpec: 'C:\\Windows\\System32\\cmd.exe'
},
(path) => path === powershell || path === 'C:\\Windows\\System32\\cmd.exe'
(path) => path === powershell || path === 'C:\\Windows\\System32\\cmd.exe',
() => ''
)
).toBe(powershell)
})
@ -123,7 +241,8 @@ describe('resolveWindowsDefaultShell', () => {
SystemRoot: 'C:\\Windows',
ComSpec: 'C:\\Windows\\System32\\cmd.exe'
},
(path) => path === 'C:\\Windows\\System32\\cmd.exe'
(path) => path === 'C:\\Windows\\System32\\cmd.exe',
() => ''
)
).toBe('C:\\Windows\\System32\\cmd.exe')
})

View File

@ -1,4 +1,4 @@
import { execFile as execFileCb } from 'node:child_process'
import { execFile as execFileCb, execFileSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { win32 as pathWin32 } from 'node:path'
@ -23,15 +23,44 @@ import {
const execFile = promisify(execFileCb)
const OPENSSH_REGISTRY_KEY = 'HKLM\\SOFTWARE\\OpenSSH'
let openSshDefaultShell: string | undefined
export function readOpenSshDefaultShell(): string {
if (openSshDefaultShell !== undefined) {
return openSshDefaultShell
}
try {
const output = execFileSync('reg.exe', ['query', OPENSSH_REGISTRY_KEY, '/v', 'DefaultShell'], {
encoding: 'utf8',
timeout: 3000,
windowsHide: true
})
const match = output.match(/^\s*DefaultShell\s+REG_\w+\s+(.+?)\s*$/im)
openSshDefaultShell = match?.[1] ?? ''
} catch {
openSshDefaultShell = ''
}
return openSshDefaultShell
}
export function resolveWindowsDefaultShell(
env: NodeJS.ProcessEnv = process.env,
existsPath: (path: string) => boolean = existsSync
existsPath: (path: string) => boolean = existsSync,
readDefaultShell: () => string = readOpenSshDefaultShell
): string {
const envShell = env.SHELL
if (envShell && existsPath(envShell)) {
return envShell
}
const configuredShell = readDefaultShell()
if (configuredShell && existsPath(configuredShell)) {
return configuredShell
}
const systemRoot = env.SystemRoot || env.WINDIR || env.windir || 'C:\\Windows'
const windowsPowerShell = pathWin32.join(
systemRoot,