Files
hermes-agent/apps/desktop/electron/backend-env.ts
Brooklyn Nicholson 20ae6087ff fix(desktop): pooled profile backends resolve their own cwd, not the app-global one
spawnPoolBackend stamped TERMINAL_CWD: hermesCwd (the launch profile's resolved
workspace) into every pooled backend's env, so a named profile whose
terminal.cwd is a placeholder/unset inherited the launch (or another) profile's
workspace (#87584). The pooled spawn now strips TERMINAL_CWD from the inherited
env and any runtime-provided mapping (case-insensitively on Windows) via the
new pooledProfileBackendEnv helper, layered on profileBackendParentEnv's
dotenv scrub; the --profile child re-resolves its own cwd like a standalone
hermes -p X serve. The primary (launch-profile) backend keeps its pin.

Supersedes #87893 (same env-strip direction, now layered on the #68367 scrub
and paired with the gateway-side tail fix).

Fixes #87584 (desktop half)

Co-authored-by: IvanShang <77005282+qdivan@users.noreply.github.com>
2026-10-05 14:06:14 -05:00

306 lines
9.9 KiB
TypeScript

import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { resolveDesktopHermesHome } from './data-paths'
// macOS apps launched from Finder/Dock inherit only /usr/bin:/bin:/usr/sbin:/sbin,
// which misses Homebrew and user-installed CLI tools (codex, git credential
// helpers). Hermes' own managed tools need no PATH help — the backend composes
// their environment in-process via pm — but user tools on PATH do.
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 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 resolveHermesHomePath(hermesHome, { pathModule, homedir = os.homedir() }: any) {
// fish (and any shell when the value is quoted) hands a literal `~` through; path.resolve()
// would pin it under cwd and the Python backend inherits that absolute path via HERMES_HOME.
let raw = String(hermesHome)
if (raw === '~' || raw.startsWith('~/') || (pathModule === path.win32 && raw.startsWith('~\\'))) {
raw = pathModule.join(homedir, raw.slice(1))
}
return pathModule.resolve(raw)
}
function isProfileHome(resolved, pathModule) {
return pathModule.basename(pathModule.dirname(resolved)).toLowerCase() === 'profiles'
}
function normalizeHermesHomeRoot(
hermesHome,
{ pathModule = pathModuleForPlatform(process.platform), homedir = os.homedir() }: any = {}
) {
if (!hermesHome) {
return hermesHome
}
const resolved = resolveHermesHomePath(hermesHome, { pathModule, homedir })
return isProfileHome(resolved, pathModule) ? pathModule.dirname(pathModule.dirname(resolved)) : resolved
}
// OS/interpreter names a dotenv may redeclare that no child can run without.
const PROCESS_ENV_NAMES = new Set([
'APPDATA',
'COMSPEC',
'HERMES_HOME',
'HOME',
'LANG',
'LC_ALL',
'LOCALAPPDATA',
'PATH',
'PWD',
'PYTHONPATH',
'SHELL',
'SSL_CERT_FILE',
'SYSTEMROOT',
'TEMP',
'TMP',
'TMPDIR',
'TZ',
'USER',
'USERPROFILE',
'VIRTUAL_ENV'
])
function dotenvKeyNames(contents = '') {
return String(contents)
.replace(/^\uFEFF/, '')
.split(/\r?\n/)
.flatMap(line => line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/)?.slice(1, 2) ?? [])
}
function readTextOrEmpty(fsModule, file) {
try {
return String(fsModule.readFileSync(file, 'utf8'))
} catch {
return ''
}
}
/**
* Parent env for a local `hermes serve` child of `profile` (#68367).
*
* `hermes desktop` loads its launch profile's `.env`/`.op.env` into os.environ
* before exec'ing Electron, so `process.env` carries that profile's platform
* credentials. A child for ANOTHER profile would inherit them ahead of its own
* dotenv (`.op.env` is even skipped once OP_SERVICE_ACCOUNT_TOKEN is set) and,
* e.g., connect the same Tlon ship as the default gateway. Drop every name the
* launch profile's dotenv declares, as `_profile_action_environment` does for
* dashboard actions; the child reloads its own scope. The launch profile's own
* backend keeps the env unchanged, and shell exports the launch dotenv never
* declared pass through everywhere.
*
* `profile` null/empty means no `--profile` flag: the child follows the sticky
* `active_profile` like a bare `hermes serve` (`_apply_profile_override`).
*/
function profileBackendParentEnv({
hermesHome,
profile,
currentEnv = process.env,
platform = process.platform,
fsModule = fs,
pathModule = pathModuleForPlatform(platform)
}: any = {}) {
const env = { ...(currentEnv || {}) }
if (!hermesHome) {
return env
}
const fold = platform === 'win32' ? (value: string) => value.toUpperCase() : (value: string) => value
const inheritedHome = currentEnv?.HERMES_HOME ? resolveHermesHomePath(currentEnv.HERMES_HOME, { pathModule }) : null
const launchHome = inheritedHome && isProfileHome(inheritedHome, pathModule) ? inheritedHome : hermesHome
const name = profile || readTextOrEmpty(fsModule, pathModule.join(hermesHome, 'active_profile')).trim()
const targetHome = !name || name === 'default' ? hermesHome : pathModule.join(hermesHome, 'profiles', name)
if (fold(pathModule.resolve(launchHome)) === fold(pathModule.resolve(targetHome))) {
return env
}
const launchOwned = new Set(
['.env', '.op.env']
.flatMap(file => dotenvKeyNames(readTextOrEmpty(fsModule, pathModule.join(launchHome, file))))
.filter(key => !PROCESS_ENV_NAMES.has(key.toUpperCase()))
.map(fold)
)
for (const key of Object.keys(env)) {
if (launchOwned.has(fold(key))) {
delete env[key]
}
}
return env
}
/**
* PATH with the entries under the PM store (HERMES_RUNTIME_DIR, else
* <hermes home>/tools, as pm.environments.store_root resolves it) moved to the
* front, every other entry kept in order. Hermes's own children must run the
* store's uv/node/npm, but shell-path.ts puts the user's login-shell entries
* (nvm, Homebrew, ~/.local/bin) ahead of the inherited PATH, which is where
* `hermes desktop` put the store dirs.
*/
function storeFirstPath(
pathValue: string,
{ currentEnv = process.env, platform = process.platform, homedir = os.homedir() }: any = {}
) {
const pathModule = pathModuleForPlatform(platform)
const delimiter = delimiterForPlatform(platform)
const hermesHome = resolveDesktopHermesHome({ home: homedir, env: currentEnv, platform })
const roots = [currentEnv?.HERMES_RUNTIME_DIR, pathModule.join(hermesHome, 'tools')].filter(Boolean)
const owned = (entry: string) =>
roots.some(root => {
const relative = pathModule.relative(pathModule.resolve(root), pathModule.resolve(entry))
return !relative.startsWith('..') && !pathModule.isAbsolute(relative)
})
const entries = String(pathValue || '').split(delimiter)
return appendUniquePathEntries([entries.filter(entry => entry && owned(entry)), entries], { delimiter })
}
/**
* The environment for the spawned Python backend. Electron knows ONE thing:
* where the interpreter is (by convention). Everything else — managed tool
* PATHs, browser paths, node — is composed in-process by pm when the backend
* spawns tools. PYTHONPATH/PYTHONHOME are scrubbed so an inherited value
* can't make the backend import modules from another checkout. Store dirs
* already on the inherited PATH stay first (storeFirstPath).
*/
function buildDesktopBackendEnv({
currentEnv = process.env,
platform = process.platform,
homedir = os.homedir()
}: any = {}) {
const delimiter = delimiterForPlatform(platform)
const key = pathEnvKey(currentEnv, platform)
const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES
const inherited = storeFirstPath(currentEnv?.[key] || '', { currentEnv, platform, homedir })
return {
PYTHONPATH: '',
PYTHONHOME: '',
// Force PEP 540 UTF-8 mode in the spawned Python backend so its stdio and
// subprocess defaults are UTF-8 even on non-UTF-8 Windows locales (GBK,
// cp1252, ...). hermes_bootstrap sets this inside the child too, but only
// after import — anything emitted earlier (interpreter startup errors,
// pre-bootstrap tracebacks) still decodes with the locale default without
// this. User's explicit setting wins. Re-port of PR #56499 (echoriver89).
PYTHONUTF8: currentEnv?.PYTHONUTF8 ?? '1',
[key]: appendUniquePathEntries([inherited, saneEntries], { delimiter })
}
}
/**
* Spawn env for a POOLED per-profile backend (`spawnPoolBackend`).
*
* TERMINAL_CWD is the LAUNCH profile's resolved workspace. A pooled child
* serves ANOTHER profile (`--profile X`): stamping the app-global cwd makes
* that profile's placeholder/unset `terminal.cwd` sessions inherit the launch
* (or another) profile's workspace (#87584). Drop TERMINAL_CWD from the
* inherited env AND from any runtime-provided mapping (case-insensitively on
* Windows); the `--profile` child re-resolves its own cwd from its profile
* config, the same way a standalone `hermes -p X serve` would. The launch
* profile's own (primary) backend keeps the pin in main.ts.
*/
function pooledProfileBackendEnv({
hermesHome,
profile,
currentEnv = process.env,
backendEnv = {},
platform = process.platform,
fsModule = fs,
pathModule = pathModuleForPlatform(platform)
}: any = {}) {
const parent = profileBackendParentEnv({ hermesHome, profile, currentEnv, platform, fsModule, pathModule })
const fold = platform === 'win32' ? (value: string) => value.toUpperCase() : (value: string) => value
const isTerminalCwd = (key: string) => fold(key) === 'TERMINAL_CWD'
const env = { ...parent }
for (const key of Object.keys(env)) {
if (isTerminalCwd(key)) {
delete env[key]
}
}
// The resolved root, not process.env's: on Windows it can come from the user
// registry, and the --profile child resolves its home under it.
env.HERMES_HOME = hermesHome
for (const [key, value] of Object.entries(backendEnv || {})) {
if (isTerminalCwd(key)) {
continue
}
env[key] = value
}
return env
}
export {
appendUniquePathEntries,
buildDesktopBackendEnv,
delimiterForPlatform,
normalizeHermesHomeRoot,
pathEnvKey,
pooledProfileBackendEnv,
POSIX_SANE_PATH_ENTRIES,
profileBackendParentEnv,
storeFirstPath
}