fix plugin discovery python env (#798)
This commit is contained in:
@@ -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<void> {
|
||||
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
|
||||
|
||||
@@ -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<HermesPluginsResponse> {
|
||||
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<HermesPluginsResponse> {
|
||||
}
|
||||
|
||||
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', '<plugin-discovery>'].join(' ')
|
||||
errors.push(`${command.command} ${args}: ${extractError(err)}`)
|
||||
}
|
||||
|
||||
throw new Error(`Failed to discover Hermes plugins.\n${errors.join('\n')}`)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user