From 4f6ca34f451601444b21ffbef5935f76fbfd1ab8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 27 Jun 2026 22:59:17 -0700 Subject: [PATCH] fix(startup): use non-interactive login shell for PATH probe (#5657) The startup PATH probe spawned the user's login shell with -ilc. The interactive (-i) flag forces sourcing of interactive rc init (compinit, oh-my-zsh, completion generators, nvm/sdkman) that forks many nested execs. On managed macOS machines with Endpoint Security agents (Jamf Protect / CrowdStrike), each nested exec awaits an ES_EVENT_TYPE_AUTH_EXEC verdict; a stalled verdict wedges the spawn in uninterruptible kernel sleep (U state) that SIGKILL cannot reap, hanging Orca startup with no window until reboot. Switch to login-but-non-interactive (-lc). PATH lives in login profiles and unguarded rc exports, both still sourced under -lc, so the captured PATH stays complete while the subprocess-spawning interactive init that triggers the AUTH_EXEC wedge is skipped. The 5s timeout + SIGKILL is kept as defense-in-depth (it cannot reap a U-state process, so avoiding the wedge via -lc is the real fix). Co-authored-by: Orca --- src/main/startup/hydrate-shell-path.test.ts | 40 +++++++++++++++++++++ src/main/startup/hydrate-shell-path.ts | 17 ++++++--- 2 files changed, 53 insertions(+), 4 deletions(-) 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 {