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>
306 lines
9.9 KiB
TypeScript
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
|
|
}
|