Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion apps/desktop/electron/backend-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
buildDesktopBackendPath,
normalizeHermesHomeRoot,
pathEnvKey,
POSIX_SANE_PATH_ENTRIES
POSIX_SANE_PATH_ENTRIES,
venvRootForInterpreter
} from './backend-env'

test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => {
Expand Down Expand Up @@ -104,3 +105,45 @@ test('Windows PATH casing and delimiter are preserved without POSIX sane entries
test('appendUniquePathEntries drops empty entries and keeps first occurrence', () => {
assert.equal(appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }), '/a:/b:/c')
})

test('venvRootForInterpreter follows the interpreter that was actually picked', () => {
// A checkout carrying both environments must not mix them: `.venv` may hold a
// different Python version than `venv`, and crossing the two makes compiled
// extensions (pydantic_core) unimportable.
assert.equal(
venvRootForInterpreter('/Users/test/.hermes/hermes-agent/.venv/bin/python', {
platform: 'darwin',
pathModule: path.posix
}),
'/Users/test/.hermes/hermes-agent/.venv'
)
assert.equal(
venvRootForInterpreter('/Users/test/.hermes/hermes-agent/venv/bin/python', {
platform: 'darwin',
pathModule: path.posix
}),
'/Users/test/.hermes/hermes-agent/venv'
)
assert.equal(
venvRootForInterpreter('C:\\repo\\hermes-agent\\.venv\\Scripts\\python.exe', {
platform: 'win32',
pathModule: path.win32
}),
'C:\\repo\\hermes-agent\\.venv'
)
})

test('venvRootForInterpreter declines interpreters that are not venv-shaped', () => {
// System interpreters have no venv site-packages to inject; the caller falls
// back rather than pointing PYTHONPATH at an unrelated tree.
assert.equal(venvRootForInterpreter('/usr/bin/python3', { platform: 'darwin', pathModule: path.posix }), '/usr')
assert.equal(
venvRootForInterpreter('/opt/homebrew/opt/python/libexec/python3', {
platform: 'darwin',
pathModule: path.posix
}),
null
)
assert.equal(venvRootForInterpreter('', { platform: 'darwin', pathModule: path.posix }), null)
assert.equal(venvRootForInterpreter(null, { platform: 'darwin', pathModule: path.posix }), null)
})
34 changes: 33 additions & 1 deletion apps/desktop/electron/backend-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,37 @@ function pathModuleForPlatform(platform = process.platform) {
return platform === 'win32' ? path.win32 : path.posix
}

// The interpreter we launch and the site-packages we inject have to come from
// the SAME environment. A checkout can carry both `.venv` and `venv` (AGENTS.md
// says prefer `.venv`, fall back to `venv`), and the two routinely hold
// different Python versions. Injecting one environment's site-packages into the
// other's interpreter makes every compiled extension unimportable: pydantic_core
// resolves to a wheel built for the wrong ABI and the backend dies at import
// with "No module named 'pydantic_core._pydantic_core'".
//
// Returns null when the interpreter is not laid out like a venv
// (`<root>/bin/python`, `<root>/Scripts/python.exe`) so the caller keeps control
// of its own fallback.
function venvRootForInterpreter(
pythonPath,
{ platform = process.platform, pathModule = pathModuleForPlatform(platform) }: any = {}
) {
if (!pythonPath) {
return null
}

const binDir = pathModule.dirname(String(pythonPath))
const expectedBinName = platform === 'win32' ? 'scripts' : 'bin'

if (pathModule.basename(binDir).toLowerCase() !== expectedBinName) {
return null
}

const venvRoot = pathModule.dirname(binDir)

return venvRoot && venvRoot !== binDir ? venvRoot : null
}

function pathEnvKey(env = process.env, platform = process.platform) {
if (platform !== 'win32') {
return 'PATH'
Expand Down Expand Up @@ -121,5 +152,6 @@ export {
delimiterForPlatform,
normalizeHermesHomeRoot,
pathEnvKey,
POSIX_SANE_PATH_ENTRIES
POSIX_SANE_PATH_ENTRIES,
venvRootForInterpreter
}
14 changes: 12 additions & 2 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import nodePty from 'node-pty'
import { stopBackendChild as stopBackendChildImpl } from './backend-child'
import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'
import { createBackendConnectionState } from './backend-connection-state'
import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env'
import { buildDesktopBackendEnv, normalizeHermesHomeRoot, venvRootForInterpreter } from './backend-env'
import { canImportHermesCli, verifyHermesCli } from './backend-probes'
import { waitForDashboardPortAnnouncement } from './backend-ready'
import { shouldLatchBackendStartFailure } from './backend-start-failure'
Expand Down Expand Up @@ -3456,7 +3456,17 @@ function createPythonBackend(root, label, backendArgs, options: any = {}) {
return null
}

const venvRoot = path.join(root, 'venv')
// findPythonForRoot() prefers `.venv` over `venv`, so the environment has to
// follow the interpreter it actually picked. Hardcoding `venv` here handed a
// `.venv` interpreter the other environment's site-packages whenever a
// checkout carried both.
const interpreterVenvRoot = venvRootForInterpreter(python)

const venvRoot =
interpreterVenvRoot && fileExists(path.join(interpreterVenvRoot, 'pyvenv.cfg'))
? interpreterVenvRoot
: path.join(root, 'venv')

const venvPython = getVenvPython(venvRoot)
const command = IS_WINDOWS && fileExists(venvPython) ? venvPython : python

Expand Down
Loading