[codex] fix Windows desktop browser packaging (#1219)

* fix windows hermes home fallback

* bundle Hermes desktop browser runtime

* bundle desktop channel dependencies

* avoid matrix e2ee build dependency

* fix windows npm shim execution

* fix bundled agent-browser chrome packaging

* fix agent-browser chrome fallback copy

* fix windows agent-browser home lookup

* copy agent-browser chrome after install

* fix browser output decoding on windows

---------

Co-authored-by: xingzhi <chuzihao.czh@alibaba-inc.com>
This commit is contained in:
ekko
2026-06-01 21:35:26 +08:00
committed by GitHub
parent 90929d0bfb
commit c27a12f56c
9 changed files with 668 additions and 50 deletions
@@ -33,6 +33,7 @@ const sitePkgs = process.env.HERMES_AGENT_SITE_PACKAGES ?? (
)
const dtPath = join(sitePkgs, 'gateway', 'platforms', 'dingtalk.py')
const browserToolPath = join(sitePkgs, 'tools', 'browser_tool.py')
const sitecustomizePath = join(sitePkgs, 'sitecustomize.py')
if (!existsSync(dtPath)) {
console.error(`dingtalk.py not found at ${dtPath} — is hermes-agent installed?`)
@@ -59,6 +60,21 @@ function patch(id, marker, find, replace) {
applied++
}
function patchText(text, id, marker, find, replace) {
if (text.includes(marker)) {
console.log(` · ${id} (already applied)`)
skipped++
return text
}
if (!text.includes(find)) {
console.log(`${id} (anchor not found — upstream changed?)`)
return text
}
applied++
console.log(`${id}`)
return text.replace(find, replace)
}
console.log(`Patching ${dtPath}`)
// NOTE: the former `dt-pre-start` patch was retired — hermes-agent now ships
@@ -179,6 +195,63 @@ if (src !== before) {
writeFileSync(dtPath, src)
}
if (existsSync(browserToolPath)) {
console.log(`Patching ${browserToolPath}`)
let browserSrc = readFileSync(browserToolPath, 'utf-8')
const browserBefore = browserSrc
browserSrc = patchText(
browserSrc,
'browser-stdout-decode-fallback',
'# patch:browser-stdout-decode-fallback',
`from hermes_cli.config import cfg_get\n`,
`from hermes_cli.config import cfg_get
# patch:browser-stdout-decode-fallback
def _hermes_read_browser_output(path: str) -> str:
data = Path(path).read_bytes()
for encoding in ("utf-8", "gb18030"):
try:
return data.decode(encoding)
except UnicodeDecodeError:
pass
return data.decode("utf-8", errors="replace")
`,
)
for (const [id, find, replace] of [
[
'browser-fallback-stdout-read',
` with open(stdout_path, "r", encoding="utf-8") as f:
stdout = f.read().strip()`,
` # patch:browser-fallback-stdout-read
stdout = _hermes_read_browser_output(stdout_path).strip()`,
],
[
'browser-command-stdout-read',
` with open(stdout_path, "r", encoding="utf-8") as f:
stdout = f.read()
with open(stderr_path, "r", encoding="utf-8") as f:
stderr = f.read()`,
` # patch:browser-command-stdout-read
stdout = _hermes_read_browser_output(stdout_path)
stderr = _hermes_read_browser_output(stderr_path)`,
],
]) {
browserSrc = patchText(
browserSrc,
id,
`# patch:${id}`,
find,
replace,
)
}
if (browserSrc !== browserBefore) {
writeFileSync(browserToolPath, browserSrc)
}
}
const brotlicffiCompatMarker = '# patch:brotlicffi-error-compat'
const brotlicffiCompat = `
${brotlicffiCompatMarker}
+334 -30
View File
@@ -1,11 +1,23 @@
#!/usr/bin/env node
// Install hermes-agent into the bundled Python at resources/python/<os>-<arch>/.
// Prefers `uv` (10-100x faster, more deterministic) and falls back to pip.
import { existsSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import {
chmodSync,
copyFileSync,
cpSync,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
rmSync,
symlinkSync,
unlinkSync,
writeFileSync,
} from 'node:fs'
import { basename, resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
import { platform as osPlatform, arch as osArch } from 'node:os'
import { platform as osPlatform, arch as osArch, homedir as osHomedir } from 'node:os'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, '..')
@@ -13,10 +25,43 @@ const ROOT = resolve(__dirname, '..')
const TARGET_OS = process.env.TARGET_OS || osPlatform()
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
const HERMES_VERSION = process.env.HERMES_VERSION || '0.15.2'
const HERMES_PACKAGE = process.env.HERMES_PACKAGE || `hermes-agent[mcp]==${HERMES_VERSION}`
// Match the packaged runtime to the channel list exposed at /hermes/channels.
// Telegram, Discord, and Slack are covered by "messaging". We intentionally
// install Matrix's plaintext deps below instead of using the "matrix" extra:
// that extra pulls mautrix[encryption] -> python-olm, which needs a fragile
// native build on desktop packaging machines. WhatsApp, QQBot, and Weixin do
// not expose dedicated hermes-agent extras; their deps are covered by base or
// the channel extras below.
const HERMES_EXTRAS = [
'mcp',
'messaging',
'slack',
'wecom',
'dingtalk',
'feishu',
].join(',')
const HERMES_PACKAGE = process.env.HERMES_PACKAGE || `hermes-agent[${HERMES_EXTRAS}]==${HERMES_VERSION}`
const EXTRA_PYTHON_PACKAGES = splitPackageList(
process.env.HERMES_EXTRA_PYTHON_PACKAGES || [
'websockets',
'mautrix==0.21.0',
'Markdown==3.10.2',
'aiosqlite==0.22.1',
'asyncpg==0.31.0',
'aiohttp-socks==0.11.0',
].join(' '),
)
const BROWSER_PACKAGES = splitPackageList(
process.env.HERMES_BROWSER_PACKAGES || 'agent-browser@^0.26.0 @askjo/camofox-browser@^1.5.2',
)
const SKIP_BROWSER_RUNTIME = process.env.HERMES_SKIP_BROWSER_RUNTIME === '1'
|| process.env.HERMES_SKIP_BROWSER_RUNTIME?.toLowerCase() === 'true'
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
const PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
const NODE_PREFIX = resolve(PY_DIR, 'node')
const AGENT_BROWSER_HOME = resolve(PY_DIR, 'agent-browser')
const PLAYWRIGHT_BROWSERS_PATH = resolve(PY_DIR, 'ms-playwright')
const pyBin = TARGET_OS === 'win32'
? resolve(PY_DIR, 'python.exe')
@@ -32,33 +77,285 @@ function hasUv() {
return r.status === 0
}
let r
function splitPackageList(value) {
return value
.split(/[,\s]+/)
.map(part => part.trim())
.filter(Boolean)
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, { stdio: 'inherit', ...options })
if (result.status !== 0) process.exit(result.status ?? 1)
return result
}
function optionalRun(command, args, options = {}) {
return spawnSync(command, args, { stdio: 'inherit', ...options })
}
function commandInvocation(command) {
if (TARGET_OS === 'win32' && command.toLowerCase().endsWith('.cmd')) {
return { command: 'cmd.exe', argsPrefix: ['/d', '/s', '/c', command] }
}
return { command, argsPrefix: [] }
}
function runInvocation(invocation, args, options = {}) {
return run(invocation.command, [...invocation.argsPrefix, ...args], options)
}
function optionalRunInvocation(invocation, args, options = {}) {
return optionalRun(invocation.command, [...invocation.argsPrefix, ...args], options)
}
function installPythonPackages(packages, label) {
if (packages.length === 0) return
if (hasUv()) {
console.log(`→ Installing ${HERMES_PACKAGE} via uv`)
r = spawnSync('uv', [
console.log(`→ Installing ${label} via uv: ${packages.join(' ')}`)
run('uv', [
'pip', 'install',
'--python', pyBin,
HERMES_PACKAGE,
], { stdio: 'inherit' })
...packages,
])
} else {
console.log(`→ Installing ${HERMES_PACKAGE} via pip`)
r = spawnSync(pyBin, [
console.log(`→ Installing ${label} via pip: ${packages.join(' ')}`)
run(pyBin, [
'-m', 'pip', 'install',
HERMES_PACKAGE,
...packages,
'--no-warn-script-location',
'--disable-pip-version-check',
], { stdio: 'inherit' })
])
}
}
if (r.status !== 0) process.exit(r.status ?? 1)
r = spawnSync(pyBin, [
'-c',
'import mcp; import tools.mcp_tool as t; assert t._MCP_AVAILABLE',
], { stdio: 'inherit' })
if (r.status !== 0) {
console.error('MCP Python SDK sanity check failed')
process.exit(r.status ?? 1)
function npmCommand() {
const candidates = TARGET_OS === 'win32'
? ['npm.cmd', 'npm.exe', 'npm']
: ['npm']
for (const candidate of candidates) {
const invocation = commandInvocation(candidate)
const result = optionalRunInvocation(invocation, ['--version'], { stdio: 'ignore' })
if (result.status === 0) return invocation
}
return null
}
function agentBrowserCommand() {
if (TARGET_OS === 'win32') {
return resolve(NODE_PREFIX, 'agent-browser.cmd')
}
return resolve(NODE_PREFIX, 'bin', 'agent-browser')
}
function browserRuntimeEnv() {
const nodePath = TARGET_OS === 'win32'
? NODE_PREFIX
: resolve(NODE_PREFIX, 'bin')
const inheritedPath = process.env.PATH || process.env.Path || ''
const pathKey = TARGET_OS === 'win32' ? 'Path' : 'PATH'
const browserExecutable = ensureBundledBrowserExecutable()
const env = {
...process.env,
[pathKey]: [nodePath, inheritedPath].filter(Boolean).join(TARGET_OS === 'win32' ? ';' : ':'),
AGENT_BROWSER_HOME,
PLAYWRIGHT_BROWSERS_PATH,
}
if (browserExecutable) env.AGENT_BROWSER_EXECUTABLE_PATH = browserExecutable
return env
}
function bundledBrowserExecutableNames() {
if (TARGET_OS === 'win32') return new Set(['chrome.exe'])
if (TARGET_OS === 'darwin') return new Set(['Google Chrome for Testing', 'Google Chrome', 'Chromium', 'chrome'])
return new Set(['chrome', 'chromium', 'chromium-browser'])
}
function defaultAgentBrowserHomes() {
const candidates = [
process.env.USERPROFILE,
process.env.UserProfile,
process.env.HOME,
process.env.HOMEDRIVE && process.env.HOMEPATH
? `${process.env.HOMEDRIVE}${process.env.HOMEPATH}`
: null,
osHomedir(),
]
return Array.from(new Set(
candidates
.map(home => home?.trim())
.filter(Boolean)
.map(home => resolve(home, '.agent-browser')),
))
}
function findBrowserInstallInHome(home) {
const names = bundledBrowserExecutableNames()
const browsersDir = join(home, 'browsers')
const bundleDirs = []
if (existsSync(browsersDir)) {
try {
for (const entry of readdirSync(browsersDir, { withFileTypes: true })) {
if (entry.isDirectory()) bundleDirs.push(join(browsersDir, entry.name))
}
} catch {}
}
for (const bundleDir of bundleDirs) {
const executable = findBrowserExecutableUnder(bundleDir, names)
if (executable) return { executable, bundleDir }
}
return null
}
function findBrowserExecutableUnder(root, names) {
const stack = [root].filter(existsSync)
const visited = new Set()
while (stack.length > 0) {
const dir = stack.pop()
if (!dir || visited.has(dir)) continue
visited.add(dir)
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const path = join(dir, entry.name)
if (entry.isFile() && names.has(entry.name)) return path
if (entry.isDirectory()) stack.push(path)
}
}
return null
}
function findBundledBrowserExecutable() {
return findBrowserInstallInHome(AGENT_BROWSER_HOME)?.executable ?? null
}
function ensureBundledBrowserExecutable() {
const bundled = findBrowserInstallInHome(AGENT_BROWSER_HOME)
if (bundled) return bundled.executable
const searchedHomes = []
for (const fallbackHome of defaultAgentBrowserHomes()) {
if (fallbackHome === AGENT_BROWSER_HOME) continue
searchedHomes.push(fallbackHome)
const fallback = findBrowserInstallInHome(fallbackHome)
if (!fallback) continue
const targetBrowsersDir = join(AGENT_BROWSER_HOME, 'browsers')
const targetBundleDir = join(targetBrowsersDir, basename(fallback.bundleDir))
mkdirSync(targetBrowsersDir, { recursive: true })
cpSync(fallback.bundleDir, targetBundleDir, { recursive: true, force: true, verbatimSymlinks: true })
console.log(`✓ copied Chrome bundle into ${targetBundleDir}`)
return findBundledBrowserExecutable()
}
if (searchedHomes.length > 0) {
console.warn(`! no Chrome bundle found in fallback agent-browser homes: ${searchedHomes.join(', ')}`)
}
return null
}
function sitePackagesDir() {
if (TARGET_OS === 'win32') {
return resolve(PY_DIR, 'Lib', 'site-packages')
}
const libDir = resolve(PY_DIR, 'lib')
const py = readdirSync(libDir).find(n => /^python\d+\.\d+$/.test(n))
if (!py) throw new Error(`Could not locate pythonX.Y under ${libDir}`)
return resolve(libDir, py, 'site-packages')
}
function pythonModuleExists(moduleName) {
const result = optionalRun(pyBin, [
'-c',
`import importlib.util, sys; sys.exit(0 if importlib.util.find_spec(${JSON.stringify(moduleName)}) else 1)`,
], { stdio: 'ignore' })
return result.status === 0
}
function removeBrokenDashboardAuthPlugin() {
if (pythonModuleExists('hermes_cli.dashboard_auth')) return
const pluginDir = resolve(sitePackagesDir(), 'plugins', 'dashboard_auth', 'nous')
if (!existsSync(pluginDir)) return
rmSync(pluginDir, { recursive: true, force: true })
console.warn(
'! Removed bundled dashboard_auth/nous plugin because hermes_cli.dashboard_auth is missing from the hermes-agent package',
)
}
function installBrowserRuntime() {
if (SKIP_BROWSER_RUNTIME) {
console.warn('! Skipping bundled browser runtime because HERMES_SKIP_BROWSER_RUNTIME is set')
return
}
if (BROWSER_PACKAGES.length === 0) {
console.warn('! Skipping bundled browser runtime because HERMES_BROWSER_PACKAGES is empty')
return
}
const npm = npmCommand()
if (!npm) {
console.error('npm not found; bundled browser runtime requires Node.js/npm')
process.exit(1)
}
console.log(`→ Installing browser runtime via npm prefix ${NODE_PREFIX}`)
runInvocation(npm, [
'install',
'-g',
'--prefix',
NODE_PREFIX,
'--silent',
'--ignore-scripts',
...BROWSER_PACKAGES,
])
const ab = agentBrowserCommand()
if (!existsSync(ab)) {
console.error(`agent-browser binary not found at ${ab} after npm install`)
process.exit(1)
}
console.log(`→ Installing Chromium for bundled agent-browser at ${AGENT_BROWSER_HOME}`)
runInvocation(commandInvocation(ab), ['install'], { env: browserRuntimeEnv() })
const browserExecutable = ensureBundledBrowserExecutable()
if (!browserExecutable) {
console.error(`Bundled Chrome executable not found under ${AGENT_BROWSER_HOME} after agent-browser install`)
process.exit(1)
}
console.log(`✓ bundled Chrome executable available at ${browserExecutable}`)
}
installPythonPackages([HERMES_PACKAGE], 'hermes-agent')
installPythonPackages(EXTRA_PYTHON_PACKAGES, 'extra Python runtime packages')
removeBrokenDashboardAuthPlugin()
installBrowserRuntime()
run(pyBin, [
'-c',
[
'import importlib.util',
'import mcp',
'import tools.mcp_tool as t',
'assert t._MCP_AVAILABLE',
'assert importlib.util.find_spec("websockets") is not None',
].join('; '),
])
const hermesBin = TARGET_OS === 'win32'
? resolve(PY_DIR, 'Scripts', 'hermes.exe')
@@ -76,14 +373,11 @@ if (!existsSync(hermesBin)) {
// it at the venv root with a *relative* symlink so the venv stays portable when copied
// into the packaged .app/.exe (an absolute symlink would break the moment the bundle
// is moved to /Applications/...).
const { readdirSync, symlinkSync, copyFileSync, unlinkSync, lstatSync } = await import('node:fs')
function siteRunAgentRelative() {
if (TARGET_OS === 'win32') {
return ['Lib', 'site-packages', 'run_agent.py'].join('\\')
}
const libDir = resolve(PY_DIR, 'lib')
const py = readdirSync(libDir).find(n => /^python\d+\.\d+$/.test(n))
return ['lib', py, 'site-packages', 'run_agent.py'].join('/')
return `${sitePackagesDir().slice(PY_DIR.length + 1)}/run_agent.py`
}
{
const relSrc = siteRunAgentRelative()
@@ -102,7 +396,6 @@ function siteRunAgentRelative() {
// Relocate: replace the pip-generated launcher (which embeds an absolute
// shebang to the build-time Python path) with a relative wrapper so the
// bundled venv works after being moved into the .app/.exe payload.
const { writeFileSync, chmodSync } = await import('node:fs')
if (TARGET_OS === 'win32') {
// Windows: pip generates a .exe launcher that embeds a relative shebang
// already. Add a .cmd wrapper that prefers the colocated python.exe.
@@ -139,8 +432,19 @@ if (TARGET_OS === 'win32') {
console.log(`✓ hermes installed at ${hermesBin} (relocatable launcher)`)
r = spawnSync(hermesCheckCommand, hermesCheckArgs, { stdio: 'inherit' })
if (r.status !== 0) {
console.error('hermes --version failed')
process.exit(r.status ?? 1)
run(hermesCheckCommand, hermesCheckArgs)
if (!SKIP_BROWSER_RUNTIME) {
run(pyBin, [
'-c',
[
'import os, shutil',
`os.environ["PLAYWRIGHT_BROWSERS_PATH"] = ${JSON.stringify(PLAYWRIGHT_BROWSERS_PATH)}`,
'from tools.browser_tool import _chromium_installed',
'assert shutil.which("agent-browser") is not None',
'assert _chromium_installed()',
].join('; '),
], { env: browserRuntimeEnv() })
}
console.log('✓ hermes Python, MCP, websockets, agent-browser, and Chromium checks passed')
+52 -4
View File
@@ -1,5 +1,5 @@
import { app } from 'electron'
import { existsSync } from 'node:fs'
import { existsSync, readdirSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { homedir, platform, arch } from 'node:os'
@@ -31,6 +31,43 @@ export function pythonDir(): string {
return resolve(app.getAppPath(), 'resources', 'python', `${osLabel}-${archLabel}`)
}
export function bundledAgentBrowserHome(): string {
return join(pythonDir(), 'agent-browser')
}
function browserExecutableNames(): Set<string> {
if (isWin) return new Set(['chrome.exe'])
if (platform() === 'darwin') return new Set(['Google Chrome for Testing', 'Google Chrome', 'Chromium', 'chrome'])
return new Set(['chrome', 'chromium', 'chromium-browser'])
}
export function bundledBrowserExecutable(): string | undefined {
const names = browserExecutableNames()
const stack = [join(bundledAgentBrowserHome(), 'browsers'), bundledAgentBrowserHome()].filter(existsSync)
const visited = new Set<string>()
while (stack.length > 0) {
const dir = stack.pop()
if (!dir || visited.has(dir)) continue
visited.add(dir)
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const path = join(dir, entry.name)
if (entry.isFile() && names.has(entry.name)) return path
if (entry.isDirectory()) stack.push(path)
}
}
return undefined
}
export function pythonBinDir(): string {
const dir = pythonDir()
return isWin ? join(dir, 'Scripts') : join(dir, 'bin')
@@ -72,12 +109,23 @@ export function hermesHome(): string {
const override = process.env.HERMES_HOME?.trim()
if (override) return resolve(override)
const defaultHome = resolve(homedir(), '.hermes')
if (isWin) {
const localAppData = process.env.LOCALAPPDATA?.trim() || process.env.APPDATA?.trim()
if (localAppData) return resolve(localAppData, 'hermes')
const candidates = [
process.env.LOCALAPPDATA,
process.env.APPDATA,
]
.map(value => value?.trim())
.filter((value): value is string => !!value)
.map(value => resolve(value, 'hermes'))
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate
}
}
return resolve(homedir(), '.hermes')
return defaultHome
}
export function tokenFile(): string {
+90 -4
View File
@@ -6,10 +6,21 @@ import { dirname, delimiter, join } from 'node:path'
import { randomBytes } from 'node:crypto'
import { promisify } from 'node:util'
import { app } from 'electron'
import { webuiServerEntry, webuiDir, hermesBin, webUiHome, hermesHome, tokenFile, pythonDir } from './paths'
import {
bundledBrowserExecutable,
webuiServerEntry,
webuiDir,
hermesBin,
webUiHome,
hermesHome,
tokenFile,
pythonDir,
} from './paths'
const DEFAULT_PORT = 8748
const DEFAULT_READY_TIMEOUT_MS = 30_000
const AGENT_BRIDGE_STARTED_MARKER = '[bootstrap] agent bridge started'
const AGENT_BRIDGE_FAILED_MARKER = '[bootstrap] agent bridge failed to start'
const execFileAsync = promisify(execFile)
let serverProc: ChildProcess | null = null
@@ -47,6 +58,60 @@ function readyTimeoutMs(): number {
return envPositiveInt('HERMES_DESKTOP_READY_TIMEOUT_MS') || DEFAULT_READY_TIMEOUT_MS
}
function createAgentBridgeStartupTracker(): {
observe: (chunk: Buffer) => void
wait: (timeoutMs: number) => Promise<void>
} {
let output = ''
let state: 'pending' | 'started' | 'failed' = 'pending'
let resolveReady: (() => void) | null = null
let rejectReady: ((err: Error) => void) | null = null
const settle = (nextState: 'started' | 'failed') => {
if (state !== 'pending') return
state = nextState
if (nextState === 'started') {
resolveReady?.()
} else {
rejectReady?.(new Error('Agent bridge failed to start'))
}
}
const observe = (chunk: Buffer) => {
if (state !== 'pending') return
output = (output + chunk.toString('utf-8')).slice(-4096)
if (output.includes(AGENT_BRIDGE_STARTED_MARKER)) {
settle('started')
} else if (output.includes(AGENT_BRIDGE_FAILED_MARKER)) {
settle('failed')
}
}
const wait = (timeoutMs: number) => {
if (state === 'started') return Promise.resolve()
if (state === 'failed') return Promise.reject(new Error('Agent bridge failed to start'))
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
if (state !== 'pending') return
state = 'failed'
reject(new Error(`Agent bridge did not become ready within ${timeoutMs}ms`))
}, timeoutMs)
resolveReady = () => {
clearTimeout(timer)
resolve()
}
rejectReady = (err) => {
clearTimeout(timer)
reject(err)
}
})
}
return { observe, wait }
}
function ensureToken(): string {
if (cachedToken) return cachedToken
const file = tokenFile()
@@ -231,17 +296,22 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
const bundledPython = isWin
? join(pythonDir(), 'python.exe')
: join(pythonDir(), 'bin', 'python3')
const bundledNodeBin = isWin
? join(pythonDir(), 'node')
: join(pythonDir(), 'node', 'bin')
const bridgePort = await getFreeTcpPort()
const workerPortBase = await getFreeTcpPortInRange(20000, 59000)
const loginShellPath = await getLoginShellPath()
const nvmNodeBinPaths = getNvmNodeBinPaths()
const runtimePath = mergePathEntries(
dirname(hermesBin()),
bundledNodeBin,
loginShellPath,
nvmNodeBinPaths,
process.env.PATH,
COMMON_USER_BIN_DIRS.join(delimiter),
)
const browserExecutable = process.env.AGENT_BROWSER_EXECUTABLE_PATH?.trim() || bundledBrowserExecutable()
// Run via Electron's "run as Node" mode — Electron binary doubles as Node.
const env: NodeJS.ProcessEnv = {
@@ -256,11 +326,18 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
HERMES_AGENT_BRIDGE_PYTHON: bundledPython,
HERMES_AGENT_CLI_PYTHON: bundledPython,
HERMES_AGENT_ROOT: pythonDir(),
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'),
// 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
// identically and avoids the issue cross-platform.
HERMES_AGENT_BRIDGE_ENDPOINT: `tcp://127.0.0.1:${bridgePort}`,
// Desktop opens the UI as soon as the Web UI HTTP server is ready, while
// the Python bridge starts in the background. Let the first chat/context
// request wait for broker readiness instead of failing during cold start.
HERMES_AGENT_BRIDGE_CONNECT_RETRY_MS: process.env.HERMES_AGENT_BRIDGE_CONNECT_RETRY_MS ?? '120000',
// Force TCP for worker endpoints too (upstream #1106). Same EDR/sandbox
// reason as above — default ipc:// unix sockets in /tmp get killed.
HERMES_AGENT_BRIDGE_WORKER_TRANSPORT: 'tcp',
@@ -278,8 +355,9 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
// HERMES_HOME/.env or by configuring per-platform allowlists.
GATEWAY_ALLOW_ALL_USERS: process.env.GATEWAY_ALLOW_ALL_USERS ?? 'true',
// Keep the bundled Hermes Agent, bridge, gateway, and Web UI path helpers
// on the same data directory. Native Windows uses %LOCALAPPDATA%\hermes;
// macOS/Linux keep the standard ~/.hermes layout.
// on the same data directory. Native Windows uses an existing
// %LOCALAPPDATA%\hermes or %APPDATA%\hermes; otherwise all platforms keep
// the standard ~/.hermes layout.
HERMES_HOME: agentHome,
HERMES_WEB_UI_HOME: home,
HERMES_WEBUI_STATE_DIR: home,
@@ -295,10 +373,14 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
windowsHide: true,
})
const bridgeStartup = createAgentBridgeStartupTracker()
serverProc.stdout?.on('data', (chunk: Buffer) => {
bridgeStartup.observe(chunk)
process.stdout.write(`[webui] ${chunk}`)
})
serverProc.stderr?.on('data', (chunk: Buffer) => {
bridgeStartup.observe(chunk)
process.stderr.write(`[webui] ${chunk}`)
})
serverProc.on('exit', (code, signal) => {
@@ -309,7 +391,11 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
}
})
await waitForReady(port, readyTimeoutMs())
const timeoutMs = readyTimeoutMs()
await Promise.all([
waitForReady(port, timeoutMs),
bridgeStartup.wait(timeoutMs),
])
return getServerUrl(port)
}
@@ -23,6 +23,7 @@ function appendPinoContext(message: string, obj: any): string {
parts.push(`profile=${obj.profile}`)
}
if (obj.request?.action) parts.push(`action=${obj.request.action}`)
if (obj.err?.message) parts.push(`error=${obj.err.message}`)
if (obj.sessionId) parts.push(`session=${obj.sessionId}`)
if (obj.runId) parts.push(`run=${obj.runId}`)
if (obj.status) parts.push(`status=${obj.status}`)
@@ -2,11 +2,12 @@
* Hermes 路径检测工具 - 跨平台兼容
*
* Hermes 数据目录在不同平台上的位置:
* - Windows 原生安装: %LOCALAPPDATA%\hermes
* - Windows 原生安装: %LOCALAPPDATA%\hermes when it exists
* - Linux/macOS/WSL2: ~/.hermes
* - 用户自定义: HERMES_HOME 环境变量
*/
import { existsSync } from 'fs'
import { basename, dirname, isAbsolute, relative, resolve, join } from 'path'
import { homedir } from 'os'
@@ -15,7 +16,7 @@ import { homedir } from 'os'
*
* 检测优先级:
* 1. HERMES_HOME 环境变量(用户自定义)
* 2. Windows: %LOCALAPPDATA%\hermes(原生安装)
* 2. Windows: existing %LOCALAPPDATA%\hermes or %APPDATA%\hermes
* 3. 默认: ~/.hermesLinux/macOS/WSL2
*
* @returns Hermes 数据目录的绝对路径
@@ -26,16 +27,25 @@ export function detectHermesHome(): string {
return resolve(process.env.HERMES_HOME)
}
// 2. Windows:直接使用 %LOCALAPPDATA%\hermes
const defaultHome = resolve(homedir(), '.hermes')
// 2. Windows:优先使用存在的原生安装数据目录;不存在时回退到 ~/.hermes。
if (process.platform === 'win32') {
const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA
if (localAppData) {
return join(localAppData, 'hermes')
const candidates = [
process.env.LOCALAPPDATA,
process.env.APPDATA,
]
.map(value => value?.trim())
.filter((value): value is string => !!value)
.map(value => resolve(value, 'hermes'))
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate
}
}
// 3. Linux/macOS~/.hermes
return resolve(homedir(), '.hermes')
return defaultHome
}
/**
+26
View File
@@ -118,6 +118,8 @@ if (!buildWorkflow.includes('npm run harness:check')) {
const desktopReleaseWorkflow = await readText('.github/workflows/desktop-release.yml')
const electronBuilderConfig = await readText('packages/desktop/electron-builder.yml')
const desktopInstallHermes = await readText('packages/desktop/scripts/install-hermes.mjs')
const desktopWebuiServer = await readText('packages/desktop/src/main/webui-server.ts')
if (!desktopReleaseWorkflow.includes('files: ${{ matrix.artifact_files }}')) {
fail('desktop-release.yml must upload matrix-specific artifact_files')
}
@@ -142,6 +144,30 @@ if (!desktopReleaseWorkflow.includes('fail_on_unmatched_files: true')) {
fail('desktop-release.yml must keep fail_on_unmatched_files: true')
}
for (const phrase of [
'websockets',
'agent-browser@^0.26.0',
'AGENT_BROWSER_HOME',
'AGENT_BROWSER_EXECUTABLE_PATH',
'PLAYWRIGHT_BROWSERS_PATH',
'ms-playwright',
'removeBrokenDashboardAuthPlugin',
]) {
if (!desktopInstallHermes.includes(phrase)) {
fail(`install-hermes.mjs must bundle Hermes browser runtime support: ${phrase}`)
}
}
for (const phrase of [
'bundledNodeBin',
'PLAYWRIGHT_BROWSERS_PATH',
'ms-playwright',
]) {
if (!desktopWebuiServer.includes(phrase)) {
fail(`desktop webui server must expose bundled browser runtime: ${phrase}`)
}
}
if (failures.length > 0) {
console.error('Harness check failed:')
for (const failure of failures) {
@@ -122,6 +122,15 @@ describe('agent bridge manager command resolution', () => {
expect(DEFAULT_AGENT_BRIDGE_ENDPOINT).not.toBe('ipc:///tmp/hermes-agent-bridge.sock')
})
it('honors the bridge connect retry environment override', async () => {
process.env.HERMES_AGENT_BRIDGE_CONNECT_RETRY_MS = '120000'
const { AgentBridgeClient } = await import('../../packages/server/src/services/hermes/agent-bridge/client')
const client = new AgentBridgeClient({ endpoint: 'tcp://127.0.0.1:1' })
expect(client.connectRetryMs).toBe(120000)
})
it('waits briefly for a restarting bridge socket before failing', async () => {
const endpoint = process.platform === 'win32'
? `tcp://127.0.0.1:${32000 + (process.pid % 10000)}`
+61
View File
@@ -0,0 +1,61 @@
import { mkdirSync, mkdtempSync, rmSync } from 'fs'
import { homedir, tmpdir } from 'os'
import { join, resolve } from 'path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { detectHermesHome } from '../../packages/server/src/services/hermes/hermes-path'
describe('Hermes path detection', () => {
const originalEnv = { ...process.env }
const originalPlatform = process.platform
let tempDir = ''
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'hermes-path-'))
process.env = { ...originalEnv }
delete process.env.HERMES_HOME
delete process.env.LOCALAPPDATA
delete process.env.APPDATA
})
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
process.env = { ...originalEnv }
if (tempDir) rmSync(tempDir, { recursive: true, force: true })
tempDir = ''
})
it('keeps explicit HERMES_HOME even when the path does not exist', () => {
process.env.HERMES_HOME = join(tempDir, 'custom-home')
expect(detectHermesHome()).toBe(resolve(tempDir, 'custom-home'))
})
it('falls back to ~/.hermes on Windows when LOCALAPPDATA hermes is missing', () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
process.env.LOCALAPPDATA = join(tempDir, 'Local')
expect(detectHermesHome()).toBe(resolve(homedir(), '.hermes'))
})
it('uses existing Windows LOCALAPPDATA hermes before APPDATA', () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
const localHermes = join(tempDir, 'Local', 'hermes')
const roamingHermes = join(tempDir, 'Roaming', 'hermes')
mkdirSync(localHermes, { recursive: true })
mkdirSync(roamingHermes, { recursive: true })
process.env.LOCALAPPDATA = join(tempDir, 'Local')
process.env.APPDATA = join(tempDir, 'Roaming')
expect(detectHermesHome()).toBe(resolve(localHermes))
})
it('falls back to existing Windows APPDATA hermes when LOCALAPPDATA hermes is missing', () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
const roamingHermes = join(tempDir, 'Roaming', 'hermes')
mkdirSync(roamingHermes, { recursive: true })
process.env.LOCALAPPDATA = join(tempDir, 'Local')
process.env.APPDATA = join(tempDir, 'Roaming')
expect(detectHermesHome()).toBe(resolve(roamingHermes))
})
})