fix: resolve daemon shell before spawning

This commit is contained in:
Vitus Apollo
2026-07-11 01:09:58 -07:00
committed by Jinjing
parent f6f497f50c
commit 8aa9474089
4 changed files with 111 additions and 3 deletions
+40
View File
@@ -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<typeof LocalPtyUtils>()
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)
+9
View File
@@ -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 ?? []) {
+39 -1
View File
@@ -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
+23 -2
View File
@@ -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