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
+8
View File
@@ -205,6 +205,14 @@ Open **http://localhost:6060**
For detailed notes and troubleshooting, see [`docs/docker.md`](./docs/docker.md). For detailed notes and troubleshooting, see [`docs/docker.md`](./docs/docker.md).
### Hermes Agent Runtime Discovery
When Web UI starts backend chat features, it prefers a source checkout that
contains `run_agent.py` such as `~/.hermes/hermes-agent`. If no source checkout
is found, it falls back to the Python environment used by the installed
`hermes` command, then the system Python. This supports both source installs
and package installs such as `pip install hermes-agent`.
## Web UI Environment Variables ## Web UI Environment Variables
These variables configure Hermes Web UI itself. Provider API keys and Hermes Agent settings are managed separately through Hermes profiles. These variables configure Hermes Web UI itself. Provider API keys and Hermes Agent settings are managed separately through Hermes profiles.
+7
View File
@@ -213,6 +213,13 @@ docker compose logs -f hermes-webui
更详细的说明与排错见:[`docs/docker.md`](./docs/docker.md) 更详细的说明与排错见:[`docs/docker.md`](./docs/docker.md)
### Hermes Agent 运行时发现
Web UI 启动后端聊天能力时,会优先使用包含 `run_agent.py` 的源码目录,例如
`~/.hermes/hermes-agent`。如果找不到源码目录,会退回到已安装 `hermes` 命令所使用
的 Python 环境,再退到系统 Python。因此源码安装和 `pip install hermes-agent` 这类
包安装方式都可以兼容。
## Web UI 环境变量 ## Web UI 环境变量
这些变量只用于配置 Hermes Web UI 自身。Provider API Key 和 Hermes Agent 相关设置仍通过 Hermes profile 管理。 这些变量只用于配置 Hermes Web UI 自身。Provider API Key 和 Hermes Agent 相关设置仍通过 Hermes profile 管理。
@@ -0,0 +1,29 @@
import { request } from '../client'
export interface XaiStartResult {
session_id: string
authorization_url: string
expires_in: number
}
export interface XaiPollResult {
status: 'pending' | 'approved' | 'expired' | 'error'
error: string | null
}
export interface XaiStatusResult {
authenticated: boolean
last_refresh?: string
}
export async function startXaiLogin(): Promise<XaiStartResult> {
return request<XaiStartResult>('/api/hermes/auth/xai/start', { method: 'POST' })
}
export async function pollXaiLogin(sessionId: string): Promise<XaiPollResult> {
return request<XaiPollResult>(`/api/hermes/auth/xai/poll/${sessionId}`)
}
export async function getXaiAuthStatus(): Promise<XaiStatusResult> {
return request<XaiStatusResult>('/api/hermes/auth/xai/status')
}
@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n'
import CodexLoginModal from './CodexLoginModal.vue' import CodexLoginModal from './CodexLoginModal.vue'
import NousLoginModal from './NousLoginModal.vue' import NousLoginModal from './NousLoginModal.vue'
import CopilotLoginModal from './CopilotLoginModal.vue' import CopilotLoginModal from './CopilotLoginModal.vue'
import XaiOAuthLoginModal from './XaiOAuthLoginModal.vue'
import { checkCopilotToken, enableCopilot, type CopilotTokenSource } from '@/api/hermes/copilot-auth' import { checkCopilotToken, enableCopilot, type CopilotTokenSource } from '@/api/hermes/copilot-auth'
import { fetchProviderModels } from '@/api/hermes/system' import { fetchProviderModels } from '@/api/hermes/system'
@@ -26,6 +27,7 @@ const fetchingModels = ref(false)
const showCodexLogin = ref(false) const showCodexLogin = ref(false)
const showNousLogin = ref(false) const showNousLogin = ref(false)
const showCopilotLogin = ref(false) const showCopilotLogin = ref(false)
const showXaiLogin = ref(false)
const copilotChecking = ref(false) const copilotChecking = ref(false)
const providerType = ref<'preset' | 'custom'>('preset') const providerType = ref<'preset' | 'custom'>('preset')
@@ -44,6 +46,7 @@ const CODEX_KEY = 'openai-codex'
const NOUS_KEY = 'nous' const NOUS_KEY = 'nous'
const COPILOT_KEY = 'copilot' const COPILOT_KEY = 'copilot'
const CLIPROXYAPI_KEY = 'cliproxyapi' const CLIPROXYAPI_KEY = 'cliproxyapi'
const XAI_OAUTH_KEY = 'xai-oauth'
const ALIBABA_CODING_KEY = 'alibaba-coding-plan' const ALIBABA_CODING_KEY = 'alibaba-coding-plan'
const ALIBABA_CODING_REGIONS = { const ALIBABA_CODING_REGIONS = {
intl: 'https://coding-intl.dashscope.aliyuncs.com/v1', intl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
@@ -54,6 +57,7 @@ const isCodex = computed(() => selectedPreset.value === CODEX_KEY)
const isNous = computed(() => selectedPreset.value === NOUS_KEY) const isNous = computed(() => selectedPreset.value === NOUS_KEY)
const isCopilot = computed(() => selectedPreset.value === COPILOT_KEY) const isCopilot = computed(() => selectedPreset.value === COPILOT_KEY)
const isCliproxyApi = computed(() => selectedPreset.value === CLIPROXYAPI_KEY) const isCliproxyApi = computed(() => selectedPreset.value === CLIPROXYAPI_KEY)
const isXaiOAuth = computed(() => selectedPreset.value === XAI_OAUTH_KEY)
const isAlibabaCoding = computed(() => selectedPreset.value === ALIBABA_CODING_KEY) const isAlibabaCoding = computed(() => selectedPreset.value === ALIBABA_CODING_KEY)
const alibabaCodingRegion = ref<'intl' | 'cn'>('intl') const alibabaCodingRegion = ref<'intl' | 'cn'>('intl')
@@ -93,6 +97,8 @@ watch(selectedPreset, (val) => {
if (val === COPILOT_KEY) { if (val === COPILOT_KEY) {
// 判断是否已能解析到 token:有 → 弹简单确认;无 → 走 in-app device flow // 判断是否已能解析到 token:有 → 弹简单确认;无 → 走 in-app device flow
void triggerCopilotAdd() void triggerCopilotAdd()
} else if (val === XAI_OAUTH_KEY) {
showXaiLogin.value = true
} }
} }
}) })
@@ -170,11 +176,16 @@ async function handleSave() {
return return
} }
if (isXaiOAuth.value) {
showXaiLogin.value = true
return
}
if (!formData.value.base_url.trim()) { if (!formData.value.base_url.trim()) {
message.warning(t('models.baseUrlRequired')) message.warning(t('models.baseUrlRequired'))
return return
} }
if (!formData.value.api_key.trim() && !isCliproxyApi.value) { if (!formData.value.api_key.trim() && !isCliproxyApi.value && !isXaiOAuth.value) {
message.warning(t('models.apiKeyRequired')) message.warning(t('models.apiKeyRequired'))
return return
} }
@@ -225,6 +236,12 @@ async function handleCopilotSuccess() {
emit('saved') emit('saved')
} }
async function handleXaiSuccess() {
showXaiLogin.value = false
message.success(t('models.providerAdded'))
emit('saved')
}
function copilotSourceLabel(source: CopilotTokenSource): string { function copilotSourceLabel(source: CopilotTokenSource): string {
if (source === 'env') return t('models.copilotAddSourceEnv') if (source === 'env') return t('models.copilotAddSourceEnv')
if (source === 'gh-cli') return t('models.copilotAddSourceGhCli') if (source === 'gh-cli') return t('models.copilotAddSourceGhCli')
@@ -281,6 +298,11 @@ function handleCopilotClose() {
selectedPreset.value = null selectedPreset.value = null
} }
function handleXaiClose() {
showXaiLogin.value = false
selectedPreset.value = null
}
function handleClose() { function handleClose() {
showModal.value = false showModal.value = false
setTimeout(() => emit('close'), 200) setTimeout(() => emit('close'), 200)
@@ -293,7 +315,7 @@ function handleClose() {
preset="card" preset="card"
:title="t('models.addProvider')" :title="t('models.addProvider')"
:style="{ width: 'min(520px, calc(100vw - 32px))' }" :style="{ width: 'min(520px, calc(100vw - 32px))' }"
:mask-closable="!loading && !showCodexLogin && !showNousLogin && !showCopilotLogin" :mask-closable="!loading && !showCodexLogin && !showNousLogin && !showCopilotLogin && !showXaiLogin"
@after-leave="emit('close')" @after-leave="emit('close')"
> >
<NForm label-placement="top"> <NForm label-placement="top">
@@ -353,7 +375,7 @@ function handleClose() {
/> />
</NFormItem> </NFormItem>
<NFormItem v-if="!isCodex && !isNous" :label="t('models.apiKey')" :required="!isCliproxyApi"> <NFormItem v-if="!isCodex && !isNous" :label="t('models.apiKey')" :required="!isCliproxyApi && !isXaiOAuth">
<NInput <NInput
v-model:value="formData.api_key" v-model:value="formData.api_key"
type="password" type="password"
@@ -420,6 +442,12 @@ function handleClose() {
@close="handleCopilotClose" @close="handleCopilotClose"
@success="handleCopilotSuccess" @success="handleCopilotSuccess"
/> />
<XaiOAuthLoginModal
v-if="showXaiLogin"
@close="handleXaiClose"
@success="handleXaiSuccess"
/>
</NModal> </NModal>
</template> </template>
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { NModal, NButton, NSpin, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { startXaiLogin, pollXaiLogin } from '@/api/hermes/xai-auth'
const { t } = useI18n()
const emit = defineEmits<{ close: []; success: [] }>()
const message = useMessage()
const showModal = ref(true)
const status = ref<'idle' | 'loading' | 'waiting' | 'approved' | 'expired' | 'error'>('idle')
const authorizationUrl = ref('')
const sessionId = ref('')
const errorMessage = ref('')
let pollTimer: ReturnType<typeof setTimeout> | null = null
async function startLogin() {
status.value = 'loading'
errorMessage.value = ''
try {
const data = await startXaiLogin()
authorizationUrl.value = data.authorization_url
sessionId.value = data.session_id
status.value = 'waiting'
window.open(authorizationUrl.value, '_blank')
startPolling()
} catch (err: any) {
status.value = 'error'
errorMessage.value = err?.message || String(err)
message.error(errorMessage.value)
}
}
function startPolling() {
stopPolling()
pollTimer = setTimeout(async () => {
try {
const result = await pollXaiLogin(sessionId.value)
if (result.status === 'pending') {
startPolling()
} else if (result.status === 'approved') {
status.value = 'approved'
message.success(t('models.xaiApproved'))
setTimeout(() => {
showModal.value = false
setTimeout(() => emit('success'), 200)
}, 1000)
} else if (result.status === 'expired') {
status.value = 'expired'
} else if (result.status === 'error') {
status.value = 'error'
errorMessage.value = result.error || 'Unknown error'
}
} catch {
startPolling()
}
}, 2000)
}
function stopPolling() {
if (pollTimer) clearTimeout(pollTimer)
pollTimer = null
}
function handleClose() {
stopPolling()
showModal.value = false
setTimeout(() => emit('close'), 200)
}
function openLink() {
window.open(authorizationUrl.value, '_blank')
}
function retry() {
status.value = 'idle'
authorizationUrl.value = ''
sessionId.value = ''
errorMessage.value = ''
startLogin()
}
onUnmounted(stopPolling)
startLogin()
</script>
<template>
<NModal
v-model:show="showModal"
preset="card"
:title="t('models.xaiLoginTitle')"
:style="{ width: 'min(440px, calc(100vw - 32px))' }"
:mask-closable="status !== 'waiting'"
@after-leave="emit('close')"
>
<div class="xai-login">
<div v-if="status === 'idle' || status === 'loading'" class="xai-login__state">
<NSpin size="small" />
</div>
<div v-else-if="status === 'waiting'" class="xai-login__state">
<p class="xai-login__hint">{{ t('models.xaiWaiting') }}</p>
<NButton type="primary" block @click="openLink">
{{ t('models.xaiOpenLink') }}
</NButton>
</div>
<div v-else-if="status === 'approved'" class="xai-login__state xai-login__state--success">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<p>{{ t('models.xaiApproved') }}</p>
</div>
<div v-else-if="status === 'expired'" class="xai-login__state">
<p class="xai-login__error">{{ t('models.xaiExpired') }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
<div v-else-if="status === 'error'" class="xai-login__state">
<p class="xai-login__error">{{ errorMessage }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
</div>
<template #footer>
<div class="modal-footer">
<NButton :disabled="status === 'waiting'" @click="handleClose">{{ t('common.cancel') }}</NButton>
</div>
</template>
</NModal>
</template>
<style scoped lang="scss">
.xai-login {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 0;
}
.xai-login__state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
min-height: 120px;
justify-content: center;
width: 100%;
}
.xai-login__hint {
font-size: 14px;
color: var(--n-text-color, inherit);
text-align: center;
line-height: 1.6;
}
.xai-login__state--success {
color: #18a058;
svg {
stroke: #18a058;
}
}
.xai-login__error {
color: #d03050;
text-align: center;
word-break: break-word;
}
.modal-footer {
display: flex;
justify-content: flex-end;
}
</style>
+5
View File
@@ -573,6 +573,11 @@ export default {
copilotDeleteHintEnv: 'This will clear COPILOT_GITHUB_TOKEN in ~/.hermes/.env. Other tools are not affected.', copilotDeleteHintEnv: 'This will clear COPILOT_GITHUB_TOKEN in ~/.hermes/.env. Other tools are not affected.',
copilotDeleteHintGhCli: 'Copilot will be hidden from Hermes. Your gh CLI login is not affected — `gh auth status` will still show you signed in.', copilotDeleteHintGhCli: 'Copilot will be hidden from Hermes. Your gh CLI login is not affected — `gh auth status` will still show you signed in.',
copilotDeleteHintAppsJson: 'Copilot will be hidden from Hermes. Your VS Code Copilot extension login is not affected.', copilotDeleteHintAppsJson: 'Copilot will be hidden from Hermes. Your VS Code Copilot extension login is not affected.',
xaiLoginTitle: 'xAI Grok OAuth Login',
xaiWaiting: 'Complete authorization in the opened xAI page. This window will close automatically once approved.',
xaiOpenLink: 'Open xAI authorization page',
xaiApproved: 'Sign-in succeeded!',
xaiExpired: 'The authorization link has expired. Please retry.',
customBadge: 'CUSTOM', customBadge: 'CUSTOM',
previewBadge: 'PREVIEW', previewBadge: 'PREVIEW',
disabledBadge: 'UNAVAILABLE', disabledBadge: 'UNAVAILABLE',
+5
View File
@@ -573,6 +573,11 @@ export default {
copilotDeleteHintEnv: '此操作会清除 ~/.hermes/.env 中的 COPILOT_GITHUB_TOKEN,不影响其他工具。', copilotDeleteHintEnv: '此操作会清除 ~/.hermes/.env 中的 COPILOT_GITHUB_TOKEN,不影响其他工具。',
copilotDeleteHintGhCli: 'Copilot 将从 Hermes 列表移除。不会影响 gh CLI —— `gh auth status` 仍显示已登录。', copilotDeleteHintGhCli: 'Copilot 将从 Hermes 列表移除。不会影响 gh CLI —— `gh auth status` 仍显示已登录。',
copilotDeleteHintAppsJson: 'Copilot 将从 Hermes 列表移除。不会影响 VS Code Copilot 插件的登录。', copilotDeleteHintAppsJson: 'Copilot 将从 Hermes 列表移除。不会影响 VS Code Copilot 插件的登录。',
xaiLoginTitle: 'xAI Grok OAuth 登录',
xaiWaiting: '请在打开的 xAI 页面完成授权。授权完成后窗口会自动关闭。',
xaiOpenLink: '打开 xAI 授权页',
xaiApproved: '登录成功!',
xaiExpired: '授权链接已过期,请重试。',
customBadge: '自定义', customBadge: '自定义',
previewBadge: '预览', previewBadge: '预览',
disabledBadge: '不可用', disabledBadge: '不可用',
@@ -6,7 +6,8 @@ import { updateConfigYaml, saveEnvValue, PROVIDER_ENV_MAP } from '../../services
import { PROVIDER_PRESETS } from '../../shared/providers' import { PROVIDER_PRESETS } from '../../shared/providers'
import { logger } from '../../services/logger' import { logger } from '../../services/logger'
const OPTIONAL_API_KEY_PROVIDERS = new Set(['cliproxyapi']) const OPTIONAL_API_KEY_PROVIDERS = new Set(['cliproxyapi', 'xai-oauth'])
const DIRECT_CONFIG_PROVIDERS = new Set(['xai-oauth'])
async function clearStoredAuthProvider(poolKey: string) { async function clearStoredAuthProvider(poolKey: string) {
try { try {
@@ -82,6 +83,10 @@ export async function create(ctx: any) {
if (PROVIDER_ENV_MAP[poolKey].base_url_env) { await saveEnvValue(PROVIDER_ENV_MAP[poolKey].base_url_env, base_url) } if (PROVIDER_ENV_MAP[poolKey].base_url_env) { await saveEnvValue(PROVIDER_ENV_MAP[poolKey].base_url_env, base_url) }
config.model.default = model config.model.default = model
config.model.provider = poolKey config.model.provider = poolKey
} else if (DIRECT_CONFIG_PROVIDERS.has(poolKey)) {
if (PROVIDER_ENV_MAP[poolKey].base_url_env) { await saveEnvValue(PROVIDER_ENV_MAP[poolKey].base_url_env, base_url) }
config.model.default = model
config.model.provider = poolKey
} else { } else {
if (!Array.isArray(config.custom_providers)) { config.custom_providers = [] } if (!Array.isArray(config.custom_providers)) { config.custom_providers = [] }
const existing = (config.custom_providers as any[]).find( const existing = (config.custom_providers as any[]).find(
@@ -0,0 +1,334 @@
import { createHash, randomBytes, randomUUID } from 'crypto'
import { createServer, type Server } from 'http'
import { request as httpsRequest, type RequestOptions } from 'https'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { URL } from 'url'
import { getActiveAuthPath } from '../../services/hermes/hermes-profile'
import { logger } from '../../services/logger'
import { updateConfigYaml } from '../../services/config-helpers'
const XAI_OAUTH_ISSUER = 'https://auth.x.ai'
const XAI_OAUTH_DISCOVERY_URL = `${XAI_OAUTH_ISSUER}/.well-known/openid-configuration`
const XAI_OAUTH_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828'
const XAI_OAUTH_SCOPE = 'openid profile email offline_access grok-cli:access api:access'
const XAI_DEFAULT_BASE_URL = 'https://api.x.ai/v1'
const XAI_REDIRECT_HOST = '127.0.0.1'
const XAI_REDIRECT_PORT = 56121
const XAI_REDIRECT_PATH = '/callback'
const POLL_MAX_DURATION = 15 * 60 * 1000
interface XaiSession {
id: string
status: 'pending' | 'approved' | 'expired' | 'error'
authorizeUrl: string
redirectUri: string
codeVerifier: string
state: string
tokenEndpoint: string
discovery: Record<string, string>
server: Server
error?: string
createdAt: number
}
interface AuthJson {
version?: number
active_provider?: string
providers?: Record<string, any>
credential_pool?: Record<string, any[]>
updated_at?: string
}
const sessions = new Map<string, XaiSession>()
function cleanupExpiredSessions() {
const now = Date.now()
sessions.forEach((session, id) => {
if (now - session.createdAt > POLL_MAX_DURATION + 60000) {
closeServer(session)
sessions.delete(id)
}
})
}
function closeServer(session: XaiSession) {
try { session.server.close() } catch {}
}
function base64Url(input: Buffer): string {
return input.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function makeCodeVerifier(): string {
return base64Url(randomBytes(48))
}
function makeCodeChallenge(verifier: string): string {
return base64Url(createHash('sha256').update(verifier).digest())
}
function validateXaiEndpoint(raw: string, field: string): string {
const url = new URL(raw)
if (url.protocol !== 'https:') throw new Error(`xAI discovery returned non-HTTPS ${field}`)
const host = url.hostname.toLowerCase()
if (host !== 'x.ai' && !host.endsWith('.x.ai')) {
throw new Error(`xAI discovery ${field} host is not on x.ai`)
}
return raw
}
async function requestJson(url: string, options: {
method?: string
headers?: Record<string, string>
body?: string
timeoutMs?: number
} = {}): Promise<{ status: number; text: string; json: any }> {
const target = new URL(url)
const timeoutMs = options.timeoutMs || 15000
const body = options.body || ''
const headers: Record<string, string> = {
Accept: 'application/json',
...(options.headers || {}),
}
if (body && !headers['Content-Length']) headers['Content-Length'] = Buffer.byteLength(body).toString()
const requestOptions: RequestOptions = {
hostname: target.hostname,
port: Number(target.port || 443),
path: `${target.pathname}${target.search}`,
method: options.method || 'GET',
headers,
timeout: timeoutMs,
}
return await new Promise((resolve, reject) => {
const req = httpsRequest(requestOptions, (res) => {
const chunks: Buffer[] = []
res.on('data', chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf-8')
let json: any = null
try { json = text ? JSON.parse(text) : null } catch {}
resolve({ status: res.statusCode || 0, text, json })
})
})
req.once('timeout', () => req.destroy(new Error(`Request timed out after ${timeoutMs}ms`)))
req.once('error', reject)
if (body) req.write(body)
req.end()
})
}
async function discoverXai(): Promise<Record<string, string>> {
const res = await requestJson(XAI_OAUTH_DISCOVERY_URL, { timeoutMs: 15000 })
if (res.status < 200 || res.status >= 300) throw new Error(`xAI discovery failed: ${res.status}`)
const payload = res.json as Record<string, unknown>
if (!payload || typeof payload !== 'object') throw new Error('xAI discovery returned invalid JSON')
const authorizationEndpoint = String(payload.authorization_endpoint || '').trim()
const tokenEndpoint = String(payload.token_endpoint || '').trim()
if (!authorizationEndpoint || !tokenEndpoint) throw new Error('xAI discovery missing endpoints')
return {
authorization_endpoint: validateXaiEndpoint(authorizationEndpoint, 'authorization_endpoint'),
token_endpoint: validateXaiEndpoint(tokenEndpoint, 'token_endpoint'),
}
}
function loadAuthJson(authPath: string): AuthJson {
try { return JSON.parse(readFileSync(authPath, 'utf-8')) as AuthJson } catch { return { version: 1 } }
}
function saveAuthJson(authPath: string, data: AuthJson): void {
data.updated_at = new Date().toISOString()
const dir = authPath.substring(0, authPath.lastIndexOf('/'))
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(authPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
}
async function saveTokens(session: XaiSession, tokenData: any) {
const accessToken = String(tokenData.access_token || '').trim()
const refreshToken = String(tokenData.refresh_token || '').trim()
if (!accessToken || !refreshToken) throw new Error('xAI token response missing access_token or refresh_token')
const lastRefresh = new Date().toISOString()
const tokens = {
access_token: accessToken,
refresh_token: refreshToken,
id_token: String(tokenData.id_token || '').trim(),
expires_in: tokenData.expires_in,
token_type: String(tokenData.token_type || 'Bearer').trim() || 'Bearer',
}
const authPath = getActiveAuthPath()
const auth = loadAuthJson(authPath)
if (!auth.providers) auth.providers = {}
auth.providers['xai-oauth'] = {
tokens,
last_refresh: lastRefresh,
auth_mode: 'oauth_pkce',
discovery: session.discovery,
redirect_uri: session.redirectUri,
}
if (!auth.credential_pool) auth.credential_pool = {}
auth.credential_pool['xai-oauth'] = [{
id: `xai-oauth-${Date.now()}`,
label: 'xAI Grok OAuth (SuperGrok Subscription)',
auth_type: 'oauth',
source: 'loopback_pkce',
priority: 0,
access_token: accessToken,
refresh_token: refreshToken,
base_url: XAI_DEFAULT_BASE_URL,
}]
saveAuthJson(authPath, auth)
await updateConfigYaml((config) => {
if (typeof config.model !== 'object' || config.model === null) config.model = {}
config.model.provider = 'xai-oauth'
config.model.default = config.model.default || 'grok-4.3'
delete config.model.base_url
delete config.model.api_key
return config
})
}
async function exchangeCode(session: XaiSession, code: string) {
const res = await requestJson(session.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: session.redirectUri,
client_id: XAI_OAUTH_CLIENT_ID,
code_verifier: session.codeVerifier,
}).toString(),
timeoutMs: 20000,
})
if (res.status < 200 || res.status >= 300) {
throw new Error(`xAI token exchange failed: ${res.status}${res.text ? ` ${res.text}` : ''}`)
}
await saveTokens(session, res.json)
}
function startCallbackServer(sessionId: string, preferredPort = XAI_REDIRECT_PORT): Promise<{ server: Server; redirectUri: string }> {
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
const session = sessions.get(sessionId)
const url = new URL(req.url || '/', `http://${XAI_REDIRECT_HOST}`)
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': 'https://auth.x.ai',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
})
res.end()
return
}
if (!session || url.pathname !== XAI_REDIRECT_PATH) {
res.writeHead(404)
res.end('Not found.')
return
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end('<html><body><h1>xAI authorization received.</h1>You can close this tab.</body></html>')
void (async () => {
try {
const error = url.searchParams.get('error')
if (error) throw new Error(url.searchParams.get('error_description') || error)
if (url.searchParams.get('state') !== session.state) throw new Error('xAI OAuth state mismatch')
const code = url.searchParams.get('code')
if (!code) throw new Error('xAI OAuth callback missing code')
await exchangeCode(session, code)
session.status = 'approved'
closeServer(session)
} catch (err: any) {
logger.error(err, 'xAI OAuth callback failed')
session.status = 'error'
session.error = err?.message || String(err)
closeServer(session)
}
})()
})
server.once('error', (err: any) => {
if (preferredPort !== 0 && err?.code === 'EADDRINUSE') {
startCallbackServer(sessionId, 0).then(resolve, reject)
} else {
reject(err)
}
})
server.listen(preferredPort, XAI_REDIRECT_HOST, () => {
const address = server.address()
const port = typeof address === 'object' && address ? address.port : preferredPort
resolve({ server, redirectUri: `http://${XAI_REDIRECT_HOST}:${port}${XAI_REDIRECT_PATH}` })
})
})
}
export async function start(ctx: any) {
try {
cleanupExpiredSessions()
const sessionId = randomUUID()
const discovery = await discoverXai()
const codeVerifier = makeCodeVerifier()
const state = randomUUID().replace(/-/g, '')
const nonce = randomUUID().replace(/-/g, '')
const { server, redirectUri } = await startCallbackServer(sessionId)
const authorizeUrl = `${discovery.authorization_endpoint}?${new URLSearchParams({
response_type: 'code',
client_id: XAI_OAUTH_CLIENT_ID,
redirect_uri: redirectUri,
scope: XAI_OAUTH_SCOPE,
code_challenge: makeCodeChallenge(codeVerifier),
code_challenge_method: 'S256',
state,
nonce,
plan: 'generic',
referrer: 'hermes-web-ui',
}).toString()}`
sessions.set(sessionId, {
id: sessionId,
status: 'pending',
authorizeUrl,
redirectUri,
codeVerifier,
state,
tokenEndpoint: discovery.token_endpoint,
discovery,
server,
createdAt: Date.now(),
})
ctx.body = { session_id: sessionId, authorization_url: authorizeUrl, expires_in: 900 }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function poll(ctx: any) {
const session = sessions.get(ctx.params.sessionId)
if (!session) { ctx.status = 404; ctx.body = { error: 'Session not found' }; return }
if (Date.now() - session.createdAt > POLL_MAX_DURATION) {
session.status = 'expired'
closeServer(session)
}
ctx.body = { status: session.status, error: session.error || null }
}
export async function status(ctx: any) {
try {
const auth = loadAuthJson(getActiveAuthPath())
const provider = auth.providers?.['xai-oauth']
const pool = auth.credential_pool?.['xai-oauth']
ctx.body = {
authenticated: !!(
provider?.tokens?.access_token ||
provider?.access_token ||
(Array.isArray(pool) && pool.some((entry: any) => entry?.access_token))
),
last_refresh: provider?.last_refresh,
}
} catch {
ctx.body = { authenticated: false }
}
}
@@ -0,0 +1,8 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/xai-auth'
export const xaiAuthRoutes = new Router()
xaiAuthRoutes.post('/api/hermes/auth/xai/start', ctrl.start)
xaiAuthRoutes.get('/api/hermes/auth/xai/poll/:sessionId', ctrl.poll)
xaiAuthRoutes.get('/api/hermes/auth/xai/status', ctrl.status)
+2
View File
@@ -20,6 +20,7 @@ import { logRoutes } from './hermes/logs'
import { codexAuthRoutes } from './hermes/codex-auth' import { codexAuthRoutes } from './hermes/codex-auth'
import { nousAuthRoutes } from './hermes/nous-auth' import { nousAuthRoutes } from './hermes/nous-auth'
import { copilotAuthRoutes } from './hermes/copilot-auth' import { copilotAuthRoutes } from './hermes/copilot-auth'
import { xaiAuthRoutes } from './hermes/xai-auth'
import { gatewayRoutes } from './hermes/gateways' import { gatewayRoutes } from './hermes/gateways'
import { weixinRoutes } from './hermes/weixin' import { weixinRoutes } from './hermes/weixin'
import { fileRoutes } from './hermes/files' import { fileRoutes } from './hermes/files'
@@ -62,6 +63,7 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next)
app.use(codexAuthRoutes.routes()) app.use(codexAuthRoutes.routes())
app.use(nousAuthRoutes.routes()) app.use(nousAuthRoutes.routes())
app.use(copilotAuthRoutes.routes()) app.use(copilotAuthRoutes.routes())
app.use(xaiAuthRoutes.routes())
app.use(gatewayRoutes.routes()) app.use(gatewayRoutes.routes())
app.use(weixinRoutes.routes()) app.use(weixinRoutes.routes())
app.use(groupChatRoutes.routes()) // Must be before proxy app.use(groupChatRoutes.routes()) // Must be before proxy
@@ -22,6 +22,7 @@ export const PROVIDER_ENV_MAP: Record<string, { api_key_env: string; base_url_en
'alibaba-coding-plan': { api_key_env: 'ALIBABA_CODING_PLAN_API_KEY', base_url_env: 'ALIBABA_CODING_PLAN_BASE_URL' }, 'alibaba-coding-plan': { api_key_env: 'ALIBABA_CODING_PLAN_API_KEY', base_url_env: 'ALIBABA_CODING_PLAN_BASE_URL' },
anthropic: { api_key_env: 'ANTHROPIC_API_KEY', base_url_env: '' }, anthropic: { api_key_env: 'ANTHROPIC_API_KEY', base_url_env: '' },
xai: { api_key_env: 'XAI_API_KEY', base_url_env: '' }, xai: { api_key_env: 'XAI_API_KEY', base_url_env: '' },
'xai-oauth': { api_key_env: '', base_url_env: '' },
xiaomi: { api_key_env: 'XIAOMI_API_KEY', base_url_env: '' }, xiaomi: { api_key_env: 'XIAOMI_API_KEY', base_url_env: '' },
'xiaomi-token-plan': { api_key_env: '', base_url_env: '' }, 'xiaomi-token-plan': { api_key_env: '', base_url_env: '' },
gemini: { api_key_env: 'GEMINI_API_KEY', base_url_env: '' }, gemini: { api_key_env: 'GEMINI_API_KEY', base_url_env: '' },
@@ -1,7 +1,7 @@
# Agent Bridge # Agent Bridge
Optional backend-side bridge for talking to `~/.hermes/hermes-agent` by Optional backend-side bridge for talking to Hermes Agent by instantiating
instantiating `run_agent.AIAgent` directly in a Python process. `run_agent.AIAgent` directly in a Python process.
This is intentionally separate from the current Web UI chat path. This is intentionally separate from the current Web UI chat path.
@@ -37,6 +37,7 @@ The service discovers Hermes Agent in this order:
3. the installed `hermes` command path 3. the installed `hermes` command path
4. current working directory and parent directories 4. current working directory and parent directories
5. common locations such as `~/.hermes/hermes-agent`, `~/hermes-agent`, and `/opt/hermes-agent` 5. common locations such as `~/.hermes/hermes-agent`, `~/hermes-agent`, and `/opt/hermes-agent`
6. the `hermes-agent` package installed in the selected Python environment
Hermes home is resolved from `--hermes-home`, `HERMES_HOME`, then `~/.hermes`. Hermes home is resolved from `--hermes-home`, `HERMES_HOME`, then `~/.hermes`.
@@ -54,6 +55,12 @@ python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py \
--hermes-home ~/.hermes --hermes-home ~/.hermes
``` ```
If no source checkout containing `run_agent.py` is found, the bridge falls back
to importing `run_agent` from the Python environment. This supports package
installs such as `pip install hermes-agent`. The Node manager prefers the source
checkout's virtualenv when a checkout exists, then the Python interpreter from
the installed `hermes` command, then the system Python.
The socket transport uses Python and Node standard libraries. No ZMQ dependency The socket transport uses Python and Node standard libraries. No ZMQ dependency
is required. is required.
@@ -11,6 +11,7 @@ from __future__ import annotations
import argparse import argparse
import copy import copy
import importlib.util
import json import json
import os import os
import queue import queue
@@ -116,10 +117,17 @@ def _candidate_agent_roots(raw: str | None = None) -> list[Path]:
return unique return unique
def _discover_agent_root(raw: str | None = None) -> Path: def _find_agent_root(raw: str | None = None) -> Path | None:
for candidate in _candidate_agent_roots(raw): for candidate in _candidate_agent_roots(raw):
if (candidate / "run_agent.py").exists(): if (candidate / "run_agent.py").exists():
return candidate return candidate
return None
def _discover_agent_root(raw: str | None = None) -> Path:
root = _find_agent_root(raw)
if root is not None:
return root
attempted = ", ".join(str(path) for path in _candidate_agent_roots(raw)) attempted = ", ".join(str(path) for path in _candidate_agent_roots(raw))
raise RuntimeError( raise RuntimeError(
"hermes-agent run_agent.py not found. Pass --agent-root or set " "hermes-agent run_agent.py not found. Pass --agent-root or set "
@@ -154,8 +162,8 @@ def _jsonable(value: Any) -> Any:
return str(value) return str(value)
def _agent_root() -> Path: def _agent_root() -> Path | None:
return _discover_agent_root(os.environ.get("HERMES_AGENT_ROOT")) return _find_agent_root(os.environ.get("HERMES_AGENT_ROOT"))
def _hermes_home() -> Path: def _hermes_home() -> Path:
@@ -216,7 +224,11 @@ def _profile_dotenv_keys() -> set[str]:
def _set_path_env(agent_root: str | None = None, hermes_home: str | None = None) -> None: def _set_path_env(agent_root: str | None = None, hermes_home: str | None = None) -> None:
os.environ["HERMES_AGENT_ROOT"] = str(_discover_agent_root(agent_root)) resolved_root = _discover_agent_root(agent_root) if agent_root else _find_agent_root()
if resolved_root is not None:
os.environ["HERMES_AGENT_ROOT"] = str(resolved_root)
else:
os.environ.pop("HERMES_AGENT_ROOT", None)
resolved_home = _discover_hermes_home(hermes_home) resolved_home = _discover_hermes_home(hermes_home)
os.environ["HERMES_HOME"] = str(resolved_home) os.environ["HERMES_HOME"] = str(resolved_home)
os.environ["HERMES_AGENT_BRIDGE_BASE_HOME"] = str(_normalize_base_home(resolved_home)) os.environ["HERMES_AGENT_BRIDGE_BASE_HOME"] = str(_normalize_base_home(resolved_home))
@@ -224,11 +236,16 @@ def _set_path_env(agent_root: str | None = None, hermes_home: str | None = None)
def _ensure_agent_imports() -> None: def _ensure_agent_imports() -> None:
root = _agent_root() root = _agent_root()
if not (root / "run_agent.py").exists(): if root is not None:
raise RuntimeError(f"hermes-agent run_agent.py not found under {root}")
root_s = str(root) root_s = str(root)
if root_s not in sys.path: if root_s not in sys.path:
sys.path.insert(0, root_s) sys.path.insert(0, root_s)
elif importlib.util.find_spec("run_agent") is None:
raise RuntimeError(
"hermes-agent run_agent.py not found in source locations and the "
"current Python environment cannot import run_agent. Install "
"hermes-agent or pass --agent-root/HERMES_AGENT_ROOT."
)
os.environ.setdefault("HERMES_HOME", str(_hermes_home())) os.environ.setdefault("HERMES_HOME", str(_hermes_home()))
os.environ.setdefault("HERMES_AGENT_BRIDGE_BASE_HOME", str(_hermes_home())) os.environ.setdefault("HERMES_AGENT_BRIDGE_BASE_HOME", str(_hermes_home()))
@@ -47,6 +47,12 @@ function pathCandidates(agentRoot?: string): string[] {
} }
function uvCandidates(agentRoot?: string): string[] { function uvCandidates(agentRoot?: string): string[] {
if (!agentRoot) {
return [
process.env.HERMES_AGENT_BRIDGE_UV,
process.env.UV,
].filter((value): value is string => !!value && value.trim().length > 0)
}
return [ return [
process.env.HERMES_AGENT_BRIDGE_UV, process.env.HERMES_AGENT_BRIDGE_UV,
process.env.UV, process.env.UV,
@@ -1,5 +1,4 @@
import { resolve, join } from 'path' import { resolve, join } from 'path'
import { homedir } from 'os'
import { readFileSync, existsSync, statSync } from 'fs' import { readFileSync, existsSync, statSync } from 'fs'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import { PROVIDER_PRESETS } from '../../shared/providers' import { PROVIDER_PRESETS } from '../../shared/providers'
@@ -44,6 +43,7 @@ const MODEL_CACHE_PROVIDER_ALIASES: Record<string, string[]> = {
'glm-coding-plan': ['zai-coding-plan'], 'glm-coding-plan': ['zai-coding-plan'],
'kimi-coding': ['kimi-for-coding'], 'kimi-coding': ['kimi-for-coding'],
'kimi-coding-cn': ['kimi-for-coding'], 'kimi-coding-cn': ['kimi-for-coding'],
'xai-oauth': ['xai'],
} }
// --- Config YAML helpers (js-yaml) --- // --- Config YAML helpers (js-yaml) ---
+19 -4
View File
@@ -222,15 +222,31 @@ function extractError(err: any): string {
export async function listHermesPlugins(): Promise<HermesPluginsResponse> { export async function listHermesPlugins(): Promise<HermesPluginsResponse> {
const command = resolveAgentBridgeCommand() const command = resolveAgentBridgeCommand()
const agentRoot = command.agentRoot || '' const agentRoot = command.agentRoot || ''
const env = { const env: NodeJS.ProcessEnv = {
...process.env, ...process.env,
HERMES_AGENT_ROOT_RESOLVED: agentRoot, HERMES_AGENT_ROOT_RESOLVED: agentRoot,
HERMES_HOME: getActiveProfileDir(), HERMES_HOME: getActiveProfileDir(),
} }
if (!agentRoot) {
delete env.PYTHONHOME
delete env.PYTHONPATH
}
const pythonArgs = [
...command.argsPrefix,
...(agentRoot ? ['-I'] : []),
'-c',
PYTHON_BRIDGE,
]
const displayArgs = [
...command.argsPrefix,
...(agentRoot ? ['-I'] : []),
'-c',
'<plugin-discovery>',
].join(' ')
const errors: string[] = [] const errors: string[] = []
try { try {
const { stdout, stderr } = await execFileAsync(command.command, [...command.argsPrefix, '-I', '-c', PYTHON_BRIDGE], { const { stdout, stderr } = await execFileAsync(command.command, pythonArgs, {
cwd: process.cwd(), cwd: process.cwd(),
env, env,
windowsHide: true, windowsHide: true,
@@ -246,8 +262,7 @@ export async function listHermesPlugins(): Promise<HermesPluginsResponse> {
} }
return parsed return parsed
} catch (err: any) { } catch (err: any) {
const args = [...command.argsPrefix, '-I', '-c', '<plugin-discovery>'].join(' ') errors.push(`${command.command} ${displayArgs}: ${extractError(err)}`)
errors.push(`${command.command} ${args}: ${extractError(err)}`)
} }
throw new Error(`Failed to discover Hermes plugins.\n${errors.join('\n')}`) throw new Error(`Failed to discover Hermes plugins.\n${errors.join('\n')}`)
+13 -6
View File
@@ -120,15 +120,22 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
builtin: true, builtin: true,
base_url: 'https://api.x.ai/v1', base_url: 'https://api.x.ai/v1',
models: [ models: [
'grok-4.3',
'grok-4.20-0309-reasoning',
'grok-4.20-0309-non-reasoning',
'grok-4.20-multi-agent-0309',
],
},
{
label: 'xAI Grok OAuth (SuperGrok Subscription)',
value: 'xai-oauth',
builtin: true,
base_url: 'https://api.x.ai/v1',
models: [
'grok-4.3',
'grok-4.20-0309-reasoning', 'grok-4.20-0309-reasoning',
'grok-4.20-0309-non-reasoning', 'grok-4.20-0309-non-reasoning',
'grok-4.20-multi-agent-0309', 'grok-4.20-multi-agent-0309',
'grok-4-1-fast',
'grok-4-1-fast-non-reasoning',
'grok-4-fast',
'grok-4-fast-non-reasoning',
'grok-4',
'grok-code-fast-1',
], ],
}, },
{ {
+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 () => { it('keeps inherited profile env keys for default profile compatibility', async () => {
await mkdir(join(tempDir, 'profiles', 'work'), { recursive: true }) await mkdir(join(tempDir, 'profiles', 'work'), { recursive: true })
await writeFile(join(tempDir, '.env'), 'OPENAI_API_KEY=default-openai\n', 'utf-8') 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(secondArg).toBe('-c')
expect(resolvedRoot).toBe(agentRoot) 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, buildModelGroups: mockBuildModelGroups,
PROVIDER_ENV_MAP: { PROVIDER_ENV_MAP: {
deepseek: { api_key_env: 'DEEPSEEK_API_KEY' }, deepseek: { api_key_env: 'DEEPSEEK_API_KEY' },
'xai-oauth': { api_key_env: '', base_url_env: 'XAI_BASE_URL' },
openrouter: {}, openrouter: {},
}, },
})) }))
@@ -39,6 +40,7 @@ vi.mock('../../packages/server/src/services/config-helpers', () => ({
vi.mock('../../packages/server/src/shared/providers', () => ({ vi.mock('../../packages/server/src/shared/providers', () => ({
buildProviderModelMap: () => ({ buildProviderModelMap: () => ({
deepseek: ['deepseek-chat', 'deepseek-reasoner'], deepseek: ['deepseek-chat', 'deepseek-reasoner'],
'xai-oauth': ['grok-4.3', 'grok-4.20-0309-reasoning'],
openrouter: ['openrouter/auto'], openrouter: ['openrouter/auto'],
}), }),
PROVIDER_PRESETS: [ PROVIDER_PRESETS: [
@@ -54,6 +56,12 @@ vi.mock('../../packages/server/src/shared/providers', () => ({
base_url: 'https://openrouter.ai/api/v1', base_url: 'https://openrouter.ai/api/v1',
models: ['openrouter/auto'], 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 () => { it('fails open for stale include rules so a provider can be recovered in the UI', async () => {