diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 908ef9a68b2..421b3d969dc 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -9,10 +9,12 @@ const { spawnMock, isPwshAvailableMock, validateWorkingDirectoryMock, + resolveUnixShellPathMock, resolveAgentForegroundProcessMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), isPwshAvailableMock: vi.fn(), + resolveUnixShellPathMock: vi.fn((shellPath: string) => shellPath), resolveAgentForegroundProcessMock: vi.fn(), validateWorkingDirectoryMock: vi.fn((cwd: string) => { if (cwd.includes('definitely-missing')) { @@ -51,6 +53,7 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => { const actual = await importOriginal() return { ...actual, + resolveUnixShellPath: resolveUnixShellPathMock, validateWorkingDirectory: validateWorkingDirectoryMock } }) @@ -116,6 +119,8 @@ describe('createPtySubprocess', () => { async (_pid: number, fallbackProcess: string | null) => fallbackProcess ) validateWorkingDirectoryMock.mockClear() + resolveUnixShellPathMock.mockReset() + resolveUnixShellPathMock.mockImplementation((shellPath: string) => shellPath) isPwshAvailableMock.mockReturnValue(false) previousUserDataPath = process.env.ORCA_USER_DATA_PATH previousPowerlevelWizardDisable = process.env[POWERLEVEL10K_WIZARD_DISABLE_ENV] @@ -181,6 +186,41 @@ describe('createPtySubprocess', () => { ) }) + it('resolves a missing Unix default before spawning node-pty', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + resolveUnixShellPathMock.mockReturnValue('/bin/sh') + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const previousShell = process.env.SHELL + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + delete process.env.SHELL + + try { + createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24, env: {} }) + + expect(resolveUnixShellPathMock).toHaveBeenCalledWith('/bin/zsh') + expect(spawnMock).toHaveBeenCalledWith( + '/bin/sh', + ['-l'], + expect.objectContaining({ env: expect.objectContaining({ SHELL: '/bin/sh' }) }) + ) + expect(warn).toHaveBeenCalledWith( + '[daemon/pty] Preferred shell "/bin/zsh" is unavailable, fell back to "/bin/sh"' + ) + } finally { + warn.mockRestore() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + if (previousShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = previousShell + } + } + }) + it('uses bundled ConPTY for native Windows daemon terminals', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 5d36ebe9d69..d748bc273b8 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -14,6 +14,7 @@ import { isValidPtySize, normalizePtySize } from './daemon-pty-size' import { ensureNodePtySpawnHelperExecutable, getNodePtySpawnHelperCandidates, + resolveUnixShellPath, validateWorkingDirectory } from '../providers/local-pty-utils' import { wrapShellSpawnForMacosTccAttribution } from '../providers/macos-tcc-login-shell' @@ -728,6 +729,14 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl addOrcaWslInteropEnv(env) } } else { + const preferredShellPath = shellPath + shellPath = resolveUnixShellPath(shellPath) + if (shellPath !== preferredShellPath) { + env.SHELL = shellPath + console.warn( + `[daemon/pty] Preferred shell "${preferredShellPath}" is unavailable, fell back to "${shellPath}"` + ) + } // Why: relay-side launch modes can ask for host defaults to stay scrubbed // even after environment normalization above. for (const key of opts.envToDelete ?? []) { diff --git a/src/main/providers/local-pty-utils.test.ts b/src/main/providers/local-pty-utils.test.ts index 66fde349ded..d5904daab7e 100644 --- a/src/main/providers/local-pty-utils.test.ts +++ b/src/main/providers/local-pty-utils.test.ts @@ -33,7 +33,11 @@ vi.mock('./macos-tcc-login-shell', () => ({ wrapShellSpawnForMacosTccAttribution: wrapSpawnMock })) -import { spawnShellWithFallback, validateWorkingDirectory } from './local-pty-utils' +import { + resolveUnixShellPath, + spawnShellWithFallback, + validateWorkingDirectory +} from './local-pty-utils' const WSL_UNC_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' const NATIVE_DIR = 'C:\\Users\\jin\\repo' @@ -102,6 +106,40 @@ describe('validateWorkingDirectory', () => { }) }) +describe('resolveUnixShellPath', () => { + beforeEach(() => { + existsSyncMock.mockReset() + accessSyncMock.mockReset() + }) + + it('preserves non-absolute shells for execvp PATH and cwd resolution', () => { + expect(resolveUnixShellPath('bash')).toBe('bash') + expect(resolveUnixShellPath('./bin/custom-shell')).toBe('./bin/custom-shell') + expect(existsSyncMock).not.toHaveBeenCalled() + }) + + it('selects an executable fallback before node-pty can fork a missing shell', () => { + existsSyncMock.mockImplementation((path) => path === '/bin/sh') + accessSyncMock.mockReturnValue(undefined) + + expect(resolveUnixShellPath('/missing/zsh')).toBe('/bin/sh') + expect(existsSyncMock.mock.calls.map(([path]) => path)).toEqual([ + '/missing/zsh', + '/bin/zsh', + '/bin/bash', + '/bin/sh' + ]) + }) + + it('reports every attempted shell when no executable candidate exists', () => { + existsSyncMock.mockReturnValue(false) + + expect(() => resolveUnixShellPath('/missing/zsh')).toThrow( + 'No executable Unix shell found (tried: /missing/zsh, /bin/zsh, /bin/bash, /bin/sh)' + ) + }) +}) + describe('spawnShellWithFallback macOS TCC login wrapping', () => { let origPlatform: PropertyDescriptor | undefined diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 53d016d312f..3e21095d535 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -1,4 +1,4 @@ -import { basename, join } from 'node:path' +import { basename, isAbsolute, join } from 'node:path' import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants } from 'node:fs' import type * as pty from 'node-pty' import { isWslUncPath } from '../../shared/wsl-paths' @@ -7,6 +7,8 @@ import { wrapShellSpawnForMacosTccAttribution } from './macos-tcc-login-shell' let didEnsureSpawnHelperExecutable = false +const UNIX_SHELL_FALLBACKS = ['/bin/zsh', '/bin/bash', '/bin/sh'] as const + function toUnpackedAsarPath(candidate: string): string { return candidate .replace(/app\.asar([/\\])/, 'app.asar.unpacked$1') @@ -46,6 +48,25 @@ export function getShellValidationError(shellPath: string): string | null { return null } +/** + * Resolves an absolute Unix shell before node-pty forks. Bare commands and + * relative paths stay untouched so execvp can resolve them against PATH or cwd. + */ +export function resolveUnixShellPath(shellPath: string): string { + if (!isAbsolute(shellPath)) { + return shellPath + } + const candidates = [ + shellPath, + ...UNIX_SHELL_FALLBACKS.filter((candidate) => candidate !== shellPath) + ] + const resolved = candidates.find((candidate) => getShellValidationError(candidate) === null) + if (resolved) { + return resolved + } + throw new Error(`No executable Unix shell found (tried: ${candidates.join(', ')})`) +} + /** * Ensure the node-pty spawn-helper binary has the executable bit set. * @@ -252,7 +273,7 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu // Try fallback shells on Unix if (process.platform !== 'win32') { - const fallbackShells = ['/bin/zsh', '/bin/bash', '/bin/sh'].filter((s) => s !== shellPath) + const fallbackShells = UNIX_SHELL_FALLBACKS.filter((candidate) => candidate !== shellPath) for (const fallback of fallbackShells) { if (getShellValidationError(fallback)) { continue