diff --git a/apps/desktop/electron/backend-env.cjs b/apps/desktop/electron/backend-env.cjs new file mode 100644 index 00000000000..d3b65f4f781 --- /dev/null +++ b/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 +} diff --git a/apps/desktop/electron/backend-env.test.cjs b/apps/desktop/electron/backend-env.test.cjs new file mode 100644 index 00000000000..1011161917a --- /dev/null +++ b/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' + ) +}) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 1c30f0f9e09..2dd0a68d0d2 100644 --- a/apps/desktop/electron/main.cjs +++ b/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 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a1b8f5495d7..d78416589f6 100644 --- a/apps/desktop/package.json +++ b/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",