diff --git a/src/main/startup/hydrate-shell-path.test.ts b/src/main/startup/hydrate-shell-path.test.ts index 4e1a05c14e7..9a57159be6c 100644 --- a/src/main/startup/hydrate-shell-path.test.ts +++ b/src/main/startup/hydrate-shell-path.test.ts @@ -133,6 +133,46 @@ describe('hydrateShellPath', () => { expect(result).toEqual({ segments: [], ok: false, failureReason: 'empty_path' }) }) + // Why: the `-i` (interactive) flag forces sourcing of interactive rc init + // (compinit, oh-my-zsh, completion generators) that forks nested execs and + // wedges startup in uninterruptible sleep under macOS Endpoint Security + // agents (#5657). The PATH probe must spawn login-but-non-interactive (`-lc`). + it('spawns the shell login-but-non-interactive (-lc, never -ilc) for the PATH probe', async () => { + const proc = createMockShellProcess() + spawnMock.mockReturnValue(proc) + + const resultPromise = hydrateShellPath({ shellOverride: '/bin/zsh', force: true }) + + expect(spawnMock).toHaveBeenCalledTimes(1) + const [spawnedShell, spawnArgs] = spawnMock.mock.calls[0] + expect(spawnedShell).toBe('/bin/zsh') + expect(spawnArgs[0]).toBe('-lc') + expect(spawnArgs).not.toContain('-ilc') + + // Emit a PATH containing version-manager dirs and confirm it still parses, + // proving login profiles + unguarded rc exports remain captured under -lc. + const path = [ + '/Users/tester/.cargo/bin', + '/Users/tester/.pyenv/shims', + '/Users/tester/.volta/bin', + '/opt/homebrew/bin' + ].join(delimiter) + proc.stdout.emit('data', Buffer.from(`__ORCA_SHELL_PATH__${path}__ORCA_SHELL_PATH__`)) + proc.emit('close') + + const result = await resultPromise + expect(result).toEqual({ + segments: [ + '/Users/tester/.cargo/bin', + '/Users/tester/.pyenv/shims', + '/Users/tester/.volta/bin', + '/opt/homebrew/bin' + ], + ok: true, + failureReason: 'none' + }) + }) + it('cleans up shell listeners when hydration times out', async () => { vi.useFakeTimers() const proc = createMockShellProcess() diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts index 81768bbc272..284014c3d5b 100644 --- a/src/main/startup/hydrate-shell-path.ts +++ b/src/main/startup/hydrate-shell-path.ts @@ -81,14 +81,21 @@ function spawnShellAndReadPath(shell: string): Promise { return new Promise((resolve) => { // Why: printing $PATH between delimiters is resilient to rc-file banners, // MOTDs, and `echo` invocations that shells like fish print unprompted. - // `-ilc` runs the shell as a login+interactive so both .profile/.zprofile - // and .bashrc/.zshrc are sourced — matches what `which` in Terminal sees. + // Why: login-but-non-interactive (`-lc`, not `-ilc`). PATH lives in login + // profiles (.zprofile/.zlogin/.profile/.bash_profile) and unguarded rc + // exports, all still sourced under `-lc`, so the captured PATH stays + // complete. The dropped `-i` flag is what matters: interactive init + // (compinit, oh-my-zsh, `source <(tool completion ...)`, nvm/sdkman) forks + // many nested execs that, under macOS Endpoint Security agents (Jamf + // Protect / CrowdStrike), each await an ES_EVENT_TYPE_AUTH_EXEC verdict; a + // stalled verdict wedges the spawn in uninterruptible kernel sleep (`U`), + // which SIGKILL cannot reap — hanging startup until reboot (#5657). const command = `printf '%s' '${DELIMITER}'; printf '%s' "$PATH"; printf '%s' '${DELIMITER}'` let finished = false let stdout = '' let timer: ReturnType | null = null - const child = spawn(shell, ['-ilc', command], { + const child = spawn(shell, ['-lc', command], { // Why: inherit current env so the shell sees the same baseline, then let // it layer its own rc files on top. Do NOT forward stdio — some shells // (oh-my-zsh setups, powerlevel10k) print a lot to stderr on startup, @@ -119,7 +126,9 @@ function spawnShellAndReadPath(shell: string): Promise { timer = setTimeout(() => { // Why: slow rc files (corporate env setup, nvm eager init) can exceed // our budget. Kill the shell and fall back to process.env rather than - // blocking the Agents pane indefinitely. + // blocking the Agents pane indefinitely. Defense-in-depth only — SIGKILL + // cannot reap a child stuck in `U` (uninterruptible) state, so the real + // fix for the Endpoint Security wedge is the non-interactive `-lc` above. try { child.kill('SIGKILL') } catch {