macOS desktop app missing PATH entries for tools
NousResearch/hermes-agent · cluster 41385 · agent
Data as of
Nothing open on GitHub. Fix merged in #44814. Check it covers your case.
The short answer
- Known?
- Yes. 9 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #44814. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #69696, #69902. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple bugs and fixes addressing the macOS desktop/GUI app inheriting a minimal launchd PATH that omits directories like ~/.local/bin and ~/.hermes/node/bin, causing tools such as computer_use, codex, and cua-driver to be undiscoverable. Proposed fixes involve extending or resolving the login-shell PATH for GUI-launched backend processes.
Open issues0
Open PRs0
Fixes on record
-
#44814 fix(desktop): complete backend PATH for Homebrew Codex
files:
apps/desktop/electron/backend-env.cjs,apps/desktop/electron/backend-env.test.cjs,apps/desktop/electron/main.cjs,apps/desktop/package.jsonShow the diff
-
apps/desktop/electron/backend-env.cjs@@ -0,0 +1,101 @@ +const path = require('node:path') + +// Match the POSIX fallback surface used by the Python terminal environment. +// macOS apps launched from Finder/Dock often inherit only /usr/bin:/bin:/usr/sbin:/sbin, +// which misses Apple Silicon Homebrew and user-installed CLI tools such as codex. +const POSIX_SANE_PATH_ENTRIES = Object.freeze([ + '/opt/homebrew/bin', + '/opt/homebrew/sbin', + '/usr/local/sbin', + '/usr/local/bin', + '/usr/sbin', + '/usr/bin', + '/sbin', + '/bin' +]) + +function delimiterForPlatform(platform = process.platform) { + return platform === 'win32' ? ';' : ':' +} + +function pathModuleForPlatform(platform = process.platform) { + return platform === 'win32' ? path.win32 : path.posix +} + +function pathEnvKey(env = process.env, platform = process.platform) { + if (platform !== 'win32') return 'PATH' + return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH' +} + +function currentPathValue(env = process.env, platform = process.platform) { + const key = pathEnvKey(env, platform) + return env?.[key] || '' +} + +function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) { + const seen = new Set() + const ordered = [] + + for (const entry of entries) { + if (!entry) continue + const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter) + for (const part of parts) { + if (!part || seen.has(part)) continue + seen.add(part) + ordered.push(part) + } + } + + return ordered.join(delimiter) +} + +function buildDesktopBackendPath({ + hermesHome, + venvRoot, + currentPath = '', + platform = process.platform, + pathModule = pathModuleForPlatform(platform) +} = {}) { + const delimiter = delimiterForPlatform(platform) + const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', 'bin') : null + const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null + const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES + + return appendUniquePathEntries( + [hermesNodeBin, venvBin, currentPath, saneEntries], + { delimiter } + ) +} + +function buildDesktopBackendEnv({ + hermesHome, + pythonPathEntries = [], + venvRoot, + currentEnv = process.env, + platform = process.platform, + pathModule = pathModuleForPlatform(platform) +} = {}) { + const delimiter = delimiterForPlatform(platform) + const currentPythonPath = currentEnv?.PYTHONPATH || '' + const key = pathEnvKey(currentEnv, platform) + + return { + PYTHONPATH: appendUniquePathEntries([...pythonPathEntries, currentPythonPath], { delimiter }), + [key]: buildDesktopBackendPath({ + hermesHome, + venvRoot, + currentPath: currentPathValue(currentEnv, platform), + platform, + pathModule + }) + } +} + +module.exports = { + POSIX_SANE_PATH_ENTRIES, + appendUniquePathEntries, + buildDesktopBackendEnv, + buildDesktopBackendPath, + delimiterForPlatform, + pathEnvKey +} -
apps/desktop/electron/backend-env.test.cjs@@ -0,0 +1,95 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const path = require('node:path') + +const { + POSIX_SANE_PATH_ENTRIES, + appendUniquePathEntries, + buildDesktopBackendEnv, + buildDesktopBackendPath, + pathEnvKey +} = require('./backend-env.cjs') + +test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => { + const result = buildDesktopBackendPath({ + hermesHome: '/Users/test/.hermes', + venvRoot: '/Users/test/.hermes/hermes-agent/venv', + currentPath: '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin', + platform: 'darwin', + pathModule: path.posix + }) + + const entries = result.split(':') + assert.equal(entries[0], '/Users/test/.hermes/node/bin') + assert.equal(entries[1], '/Users/test/.hermes/hermes-agent/venv/bin') + assert.ok(entries.includes('/opt/homebrew/bin'), 'Apple Silicon Homebrew bin is added') + assert.ok(entries.includes('/opt/homebrew/sbin'), 'Apple Silicon Homebrew sbin is added') + assert.ok(entries.includes('/usr/local/sbin'), 'missing standard sbin is added') + + for (const expected of POSIX_SANE_PATH_ENTRIES) { + assert.ok(entries.includes(expected), `${expected} should be present`) + } +}) + +test('desktop backend PATH preserves first occurrence and avoids duplicates', () => { + const result = buildDesktopBackendPath({ + hermesHome: '/Users/test/.hermes', + venvRoot: '/Users/test/.hermes/hermes-agent/venv', + currentPath: '/opt/homebrew/bin:/usr/bin:/opt/homebrew/bin:/bin', + platform: 'darwin', + pathModule: path.posix + }) + + const entries = result.split(':') + assert.equal(entries.filter(entry => entry === '/opt/homebrew/bin').length, 1) + assert.ok( + entries.indexOf('/opt/homebrew/bin') < entries.indexOf('/opt/homebrew/sbin'), + 'existing Homebrew bin keeps its precedence over appended missing sane entries' + ) +}) + +test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () => { + const env = buildDesktopBackendEnv({ + hermesHome: '/Users/test/.hermes', + pythonPathEntries: ['/repo/hermes-agent'], + venvRoot: '/Users/test/.hermes/hermes-agent/venv', + currentEnv: { + PATH: '/usr/bin:/bin', + PYTHONPATH: '/existing/pythonpath' + }, + platform: 'darwin', + pathModule: path.posix + }) + + assert.equal(env.PYTHONPATH, '/repo/hermes-agent:/existing/pythonpath') + assert.ok(env.PATH.startsWith('/Users/test/.hermes/node/bin:/Users/test/.hermes/hermes-agent/venv/bin:')) + assert.ok(env.PATH.includes('/opt/homebrew/bin')) +}) + +test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => { + const env = buildDesktopBackendEnv({ + hermesHome: 'C:\\Users\\test\\AppData\\Local\\hermes', + pythonPathEntries: ['C:\\repo\\hermes-agent'], + venvRoot: 'C:\\Users\\test\\AppData\\Local\\hermes\\hermes-agent\\venv', + currentEnv: { + Path: 'C:\\Windows\\System32;C:\\Windows', + PYTHONPATH: 'C:\\existing\\pythonpath' + }, + platform: 'win32', + pathModule: path.win32 + }) + + assert.equal(pathEnvKey({ Path: 'x' }, 'win32'), 'Path') + assert.equal(env.PATH, undefined) + assert.ok(env.Path.startsWith('C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin;')) + assert.ok(env.Path.includes('\\venv\\Scripts;')) + assert.ok(env.Path.includes(';C:\\Windows\\System32;C:\\Windows')) + assert.equal(env.Path.includes('/opt/homebrew/bin'), false) +}) + +test('appendUniquePathEntries drops empty entries and keeps first occurrence', () => { + assert.equal( + appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }), + '/a:/b:/c' + ) +}) -
apps/desktop/electron/main.cjs@@ -33,6 +33,7 @@ const { adoptServedDashboardToken } = require('./dashboard-token.cjs') const { PortPool } = require('./port-pool.cjs') const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') +const { buildDesktopBackendEnv } = require('./backend-env.cjs') const { readDirForIpc } = require('./fs-read-dir.cjs') const { gitRootForIpc } = require('./git-root.cjs') const { @@ -2134,9 +2135,11 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) { label, command: python, args: ['-m', 'hermes_cli.main', ...dashboardArgs], - env: { - PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter) - }, + env: buildDesktopBackendEnv({ + hermesHome: HERMES_HOME, + pythonPathEntries: [root], + venvRoot: path.join(root, 'venv') + }), root, bootstrap: Boolean(options.bootstrap), shell: false @@ -2155,9 +2158,11 @@ function createActiveBackend(dashboardArgs) { label: `Hermes at ${ACTIVE_HERMES_ROOT}`, command: fileExists(venvPython) ? venvPython : findSystemPython(), args: ['-m', 'hermes_cli.main', ...dashboardArgs], - env: { - PYTHONPATH: [ACTIVE_HERMES_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter) - }, + env: buildDesktopBackendEnv({ + hermesHome: HERMES_HOME, + pythonPathEntries: [ACTIVE_HERMES_ROOT], + venvRoot: VENV_ROOT + }), root: ACTIVE_HERMES_ROOT, bootstrap: true, shell: false -
apps/desktop/package.json@@ -36,7 +36,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/port-pool.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/port-pool.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix",
-
-
#69696 feat(desktop): resolve login-shell PATH for GUI launches (port of cline/cline#12429)
files:
apps/desktop/electron/main.ts,apps/desktop/electron/shell-path.test.ts,apps/desktop/electron/shell-path.ts -
#69902 fix(computer_use): resolve cua-driver under thin GUI PATH (supersedes #55631)
files:
contributors/emails/[redacted:email],contributors/emails/[redacted:email],hermes_cli/main.py,hermes_cli/tools_config.py,tests/computer_use/test_cua_cli_fallback_env.py,tests/computer_use/test_cua_spawn_env_sanitization.py,tests/computer_use/test_doctor.py,tests/computer_use/test_permissions_resolution.py,tests/hermes_cli/test_tools_config.py,tests/tools/test_computer_use.py,tools/computer_use/cua_backend.py,tools/computer_use/doctor.py,tools/computer_use/permissions.py
3 PRs closed without merging
From the close comments: superseded by another PR 2 · won't fix / by design 1
-
#41389 fix(desktop): extend PATH for dashboard backend so codex is discoverable on macOS · superseded by another PR →
#44814,#41385 - #51251 fix(desktop): include user local bin in backend PATH · won't fix / by design
-
#51648 fix(computer_use): resolve cua-driver in canonical install dirs under thin GUI PATH (#51393) · superseded by another PR →
#69902
Closed issues · 3
- Closed#41385[Bug]: /codex-runtime on fails on macOS desktop — dashboard inherits minimal PATH without ~/.hermes/node/bin
- Closed#51249[Bug]: Hermes Desktop backend PATH misses ~/.local/bin
- Closed#51393computer_use tool silently unavailable in macOS desktop app (works in CLI) — which() gate misses ~/.local/bin under launchd PATH
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.19: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab