From 97add739a0903408c8ec28f2999d54b0a39f2846 Mon Sep 17 00:00:00 2001 From: xingzhi Date: Mon, 1 Jun 2026 16:24:38 +0800 Subject: [PATCH] fix windows hermes home fallback --- packages/desktop/scripts/install-hermes.mjs | 2 +- packages/desktop/src/main/paths.ts | 17 ++++- packages/desktop/src/main/webui-server.ts | 75 ++++++++++++++++++- .../server/src/controllers/hermes/logs.ts | 1 + .../server/src/services/hermes/hermes-path.ts | 24 ++++-- tests/server/agent-bridge-manager.test.ts | 9 +++ tests/server/hermes-path.test.ts | 61 +++++++++++++++ 7 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 tests/server/hermes-path.test.ts diff --git a/packages/desktop/scripts/install-hermes.mjs b/packages/desktop/scripts/install-hermes.mjs index c735ad60b..3ce7ea972 100644 --- a/packages/desktop/scripts/install-hermes.mjs +++ b/packages/desktop/scripts/install-hermes.mjs @@ -12,7 +12,7 @@ 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_VERSION = process.env.HERMES_VERSION || '0.15.1' const HERMES_PACKAGE = process.env.HERMES_PACKAGE || `hermes-agent[mcp]==${HERMES_VERSION}` const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS diff --git a/packages/desktop/src/main/paths.ts b/packages/desktop/src/main/paths.ts index 04a2dd592..9f74b6012 100644 --- a/packages/desktop/src/main/paths.ts +++ b/packages/desktop/src/main/paths.ts @@ -63,12 +63,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 67c7a8880..8154a91ec 100644 --- a/packages/desktop/src/main/webui-server.ts +++ b/packages/desktop/src/main/webui-server.ts @@ -10,6 +10,8 @@ import { webuiServerEntry, webuiDir, hermesBin, webUiHome, hermesHome, tokenFile 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 +49,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() @@ -261,6 +317,10 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise { // 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 +338,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 +356,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 +374,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 ac709b884..05bd3a2bc 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 f44dd15a1..c18a99e19 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/tests/server/agent-bridge-manager.test.ts b/tests/server/agent-bridge-manager.test.ts index 06ff55c49..cdde43adb 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 000000000..7d5a4d127 --- /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)) + }) +})