From c27a12f56ce85212f7f83f7d34c20c182b08ff8b Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:35:26 +0800 Subject: [PATCH] [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 --- .../desktop/scripts/apply-hermes-patches.mjs | 73 ++++ packages/desktop/scripts/install-hermes.mjs | 374 ++++++++++++++++-- packages/desktop/src/main/paths.ts | 56 ++- packages/desktop/src/main/webui-server.ts | 94 ++++- .../server/src/controllers/hermes/logs.ts | 1 + .../server/src/services/hermes/hermes-path.ts | 24 +- scripts/harness-check.mjs | 26 ++ tests/server/agent-bridge-manager.test.ts | 9 + tests/server/hermes-path.test.ts | 61 +++ 9 files changed, 668 insertions(+), 50 deletions(-) create mode 100644 tests/server/hermes-path.test.ts diff --git a/packages/desktop/scripts/apply-hermes-patches.mjs b/packages/desktop/scripts/apply-hermes-patches.mjs index 4f5aeaf..73aba6f 100644 --- a/packages/desktop/scripts/apply-hermes-patches.mjs +++ b/packages/desktop/scripts/apply-hermes-patches.mjs @@ -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} diff --git a/packages/desktop/scripts/install-hermes.mjs b/packages/desktop/scripts/install-hermes.mjs index c735ad6..4a510ed 100644 --- a/packages/desktop/scripts/install-hermes.mjs +++ b/packages/desktop/scripts/install-hermes.mjs @@ -1,11 +1,23 @@ #!/usr/bin/env node // Install hermes-agent into the bundled Python at resources/python/-/. // 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, [ - '-m', 'pip', 'install', - HERMES_PACKAGE, - '--no-warn-script-location', - '--disable-pip-version-check', - ], { stdio: 'inherit' }) + ...packages, + ]) + } else { + console.log(`→ Installing ${label} via pip: ${packages.join(' ')}`) + run(pyBin, [ + '-m', 'pip', 'install', + ...packages, + '--no-warn-script-location', + '--disable-pip-version-check', + ]) + } } -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') diff --git a/packages/desktop/src/main/paths.ts b/packages/desktop/src/main/paths.ts index 02cd9ff..376a41d 100644 --- a/packages/desktop/src/main/paths.ts +++ b/packages/desktop/src/main/paths.ts @@ -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 { + 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() + + 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 { diff --git a/packages/desktop/src/main/webui-server.ts b/packages/desktop/src/main/webui-server.ts index 67c7a88..6e6292b 100644 --- a/packages/desktop/src/main/webui-server.ts +++ b/packages/desktop/src/main/webui-server.ts @@ -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 +} { + 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((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 { 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 { 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 { // 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 { 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 { } }) - await waitForReady(port, readyTimeoutMs()) + const timeoutMs = readyTimeoutMs() + await Promise.all([ + waitForReady(port, timeoutMs), + bridgeStartup.wait(timeoutMs), + ]) return getServerUrl(port) } diff --git a/packages/server/src/controllers/hermes/logs.ts b/packages/server/src/controllers/hermes/logs.ts index ac709b8..05bd3a2 100644 --- a/packages/server/src/controllers/hermes/logs.ts +++ b/packages/server/src/controllers/hermes/logs.ts @@ -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}`) diff --git a/packages/server/src/services/hermes/hermes-path.ts b/packages/server/src/services/hermes/hermes-path.ts index f44dd15..c18a99e 100644 --- a/packages/server/src/services/hermes/hermes-path.ts +++ b/packages/server/src/services/hermes/hermes-path.ts @@ -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 } /** diff --git a/scripts/harness-check.mjs b/scripts/harness-check.mjs index bd6ab97..7d3a5ab 100644 --- a/scripts/harness-check.mjs +++ b/scripts/harness-check.mjs @@ -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) { diff --git a/tests/server/agent-bridge-manager.test.ts b/tests/server/agent-bridge-manager.test.ts index 06ff55c..cdde43a 100644 --- a/tests/server/agent-bridge-manager.test.ts +++ b/tests/server/agent-bridge-manager.test.ts @@ -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)}` diff --git a/tests/server/hermes-path.test.ts b/tests/server/hermes-path.test.ts new file mode 100644 index 0000000..7d5a4d1 --- /dev/null +++ b/tests/server/hermes-path.test.ts @@ -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)) + }) +})