From 53f0301da4981daf997694ac74175c103e49281d Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Sun, 17 May 2026 09:45:56 +0800 Subject: [PATCH] Add Hermes Agent package fallback and xAI OAuth (#808) --- README.md | 8 + README_zh.md | 7 + packages/client/src/api/hermes/xai-auth.ts | 29 ++ .../hermes/models/ProviderFormModal.vue | 34 +- .../hermes/models/XaiOAuthLoginModal.vue | 176 +++++++++ packages/client/src/i18n/locales/en.ts | 5 + packages/client/src/i18n/locales/zh.ts | 5 + .../src/controllers/hermes/providers.ts | 7 +- .../server/src/controllers/hermes/xai-auth.ts | 334 ++++++++++++++++++ packages/server/src/routes/hermes/xai-auth.ts | 8 + packages/server/src/routes/index.ts | 2 + .../server/src/services/config-helpers.ts | 1 + .../services/hermes/agent-bridge/README.md | 11 +- .../hermes/agent-bridge/hermes_bridge.py | 35 +- .../services/hermes/agent-bridge/manager.ts | 6 + .../src/services/hermes/model-context.ts | 2 +- .../server/src/services/hermes/plugins.ts | 23 +- packages/server/src/shared/providers.ts | 19 +- tests/server/agent-bridge-manager.test.ts | 70 ++++ tests/server/agent-bridge-profile-env.test.ts | 45 +++ tests/server/hermes-plugins-env.test.ts | 38 ++ .../model-visibility-controller.test.ts | 32 ++ 22 files changed, 871 insertions(+), 26 deletions(-) create mode 100644 packages/client/src/api/hermes/xai-auth.ts create mode 100644 packages/client/src/components/hermes/models/XaiOAuthLoginModal.vue create mode 100644 packages/server/src/controllers/hermes/xai-auth.ts create mode 100644 packages/server/src/routes/hermes/xai-auth.ts create mode 100644 tests/server/agent-bridge-manager.test.ts diff --git a/README.md b/README.md index 2a40275..f601046 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,14 @@ Open **http://localhost:6060** 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 These variables configure Hermes Web UI itself. Provider API keys and Hermes Agent settings are managed separately through Hermes profiles. diff --git a/README_zh.md b/README_zh.md index dfd974c..ed7b3b8 100644 --- a/README_zh.md +++ b/README_zh.md @@ -213,6 +213,13 @@ docker compose logs -f hermes-webui 更详细的说明与排错见:[`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 环境变量 这些变量只用于配置 Hermes Web UI 自身。Provider API Key 和 Hermes Agent 相关设置仍通过 Hermes profile 管理。 diff --git a/packages/client/src/api/hermes/xai-auth.ts b/packages/client/src/api/hermes/xai-auth.ts new file mode 100644 index 0000000..a26b87e --- /dev/null +++ b/packages/client/src/api/hermes/xai-auth.ts @@ -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 { + return request('/api/hermes/auth/xai/start', { method: 'POST' }) +} + +export async function pollXaiLogin(sessionId: string): Promise { + return request(`/api/hermes/auth/xai/poll/${sessionId}`) +} + +export async function getXaiAuthStatus(): Promise { + return request('/api/hermes/auth/xai/status') +} diff --git a/packages/client/src/components/hermes/models/ProviderFormModal.vue b/packages/client/src/components/hermes/models/ProviderFormModal.vue index c7baf87..daae499 100644 --- a/packages/client/src/components/hermes/models/ProviderFormModal.vue +++ b/packages/client/src/components/hermes/models/ProviderFormModal.vue @@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n' import CodexLoginModal from './CodexLoginModal.vue' import NousLoginModal from './NousLoginModal.vue' import CopilotLoginModal from './CopilotLoginModal.vue' +import XaiOAuthLoginModal from './XaiOAuthLoginModal.vue' import { checkCopilotToken, enableCopilot, type CopilotTokenSource } from '@/api/hermes/copilot-auth' import { fetchProviderModels } from '@/api/hermes/system' @@ -26,6 +27,7 @@ const fetchingModels = ref(false) const showCodexLogin = ref(false) const showNousLogin = ref(false) const showCopilotLogin = ref(false) +const showXaiLogin = ref(false) const copilotChecking = ref(false) const providerType = ref<'preset' | 'custom'>('preset') @@ -44,6 +46,7 @@ const CODEX_KEY = 'openai-codex' const NOUS_KEY = 'nous' const COPILOT_KEY = 'copilot' const CLIPROXYAPI_KEY = 'cliproxyapi' +const XAI_OAUTH_KEY = 'xai-oauth' const ALIBABA_CODING_KEY = 'alibaba-coding-plan' const ALIBABA_CODING_REGIONS = { 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 isCopilot = computed(() => selectedPreset.value === COPILOT_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 alibabaCodingRegion = ref<'intl' | 'cn'>('intl') @@ -93,6 +97,8 @@ watch(selectedPreset, (val) => { if (val === COPILOT_KEY) { // 判断是否已能解析到 token:有 → 弹简单确认;无 → 走 in-app device flow void triggerCopilotAdd() + } else if (val === XAI_OAUTH_KEY) { + showXaiLogin.value = true } } }) @@ -170,11 +176,16 @@ async function handleSave() { return } + if (isXaiOAuth.value) { + showXaiLogin.value = true + return + } + if (!formData.value.base_url.trim()) { message.warning(t('models.baseUrlRequired')) return } - if (!formData.value.api_key.trim() && !isCliproxyApi.value) { + if (!formData.value.api_key.trim() && !isCliproxyApi.value && !isXaiOAuth.value) { message.warning(t('models.apiKeyRequired')) return } @@ -225,6 +236,12 @@ async function handleCopilotSuccess() { emit('saved') } +async function handleXaiSuccess() { + showXaiLogin.value = false + message.success(t('models.providerAdded')) + emit('saved') +} + function copilotSourceLabel(source: CopilotTokenSource): string { if (source === 'env') return t('models.copilotAddSourceEnv') if (source === 'gh-cli') return t('models.copilotAddSourceGhCli') @@ -281,6 +298,11 @@ function handleCopilotClose() { selectedPreset.value = null } +function handleXaiClose() { + showXaiLogin.value = false + selectedPreset.value = null +} + function handleClose() { showModal.value = false setTimeout(() => emit('close'), 200) @@ -293,7 +315,7 @@ function handleClose() { preset="card" :title="t('models.addProvider')" :style="{ width: 'min(520px, calc(100vw - 32px))' }" - :mask-closable="!loading && !showCodexLogin && !showNousLogin && !showCopilotLogin" + :mask-closable="!loading && !showCodexLogin && !showNousLogin && !showCopilotLogin && !showXaiLogin" @after-leave="emit('close')" > @@ -353,7 +375,7 @@ function handleClose() { /> - + + + diff --git a/packages/client/src/components/hermes/models/XaiOAuthLoginModal.vue b/packages/client/src/components/hermes/models/XaiOAuthLoginModal.vue new file mode 100644 index 0000000..836c44a --- /dev/null +++ b/packages/client/src/components/hermes/models/XaiOAuthLoginModal.vue @@ -0,0 +1,176 @@ + + + + + diff --git a/packages/client/src/i18n/locales/en.ts b/packages/client/src/i18n/locales/en.ts index 4273fc6..289d204 100644 --- a/packages/client/src/i18n/locales/en.ts +++ b/packages/client/src/i18n/locales/en.ts @@ -573,6 +573,11 @@ export default { 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.', 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', previewBadge: 'PREVIEW', disabledBadge: 'UNAVAILABLE', diff --git a/packages/client/src/i18n/locales/zh.ts b/packages/client/src/i18n/locales/zh.ts index e9675eb..0a5e36e 100644 --- a/packages/client/src/i18n/locales/zh.ts +++ b/packages/client/src/i18n/locales/zh.ts @@ -573,6 +573,11 @@ export default { copilotDeleteHintEnv: '此操作会清除 ~/.hermes/.env 中的 COPILOT_GITHUB_TOKEN,不影响其他工具。', copilotDeleteHintGhCli: 'Copilot 将从 Hermes 列表移除。不会影响 gh CLI —— `gh auth status` 仍显示已登录。', copilotDeleteHintAppsJson: 'Copilot 将从 Hermes 列表移除。不会影响 VS Code Copilot 插件的登录。', + xaiLoginTitle: 'xAI Grok OAuth 登录', + xaiWaiting: '请在打开的 xAI 页面完成授权。授权完成后窗口会自动关闭。', + xaiOpenLink: '打开 xAI 授权页', + xaiApproved: '登录成功!', + xaiExpired: '授权链接已过期,请重试。', customBadge: '自定义', previewBadge: '预览', disabledBadge: '不可用', diff --git a/packages/server/src/controllers/hermes/providers.ts b/packages/server/src/controllers/hermes/providers.ts index 9f1319a..75d0898 100644 --- a/packages/server/src/controllers/hermes/providers.ts +++ b/packages/server/src/controllers/hermes/providers.ts @@ -6,7 +6,8 @@ import { updateConfigYaml, saveEnvValue, PROVIDER_ENV_MAP } from '../../services import { PROVIDER_PRESETS } from '../../shared/providers' 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) { 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) } config.model.default = model 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 { if (!Array.isArray(config.custom_providers)) { config.custom_providers = [] } const existing = (config.custom_providers as any[]).find( diff --git a/packages/server/src/controllers/hermes/xai-auth.ts b/packages/server/src/controllers/hermes/xai-auth.ts new file mode 100644 index 0000000..5a412a2 --- /dev/null +++ b/packages/server/src/controllers/hermes/xai-auth.ts @@ -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 + server: Server + error?: string + createdAt: number +} + +interface AuthJson { + version?: number + active_provider?: string + providers?: Record + credential_pool?: Record + updated_at?: string +} + +const sessions = new Map() + +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 + 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 = { + 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> { + 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 + 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('

xAI authorization received.

You can close this tab.') + + 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 } + } +} diff --git a/packages/server/src/routes/hermes/xai-auth.ts b/packages/server/src/routes/hermes/xai-auth.ts new file mode 100644 index 0000000..cc15f6d --- /dev/null +++ b/packages/server/src/routes/hermes/xai-auth.ts @@ -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) diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index 24e10da..76064bd 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -20,6 +20,7 @@ import { logRoutes } from './hermes/logs' import { codexAuthRoutes } from './hermes/codex-auth' import { nousAuthRoutes } from './hermes/nous-auth' import { copilotAuthRoutes } from './hermes/copilot-auth' +import { xaiAuthRoutes } from './hermes/xai-auth' import { gatewayRoutes } from './hermes/gateways' import { weixinRoutes } from './hermes/weixin' 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(nousAuthRoutes.routes()) app.use(copilotAuthRoutes.routes()) + app.use(xaiAuthRoutes.routes()) app.use(gatewayRoutes.routes()) app.use(weixinRoutes.routes()) app.use(groupChatRoutes.routes()) // Must be before proxy diff --git a/packages/server/src/services/config-helpers.ts b/packages/server/src/services/config-helpers.ts index 2b45fe2..4181809 100644 --- a/packages/server/src/services/config-helpers.ts +++ b/packages/server/src/services/config-helpers.ts @@ -22,6 +22,7 @@ export const PROVIDER_ENV_MAP: Record list[Path]: 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): if (candidate / "run_agent.py").exists(): 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)) raise RuntimeError( "hermes-agent run_agent.py not found. Pass --agent-root or set " @@ -154,8 +162,8 @@ def _jsonable(value: Any) -> Any: return str(value) -def _agent_root() -> Path: - return _discover_agent_root(os.environ.get("HERMES_AGENT_ROOT")) +def _agent_root() -> Path | None: + return _find_agent_root(os.environ.get("HERMES_AGENT_ROOT")) 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: - 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) os.environ["HERMES_HOME"] = str(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: root = _agent_root() - if not (root / "run_agent.py").exists(): - raise RuntimeError(f"hermes-agent run_agent.py not found under {root}") - root_s = str(root) - if root_s not in sys.path: - sys.path.insert(0, root_s) + if root is not None: + root_s = str(root) + if root_s not in sys.path: + 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_AGENT_BRIDGE_BASE_HOME", str(_hermes_home())) diff --git a/packages/server/src/services/hermes/agent-bridge/manager.ts b/packages/server/src/services/hermes/agent-bridge/manager.ts index a43e7cf..6644e4b 100644 --- a/packages/server/src/services/hermes/agent-bridge/manager.ts +++ b/packages/server/src/services/hermes/agent-bridge/manager.ts @@ -47,6 +47,12 @@ function pathCandidates(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 [ process.env.HERMES_AGENT_BRIDGE_UV, process.env.UV, diff --git a/packages/server/src/services/hermes/model-context.ts b/packages/server/src/services/hermes/model-context.ts index 1a04bb4..c688fde 100644 --- a/packages/server/src/services/hermes/model-context.ts +++ b/packages/server/src/services/hermes/model-context.ts @@ -1,5 +1,4 @@ import { resolve, join } from 'path' -import { homedir } from 'os' import { readFileSync, existsSync, statSync } from 'fs' import yaml from 'js-yaml' import { PROVIDER_PRESETS } from '../../shared/providers' @@ -44,6 +43,7 @@ const MODEL_CACHE_PROVIDER_ALIASES: Record = { 'glm-coding-plan': ['zai-coding-plan'], 'kimi-coding': ['kimi-for-coding'], 'kimi-coding-cn': ['kimi-for-coding'], + 'xai-oauth': ['xai'], } // --- Config YAML helpers (js-yaml) --- diff --git a/packages/server/src/services/hermes/plugins.ts b/packages/server/src/services/hermes/plugins.ts index e3adb38..fba4f9c 100644 --- a/packages/server/src/services/hermes/plugins.ts +++ b/packages/server/src/services/hermes/plugins.ts @@ -222,15 +222,31 @@ function extractError(err: any): string { export async function listHermesPlugins(): Promise { const command = resolveAgentBridgeCommand() const agentRoot = command.agentRoot || '' - const env = { + const env: NodeJS.ProcessEnv = { ...process.env, HERMES_AGENT_ROOT_RESOLVED: agentRoot, 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', + '', + ].join(' ') const errors: string[] = [] 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(), env, windowsHide: true, @@ -246,8 +262,7 @@ export async function listHermesPlugins(): Promise { } return parsed } catch (err: any) { - const args = [...command.argsPrefix, '-I', '-c', ''].join(' ') - errors.push(`${command.command} ${args}: ${extractError(err)}`) + errors.push(`${command.command} ${displayArgs}: ${extractError(err)}`) } throw new Error(`Failed to discover Hermes plugins.\n${errors.join('\n')}`) diff --git a/packages/server/src/shared/providers.ts b/packages/server/src/shared/providers.ts index b92d9b3..83c7b29 100644 --- a/packages/server/src/shared/providers.ts +++ b/packages/server/src/shared/providers.ts @@ -120,15 +120,22 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ builtin: true, base_url: 'https://api.x.ai/v1', 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-non-reasoning', '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', ], }, { diff --git a/tests/server/agent-bridge-manager.test.ts b/tests/server/agent-bridge-manager.test.ts new file mode 100644 index 0000000..e21d439 --- /dev/null +++ b/tests/server/agent-bridge-manager.test.ts @@ -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, + }) + }) +}) diff --git a/tests/server/agent-bridge-profile-env.test.ts b/tests/server/agent-bridge-profile-env.test.ts index 4d1e792..f619480 100644 --- a/tests/server/agent-bridge-profile-env.test.ts +++ b/tests/server/agent-bridge-profile-env.test.ts @@ -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') diff --git a/tests/server/hermes-plugins-env.test.ts b/tests/server/hermes-plugins-env.test.ts index 4c1e7cc..e82a206 100644 --- a/tests/server/hermes-plugins-env.test.ts +++ b/tests/server/hermes-plugins-env.test.ts @@ -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') + }) }) diff --git a/tests/server/model-visibility-controller.test.ts b/tests/server/model-visibility-controller.test.ts index e5cadb6..0f2e2f6 100644 --- a/tests/server/model-visibility-controller.test.ts +++ b/tests/server/model-visibility-controller.test.ts @@ -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 () => {