[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 dtPath = join(sitePkgs, 'gateway', 'platforms', 'dingtalk.py')
|
||||||
|
const browserToolPath = join(sitePkgs, 'tools', 'browser_tool.py')
|
||||||
const sitecustomizePath = join(sitePkgs, 'sitecustomize.py')
|
const sitecustomizePath = join(sitePkgs, 'sitecustomize.py')
|
||||||
if (!existsSync(dtPath)) {
|
if (!existsSync(dtPath)) {
|
||||||
console.error(`dingtalk.py not found at ${dtPath} — is hermes-agent installed?`)
|
console.error(`dingtalk.py not found at ${dtPath} — is hermes-agent installed?`)
|
||||||
@@ -59,6 +60,21 @@ function patch(id, marker, find, replace) {
|
|||||||
applied++
|
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}`)
|
console.log(`Patching ${dtPath}`)
|
||||||
|
|
||||||
// NOTE: the former `dt-pre-start` patch was retired — hermes-agent now ships
|
// NOTE: the former `dt-pre-start` patch was retired — hermes-agent now ships
|
||||||
@@ -179,6 +195,63 @@ if (src !== before) {
|
|||||||
writeFileSync(dtPath, src)
|
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 brotlicffiCompatMarker = '# patch:brotlicffi-error-compat'
|
||||||
const brotlicffiCompat = `
|
const brotlicffiCompat = `
|
||||||
${brotlicffiCompatMarker}
|
${brotlicffiCompatMarker}
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Install hermes-agent into the bundled Python at resources/python/<os>-<arch>/.
|
// Install hermes-agent into the bundled Python at resources/python/<os>-<arch>/.
|
||||||
// Prefers `uv` (10-100x faster, more deterministic) and falls back to pip.
|
// Prefers `uv` (10-100x faster, more deterministic) and falls back to pip.
|
||||||
import { existsSync } from 'node:fs'
|
import {
|
||||||
import { resolve, dirname } from 'node:path'
|
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 { fileURLToPath } from 'node:url'
|
||||||
import { spawnSync } from 'node:child_process'
|
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 __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const ROOT = resolve(__dirname, '..')
|
const ROOT = resolve(__dirname, '..')
|
||||||
@@ -13,10 +25,43 @@ const ROOT = resolve(__dirname, '..')
|
|||||||
const TARGET_OS = process.env.TARGET_OS || osPlatform()
|
const TARGET_OS = process.env.TARGET_OS || osPlatform()
|
||||||
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
|
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
|
||||||
const HERMES_VERSION = process.env.HERMES_VERSION || '0.15.2'
|
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 OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
||||||
const PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
|
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'
|
const pyBin = TARGET_OS === 'win32'
|
||||||
? resolve(PY_DIR, 'python.exe')
|
? resolve(PY_DIR, 'python.exe')
|
||||||
@@ -32,34 +77,286 @@ function hasUv() {
|
|||||||
return r.status === 0
|
return r.status === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
let r
|
function splitPackageList(value) {
|
||||||
if (hasUv()) {
|
return value
|
||||||
console.log(`→ Installing ${HERMES_PACKAGE} via uv`)
|
.split(/[,\s]+/)
|
||||||
r = spawnSync('uv', [
|
.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',
|
'pip', 'install',
|
||||||
'--python', pyBin,
|
'--python', pyBin,
|
||||||
HERMES_PACKAGE,
|
...packages,
|
||||||
], { stdio: 'inherit' })
|
])
|
||||||
} else {
|
} else {
|
||||||
console.log(`→ Installing ${HERMES_PACKAGE} via pip`)
|
console.log(`→ Installing ${label} via pip: ${packages.join(' ')}`)
|
||||||
r = spawnSync(pyBin, [
|
run(pyBin, [
|
||||||
'-m', 'pip', 'install',
|
'-m', 'pip', 'install',
|
||||||
HERMES_PACKAGE,
|
...packages,
|
||||||
'--no-warn-script-location',
|
'--no-warn-script-location',
|
||||||
'--disable-pip-version-check',
|
'--disable-pip-version-check',
|
||||||
], { stdio: 'inherit' })
|
])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (r.status !== 0) process.exit(r.status ?? 1)
|
|
||||||
|
|
||||||
r = spawnSync(pyBin, [
|
function npmCommand() {
|
||||||
'-c',
|
const candidates = TARGET_OS === 'win32'
|
||||||
'import mcp; import tools.mcp_tool as t; assert t._MCP_AVAILABLE',
|
? ['npm.cmd', 'npm.exe', 'npm']
|
||||||
], { stdio: 'inherit' })
|
: ['npm']
|
||||||
if (r.status !== 0) {
|
for (const candidate of candidates) {
|
||||||
console.error('MCP Python SDK sanity check failed')
|
const invocation = commandInvocation(candidate)
|
||||||
process.exit(r.status ?? 1)
|
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'
|
const hermesBin = TARGET_OS === 'win32'
|
||||||
? resolve(PY_DIR, 'Scripts', 'hermes.exe')
|
? resolve(PY_DIR, 'Scripts', 'hermes.exe')
|
||||||
: resolve(PY_DIR, 'bin', 'hermes')
|
: 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
|
// 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
|
// into the packaged .app/.exe (an absolute symlink would break the moment the bundle
|
||||||
// is moved to /Applications/...).
|
// is moved to /Applications/...).
|
||||||
const { readdirSync, symlinkSync, copyFileSync, unlinkSync, lstatSync } = await import('node:fs')
|
|
||||||
function siteRunAgentRelative() {
|
function siteRunAgentRelative() {
|
||||||
if (TARGET_OS === 'win32') {
|
if (TARGET_OS === 'win32') {
|
||||||
return ['Lib', 'site-packages', 'run_agent.py'].join('\\')
|
return ['Lib', 'site-packages', 'run_agent.py'].join('\\')
|
||||||
}
|
}
|
||||||
const libDir = resolve(PY_DIR, 'lib')
|
return `${sitePackagesDir().slice(PY_DIR.length + 1)}/run_agent.py`
|
||||||
const py = readdirSync(libDir).find(n => /^python\d+\.\d+$/.test(n))
|
|
||||||
return ['lib', py, 'site-packages', 'run_agent.py'].join('/')
|
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
const relSrc = siteRunAgentRelative()
|
const relSrc = siteRunAgentRelative()
|
||||||
@@ -102,7 +396,6 @@ function siteRunAgentRelative() {
|
|||||||
// Relocate: replace the pip-generated launcher (which embeds an absolute
|
// Relocate: replace the pip-generated launcher (which embeds an absolute
|
||||||
// shebang to the build-time Python path) with a relative wrapper so the
|
// shebang to the build-time Python path) with a relative wrapper so the
|
||||||
// bundled venv works after being moved into the .app/.exe payload.
|
// bundled venv works after being moved into the .app/.exe payload.
|
||||||
const { writeFileSync, chmodSync } = await import('node:fs')
|
|
||||||
if (TARGET_OS === 'win32') {
|
if (TARGET_OS === 'win32') {
|
||||||
// Windows: pip generates a .exe launcher that embeds a relative shebang
|
// Windows: pip generates a .exe launcher that embeds a relative shebang
|
||||||
// already. Add a .cmd wrapper that prefers the colocated python.exe.
|
// 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)`)
|
console.log(`✓ hermes installed at ${hermesBin} (relocatable launcher)`)
|
||||||
|
|
||||||
r = spawnSync(hermesCheckCommand, hermesCheckArgs, { stdio: 'inherit' })
|
run(hermesCheckCommand, hermesCheckArgs)
|
||||||
if (r.status !== 0) {
|
|
||||||
console.error('hermes --version failed')
|
if (!SKIP_BROWSER_RUNTIME) {
|
||||||
process.exit(r.status ?? 1)
|
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 { app } from 'electron'
|
||||||
import { existsSync } from 'node:fs'
|
import { existsSync, readdirSync } from 'node:fs'
|
||||||
import { join, resolve } from 'node:path'
|
import { join, resolve } from 'node:path'
|
||||||
import { homedir, platform, arch } from 'node:os'
|
import { homedir, platform, arch } from 'node:os'
|
||||||
|
|
||||||
@@ -31,6 +31,43 @@ export function pythonDir(): string {
|
|||||||
return resolve(app.getAppPath(), 'resources', 'python', `${osLabel}-${archLabel}`)
|
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 {
|
export function pythonBinDir(): string {
|
||||||
const dir = pythonDir()
|
const dir = pythonDir()
|
||||||
return isWin ? join(dir, 'Scripts') : join(dir, 'bin')
|
return isWin ? join(dir, 'Scripts') : join(dir, 'bin')
|
||||||
@@ -72,12 +109,23 @@ export function hermesHome(): string {
|
|||||||
const override = process.env.HERMES_HOME?.trim()
|
const override = process.env.HERMES_HOME?.trim()
|
||||||
if (override) return resolve(override)
|
if (override) return resolve(override)
|
||||||
|
|
||||||
|
const defaultHome = resolve(homedir(), '.hermes')
|
||||||
|
|
||||||
if (isWin) {
|
if (isWin) {
|
||||||
const localAppData = process.env.LOCALAPPDATA?.trim() || process.env.APPDATA?.trim()
|
const candidates = [
|
||||||
if (localAppData) return resolve(localAppData, 'hermes')
|
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 {
|
export function tokenFile(): string {
|
||||||
|
|||||||
@@ -6,10 +6,21 @@ import { dirname, delimiter, join } from 'node:path'
|
|||||||
import { randomBytes } from 'node:crypto'
|
import { randomBytes } from 'node:crypto'
|
||||||
import { promisify } from 'node:util'
|
import { promisify } from 'node:util'
|
||||||
import { app } from 'electron'
|
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_PORT = 8748
|
||||||
const DEFAULT_READY_TIMEOUT_MS = 30_000
|
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)
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
let serverProc: ChildProcess | null = null
|
let serverProc: ChildProcess | null = null
|
||||||
@@ -47,6 +58,60 @@ function readyTimeoutMs(): number {
|
|||||||
return envPositiveInt('HERMES_DESKTOP_READY_TIMEOUT_MS') || DEFAULT_READY_TIMEOUT_MS
|
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 {
|
function ensureToken(): string {
|
||||||
if (cachedToken) return cachedToken
|
if (cachedToken) return cachedToken
|
||||||
const file = tokenFile()
|
const file = tokenFile()
|
||||||
@@ -231,17 +296,22 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
|
|||||||
const bundledPython = isWin
|
const bundledPython = isWin
|
||||||
? join(pythonDir(), 'python.exe')
|
? join(pythonDir(), 'python.exe')
|
||||||
: join(pythonDir(), 'bin', 'python3')
|
: join(pythonDir(), 'bin', 'python3')
|
||||||
|
const bundledNodeBin = isWin
|
||||||
|
? join(pythonDir(), 'node')
|
||||||
|
: join(pythonDir(), 'node', 'bin')
|
||||||
const bridgePort = await getFreeTcpPort()
|
const bridgePort = await getFreeTcpPort()
|
||||||
const workerPortBase = await getFreeTcpPortInRange(20000, 59000)
|
const workerPortBase = await getFreeTcpPortInRange(20000, 59000)
|
||||||
const loginShellPath = await getLoginShellPath()
|
const loginShellPath = await getLoginShellPath()
|
||||||
const nvmNodeBinPaths = getNvmNodeBinPaths()
|
const nvmNodeBinPaths = getNvmNodeBinPaths()
|
||||||
const runtimePath = mergePathEntries(
|
const runtimePath = mergePathEntries(
|
||||||
dirname(hermesBin()),
|
dirname(hermesBin()),
|
||||||
|
bundledNodeBin,
|
||||||
loginShellPath,
|
loginShellPath,
|
||||||
nvmNodeBinPaths,
|
nvmNodeBinPaths,
|
||||||
process.env.PATH,
|
process.env.PATH,
|
||||||
COMMON_USER_BIN_DIRS.join(delimiter),
|
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.
|
// Run via Electron's "run as Node" mode — Electron binary doubles as Node.
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
@@ -256,11 +326,18 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
|
|||||||
HERMES_AGENT_BRIDGE_PYTHON: bundledPython,
|
HERMES_AGENT_BRIDGE_PYTHON: bundledPython,
|
||||||
HERMES_AGENT_CLI_PYTHON: bundledPython,
|
HERMES_AGENT_CLI_PYTHON: bundledPython,
|
||||||
HERMES_AGENT_ROOT: pythonDir(),
|
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/...`
|
// Force TCP loopback for the agent bridge. The default `ipc:///tmp/...`
|
||||||
// unix socket is rejected on macOS in some EDR/sandbox setups (silent
|
// 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
|
// SIGKILL of the bridge child within ~150ms). TCP on 127.0.0.1 works
|
||||||
// identically and avoids the issue cross-platform.
|
// identically and avoids the issue cross-platform.
|
||||||
HERMES_AGENT_BRIDGE_ENDPOINT: `tcp://127.0.0.1:${bridgePort}`,
|
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
|
// Force TCP for worker endpoints too (upstream #1106). Same EDR/sandbox
|
||||||
// reason as above — default ipc:// unix sockets in /tmp get killed.
|
// reason as above — default ipc:// unix sockets in /tmp get killed.
|
||||||
HERMES_AGENT_BRIDGE_WORKER_TRANSPORT: 'tcp',
|
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.
|
// HERMES_HOME/.env or by configuring per-platform allowlists.
|
||||||
GATEWAY_ALLOW_ALL_USERS: process.env.GATEWAY_ALLOW_ALL_USERS ?? 'true',
|
GATEWAY_ALLOW_ALL_USERS: process.env.GATEWAY_ALLOW_ALL_USERS ?? 'true',
|
||||||
// Keep the bundled Hermes Agent, bridge, gateway, and Web UI path helpers
|
// Keep the bundled Hermes Agent, bridge, gateway, and Web UI path helpers
|
||||||
// on the same data directory. Native Windows uses %LOCALAPPDATA%\hermes;
|
// on the same data directory. Native Windows uses an existing
|
||||||
// macOS/Linux keep the standard ~/.hermes layout.
|
// %LOCALAPPDATA%\hermes or %APPDATA%\hermes; otherwise all platforms keep
|
||||||
|
// the standard ~/.hermes layout.
|
||||||
HERMES_HOME: agentHome,
|
HERMES_HOME: agentHome,
|
||||||
HERMES_WEB_UI_HOME: home,
|
HERMES_WEB_UI_HOME: home,
|
||||||
HERMES_WEBUI_STATE_DIR: home,
|
HERMES_WEBUI_STATE_DIR: home,
|
||||||
@@ -295,10 +373,14 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
|
|||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const bridgeStartup = createAgentBridgeStartupTracker()
|
||||||
|
|
||||||
serverProc.stdout?.on('data', (chunk: Buffer) => {
|
serverProc.stdout?.on('data', (chunk: Buffer) => {
|
||||||
|
bridgeStartup.observe(chunk)
|
||||||
process.stdout.write(`[webui] ${chunk}`)
|
process.stdout.write(`[webui] ${chunk}`)
|
||||||
})
|
})
|
||||||
serverProc.stderr?.on('data', (chunk: Buffer) => {
|
serverProc.stderr?.on('data', (chunk: Buffer) => {
|
||||||
|
bridgeStartup.observe(chunk)
|
||||||
process.stderr.write(`[webui] ${chunk}`)
|
process.stderr.write(`[webui] ${chunk}`)
|
||||||
})
|
})
|
||||||
serverProc.on('exit', (code, signal) => {
|
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)
|
return getServerUrl(port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ function appendPinoContext(message: string, obj: any): string {
|
|||||||
parts.push(`profile=${obj.profile}`)
|
parts.push(`profile=${obj.profile}`)
|
||||||
}
|
}
|
||||||
if (obj.request?.action) parts.push(`action=${obj.request.action}`)
|
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.sessionId) parts.push(`session=${obj.sessionId}`)
|
||||||
if (obj.runId) parts.push(`run=${obj.runId}`)
|
if (obj.runId) parts.push(`run=${obj.runId}`)
|
||||||
if (obj.status) parts.push(`status=${obj.status}`)
|
if (obj.status) parts.push(`status=${obj.status}`)
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
* Hermes 路径检测工具 - 跨平台兼容
|
* Hermes 路径检测工具 - 跨平台兼容
|
||||||
*
|
*
|
||||||
* Hermes 数据目录在不同平台上的位置:
|
* Hermes 数据目录在不同平台上的位置:
|
||||||
* - Windows 原生安装: %LOCALAPPDATA%\hermes
|
* - Windows 原生安装: %LOCALAPPDATA%\hermes when it exists
|
||||||
* - Linux/macOS/WSL2: ~/.hermes
|
* - Linux/macOS/WSL2: ~/.hermes
|
||||||
* - 用户自定义: HERMES_HOME 环境变量
|
* - 用户自定义: HERMES_HOME 环境变量
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { existsSync } from 'fs'
|
||||||
import { basename, dirname, isAbsolute, relative, resolve, join } from 'path'
|
import { basename, dirname, isAbsolute, relative, resolve, join } from 'path'
|
||||||
import { homedir } from 'os'
|
import { homedir } from 'os'
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ import { homedir } from 'os'
|
|||||||
*
|
*
|
||||||
* 检测优先级:
|
* 检测优先级:
|
||||||
* 1. HERMES_HOME 环境变量(用户自定义)
|
* 1. HERMES_HOME 环境变量(用户自定义)
|
||||||
* 2. Windows: %LOCALAPPDATA%\hermes(原生安装)
|
* 2. Windows: existing %LOCALAPPDATA%\hermes or %APPDATA%\hermes
|
||||||
* 3. 默认: ~/.hermes(Linux/macOS/WSL2)
|
* 3. 默认: ~/.hermes(Linux/macOS/WSL2)
|
||||||
*
|
*
|
||||||
* @returns Hermes 数据目录的绝对路径
|
* @returns Hermes 数据目录的绝对路径
|
||||||
@@ -26,16 +27,25 @@ export function detectHermesHome(): string {
|
|||||||
return resolve(process.env.HERMES_HOME)
|
return resolve(process.env.HERMES_HOME)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Windows:直接使用 %LOCALAPPDATA%\hermes
|
const defaultHome = resolve(homedir(), '.hermes')
|
||||||
|
|
||||||
|
// 2. Windows:优先使用存在的原生安装数据目录;不存在时回退到 ~/.hermes。
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA
|
const candidates = [
|
||||||
if (localAppData) {
|
process.env.LOCALAPPDATA,
|
||||||
return join(localAppData, 'hermes')
|
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
|
// 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 desktopReleaseWorkflow = await readText('.github/workflows/desktop-release.yml')
|
||||||
const electronBuilderConfig = await readText('packages/desktop/electron-builder.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 }}')) {
|
if (!desktopReleaseWorkflow.includes('files: ${{ matrix.artifact_files }}')) {
|
||||||
fail('desktop-release.yml must upload matrix-specific 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')
|
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) {
|
if (failures.length > 0) {
|
||||||
console.error('Harness check failed:')
|
console.error('Harness check failed:')
|
||||||
for (const failure of failures) {
|
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')
|
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 () => {
|
it('waits briefly for a restarting bridge socket before failing', async () => {
|
||||||
const endpoint = process.platform === 'win32'
|
const endpoint = process.platform === 'win32'
|
||||||
? `tcp://127.0.0.1:${32000 + (process.pid % 10000)}`
|
? `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