Codex/pr 1217 (#1226)

* bundle node and windows git runtimes

* split desktop runtime into release package

* fix desktop runtime packaging ci

* embed desktop runtime release tag

* show desktop runtime download progress

* fix desktop runtime release handling

* refactor desktop runtime version config

* fix desktop package license

---------

Co-authored-by: xingzhi <chuzihao.czh@alibaba-inc.com>
Co-authored-by: ekko <fqsy1416@gmail.com>
This commit is contained in:
sir1st
2026-06-02 08:55:17 +08:00
committed by GitHub
parent 7440da9d23
commit 00ea452310
22 changed files with 1077 additions and 55 deletions
+42 -4
View File
@@ -1,8 +1,20 @@
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync } from 'node:fs'
import { delimiter, dirname } from 'node:path'
import { bundledPython, hermesBin, hermesHome, pythonDir, webUiHome } from './paths'
import { delimiter, dirname, join } from 'node:path'
import {
bundledBrowserExecutable,
bundledGit,
bundledNode,
bundledPython,
gitPathDirs,
hermesBin,
hermesHome,
nodeBinDir,
pythonDir,
webUiHome,
} from './paths'
import { HERMES_CLI_ARG } from './cli-constants'
import { ensureDesktopRuntime } from './runtime-manager'
export function parseHermesCliArgs(argv: string[] = process.argv): string[] | null {
const index = argv.indexOf(HERMES_CLI_ARG)
@@ -11,10 +23,17 @@ export function parseHermesCliArgs(argv: string[] = process.argv): string[] | nu
}
export async function runBundledHermesCli(args: string[]): Promise<number> {
try {
await ensureDesktopRuntime()
} catch (err) {
console.error(`Failed to prepare Hermes runtime: ${err instanceof Error ? err.message : String(err)}`)
return 1
}
const command = hermesBin()
if (!existsSync(command)) {
console.error(`hermes binary missing at ${command}`)
console.error('Run: npm run prepare:python (to bundle Python + hermes-agent)')
console.error('Run: npm run prepare:runtime (to build a local Hermes runtime)')
return 127
}
@@ -22,7 +41,20 @@ export async function runBundledHermesCli(args: string[]): Promise<number> {
mkdirSync(hermesHome(), { recursive: true })
const binDir = dirname(command)
const pathValue = process.env.PATH ? `${binDir}${delimiter}${process.env.PATH}` : binDir
const bundledNodeBin = nodeBinDir()
const bundledAgentBrowserBin = process.platform === 'win32'
? join(pythonDir(), 'node')
: join(pythonDir(), 'node', 'bin')
const inheritedPath = process.env.PATH || process.env.Path || ''
const pathValue = [
binDir,
bundledAgentBrowserBin,
bundledNodeBin,
gitPathDirs().join(delimiter),
inheritedPath,
].filter(Boolean).join(delimiter)
const gitBin = bundledGit()
const browserExecutable = process.env.AGENT_BROWSER_EXECUTABLE_PATH?.trim() || bundledBrowserExecutable()
const env: NodeJS.ProcessEnv = {
...process.env,
HERMES_DESKTOP: 'true',
@@ -30,6 +62,12 @@ export async function runBundledHermesCli(args: string[]): Promise<number> {
HERMES_AGENT_BRIDGE_PYTHON: bundledPython(),
HERMES_AGENT_CLI_PYTHON: bundledPython(),
HERMES_AGENT_ROOT: pythonDir(),
HERMES_AGENT_NODE: bundledNode(),
HERMES_AGENT_NODE_ROOT: process.platform === 'win32' ? bundledNodeBin : dirname(bundledNodeBin),
AGENT_BROWSER_HOME: process.env.AGENT_BROWSER_HOME?.trim() || join(hermesHome(), 'agent-browser'),
...(browserExecutable ? { AGENT_BROWSER_EXECUTABLE_PATH: browserExecutable } : {}),
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || join(pythonDir(), 'ms-playwright'),
...(gitBin ? { HERMES_AGENT_GIT: gitBin } : {}),
HERMES_HOME: hermesHome(),
HERMES_WEB_UI_HOME: webUiHome(),
HERMES_WEBUI_STATE_DIR: webUiHome(),
+82 -4
View File
@@ -6,6 +6,7 @@ import { checkForDesktopUpdates, initAutoUpdater } from './updater'
import { t } from './desktop-i18n'
import { installHermesStudioCliShim } from './cli-shim'
import { parseHermesCliArgs, runBundledHermesCli } from './hermes-cli'
import { ensureDesktopRuntime, type RuntimeProgress } from './runtime-manager'
const PORT = Number(process.env.HERMES_DESKTOP_PORT) || 8748
const START_HIDDEN = process.argv.includes('--hidden')
@@ -15,6 +16,7 @@ let mainWindow: BrowserWindow | null = null
let serverUrl: string | null = null
let tray: Tray | null = null
let isQuitting = false
let isBootstrapping = false
function showMainWindow() {
if (!mainWindow) {
@@ -168,25 +170,91 @@ function splashHtml(): string {
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Hermes Studio</title>
<style>
html,body{margin:0;height:100%;background:#1a1a1a;color:#e5e5e5;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;}
.wrap{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:24px}
.wrap{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:20px}
.dot{width:10px;height:10px;border-radius:50%;background:#888;animation:pulse 1.2s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:.3}50%{opacity:1}}
.row{display:flex;gap:8px}
.row .dot:nth-child(2){animation-delay:.2s}.row .dot:nth-child(3){animation-delay:.4s}
.label{font-size:14px;color:#999}
.label{font-size:14px;color:#b8b8b8}
.detail{min-height:18px;font-size:12px;color:#7f7f7f}
.progress{width:320px;height:6px;border-radius:999px;background:#2b2b2b;overflow:hidden}
.bar{width:0;height:100%;background:#d8d8d8;transition:width .18s ease}
h1{font-weight:500;margin:0;font-size:18px}
</style></head><body><div class="wrap">
<h1>Hermes Studio</h1>
<div class="row"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>
<div class="label">Starting local services</div>
<div id="label" class="label">Starting local services...</div>
<div class="progress"><div id="bar" class="bar"></div></div>
<div id="detail" class="detail"></div>
</div></body></html>`
return 'data:text/html;charset=utf-8,' + encodeURIComponent(html)
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB']
let value = bytes / 1024
let unit = units[0]
for (let i = 1; i < units.length && value >= 1024; i += 1) {
value /= 1024
unit = units[i]
}
return `${value.toFixed(value >= 100 ? 0 : 1)} ${unit}`
}
function updateSplash(progress: RuntimeProgress) {
if (!mainWindow || mainWindow.isDestroyed()) return
const label = progress.message
const percent = typeof progress.percent === 'number' ? Math.round(progress.percent) : null
let detail = ''
if (progress.receivedBytes && progress.totalBytes) {
detail = `${formatBytes(progress.receivedBytes)} / ${formatBytes(progress.totalBytes)}`
if (percent !== null) detail += ` (${percent}%)`
} else if (percent !== null) {
detail = `${percent}%`
}
mainWindow.webContents.executeJavaScript(`
{
const label = document.getElementById('label');
const detail = document.getElementById('detail');
const bar = document.getElementById('bar');
if (label) label.textContent = ${JSON.stringify(label)};
if (detail) detail.textContent = ${JSON.stringify(detail)};
if (bar) bar.style.width = ${JSON.stringify(percent === null ? '100%' : `${percent}%`)};
}
`).catch(() => undefined)
}
async function bootstrap() {
if (isBootstrapping) return
isBootstrapping = true
try {
await ensureDesktopRuntime(updateSplash)
} catch (err) {
console.error('Failed to prepare Hermes runtime:', err)
if (mainWindow) {
const msg = String(err instanceof Error ? err.message : err).replace(/[<>]/g, '')
mainWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(
`<html><body style="font-family:system-ui;padding:32px;background:#1a1a1a;color:#eee">
<h2>Failed to prepare Hermes runtime</h2><pre style="white-space:pre-wrap;color:#f88">${msg}</pre>
<button id="retry" style="margin-top:16px;padding:8px 14px;border:1px solid #555;border-radius:6px;background:#2b2b2b;color:#eee;cursor:pointer">Retry</button>
<script>
document.getElementById('retry')?.addEventListener('click', () => {
window.hermesDesktop?.retryBootstrap?.()
})
</script>
</body></html>`,
))
}
isBootstrapping = false
return
}
if (!hermesBinExists()) {
console.error(`hermes binary missing at ${hermesBin()}`)
console.error('Run: npm run prepare:python (to bundle Python + hermes-agent)')
console.error('Run: npm run prepare:runtime (to build a local Hermes runtime)')
}
try {
@@ -203,10 +271,20 @@ async function bootstrap() {
</body></html>`,
))
}
} finally {
isBootstrapping = false
}
}
ipcMain.handle('hermes-desktop:get-token', () => getToken())
ipcMain.handle('hermes-desktop:retry-bootstrap', async () => {
if (serverUrl) {
await mainWindow?.loadURL(serverUrl)
return
}
await mainWindow?.loadURL(splashHtml())
await bootstrap()
})
function runDesktopApp() {
const gotLock = app.requestSingleInstanceLock()
+61 -4
View File
@@ -23,12 +23,69 @@ export function webuiServerEntry(): string {
return join(webuiDir(), 'dist', 'server', 'index.js')
}
// Bundled Python directory.
export function runtimePlatformKey(): string {
return `${osLabel}-${archLabel}`
}
export function desktopRuntimeDir(): string {
const override = process.env.HERMES_DESKTOP_RUNTIME_DIR?.trim()
if (override) return resolve(override)
return join(webUiHome(), 'desktop-runtime', runtimePlatformKey())
}
function packagedResourceDir(name: string): string {
return resolve(process.resourcesPath, name)
}
// dev: packages/desktop/resources/python/<os>-<arch>
// prod: <resources>/python
// prod: <resources>/python when present, otherwise downloaded runtime cache.
export function pythonDir(): string {
if (app.isPackaged) return resolve(process.resourcesPath, 'python')
return resolve(app.getAppPath(), 'resources', 'python', `${osLabel}-${archLabel}`)
if (app.isPackaged) {
const packaged = packagedResourceDir('python')
return existsSync(packaged) ? packaged : join(desktopRuntimeDir(), 'python')
}
return resolve(app.getAppPath(), 'resources', 'python', runtimePlatformKey())
}
export function nodeDir(): string {
if (app.isPackaged) {
const packaged = packagedResourceDir('node')
return existsSync(packaged) ? packaged : join(desktopRuntimeDir(), 'node')
}
return resolve(app.getAppPath(), 'resources', 'node', runtimePlatformKey())
}
export function nodeBinDir(): string {
const dir = nodeDir()
return isWin ? dir : join(dir, 'bin')
}
export function bundledNode(): string {
return isWin ? join(nodeDir(), 'node.exe') : join(nodeBinDir(), 'node')
}
export function gitDir(): string {
if (app.isPackaged) {
const packaged = packagedResourceDir('git')
return existsSync(packaged) ? packaged : join(desktopRuntimeDir(), 'git')
}
return resolve(app.getAppPath(), 'resources', 'git', runtimePlatformKey())
}
export function gitPathDirs(): string[] {
if (!isWin) return []
const dir = gitDir()
return [
join(dir, 'cmd'),
join(dir, 'mingw64', 'bin'),
join(dir, 'usr', 'bin'),
].filter(existsSync)
}
export function bundledGit(): string | undefined {
if (!isWin) return undefined
const git = join(gitDir(), 'cmd', 'git.exe')
return existsSync(git) ? git : undefined
}
export function bundledAgentBrowserHome(): string {
@@ -0,0 +1,297 @@
import { execFile } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
createReadStream,
createWriteStream,
existsSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs'
import { get as httpGet } from 'node:http'
import { get as httpsGet } from 'node:https'
import { basename, dirname, join } from 'node:path'
import { promisify } from 'node:util'
import { app } from 'electron'
import {
bundledGit,
bundledNode,
desktopRuntimeDir,
hermesBinExists,
runtimePlatformKey,
} from './paths'
const execFileAsync = promisify(execFile)
const DEFAULT_RUNTIME_BASE_URL = 'https://download.ekkolearnai.com'
const RUNTIME_MANIFEST_NAME = 'runtime-manifest.json'
const PACKAGED_RUNTIME_RELEASE_NAME = 'runtime-release.json'
type RuntimeManifest = {
schema: number
platform: string
hermesAgentVersion?: string
asset?: {
name: string
url?: string
sha256?: string
size?: number
}
}
type RuntimeDescriptor = {
name: string
url: string
sha256?: string
hermesAgentVersion?: string
}
export type RuntimeProgress = {
stage: 'resolve' | 'download' | 'verify' | 'extract' | 'ready'
message: string
percent?: number
receivedBytes?: number
totalBytes?: number
}
type RuntimeProgressHandler = (progress: RuntimeProgress) => void
function runtimeReady(): boolean {
const gitReady = process.platform !== 'win32' || !!bundledGit()
return hermesBinExists() && existsSync(bundledNode()) && gitReady
}
function releaseTagCandidates(): string[] {
const override = process.env.HERMES_DESKTOP_RUNTIME_RELEASE_TAG?.trim()
if (override) return [override]
const version = app.getVersion()
const candidates = [packagedRuntimeReleaseTag(), version, `v${version}`, 'latest']
return Array.from(new Set(candidates.filter((tag): tag is string => typeof tag === 'string' && tag.length > 0)))
}
function packagedRuntimeReleaseTag(): string | null {
const candidates = app.isPackaged
? [join(process.resourcesPath, 'build', PACKAGED_RUNTIME_RELEASE_NAME)]
: [join(app.getAppPath(), 'build', PACKAGED_RUNTIME_RELEASE_NAME)]
for (const candidate of candidates) {
if (!existsSync(candidate)) continue
try {
const metadata = JSON.parse(readFileSync(candidate, 'utf-8')) as { tag?: unknown }
if (typeof metadata.tag === 'string' && metadata.tag.trim()) return metadata.tag.trim()
} catch {}
}
return null
}
function runtimeAssetUrl(assetName: string, tag: string): string {
const repo = process.env.HERMES_DESKTOP_RUNTIME_REPO?.trim()
if (repo) {
if (tag === 'latest') {
return `https://github.com/${repo}/releases/latest/download/${encodeURIComponent(assetName)}`
}
return `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(assetName)}`
}
const template = process.env.HERMES_DESKTOP_RUNTIME_BASE_URL?.trim() || DEFAULT_RUNTIME_BASE_URL
if (template.includes('{asset}') || template.includes('{tag}')) {
return template
.replace(/\{asset\}/g, encodeURIComponent(assetName))
.replace(/\{tag\}/g, encodeURIComponent(tag))
}
return `${template.replace(/\/$/, '')}/${encodeURIComponent(tag)}/${encodeURIComponent(assetName)}`
}
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`GET ${url} returned ${response.status}`)
}
return await response.json() as T
}
async function resolveRuntimeDescriptor(): Promise<RuntimeDescriptor> {
const directUrl = process.env.HERMES_DESKTOP_RUNTIME_URL?.trim()
if (directUrl) {
return { name: basename(new URL(directUrl).pathname) || 'hermes-runtime.tar.gz', url: directUrl }
}
const platformManifestName = `hermes-runtime-${runtimePlatformKey()}.json`
const manifestOverride = process.env.HERMES_DESKTOP_RUNTIME_MANIFEST_URL?.trim()
const candidates = manifestOverride
? [{ tag: '', url: manifestOverride }]
: releaseTagCandidates().map(tag => ({ tag, url: runtimeAssetUrl(platformManifestName, tag) }))
let lastError: Error | null = null
for (const candidate of candidates) {
try {
const manifest = await fetchJson<RuntimeManifest>(candidate.url)
if (!manifest.asset?.name) {
throw new Error(`runtime manifest is missing asset.name: ${candidate.url}`)
}
return {
name: manifest.asset.name,
url: manifest.asset.url || runtimeAssetUrl(manifest.asset.name, candidate.tag),
sha256: manifest.asset.sha256,
hermesAgentVersion: manifest.hermesAgentVersion,
}
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err))
}
}
throw lastError || new Error('Unable to resolve Hermes desktop runtime package')
}
function readCachedRuntimeManifest(root: string): RuntimeManifest | null {
const file = join(root, RUNTIME_MANIFEST_NAME)
if (!existsSync(file)) return null
try {
return JSON.parse(readFileSync(file, 'utf-8')) as RuntimeManifest
} catch {
return null
}
}
function cachedRuntimeMatches(root: string, descriptor: RuntimeDescriptor): boolean {
if (!runtimeReady()) return false
const manifest = readCachedRuntimeManifest(root)
if (!manifest?.asset?.name) return true
return manifest.asset.name === descriptor.name
}
function downloadFile(
url: string,
target: string,
onProgress?: RuntimeProgressHandler,
redirects = 5,
): Promise<void> {
return new Promise((resolve, reject) => {
const parsed = new URL(url)
const getter = parsed.protocol === 'http:' ? httpGet : httpsGet
const req = getter(parsed, response => {
const status = response.statusCode || 0
const location = response.headers.location
if (status >= 300 && status < 400 && location && redirects > 0) {
response.resume()
downloadFile(new URL(location, url).toString(), target, onProgress, redirects - 1).then(resolve, reject)
return
}
if (status < 200 || status >= 300) {
response.resume()
reject(new Error(`GET ${url} returned ${status}`))
return
}
const totalBytes = Number(response.headers['content-length']) || undefined
let receivedBytes = 0
response.on('data', chunk => {
receivedBytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk)
onProgress?.({
stage: 'download',
message: 'Downloading Hermes runtime...',
percent: totalBytes ? Math.min(100, (receivedBytes / totalBytes) * 100) : undefined,
receivedBytes,
totalBytes,
})
})
const file = createWriteStream(target)
response.pipe(file)
file.on('finish', () => file.close(() => resolve()))
file.on('error', reject)
})
req.on('error', reject)
})
}
async function sha256File(file: string): Promise<string> {
const hash = createHash('sha256')
await new Promise<void>((resolve, reject) => {
const stream = createReadStream(file)
stream.on('data', chunk => hash.update(chunk))
stream.on('end', resolve)
stream.on('error', reject)
})
return hash.digest('hex')
}
async function extractRuntimeArchive(archive: string, targetRoot: string): Promise<void> {
const parent = dirname(targetRoot)
const tempRoot = join(parent, `.runtime-${process.pid}-${Date.now()}`)
rmSync(tempRoot, { recursive: true, force: true })
mkdirSync(tempRoot, { recursive: true })
try {
await execFileAsync(process.platform === 'win32' ? 'tar.exe' : 'tar', ['-xzf', archive, '-C', tempRoot], {
windowsHide: true,
})
for (const required of ['python', 'node']) {
if (!existsSync(join(tempRoot, required))) {
throw new Error(`Runtime archive did not contain ${required}/`)
}
}
rmSync(targetRoot, { recursive: true, force: true })
mkdirSync(parent, { recursive: true })
renameSync(tempRoot, targetRoot)
} catch (err) {
rmSync(tempRoot, { recursive: true, force: true })
throw err
}
}
export async function ensureDesktopRuntime(onProgress?: RuntimeProgressHandler): Promise<void> {
const runtimeRoot = desktopRuntimeDir()
mkdirSync(runtimeRoot, { recursive: true })
let descriptor: RuntimeDescriptor
try {
onProgress?.({ stage: 'resolve', message: 'Checking Hermes runtime...' })
descriptor = await resolveRuntimeDescriptor()
} catch (err) {
if (runtimeReady() && !process.env.HERMES_DESKTOP_RUNTIME_FORCE_UPDATE) {
console.warn(`[runtime] using cached Hermes runtime because update check failed: ${err instanceof Error ? err.message : String(err)}`)
return
}
throw err
}
if (cachedRuntimeMatches(runtimeRoot, descriptor) && !process.env.HERMES_DESKTOP_RUNTIME_FORCE_UPDATE) return
const archive = join(dirname(runtimeRoot), `${descriptor.name}.download`)
console.log(`[runtime] downloading Hermes runtime ${descriptor.name}`)
onProgress?.({ stage: 'download', message: `Downloading ${descriptor.name}...` })
await downloadFile(descriptor.url, archive, onProgress)
if (descriptor.sha256) {
onProgress?.({ stage: 'verify', message: 'Verifying Hermes runtime...' })
const actual = await sha256File(archive)
if (actual !== descriptor.sha256) {
throw new Error(`Runtime checksum mismatch for ${descriptor.name}`)
}
}
onProgress?.({ stage: 'extract', message: 'Extracting Hermes runtime...' })
await extractRuntimeArchive(archive, runtimeRoot)
const archiveSize = statSync(archive).size
rmSync(archive, { force: true })
const manifestPath = join(runtimeRoot, RUNTIME_MANIFEST_NAME)
if (!existsSync(manifestPath)) {
writeFileSync(manifestPath, JSON.stringify({
schema: 1,
platform: runtimePlatformKey(),
hermesAgentVersion: descriptor.hermesAgentVersion,
asset: {
name: descriptor.name,
sha256: descriptor.sha256,
size: archiveSize,
},
}, null, 2))
}
onProgress?.({ stage: 'ready', message: 'Hermes runtime ready.' })
console.log(`[runtime] Hermes runtime ready at ${runtimeRoot}`)
}
+14 -1
View File
@@ -8,11 +8,15 @@ import { promisify } from 'node:util'
import { app } from 'electron'
import {
bundledBrowserExecutable,
bundledGit,
bundledNode,
gitPathDirs,
webuiServerEntry,
webuiDir,
hermesBin,
webUiHome,
hermesHome,
nodeBinDir,
tokenFile,
pythonDir,
} from './paths'
@@ -296,22 +300,28 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
const bundledPython = isWin
? join(pythonDir(), 'python.exe')
: join(pythonDir(), 'bin', 'python3')
const bundledNodeBin = isWin
const bundledAgentBrowserBin = isWin
? join(pythonDir(), 'node')
: join(pythonDir(), 'node', 'bin')
const bundledNodeBin = nodeBinDir()
const bundledGitPath = gitPathDirs().join(delimiter)
const bridgePort = await getFreeTcpPort()
const workerPortBase = await getFreeTcpPortInRange(20000, 59000)
const loginShellPath = await getLoginShellPath()
const nvmNodeBinPaths = getNvmNodeBinPaths()
const runtimePath = mergePathEntries(
dirname(hermesBin()),
bundledAgentBrowserBin,
bundledNodeBin,
bundledGitPath,
loginShellPath,
nvmNodeBinPaths,
process.env.PATH,
process.env.Path,
COMMON_USER_BIN_DIRS.join(delimiter),
)
const browserExecutable = process.env.AGENT_BROWSER_EXECUTABLE_PATH?.trim() || bundledBrowserExecutable()
const gitBin = bundledGit()
// Run via Electron's "run as Node" mode — Electron binary doubles as Node.
const env: NodeJS.ProcessEnv = {
@@ -326,9 +336,12 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
HERMES_AGENT_BRIDGE_PYTHON: bundledPython,
HERMES_AGENT_CLI_PYTHON: bundledPython,
HERMES_AGENT_ROOT: pythonDir(),
HERMES_AGENT_NODE: bundledNode(),
HERMES_AGENT_NODE_ROOT: isWin ? bundledNodeBin : dirname(bundledNodeBin),
AGENT_BROWSER_HOME: process.env.AGENT_BROWSER_HOME?.trim() || join(agentHome, 'agent-browser'),
...(browserExecutable ? { AGENT_BROWSER_EXECUTABLE_PATH: browserExecutable } : {}),
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || join(pythonDir(), 'ms-playwright'),
...(gitBin ? { HERMES_AGENT_GIT: gitBin } : {}),
// Force TCP loopback for the agent bridge. The default `ipc:///tmp/...`
// unix socket is rejected on macOS in some EDR/sandbox setups (silent
// SIGKILL of the bridge child within ~150ms). TCP on 127.0.0.1 works