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 <help@stably.ai>
This commit is contained in:
Neil
2026-06-27 22:59:17 -07:00
co-authored by Orca
parent bffe436aeb
commit 4f6ca34f45
2 changed files with 53 additions and 4 deletions
@@ -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()
+13 -4
View File
@@ -81,14 +81,21 @@ function spawnShellAndReadPath(shell: string): Promise<HydrationResult> {
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<typeof setTimeout> | 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<HydrationResult> {
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 {