diff --git a/src/relay/pty-shell-utils.test.ts b/src/relay/pty-shell-utils.test.ts index b880c5a64..d4f69f4dd 100644 --- a/src/relay/pty-shell-utils.test.ts +++ b/src/relay/pty-shell-utils.test.ts @@ -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( } 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') }) diff --git a/src/relay/pty-shell-utils.ts b/src/relay/pty-shell-utils.ts index 6d589b8c6..9860917c5 100644 --- a/src/relay/pty-shell-utils.ts +++ b/src/relay/pty-shell-utils.ts @@ -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,