Files
hermes-agent/apps/desktop/electron/shell-path.test.ts
T
Teknium c13105a8c5 Port from cline/cline#12482 era: login-shell PATH resolution for GUI-launched desktop (cline/cline#12429)
feat(desktop): resolve the user's login-shell PATH once at startup and
merge it into process.env before the backend spawns.

GUI launches (Finder/Dock on macOS, desktop launchers on Linux) inherit
a minimal PATH that never runs the user's shell profiles, so the
backend process — and everything it spawns or probes (shutil.which
availability checks like cua-driver, stdio MCP servers, Electron-side
git/gh/hermes resolvers) — cannot see Homebrew-, nvm-, pyenv-, cargo-,
or ~/.local/bin-installed tools. backend-env.ts's static sane-entry
list covers Homebrew//usr/local but not profile-added dirs.

Approach (ported from cline/cline#12429, mirrors VS Code's shell
environment resolution):
- new electron/shell-path.ts: run $SHELL -ilc (fallback -lc for the
  macOS system-bash-3.2 swallow) printing $PATH between sentinel
  markers so profile banners can't corrupt the capture
- merge login-shell entries first, current-only entries appended,
  deduped via backend-env's appendUniquePathEntries
- single-flight, timeout-bounded, failure-hardened: a broken or slow
  shell profile never blocks boot; win32 no-op
- warmed at app.whenReady, awaited before backend runtime resolution

12 unit tests + live E2E verified (GUI-minimal PATH enriched with
~/.local/bin, nvm, cargo, go entries on a real shell).
2026-08-16 22:05:51 -07:00

148 lines
5.4 KiB
TypeScript

