Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/desktop/scripts/install-hermes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 14 additions & 3 deletions packages/desktop/src/main/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
75 changes: 72 additions & 3 deletions packages/desktop/src/main/webui-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<void>
} {
let output = ''
let state: 'pending' | 'started' | 'failed' = 'pending'
let resolveReady: (() => void) | null = null
let rejectReady: ((err: Error) => void) | null = null

const settle = (nextState: 'started' | 'failed') => {
if (state !== 'pending') return
state = nextState
if (nextState === 'started') {
resolveReady?.()
} else {
rejectReady?.(new Error('Agent bridge failed to start'))
}
}

const observe = (chunk: Buffer) => {
if (state !== 'pending') return
output = (output + chunk.toString('utf-8')).slice(-4096)
if (output.includes(AGENT_BRIDGE_STARTED_MARKER)) {
settle('started')
} else if (output.includes(AGENT_BRIDGE_FAILED_MARKER)) {
settle('failed')
}
}

const wait = (timeoutMs: number) => {
if (state === 'started') return Promise.resolve()
if (state === 'failed') return Promise.reject(new Error('Agent bridge failed to start'))

return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
if (state !== 'pending') return
state = 'failed'
reject(new Error(`Agent bridge did not become ready within ${timeoutMs}ms`))
}, timeoutMs)

resolveReady = () => {
clearTimeout(timer)
resolve()
}
rejectReady = (err) => {
clearTimeout(timer)
reject(err)
}
})
}

return { observe, wait }
}

function ensureToken(): string {
if (cachedToken) return cachedToken
const file = tokenFile()
Expand Down Expand Up @@ -261,6 +317,10 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
// 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',
Expand All @@ -278,8 +338,9 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
// HERMES_HOME/.env or by configuring per-platform allowlists.
GATEWAY_ALLOW_ALL_USERS: process.env.GATEWAY_ALLOW_ALL_USERS ?? 'true',
// Keep the bundled Hermes Agent, bridge, gateway, and Web UI path helpers
// on the same data directory. Native Windows uses %LOCALAPPDATA%\hermes;
// macOS/Linux keep the standard ~/.hermes layout.
// on the same data directory. Native Windows uses an existing
// %LOCALAPPDATA%\hermes or %APPDATA%\hermes; otherwise all platforms keep
// the standard ~/.hermes layout.
HERMES_HOME: agentHome,
HERMES_WEB_UI_HOME: home,
HERMES_WEBUI_STATE_DIR: home,
Expand All @@ -295,10 +356,14 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
windowsHide: true,
})

const bridgeStartup = createAgentBridgeStartupTracker()

serverProc.stdout?.on('data', (chunk: Buffer) => {
bridgeStartup.observe(chunk)
process.stdout.write(`[webui] ${chunk}`)
})
serverProc.stderr?.on('data', (chunk: Buffer) => {
bridgeStartup.observe(chunk)
process.stderr.write(`[webui] ${chunk}`)
})
serverProc.on('exit', (code, signal) => {
Expand All @@ -309,7 +374,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)
}

Expand Down
1 change: 1 addition & 0 deletions packages/server/src/controllers/hermes/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand Down
24 changes: 17 additions & 7 deletions packages/server/src/services/hermes/hermes-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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 数据目录的绝对路径
Expand All @@ -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
}

/**
Expand Down
9 changes: 9 additions & 0 deletions tests/server/agent-bridge-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`
Expand Down
61 changes: 61 additions & 0 deletions tests/server/hermes-path.test.ts
Original file line number Diff line number Diff line change
@@ -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))
})
})
Loading