Add Hermes Agent package fallback and xAI OAuth (#808)

This commit is contained in:
ekko
2026-05-17 09:45:56 +08:00
committed by GitHub
parent 0c2bafc619
commit 53f0301da4
22 changed files with 871 additions and 26 deletions
+70
View File
@@ -0,0 +1,70 @@
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('agent bridge manager command resolution', () => {
const originalEnv = { ...process.env }
let tempDir = ''
beforeEach(() => {
vi.resetModules()
tempDir = mkdtempSync(join(tmpdir(), 'hermes-agent-bridge-manager-'))
process.env = { ...originalEnv }
delete process.env.HERMES_AGENT_ROOT
delete process.env.HERMES_AGENT_BRIDGE_PYTHON
delete process.env.HERMES_AGENT_BRIDGE_UV
delete process.env.UV
})
afterEach(() => {
process.env = { ...originalEnv }
if (tempDir) rmSync(tempDir, { recursive: true, force: true })
})
it('uses the installed hermes command Python when no source root exists', async () => {
const binDir = join(tempDir, 'bin')
const homeDir = join(tempDir, 'home')
const fakePython = join(binDir, 'python')
const fakeHermes = join(binDir, 'hermes')
mkdirSync(binDir, { recursive: true })
mkdirSync(homeDir, { recursive: true })
writeFileSync(fakePython, '#!/bin/sh\n')
chmodSync(fakePython, 0o755)
writeFileSync(fakeHermes, `#!${fakePython}\n`)
chmodSync(fakeHermes, 0o755)
process.env.HERMES_HOME = homeDir
process.env.HERMES_BIN = fakeHermes
const { resolveAgentBridgeCommand } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
const command = resolveAgentBridgeCommand()
expect(command).toEqual({
command: fakePython,
argsPrefix: [],
agentRoot: undefined,
hermesHome: homeDir,
})
})
it('falls back to system Python instead of uv when no source root exists', async () => {
const homeDir = join(tempDir, 'home')
const fakePython = join(tempDir, 'python3')
mkdirSync(homeDir, { recursive: true })
writeFileSync(fakePython, '#!/bin/sh\n')
chmodSync(fakePython, 0o755)
process.env.HERMES_HOME = homeDir
process.env.HERMES_BIN = join(tempDir, 'missing-hermes')
process.env.PYTHON = fakePython
const { resolveAgentBridgeCommand } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
const command = resolveAgentBridgeCommand()
expect(command).toEqual({
command: fakePython,
argsPrefix: [],
agentRoot: undefined,
hermesHome: homeDir,
})
})
})
@@ -144,6 +144,51 @@ print(json.dumps({
})
})
it('falls back to package imports when no Hermes Agent source root exists', async () => {
const packageDir = join(tempDir, 'site-packages')
const hermesHome = join(tempDir, 'home')
await mkdir(packageDir, { recursive: true })
await mkdir(hermesHome, { recursive: true })
await writeFile(join(packageDir, 'run_agent.py'), 'class AIAgent: pass\n', 'utf-8')
const expectedHermesHome = await realpath(hermesHome)
const result = await runBridgeProbe(`
import importlib.util
import json
import os
import sys
spec = importlib.util.spec_from_file_location("hermes_bridge", os.environ["BRIDGE_PATH"])
bridge = importlib.util.module_from_spec(spec)
sys.modules["hermes_bridge"] = bridge
spec.loader.exec_module(bridge)
package_dir = os.path.join(os.environ["TEST_HERMES_HOME"], "site-packages")
hermes_home = os.path.join(os.environ["TEST_HERMES_HOME"], "home")
sys.path.insert(0, package_dir)
bridge._candidate_agent_roots = lambda raw=None: []
os.environ.pop("HERMES_AGENT_ROOT", None)
bridge._set_path_env(None, hermes_home)
bridge._ensure_agent_imports()
from run_agent import AIAgent
print(json.dumps({
"agent_root": os.environ.get("HERMES_AGENT_ROOT"),
"home": os.environ.get("HERMES_HOME"),
"base": os.environ.get("HERMES_AGENT_BRIDGE_BASE_HOME"),
"agent_class": AIAgent.__name__,
}))
`)
expect(result).toEqual({
agent_root: null,
home: expectedHermesHome,
base: expectedHermesHome,
agent_class: 'AIAgent',
})
})
it('keeps inherited profile env keys for default profile compatibility', async () => {
await mkdir(join(tempDir, 'profiles', 'work'), { recursive: true })
await writeFile(join(tempDir, '.env'), 'OPENAI_API_KEY=default-openai\n', 'utf-8')
+38
View File
@@ -57,4 +57,42 @@ describe('Hermes plugin discovery environment', () => {
expect(secondArg).toBe('-c')
expect(resolvedRoot).toBe(agentRoot)
})
it('uses package Python without isolated mode when no source root is resolved', async () => {
const binDir = join(tempDir, 'bin')
const captureFile = join(tempDir, 'capture-package.txt')
const fakePython = join(binDir, 'python')
const fakeHermes = join(binDir, 'hermes')
mkdirSync(binDir, { recursive: true })
writeFileSync(fakePython, [
'#!/bin/sh',
'printf "%s\\n%s\\n%s\\n%s\\n" "$0" "$1" "${PYTHONPATH-unset}" "${PYTHONHOME-unset}" > "$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.UV
delete process.env.HERMES_PYTHON
process.env.HERMES_HOME = join(tempDir, 'home')
process.env.HERMES_BIN = fakeHermes
process.env.CAPTURE_FILE = captureFile
process.env.PYTHONPATH = join(tempDir, 'shadow-path')
process.env.PYTHONHOME = join(tempDir, 'shadow-home')
const { listHermesPlugins } = await import('../../packages/server/src/services/hermes/plugins')
await expect(listHermesPlugins()).resolves.toMatchObject({ plugins: [] })
const [command, firstArg, pythonPath, pythonHome] = readFileSync(captureFile, 'utf8').trim().split('\n')
expect(command).toBe(fakePython)
expect(firstArg).toBe('-c')
expect(pythonPath).toBe('unset')
expect(pythonHome).toBe('unset')
})
})
@@ -32,6 +32,7 @@ vi.mock('../../packages/server/src/services/config-helpers', () => ({
buildModelGroups: mockBuildModelGroups,
PROVIDER_ENV_MAP: {
deepseek: { api_key_env: 'DEEPSEEK_API_KEY' },
'xai-oauth': { api_key_env: '', base_url_env: 'XAI_BASE_URL' },
openrouter: {},
},
}))
@@ -39,6 +40,7 @@ vi.mock('../../packages/server/src/services/config-helpers', () => ({
vi.mock('../../packages/server/src/shared/providers', () => ({
buildProviderModelMap: () => ({
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
'xai-oauth': ['grok-4.3', 'grok-4.20-0309-reasoning'],
openrouter: ['openrouter/auto'],
}),
PROVIDER_PRESETS: [
@@ -54,6 +56,12 @@ vi.mock('../../packages/server/src/shared/providers', () => ({
base_url: 'https://openrouter.ai/api/v1',
models: ['openrouter/auto'],
},
{
value: 'xai-oauth',
label: 'xAI Grok OAuth (SuperGrok Subscription)',
base_url: 'https://api.x.ai/v1',
models: ['grok-4.3', 'grok-4.20-0309-reasoning'],
},
],
}))
@@ -138,6 +146,30 @@ describe('models controller — model visibility', () => {
]))
})
it('shows xAI Grok OAuth when SuperGrok credentials exist in auth.json', async () => {
mockExistsSync.mockReturnValue(true)
mockReadFileSync.mockReturnValue(JSON.stringify({
providers: {
'xai-oauth': {
tokens: { access_token: 'xai-token' },
},
},
}))
const ctx = makeCtx()
await ctrl.getAvailable(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.groups).toEqual(expect.arrayContaining([
expect.objectContaining({
provider: 'xai-oauth',
label: 'xAI Grok OAuth (SuperGrok Subscription)',
base_url: 'https://api.x.ai/v1',
models: ['grok-4.3', 'grok-4.20-0309-reasoning'],
}),
]))
})
it('fails open for stale include rules so a provider can be recovered in the UI', async () => {