diff --git a/packages/server/src/services/hermes/agent-bridge/manager.ts b/packages/server/src/services/hermes/agent-bridge/manager.ts index 0018124..a43e7cf 100644 --- a/packages/server/src/services/hermes/agent-bridge/manager.ts +++ b/packages/server/src/services/hermes/agent-bridge/manager.ts @@ -15,7 +15,7 @@ export interface AgentBridgeManagerOptions { startupTimeoutMs?: number } -interface BridgeCommand { +export interface BridgeCommand { command: string argsPrefix: string[] agentRoot?: string @@ -150,7 +150,7 @@ function resolveAgentRoot(explicit?: string, hermesHome = detectHermesHome()): s return candidates.find(candidate => existsSync(join(candidate, 'run_agent.py'))) } -function bridgeCommand(options: AgentBridgeManagerOptions): BridgeCommand { +export function resolveAgentBridgeCommand(options: AgentBridgeManagerOptions = {}): BridgeCommand { const hermesHome = options.hermesHome || detectHermesHome() const agentRoot = resolveAgentRoot(options.agentRoot, hermesHome) const explicitPython = options.python || process.env.HERMES_AGENT_BRIDGE_PYTHON @@ -227,7 +227,7 @@ export class AgentBridgeManager { private async startProcess(): Promise { const script = bridgeScriptPath() - const command = bridgeCommand(this.options) + const command = resolveAgentBridgeCommand(this.options) const args = [...command.argsPrefix, script, '--endpoint', this.endpoint] const agentRoot = command.agentRoot const hermesHome = command.hermesHome diff --git a/packages/server/src/services/hermes/plugins.ts b/packages/server/src/services/hermes/plugins.ts index 6212822..e3adb38 100644 --- a/packages/server/src/services/hermes/plugins.ts +++ b/packages/server/src/services/hermes/plugins.ts @@ -1,9 +1,7 @@ import { execFile } from 'child_process' -import { existsSync, readFileSync } from 'fs' -import { dirname, join, resolve } from 'path' -import { homedir } from 'os' import { promisify } from 'util' import { getActiveProfileDir } from './hermes-profile' +import { resolveAgentBridgeCommand } from './agent-bridge/manager' const execFileAsync = promisify(execFile) @@ -215,93 +213,6 @@ print(json.dumps({ })) ` -function hasHermesPluginModule(root: string): boolean { - return existsSync(join(root, 'hermes_cli', 'plugins.py')) -} - -function maybeRootFromHermesBin(): string[] { - const hermesBin = process.env.HERMES_BIN?.trim() - if (!hermesBin || hermesBin.includes('\n')) return [] - - const resolvedBin = resolve(hermesBin) - const candidates = [ - dirname(dirname(dirname(resolvedBin))), // /opt/hermes/.venv/bin/hermes -> /opt/hermes - dirname(dirname(resolvedBin)), - dirname(resolvedBin), - ] - return candidates.filter((candidate, index) => candidates.indexOf(candidate) === index) -} - -/** - * Parse the shebang of the hermes binary to extract the Python interpreter path. - * Works with pip-installed launchers, uv tool launchers, and manual venv installs. - * e.g. "#!/Users/ekko/.hermes/hermes-agent/venv/bin/python3" -> that path - */ -function pythonFromHermesShebang(): string | undefined { - const hermesBin = process.env.HERMES_BIN?.trim() || 'hermes' - try { - const resolved = resolve(hermesBin) - if (!existsSync(resolved)) return undefined - const head = readFileSync(resolved, 'utf8').slice(0, 256) - const match = head.match(/^#!\s*(\/[^\s\n]+)/) - return match ? match[1] : undefined - } catch { - return undefined - } -} - -function resolveHermesAgentRoot(): string { - const candidates = [ - process.env.HERMES_AGENT_ROOT?.trim(), - ...maybeRootFromHermesBin(), - '/opt/hermes', - join(homedir(), '.hermes', 'hermes-agent'), // Unix/Linux/macOS - ] - - // Windows specific path - if (process.platform === 'win32' && process.env.LOCALAPPDATA) { - candidates.push(join(process.env.LOCALAPPDATA, 'hermes', 'hermes-agent')) - } - - return (candidates.filter(Boolean) as string[]).find(hasHermesPluginModule) || '' -} - - -function pythonCandidates(agentRoot: string): string[] { - const hermesBin = process.env.HERMES_BIN?.trim() - let hermesBinPython: string | undefined - if (hermesBin) { - // Windows: hermes -> venv\Scripts\python.exe - // Unix: hermes -> venv/bin/python - if (hermesBin.includes('\\Scripts\\') || hermesBin.includes('/Scripts/')) { - hermesBinPython = join(dirname(hermesBin), 'python.exe') - } else if (hermesBin.includes('/bin/') || hermesBin.includes('\\bin\\')) { - hermesBinPython = join(dirname(hermesBin), 'python') - } - } - - const rootPythons = agentRoot ? [ - join(agentRoot, 'venv', 'bin', 'python'), // Unix - join(agentRoot, 'venv', 'Scripts', 'python.exe'), // Windows - join(agentRoot, '.venv', 'bin', 'python'), // Unix (alternative) - join(agentRoot, '.venv', 'Scripts', 'python.exe'), // Windows (alternative) - ] : [] - - const candidates = [ - process.env.HERMES_PYTHON?.trim(), - hermesBinPython, - ...rootPythons, - 'python3', - 'python', - pythonFromHermesShebang(), - ].filter(Boolean) as string[] - - return candidates.filter((candidate) => { - if (candidate.includes('/') || candidate.includes('\\')) return existsSync(candidate) - return true - }) -} - function extractError(err: any): string { const stdout = typeof err?.stdout === 'string' ? err.stdout.trim() : '' const stderr = typeof err?.stderr === 'string' ? err.stderr.trim() : '' @@ -309,7 +220,8 @@ function extractError(err: any): string { } export async function listHermesPlugins(): Promise { - const agentRoot = resolveHermesAgentRoot() + const command = resolveAgentBridgeCommand() + const agentRoot = command.agentRoot || '' const env = { ...process.env, HERMES_AGENT_ROOT_RESOLVED: agentRoot, @@ -317,26 +229,25 @@ export async function listHermesPlugins(): Promise { } const errors: string[] = [] - for (const python of pythonCandidates(agentRoot)) { - try { - const { stdout, stderr } = await execFileAsync(python, ['-I', '-c', PYTHON_BRIDGE], { - cwd: process.cwd(), - env, - windowsHide: true, - timeout: 15000, - maxBuffer: 10 * 1024 * 1024, - }) - const parsed = JSON.parse(stdout) as HermesPluginsResponse & { error?: string; detail?: string } - if ((parsed as any).error) { - throw new Error(`${(parsed as any).error}: ${(parsed as any).detail || 'unknown error'}`) - } - if (stderr?.trim()) { - parsed.warnings = [...(parsed.warnings || []), stderr.trim()] - } - return parsed - } catch (err: any) { - errors.push(`${python}: ${extractError(err)}`) + try { + const { stdout, stderr } = await execFileAsync(command.command, [...command.argsPrefix, '-I', '-c', PYTHON_BRIDGE], { + cwd: process.cwd(), + env, + windowsHide: true, + timeout: 15000, + maxBuffer: 10 * 1024 * 1024, + }) + const parsed = JSON.parse(stdout) as HermesPluginsResponse & { error?: string; detail?: string } + if ((parsed as any).error) { + throw new Error(`${(parsed as any).error}: ${(parsed as any).detail || 'unknown error'}`) } + if (stderr?.trim()) { + parsed.warnings = [...(parsed.warnings || []), stderr.trim()] + } + return parsed + } catch (err: any) { + const args = [...command.argsPrefix, '-I', '-c', ''].join(' ') + errors.push(`${command.command} ${args}: ${extractError(err)}`) } throw new Error(`Failed to discover Hermes plugins.\n${errors.join('\n')}`) diff --git a/tests/server/hermes-plugins-env.test.ts b/tests/server/hermes-plugins-env.test.ts new file mode 100644 index 0000000..4c1e7cc --- /dev/null +++ b/tests/server/hermes-plugins-env.test.ts @@ -0,0 +1,60 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('Hermes plugin discovery environment', () => { + const originalEnv = { ...process.env } + let tempDir = '' + + beforeEach(() => { + vi.resetModules() + tempDir = mkdtempSync(join(tmpdir(), 'hermes-plugins-env-')) + process.env = { ...originalEnv } + }) + + afterEach(() => { + process.env = { ...originalEnv } + if (tempDir) rmSync(tempDir, { recursive: true, force: true }) + }) + + it('uses the same venv python and agent root resolved from the hermes binary as the bridge', async () => { + const agentRoot = join(tempDir, 'agent') + const venvBin = join(agentRoot, '.venv', 'bin') + const hermesCliDir = join(agentRoot, 'hermes_cli') + const captureFile = join(tempDir, 'capture.txt') + const fakePython = join(venvBin, 'python') + const fakeHermes = join(venvBin, 'hermes') + + mkdirSync(venvBin, { recursive: true }) + mkdirSync(hermesCliDir, { recursive: true }) + writeFileSync(join(agentRoot, 'run_agent.py'), '') + writeFileSync(join(hermesCliDir, 'plugins.py'), '') + writeFileSync(fakePython, [ + '#!/bin/sh', + 'printf "%s\\n%s\\n%s\\n%s\\n" "$0" "$1" "$2" "$HERMES_AGENT_ROOT_RESOLVED" > "$CAPTURE_FILE"', + 'printf "%s\\n" \'{"plugins":[],"warnings":[],"metadata":{"hermesAgentRoot":"","pythonExecutable":"","cwd":"","projectPluginsEnabled":false}}\'', + '', + ].join('\n')) + chmodSync(fakePython, 0o755) + writeFileSync(fakeHermes, `#!${fakePython}\n`) + chmodSync(fakeHermes, 0o755) + + delete process.env.HERMES_AGENT_ROOT + delete process.env.HERMES_AGENT_BRIDGE_PYTHON + delete process.env.HERMES_AGENT_BRIDGE_UV + delete process.env.HERMES_PYTHON + process.env.HERMES_HOME = join(tempDir, 'home') + process.env.HERMES_BIN = fakeHermes + process.env.CAPTURE_FILE = captureFile + + const { listHermesPlugins } = await import('../../packages/server/src/services/hermes/plugins') + await expect(listHermesPlugins()).resolves.toMatchObject({ plugins: [] }) + + const [command, firstArg, secondArg, resolvedRoot] = readFileSync(captureFile, 'utf8').trim().split('\n') + expect(command).toBe(fakePython) + expect(firstArg).toBe('-I') + expect(secondArg).toBe('-c') + expect(resolvedRoot).toBe(agentRoot) + }) +})