[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:
@@ -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}
|
||||
|
||||
@@ -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,34 +77,286 @@ function hasUv() {
|
||||
return r.status === 0
|
||||
}
|
||||
|
||||
let r
|
||||
if (hasUv()) {
|
||||
console.log(`→ Installing ${HERMES_PACKAGE} via uv`)
|
||||
r = spawnSync('uv', [
|
||||
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 ${label} via uv: ${packages.join(' ')}`)
|
||||
run('uv', [
|
||||
'pip', 'install',
|
||||
'--python', pyBin,
|
||||
HERMES_PACKAGE,
|
||||
], { stdio: 'inherit' })
|
||||
} else {
|
||||
console.log(`→ Installing ${HERMES_PACKAGE} via pip`)
|
||||
r = spawnSync(pyBin, [
|
||||
...packages,
|
||||
])
|
||||
} else {
|
||||
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')
|
||||
: resolve(PY_DIR, 'bin', 'hermes')
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. 默认: ~/.hermes(Linux/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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)}`
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user