import assert from 'node:assert/strict'
import { beforeEach, test } from 'vitest'
import {
applyLoginShellPath,
ensureLoginShellPath,
extractSentinelPath,
loginShellExecutable,
mergeLoginShellPath,
resetLoginShellPathForTests
} from './shell-path'
const START = '__HERMES_LOGIN_PATH_START__'
const END = '__HERMES_LOGIN_PATH_END__'
function fakeExecFile(outputsByFlags, { error = null, calls = [] }: any = {}) {
return (file, args, _options, callback) => {
calls.push({ file, args })
const flags = args[0]
const stdout = outputsByFlags[flags] ?? ''
queueMicrotask(() => callback(error, stdout, ''))
return { stdin: { end() {} } }
}
}
beforeEach(() => {
resetLoginShellPathForTests()
})
test('extractSentinelPath survives profile banner noise around the markers', () => {
const stdout = `Welcome to my shell!\nmotd garbage\n${START}/opt/homebrew/bin:/usr/bin${END}trailing noise`
assert.equal(extractSentinelPath(stdout), '/opt/homebrew/bin:/usr/bin')
})
test('extractSentinelPath uses the last start marker (echoed command lines cannot poison it)', () => {
const stdout = `${START}poisoned-echo${END}\n${START}/real/path${END}`
assert.equal(extractSentinelPath(stdout), '/real/path')
})
test('extractSentinelPath returns null when markers are missing or empty', () => {
assert.equal(extractSentinelPath('no markers here'), null)
assert.equal(extractSentinelPath(`${START}${END}`), null)
assert.equal(extractSentinelPath(`${START}/half/open`), null)
})
test('mergeLoginShellPath puts login entries first and appends current-only entries', () => {
const merged = mergeLoginShellPath(
'/opt/homebrew/bin:/Users/u/.local/bin:/usr/bin:/bin',
'/usr/bin:/bin:/launcher/only',
{ delimiter: ':' }
)
assert.equal(merged, '/opt/homebrew/bin:/Users/u/.local/bin:/usr/bin:/bin:/launcher/only')
})
test('loginShellExecutable honors $SHELL and falls back per platform', () => {
assert.equal(loginShellExecutable({ SHELL: '/usr/local/bin/fish' }, 'darwin'), '/usr/local/bin/fish')
assert.equal(loginShellExecutable({}, 'darwin'), '/bin/zsh')
assert.equal(loginShellExecutable({}, 'linux'), '/bin/bash')
})
test('applyLoginShellPath merges the captured login PATH into the env', async () => {
const env: any = { SHELL: '/bin/zsh', PATH: '/usr/bin:/bin' }
const calls: any[] = []
const execFileFn = fakeExecFile({ '-ilc': `${START}/opt/homebrew/bin:/Users/u/.cargo/bin:/usr/bin${END}` }, { calls })
const result = await applyLoginShellPath({ env, platform: 'darwin', execFileFn })
assert.equal(result.applied, true)
assert.equal(env.PATH, '/opt/homebrew/bin:/Users/u/.cargo/bin:/usr/bin:/bin')
assert.equal(calls.length, 1)
assert.equal(calls[0].file, '/bin/zsh')
assert.equal(calls[0].args[0], '-ilc')
})
test('applyLoginShellPath falls back to -lc when -ilc yields no sentinel (bash 3.2 swallow)', async () => {
const env: any = { SHELL: '/bin/bash', PATH: '/usr/bin' }
const calls: any[] = []
const execFileFn = fakeExecFile({ '-ilc': 'swallowed', '-lc': `${START}/opt/homebrew/bin:/usr/bin${END}` }, { calls })
const result = await applyLoginShellPath({ env, platform: 'darwin', execFileFn })
assert.equal(result.applied, true)
assert.deepEqual(
calls.map(call => call.args[0]),
['-ilc', '-lc']
)
assert.equal(env.PATH, '/opt/homebrew/bin:/usr/bin')
})
test('applyLoginShellPath leaves the env untouched when resolution fails', async () => {
const env: any = { SHELL: '/bin/zsh', PATH: '/usr/bin:/bin' }
const execFileFn = fakeExecFile({}, { error: new Error('boom') })
const result = await applyLoginShellPath({ env, platform: 'darwin', execFileFn })
assert.equal(result.applied, false)
assert.equal(result.reason, 'unresolved')
assert.equal(env.PATH, '/usr/bin:/bin')
})
test('applyLoginShellPath reports unchanged when the login PATH adds nothing new', async () => {
const env: any = { SHELL: '/bin/zsh', PATH: '/opt/homebrew/bin:/usr/bin' }
const execFileFn = fakeExecFile({ '-ilc': `${START}/opt/homebrew/bin:/usr/bin${END}` })
const result = await applyLoginShellPath({ env, platform: 'darwin', execFileFn })
assert.equal(result.applied, false)
assert.equal(result.reason, 'unchanged')
assert.equal(env.PATH, '/opt/homebrew/bin:/usr/bin')
})
test('applyLoginShellPath is a no-op on Windows', async () => {
const env: any = { Path: 'C:\\Windows\\System32' }
const result = await applyLoginShellPath({ env, platform: 'win32' })
assert.equal(result.applied, false)
assert.equal(result.reason, 'win32')
assert.equal(env.Path, 'C:\\Windows\\System32')
})
test('ensureLoginShellPath is single-flight — concurrent callers share one shell probe', async () => {
const env: any = { SHELL: '/bin/zsh', PATH: '/usr/bin' }
const calls: any[] = []
const execFileFn = fakeExecFile({ '-ilc': `${START}/opt/homebrew/bin:/usr/bin${END}` }, { calls })
const [first, second] = await Promise.all([
ensureLoginShellPath({ env, platform: 'darwin', execFileFn }),
ensureLoginShellPath({ env, platform: 'darwin', execFileFn })
])
assert.equal(first.applied, true)
assert.equal(second.applied, true)
assert.equal(calls.length, 1)
assert.equal(env.PATH, '/opt/homebrew/bin:/usr/bin')
})
test('ensureLoginShellPath never rejects', async () => {
const execFileFn = () => {
throw new Error('spawn EACCES')
}
const result = await ensureLoginShellPath({ env: { SHELL: '/bin/zsh' }, platform: 'darwin', execFileFn })
assert.equal(result.applied, false)
})