Merge remote-tracking branch 'upstream/main' into HEAD

# Conflicts:
#	scripts/release.py
This commit is contained in:
kshitijk4poor 2026-07-03 03:52:15 +05:30
commit 0950dae2fa
99 changed files with 3685 additions and 3296 deletions

View file

@ -4207,7 +4207,7 @@ def resolve_provider_client(
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
# ── xAI Grok OAuth (loopback PKCE → Responses API) ───────────────
# ── xAI Grok OAuth (device code → Responses API) ───────────────
# Without this branch, an xai-oauth main provider falls through to the
# generic ``oauth_external`` arm below and returns ``(None, None)``,
# silently re-routing every auxiliary task (compression, web extract,

View file

@ -22,7 +22,7 @@ _PERSISTABLE_PROVIDER_SOURCES = frozenset({
("minimax-oauth", "oauth"),
("nous", "device_code"),
("openai-codex", "device_code"),
("xai-oauth", "loopback_pkce"),
("xai-oauth", "device_code"),
})
_SAFE_SECRETISH_METADATA_KEYS = frozenset({

View file

@ -82,7 +82,7 @@ _TERMINAL_AUTH_REASONS = frozenset({
# without losing recoverability — the user always has the option to re-add
# via ``hermes auth add``.
#
# Singleton-seeded entries (``device_code``, ``loopback_pkce``, ``claude_code``)
# Singleton-seeded entries (``device_code``, ``claude_code``)
# are NOT pruned because ``_seed_from_singletons`` would just re-create them
# on the next ``load_pool()`` with the same stale singleton tokens, defeating
# the cleanup. They remain in the pool marked DEAD until an explicit re-auth
@ -724,11 +724,11 @@ class CredentialPool:
keeps the consumed refresh_token and the next ``_refresh_entry`` call
would replay it and get a ``refresh_token_reused``-style 4xx.
Only applies to entries seeded from the singleton (``loopback_pkce``);
manually added entries (``manual:xai_pkce``) are independent
credentials with their own refresh-token lifecycle.
Only applies to entries seeded from the singleton (``device_code``);
manually added entries are independent credentials with their own
refresh-token lifecycle.
"""
if self.provider != "xai-oauth" or entry.source != "loopback_pkce":
if self.provider != "xai-oauth" or entry.source != "device_code":
return entry
try:
with _auth_store_lock():
@ -868,8 +868,9 @@ class CredentialPool:
"""
# Only sync entries that were seeded *from* a singleton. Manually
# added pool entries (source="manual:*") are independent credentials
# and must not write back to the singleton.
if entry.source not in {"device_code", "loopback_pkce"}:
# and must not write back to the singleton. All singleton-seeded
# device-code sources (nous, openai-codex, xAI) use ``device_code``.
if entry.source != "device_code":
return
try:
with _auth_store_lock():
@ -1112,8 +1113,8 @@ class CredentialPool:
# consumed the refresh token between our proactive sync and the
# HTTP call. Re-check auth.json and adopt the fresh tokens if
# they have rotated since. Only meaningful for singleton-seeded
# (loopback_pkce) entries; manual entries don't share state with
# the singleton.
# (device_code) entries; manual entries don't share
# state with the singleton.
if self.provider == "xai-oauth":
synced = self._sync_xai_oauth_entry_from_auth_store(entry)
if synced.refresh_token != entry.refresh_token:
@ -1135,8 +1136,8 @@ class CredentialPool:
# Terminal error: auth.json has no newer tokens — the stored
# refresh_token is dead. Clear it from auth.json so the next
# session does not re-seed the same revoked credentials, and
# remove all singleton-seeded (loopback_pkce) entries from the
# in-memory pool. Mirrors the Nous quarantine path above.
# remove all singleton-seeded xAI entries from the in-memory
# pool. Mirrors the Nous quarantine path above.
if auth_mod._is_terminal_xai_oauth_refresh_error(exc):
logger.debug(
"xAI OAuth refresh token is terminally invalid; clearing local token state"
@ -1170,11 +1171,11 @@ class CredentialPool:
)
removed_ids = [
item.id for item in self._entries
if item.source == "loopback_pkce"
if item.source == "device_code"
]
self._entries = [
item for item in self._entries
if item.source != "loopback_pkce"
if item.source != "device_code"
]
if self._current_id == entry.id:
self._current_id = None
@ -1352,7 +1353,7 @@ class CredentialPool:
if self.provider == "xai-oauth":
return auth_mod._xai_access_token_is_expiring(
entry.access_token,
auth_mod.XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
auth_mod._xai_proactive_refresh_skew_seconds(entry.access_token),
)
if self.provider == "nous":
# Nous refresh can require network access and should happen when
@ -1414,7 +1415,7 @@ class CredentialPool:
# tokens that another process (or a fresh `hermes model` ->
# xAI Grok OAuth login) has since rotated in auth.json.
if (self.provider == "xai-oauth"
and entry.source == "loopback_pkce"
and entry.source == "device_code"
and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):
synced = self._sync_xai_oauth_entry_from_auth_store(entry)
if synced is not entry:
@ -2064,28 +2065,30 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
# (``providers["xai-oauth"]``). Surface them in the pool too so
# ``hermes auth list`` reflects the logged-in state and so the pool
# is the single source of truth for refresh during runtime resolution.
if _is_suppressed(provider, "loopback_pkce"):
return changed, active_sources
state = _load_provider_state(auth_store, "xai-oauth")
tokens = state.get("tokens") if isinstance(state, dict) else None
if isinstance(tokens, dict) and tokens.get("access_token"):
active_sources.add("loopback_pkce")
# Device code is the only supported xAI OAuth flow; the singleton is
# always surfaced as ``device_code`` (consistent with nous/codex).
source = "device_code"
if _is_suppressed(provider, source):
return changed, active_sources
active_sources.add(source)
from hermes_cli.auth import DEFAULT_XAI_OAUTH_BASE_URL
base_url = DEFAULT_XAI_OAUTH_BASE_URL
changed |= _upsert_entry(
entries,
provider,
"loopback_pkce",
source,
{
"source": "loopback_pkce",
"source": source,
"auth_type": AUTH_TYPE_OAUTH,
"access_token": tokens.get("access_token", ""),
"refresh_token": tokens.get("refresh_token"),
"base_url": base_url,
"last_refresh": state.get("last_refresh"),
"label": label_from_token(tokens.get("access_token", ""), "loopback_pkce"),
"label": label_from_token(tokens.get("access_token", ""), source),
},
)

View file

@ -265,7 +265,7 @@ def _remove_minimax_oauth(provider: str, removed) -> RemovalResult:
return result
def _remove_xai_oauth_loopback_pkce(provider: str, removed) -> RemovalResult:
def _remove_xai_oauth_device_code(provider: str, removed) -> RemovalResult:
"""xAI OAuth tokens live in auth.json providers.xai-oauth — clear them.
Without this step, ``hermes auth remove xai-oauth <N>`` silently undoes
@ -275,11 +275,6 @@ def _remove_xai_oauth_loopback_pkce(provider: str, removed) -> RemovalResult:
entry from the still-present singleton credentials reappear with no
user feedback. Clearing the singleton in step with the suppression set
by the central dispatcher makes the removal stick.
Belt-and-braces against the manual entry path: ``hermes auth add
xai-oauth`` produces a ``manual:xai_pkce`` entry whose removal step
falls through to "unregistered → nothing to clean up" (correct
manual entries are pool-only).
"""
result = RemovalResult()
if _clear_auth_store_provider(provider):
@ -423,8 +418,8 @@ def _register_all_sources() -> None:
description="auth.json providers.openai-codex + ~/.codex/auth.json",
))
register(RemovalStep(
provider="xai-oauth", source_id="loopback_pkce",
remove_fn=_remove_xai_oauth_loopback_pkce,
provider="xai-oauth", source_id="device_code",
remove_fn=_remove_xai_oauth_device_code,
description="auth.json providers.xai-oauth",
))
register(RemovalStep(

View file

@ -820,9 +820,22 @@ def normalize_usage(
input_tokens = max(0, prompt_total - cache_read_tokens - cache_write_tokens)
reasoning_tokens = 0
# Responses API shape: output_tokens_details.reasoning_tokens.
# Chat Completions shape (OpenAI, OpenRouter, DeepSeek, etc.):
# completion_tokens_details.reasoning_tokens. Reading only the former
# left reasoning_tokens=0 for every chat_completions reasoning model —
# hidden thinking was invisible in session accounting even though it
# dominates output spend on models like deepseek-v4-flash (measured:
# single calls burning 21K reasoning tokens to emit 500 visible tokens).
output_details = getattr(response_usage, "output_tokens_details", None)
if output_details:
reasoning_tokens = _to_int(getattr(output_details, "reasoning_tokens", 0))
if not reasoning_tokens:
completion_details = getattr(response_usage, "completion_tokens_details", None)
if completion_details:
reasoning_tokens = _to_int(
getattr(completion_details, "reasoning_tokens", 0)
)
return CanonicalUsage(
input_tokens=input_tokens,

View file

@ -5419,19 +5419,24 @@ function profileNameFromDeleteRequest(request) {
return name.toLowerCase()
}
// Returns the profile name whose backend was torn down, or null when the
// request is not a profile-delete. The caller uses this to skip ensureBackend
// for the just-torn-down profile — otherwise ensureBackend respawns a pool
// backend whose ensure_hermes_home() recreates the deleted profile directory.
async function prepareProfileDeleteRequest(request) {
const profile = profileNameFromDeleteRequest(request)
if (!profile || profile === 'default' || !PROFILE_NAME_RE.test(profile)) {
return
return null
}
if (profile === primaryProfileKey()) {
writeActiveDesktopProfile('default')
await teardownPrimaryBackendAndWait()
return
return profile
}
await teardownPoolBackendAndWait(profile)
return profile
}
async function startHermes() {
@ -6465,10 +6470,15 @@ ipcMain.handle('hermes:api', async (_event, request) => {
return rerouted
}
await prepareProfileDeleteRequest(request)
const tornDownProfile = await prepareProfileDeleteRequest(request)
const profile = request?.profile
const connection = await ensureBackend(profile)
// After tearing down a backend for profile deletion, route to the primary
// backend instead of spawning a fresh pool backend. A freshly spawned
// backend calls ensure_hermes_home() which recreates the profile directory,
// defeating the deletion and leaving a zombie process.
const routeProfile = tornDownProfile ? null : profile
const connection = await ensureBackend(routeProfile)
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
const requestPath = pathWithGlobalRemoteProfile(request.path, profile, {
globalRemote: globalRemoteActive(),

View file

@ -0,0 +1,66 @@
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const ELECTRON_DIR = __dirname
function readElectronFile(name) {
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
}
// ---------------------------------------------------------------------------
// prepareProfileDeleteRequest must return the torn-down profile name so the
// caller can skip ensureBackend for that profile (issue #52279).
// ---------------------------------------------------------------------------
test('prepareProfileDeleteRequest returns the torn-down profile name', () => {
const source = readElectronFile('main.cjs')
// Locate the function definition and its closing brace.
const fnStart = source.indexOf('async function prepareProfileDeleteRequest(')
assert.notEqual(fnStart, -1, 'prepareProfileDeleteRequest function not found')
// The function must contain "return profile" (pool and primary paths).
const fnBody = source.slice(fnStart, fnStart + 800)
const returnProfileCount = (fnBody.match(/return profile/g) || []).length
assert.ok(
returnProfileCount >= 2,
`expected at least 2 "return profile" statements (primary + pool paths), found ${returnProfileCount}`
)
// The early-exit guard must return null (not void/undefined).
assert.match(
fnBody,
/return null/,
'early-exit guard should return null, not undefined'
)
})
test('hermes:api handler routes profile-delete requests to the primary backend', () => {
const source = readElectronFile('main.cjs')
// The handler must capture prepareProfileDeleteRequest's return value.
assert.match(
source,
/const tornDownProfile = await prepareProfileDeleteRequest\(request\)/,
'handler should capture the return value of prepareProfileDeleteRequest'
)
// The handler must use the return value to skip ensureBackend for the
// torn-down profile, routing to the primary (null) instead.
assert.match(
source,
/const routeProfile = tornDownProfile \? null : profile/,
'handler should route to primary backend when a profile was just torn down'
)
// ensureBackend must be called with the conditional route profile.
assert.match(
source,
/const connection = await ensureBackend\(routeProfile\)/,
'handler should pass routeProfile (not raw profile) to ensureBackend'
)
})

View file

@ -0,0 +1,282 @@
import { readDesktopFileDataUrl } from '@/lib/desktop-fs'
import { filePathFromMediaPath, isRemoteGateway, mediaExternalUrl } from '@/lib/media'
import type { SessionInfo, SessionMessage } from '@/types/hermes'
export type ArtifactKind = 'image' | 'file' | 'link'
export type ArtifactFilter = 'all' | ArtifactKind
export const ARTIFACT_FILTERS: readonly ArtifactFilter[] = ['all', 'image', 'file', 'link']
export interface ArtifactRecord {
id: string
kind: ArtifactKind
value: string
href: string
label: string
sessionId: string
sessionTitle: string
timestamp: number
}
const MARKDOWN_IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g
const MARKDOWN_LINK_RE = /\[([^\]]+)\]\(([^)\s]+)\)/g
const URL_RE = /https?:\/\/[^\s<>"')]+/g
const PATH_RE = /(^|[\s("'`])((?:\/|~\/|\.\.?\/)[^\s"'`<>]+(?:\.[a-z0-9]{1,8})?)/gi
const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp|svg|bmp)(?:\?.*)?$/i
const FILE_EXT_RE = /\.(?:png|jpe?g|gif|webp|svg|bmp|pdf|txt|json|md|csv|zip|tar|gz|mp3|wav|mp4|mov)(?:\?.*)?$/i
const KEY_HINT_RE = /(path|file|url|image|artifact|output|download|result|target)/i
function artifactSessionTitle(session: SessionInfo): string {
return session.title?.trim() || session.preview?.trim() || 'Untitled session'
}
function normalizeValue(value: string): string {
return value.trim().replace(/[),.;]+$/, '')
}
function parseMaybeJson(value: string): unknown {
if (!value.trim()) {
return null
}
try {
return JSON.parse(value)
} catch {
return null
}
}
function looksLikePathOrUrl(value: string): boolean {
return (
value.startsWith('http://') ||
value.startsWith('https://') ||
value.startsWith('file://') ||
value.startsWith('data:image/') ||
value.startsWith('/') ||
value.startsWith('./') ||
value.startsWith('../') ||
value.startsWith('~/')
)
}
function looksLikeArtifact(value: string): boolean {
if (/^(?:https?:\/\/|data:image\/)/.test(value)) {
return true
}
if (looksLikePathOrUrl(value) && (IMAGE_EXT_RE.test(value) || FILE_EXT_RE.test(value))) {
return true
}
return value.startsWith('/') && value.includes('.')
}
function artifactKind(value: string): ArtifactKind {
if (value.startsWith('data:image/') || IMAGE_EXT_RE.test(value)) {
return 'image'
}
if (
value.startsWith('/') ||
value.startsWith('./') ||
value.startsWith('../') ||
value.startsWith('~/') ||
value.startsWith('file://')
) {
return 'file'
}
return 'link'
}
function artifactHref(value: string): string {
if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('data:')) {
return value
}
if (value.startsWith('file://') || value.startsWith('/')) {
return mediaExternalUrl(value)
}
return value
}
export async function artifactImageSrc(value: string, href = artifactHref(value)): Promise<string> {
if (/^(?:https?|data):/i.test(value)) {
return href
}
if (typeof window !== 'undefined' && window.hermesDesktop && isRemoteGateway()) {
return readDesktopFileDataUrl(filePathFromMediaPath(value))
}
return href
}
function artifactLabel(value: string): string {
try {
const url = new URL(value)
const item = url.pathname.split('/').filter(Boolean).pop()
return item || value
} catch {
const parts = value.split(/[\\/]/).filter(Boolean)
return parts.pop() || value
}
}
function messageText(message: SessionMessage): string {
if (typeof message.content === 'string' && message.content.trim()) {
return message.content
}
if (typeof message.text === 'string' && message.text.trim()) {
return message.text
}
if (typeof message.context === 'string' && message.context.trim()) {
return message.context
}
return ''
}
function collectStringValues(
value: unknown,
keyPath: string,
collector: (value: string, keyPath: string) => void
): void {
if (typeof value === 'string') {
collector(value, keyPath)
return
}
if (Array.isArray(value)) {
value.forEach((entry, index) => collectStringValues(entry, `${keyPath}.${index}`, collector))
return
}
if (!value || typeof value !== 'object') {
return
}
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
collectStringValues(child, keyPath ? `${keyPath}.${key}` : key, collector)
}
}
function collectArtifactsFromText(text: string, pushValue: (value: string) => void): void {
for (const match of text.matchAll(MARKDOWN_IMAGE_RE)) {
pushValue(match[2] || '')
}
for (const match of text.matchAll(MARKDOWN_LINK_RE)) {
const start = match.index ?? 0
if (start > 0 && text[start - 1] === '!') {
continue
}
const value = match[2] || ''
if (looksLikeArtifact(value)) {
pushValue(value)
}
}
for (const match of text.matchAll(URL_RE)) {
const value = match[0] || ''
if (looksLikeArtifact(value)) {
pushValue(value)
}
}
for (const match of text.matchAll(PATH_RE)) {
pushValue(match[2] || '')
}
}
function collectArtifactsFromMessage(message: SessionMessage, pushValue: (value: string) => void): void {
const text = messageText(message)
if (text) {
collectArtifactsFromText(text, pushValue)
}
if (message.role !== 'tool' && !Array.isArray(message.tool_calls)) {
return
}
if (Array.isArray(message.tool_calls)) {
for (const call of message.tool_calls) {
collectStringValues(call, 'tool_call', (value, keyPath) => {
const normalized = normalizeValue(value)
if (!normalized) {
return
}
if (KEY_HINT_RE.test(keyPath) && (looksLikePathOrUrl(normalized) || FILE_EXT_RE.test(normalized))) {
pushValue(normalized)
}
})
}
}
const parsed = parseMaybeJson(text)
if (parsed !== null) {
collectStringValues(parsed, 'tool_result', (value, keyPath) => {
const normalized = normalizeValue(value)
if (!normalized) {
return
}
if ((KEY_HINT_RE.test(keyPath) || looksLikePathOrUrl(normalized)) && looksLikeArtifact(normalized)) {
pushValue(normalized)
}
})
}
}
export function collectArtifactsForSession(session: SessionInfo, messages: SessionMessage[]): ArtifactRecord[] {
const found = new Map<string, ArtifactRecord>()
const title = artifactSessionTitle(session)
for (const message of messages) {
if (message.role !== 'assistant' && message.role !== 'tool') {
continue
}
collectArtifactsFromMessage(message, candidate => {
const value = normalizeValue(candidate)
if (!value || !looksLikeArtifact(value)) {
return
}
const key = `${session.id}:${value}`
if (found.has(key)) {
return
}
found.set(key, {
id: key,
kind: artifactKind(value),
value,
href: artifactHref(value),
label: artifactLabel(value),
sessionId: session.id,
sessionTitle: title,
timestamp: message.timestamp || session.last_active || session.started_at || Date.now()
})
})
}
return Array.from(found.values())
}

View file

@ -1,8 +1,9 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import type { SessionInfo, SessionMessage } from '@/types/hermes'
import { collectArtifactsForSession } from './index'
import { artifactImageSrc, collectArtifactsForSession } from './artifact-utils'
function makeSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
@ -24,6 +25,12 @@ function makeSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
}
describe('collectArtifactsForSession', () => {
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
$connection.set(null)
})
it('indexes plain https links from assistant text', () => {
const artifacts = collectArtifactsForSession(makeSession(), [
{
@ -59,4 +66,26 @@ describe('collectArtifactsForSession', () => {
value: 'https://example.com/changelog/latest'
})
})
it('resolves remote image artifact thumbnails through the desktop fs bridge', async () => {
const api = vi.fn(async ({ path }: { path: string }) => {
if (path.startsWith('/api/fs/read-data-url?')) {
return { dataUrl: 'data:image/jpeg;base64,cmVtb3Rl' }
}
throw new Error(`unexpected path ${path}`)
})
vi.stubGlobal('window', { hermesDesktop: { api } })
$connection.set({ baseUrl: 'https://gw', mode: 'remote', token: 'secret' } as never)
const path = '/Users/me/.hermes/skills/work-esab/references/images/manual-step03.jpeg'
const downloadHref = `https://gw/api/files/download?path=${encodeURIComponent(path)}&token=secret`
await expect(artifactImageSrc(path, downloadHref)).resolves.toBe('data:image/jpeg;base64,cmVtb3Rl')
expect(api).toHaveBeenCalledWith({
path: '/api/fs/read-data-url?path=%2FUsers%2Fme%2F.hermes%2Fskills%2Fwork-esab%2Freferences%2Fimages%2Fmanual-step03.jpeg'
})
})
})

View file

@ -21,13 +21,10 @@ import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { Tip } from '@/components/ui/tooltip'
import { getSessionMessages, listAllProfileSessions } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons'
import { mediaExternalUrl } from '@/lib/media'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import type { SessionInfo, SessionMessage } from '@/types/hermes'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@ -36,28 +33,13 @@ import { PageSearchShell } from '../page-search-shell'
import { sessionRoute } from '../routes'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
type ArtifactKind = 'image' | 'file' | 'link'
type ArtifactFilter = 'all' | ArtifactKind
const ARTIFACT_FILTERS: readonly ArtifactFilter[] = ['all', 'image', 'file', 'link']
interface ArtifactRecord {
id: string
kind: ArtifactKind
value: string
href: string
label: string
sessionId: string
sessionTitle: string
timestamp: number
}
const MARKDOWN_IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g
const MARKDOWN_LINK_RE = /\[([^\]]+)\]\(([^)\s]+)\)/g
const URL_RE = /https?:\/\/[^\s<>"')]+/g
const PATH_RE = /(^|[\s("'`])((?:\/|~\/|\.\.?\/)[^\s"'`<>]+(?:\.[a-z0-9]{1,8})?)/gi
const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp|svg|bmp)(?:\?.*)?$/i
const FILE_EXT_RE = /\.(?:png|jpe?g|gif|webp|svg|bmp|pdf|txt|json|md|csv|zip|tar|gz|mp3|wav|mp4|mov)(?:\?.*)?$/i
const KEY_HINT_RE = /(path|file|url|image|artifact|output|download|result|target)/i
import {
ARTIFACT_FILTERS,
type ArtifactFilter,
artifactImageSrc,
type ArtifactRecord,
collectArtifactsForSession
} from './artifact-utils'
const ARTIFACT_TIME_FMT = new Intl.DateTimeFormat(undefined, {
day: 'numeric',
@ -66,246 +48,6 @@ const ARTIFACT_TIME_FMT = new Intl.DateTimeFormat(undefined, {
month: 'short'
})
function normalizeValue(value: string): string {
return value.trim().replace(/[),.;]+$/, '')
}
function parseMaybeJson(value: string): unknown {
if (!value.trim()) {
return null
}
try {
return JSON.parse(value)
} catch {
return null
}
}
function looksLikePathOrUrl(value: string): boolean {
return (
value.startsWith('http://') ||
value.startsWith('https://') ||
value.startsWith('file://') ||
value.startsWith('data:image/') ||
value.startsWith('/') ||
value.startsWith('./') ||
value.startsWith('../') ||
value.startsWith('~/')
)
}
function looksLikeArtifact(value: string): boolean {
if (/^(?:https?:\/\/|data:image\/)/.test(value)) {
return true
}
if (looksLikePathOrUrl(value) && (IMAGE_EXT_RE.test(value) || FILE_EXT_RE.test(value))) {
return true
}
return value.startsWith('/') && value.includes('.')
}
function artifactKind(value: string): ArtifactKind {
if (value.startsWith('data:image/') || IMAGE_EXT_RE.test(value)) {
return 'image'
}
if (
value.startsWith('/') ||
value.startsWith('./') ||
value.startsWith('../') ||
value.startsWith('~/') ||
value.startsWith('file://')
) {
return 'file'
}
return 'link'
}
function artifactHref(value: string): string {
if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('data:')) {
return value
}
if (value.startsWith('file://') || value.startsWith('/')) {
return mediaExternalUrl(value)
}
return value
}
function artifactLabel(value: string): string {
try {
const url = new URL(value)
const item = url.pathname.split('/').filter(Boolean).pop()
return item || value
} catch {
const parts = value.split(/[\\/]/).filter(Boolean)
return parts.pop() || value
}
}
function messageText(message: SessionMessage): string {
if (typeof message.content === 'string' && message.content.trim()) {
return message.content
}
if (typeof message.text === 'string' && message.text.trim()) {
return message.text
}
if (typeof message.context === 'string' && message.context.trim()) {
return message.context
}
return ''
}
function collectStringValues(
value: unknown,
keyPath: string,
collector: (value: string, keyPath: string) => void
): void {
if (typeof value === 'string') {
collector(value, keyPath)
return
}
if (Array.isArray(value)) {
value.forEach((entry, index) => collectStringValues(entry, `${keyPath}.${index}`, collector))
return
}
if (!value || typeof value !== 'object') {
return
}
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
collectStringValues(child, keyPath ? `${keyPath}.${key}` : key, collector)
}
}
function collectArtifactsFromText(text: string, pushValue: (value: string) => void): void {
for (const match of text.matchAll(MARKDOWN_IMAGE_RE)) {
pushValue(match[2] || '')
}
for (const match of text.matchAll(MARKDOWN_LINK_RE)) {
const start = match.index ?? 0
if (start > 0 && text[start - 1] === '!') {
continue
}
const value = match[2] || ''
if (looksLikeArtifact(value)) {
pushValue(value)
}
}
for (const match of text.matchAll(URL_RE)) {
const value = match[0] || ''
if (looksLikeArtifact(value)) {
pushValue(value)
}
}
for (const match of text.matchAll(PATH_RE)) {
pushValue(match[2] || '')
}
}
function collectArtifactsFromMessage(message: SessionMessage, pushValue: (value: string) => void): void {
const text = messageText(message)
if (text) {
collectArtifactsFromText(text, pushValue)
}
if (message.role !== 'tool' && !Array.isArray(message.tool_calls)) {
return
}
if (Array.isArray(message.tool_calls)) {
for (const call of message.tool_calls) {
collectStringValues(call, 'tool_call', (value, keyPath) => {
const normalized = normalizeValue(value)
if (!normalized) {
return
}
if (KEY_HINT_RE.test(keyPath) && (looksLikePathOrUrl(normalized) || FILE_EXT_RE.test(normalized))) {
pushValue(normalized)
}
})
}
}
const parsed = parseMaybeJson(text)
if (parsed !== null) {
collectStringValues(parsed, 'tool_result', (value, keyPath) => {
const normalized = normalizeValue(value)
if (!normalized) {
return
}
if ((KEY_HINT_RE.test(keyPath) || looksLikePathOrUrl(normalized)) && looksLikeArtifact(normalized)) {
pushValue(normalized)
}
})
}
}
export function collectArtifactsForSession(session: SessionInfo, messages: SessionMessage[]): ArtifactRecord[] {
const found = new Map<string, ArtifactRecord>()
const title = sessionTitle(session)
for (const message of messages) {
if (message.role !== 'assistant' && message.role !== 'tool') {
continue
}
collectArtifactsFromMessage(message, candidate => {
const value = normalizeValue(candidate)
if (!value || !looksLikeArtifact(value)) {
return
}
const key = `${session.id}:${value}`
if (found.has(key)) {
return
}
found.set(key, {
id: key,
kind: artifactKind(value),
value,
href: artifactHref(value),
label: artifactLabel(value),
sessionId: session.id,
sessionTitle: title,
timestamp: message.timestamp || session.last_active || session.started_at || Date.now()
})
})
}
return Array.from(found.values())
}
function formatArtifactTime(timestamp: number): string {
return ARTIFACT_TIME_FMT.format(new Date(timestamp))
}
@ -684,6 +426,28 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
const { t } = useI18n()
const a = t.artifacts
const kindLabel = artifact.kind === 'image' ? a.kindImage : artifact.kind === 'file' ? a.kindFile : a.kindLink
const [src, setSrc] = useState('')
useEffect(() => {
let active = true
setSrc('')
void artifactImageSrc(artifact.value, artifact.href)
.then(nextSrc => {
if (active) {
setSrc(nextSrc)
}
})
.catch(() => {
if (active) {
onImageError(artifact.id)
}
})
return () => {
active = false
}
}, [artifact.href, artifact.id, artifact.value, onImageError])
return (
<article className="group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)">
@ -693,7 +457,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
failedImage && 'cursor-default'
)}
>
{!failedImage && (
{!failedImage && src && (
<ZoomableImage
alt={artifact.label}
className="max-h-40 max-w-full cursor-zoom-in rounded-md object-contain"
@ -702,7 +466,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
loading="lazy"
onError={() => onImageError(artifact.id)}
slot="artifact-media"
src={artifact.href}
src={src}
/>
)}
</div>

View file

@ -1,6 +1,14 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from './use-composer-actions'
import { $connection } from '@/store/session'
import {
attachmentPreviewDataUrl,
type DroppedFile,
extractDroppedFiles,
HERMES_PATHS_MIME,
partitionDroppedFiles
} from './use-composer-actions'
// A Finder/Explorer drop carries a native File handle; an in-app drag (project
// tree, gutter line ref) is path-only. The split decides whether a drop becomes
@ -178,3 +186,61 @@ describe('extractDroppedFiles', () => {
expect(result[0]?.isDirectory).toBe(true)
})
})
describe('attachmentPreviewDataUrl', () => {
const LOCAL_PREVIEW = 'data:image/png;base64,bG9jYWw='
const REMOTE_PREVIEW = 'data:image/png;base64,cmVtb3Rl'
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
$connection.set(null)
})
it('reads a local path via the local bridge even in remote mode (paperclip/paste/OS drop)', async () => {
const readFileDataUrl = vi.fn(async () => LOCAL_PREVIEW)
const api = vi.fn()
vi.stubGlobal('window', { hermesDesktop: { api, readFileDataUrl } })
$connection.set({ mode: 'remote' } as never)
await expect(attachmentPreviewDataUrl('/Users/me/Pictures/pic.png')).resolves.toBe(LOCAL_PREVIEW)
expect(readFileDataUrl).toHaveBeenCalledWith('/Users/me/Pictures/pic.png')
expect(api).not.toHaveBeenCalled()
})
it('falls back to the remote fs bridge when the path is not on this machine (project-tree drag)', async () => {
const readFileDataUrl = vi.fn(async () => {
throw new Error('ENOENT')
})
const api = vi.fn(async ({ path }: { path: string }) => {
if (path.startsWith('/api/fs/read-data-url?')) {
return { dataUrl: REMOTE_PREVIEW }
}
throw new Error(`unexpected path ${path}`)
})
vi.stubGlobal('window', { hermesDesktop: { api, readFileDataUrl } })
$connection.set({ mode: 'remote' } as never)
await expect(attachmentPreviewDataUrl('/home/gateway/shot.png')).resolves.toBe(REMOTE_PREVIEW)
expect(api).toHaveBeenCalledWith({
path: '/api/fs/read-data-url?path=%2Fhome%2Fgateway%2Fshot.png'
})
})
it('falls back when the local bridge returns an empty read', async () => {
const readFileDataUrl = vi.fn(async () => '')
const api = vi.fn(async () => ({ dataUrl: REMOTE_PREVIEW }))
vi.stubGlobal('window', { hermesDesktop: { api, readFileDataUrl } })
$connection.set({ mode: 'remote' } as never)
await expect(attachmentPreviewDataUrl('/home/gateway/shot.png')).resolves.toBe(REMOTE_PREVIEW)
})
})

View file

@ -39,6 +39,29 @@ export function isImagePath(filePath: string): boolean {
return IMAGE_EXTENSION_PATTERN.test(filePath)
}
/**
* Read an attachment's thumbnail preview, local disk first. Paperclip picks,
* clipboard saves, and OS drops always hand us paths on THIS machine the
* remote-routed fs facade would 404 them against the gateway and toast a bogus
* "preview failed" even though the attach itself works (upload reads local
* bytes too). In-app drags from the remote project tree are the opposite case:
* the local read fails there, so fall back to the facade (remote fs bridge).
* In local mode the facade IS the local bridge, so this stays a single read.
*/
export async function attachmentPreviewDataUrl(filePath: string): Promise<string> {
try {
const local = await window.hermesDesktop?.readFileDataUrl?.(filePath)
if (local) {
return local
}
} catch {
// Not on this machine (or unreadable locally) — try the gateway.
}
return readDesktopFileDataUrl(filePath)
}
export interface DroppedFile {
/** Browser-native File handle. Absent for in-app drags (e.g. project tree). */
file?: File
@ -367,7 +390,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
attachToMain(baseAttachment)
try {
const previewUrl = await readDesktopFileDataUrl(filePath)
const previewUrl = await attachmentPreviewDataUrl(filePath)
if (previewUrl) {
addComposerAttachment({ ...baseAttachment, previewUrl })

View file

@ -27,6 +27,7 @@ import { Codicon } from '@/components/ui/codicon'
import { ColorSwatches } from '@/components/ui/color-swatches'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
@ -57,6 +58,11 @@ import { PROFILES_ROUTE } from '../../routes'
const RAIL_GAP = 4 // px — matches gap-1 between squares.
// Past this many profiles the strip of colored squares stops scaling (tiny
// drag targets, endless horizontal scroll), so the rail collapses to a compact
// select. Drag-reorder and long-press-recolor live only on the squares path.
const PROFILE_DROPDOWN_THRESHOLD = 13
// easeOutBack — a little overshoot so squares spring into their new slot rather
// than sliding in flat. Neighbors reflow on RAIL_TRANSITION; the dragged square
// glides between snapped cells on the snappier DRAG_TRANSITION.
@ -102,6 +108,10 @@ export function ProfileRail() {
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
const scrollRef = useRef<HTMLDivElement>(null)
// Too many profiles for the square strip → collapse to the select. Declared
// ahead of the wheel effect, which re-binds when the strip mounts/unmounts.
const condensed = profiles.length > PROFILE_DROPDOWN_THRESHOLD
// A plain mouse wheel only emits deltaY; map it to horizontal scroll so the
// rail is navigable without a trackpad. Trackpad x-scroll (deltaX) passes
// through. Native + non-passive so we can preventDefault and not bleed the
@ -125,7 +135,8 @@ export function ProfileRail() {
el.addEventListener('wheel', onWheel, { passive: false })
return () => el.removeEventListener('wheel', onWheel)
}, [])
// `condensed` swaps the strip out for the dropdown (ref goes null/back).
}, [condensed])
const isAll = scope === ALL_PROFILES
const activeKey = normalizeProfileKey(gatewayProfile)
@ -228,51 +239,57 @@ export function ProfileRail() {
/>
)}
<div
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
ref={scrollRef}
>
{multiProfile && (
<DndContext
collisionDetection={closestCenter}
modifiers={[stepThroughCells]}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragStart={handleDragStart}
sensors={sensors}
>
<SortableContext items={named.map(profile => profile.name)} strategy={horizontalListSortingStrategy}>
{/* relative the strip is the dragged square's offsetParent, so the
clamp modifier bounds drags to the occupied cells (not the +). */}
<div className="relative flex items-center gap-1">
{named.map(profile => (
<ProfileSquare
active={!isAll && normalizeProfileKey(profile.name) === activeKey}
color={resolveProfileColor(profile.name, colors)}
key={profile.name}
label={profile.name}
onDelete={() => setPendingDelete(profile)}
onRecolor={color => setProfileColor(profile.name, color)}
onRename={() => setPendingRename(profile)}
onSelect={() => selectProfile(profile.name)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
{condensed ? (
// Condensed path: one compact dropdown instead of N squares. No drag
// reorder, no long-press recolor, no per-square context menu — Manage
// covers rename/delete at this scale.
<div className="flex min-w-0 flex-1 items-center gap-1">
<ProfileDropdown
activeKey={isAll ? null : activeKey}
colors={colors}
onSelect={selectProfile}
profiles={named}
/>
<AddProfileButton label={p.newProfile} onClick={() => setCreateOpen(true)} />
</div>
) : (
<div
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
ref={scrollRef}
>
{multiProfile && (
<DndContext
collisionDetection={closestCenter}
modifiers={[stepThroughCells]}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragStart={handleDragStart}
sensors={sensors}
>
<SortableContext items={named.map(profile => profile.name)} strategy={horizontalListSortingStrategy}>
{/* relative the strip is the dragged square's offsetParent, so the
clamp modifier bounds drags to the occupied cells (not the +). */}
<div className="relative flex items-center gap-1">
{named.map(profile => (
<ProfileSquare
active={!isAll && normalizeProfileKey(profile.name) === activeKey}
color={resolveProfileColor(profile.name, colors)}
key={profile.name}
label={profile.name}
onDelete={() => setPendingDelete(profile)}
onRecolor={color => setProfileColor(profile.name, color)}
onRename={() => setPendingRename(profile)}
onSelect={() => selectProfile(profile.name)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
<Tip label={p.newProfile}>
<button
aria-label={p.newProfile}
className="grid size-5 shrink-0 place-items-center rounded-[3px] text-(--ui-text-tertiary) opacity-55 transition hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100"
onClick={() => setCreateOpen(true)}
type="button"
>
<Codicon name="add" size="0.75rem" />
</button>
</Tip>
</div>
<AddProfileButton label={p.newProfile} onClick={() => setCreateOpen(true)} />
</div>
)}
{/* Always reachable, even with only the default profile: the manage
overlay is the only place to edit a profile's SOUL.md, and a
@ -309,6 +326,71 @@ export function ProfileRail() {
)
}
// The "+" create button, shared by both rail render paths.
function AddProfileButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<Tip label={label}>
<button
aria-label={label}
className="grid size-5 shrink-0 place-items-center rounded-[3px] text-(--ui-text-tertiary) opacity-55 transition hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100"
onClick={onClick}
type="button"
>
<Codicon name="add" size="0.75rem" />
</button>
</Tip>
)
}
// The condensed rail: every named profile in one compact select. The trigger
// shows the active profile (tinted initial + name); on default/all scope it
// falls back to the placeholder since the left toggle pill carries that state.
function ProfileDropdown({
activeKey,
colors,
onSelect,
profiles
}: {
activeKey: null | string
colors: Record<string, string>
onSelect: (name: string) => void
profiles: ProfileInfo[]
}) {
const { t } = useI18n()
const p = t.profiles
const value = activeKey ? (profiles.find(profile => normalizeProfileKey(profile.name) === activeKey)?.name ?? '') : ''
return (
<Select onValueChange={name => name && onSelect(name)} value={value}>
<SelectTrigger aria-label={p.title} className="min-w-0 flex-1" size="xs">
<SelectValue placeholder={p.title} />
</SelectTrigger>
<SelectContent collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }} side="top">
{profiles.map(profile => {
const color = resolveProfileColor(profile.name, colors)
const hue = color ?? 'var(--ui-text-quaternary)'
return (
<SelectItem key={profile.name} value={profile.name}>
<span className="flex min-w-0 items-center gap-1.5">
<span
aria-hidden="true"
className="grid size-4 shrink-0 place-items-center rounded-[3px] text-[0.5rem] font-semibold uppercase leading-none"
style={{ backgroundColor: profileColorSoft(hue, 22), color: color ?? undefined }}
>
{profile.name.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
</span>
<span className="truncate">{profile.name}</span>
</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
)
}
interface ProfilePillProps {
active: boolean
// home / All / Manage are glyph action buttons (navigation, not identity).

View file

@ -191,6 +191,7 @@ export function DesktopController() {
currentView,
openAgents,
openCommandCenterSection,
openStarmap,
profilesOpen,
settingsOpen,
starmapOpen,
@ -739,6 +740,7 @@ export function DesktopController() {
busyRef,
createBackendSessionForSend,
handleSkinCommand,
openMemoryGraph: openStarmap,
refreshSessions,
requestGateway,
resumeStoredSession: resumeSession,

View file

@ -19,7 +19,6 @@ import { Textarea } from '@/components/ui/textarea'
import {
createProfile,
deleteProfile,
getProfiles,
getProfileSoul,
type ProfileInfo,
renameProfile,
@ -31,7 +30,7 @@ import { profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
import { slug } from '@/lib/sanitize'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $profileColors } from '@/store/profile'
import { $profileColors, refreshProfiles } from '@/store/profile'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import {
@ -72,7 +71,7 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
const refresh = useCallback(async () => {
try {
const { profiles: list } = await getProfiles()
const list = await refreshProfiles()
setProfiles(list)
setSelectedName(current => {
if (current && list.some(p => p.name === current)) {

View file

@ -21,6 +21,23 @@ function recordingLimit(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : DEFAULT_VOICE_SECONDS
}
/** config.yaml hands back whatever the user wrote `reasoning_effort: false`
* (or `off`/`no`, which YAML also parses to boolean false) means thinking
* disabled, and a bare boolean must not throw on `.trim()`. */
function normalizeConfigEffort(value: unknown): string {
if (value === false) {
return 'none'
}
if (typeof value !== 'string') {
return ''
}
const effort = value.trim().toLowerCase()
return effort === 'false' || effort === 'disabled' ? 'none' : effort
}
interface HermesConfigOptions {
activeSessionIdRef: MutableRefObject<string | null>
refreshProjectBranch: (cwd: string) => Promise<void>
@ -60,7 +77,7 @@ export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: He
void refreshProjectBranch($currentCwd.get() || cwd)
}
const reasoning = (config.agent?.reasoning_effort ?? '').trim()
const reasoning = normalizeConfigEffort(config.agent?.reasoning_effort)
const tier = (config.agent?.service_tier ?? '').trim()
setCurrentReasoningEffort(prev => (activeSessionIdRef.current ? prev : reasoning))

View file

@ -4,7 +4,7 @@ import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { textPart } from '@/lib/chat-messages'
import { $composerAttachments, type ComposerAttachment } from '@/store/composer'
import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer'
import { $busy, $connection, $messages, $sessions, setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
@ -54,6 +54,7 @@ function Harness({
busyRef,
onReady,
onSeedState,
openMemoryGraph,
refreshSessions,
requestGateway,
resumeStoredSession,
@ -63,6 +64,7 @@ function Harness({
busyRef?: MutableRefObject<boolean>
onReady: (handle: HarnessHandle) => void
onSeedState?: (state: Record<string, unknown>) => void
openMemoryGraph?: () => void
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
@ -91,6 +93,7 @@ function Harness({
busyRef: localBusyRef,
createBackendSessionForSend: async () => RUNTIME_SESSION_ID,
handleSkinCommand: () => '',
openMemoryGraph: openMemoryGraph ?? (() => undefined),
refreshSessions,
requestGateway,
resumeStoredSession: resumeStoredSession ?? (() => undefined),
@ -272,6 +275,70 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
expect(renderedText).toContain('⊙ Goal set. Starting now.')
expect(renderedText).not.toContain('/goal: no output')
})
it('dispatches a slash command with a multiline arg instead of "empty slash command" (#41323, #55510)', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
const states: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'slash.exec') {
return { type: 'send', message: 'Write a Python script\nthat prints Hello World' } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
onSeedState={s => states.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.submitText('/goal Write a Python script\nthat prints Hello World')
// The newline lives in the arg — the command still reaches the gateway
// whole, exactly as the CLI and Telegram handle it.
expect(calls.map(c => c.method)).toEqual(['slash.exec', 'prompt.submit'])
expect(calls[0]?.params).toEqual({
command: 'goal Write a Python script\nthat prints Hello World',
session_id: RUNTIME_SESSION_ID
})
const renderedText = states
.flatMap(state => {
const messages = Array.isArray(state.messages)
? (state.messages as Array<{ parts?: Array<{ text?: string }> }>)
: []
return messages.flatMap(message => (message.parts ?? []).map(part => part.text ?? ''))
})
.join('\n')
expect(renderedText).not.toContain('empty slash command')
})
it('restores a degenerate slash payload to the composer instead of losing it', async () => {
setComposerDraft('')
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
// `/ text` parses to an empty command name on every surface (CLI parity).
// The composer draft was already cleared on submit and slash input never
// enters the Up-arrow history ring, so the payload must be handed back.
await handle!.submitText('/ pasted context that must not vanish')
expect($composerDraft.get()).toBe('/ pasted context that must not vanish')
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
})
})
describe('usePromptActions desktop slash pickers', () => {
@ -305,6 +372,29 @@ describe('usePromptActions desktop slash pickers', () => {
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
})
it('opens the memory graph overlay for /journey and its aliases instead of hitting the backend', async () => {
const openMemoryGraph = vi.fn()
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
openMemoryGraph={openMemoryGraph}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.submitText('/journey')
await handle!.submitText('/memory-graph')
await handle!.submitText('/learning')
expect(openMemoryGraph).toHaveBeenCalledTimes(3)
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
expect(requestGateway).not.toHaveBeenCalledWith('command.dispatch', expect.anything())
})
it('marks a timed-out handoff as failed so the next attempt can retry', async () => {
vi.useFakeTimers()
const calls: { method: string; params?: Record<string, unknown> }[] = []

View file

@ -2,7 +2,7 @@ import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { transcribeAudio, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS, transcribeAudio } from '@/hermes'
import { useI18n } from '@/i18n'
import { stripAnsi } from '@/lib/ansi'
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
@ -158,6 +158,7 @@ interface PromptActionsOptions {
branchCurrentSession: () => Promise<boolean>
createBackendSessionForSend: (preview?: string | null) => Promise<string | null>
handleSkinCommand: (arg: string) => string
openMemoryGraph: () => void
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
resumeStoredSession: (storedSessionId: string) => Promise<void> | void
@ -185,6 +186,7 @@ export function usePromptActions({
branchCurrentSession,
createBackendSessionForSend,
handleSkinCommand,
openMemoryGraph,
refreshSessions,
requestGateway,
resumeStoredSession,
@ -447,6 +449,7 @@ export function usePromptActions({
createBackendSessionForSend,
handleSkinCommand,
handoffSession,
openMemoryGraph,
refreshSessions,
requestGateway,
resumeStoredSession,

View file

@ -54,6 +54,7 @@ interface SlashCommandDeps {
platform: string,
options?: { onProgress?: (state: string) => void; sessionId?: string }
) => Promise<{ ok: boolean; error?: string }>
openMemoryGraph: () => void
refreshSessions: () => Promise<void>
requestGateway: GatewayRequest
resumeStoredSession: (storedSessionId: string) => Promise<void> | void
@ -75,6 +76,7 @@ export function useSlashCommand(deps: SlashCommandDeps) {
createBackendSessionForSend,
handleSkinCommand,
handoffSession,
openMemoryGraph,
refreshSessions,
requestGateway,
resumeStoredSession,
@ -388,6 +390,13 @@ export function useSlashCommand(deps: SlashCommandDeps) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
},
// /journey (aliases /learning, /memory-graph) opens the memory graph
// overlay — the desktop's visual counterpart of the TUI journey
// timeline — instead of printing a text rendering into the transcript.
// Args are ignored, matching the TUI overlay behavior.
journey: async () => {
openMemoryGraph()
},
// /hatch opens the pet generator overlay (the desktop's rich, multi-step
// generate→pick→hatch→adopt flow). A typed description seeds the prompt
// so `/hatch a cyber fox` lands on the composer step prefilled.
@ -561,6 +570,14 @@ export function useSlashCommand(deps: SlashCommandDeps) {
const { name, arg } = parseSlashCommand(command)
if (!name) {
// The composer draft was already cleared on submit, and slash input
// never lands in the Up-arrow history ring (it derives from sent user
// messages) — so without this restore, any payload after a degenerate
// slash (`/ text`, `/` + newline) is lost forever. Hand it back.
if (command.replace(/^\/+/, '').trim()) {
setComposerDraft(command)
}
const sessionId = await ensureSessionId(sessionHint)
if (sessionId) {
@ -604,6 +621,7 @@ export function useSlashCommand(deps: SlashCommandDeps) {
createBackendSessionForSend,
handleSkinCommand,
handoffSession,
openMemoryGraph,
refreshSessions,
requestGateway,
resumeStoredSession,

View file

@ -1,7 +1,7 @@
import { type MutableRefObject, useCallback } from 'react'
import type { Translations } from '@/i18n'
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
import type { Translations } from '@/i18n'
import { type ChatMessage, textPart } from '@/lib/chat-messages'
import { optimisticAttachmentRef } from '@/lib/chat-runtime'
import { setMutableRef } from '@/lib/mutable-ref'

View file

@ -307,10 +307,12 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const reasoningSupported = mainCaps?.reasoning ?? true
const fastSupported = mainCaps?.fast ?? false
const effortValue =
String(getNested(config ?? {}, 'agent.reasoning_effort') ?? '')
.trim()
.toLowerCase() || 'medium'
// Hand-written `reasoning_effort: false`/`off` reaches us as boolean false
// ("false" once stringified) — show it as Off, not an empty select.
const rawEffort = String(getNested(config ?? {}, 'agent.reasoning_effort') ?? '')
.trim()
.toLowerCase()
const effortValue = rawEffort === 'false' || rawEffort === 'disabled' ? 'none' : rawEffort || 'medium'
const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier'))

View file

@ -14,35 +14,22 @@ beforeAll(() => {
Element.prototype.releasePointerCapture = vi.fn()
})
const getMoaModels = vi.fn()
const getGlobalModelOptions = vi.fn()
vi.mock('@/hermes', () => ({
getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args),
getMoaModels: (...args: unknown[]) => getMoaModels(...args)
getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args)
}))
function moaPreset() {
return {
aggregator: { provider: 'deepseek', model: 'deepseek-v4-pro' },
aggregator_temperature: 0.7,
enabled: true,
max_tokens: 4096,
reference_models: [{ provider: 'zai', model: 'glm-5.2' }],
reference_temperature: 0.7
}
}
// MoA presets now arrive as the catalog's virtual `moa` provider row (the same
// payload a remote gateway's model.options returns), not the /api/model/moa
// REST config.
const MOA_PROVIDER = { models: ['default', 'BeastMode'], name: 'Mixture of Agents', slug: 'moa' }
beforeEach(() => {
$activeSessionId.set('runtime-1')
$currentModel.set('')
$currentProvider.set('')
getGlobalModelOptions.mockResolvedValue({ providers: [] })
getMoaModels.mockResolvedValue({
default_preset: 'default',
active_preset: 'default',
presets: { default: moaPreset(), BeastMode: moaPreset() }
})
getGlobalModelOptions.mockResolvedValue({ providers: [MOA_PROVIDER] })
})
afterEach(() => {
@ -89,4 +76,27 @@ describe('ModelMenuPanel MoA presets', () => {
const item = row.closest('[role="menuitem"]') ?? row.parentElement
expect(item?.querySelector('.codicon-check')).not.toBeNull()
})
it('keeps the virtual moa provider out of the main model groups (presets section only)', async () => {
renderPanel()
await findByText(document.body, 'MoA: BeastMode')
// The provider group header would read "Mixture of Agents"; the presets
// section header reads "MoA presets". Only the latter should exist.
expect(document.body.textContent).toContain('MoA presets')
expect(document.body.textContent).not.toContain('Mixture of Agents')
})
it('renders presets from the catalog even before a session exists', async () => {
$activeSessionId.set('')
const onSelectModel = renderPanel()
const row = await findByText(document.body, 'MoA: BeastMode')
fireEvent.click(row)
// Pre-session picks are UI state shipped on the next session.create — the
// row must not be disabled and must still route through onSelectModel.
expect(onSelectModel).toHaveBeenCalledWith({ model: 'BeastMode', provider: 'moa' })
})
})

View file

@ -16,8 +16,8 @@ import {
} from '@/components/ui/dropdown-menu'
import { Skeleton } from '@/components/ui/skeleton'
import type { HermesGateway } from '@/hermes'
import { getGlobalModelOptions, getMoaModels } from '@/hermes'
import { useI18n } from '@/i18n'
import { requestModelOptions } from '@/lib/model-options'
import {
currentPickerSelection,
displayModelName,
@ -42,7 +42,7 @@ import {
$currentProvider,
$currentReasoningEffort
} from '@/store/session'
import type { MoaConfigResponse, ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
@ -82,18 +82,10 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
const modelOptions = useQuery({
queryKey: ['model-options', activeSessionId || 'global'],
queryFn: (): Promise<ModelOptionsResponse> => {
if (gateway && activeSessionId) {
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
}
return getGlobalModelOptions()
}
})
const moaOptions = useQuery({
queryKey: ['moa-presets'],
queryFn: (): Promise<MoaConfigResponse> => getMoaModels()
// Gateway-first even with no session yet: a connected (possibly remote)
// gateway owns the model catalog, including virtual providers like `moa`
// that the local REST fallback can't know about (#53817).
queryFn: (): Promise<ModelOptionsResponse> => requestModelOptions({ gateway, sessionId: activeSessionId })
})
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
@ -112,9 +104,22 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
const providers = modelOptions.data?.providers
// The catalog carries MoA presets as a virtual `moa` provider row. Render
// them in their dedicated section below and keep the row out of the main
// provider groups so presets don't show up twice.
const moaPresets = useMemo(
() => providers?.find(provider => provider.slug.toLowerCase() === 'moa')?.models ?? [],
[providers]
)
const pickerProviders = useMemo(
() => providers?.filter(provider => provider.slug.toLowerCase() !== 'moa') ?? [],
[providers]
)
const effectiveVisibleModels = useMemo(
() => effectiveVisibleKeys(visibleModels, providers ?? []),
[visibleModels, providers]
() => effectiveVisibleKeys(visibleModels, pickerProviders),
[visibleModels, pickerProviders]
)
// The composer picker never persists the profile default. With a session it
@ -136,13 +141,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
try {
const queryKey = ['model-options', activeSessionId || 'global']
const next =
gateway && activeSessionId
? await gateway.request<ModelOptionsResponse>('model.options', {
session_id: activeSessionId,
refresh: true
})
: await getGlobalModelOptions({ refresh: true })
const next = await requestModelOptions({ gateway, refresh: true, sessionId: activeSessionId })
queryClient.setQueryData<ModelOptionsResponse>(queryKey, next)
} catch {
@ -185,18 +184,20 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
// Previously this dispatched the one-shot `/moa` command, which ran a single
// turn through MoA and then silently reverted to the prior model (#54670) —
// the dropdown presented presets like persistent selections but they weren't.
// No session gate: like regular model rows, a pre-session pick is UI state
// shipped on the next session.create.
const selectMoaPreset = async (preset: string) => {
if (!activeSessionId) {
if ((await switchTo(preset, 'moa')) === false) {
return
}
await switchTo(preset, 'moa')
closeMenu()
}
const groups = useMemo(
() =>
groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels),
[providers, search, optionsModel, optionsProvider, effectiveVisibleModels]
groupModels(pickerProviders, search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels),
[pickerProviders, search, optionsModel, optionsProvider, effectiveVisibleModels]
)
return (
@ -222,7 +223,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
<DropdownMenuItem className={dropdownMenuRow} disabled>
{error}
</DropdownMenuItem>
) : groups.length === 0 ? (
) : groups.length === 0 && moaPresets.length === 0 ? (
<DropdownMenuItem className={dropdownMenuRow} disabled>
{copy.noModels}
</DropdownMenuItem>
@ -322,16 +323,15 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
<DropdownMenuSeparator className="mx-0" />
{moaOptions.data && Object.keys(moaOptions.data.presets ?? {}).length > 0 ? (
{moaPresets.length > 0 ? (
<>
<DropdownMenuLabel className={dropdownMenuSectionLabel}>MoA presets</DropdownMenuLabel>
{Object.keys(moaOptions.data.presets).map(preset => {
const isCurrentMoa = currentProvider === 'moa' && currentModel === preset
{moaPresets.map(preset => {
const isCurrentMoa = optionsProvider === 'moa' && optionsModel === preset
return (
<DropdownMenuItem
className={dropdownMenuRow}
disabled={!activeSessionId}
key={`moa:${preset}`}
onSelect={event => {
event.preventDefault()

View file

@ -27,6 +27,7 @@ import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/extern
import { createMemoizedMathPlugin } from '@/lib/katex-memo'
import { preprocessMarkdown } from '@/lib/markdown-preprocess'
import {
downloadGatewayMediaFile,
filePathFromMediaPath,
gatewayMediaDataUrl,
isRemoteGateway,
@ -129,21 +130,50 @@ async function mediaSrc(path: string): Promise<string> {
return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
}
function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) {
function useOpenMediaFile(path: string) {
const [openFailed, setOpenFailed] = useState(false)
const open = () => {
if (window.hermesDesktop && isRemoteGateway()) {
setOpenFailed(false)
void downloadGatewayMediaFile(path).catch(() => setOpenFailed(true))
} else {
openExternalLink(mediaExternalUrl(path))
}
}
return { open, openFailed }
}
function OpenMediaFailedNote({ name }: { name: string }) {
return (
<button
className="mt-2 bg-transparent text-xs font-medium text-muted-foreground underline underline-offset-4 decoration-current/20 hover:text-foreground"
onClick={() => void window.hermesDesktop?.openExternal(mediaExternalUrl(path))}
type="button"
>
Open {kind} file
</button>
<span className="mt-1 block text-xs text-muted-foreground">
Couldn&apos;t fetch {name} from the gateway (missing, unreadable, or too large).
</span>
)
}
function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) {
const { open, openFailed } = useOpenMediaFile(path)
return (
<span className="block">
<button
className="mt-2 bg-transparent text-xs font-medium text-muted-foreground underline underline-offset-4 decoration-current/20 hover:text-foreground"
onClick={open}
type="button"
>
Open {kind} file
</button>
{openFailed && <OpenMediaFailedNote name={mediaName(path)} />}
</span>
)
}
function MediaAttachment({ path }: { path: string }) {
const [src, setSrc] = useState('')
const [failed, setFailed] = useState(false)
const { open, openFailed } = useOpenMediaFile(path)
const kind = mediaKind(path)
const name = mediaName(path)
@ -153,6 +183,15 @@ function MediaAttachment({ path }: { path: string }) {
setFailed(false)
setSrc('')
if (kind === 'file') {
setFailed(true)
return () => {
cancelled = true
}
}
void mediaSrc(path)
.then(value => {
if (value.startsWith('blob:')) {
@ -178,7 +217,7 @@ function MediaAttachment({ path }: { path: string }) {
URL.revokeObjectURL(objectUrl)
}
}
}, [path])
}, [kind, path])
if (kind === 'image' && src) {
return (
@ -214,16 +253,19 @@ function MediaAttachment({ path }: { path: string }) {
}
return (
<a
className="font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere"
href="#"
onClick={event => {
event.preventDefault()
openExternalLink(mediaExternalUrl(path))
}}
>
{failed ? `Open ${name}` : `Loading ${name}...`}
</a>
<span className="wrap-anywhere">
<a
className="font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere"
href="#"
onClick={event => {
event.preventDefault()
open()
}}
>
{failed ? `Open ${name}` : `Loading ${name}...`}
</a>
{openFailed && <OpenMediaFailedNote name={name} />}
</span>
)
}

View file

@ -2,11 +2,11 @@ import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useI18n } from '@/i18n'
import { requestModelOptions } from '@/lib/model-options'
import { currentPickerSelection } from '@/lib/model-status-label'
import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes'
import type { ModelOptionProvider, ModelPricing } from '@/types/hermes'
import type { HermesGateway } from '../hermes'
import { getGlobalModelOptions } from '../hermes'
import { cn } from '../lib/utils'
import { startManualOnboarding } from '../store/onboarding'
@ -54,15 +54,7 @@ export function ModelPickerDialog({
const modelOptions = useQuery({
queryKey: ['model-options', sessionId || 'global'],
queryFn: () => {
if (gw && sessionId) {
return gw.request<ModelOptionsResponse>('model.options', {
session_id: sessionId
})
}
return getGlobalModelOptions()
},
queryFn: () => requestModelOptions({ gateway: gw, sessionId }),
enabled: open
})

View file

@ -96,21 +96,6 @@ export function FlowPanel({
)
}
if (flow.status === 'awaiting_browser') {
return (
<Step title={t.onboarding.signInWith(title)}>
<p className="text-sm text-muted-foreground">{t.onboarding.autoBrowser(title)}</p>
<FlowFooter left={<DocsLink href={flow.start.auth_url}>{t.onboarding.reopenSignInPage}</DocsLink>}>
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
{t.onboarding.waitingAuthorize}
</span>
<CancelBtn size="sm" />
</FlowFooter>
</Step>
)
}
if (flow.status === 'external_pending') {
return (
<Step title={t.onboarding.signInWith(title)}>

View file

@ -1744,7 +1744,6 @@ export const en: Translations = {
flowSubtitles: {
pkce: 'Opens your browser to sign in, then continues here',
device_code: 'Opens a verification page in your browser — Hermes connects automatically',
loopback: 'Opens your browser to sign in — Hermes connects automatically',
external: 'Sign in once in your terminal, then come back to chat'
},
startingSignIn: provider => `Starting sign-in for ${provider}...`,

View file

@ -1852,7 +1852,6 @@ export const ja = defineLocale({
flowSubtitles: {
pkce: 'ブラウザーを開いてサインインし、ここに戻ります',
device_code: 'ブラウザーで確認ページを開きます — Hermes が自動接続します',
loopback: 'サインインのためブラウザーを開きます — Hermes が自動接続します',
external: 'ターミナルで一度サインインして、チャットに戻ります'
},
startingSignIn: provider => `${provider} のサインインを開始中...`,

View file

@ -1793,7 +1793,6 @@ export const zhHant = defineLocale({
flowSubtitles: {
pkce: '開啟瀏覽器登入,然後回到這裡繼續',
device_code: '在瀏覽器中開啟驗證頁面 — Hermes 會自動連線',
loopback: '開啟瀏覽器登入 — Hermes 會自動連線',
external: '先在終端機登入一次,然後回來繼續聊天'
},
startingSignIn: provider => `正在為 ${provider} 啟動登入...`,

View file

@ -1917,7 +1917,6 @@ export const zh: Translations = {
flowSubtitles: {
pkce: '打开浏览器登录,然后回到这里继续',
device_code: '在浏览器中打开验证页面 — Hermes 会自动连接',
loopback: '打开浏览器登录 — Hermes 会自动连接',
external: '先在终端登录一次,然后回来继续对话'
},
startingSignIn: provider => `正在为 ${provider} 启动登录...`,

View file

@ -6,7 +6,8 @@ import {
attachmentDisplayText,
coerceThinkingText,
optimisticAttachmentRef,
parseCommandDispatch
parseCommandDispatch,
parseSlashCommand
} from './chat-runtime'
const DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANS'
@ -111,3 +112,41 @@ describe('parseCommandDispatch', () => {
expect(parseCommandDispatch({ type: 'prefill', notice: 'x' })).toBeNull()
})
})
describe('parseSlashCommand', () => {
it('parses a single-line command', () => {
expect(parseSlashCommand('/some-skill do something')).toEqual({
arg: 'do something',
name: 'some-skill'
})
})
it('keeps a multiline arg intact instead of failing the whole parse (#41323)', () => {
expect(parseSlashCommand('/goal Write a Python script\nthat prints Hello World')).toEqual({
arg: 'Write a Python script\nthat prints Hello World',
name: 'goal'
})
})
it('parses a skill command with a long pasted multi-paragraph context (#55510)', () => {
const context = 'summarize this:\n\nparagraph one\nparagraph two\n\nparagraph three'
expect(parseSlashCommand(`/some-skill ${context}`)).toEqual({
arg: context,
name: 'some-skill'
})
})
it('takes the name across a newline boundary like the CLI and gateway (split on any whitespace)', () => {
expect(parseSlashCommand('/goal\npasted block')).toEqual({ arg: 'pasted block', name: 'goal' })
})
it('keeps truly empty slash input empty', () => {
expect(parseSlashCommand('/')).toEqual({ arg: '', name: '' })
expect(parseSlashCommand('/ ')).toEqual({ arg: '', name: '' })
})
it('does not treat text after horizontal whitespace as a command name (CLI parity)', () => {
expect(parseSlashCommand('/ some words')).toEqual({ arg: '', name: '' })
})
})

View file

@ -223,7 +223,12 @@ export function normalizePersonalityValue(value: string): string {
}
export function parseSlashCommand(command: string) {
const match = command.replace(/^\/+/, '').match(/^(\S+)\s*(.*)$/)
// `[\s\S]*` (not `.*`): the arg may span newlines — `/goal <multi-line text>`
// or a skill command with a long pasted context. The old `.*$` regex failed
// the whole match on any newline, so every multiline slash command parsed as
// an empty name and got swallowed (#41323, #55510). The backend and CLI both
// split on any whitespace (`split(maxsplit=1)`), so this is the parity fix.
const match = command.replace(/^\/+/, '').match(/^(\S+)([\s\S]*)$/)
return match ? { name: match[1], arg: match[2].trim() } : { name: '', arg: '' }
}

View file

@ -142,12 +142,22 @@ describe('desktop filesystem facade', () => {
expect(selectPaths).not.toHaveBeenCalled()
})
it('uses the local Electron picker for remote file selection', async () => {
const remoteSelect = vi.fn(async () => ['/remote/project'])
$connection.set({ mode: 'remote' } as never)
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
await expect(selectDesktopPaths({ directories: false, multiple: false })).resolves.toEqual(['/local'])
expect(selectPaths).toHaveBeenCalledWith({ directories: false, multiple: false })
expect(remoteSelect).not.toHaveBeenCalled()
})
it('limits the remote picker to single-directory selection', async () => {
const remoteSelect = vi.fn(async () => ['/remote/project'])
$connection.set({ mode: 'remote' } as never)
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
await expect(selectDesktopPaths({ directories: false, multiple: false })).resolves.toEqual([])
await expect(selectDesktopPaths({ directories: true })).resolves.toEqual(['/remote/project'])
expect(remoteSelect).toHaveBeenCalledWith({ directories: true, multiple: false })

View file

@ -179,7 +179,7 @@ export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Pr
}
if (!options?.directories) {
return []
return desktop.selectPaths(options)
}
return remotePicker ? remotePicker.selectPaths({ ...options, multiple: false }) : []

View file

@ -73,6 +73,18 @@ describe('desktop slash command curation', () => {
expect(resolveDesktopCommand('/browser')?.args).toBe(true)
})
it('routes /journey (and aliases) to the memory graph overlay action', () => {
expect(resolveDesktopCommand('/journey')?.surface).toEqual({ kind: 'action', action: 'journey' })
expect(resolveDesktopCommand('/memory-graph')?.surface).toEqual({ kind: 'action', action: 'journey' })
expect(resolveDesktopCommand('/learning')?.surface).toEqual({ kind: 'action', action: 'journey' })
expect(isDesktopSlashCommand('/journey')).toBe(true)
expect(isDesktopSlashCommand('/memory-graph')).toBe(true)
expect(isDesktopSlashSuggestion('/journey')).toBe(true)
// Aliases execute but stay out of the popover.
expect(isDesktopSlashSuggestion('/memory-graph')).toBe(false)
expect(desktopSlashUnavailableMessage('/journey')).toBeNull()
})
it('allows aliases to execute without cluttering the popover', () => {
expect(isDesktopSlashSuggestion('/reset')).toBe(false)
expect(isDesktopSlashCommand('/reset')).toBe(true)

View file

@ -34,6 +34,7 @@ export type DesktopActionId =
| 'handoff'
| 'hatch'
| 'help'
| 'journey'
| 'new'
| 'pet'
| 'profile'
@ -122,6 +123,12 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
surface: action('browser'),
args: true
},
{
name: '/journey',
description: 'Open the memory graph — skills + memories over time',
aliases: ['/learning', '/memory-graph'],
surface: action('journey')
},
// Overlay pickers
{ name: '/model', description: 'Switch the model for this session', surface: picker('model'), hidden: true },

View file

@ -2,7 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway, mediaExternalUrl } from './media'
import {
downloadGatewayMediaFile,
filePathFromMediaPath,
gatewayMediaDataUrl,
isRemoteGateway,
mediaExternalUrl
} from './media'
describe('isRemoteGateway', () => {
afterEach(() => {
@ -68,23 +74,79 @@ describe('mediaExternalUrl', () => {
})
describe('gatewayMediaDataUrl', () => {
const api = vi.fn(async () => ({ data_url: 'data:image/png;base64,ZHVtbXk=' }))
const api = vi.fn(async ({ path }: { path: string }) => {
if (path.startsWith('/api/fs/read-data-url?')) {
return { dataUrl: 'data:image/png;base64,ZHVtbXk=' }
}
throw new Error(`unexpected path ${path}`)
})
beforeEach(() => {
api.mockClear()
vi.stubGlobal('window', { hermesDesktop: { api } })
$connection.set({ mode: 'remote' } as never)
})
afterEach(() => {
vi.unstubAllGlobals()
$connection.set(null)
})
it('requests the encoded gateway path and returns the data URL', async () => {
const url = await gatewayMediaDataUrl('/home/u/.hermes/images/a b.png')
it('reads gateway media through the desktop fs bridge instead of /api/media roots', async () => {
const url = await gatewayMediaDataUrl('/home/u/.hermes/skills/demo/images/a b.png')
expect(url).toBe('data:image/png;base64,ZHVtbXk=')
expect(api).toHaveBeenCalledWith({
path: '/api/media?path=%2Fhome%2Fu%2F.hermes%2Fimages%2Fa%20b.png'
path: '/api/fs/read-data-url?path=%2Fhome%2Fu%2F.hermes%2Fskills%2Fdemo%2Fimages%2Fa%20b.png'
})
})
})
describe('downloadGatewayMediaFile', () => {
const api = vi.fn(async ({ path }: { path: string }) => {
if (path.startsWith('/api/fs/read-data-url?')) {
return { dataUrl: 'data:text/markdown;base64,IyByZXBvcnQ=' }
}
throw new Error(`unexpected path ${path}`)
})
let clickSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
api.mockClear()
vi.stubGlobal('window', { hermesDesktop: { api }, setTimeout: vi.fn() })
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ blob: async () => new Blob(['# report'], { type: 'text/markdown' }) }))
)
URL.createObjectURL = vi.fn(() => 'blob:remote-artifact')
URL.revokeObjectURL = vi.fn()
clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
$connection.set({ mode: 'remote' } as never)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
clickSpy.mockRestore()
$connection.set(null)
})
it('downloads gateway files through the desktop fs bridge', async () => {
await downloadGatewayMediaFile('file:///Users/me/project/report.md')
expect(api).toHaveBeenCalledWith({
path: '/api/fs/read-data-url?path=%2FUsers%2Fme%2Fproject%2Freport.md'
})
expect(clickSpy).toHaveBeenCalledOnce()
})
it('rejects when the gateway refuses the file read', async () => {
api.mockRejectedValueOnce(new Error('403 File is not readable'))
await expect(downloadGatewayMediaFile('/Users/me/project/report.md')).rejects.toThrow('403')
expect(clickSpy).not.toHaveBeenCalled()
})
})

View file

@ -1,3 +1,4 @@
import { readDesktopFileDataUrl } from '@/lib/desktop-fs'
import { $connection } from '@/store/session'
export type MediaKind = 'audio' | 'image' | 'video' | 'file'
@ -114,18 +115,34 @@ export function isRemoteGateway(): boolean {
return $connection.get()?.mode === 'remote'
}
// Fetch a gateway-local image as a data URL via the authenticated REST bridge.
// Used in remote mode where readFileDataUrl (which reads THIS machine's disk)
// can't see files the agent wrote on the gateway. Requires the gateway to
// expose GET /api/media (hermes_cli/web_server.py).
// Fetch gateway-local media as a data URL via the authenticated desktop FS
// bridge. Remote Desktop artifacts can live anywhere the gateway can read
// (workspace, skills, ~/.hermes/cache, etc.); /api/media is intentionally
// narrower and rejects non-images plus images outside its media roots.
export async function gatewayMediaDataUrl(path: string): Promise<string> {
const file = filePathFromMediaPath(path)
return readDesktopFileDataUrl(filePathFromMediaPath(path))
}
const result = await window.hermesDesktop!.api<{ data_url: string }>({
path: `/api/media?path=${encodeURIComponent(file)}`
})
// Remote-mode replacement for opening gateway-local file paths with file://.
// The file lives on the gateway, so fetch it over the authenticated fs bridge
// and hand the bytes to the local browser shell as a download.
export async function downloadGatewayMediaFile(path: string): Promise<void> {
const dataUrl = await readDesktopFileDataUrl(filePathFromMediaPath(path))
return result.data_url
if (!dataUrl) {
throw new Error('Gateway returned no file data')
}
const response = await fetch(dataUrl)
const blobUrl = URL.createObjectURL(await response.blob())
const anchor = document.createElement('a')
anchor.href = blobUrl
anchor.download = mediaName(path)
anchor.rel = 'noopener noreferrer'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
}
export function mediaDisplayLabel(path: string): string {

View file

@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getGlobalModelOptions } from '@/hermes'
import { requestModelOptions } from './model-options'
const globalOptions = { model: 'hermes-4', provider: 'nous', providers: [] }
vi.mock('@/hermes', () => ({
getGlobalModelOptions: vi.fn(() => Promise.resolve(globalOptions))
}))
describe('requestModelOptions', () => {
afterEach(() => {
vi.clearAllMocks()
})
it('uses the connected gateway even before a session exists', async () => {
const gatewayPayload = { model: 'BeastMode', provider: 'moa', providers: [] }
const gateway = {
request: vi.fn(() => Promise.resolve(gatewayPayload))
}
await expect(requestModelOptions({ gateway: gateway as never, sessionId: null })).resolves.toBe(gatewayPayload)
expect(gateway.request).toHaveBeenCalledWith('model.options', {})
expect(getGlobalModelOptions).not.toHaveBeenCalled()
})
it('passes the active session id and refresh flag through the gateway', async () => {
const gateway = {
request: vi.fn(() => Promise.resolve(globalOptions))
}
await requestModelOptions({ gateway: gateway as never, refresh: true, sessionId: 'session-1' })
expect(gateway.request).toHaveBeenCalledWith('model.options', {
refresh: true,
session_id: 'session-1'
})
})
it('falls back to REST when no gateway is connected', async () => {
await requestModelOptions({ refresh: true })
expect(getGlobalModelOptions).toHaveBeenCalledWith({ refresh: true })
})
})

View file

@ -0,0 +1,25 @@
import { getGlobalModelOptions, type HermesGateway, type ModelOptionsResponse } from '@/hermes'
interface ModelOptionsRequest {
gateway?: HermesGateway
refresh?: boolean
sessionId?: null | string
}
export function requestModelOptions({ gateway, refresh = false, sessionId }: ModelOptionsRequest): Promise<ModelOptionsResponse> {
if (gateway) {
const params: Record<string, unknown> = {}
if (sessionId) {
params.session_id = sessionId
}
if (refresh) {
params.refresh = true
}
return gateway.request<ModelOptionsResponse>('model.options', params)
}
return getGlobalModelOptions(refresh ? { refresh: true } : undefined)
}

View file

@ -18,7 +18,6 @@ import type { ModelOptionProvider, OAuthProvider, OAuthStartResponse } from '@/t
type PkceStart = Extract<OAuthStartResponse, { flow: 'pkce' }>
type DeviceStart = Extract<OAuthStartResponse, { flow: 'device_code' }>
type LoopbackStart = Extract<OAuthStartResponse, { flow: 'loopback' }>
export type OnboardingMode = 'apikey' | 'oauth'
@ -27,10 +26,6 @@ export type OnboardingFlow =
| { provider: OAuthProvider; status: 'starting' }
| { code: string; provider: OAuthProvider; start: PkceStart; status: 'awaiting_user' }
| { copied: boolean; provider: OAuthProvider; start: DeviceStart; status: 'polling' }
// Loopback PKCE (xAI Grok): browser opens, the local backend's 127.0.0.1
// listener catches the redirect, and we poll until the worker finishes.
// No code to paste and no user_code to show — just a waiting state.
| { provider: OAuthProvider; start: LoopbackStart; status: 'awaiting_browser' }
| { provider: OAuthProvider; start: OAuthStartResponse; status: 'submitting' }
| { copied: boolean; provider: OAuthProvider; status: 'external_pending' }
| { provider: OAuthProvider; status: 'success' }
@ -593,15 +588,6 @@ export async function startProviderOAuth(provider: OAuthProvider, ctx: Onboardin
return
}
if (start.flow === 'loopback') {
// No code to paste: the redirect lands on the backend's loopback
// listener. Just wait and poll the session until the worker finishes.
setFlow({ status: 'awaiting_browser', provider, start })
pollTimer = window.setInterval(() => void pollSession(provider, start, ctx), POLL_MS)
return
}
setFlow({ status: 'polling', provider, start, copied: false })
pollTimer = window.setInterval(() => void pollSession(provider, start, ctx), POLL_MS)
} catch (error) {
@ -609,10 +595,8 @@ export async function startProviderOAuth(provider: OAuthProvider, ctx: Onboardin
}
}
// Poll a session-backed flow (device_code or loopback) until it resolves.
// Both shapes only need the session_id to poll; the start is threaded
// through to the error flow so the user can retry from the same context.
async function pollSession(provider: OAuthProvider, start: DeviceStart | LoopbackStart, ctx: OnboardingContext) {
// Poll a session-backed device-code flow until it resolves.
async function pollSession(provider: OAuthProvider, start: DeviceStart, ctx: OnboardingContext) {
try {
const { error_message, status } = await pollOAuthSession(provider.id, start.session_id)

View file

@ -2,6 +2,7 @@ import { atom } from 'nanostores'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesConnection } from '@/global'
import type { ProfileInfo } from '@/types/hermes'
// Keep profile.ts's side-effecting imports inert: the gateway socket layer and
// the REST query client must not run for real in a unit test.
@ -17,9 +18,20 @@ vi.mock('@/hermes', () => ({
vi.mock('@/lib/query-client', () => ({ queryClient: { invalidateQueries: vi.fn() } }))
vi.mock('@/store/starmap', () => ({ resetStarmapGraph }))
const { $activeGatewayProfile, ensureGatewayProfile } = await import('./profile')
const { $activeGatewayProfile, $profiles, ensureGatewayProfile, refreshProfiles } = await import('./profile')
const { $connection } = await import('./session')
const { queryClient } = await import('@/lib/query-client')
const { getProfiles } = await import('@/hermes')
const profile = (name: string, isDefault = false): ProfileInfo => ({
has_env: false,
is_default: isDefault,
model: null,
name,
path: `/tmp/hermes/${name}`,
provider: null,
skill_count: 0
})
const remoteConn = (over: Partial<HermesConnection> = {}): HermesConnection =>
({ baseUrl: 'https://hermes-roy.tail.ts.net', mode: 'remote', profile: 'vps-remote', ...over }) as HermesConnection
@ -35,6 +47,7 @@ beforeEach(() => {
$gateway.set({ id: 'live-socket' })
$activeGatewayProfile.set('default')
$connection.set(localConn())
$profiles.set([])
vi.stubGlobal('window', { hermesDesktop: { getConnection } })
vi.mocked(queryClient.invalidateQueries).mockClear()
resetStarmapGraph.mockClear()
@ -101,3 +114,23 @@ describe('profile-scoped cache invalidation', () => {
expect(resetStarmapGraph).toHaveBeenCalledTimes(1)
})
})
describe('refreshProfiles shared rail list (#49289)', () => {
it('removes a deleted profile from the shared $profiles cache after Manage Profiles refreshes', async () => {
$profiles.set([profile('default', true), profile('test1')])
vi.mocked(getProfiles).mockResolvedValueOnce({ profiles: [profile('default', true)] })
await refreshProfiles()
expect($profiles.get().map(profile => profile.name)).toEqual(['default'])
})
it('leaves the shared $profiles cache intact when the refresh fails', async () => {
$profiles.set([profile('default', true), profile('test1')])
vi.mocked(getProfiles).mockRejectedValueOnce(new Error('backend unavailable'))
await expect(refreshProfiles()).rejects.toThrow('backend unavailable')
expect($profiles.get().map(profile => profile.name)).toEqual(['default', 'test1'])
})
})

View file

@ -38,6 +38,13 @@ export function setActiveProfile(name: string): void {
$activeProfile.set(name || 'default')
}
export async function refreshProfiles(): Promise<ProfileInfo[]> {
const { profiles } = await getProfiles()
$profiles.set(profiles)
return profiles
}
// ── Rail order ─────────────────────────────────────────────────────────────
// User-defined order for the named (non-default) profile squares in the rail.
// Names absent from the list fall back to alphabetical, appended at the tail —
@ -111,8 +118,7 @@ export async function refreshActiveProfile(): Promise<void> {
}
try {
const { profiles } = await getProfiles()
$profiles.set(profiles)
await refreshProfiles()
} catch {
// Leave the cached list in place.
}

View file

@ -51,7 +51,10 @@ const {
applyUpdates,
$updateApply,
$updateOverlayOpen,
resetUpdateApplyState
resetUpdateApplyState,
startUpdatePoller,
stopUpdatePoller,
$updateStatus
} = await import('./updates')
const { setConnection } = await import('./session')
@ -454,3 +457,72 @@ describe('applyBackendUpdate recovery', () => {
expect($backendUpdateApply.get().stage).toBe('error')
})
})
describe('startUpdatePoller', () => {
const checkMock = vi.fn()
const onProgressMock = vi.fn()
const listeners: Record<string, Function> = {}
beforeEach(() => {
storage.clear()
checkMock.mockReset()
onProgressMock.mockReset()
Object.keys(listeners).forEach(k => delete listeners[k])
checkMock.mockResolvedValue({
supported: true,
behind: 5,
targetSha: 'sha-abc',
fetchedAt: 0
})
$updateStatus.set(null)
;(globalThis as unknown as { window: unknown }).window = {
hermesDesktop: { updates: { check: checkMock, onProgress: onProgressMock } },
addEventListener: vi.fn((event: string, handler: Function) => {
listeners[event] = handler
}),
removeEventListener: vi.fn()
}
vi.useFakeTimers()
stopUpdatePoller()
})
afterEach(() => {
stopUpdatePoller()
delete (globalThis as unknown as { window?: unknown }).window
vi.useRealTimers()
})
it('calls checkUpdates() on startup so the version pill populates immediately', async () => {
startUpdatePoller()
// checkUpdates() is async — flush microtasks without advancing the 30-min interval.
await vi.advanceTimersByTimeAsync(0)
expect(checkMock).toHaveBeenCalled()
expect($updateStatus.get()?.behind).toBe(5)
})
it('calls checkUpdates() on each interval tick', async () => {
startUpdatePoller()
await vi.advanceTimersByTimeAsync(0)
checkMock.mockClear()
await vi.advanceTimersByTimeAsync(30 * 60 * 1000)
expect(checkMock).toHaveBeenCalled()
})
it('calls checkUpdates() when the window regains focus', async () => {
startUpdatePoller()
await vi.advanceTimersByTimeAsync(0)
checkMock.mockClear()
// Invoke the registered focus handler directly (the mock window doesn't
// propagate DOM events, so call the stored listener).
listeners['focus']?.()
await vi.advanceTimersByTimeAsync(0)
expect(checkMock).toHaveBeenCalled()
})
})

View file

@ -611,6 +611,7 @@ export function startUpdatePoller(): void {
}
pollerStarted = true
void checkUpdates()
void checkBackendUpdates()
void refreshDesktopVersion()
bridge.onProgress(ingestProgress)
@ -633,6 +634,7 @@ export function startUpdatePoller(): void {
window.addEventListener('focus', onFocus)
backgroundTimer = setInterval(
() => {
void checkUpdates()
void checkBackendUpdates()
},
30 * 60 * 1000
@ -660,6 +662,7 @@ function onFocus() {
}
lastFocusAt = now
void checkUpdates()
void checkBackendUpdates()
void refreshDesktopVersion()
}

View file

@ -53,7 +53,7 @@ export interface OAuthProvider {
disconnect_hint?: null | string
disconnectable?: boolean
docs_url: string
flow: 'device_code' | 'external' | 'loopback' | 'pkce'
flow: 'device_code' | 'external' | 'pkce'
id: string
name: string
status: OAuthProviderStatus
@ -78,12 +78,6 @@ export type OAuthStartResponse =
user_code: string
verification_url: string
}
| {
auth_url: string
expires_in: number
flow: 'loopback'
session_id: string
}
export interface OAuthSubmitResponse {
message?: string

10
cli.py
View file

@ -334,11 +334,15 @@ def _resolve_prefill_messages_file(config: Dict[str, Any]) -> str:
return ""
def _parse_reasoning_config(effort: str) -> dict | None:
"""Parse a reasoning effort level into an OpenRouter reasoning config dict."""
def _parse_reasoning_config(effort) -> dict | None:
"""Parse a reasoning effort level into an OpenRouter reasoning config dict.
Accepts the raw config value (string or YAML boolean ``false``/``off``
parse as thinking disabled, see parse_reasoning_effort).
"""
from hermes_constants import parse_reasoning_effort
result = parse_reasoning_effort(effort)
if effort and effort.strip() and result is None:
if effort and str(effort).strip() and result is None:
logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort)
return result

View file

@ -2620,10 +2620,12 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
except Exception:
pass
# Reasoning config from config.yaml
# Reasoning config from config.yaml (raw value — a YAML boolean False
# means thinking disabled, see parse_reasoning_effort)
from hermes_constants import parse_reasoning_effort
effort = str(_cfg.get("agent", {}).get("reasoning_effort", "")).strip()
reasoning_config = parse_reasoning_effort(effort)
reasoning_config = parse_reasoning_effort(
_cfg.get("agent", {}).get("reasoning_effort", "")
)
# Prefill messages from env or config.yaml. The top-level
# prefill_messages_file key is canonical; agent.prefill_messages_file is

View file

@ -708,8 +708,19 @@ class WebhookAdapter(BasePlatformAdapter):
delivery_id,
)
# Non-blocking — return 202 Accepted immediately
task = asyncio.create_task(self.handle_message(event))
# Non-blocking — return 202 Accepted immediately. Wrap the agent run
# so that the per-delivery webhook session is marked ended in state.db
# once the run finishes. A webhook delivery uses a unique one-shot
# session (delivery_id is baked into the session key above), so it
# will never receive another turn — it must be closed on completion,
# exactly like a cron run closes its session with "cron_complete"
# (cron/scheduler.py). Without this, webhook sessions keep
# ``ended_at`` NULL forever; ``SessionDB.prune_sessions`` only reaps
# rows with ``ended_at`` set, so unclosed webhook sessions accumulate
# unbounded and drive state.db bloat (the ghost-session leak).
task = asyncio.create_task(
self._run_delivery_and_close(event, session_chat_id)
)
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
@ -723,6 +734,90 @@ class WebhookAdapter(BasePlatformAdapter):
status=202,
)
async def _run_delivery_and_close(
self, event: "MessageEvent", session_chat_id: str
) -> None:
"""Run the agent for one webhook delivery, then close its session.
A webhook delivery is one-shot: the ``delivery_id`` is baked into the
session key so the session will never receive a second turn. Mirror
the cron completion path (``cron/scheduler.py``
``end_session(..., "cron_complete")``) by marking the session ended
once the run finishes. ``end_session()`` is first-reason-wins and
no-ops on an already-ended row, so this is safe even when a terminal
path (compression split, ``/new``, ``agent_close``) already closed it.
The close runs in ``finally`` so an agent error still reaps the row
otherwise the ghost-session leak persists on the failure path too.
"""
try:
await self.handle_message(event)
finally:
await self._end_webhook_session(event, session_chat_id)
async def _end_webhook_session(
self, event: "MessageEvent", session_chat_id: str
) -> None:
"""Mark the per-delivery webhook session ended in state.db.
Resolves the persisted ``session_id`` from the gateway session store
using the SAME source the run was keyed on (so profile multiplexing
and key construction match exactly), then closes it via the existing
``SessionDB.end_session`` API never a hand-written UPDATE.
"""
runner = self.gateway_runner
if runner is None:
return
session_db = getattr(runner, "_session_db", None)
store = getattr(runner, "session_store", None)
if session_db is None or store is None:
return
try:
key_fn = getattr(runner, "_session_key_for_source", None)
if key_fn is None:
return
session_key = key_fn(event.source)
# Resolve the persisted session_id via the store's public,
# lock-held accessor (peek_session_id) rather than reaching into
# the private _entries dict without the store lock. Fall back to
# the private path only for older stores / test doubles that
# predate the accessor.
peek = getattr(store, "peek_session_id", None)
if callable(peek):
session_id = peek(session_key)
else:
if hasattr(store, "_ensure_loaded"):
try:
store._ensure_loaded()
except Exception:
pass
entries = getattr(store, "_entries", {}) or {}
entry = entries.get(session_key)
session_id = getattr(entry, "session_id", None) if entry else None
if not session_id:
logger.debug(
"[webhook] No session_id to close for %s (key=%s)",
session_chat_id,
session_key,
)
return
# AsyncSessionDB forwards end_session via asyncio.to_thread; a
# plain SessionDB exposes it synchronously. Handle both.
_end = session_db.end_session
result = _end(session_id, "webhook_complete")
if asyncio.iscoroutine(result):
await result
logger.debug(
"[webhook] Closed session %s for delivery %s",
session_id,
session_chat_id,
)
except Exception as e:
logger.debug(
"[webhook] Failed to close session for %s: %s",
session_chat_id,
e,
)
# ------------------------------------------------------------------
# Signature validation
# ------------------------------------------------------------------

View file

@ -4643,9 +4643,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"""
from hermes_constants import parse_reasoning_effort
cfg = _load_gateway_runtime_config()
effort = str(cfg_get(cfg, "agent", "reasoning_effort", default="") or "").strip()
# Keep the raw value — coercing with ``or ""`` turns a YAML boolean
# False (``reasoning_effort: false``/``off``/``no``) into "", silently
# re-enabling thinking for users who explicitly disabled it.
effort = cfg_get(cfg, "agent", "reasoning_effort", default="")
result = parse_reasoning_effort(effort)
if effort and effort.strip() and result is None:
if effort and str(effort).strip() and result is None:
logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort)
return result
@ -15586,6 +15589,72 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
agent._last_flushed_db_idx = 0
agent._api_call_count = 0
def _commit_memory_before_soft_evict(self, agent: Any, key: str) -> None:
"""Fire on_session_end extraction before soft-evicting a live agent.
Soft eviction (``_release_evicted_agent_soft``) deliberately keeps the
session resumable and does NOT fire ``on_session_end`` that hook is
reserved for the true session boundary, tear-down done by
``_session_expiry_watcher`` when the session finally expires.
But the watcher tears down whatever agent it finds in ``_agent_cache``
at expiry time. If cache pressure (the LRU cap) soft-evicts a
finalizable session's agent BEFORE it expires, the watcher later finds
no cached agent and ``on_session_end`` is silently skipped memory
providers never see the transcript (#11205, LRU-cap variant).
We hold the live, fully-scoped agent right now, so commit its
end-of-session memory extraction here using the agent's own memory
manager (correct per-user/chat scoping, no reconstruction). This uses
``commit_memory_session`` extraction WITHOUT provider teardown so
the eviction stays soft and a resumed turn keeps working.
Only fires for sessions the expiry watcher will eventually finalize
(finite reset policy). For ``mode == "none"`` sessions the watcher
never runs, so there is no missed-boundary to compensate for and we
skip the commit (the agent is simply released). Best-effort: any
failure is swallowed so eviction still proceeds.
"""
if agent is None or not hasattr(agent, "commit_memory_session"):
return
if getattr(agent, "_memory_manager", None) is None:
return # no external memory provider — nothing to commit
try:
_store = getattr(self, "session_store", None)
if _store is None:
return
_store._ensure_loaded()
entry = _store._entries.get(key)
if entry is None:
return
# Only compensate when the watcher would otherwise expect to find
# this agent at expiry (finite policy, not yet expired). Expired
# sessions are torn down by the watcher directly; mode="none"
# sessions are never finalized.
if not _store.is_session_finalizable(entry):
return
if _store._is_session_expired(entry):
return
messages = getattr(agent, "_session_messages", None)
agent.commit_memory_session(messages if isinstance(messages, list) else None)
logger.debug(
"Committed on_session_end extraction before soft-evicting "
"finalizable session=%s (cache pressure, pre-expiry)", key,
)
except Exception as _e:
logger.debug("Pre-evict memory commit failed for %s: %s", key, _e)
def _commit_then_release_soft(self, agent: Any, key: str) -> None:
"""Commit end-of-session memory (if warranted), then soft-release.
Runs on the daemon eviction thread so the memory-provider call and the
client teardown never block the caller's held cache lock. Order matters:
commit uses the live agent's memory manager before ``release_clients``
drops the message buffer.
"""
self._commit_memory_before_soft_evict(agent, key)
self._release_evicted_agent_soft(agent)
def _release_evicted_agent_soft(self, agent: Any) -> None:
"""Soft cleanup for cache-evicted agents — preserves session tool state.
@ -15684,9 +15753,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
key, len(_cache),
)
if agent is not None:
# Commit end-of-session memory extraction, then soft-release,
# both on the daemon thread so the (possibly network-bound)
# provider call never blocks the held cache lock. The commit
# only fires for finalizable-not-yet-expired sessions whose
# agent would otherwise vanish before the expiry watcher can
# fire on_session_end (#11205, LRU-cap variant).
threading.Thread(
target=self._release_evicted_agent_soft,
args=(agent,),
target=self._commit_then_release_soft,
args=(agent, key),
daemon=True,
name=f"agent-cache-evict-{key[:24]}",
).start()
@ -15724,6 +15799,45 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if last_activity is None:
continue
if (now - last_activity) > _AGENT_CACHE_IDLE_TTL_SECS:
# Check whether the session has actually expired in the
# session store. If it hasn't (e.g. daily-reset mode
# where the reset fires hours after the user's last
# message), keep the agent in cache so the session-store
# expiry watcher can still find it and call
# on_session_end() with the live transcript. Skipping
# eviction here means the agent stays alive until the
# session genuinely expires, at which point the watcher
# (gateway/run.py _session_expiry_watcher) tears it down
# properly. (#11205 follow-up)
#
# BUT only defer when the watcher will EVER finalize this
# session. For a mode == "none" session the watcher never
# fires (is_session_finalizable() is False), so deferring
# would pin the agent in cache for the gateway's entire
# lifetime — the exact leak this idle sweep exists to
# relieve. Those sessions fall through to soft eviction
# WITHOUT on_session_end, and that is correct: a mode=="none"
# session never reaches a session-end boundary, so there is
# no missed on_session_end to compensate for. (The finite
# case — a session evicted under LRU-cap pressure before it
# expires — is instead covered by _commit_memory_before_soft_
# evict on the cap path, which fires on_session_end via the
# live agent's memory manager before releasing it.)
session_entry = None
_store = getattr(self, "session_store", None)
try:
if _store is not None:
_store._ensure_loaded()
session_entry = _store._entries.get(key)
except Exception:
session_entry = None
if (
session_entry is not None
and _store is not None
and _store.is_session_finalizable(session_entry)
and not _store._is_session_expired(session_entry)
):
continue # keep agent — finite session hasn't expired
to_evict.append((key, agent))
for key, _ in to_evict:
_cache.pop(key, None)

View file

@ -1264,6 +1264,37 @@ class SessionStore:
return False
def is_session_finalizable(self, entry: SessionEntry) -> bool:
"""Return True if the expiry watcher will *ever* finalize this session.
The expiry watcher (``GatewayRunner._session_expiry_watcher``) only
tears an agent down and only then fires ``on_session_end`` for
sessions whose reset policy eventually expires. A ``mode == "none"``
session never expires (``_is_session_expired`` returns ``False``
forever), so the watcher will never finalize it.
This distinction matters for the agent-cache idle sweep: deferring
idle eviction to "let the watcher finalize it later" is only correct
when the watcher WILL run for this session. For a ``mode == "none"``
session, deferring pins the cached agent in memory for the gateway's
entire lifetime with no finalization ever coming the exact leak the
idle sweep exists to relieve. Callers use this predicate to decide
whether the session store owns the eviction boundary (finalizable) or
the idle sweep must still reap the agent itself (not finalizable).
Public wrapper so callers don't reach into policy internals. Errors
resolving the policy are treated as "not finalizable" (safe: the idle
sweep falls back to reaping the agent rather than pinning it).
"""
try:
policy = self.config.get_reset_policy(
platform=entry.platform,
session_type=entry.chat_type,
)
return policy.mode != "none"
except Exception:
return False
def _is_session_ended_in_db(self, session_id: str) -> bool:
"""Return True iff state.db has this session with a non-null end_reason.
@ -1887,6 +1918,22 @@ class SessionStore:
if entry.session_id == session_id:
return entry
return None
def peek_session_id(self, session_key: str) -> Optional[str]:
"""Return the persisted session_id currently bound to a session key.
Public, lock-held accessor for the keysession_id mapping. Callers that
need to resolve the session row for a source (e.g. the webhook
delivery-close path) should use this rather than reaching into the
private ``_entries`` dict without holding ``self._lock``. Returns None
when the key is unknown or has no session_id yet.
"""
if not session_key:
return None
with self._lock:
self._ensure_loaded_locked()
entry = self._entries.get(session_key)
return getattr(entry, "session_id", None) if entry else None
def append_to_transcript(self, session_id: str, message: Dict[str, Any], skip_db: bool = False) -> None:
"""Append a message to a session's transcript (SQLite).

File diff suppressed because it is too large Load diff

View file

@ -345,19 +345,19 @@ def auth_add_command(args) -> None:
return
if provider == "xai-oauth":
creds = auth_mod._xai_oauth_loopback_login(
creds = auth_mod._xai_oauth_device_code_login(
timeout_seconds=getattr(args, "timeout", None) or 20.0,
open_browser=not getattr(args, "no_browser", False),
manual_paste=bool(getattr(args, "manual_paste", False)),
)
auth_mod._save_xai_oauth_tokens(
creds["tokens"],
discovery=creds.get("discovery"),
redirect_uri=creds.get("redirect_uri", ""),
last_refresh=creds.get("last_refresh"),
auth_mode="oauth_device_code",
)
pool = load_pool(provider)
entry = next((e for e in pool.entries() if getattr(e, "source", "") == "loopback_pkce"), None)
entry = next((e for e in pool.entries() if getattr(e, "source", "") == "device_code"), None)
shown_label = entry.label if entry is not None else label_from_token(
creds["tokens"]["access_token"], _oauth_default_label(provider, 1)
)

View file

@ -847,6 +847,15 @@ def ensure_hermes_home():
any files created (e.g. SOUL.md) are group-writable (0660).
"""
home = get_hermes_home()
# Named profiles must be created explicitly (e.g. ``hermes profile create``).
# If a stale process keeps running after the profile was renamed/deleted,
# silently mkdir-ing the old HERMES_HOME would resurrect an empty skeleton
# and make the deleted profile reappear in Desktop/profile lists.
if home.parent.name == "profiles" and not home.exists():
raise FileNotFoundError(
f"Named profile home does not exist: {home}. "
"Create the profile explicitly before using it."
)
if is_managed():
old_umask = os.umask(0o007)
try:

View file

@ -567,12 +567,7 @@ def _model_flow_xai_oauth(_config, current_model="", *, args=None):
print("Starting a fresh xAI OAuth login...")
print()
try:
# Forward CLI flags from ``hermes model --manual-paste``
# / ``--no-browser`` / ``--timeout`` into the loopback
# login. Without this, browser-only remotes (#26923)
# can't reach the manual-paste path via ``hermes model``.
mock_args = argparse.Namespace(
manual_paste=bool(getattr(args, "manual_paste", False)),
no_browser=bool(getattr(args, "no_browser", False)),
timeout=getattr(args, "timeout", None),
)
@ -594,7 +589,6 @@ def _model_flow_xai_oauth(_config, current_model="", *, args=None):
print()
try:
mock_args = argparse.Namespace(
manual_paste=bool(getattr(args, "manual_paste", False)),
no_browser=bool(getattr(args, "no_browser", False)),
timeout=getattr(args, "timeout", None),
)

View file

@ -285,17 +285,14 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"xai": _xai_curated_models(),
"nvidia": [
# NVIDIA flagship reasoning models
"nvidia/nemotron-3-ultra-550b-a55b",
"nvidia/nemotron-3-super-120b-a12b",
"nvidia/nemotron-3-nano-30b-a3b",
"nvidia/llama-3.3-nemotron-super-49b-v1.5",
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning",
# Third-party agentic models hosted on build.nvidia.com
# (map to OpenRouter defaults — users get familiar picks on NIM)
"qwen/qwen3.5-397b-a17b",
"deepseek-ai/deepseek-v3.2",
"z-ai/glm-5.2",
"moonshotai/kimi-k2.6",
"minimaxai/minimax-m2.5",
"z-ai/glm5",
"openai/gpt-oss-120b",
"minimaxai/minimax-m3",
],
"kimi-coding": [
"kimi-k2.7-code",

View file

@ -1257,6 +1257,189 @@ def backfill_profile_envs(quiet: bool = False) -> List[str]:
return backfilled
def _profile_bound_backend_pids(canon: str, profile_dir: Path) -> list[int]:
"""PIDs of running Hermes *backends* bound to this profile.
The ``gateway.pid`` file only tracks the messaging gateway. A Desktop app
spawns a headless ``serve`` (or legacy ``dashboard --no-open``) backend per
profile that holds the profile's SQLite connection open and keeps writing
sessions/WAL/sandbox files the writer that makes ``rmtree`` hit
``ENOTEMPTY`` (and, pre-fix, resurrected the tree). ``gateway.pid`` never
names it, so find it by inspection: a Hermes backend subcommand
(``serve``/``dashboard``/``gateway``) that is bound to *this* profile either
by a ``--profile <canon>`` / ``-p <canon>`` selector or by a ``HERMES_HOME``
that resolves to ``profile_dir``.
Best-effort and tightly scoped: current-user processes only, backend
subcommands only (never an interactive ``chat``/``tui``), and never this
process or its ancestors. Returns an empty list if ``psutil`` can't
inspect anything.
"""
try:
import psutil # type: ignore
except Exception:
return []
try:
resolved_dir = profile_dir.resolve()
except OSError:
resolved_dir = profile_dir
# Never terminate ourselves or a parent (e.g. `hermes -p <canon> profile
# delete` runs under the very profile it's deleting).
skip: set[int] = {os.getpid()}
try:
parent = psutil.Process(os.getpid()).parent()
while parent is not None:
skip.add(parent.pid)
parent = parent.parent()
except Exception:
pass
try:
current_user = psutil.Process(os.getpid()).username()
except Exception:
current_user = None
backend_tokens = {"serve", "dashboard", "gateway"}
hermes_markers = ("hermes_cli.main", "hermes-gateway", "tui_gateway")
pids: list[int] = []
for proc in psutil.process_iter(["pid", "name", "username", "cmdline"]):
try:
info = proc.info
pid = info.get("pid")
if pid is None or pid in skip:
continue
if current_user is not None and info.get("username") != current_user:
continue
argv = info.get("cmdline") or []
if not argv:
continue
# Must be a Hermes process: either an entrypoint marker in argv, or
# a resolved executable named `hermes`.
joined = " ".join(argv)
exe_name = os.path.basename(argv[0]).lower()
is_hermes = (
any(marker in joined for marker in hermes_markers)
or exe_name == "hermes"
or exe_name.startswith("hermes")
)
if not is_hermes:
continue
# Restrict to backend subcommands so we never kill an interactive
# session the user is deliberately running.
tokens = {tok.lower() for tok in argv}
if not (tokens & backend_tokens):
continue
# Bound to THIS profile — by selector flag in argv...
bound = False
for i, tok in enumerate(argv):
if tok in {"--profile", "-p"} and i + 1 < len(argv):
if normalize_profile_name(argv[i + 1]) == canon:
bound = True
break
elif tok.startswith("--profile="):
if normalize_profile_name(tok.split("=", 1)[1]) == canon:
bound = True
break
# ...or by HERMES_HOME env pointing at this profile dir.
if not bound:
try:
env_home = (proc.environ() or {}).get("HERMES_HOME", "")
if env_home and Path(env_home).resolve() == resolved_dir:
bound = True
except Exception:
# environ() can raise AccessDenied even same-user on some
# platforms; fall back to the argv signal only.
pass
if bound:
pids.append(pid)
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
continue
except Exception:
continue
return pids
def _stop_profile_backends(canon: str, profile_dir: Path) -> None:
"""Terminate any Desktop-spawned / stray backends bound to this profile.
Complements ``_stop_gateway_process`` (which only knows ``gateway.pid``):
without this, a live ``serve``/``dashboard`` backend keeps creating files
under the profile dir while ``rmtree`` walks it, so the final ``rmdir``
fails with ``ENOTEMPTY`` and the delete doesn't converge. Best-effort:
any failure is reported and swallowed so it never makes delete worse.
"""
pids = _profile_bound_backend_pids(canon, profile_dir)
if not pids:
return
try:
from gateway.status import _pid_exists, terminate_pid as _terminate_pid
except Exception:
return
for pid in pids:
try:
_terminate_pid(pid) # graceful first
except (ProcessLookupError, PermissionError, OSError):
continue
# Wait up to 10s for graceful exit, then force-kill stragglers.
deadline = time.time() + 10.0
while time.time() < deadline:
if not any(_pid_exists(pid) for pid in pids):
break
time.sleep(0.5)
for pid in pids:
if _pid_exists(pid):
try:
_terminate_pid(pid, force=True)
except (ProcessLookupError, PermissionError, OSError):
pass
print(f"✓ Stopped {len(pids)} profile backend process(es)")
def _rmtree_with_retry(profile_dir: Path, onexc_handler) -> None:
"""``shutil.rmtree`` with a short retry loop for transient races.
Even after stopping the gateway and profile backends, a just-terminated
process can leave in-flight writes (SQLite ``-wal``/``-shm`` checkpoints,
sandbox temp files) that land after ``rmtree`` has walked past a directory,
surfacing as ``ENOTEMPTY`` (POSIX) or a transient ``PermissionError``
(Windows file lock still releasing). A few spaced retries let those settle
instead of failing the whole delete on a race the next attempt would win.
"""
attempts = 3
last_exc: OSError | None = None
for attempt in range(attempts):
try:
# ``onexc`` was added in 3.12; fall back to ``onerror`` on 3.11.
try:
shutil.rmtree(profile_dir, onexc=onexc_handler)
except TypeError:
shutil.rmtree(profile_dir, onerror=onexc_handler)
return
except OSError as e:
last_exc = e
if not profile_dir.exists():
return
if attempt < attempts - 1:
time.sleep(0.3 * (attempt + 1))
if last_exc is not None:
raise last_exc
def delete_profile(name: str, yes: bool = False) -> Path:
"""Delete a profile, its wrapper script, and its gateway service.
@ -1334,6 +1517,13 @@ def delete_profile(name: str, yes: bool = False) -> Path:
if gw_running:
_stop_gateway_process(profile_dir)
# 2b. Stop any other backends bound to this profile (Desktop-spawned
# serve/dashboard processes the gateway.pid file never names). They hold
# the profile's SQLite connection open and keep writing files, which makes
# the rmtree below fail with ENOTEMPTY and — before the ensure_hermes_home
# guard — resurrected the deleted tree.
_stop_profile_backends(canon, profile_dir)
# 3. Remove wrapper script
if has_wrapper:
if remove_wrapper_script(canon):
@ -1379,11 +1569,7 @@ def delete_profile(name: str, yes: bool = False) -> Path:
else:
raise
# ``onexc`` was added in 3.12; fall back to ``onerror`` on 3.11.
try:
shutil.rmtree(profile_dir, onexc=_make_writable)
except TypeError:
shutil.rmtree(profile_dir, onerror=_make_writable)
_rmtree_with_retry(profile_dir, _make_writable)
print(f"✓ Removed {profile_dir}")
except Exception as e:
print(f"⚠ Could not remove {profile_dir}: {e}")

View file

@ -881,7 +881,7 @@ def _xai_oauth_logged_in_for_setup() -> bool:
def _run_xai_oauth_login_from_setup() -> bool:
"""Run the xAI Grok OAuth loopback login from inside the setup wizard.
"""Run the xAI Grok OAuth device-code login from inside the setup wizard.
Returns True on success, False on any failure (the caller falls back
to whatever the user picked next, e.g. Edge TTS).
@ -892,7 +892,7 @@ def _run_xai_oauth_login_from_setup() -> bool:
_is_remote_session,
_save_xai_oauth_tokens,
_update_config_for_provider,
_xai_oauth_loopback_login,
_xai_oauth_device_code_login,
)
except Exception as exc:
print_warning(f"xAI Grok OAuth helpers unavailable: {exc}")
@ -902,12 +902,13 @@ def _run_xai_oauth_login_from_setup() -> bool:
print()
print_info("Signing in to xAI Grok OAuth (SuperGrok / Premium+)...")
try:
creds = _xai_oauth_loopback_login(open_browser=open_browser)
creds = _xai_oauth_device_code_login(open_browser=open_browser)
_save_xai_oauth_tokens(
creds["tokens"],
discovery=creds.get("discovery"),
redirect_uri=creds.get("redirect_uri", ""),
last_refresh=creds.get("last_refresh"),
auth_mode="oauth_device_code",
)
_update_config_for_provider(
"xai-oauth", creds.get("base_url", DEFAULT_XAI_OAUTH_BASE_URL)

View file

@ -40,17 +40,6 @@ def build_auth_parser(subparsers, *, cmd_auth: Callable) -> None:
action="store_true",
help="Do not auto-open a browser for OAuth login",
)
auth_add.add_argument(
"--manual-paste",
action="store_true",
help=(
"Skip the loopback callback listener and paste the failed "
"callback URL from your browser instead. Use this on "
"browser-only remotes (GCP Cloud Shell, GitHub Codespaces, "
"EC2 Instance Connect, ...) where 127.0.0.1 on the remote "
"isn't reachable from your laptop. See #26923."
),
)
auth_add.add_argument(
"--timeout", type=float, help="OAuth/network timeout in seconds"
)

View file

@ -45,16 +45,6 @@ def build_model_parser(subparsers, *, cmd_model: Callable) -> None:
action="store_true",
help="Do not attempt to open the browser automatically during Nous login",
)
model_parser.add_argument(
"--manual-paste",
action="store_true",
help=(
"For loopback OAuth providers (xai-oauth, ...): skip the local "
"callback listener and paste the failed callback URL from your "
"browser instead. Use on browser-only remotes (Cloud Shell, "
"Codespaces, EC2 Instance Connect, ...). See #26923."
),
)
model_parser.add_argument(
"--timeout",
type=float,

View file

@ -6427,7 +6427,7 @@ def _copilot_acp_status() -> Dict[str, Any]:
# ``flow`` describes the OAuth shape so the modal can pick the right UI:
# ``pkce`` = open URL + paste callback code, ``device_code`` = show code +
# verification URL + poll, ``external`` = read-only (delegated to a third-party
# CLI like Claude Code or Qwen), ``loopback`` = 127.0.0.1 callback listener.
# CLI like Claude Code or Qwen).
_OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
{
"id": "nous",
@ -6469,10 +6469,10 @@ _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
{
"id": "xai-oauth",
"name": "xAI Grok OAuth (SuperGrok / Premium+)",
# Loopback PKCE: the desktop's local backend binds a 127.0.0.1
# callback server, the client opens the browser, and the redirect
# lands back on the loopback listener — no code to copy/paste.
"flow": "loopback",
# Device code is the default because it works in remote shells,
# containers, and desktop installs without requiring a reachable
# 127.0.0.1 callback.
"flow": "device_code",
"cli_command": "hermes auth add xai-oauth",
"docs_url": "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth",
"status_fn": None, # dispatched via auth.get_xai_oauth_auth_status
@ -6696,7 +6696,7 @@ async def list_oauth_providers(profile: Optional[str] = None):
Response shape (per provider):
id stable identifier (used in DELETE path)
name human label
flow "pkce" | "device_code" | "external" | "loopback"
flow "pkce" | "device_code" | "external"
cli_command fallback CLI command for users to run manually
disconnect_command shell command that clears an external provider's
creds (run in the embedded terminal), else null
@ -6831,19 +6831,6 @@ async def disconnect_oauth_provider(
# 4. On "approved" the background thread has already saved creds; UI
# refreshes the providers list.
#
# Loopback PKCE (xAI Grok):
# 1. POST /api/providers/oauth/xai-oauth/start
# → server binds a 127.0.0.1 callback listener, builds the xAI
# authorize URL, spawns a background worker waiting on the redirect
# → returns { session_id, flow: "loopback", auth_url, expires_in }
# 2. UI opens auth_url in the browser. There is NO user_code/code to
# paste — the redirect lands back on the loopback listener.
# 3. UI polls GET /api/providers/oauth/{provider}/poll/{session_id}
# (same endpoint as device_code) until status != "pending".
# 4. The worker exchanges the code, persists creds, sets "approved".
# DELETE /sessions/{id} cancels: the worker bails before persisting
# and the callback server is shut down to free the port immediately.
#
# Sessions are kept in-memory only (single-process FastAPI) and time out
# after 15 minutes. A periodic cleanup runs on each /start call to GC
# expired sessions so the dict doesn't grow without bound.
@ -7103,7 +7090,7 @@ async def _start_device_code_flow(
provider_id: str,
profile: Optional[str] = None,
) -> Dict[str, Any]:
"""Initiate a device-code flow (Nous, OpenAI Codex, or MiniMax).
"""Initiate a device-code flow (Nous, OpenAI Codex, MiniMax, or xAI).
Calls the provider's device-auth endpoint via the existing CLI helpers,
then spawns a background poller. Returns the user-facing display fields
@ -7272,222 +7259,43 @@ async def _start_device_code_flow(
"poll_interval": max(2, (sess["interval_ms"] or 2000) // 1000),
}
raise HTTPException(status_code=400, detail=f"Provider {provider_id} does not support device-code flow")
if provider_id == "xai-oauth":
from hermes_cli.auth import _xai_oauth_request_device_code
import httpx
def _do_xai_device_request():
with httpx.Client(
timeout=httpx.Timeout(20.0),
headers={"Accept": "application/json"},
) as client:
return _xai_oauth_request_device_code(client)
# xAI Grok OAuth uses a loopback-redirect PKCE flow (RFC 8252). Unlike the
# device-code providers there is no user_code to display: the local backend
# binds a 127.0.0.1 callback server, the client opens the authorize URL in
# the browser, and the redirect lands back on the loopback listener. The
# background worker waits for that callback, exchanges the code, and persists
# the tokens exactly like `hermes auth add xai-oauth`.
_XAI_LOOPBACK_TIMEOUT_SECONDS = 300.0
def _start_xai_loopback_flow(profile: Optional[str] = None) -> Dict[str, Any]:
"""Begin the xAI loopback PKCE flow.
Binds the local callback server, builds the authorize URL, and spawns a
background worker that waits for the redirect and finishes the exchange.
Returns the authorize URL for the client to open in the browser.
"""
from hermes_cli import auth as hauth
discovery = hauth._xai_oauth_discovery()
server, thread, callback_result, redirect_uri = hauth._xai_start_callback_server()
try:
hauth._xai_validate_loopback_redirect_uri(redirect_uri)
verifier = hauth._oauth_pkce_code_verifier()
challenge = hauth._oauth_pkce_code_challenge(verifier)
state = secrets.token_hex(16)
nonce = secrets.token_hex(16)
authorize_url = hauth._xai_oauth_build_authorize_url(
authorization_endpoint=discovery["authorization_endpoint"],
redirect_uri=redirect_uri,
code_challenge=challenge,
state=state,
nonce=nonce,
device_data = await asyncio.get_running_loop().run_in_executor(
None, _do_xai_device_request
)
except Exception:
# Binding succeeded but URL construction failed — release the socket
# and join the serving thread so we don't leak a listener (or a
# lingering daemon thread) on the loopback port.
try:
server.shutdown()
server.server_close()
except Exception:
pass
try:
thread.join(timeout=1.0)
except Exception:
pass
raise
sid, sess = _new_oauth_session("xai-oauth", "loopback", profile=profile)
sess["server"] = server
sess["thread"] = thread
sess["callback_result"] = callback_result
sess["redirect_uri"] = redirect_uri
sess["verifier"] = verifier
sess["challenge"] = challenge
sess["state"] = state
sess["token_endpoint"] = discovery["token_endpoint"]
sess["discovery"] = discovery
sess["expires_at"] = time.time() + _XAI_LOOPBACK_TIMEOUT_SECONDS
threading.Thread(
target=_xai_loopback_worker, args=(sid,), daemon=True,
name=f"oauth-xai-{sid[:6]}",
).start()
return {
"session_id": sid,
"flow": "loopback",
"auth_url": authorize_url,
"expires_in": int(_XAI_LOOPBACK_TIMEOUT_SECONDS),
}
def _xai_loopback_worker(session_id: str) -> None:
"""Wait for the xAI loopback callback, exchange the code, persist tokens."""
from datetime import datetime, timezone
from hermes_cli import auth as hauth
with _oauth_sessions_lock:
sess = _oauth_sessions.get(session_id)
if not sess:
return
def _fail(message: str) -> None:
with _oauth_sessions_lock:
s = _oauth_sessions.get(session_id)
if s is not None:
s["status"] = "error"
s["error_message"] = message
def _cancelled() -> bool:
# The session is removed from the registry when the user cancels
# (DELETE /sessions/{id}). If that happened while we were blocked on
# the callback or token exchange, abort instead of persisting tokens
# the user no longer wants.
with _oauth_sessions_lock:
return session_id not in _oauth_sessions
try:
callback = hauth._xai_wait_for_callback(
sess["server"],
sess["thread"],
sess["callback_result"],
timeout_seconds=_XAI_LOOPBACK_TIMEOUT_SECONDS,
)
except Exception as exc:
_fail(f"xAI authorization timed out: {exc}")
return
if _cancelled():
return
if callback.get("error"):
detail = callback.get("error_description") or callback["error"]
_fail(f"xAI authorization failed: {detail}")
return
if callback.get("state") != sess["state"]:
_fail("xAI authorization failed: state mismatch.")
return
code = str(callback.get("code") or "").strip()
if not code:
_fail("xAI authorization failed: missing authorization code.")
return
try:
payload = hauth._xai_oauth_exchange_code_for_tokens(
token_endpoint=sess["token_endpoint"],
code=code,
redirect_uri=sess["redirect_uri"],
code_verifier=sess["verifier"],
code_challenge=sess["challenge"],
)
access_token = str(payload.get("access_token", "") or "").strip()
refresh_token = str(payload.get("refresh_token", "") or "").strip()
if not access_token or not refresh_token:
_fail("xAI token exchange did not return the expected tokens.")
return
base_url = hauth._xai_validate_inference_base_url(
os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/")
or os.getenv("XAI_BASE_URL", "").strip().rstrip("/"),
fallback=hauth.DEFAULT_XAI_OAUTH_BASE_URL,
)
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
tokens = {
"access_token": access_token,
"refresh_token": refresh_token,
"id_token": str(payload.get("id_token", "") or "").strip(),
"expires_in": payload.get("expires_in"),
"token_type": str(payload.get("token_type") or "Bearer").strip() or "Bearer",
sid, sess = _new_oauth_session("xai-oauth", "device_code", profile=profile)
sess["device_code"] = str(device_data["device_code"])
sess["interval"] = int(device_data["interval"])
sess["expires_at"] = time.time() + int(device_data["expires_in"])
threading.Thread(
target=_xai_device_poller,
args=(sid,),
daemon=True,
name=f"oauth-poll-{sid[:6]}",
).start()
return {
"session_id": sid,
"flow": "device_code",
"user_code": str(device_data["user_code"]),
"verification_url": str(
device_data.get("verification_uri_complete")
or device_data["verification_uri"]
),
"expires_in": int(device_data["expires_in"]),
"poll_interval": int(device_data["interval"]),
}
if _cancelled():
return
with _profile_scope(_oauth_session_profile(session_id)):
hauth._save_xai_oauth_tokens(
tokens,
discovery=sess.get("discovery"),
redirect_uri=sess["redirect_uri"],
last_refresh=last_refresh,
)
_add_xai_oauth_pool_entry(access_token, refresh_token, base_url, last_refresh)
except Exception as exc:
_fail(f"xAI token exchange failed: {exc}")
return
with _oauth_sessions_lock:
s = _oauth_sessions.get(session_id)
if s is not None:
s["status"] = "approved"
_log.info("oauth/loopback: xai-oauth login completed (session=%s)", session_id)
def _add_xai_oauth_pool_entry(
access_token: str, refresh_token: str, base_url: str, last_refresh: str
) -> None:
"""Mirror `hermes auth add xai-oauth`'s credential-pool insert.
Best-effort: the auth-store write in _save_xai_oauth_tokens is the source
of truth for runtime resolution; the pool entry only matters for the
rotation strategy.
"""
try:
import uuid
from agent.credential_pool import (
PooledCredential,
load_pool,
AUTH_TYPE_OAUTH,
SOURCE_MANUAL,
)
pool = load_pool("xai-oauth")
existing = [
e for e in pool.entries()
if getattr(e, "source", "").startswith(f"{SOURCE_MANUAL}:dashboard_xai_pkce")
]
for e in existing:
try:
pool.remove_entry(getattr(e, "id", ""))
except Exception:
pass
entry = PooledCredential(
provider="xai-oauth",
id=uuid.uuid4().hex[:6],
label="dashboard PKCE",
auth_type=AUTH_TYPE_OAUTH,
priority=0,
source=f"{SOURCE_MANUAL}:dashboard_xai_pkce",
access_token=access_token,
refresh_token=refresh_token,
base_url=base_url,
last_refresh=last_refresh,
)
pool.add_entry(entry)
except Exception as e:
_log.warning("xai-oauth pool add (dashboard) failed: %s", e)
raise HTTPException(status_code=400, detail=f"Provider {provider_id} does not support device-code flow")
def _nous_poller(session_id: str) -> None:
@ -7638,6 +7446,70 @@ def _minimax_poller(session_id: str) -> None:
sess["error_message"] = str(e)
def _xai_device_poller(session_id: str) -> None:
"""Background poller for xAI's OAuth device-code flow."""
import httpx
from hermes_cli.auth import (
_save_xai_oauth_tokens,
_xai_oauth_discovery,
_xai_oauth_poll_device_token,
unsuppress_credential_source,
)
with _oauth_sessions_lock:
sess = _oauth_sessions.get(session_id)
if not sess:
return
device_code = sess["device_code"]
interval = int(sess["interval"])
expires_in = max(60, int(sess["expires_at"] - time.time()))
try:
discovery = _xai_oauth_discovery(20.0)
with httpx.Client(
timeout=httpx.Timeout(20.0),
headers={"Accept": "application/json"},
) as client:
token_data = _xai_oauth_poll_device_token(
client,
token_endpoint=discovery["token_endpoint"],
device_code=device_code,
expires_in=expires_in,
poll_interval=interval,
)
tokens = {
"access_token": str(token_data.get("access_token", "") or "").strip(),
"refresh_token": str(token_data.get("refresh_token", "") or "").strip(),
"id_token": str(token_data.get("id_token", "") or "").strip(),
"expires_in": token_data.get("expires_in"),
"token_type": str(token_data.get("token_type") or "Bearer").strip() or "Bearer",
}
with _profile_scope(_oauth_session_profile(session_id)):
_save_xai_oauth_tokens(
tokens,
discovery=discovery,
last_refresh=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
auth_mode="oauth_device_code",
)
# The singleton write above is the single source of truth: the
# credential-pool load seeds it as the canonical ``device_code``
# entry. Do NOT also insert a parallel ``manual:dashboard_*`` pool
# entry — that duplicates the single-use refresh token across two
# entries and triggers rotation churn / ``refresh_token_reused``.
# An interactive dashboard login is also an explicit re-enable
# signal, so clear any ``device_code`` suppression left by a
# prior ``hermes auth remove xai-oauth`` (mirrors auth_add_command
# and the ``hermes model`` re-login path in _login_xai_oauth).
unsuppress_credential_source("xai-oauth", "device_code")
with _oauth_sessions_lock:
sess["status"] = "approved"
_log.info("oauth/device: xai login completed (session=%s)", session_id)
except Exception as e:
_log.warning("xai device-code poll failed (session=%s): %s", session_id, e)
with _oauth_sessions_lock:
sess["status"] = "error"
sess["error_message"] = str(e)
def _codex_full_login_worker(session_id: str) -> None:
"""Run the complete OpenAI Codex device-code flow.
@ -7787,10 +7659,6 @@ async def start_oauth_login(
return _start_anthropic_pkce(profile=profile)
if catalog_entry["flow"] == "device_code":
return await _start_device_code_flow(provider_id, profile=profile)
if catalog_entry["flow"] == "loopback" and provider_id == "xai-oauth":
return await asyncio.get_running_loop().run_in_executor(
None, _start_xai_loopback_flow, profile,
)
except HTTPException:
raise
except Exception as e:
@ -7828,10 +7696,9 @@ async def poll_oauth_session(
):
"""Poll a session's status (no auth — read-only state).
Shared by the device-code flows (Nous, OpenAI Codex, MiniMax) and the
loopback flow (xAI Grok). Both surface progress through the same
background-worker-updated ``status`` field, so a single poll endpoint
serves them all.
Shared by the device-code flows (Nous, OpenAI Codex, MiniMax, xAI).
Each surfaces progress through the same background-worker-updated
``status`` field, so a single poll endpoint serves them all.
"""
with _oauth_sessions_lock:
sess = _oauth_sessions.get(session_id)
@ -7859,33 +7726,6 @@ async def cancel_oauth_session(
sess = _oauth_sessions.pop(session_id, None)
if sess is None:
return {"ok": False, "message": "session not found"}
# Loopback sessions own a bound 127.0.0.1 callback server. Without an
# explicit shutdown the worker would keep that port held until
# _xai_wait_for_callback times out (up to 5 min). Free it immediately so
# an orphaned listener can't block a subsequent sign-in attempt.
if sess.get("flow") == "loopback":
# The worker is blocked in _xai_wait_for_callback, which polls
# callback_result rather than the server state. Flag the result as
# cancelled so that loop returns on its next tick instead of spinning
# until the timeout — otherwise repeated cancel/retry piles up daemon
# threads. (_cancelled() in the worker then short-circuits before any
# persist.)
result = sess.get("callback_result")
if isinstance(result, dict):
result["error"] = result.get("error") or "cancelled"
server = sess.get("server")
thread = sess.get("thread")
try:
if server is not None:
server.shutdown()
server.server_close()
except Exception:
pass
try:
if thread is not None:
thread.join(timeout=1.0)
except Exception:
pass
return {"ok": True, "session_id": session_id}
@ -14279,13 +14119,24 @@ def start_server(
# OSError inside create_server() and exits with a clear error — no
# separate preflight probe needed.
# Loopback binds are the Desktop case: a single local client, no reverse
# proxy in front. A GIL-heavy agent turn can stall the event loop past 20s,
# and uvicorn's ws keepalive ping runs on that same starved loop — so a
# 20s ping timeout kills an otherwise-healthy local connection over a
# recoverable stall (QW-1). Give loopback a longer 60s timeout / 30s
# interval to ride out those stalls. Non-loopback binds sit behind a
# Cloudflare Tunnel (idle timeout ~100s), so keep them at 20/20 to detect
# half-open connections promptly and stay under the tunnel's idle window.
# proxy in front. uvicorn's ws keepalive ping runs ON the same event loop
# as agent turns, and a single synchronous GIL-holding call on a worker
# thread (e.g. a regex/scrub over a large model output, or a long
# delegate_task subagent turn) can starve that loop for *minutes* — the
# loop cannot process the incoming pong, so uvicorn declares the socket
# dead and closes it, dropping an otherwise-healthy local connection
# (#53773: "event loop stalled 226.3s"; #48445/#50005). A longer timeout
# only raises the threshold — a multi-minute stall sails past any finite
# window. The keepalive ping exists to detect *half-open* connections
# (reverse-proxy 524, dropped tunnels), which cannot happen on loopback:
# there is no network or proxy in the path, and a dead local client tears
# the socket down with a real FIN/RST that starlette surfaces as
# WebSocketDisconnect regardless of the ping. So on loopback the ping
# provides ~no liveness value while actively killing recoverable stalls —
# disable it entirely. Non-loopback binds sit behind a Cloudflare Tunnel
# (idle timeout ~100s) where half-open IS a real failure mode, so keep the
# ping at 20/20 to detect it promptly and stay under the tunnel's idle
# window.
_is_loopback = host in ("127.0.0.1", "localhost", "::1")
config = uvicorn.Config(
app, host=host, port=port, log_level="warning",
@ -14297,12 +14148,12 @@ def start_server(
# decide cookie Secure flags, so we flip proxy_headers on for that
# mode.
proxy_headers=bool(app.state.auth_required),
# Detect half-open WS connections (reverse-proxy 524, dropped
# tunnels) within ~20-40s so WebSocketDisconnect fires the
# disconnect→reap path. 20s stays under Cloudflare Tunnel's idle
# timeout, keeping it warm. Loopback gets a longer window (see above).
ws_ping_interval=30.0 if _is_loopback else 20.0,
ws_ping_timeout=60.0 if _is_loopback else 20.0,
# Half-open detection for public binds only (see above). Loopback
# disables the protocol ping (None) so an event-loop stall can never
# trigger a false disconnect; a genuinely dead local client is still
# reaped via the WebSocketDisconnect → disconnect/reap path.
ws_ping_interval=None if _is_loopback else 20.0,
ws_ping_timeout=None if _is_loopback else 20.0,
)
server = uvicorn.Server(config)

View file

@ -794,18 +794,26 @@ def apply_subprocess_home_env(env: dict[str, str]) -> None:
VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")
def parse_reasoning_effort(effort: str) -> dict | None:
def parse_reasoning_effort(effort) -> dict | None:
"""Parse a reasoning effort level into a config dict.
Valid levels: "none", "minimal", "low", "medium", "high", "xhigh".
Returns None when the input is empty or unrecognized (caller uses default).
Returns {"enabled": False} for "none".
Returns {"enabled": False} for "none" (aliases: "false", "disabled", and
YAML boolean False users write ``reasoning_effort: false``/``off``/``no``
in config.yaml and YAML hands us a bool, which must mean disabled, not
"fall back to the default and keep thinking").
Returns {"enabled": True, "effort": <level>} for valid effort levels.
"""
if not effort or not effort.strip():
if effort is False:
return {"enabled": False}
if effort is None or effort is True:
return None
effort = str(effort)
if not effort.strip():
return None
effort = effort.strip().lower()
if effort == "none":
if effort in {"none", "false", "disabled"}:
return {"enabled": False}
if effort in VALID_REASONING_EFFORTS:
return {"enabled": True, "effort": effort}

View file

@ -1,9 +1,67 @@
"""ZAI / GLM provider profile."""
"""ZAI / GLM provider profile.
Z.AI's GLM-4.5-and-later chat models default to thinking-mode ON when the
request omits ``thinking``. Hermes' ``reasoning_config = {"enabled": False}``
was previously a silent no-op on this route the base profile emits nothing,
so users who turned thinking off (desktop toggle, ``/reasoning none``,
``reasoning_effort: none``/``false`` in config.yaml) kept burning thinking
tokens on every turn.
:meth:`ZaiProfile.build_api_kwargs_extras` translates the Hermes reasoning
config into the wire shape Z.AI's OpenAI-compat endpoint expects:
{"extra_body": {"thinking": {"type": "enabled" | "disabled"}}}
When no reasoning preference is set (``reasoning_config is None``) the field
is omitted so the server default applies, matching prior behavior. GLM
models before 4.5 (e.g. ``glm-4-9b``) don't accept ``thinking`` and are left
untouched.
"""
from __future__ import annotations
import re
from typing import Any
from providers import register_provider
from providers.base import ProviderProfile
zai = ProviderProfile(
_GLM_VERSION_RE = re.compile(r"^glm-(\d+)(?:\.(\d+))?")
def _model_supports_thinking(model: str | None) -> bool:
"""GLM thinking-capable model families: glm-4.5 and later (4.5, 4.6, 5…)."""
m = (model or "").strip().lower()
match = _GLM_VERSION_RE.match(m)
if not match:
return False
major = int(match.group(1))
minor = int(match.group(2) or 0)
return (major, minor) >= (4, 5)
class ZaiProfile(ProviderProfile):
"""Z.AI / GLM — extra_body.thinking enabled/disabled."""
def build_api_kwargs_extras(
self, *, reasoning_config: dict | None = None, model: str | None = None, **context
) -> tuple[dict[str, Any], dict[str, Any]]:
extra_body: dict[str, Any] = {}
top_level: dict[str, Any] = {}
if not _model_supports_thinking(model):
return extra_body, top_level
# Only emit when the user expressed a preference; omitting the field
# keeps the server default (enabled) exactly as before.
if isinstance(reasoning_config, dict):
enabled = reasoning_config.get("enabled") is not False
extra_body["thinking"] = {"type": "enabled" if enabled else "disabled"}
return extra_body, top_level
zai = ZaiProfile(
name="zai",
aliases=("glm", "z-ai", "z.ai", "zhipu"),
env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"),

View file

@ -55,6 +55,11 @@ _QUOTE_RE = re.compile(r"^\s{0,3}>\s?(.*)$")
_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)+\|?\s*$")
def _is_list_line(line: str) -> bool:
"""True if ``line`` is a markdown list item (bullet or ordered)."""
return bool(_BULLET_RE.match(line) or _ORDERED_RE.match(line))
def _indent_level(spaces: str) -> int:
"""Map leading whitespace to a nesting level (2 spaces or 1 tab per level)."""
width = 0
@ -434,7 +439,7 @@ def render_blocks(
continue
# List group (bullets + ordered, with nesting)
if _BULLET_RE.match(line) or _ORDERED_RE.match(line):
if _is_list_line(line):
flush_para()
items: List[Tuple[int, bool, str]] = []
while i < n:
@ -451,6 +456,22 @@ def render_blocks(
indent, ordered, txt = items[-1]
items[-1] = (indent, ordered, txt + " " + lines[i].strip())
i += 1
elif not lines[i].strip() and items:
# Blank line inside a list run. LLM-authored ordered
# lists commonly separate items with a blank line; if
# the next non-blank line is another list item, treat
# the blank(s) as a soft separator and keep the run
# going so the items stay in one rich_text_list (Slack
# numbers each list independently, so splitting would
# restart every item at "1."). Otherwise the blank
# ends the list.
j = i + 1
while j < n and not lines[j].strip():
j += 1
if j < n and _is_list_line(lines[j]):
i = j
else:
break
else:
break
blocks.append(_list_block(items))

View file

@ -4112,7 +4112,7 @@ class AIAgent:
#
# When an agent is using a non-singleton credential — e.g. a manual
# pool entry (``hermes auth add xai-oauth``) whose tokens belong to
# a different account than the loopback_pkce singleton, or an agent
# a different account than the device_code singleton, or an agent
# constructed with an explicit ``api_key=`` arg — force-refreshing
# the singleton here and adopting its tokens silently re-routes the
# rest of the conversation onto the singleton's account. The

View file

@ -46,6 +46,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"infinitycrew39@gmail.com": "infinitycrew39", # PR #56431 salvage (honor live vLLM context limits on local endpoints)
"hermes.wanderer@yahoo.com": "trismegistus-wanderer", # PR #31856 salvage (gateway: defer idle-TTL agent-cache eviction until the session store says the session actually expired, so the expiry watcher can still fire MemoryProvider.on_session_end with the live transcript; #11205)
"louis@letsfive.io": "Mibayy", # PR #3243 salvage (/compact alias + preview/aggressive flags for /compress)
"louis@letsfive.io": "Mibayy", # PR #3176 salvage (api-server: per-client model routing via model_routes)
"jneeee@outlook.com": "jneeee", # PR #3526 salvage (extra HTTP headers for LLM API calls via config.yaml)
@ -60,6 +61,7 @@ AUTHOR_MAP = {
"sahibzada@fastino.ai": "sahibzada-allahyar", # PR #39227 salvage (desktop: configured terminal.cwd overrides a stale remembered workspace-cwd localStorage value when no session is active; #38855)
"jvsantos.cunha@gmail.com": "plcunha", # PR #55300 salvage (gateway: record child gateway peer metadata after a compression session-id rotation and repoint stale sessions.json compression-parent entries to the recovered live child; consolidated in the compression-routing-integrity salvage)
"jakepresent1@gmail.com": "jakepresent", # PR #55721 salvage (gateway: identity-guard stale in-flight compression splits — a late run may publish its compressed child only if its run generation is still current and the session key still points at the run's original parent, so an old run can't overwrite a newer /new or moved binding)
"gumclaw@gumroad.com": "gumclaw", # PR #57322 salvage (gateway: close per-delivery webhook sessions on completion so prune_sessions can reap them — fixes unbounded state.db growth from unprunable ended_at=NULL webhook rows)
"zhangml@tech.icbc.com.cn": "zmlgit", # PR #54872 salvage (multiplex-profile kanban: route task notifications via the owning profile's adapter + wake the creator agent with a synthetic internal MessageEvent on terminal events)
"1079826437@qq.com": "nankingjing", # PR #56404 salvage (gateway: while a state.db compression lock is held for the session, demote busy_input_mode 'interrupt' to 'queue' so a rapid message burst can't interrupt and fork orphaned compression siblings off a stale parent; #56391)
"ud@arubangles.com": "udatny", # PR #29433 salvage (subdirectory_hints: catch RuntimeError from Path.expanduser()/Path.home() so a literal ~ in tool-call args — e.g. LLM "~500-700" or ~unknownuser — can't escape the hint walker and crash the conversation loop)
@ -176,6 +178,7 @@ AUTHOR_MAP = {
"rayjun0412@gmail.com": "rayjun", # cron model.default salvage co-author (#43952)
"96944678+sweetcornna@users.noreply.github.com": "sweetcornna", # cron ticker-liveness salvage co-author (#33849)
"izumi0uu@gmail.com": "izumi0uu", # PR #49544 salvage (native rich reply echo; #49534)
"zhangyingliang@outlook.com": "yingliang-zhang", # PR #56084 (setup RPC pool routing; #57335)
"dev@pixlmedia.no": "texhy", # PR #27435 salvage (few-but-huge preflight compression gate; #27405)
"qdaszx@naver.com": "qdaszx", # PR #29190 salvage (non-blocking OSV malware preflight; #29184)
"w31rdm4ch1n3z@protonmail.com": "w31rdm4ch1nZ",

View file

@ -2827,7 +2827,7 @@ def test_xai_oauth_terminal_refresh_clears_auth_json_and_removes_pool_entries(
pool = load_pool("xai-oauth")
selected = pool.select()
assert selected is not None
assert selected.source == "loopback_pkce"
assert selected.source == "device_code"
# Add a manual API-key entry that must survive the quarantine.
pool.add_entry(PooledCredential.from_dict("xai-oauth", {
@ -2868,7 +2868,7 @@ def test_xai_oauth_terminal_refresh_clears_auth_json_and_removes_pool_entries(
assert [entry["id"] for entry in auth_payload["credential_pool"]["xai-oauth"]] == ["manual-key"]
# A second try_refresh_current must not call refresh_xai_oauth_pure again
# (pool is now empty of loopback entries and current is None).
# (pool is now empty of device-code entries and current is None).
assert pool.try_refresh_current() is None
assert refresh_calls["count"] == 1

View file

@ -675,6 +675,89 @@ class TestAgentCacheBoundedGrowth:
# Hard-cleanup path must NOT have fired — that's for session expiry only.
assert cleanup_calls == []
def test_cap_commits_memory_before_evicting_finalizable(self, monkeypatch):
"""LRU-cap eviction of a finalizable, not-yet-expired agent commits
on_session_end extraction before releasing.
The agent would otherwise vanish from _agent_cache before the expiry
watcher runs, so the watcher would never fire on_session_end() and
memory providers would miss the transcript (#11205, LRU-cap variant).
We hold the live agent at eviction time, so commit its memory then.
"""
from gateway import run as gw_run
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", 1)
runner = self._bounded_runner()
commit_calls: list = []
release_calls: list = []
runner._release_evicted_agent_soft = lambda agent: release_calls.append(agent)
# Finalizable (finite policy), not yet expired.
runner.session_store = MagicMock()
runner.session_store._entries = {"old": MagicMock(), "new": MagicMock()}
runner.session_store.is_session_finalizable.return_value = True
runner.session_store._is_session_expired.return_value = False
old_agent = self._fake_agent()
old_agent._memory_manager = MagicMock() # has an external provider
old_agent._session_messages = [{"role": "user", "content": "hi"}]
old_agent.commit_memory_session = lambda msgs=None: commit_calls.append(msgs)
new_agent = self._fake_agent()
with runner._agent_cache_lock:
runner._agent_cache["old"] = (old_agent, "sig_old")
runner._agent_cache["new"] = (new_agent, "sig_new")
runner._enforce_agent_cache_cap()
import time as _t
deadline = _t.time() + 2.0
while _t.time() < deadline and not release_calls:
_t.sleep(0.02)
# Memory committed with the live transcript, THEN client released.
assert commit_calls == [[{"role": "user", "content": "hi"}]]
assert old_agent in release_calls
def test_cap_skips_memory_commit_for_non_finalizable(self, monkeypatch):
"""LRU-cap eviction of a mode='none' agent does NOT commit memory.
The expiry watcher never finalizes a mode='none' session, so there is
no missed on_session_end boundary to compensate for. Committing here
would fire premature/repeat extraction for a session that simply keeps
living. The agent is released without a commit.
"""
from gateway import run as gw_run
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", 1)
runner = self._bounded_runner()
commit_calls: list = []
release_calls: list = []
runner._release_evicted_agent_soft = lambda agent: release_calls.append(agent)
runner.session_store = MagicMock()
runner.session_store._entries = {"old": MagicMock(), "new": MagicMock()}
runner.session_store.is_session_finalizable.return_value = False # mode='none'
runner.session_store._is_session_expired.return_value = False
old_agent = self._fake_agent()
old_agent._memory_manager = MagicMock()
old_agent._session_messages = [{"role": "user", "content": "hi"}]
old_agent.commit_memory_session = lambda msgs=None: commit_calls.append(msgs)
new_agent = self._fake_agent()
with runner._agent_cache_lock:
runner._agent_cache["old"] = (old_agent, "sig_old")
runner._agent_cache["new"] = (new_agent, "sig_new")
runner._enforce_agent_cache_cap()
import time as _t
deadline = _t.time() + 2.0
while _t.time() < deadline and not release_calls:
_t.sleep(0.02)
assert commit_calls == [] # no premature extraction
assert old_agent in release_calls # still released
def test_idle_ttl_sweep_evicts_stale_agents(self, monkeypatch):
"""_sweep_idle_cached_agents removes agents idle past the TTL."""
from gateway import run as gw_run
@ -708,6 +791,138 @@ class TestAgentCacheBoundedGrowth:
assert runner._sweep_idle_cached_agents() == 0
assert "s" in runner._agent_cache
def test_idle_sweep_keeps_agent_when_session_not_expired(self, monkeypatch):
"""Agents past idle TTL are kept if the session hasn't expired yet.
In daily-reset mode the reset can fire hours after the last
user message evicting the agent early means the
session-expiry watcher has nothing to call on_session_end()
with, and memory providers miss the live transcript.
"""
from gateway import run as gw_run
monkeypatch.setattr(gw_run, "_AGENT_CACHE_IDLE_TTL_SECS", 0.01)
runner = self._bounded_runner()
runner._cleanup_agent_resources = MagicMock()
import time as _t
stale = self._fake_agent(last_activity=_t.time() - 10.0)
# Session store says the session is still alive AND is finalizable
# (finite reset policy) — so deferring eviction is correct: the expiry
# watcher will find this agent later and fire on_session_end().
session_entry = MagicMock()
runner.session_store = MagicMock()
runner.session_store._entries = {"stale-session": session_entry}
runner.session_store.is_session_finalizable.return_value = True
runner.session_store._is_session_expired.return_value = False
runner._agent_cache["stale-session"] = (stale, "sig")
evicted = runner._sweep_idle_cached_agents()
assert evicted == 0
assert "stale-session" in runner._agent_cache
def test_idle_sweep_evicts_when_session_is_expired(self, monkeypatch):
"""Agent IS evicted when past idle TTL AND session store says expired."""
from gateway import run as gw_run
monkeypatch.setattr(gw_run, "_AGENT_CACHE_IDLE_TTL_SECS", 0.01)
runner = self._bounded_runner()
runner._cleanup_agent_resources = MagicMock()
import time as _t
stale = self._fake_agent(last_activity=_t.time() - 10.0)
# Session store says the session has expired.
session_entry = MagicMock()
runner.session_store = MagicMock()
runner.session_store._entries = {"stale-session": session_entry}
runner.session_store.is_session_finalizable.return_value = True
runner.session_store._is_session_expired.return_value = True
runner._agent_cache["stale-session"] = (stale, "sig")
evicted = runner._sweep_idle_cached_agents()
assert evicted == 1
assert "stale-session" not in runner._agent_cache
def test_idle_sweep_evicts_non_finalizable_session(self, monkeypatch):
"""A mode='none' session's idle agent IS still evicted.
is_session_finalizable() is False for reset-policy 'none': the expiry
watcher never finalizes such a session, so deferring eviction would
pin the cached agent for the gateway's whole lifetime — the exact
leak the idle sweep exists to relieve. The sweep must reap it even
though _is_session_expired() is (and stays) False.
"""
from gateway import run as gw_run
monkeypatch.setattr(gw_run, "_AGENT_CACHE_IDLE_TTL_SECS", 0.01)
runner = self._bounded_runner()
runner._cleanup_agent_resources = MagicMock()
import time as _t
stale = self._fake_agent(last_activity=_t.time() - 10.0)
session_entry = MagicMock()
runner.session_store = MagicMock()
runner.session_store._entries = {"never-session": session_entry}
# mode='none' → never finalizable, never expired.
runner.session_store.is_session_finalizable.return_value = False
runner.session_store._is_session_expired.return_value = False
runner._agent_cache["never-session"] = (stale, "sig")
evicted = runner._sweep_idle_cached_agents()
assert evicted == 1
assert "never-session" not in runner._agent_cache
def test_is_session_finalizable_real_predicate(self, tmp_path):
"""is_session_finalizable() reflects the real reset policy.
Uses a real SessionStore + GatewayConfig (no mocks) so the predicate
is exercised against actual get_reset_policy() output: True for finite
policies (idle/daily/both), False only for mode='none'.
"""
from datetime import datetime
from unittest.mock import patch as _patch
from gateway.config import GatewayConfig, Platform, SessionResetPolicy
from gateway.session import (
SessionEntry, SessionSource, SessionStore, build_session_key,
)
def _entry_for(platform: Platform) -> SessionEntry:
src = SessionSource(
platform=platform, user_id="u1", chat_id="c1",
user_name="t", chat_type="dm",
)
return SessionEntry(
session_key=build_session_key(src),
session_id="s1",
created_at=datetime.now(),
updated_at=datetime.now(),
origin=src,
platform=src.platform,
chat_type=src.chat_type,
)
config = GatewayConfig()
# Give Telegram a 'none' policy via the per-platform override; leave the
# default policy finite ('both') for the Discord case.
config.default_reset_policy = SessionResetPolicy(mode="both")
config.reset_by_platform[Platform.TELEGRAM] = SessionResetPolicy(mode="none")
with _patch("gateway.session.SessionStore._ensure_loaded"):
store = SessionStore(sessions_dir=tmp_path, config=config)
store._db = None
# mode='none' → never finalized by the watcher.
assert store.is_session_finalizable(_entry_for(Platform.TELEGRAM)) is False
# default 'both' → finite, will eventually expire.
assert store.is_session_finalizable(_entry_for(Platform.DISCORD)) is True
def test_plain_dict_cache_is_tolerated(self):
"""Test fixtures using plain {} don't crash _enforce_agent_cache_cap."""
from gateway.run import GatewayRunner

View file

@ -90,6 +90,47 @@ class TestInlineFormatting:
]
assert styled, "expected a bold-styled text element in the list item"
def test_blank_line_separated_ordered_items_stay_in_one_list(self):
"""Regression: blank lines between ordered items must not reset numbering.
Slack numbers each rich_text_list independently. If blank lines break
the list run, N items produce N separate lists each starting at 1.
See: https://github.com/NousResearch/hermes-agent/issues/57076
"""
md = "1. alpha\n\n1. beta\n\n1. gamma"
blocks = render_blocks(md)
rich = [b for b in blocks if b["type"] == "rich_text"][0]
lists = [e for e in rich["elements"] if e["type"] == "rich_text_list"]
# Must be ONE list with 3 items, not 3 separate single-item lists
assert len(lists) == 1
items = lists[0]["elements"]
assert len(items) == 3
def test_blank_separated_mixed_list_matches_contiguous_layout(self):
"""A blank line between different list kinds must render like the
contiguous form: one rich_text block whose sub-lists split only on
(indent, ordered) changes not a separate block per item.
"""
rich = [b for b in render_blocks("1. a\n\n- b") if b["type"] == "rich_text"]
# Single rich_text block (matches contiguous "1. a\n- b"), two sub-lists
assert len(rich) == 1
styles = [e["style"] for e in rich[0]["elements"] if e["type"] == "rich_text_list"]
assert styles == ["ordered", "bullet"]
def test_blank_line_before_paragraph_ends_the_list(self):
"""A blank line followed by non-list content must still end the run,
so a list paragraph list sequence stays three separate blocks.
"""
blocks = render_blocks("1. a\n\nsome paragraph text\n\n1. b")
lists = [
e
for b in blocks
for e in b.get("elements", [])
if e.get("type") == "rich_text_list"
]
# Two independent single-item lists, not one merged three-item list
assert [len(e["elements"]) for e in lists] == [1, 1]
class TestTables:
def test_pipe_table_renders_native_table_block(self):

View file

@ -0,0 +1,216 @@
"""Invariant test: a completed webhook delivery closes its session.
Regression guard for the ghost-session leak. Webhook deliveries create a
unique one-shot session (``delivery_id`` baked into the session key), but the
adapter historically fired ``handle_message`` without ever ending the session.
``SessionDB.prune_sessions`` only reaps rows where ``ended_at IS NOT NULL``, so
every webhook session stayed unprunable and state.db grew without bound (this
was the primary driver of the SQLite lock-contention gateway outage).
The invariant asserted here is a *behavior contract*, not a snapshot: once a
webhook delivery's agent run completes, the session row for that delivery must
have ``ended_at`` set mirroring how a cron run closes its session with
``end_session(..., "cron_complete")``. We exercise the REAL close path
(``WebhookAdapter._run_delivery_and_close`` ``_end_webhook_session``
``SessionDB.end_session``) against a REAL ``SessionStore`` + ``SessionDB`` on a
temp HERMES_HOME, so an integration regression can't hide behind a mock.
"""
import asyncio
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType, SendResult
from gateway.platforms.webhook import WebhookAdapter, _INSECURE_NO_AUTH
from gateway.session import SessionSource, SessionStore
from hermes_state import SessionDB
def _make_adapter(routes, **extra_kw) -> WebhookAdapter:
extra = {"host": "127.0.0.1", "port": 0, "routes": routes}
extra.update(extra_kw)
config = PlatformConfig(enabled=True, extra=extra)
return WebhookAdapter(config)
class _FakeRunner:
"""Minimal gateway runner surface the webhook close path depends on.
Wires a real ``SessionStore`` (which owns a real ``SessionDB``) and reuses
that same ``SessionDB`` as ``_session_db`` so the row created at routing
time is the row the close path ends exactly the wiring the live gateway
has (``self.session_store`` + ``self._session_db``).
"""
def __init__(self, store: SessionStore):
self.session_store = store
self._session_db = store._db
def _session_key_for_source(self, source: SessionSource) -> str:
return self.session_store._generate_session_key(source)
@pytest.mark.asyncio
async def test_completed_webhook_delivery_closes_its_session(tmp_path):
"""After a webhook run finishes, its session row has ended_at set."""
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
config = GatewayConfig(
platforms={Platform.WEBHOOK: PlatformConfig(enabled=True)}
)
store = SessionStore(sessions_dir=sessions_dir, config=config)
assert store._db is not None, "test requires a real SessionDB"
runner = _FakeRunner(store)
adapter = _make_adapter(
{
"alerts": {
"secret": _INSECURE_NO_AUTH,
"prompt": "Alert: {message}",
"deliver": "log",
}
}
)
adapter.gateway_runner = runner
# The gateway creates the session row when it routes the inbound event to
# the agent. Simulate that inside handle_message so the close path has a
# real row to reap, and capture the session_id for the assertion.
created = {}
async def _fake_handle_message(event: MessageEvent) -> None:
entry = store.get_or_create_session(event.source)
created["session_id"] = entry.session_id
adapter.handle_message = _fake_handle_message
delivery_id = "alert-close-001"
session_chat_id = f"webhook:alerts:{delivery_id}"
source = adapter.build_source(
chat_id=session_chat_id,
chat_name="webhook/alerts",
chat_type="webhook",
user_id="webhook:alerts",
user_name="alerts",
)
event = MessageEvent(
text="Alert: server on fire",
message_type=MessageType.TEXT,
source=source,
raw_message={"message": "server on fire"},
message_id=delivery_id,
)
# Run the exact wrapper the adapter now schedules on delivery.
await adapter._run_delivery_and_close(event, session_chat_id)
session_id = created["session_id"]
row = store._db.get_session(session_id)
assert row is not None
# INVARIANT: a completed webhook session must be closed so prune can reap it.
assert row["ended_at"] is not None, (
"webhook session was never closed — ended_at is NULL, so "
"prune_sessions can never reap it (the ghost-session leak)"
)
assert row["end_reason"] == "webhook_complete"
# And the closed row is actually prunable, unlike the pre-fix leak.
pruned = store._db.prune_sessions(older_than_days=0, source="webhook")
assert pruned >= 1
store._db.close()
@pytest.mark.asyncio
async def test_webhook_session_closed_even_when_agent_run_raises(tmp_path):
"""A failing agent run still closes the session (finally-path)."""
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
config = GatewayConfig(
platforms={Platform.WEBHOOK: PlatformConfig(enabled=True)}
)
store = SessionStore(sessions_dir=sessions_dir, config=config)
runner = _FakeRunner(store)
adapter = _make_adapter(
{"alerts": {"secret": _INSECURE_NO_AUTH, "prompt": "x", "deliver": "log"}}
)
adapter.gateway_runner = runner
created = {}
async def _boom(event: MessageEvent) -> None:
# Row exists (routing happened) before the run blows up mid-turn.
entry = store.get_or_create_session(event.source)
created["session_id"] = entry.session_id
raise RuntimeError("agent exploded mid-run")
adapter.handle_message = _boom
delivery_id = "alert-fail-001"
session_chat_id = f"webhook:alerts:{delivery_id}"
source = adapter.build_source(
chat_id=session_chat_id,
chat_name="webhook/alerts",
chat_type="webhook",
user_id="webhook:alerts",
user_name="alerts",
)
event = MessageEvent(
text="x",
message_type=MessageType.TEXT,
source=source,
raw_message={},
message_id=delivery_id,
)
with pytest.raises(RuntimeError):
await adapter._run_delivery_and_close(event, session_chat_id)
row = store._db.get_session(created["session_id"])
assert row is not None
assert row["ended_at"] is not None, (
"session left open after a failed webhook run — the leak persists "
"on the error path"
)
assert row["end_reason"] == "webhook_complete"
store._db.close()
def test_peek_session_id_resolves_bound_key(tmp_path):
"""SessionStore.peek_session_id returns the session_id bound to a key.
This is the public, lock-held accessor the webhook close path uses to
resolve a session row from its key without reaching into the private
``_entries`` dict. A missing/unknown key returns None (so the close path
debug-logs and no-ops rather than closing the wrong row).
"""
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
config = GatewayConfig(
platforms={Platform.WEBHOOK: PlatformConfig(enabled=True)}
)
store = SessionStore(sessions_dir=sessions_dir, config=config)
adapter = _make_adapter(
{"alerts": {"secret": _INSECURE_NO_AUTH, "prompt": "x", "deliver": "log"}}
)
source = adapter.build_source(
chat_id="webhook:alerts:peek-001",
chat_name="webhook/alerts",
chat_type="webhook",
user_id="webhook:alerts",
user_name="alerts",
)
entry = store.get_or_create_session(source)
key = store._generate_session_key(source)
# Known key → the bound session_id.
assert store.peek_session_id(key) == entry.session_id
# Unknown key and empty key → None (never a wrong-row close).
assert store.peek_session_id("no:such:key") is None
assert store.peek_session_id("") is None
if store._db is not None:
store._db.close()

View file

@ -520,7 +520,7 @@ def test_auth_add_xai_oauth_sets_active_provider(tmp_path, monkeypatch):
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
access_token = "xai-test-access-token"
monkeypatch.setattr(
"hermes_cli.auth._xai_oauth_loopback_login",
"hermes_cli.auth._xai_oauth_device_code_login",
lambda **kwargs: {
"tokens": {
"access_token": access_token,
@ -529,10 +529,10 @@ def test_auth_add_xai_oauth_sets_active_provider(tmp_path, monkeypatch):
"token_type": "Bearer",
},
"discovery": {"token_endpoint": "https://auth.x.ai/token"},
"redirect_uri": "http://127.0.0.1:7777/callback",
"redirect_uri": "",
"base_url": "https://api.x.ai/v1",
"last_refresh": "2026-06-02T10:00:00Z",
"source": "oauth-loopback",
"source": "oauth-device-code",
},
)
@ -545,7 +545,6 @@ def test_auth_add_xai_oauth_sets_active_provider(tmp_path, monkeypatch):
label = None
timeout = None
no_browser = False
manual_paste = False
auth_add_command(_Args())
@ -554,9 +553,10 @@ def test_auth_add_xai_oauth_sets_active_provider(tmp_path, monkeypatch):
assert payload["active_provider"] == "xai-oauth"
# providers singleton written by _save_xai_oauth_tokens
assert payload["providers"]["xai-oauth"]["tokens"]["access_token"] == access_token
assert payload["providers"]["xai-oauth"]["auth_mode"] == "oauth_device_code"
# pool seeded from singleton by _seed_from_singletons("xai-oauth")
entries = payload["credential_pool"]["xai-oauth"]
entry = next(item for item in entries if item["source"] == "loopback_pkce")
entry = next(item for item in entries if item["source"] == "device_code")
assert entry["refresh_token"] == "xai-refresh-token"

View file

@ -1,8 +1,11 @@
"""Unit tests for _print_loopback_ssh_hint() in hermes_cli/auth.py.
The helper exists to warn users that loopback OAuth flows (xAI Grok OAuth,
Spotify) don't work over SSH unless they set up an `ssh -L` port forward
between their laptop's browser and the remote host's loopback listener.
The helper warns users that loopback OAuth flows (Spotify) don't work over
SSH unless they set up an `ssh -L` port forward between their laptop's
browser and the remote host's loopback listener.
xAI Grok OAuth no longer uses this helper its login is device-code-only
but the Spotify integration still relies on it.
"""
from __future__ import annotations
@ -25,7 +28,7 @@ def _cap(fn):
def test_loopback_ssh_hint_silent_when_not_remote(monkeypatch):
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: False)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"http://127.0.0.1:56121/callback", docs_url=auth_mod.XAI_OAUTH_DOCS_URL
"http://127.0.0.1:43827/spotify/callback", docs_url=auth_mod.SPOTIFY_DOCS_URL
))
assert out == ""
@ -33,26 +36,25 @@ def test_loopback_ssh_hint_silent_when_not_remote(monkeypatch):
def test_loopback_ssh_hint_prints_tunnel_command_on_ssh(monkeypatch):
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"http://127.0.0.1:56121/callback", docs_url=auth_mod.XAI_OAUTH_DOCS_URL
"http://127.0.0.1:43827/spotify/callback", docs_url=auth_mod.SPOTIFY_DOCS_URL
))
# Must include the exact ssh -L command with the port from the redirect URI
assert "ssh -N -L 56121:127.0.0.1:56121" in out
assert "ssh -N -L 43827:127.0.0.1:43827" in out
# Must include the provider-specific docs URL
assert auth_mod.XAI_OAUTH_DOCS_URL in out
assert auth_mod.SPOTIFY_DOCS_URL in out
# Must always include the cross-provider SSH guide
assert auth_mod.OAUTH_OVER_SSH_DOCS_URL in out
def test_loopback_ssh_hint_uses_actual_bound_port(monkeypatch):
"""When the preferred port is busy, _xai_start_callback_server falls back to
an OS-assigned port. The hint must echo whichever port actually got bound,
not the hardcoded constant."""
"""When the preferred port is busy, the callback server falls back to an
OS-assigned port. The hint must echo whichever port actually got bound,
not a hardcoded constant."""
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"http://127.0.0.1:51234/callback", docs_url=auth_mod.XAI_OAUTH_DOCS_URL
"http://127.0.0.1:51234/callback", docs_url=auth_mod.SPOTIFY_DOCS_URL
))
assert "ssh -N -L 51234:127.0.0.1:51234" in out
assert "56121" not in out
assert "43827" not in out
def test_loopback_ssh_hint_silent_for_non_loopback_uri(monkeypatch):
@ -60,7 +62,7 @@ def test_loopback_ssh_hint_silent_for_non_loopback_uri(monkeypatch):
by mistake, we don't tell the user to forward an external port."""
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"https://example.com/callback", docs_url=auth_mod.XAI_OAUTH_DOCS_URL
"https://example.com/callback", docs_url=auth_mod.SPOTIFY_DOCS_URL
))
assert out == ""
@ -68,7 +70,7 @@ def test_loopback_ssh_hint_silent_for_non_loopback_uri(monkeypatch):
def test_loopback_ssh_hint_silent_for_malformed_uri(monkeypatch):
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"not-a-uri", docs_url=auth_mod.XAI_OAUTH_DOCS_URL
"not-a-uri", docs_url=auth_mod.SPOTIFY_DOCS_URL
))
assert out == ""
@ -86,13 +88,13 @@ def test_loopback_ssh_hint_works_without_provider_docs_url(monkeypatch):
def test_loopback_ssh_hint_accepts_localhost_hostname(monkeypatch):
"""The constant is 127.0.0.1, but parsing tolerates `localhost` too in case
a future caller normalizes the URI differently."""
"""Parsing tolerates `localhost` in case a future caller normalizes the
URI differently."""
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"http://localhost:56121/callback"
"http://localhost:43827/callback"
))
assert "ssh -N -L 56121:127.0.0.1:56121" in out
assert "ssh -N -L 43827:127.0.0.1:43827" in out
def test_loopback_ssh_hint_includes_user_at_host(monkeypatch):
@ -101,16 +103,16 @@ def test_loopback_ssh_hint_includes_user_at_host(monkeypatch):
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
monkeypatch.setattr(auth_mod, "_ssh_user_at_host", lambda: "alice@myserver.lan")
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"http://127.0.0.1:56121/callback"
"http://127.0.0.1:43827/callback"
))
assert "ssh -N -L 56121:127.0.0.1:56121 alice@myserver.lan" in out
assert "ssh -N -L 43827:127.0.0.1:43827 alice@myserver.lan" in out
def test_loopback_ssh_hint_has_visual_header(monkeypatch):
"""The hint should print a divider and header so it stands out in noisy output."""
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"http://127.0.0.1:56121/callback"
"http://127.0.0.1:43827/callback"
))
assert "Remote session detected" in out
assert "---" in out # divider is present

View file

@ -1,684 +0,0 @@
"""Tests for the OAuth manual-paste fallback for browser-only remotes.
Regression coverage for [#26923](https://github.com/NousResearch/hermes-agent/issues/26923):
GCP Cloud Shell, GitHub Codespaces, AWS EC2 Instance Connect and
other browser-only remote consoles can't reach the
``http://127.0.0.1:56121/callback`` loopback listener bound on the
remote VM. The previous SSH-tunnel hint was useless without a real
SSH client, leaving the user with no path forward. This test file
locks in four things:
* ``_is_remote_session`` recognises the cloud-shell / Codespaces
envvars (so the existing hint at least fires).
* ``_parse_pasted_callback`` accepts every form a user might paste
(full URL, ``?code=...&state=...`` fragment, bare ``code=...``,
bare opaque value) and returns the same shape the loopback HTTP
handler does.
* ``_prompt_manual_callback_paste`` reads stdin and produces that
same shape.
* ``_xai_oauth_loopback_login(manual_paste=True)`` skips the HTTP
server entirely, validates ``state``, and goes straight to the
token exchange proving the paste path actually wires up.
"""
from __future__ import annotations
import builtins
import io
import contextlib
import pytest
from hermes_cli import auth as auth_mod
# ---------------------------------------------------------------------------
# _is_remote_session — broadened detection (#26923)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"envvar",
[
"SSH_CLIENT",
"SSH_TTY",
"CLOUD_SHELL",
"CODESPACES",
"CODESPACE_NAME",
"GITPOD_WORKSPACE_ID",
"REPL_ID",
"STACKBLITZ",
],
)
def test_is_remote_session_detects_known_remote_envvar(monkeypatch, envvar):
"""Each documented remote-console env var must trip the check.
The SSH ones preserve historical behaviour; the cloud-shell ones
are what closes #26923. Without these, the SSH hint never fires
and the user has no signal that ``--manual-paste`` exists.
"""
for name in (
"SSH_CLIENT",
"SSH_TTY",
"CLOUD_SHELL",
"CODESPACES",
"CODESPACE_NAME",
"GITPOD_WORKSPACE_ID",
"REPL_ID",
"STACKBLITZ",
):
monkeypatch.delenv(name, raising=False)
monkeypatch.setenv(envvar, "1")
assert auth_mod._is_remote_session() is True
def test_is_remote_session_false_when_no_remote_envvars(monkeypatch):
for name in (
"SSH_CLIENT",
"SSH_TTY",
"CLOUD_SHELL",
"CODESPACES",
"CODESPACE_NAME",
"GITPOD_WORKSPACE_ID",
"REPL_ID",
"STACKBLITZ",
):
monkeypatch.delenv(name, raising=False)
assert auth_mod._is_remote_session() is False
# ---------------------------------------------------------------------------
# _parse_pasted_callback — accept every plausible paste form
# ---------------------------------------------------------------------------
def test_parse_full_callback_url():
out = auth_mod._parse_pasted_callback(
"http://127.0.0.1:56121/callback?code=abc123&state=deadbeef"
)
assert out == {
"code": "abc123",
"state": "deadbeef",
"error": None,
"error_description": None,
}
def test_parse_callback_url_https_and_extra_params():
out = auth_mod._parse_pasted_callback(
"https://127.0.0.1:56121/callback?code=abc&state=xyz&scope=openid"
)
assert out["code"] == "abc"
assert out["state"] == "xyz"
def test_parse_bare_query_string_with_leading_question_mark():
out = auth_mod._parse_pasted_callback("?code=p1&state=s1")
assert out["code"] == "p1"
assert out["state"] == "s1"
def test_parse_bare_query_fragment_no_question_mark():
out = auth_mod._parse_pasted_callback("code=p2&state=s2")
assert out["code"] == "p2"
assert out["state"] == "s2"
def test_parse_bare_opaque_code_value():
"""Some users only copy the ``code`` value itself."""
out = auth_mod._parse_pasted_callback("ABCDEF-the-code-value")
assert out["code"] == "ABCDEF-the-code-value"
assert out["state"] is None
def test_parse_callback_with_error_field():
out = auth_mod._parse_pasted_callback(
"http://127.0.0.1:56121/callback?error=access_denied"
"&error_description=user+rejected"
)
assert out["code"] is None
assert out["error"] == "access_denied"
assert out["error_description"] == "user rejected"
def test_parse_empty_input_returns_all_none():
out = auth_mod._parse_pasted_callback("")
assert out == {
"code": None,
"state": None,
"error": None,
"error_description": None,
}
def test_parse_whitespace_only_returns_all_none():
out = auth_mod._parse_pasted_callback(" \n\t ")
assert out["code"] is None
def test_parse_malformed_url_does_not_crash():
out = auth_mod._parse_pasted_callback("http://[not a url")
# Malformed URLs return all-None rather than raising — the caller
# (state check) will reject the empty payload with a clear error.
assert out["code"] is None
# ---------------------------------------------------------------------------
# _prompt_manual_callback_paste — stdin handling
# ---------------------------------------------------------------------------
def test_prompt_reads_stdin_and_parses(monkeypatch):
monkeypatch.setattr(
builtins, "input",
lambda *_a, **_k: "http://127.0.0.1:56121/callback?code=abc&state=xyz",
)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
out = auth_mod._prompt_manual_callback_paste(
"http://127.0.0.1:56121/callback"
)
rendered = buf.getvalue()
assert "Manual callback paste" in rendered
assert "127.0.0.1:56121" in rendered
assert out["code"] == "abc"
assert out["state"] == "xyz"
def test_prompt_eof_returns_all_none(monkeypatch):
def _raise_eof(*_a, **_k):
raise EOFError()
monkeypatch.setattr(builtins, "input", _raise_eof)
with contextlib.redirect_stdout(io.StringIO()):
out = auth_mod._prompt_manual_callback_paste(
"http://127.0.0.1:56121/callback"
)
assert out["code"] is None
def test_prompt_keyboard_interrupt_returns_all_none(monkeypatch):
def _raise_kbi(*_a, **_k):
raise KeyboardInterrupt()
monkeypatch.setattr(builtins, "input", _raise_kbi)
with contextlib.redirect_stdout(io.StringIO()):
out = auth_mod._prompt_manual_callback_paste(
"http://127.0.0.1:56121/callback"
)
assert out["code"] is None
# ---------------------------------------------------------------------------
# _xai_oauth_loopback_login(manual_paste=True) — full integration
# ---------------------------------------------------------------------------
class _StubTokenResponse:
status_code = 200
def __init__(self, payload):
self._payload = payload
self.text = ""
def json(self):
return self._payload
def test_xai_loopback_login_manual_paste_skips_http_server(monkeypatch):
"""``manual_paste=True`` must NOT bind a loopback HTTP server.
Direct end-to-end regression for #26923: the whole point is that
the listener is unreachable on browser-only remotes, so the paste
path must avoid it entirely. We assert this by replacing
``_xai_start_callback_server`` with a function that fails if
invoked, then driving the full happy path with a stubbed prompt
+ stubbed token endpoint.
"""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
def _server_must_not_be_called(*_a, **_k):
raise AssertionError(
"manual_paste=True must skip the loopback HTTP server "
"(regression for #26923)"
)
monkeypatch.setattr(
auth_mod, "_xai_start_callback_server", _server_must_not_be_called
)
captured_state: dict = {}
def _fake_prompt(_redirect_uri):
# Hermes generates state internally; we won't know it ahead of
# time, so capture the state Hermes baked into the authorize
# URL via a sneak peek on ``_xai_oauth_build_authorize_url``.
return {
"code": "fake-auth-code",
"state": captured_state["value"],
"error": None,
"error_description": None,
}
monkeypatch.setattr(
auth_mod, "_prompt_manual_callback_paste", _fake_prompt
)
original_build = auth_mod._xai_oauth_build_authorize_url
def _capture_state(**kwargs):
captured_state["value"] = kwargs["state"]
return original_build(**kwargs)
monkeypatch.setattr(
auth_mod, "_xai_oauth_build_authorize_url", _capture_state
)
def _fake_token_post(*_a, **_k):
return _StubTokenResponse(
{
"access_token": "at",
"refresh_token": "rt",
"id_token": "",
"expires_in": 3600,
"token_type": "Bearer",
}
)
monkeypatch.setattr(auth_mod.httpx, "post", _fake_token_post)
with contextlib.redirect_stdout(io.StringIO()):
creds = auth_mod._xai_oauth_loopback_login(manual_paste=True)
assert creds["tokens"]["access_token"] == "at"
assert creds["tokens"]["refresh_token"] == "rt"
assert "127.0.0.1:56121" in creds["redirect_uri"]
def test_xai_loopback_login_manual_paste_state_mismatch_raises(monkeypatch):
"""A pasted callback with the wrong state must still be rejected.
The HTTP-server path uses the same state check; manual-paste
must not be a CSRF bypass.
"""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
monkeypatch.setattr(
auth_mod, "_prompt_manual_callback_paste",
lambda _ru: {
"code": "fake",
"state": "WRONG-STATE",
"error": None,
"error_description": None,
},
)
with contextlib.redirect_stdout(io.StringIO()):
with pytest.raises(auth_mod.AuthError) as exc:
auth_mod._xai_oauth_loopback_login(manual_paste=True)
assert exc.value.code == "xai_state_mismatch"
def test_xai_loopback_login_manual_paste_bare_code_succeeds(monkeypatch):
"""Bare-code paste (state=None) must complete login under manual_paste.
xAI's consent page renders the authorization code in-page rather than
redirecting through 127.0.0.1, so on remote/headless setups the only
value the user can obtain is the opaque code with no ``state=``
parameter. ``_parse_pasted_callback`` correctly returns
``state=None`` for that input. The login flow must accept this case
(PKCE still protects the exchange); historically it raised
``xai_state_mismatch``. Regression for the bare-code branch of #26923.
"""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
monkeypatch.setattr(
auth_mod, "_prompt_manual_callback_paste",
lambda _ru: {
"code": "bare-opaque-code",
"state": None,
"error": None,
"error_description": None,
},
)
def _fake_token_post(*_a, **_k):
return _StubTokenResponse(
{
"access_token": "at",
"refresh_token": "rt",
"id_token": "",
"expires_in": 3600,
"token_type": "Bearer",
}
)
monkeypatch.setattr(auth_mod.httpx, "post", _fake_token_post)
with contextlib.redirect_stdout(io.StringIO()):
creds = auth_mod._xai_oauth_loopback_login(manual_paste=True)
assert creds["tokens"]["access_token"] == "at"
assert creds["tokens"]["refresh_token"] == "rt"
def test_xai_loopback_login_loopback_path_rejects_missing_state(monkeypatch):
"""Loopback (manual_paste=False) must NOT accept ``state=None``.
The bare-code relaxation only applies to the manual-paste path,
where the user demonstrably has no way to supply ``state``. The
HTTP-server path always sees ``state`` populated from the real
callback query string, so missing state there means something is
wrong (a malformed callback, an attacker-supplied request) and
must still raise ``xai_state_mismatch``.
"""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
class _StubServer:
def shutdown(self):
return None
def server_close(self):
return None
monkeypatch.setattr(
auth_mod, "_xai_start_callback_server",
lambda *_a, **_k: (
_StubServer(),
None,
{"code": "fake", "state": None, "error": None,
"error_description": None},
"http://127.0.0.1:56121/callback",
),
)
monkeypatch.setattr(
auth_mod, "_xai_wait_for_callback",
lambda *_a, **_k: {
"code": "fake",
"state": None,
"error": None,
"error_description": None,
},
)
monkeypatch.setattr(auth_mod, "_xai_validate_loopback_redirect_uri", lambda _u: None)
monkeypatch.setattr(auth_mod, "_print_loopback_ssh_hint", lambda *_a, **_k: None)
with contextlib.redirect_stdout(io.StringIO()):
with pytest.raises(auth_mod.AuthError) as exc:
auth_mod._xai_oauth_loopback_login(manual_paste=False, open_browser=False)
assert exc.value.code == "xai_state_mismatch"
def test_xai_loopback_login_manual_paste_missing_code_raises(monkeypatch):
"""Empty paste must surface as ``xai_code_missing``, not crash."""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
captured: dict = {"state": None}
original_build = auth_mod._xai_oauth_build_authorize_url
def _capture(**kw):
captured["state"] = kw["state"]
return original_build(**kw)
monkeypatch.setattr(auth_mod, "_xai_oauth_build_authorize_url", _capture)
monkeypatch.setattr(
auth_mod, "_prompt_manual_callback_paste",
lambda _ru: {
"code": None,
"state": captured["state"],
"error": None,
"error_description": None,
},
)
with contextlib.redirect_stdout(io.StringIO()):
with pytest.raises(auth_mod.AuthError) as exc:
auth_mod._xai_oauth_loopback_login(manual_paste=True)
assert exc.value.code == "xai_code_missing"
def test_xai_loopback_login_timeout_falls_back_to_manual_paste(monkeypatch):
"""Loopback timeout should accept a bare Grok Build code paste."""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
class _StubServer:
def shutdown(self):
return None
def server_close(self):
return None
class _StubThread:
def join(self, timeout=None):
return None
monkeypatch.setattr(
auth_mod,
"_xai_start_callback_server",
lambda: (
_StubServer(),
_StubThread(),
{
"code": None,
"state": None,
"error": None,
"error_description": None,
},
"http://127.0.0.1:56121/callback",
),
)
captured: dict = {"state": None, "prompt_calls": 0}
original_build = auth_mod._xai_oauth_build_authorize_url
def _capture(**kwargs):
captured["state"] = kwargs["state"]
return original_build(**kwargs)
monkeypatch.setattr(auth_mod, "_xai_oauth_build_authorize_url", _capture)
def _raise_timeout(*_a, **_k):
raise auth_mod.AuthError(
"xAI authorization timed out waiting for the local callback.",
provider="xai-oauth",
code="xai_callback_timeout",
)
monkeypatch.setattr(auth_mod, "_xai_wait_for_callback", _raise_timeout)
def _fake_prompt(_redirect_uri):
captured["prompt_calls"] += 1
return {
"code": "manual-auth-code",
"state": None,
"error": None,
"error_description": None,
}
monkeypatch.setattr(auth_mod, "_prompt_manual_callback_paste", _fake_prompt)
monkeypatch.setattr(
auth_mod.sys, "stdin", type("StubStdin", (), {"isatty": lambda self: True})()
)
monkeypatch.setattr(
auth_mod.httpx,
"post",
lambda *_a, **_k: _StubTokenResponse(
{
"access_token": "at-timeout",
"refresh_token": "rt-timeout",
"id_token": "",
"expires_in": 3600,
"token_type": "Bearer",
}
),
)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
creds = auth_mod._xai_oauth_loopback_login(manual_paste=False)
rendered = buf.getvalue()
assert "xAI loopback callback timed out." in rendered
assert "--manual-paste" in rendered
assert captured["prompt_calls"] == 1
assert creds["tokens"]["access_token"] == "at-timeout"
assert creds["tokens"]["refresh_token"] == "rt-timeout"
def test_xai_wait_for_callback_accepts_ready_stdin_code(monkeypatch):
"""Users can paste the Grok Build code while Hermes is still waiting."""
class _StubServer:
shutdown_called = False
close_called = False
def shutdown(self):
self.shutdown_called = True
def server_close(self):
self.close_called = True
class _StubThread:
joined = False
def join(self, timeout=None):
self.joined = True
server = _StubServer()
thread = _StubThread()
monkeypatch.setattr(
auth_mod,
"_read_ready_stdin_line",
lambda: "ready-grok-build-code\n",
)
out = auth_mod._xai_wait_for_callback(
server,
thread,
{"code": None, "error": None},
timeout_seconds=5,
manual_paste_redirect_uri="http://127.0.0.1:56121/callback",
)
assert out["code"] == "ready-grok-build-code"
assert out["state"] is None
assert out["_manual_paste"] is True
assert server.shutdown_called is True
assert server.close_called is True
assert thread.joined is True
def test_xai_loopback_login_timeout_noninteractive_reraises(monkeypatch):
"""Non-interactive stdin must keep the original timeout error."""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
class _StubServer:
def shutdown(self):
return None
def server_close(self):
return None
class _StubThread:
def join(self, timeout=None):
return None
monkeypatch.setattr(
auth_mod,
"_xai_start_callback_server",
lambda: (
_StubServer(),
_StubThread(),
{
"code": None,
"state": None,
"error": None,
"error_description": None,
},
"http://127.0.0.1:56121/callback",
),
)
monkeypatch.setattr(
auth_mod,
"_xai_wait_for_callback",
lambda *_a, **_k: (_ for _ in ()).throw(
auth_mod.AuthError(
"xAI authorization timed out waiting for the local callback.",
provider="xai-oauth",
code="xai_callback_timeout",
)
),
)
monkeypatch.setattr(
auth_mod.sys, "stdin", type("StubStdin", (), {"isatty": lambda self: False})()
)
monkeypatch.setattr(
auth_mod,
"_prompt_manual_callback_paste",
lambda *_a, **_k: pytest.fail("manual-paste fallback should not run"),
)
with contextlib.redirect_stdout(io.StringIO()):
with pytest.raises(auth_mod.AuthError) as exc:
auth_mod._xai_oauth_loopback_login(manual_paste=False)
assert exc.value.code == "xai_callback_timeout"
# ---------------------------------------------------------------------------
# _print_loopback_ssh_hint — now also mentions --manual-paste
# ---------------------------------------------------------------------------
def test_ssh_hint_mentions_manual_paste_for_non_ssh_remotes(monkeypatch):
"""Users on Cloud Shell / Codespaces have no real SSH client; the
hint must point them at the new ``--manual-paste`` flag instead
of leaving them stuck on the ``ssh -L`` recipe."""
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
auth_mod._print_loopback_ssh_hint(
"http://127.0.0.1:56121/callback",
docs_url=auth_mod.XAI_OAUTH_DOCS_URL,
)
rendered = buf.getvalue()
assert "--manual-paste" in rendered
assert "Cloud Shell" in rendered or "Codespaces" in rendered

View file

@ -2,9 +2,7 @@
import base64
import json
import socket
import time
import urllib.request
from pathlib import Path
import pytest
@ -14,17 +12,13 @@ from hermes_cli.auth import (
DEFAULT_XAI_OAUTH_BASE_URL,
PROVIDER_REGISTRY,
XAI_OAUTH_CLIENT_ID,
XAI_OAUTH_REDIRECT_HOST,
XAI_OAUTH_REDIRECT_PATH,
XAI_OAUTH_SCOPE,
_read_xai_oauth_tokens,
_save_xai_oauth_tokens,
_xai_access_token_is_expiring,
_xai_callback_cors_origin,
_xai_oauth_build_authorize_url,
_xai_start_callback_server,
_xai_oauth_poll_device_token,
_xai_oauth_request_device_code,
_xai_validate_inference_base_url,
_xai_validate_loopback_redirect_uri,
format_auth_error,
get_xai_oauth_auth_status,
refresh_xai_oauth_pure,
@ -44,6 +38,7 @@ def _setup_hermes_auth(
access_token: str = "access",
refresh_token: str = "refresh",
discovery: dict | None = None,
auth_mode: str = "oauth_pkce",
):
"""Write xAI OAuth tokens into the Hermes auth store at the given root."""
hermes_home.mkdir(parents=True, exist_ok=True)
@ -56,7 +51,7 @@ def _setup_hermes_auth(
"token_type": "Bearer",
},
"last_refresh": "2026-05-14T00:00:00Z",
"auth_mode": "oauth_pkce",
"auth_mode": auth_mode,
}
if discovery is not None:
state["discovery"] = discovery
@ -177,233 +172,84 @@ def test_xai_access_token_is_expiring_returns_false_for_jwt_without_exp():
# ---------------------------------------------------------------------------
# Loopback redirect URI validation
# Device-code flow
# ---------------------------------------------------------------------------
def test_xai_validate_loopback_redirect_uri_accepts_localhost_with_port():
host, port, path = _xai_validate_loopback_redirect_uri(
"http://127.0.0.1:56121/callback"
def test_xai_oauth_request_device_code_returns_display_fields():
response = _StubHTTPResponse(
200,
{
"device_code": "device-code",
"user_code": "ABCD-EFGH",
"verification_uri": "https://accounts.x.ai/oauth2/device",
"verification_uri_complete": "https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH",
"expires_in": 1800,
"interval": 5,
},
)
assert host == XAI_OAUTH_REDIRECT_HOST
assert port == 56121
assert path == XAI_OAUTH_REDIRECT_PATH
client = _StubHTTPClient(response)
payload = _xai_oauth_request_device_code(client)
assert payload["user_code"] == "ABCD-EFGH"
method, args, kwargs = client.last_call
assert method == "post"
assert args[0] == "https://auth.x.ai/oauth2/device/code"
assert kwargs["data"]["client_id"] == XAI_OAUTH_CLIENT_ID
assert kwargs["data"]["scope"] == XAI_OAUTH_SCOPE
def test_xai_validate_loopback_redirect_uri_rejects_https():
def test_xai_oauth_request_device_code_rejects_missing_fields():
client = _StubHTTPClient(_StubHTTPResponse(200, {"device_code": "d"}))
with pytest.raises(AuthError) as exc:
_xai_validate_loopback_redirect_uri("https://127.0.0.1:56121/callback")
assert exc.value.code == "xai_redirect_invalid"
_xai_oauth_request_device_code(client)
assert exc.value.code == "device_code_invalid"
def test_xai_validate_loopback_redirect_uri_rejects_non_loopback():
with pytest.raises(AuthError) as exc:
_xai_validate_loopback_redirect_uri("http://example.com:56121/callback")
assert exc.value.code == "xai_redirect_invalid"
def test_xai_oauth_poll_device_token_waits_until_authorized(monkeypatch):
class _SequenceClient:
def __init__(self):
self.calls = []
self.responses = [
_StubHTTPResponse(
400,
{
"error": "authorization_pending",
"error_description": "User has not yet authorized",
},
),
_StubHTTPResponse(
200,
{
"access_token": "xai-access",
"refresh_token": "xai-refresh",
"expires_in": 3600,
"token_type": "Bearer",
},
),
]
def post(self, *args, **kwargs):
self.calls.append((args, kwargs))
return self.responses.pop(0)
def test_xai_validate_loopback_redirect_uri_rejects_missing_port():
with pytest.raises(AuthError) as exc:
_xai_validate_loopback_redirect_uri("http://127.0.0.1/callback")
assert exc.value.code == "xai_redirect_invalid"
monkeypatch.setattr("hermes_cli.auth.time.sleep", lambda _: None)
client = _SequenceClient()
# ---------------------------------------------------------------------------
# Authorize URL construction
# ---------------------------------------------------------------------------
def _parse_authorize_url(url: str) -> dict:
from urllib.parse import urlparse, parse_qs
parsed = urlparse(url)
return {k: v[0] for k, v in parse_qs(parsed.query).items()}
def test_xai_oauth_authorize_url_includes_plan_generic():
"""Regression: accounts.x.ai requires `plan=generic` for loopback OAuth on
non-allowlisted clients. Must always be present on the authorize URL."""
url = _xai_oauth_build_authorize_url(
authorization_endpoint="https://auth.x.ai/oauth2/authorize",
redirect_uri="http://127.0.0.1:56121/callback",
code_challenge="challenge-xyz",
state="state-abc",
nonce="nonce-def",
payload = _xai_oauth_poll_device_token(
client,
token_endpoint="https://auth.x.ai/oauth2/token",
device_code="device-code",
expires_in=30,
poll_interval=1,
)
params = _parse_authorize_url(url)
assert params["plan"] == "generic"
def test_xai_oauth_authorize_url_includes_referrer_hermes_agent():
"""Attribution: xAI's OAuth server can identify Hermes-originated logins
via the referrer query param. Must always be present on the authorize URL."""
url = _xai_oauth_build_authorize_url(
authorization_endpoint="https://auth.x.ai/oauth2/authorize",
redirect_uri="http://127.0.0.1:56121/callback",
code_challenge="challenge-xyz",
state="state-abc",
nonce="nonce-def",
)
params = _parse_authorize_url(url)
assert params["referrer"] == "hermes-agent"
def test_xai_oauth_authorize_url_includes_pkce_and_oidc_params():
url = _xai_oauth_build_authorize_url(
authorization_endpoint="https://auth.x.ai/oauth2/authorize",
redirect_uri="http://127.0.0.1:56121/callback",
code_challenge="challenge-xyz",
state="state-abc",
nonce="nonce-def",
)
params = _parse_authorize_url(url)
assert params["response_type"] == "code"
assert params["client_id"] == XAI_OAUTH_CLIENT_ID
assert params["redirect_uri"] == "http://127.0.0.1:56121/callback"
assert params["scope"] == XAI_OAUTH_SCOPE
assert params["code_challenge"] == "challenge-xyz"
assert params["code_challenge_method"] == "S256"
assert params["state"] == "state-abc"
assert params["nonce"] == "nonce-def"
# ---------------------------------------------------------------------------
# CORS allowlist
# ---------------------------------------------------------------------------
def test_xai_callback_cors_origin_allowlist():
assert _xai_callback_cors_origin("https://accounts.x.ai") == "https://accounts.x.ai"
assert _xai_callback_cors_origin("https://auth.x.ai") == "https://auth.x.ai"
def test_xai_callback_cors_origin_rejects_unknown_origin():
assert _xai_callback_cors_origin("https://attacker.example.com") == ""
assert _xai_callback_cors_origin(None) == ""
assert _xai_callback_cors_origin("") == ""
def test_xai_callback_server_accepts_fallback_code_while_browser_connection_is_stuck():
"""Regression: Chrome/xAI can leave a loopback connection open after
showing the Grok Build fallback code. A single-threaded callback server then
blocks forever and cannot accept the manual fallback callback.
"""
server, thread, result, redirect_uri = _xai_start_callback_server(preferred_port=0)
stuck = socket.create_connection((XAI_OAUTH_REDIRECT_HOST, server.server_address[1]), timeout=2)
try:
stuck.sendall(b"GET /callback?code=stuck")
callback_url = f"{redirect_uri}?code=fallback-code&state=state-123"
with urllib.request.urlopen(callback_url, timeout=2) as response:
body = response.read().decode("utf-8")
assert response.status == 200
assert "xAI authorization received" in body
assert result["code"] == "fallback-code"
assert result["state"] == "state-123"
finally:
stuck.close()
server.shutdown()
server.server_close()
thread.join(timeout=1.0)
def test_xai_callback_server_latches_first_terminal_callback_result():
server, thread, result, redirect_uri = _xai_start_callback_server(preferred_port=0)
try:
with urllib.request.urlopen(f"{redirect_uri}?code=first-code&state=state-1", timeout=2) as response:
assert response.status == 200
with urllib.request.urlopen(
f"{redirect_uri}?error=access_denied&error_description=late&state=state-2",
timeout=2,
) as response:
body = response.read().decode("utf-8")
assert response.status == 200
assert "xAI authorization failed" in body
assert result["code"] == "first-code"
assert result["state"] == "state-1"
assert result["error"] is None
assert result["error_description"] is None
finally:
server.shutdown()
server.server_close()
thread.join(timeout=1.0)
# ---------------------------------------------------------------------------
# Loopback callback handler GET responses
# ---------------------------------------------------------------------------
def _get_callback(redirect_uri: str, query: str = "") -> tuple[int, str]:
"""GET the loopback callback URL with an optional query string."""
from urllib.request import Request, urlopen
from urllib.error import HTTPError
target = redirect_uri + (("?" + query) if query else "")
req = Request(target, method="GET")
try:
with urlopen(req, timeout=5.0) as resp:
return resp.getcode(), resp.read().decode("utf-8", "replace")
except HTTPError as exc:
return exc.code, exc.read().decode("utf-8", "replace")
def test_xai_callback_handler_returns_400_when_callback_url_lacks_code_and_error():
"""Bare loopback URL (no code, no error) must not claim authorization received.
Regression for #27385: when xAI's auth backend fails to redirect and the user
manually navigates to http://127.0.0.1:<port>/callback, the handler used to
return 200 "xAI authorization received" while the CLI's wait loop still timed
out leaving the user with a contradictory success page and a CLI error.
"""
server, thread, result, redirect_uri = _xai_start_callback_server(preferred_port=0)
try:
status, body = _get_callback(redirect_uri)
assert status == 400
assert "not received" in body.lower()
assert "hermes auth add xai-oauth" in body
# Wait loop must still see no code/error so it raises a real timeout,
# rather than treating this empty hit as a successful callback.
assert result["code"] is None
assert result["error"] is None
finally:
server.shutdown()
server.server_close()
thread.join(timeout=1.0)
def test_xai_callback_handler_accepts_callback_with_code():
"""A real OAuth redirect (code + state) still records both and shows success."""
server, thread, result, redirect_uri = _xai_start_callback_server(preferred_port=0)
try:
status, body = _get_callback(redirect_uri, query="code=abc&state=xyz")
assert status == 200
assert "xAI authorization received" in body
assert result["code"] == "abc"
assert result["state"] == "xyz"
assert result["error"] is None
finally:
server.shutdown()
server.server_close()
thread.join(timeout=1.0)
def test_xai_callback_handler_records_error_callback():
"""A redirect carrying an `error` param must surface the failure page and capture detail."""
server, thread, result, redirect_uri = _xai_start_callback_server(preferred_port=0)
try:
status, body = _get_callback(
redirect_uri,
query="error=access_denied&error_description=user%20cancelled",
)
assert status == 200
assert "xAI authorization failed" in body
assert result["error"] == "access_denied"
assert result["error_description"] == "user cancelled"
assert result["code"] is None
finally:
server.shutdown()
server.server_close()
thread.join(timeout=1.0)
assert payload["access_token"] == "xai-access"
assert len(client.calls) == 2
assert client.calls[0][1]["data"]["grant_type"] == "urn:ietf:params:oauth:grant-type:device_code"
# ---------------------------------------------------------------------------
@ -487,7 +333,9 @@ def test_resolve_xai_runtime_credentials_returns_singleton_state(tmp_path, monke
assert creds["api_key"] == fresh
assert creds["base_url"] == DEFAULT_XAI_OAUTH_BASE_URL
assert creds["source"] == "hermes-auth-store"
assert creds["auth_mode"] == "oauth_pkce"
# Display/telemetry label is hardcoded to the only supported flow, even
# though this fixture persisted a legacy ``oauth_pkce`` auth_mode.
assert creds["auth_mode"] == "oauth_device_code"
def test_resolve_xai_runtime_credentials_refreshes_expiring_token(tmp_path, monkeypatch):
@ -814,7 +662,9 @@ def test_get_xai_oauth_auth_status_logged_in_via_singleton(tmp_path, monkeypatch
status = get_xai_oauth_auth_status()
assert status["logged_in"] is True
assert status["api_key"] == fresh
assert status["auth_mode"] == "oauth_pkce"
# Display/telemetry label is hardcoded to the only supported flow, even
# though this fixture persisted a legacy ``oauth_pkce`` auth_mode.
assert status["auth_mode"] == "oauth_device_code"
def test_get_xai_oauth_auth_status_logged_out(tmp_path, monkeypatch):
@ -1168,7 +1018,10 @@ def test_xai_oauth_discovery_validates_authorization_endpoint(monkeypatch):
def test_credential_pool_seeds_xai_oauth_from_singleton(tmp_path, monkeypatch):
"""After `hermes model` -> xai-oauth, the singleton holds tokens. load_pool
must surface that as a pool entry so `hermes auth list` reflects truth and
refreshes route through the pool consistently with codex."""
refreshes route through the pool consistently with codex.
Device code is the only supported xAI OAuth flow, so the singleton is
always surfaced as ``device_code``."""
from agent.credential_pool import load_pool
hermes_home = tmp_path / "hermes"
@ -1183,10 +1036,30 @@ def test_credential_pool_seeds_xai_oauth_from_singleton(tmp_path, monkeypatch):
entry = entries[0]
assert entry.access_token == fresh
assert entry.refresh_token == "rt-1"
assert entry.source == "loopback_pkce"
assert entry.source == "device_code"
assert entry.base_url == DEFAULT_XAI_OAUTH_BASE_URL
def test_credential_pool_seeds_xai_oauth_device_code_source(tmp_path, monkeypatch):
"""Device-code xAI logins should show a device_code source in auth list."""
from agent.credential_pool import load_pool
hermes_home = tmp_path / "hermes"
fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
_setup_hermes_auth(
hermes_home,
access_token=fresh,
refresh_token="rt-1",
auth_mode="oauth_device_code",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
pool = load_pool("xai-oauth")
entry = pool.entries()[0]
assert entry.source == "device_code"
assert entry.access_token == fresh
def test_credential_pool_does_not_seed_when_singleton_missing_access_token(tmp_path, monkeypatch):
from agent.credential_pool import load_pool
@ -1208,20 +1081,20 @@ def test_credential_pool_does_not_seed_when_singleton_missing_access_token(tmp_p
assert not pool.has_credentials()
def test_credential_pool_seed_respects_suppression(tmp_path, monkeypatch):
"""`hermes auth remove xai-oauth <N>` for the seeded entry suppresses
further re-seeding so the removal is stable across load_pool calls."""
def test_credential_pool_device_code_seed_respects_suppression(tmp_path, monkeypatch):
from agent.credential_pool import load_pool
from hermes_cli.auth import suppress_credential_source
hermes_home = tmp_path / "hermes"
fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
_setup_hermes_auth(hermes_home, access_token=fresh)
_setup_hermes_auth(
hermes_home,
access_token=fresh,
auth_mode="oauth_device_code",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Suppress the source — mimic `hermes auth remove`.
from hermes_cli.auth import suppress_credential_source
suppress_credential_source("xai-oauth", "loopback_pkce")
suppress_credential_source("xai-oauth", "device_code")
pool = load_pool("xai-oauth")
assert not pool.has_credentials()
@ -1235,11 +1108,11 @@ def test_auth_remove_xai_oauth_clears_singleton_and_sticks(tmp_path, monkeypatch
the user-facing removal a no-op (the entry reappears on the next
invocation with no warning).
The bug pre-fix: there was no RemovalStep registered for
(xai-oauth, loopback_pkce), so ``find_removal_step`` returned None
The bug pre-fix: there was no RemovalStep registered for the
xai-oauth singleton source, so ``find_removal_step`` returned None
and ``auth_remove_command`` fell through to the "unregistered source —
nothing to clean up" branch. That branch is correct for ``manual``
entries (pool-only) but wrong for singleton-seeded loopback_pkce
entries (pool-only) but wrong for singleton-seeded ``device_code``
entries (auth.json singleton survives the in-memory removal)."""
from agent.credential_pool import load_pool
from hermes_cli.auth_commands import auth_remove_command
@ -1272,11 +1145,82 @@ def test_auth_remove_xai_oauth_clears_singleton_and_sticks(tmp_path, monkeypatch
pool_after = load_pool("xai-oauth")
assert not pool_after.has_credentials(), (
"Removal must stick across load_pool() calls — without the "
"loopback_pkce RemovalStep, the seed function reads the singleton "
"device_code RemovalStep, the seed function reads the singleton "
"and rebuilds the entry on every Hermes invocation."
)
def test_login_xai_oauth_relogin_clears_suppression_and_reseeds(tmp_path, monkeypatch):
"""remove -> ``hermes model`` re-login (``_login_xai_oauth``) must clear the
``device_code`` suppression marker so the singleton seed re-creates the
pool entry.
Pre-fix: ``auth_remove_command`` set ``["device_code"]`` suppression but
only ``auth_add_command`` cleared it the ``hermes model`` re-login path did
not. So after remove -> re-login the seed kept skipping and ``hermes auth
list`` showed no xAI entry even though the agent still worked via the
singleton fallback. The fix calls ``unsuppress_credential_source`` on
explicit interactive login success.
"""
from types import SimpleNamespace
from agent.credential_pool import load_pool
from hermes_cli.auth import (
_login_xai_oauth,
is_source_suppressed,
suppress_credential_source,
)
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False)
monkeypatch.delenv("XAI_BASE_URL", raising=False)
# Post-remove state: singleton gone + device_code suppressed, so the
# seed is gated off and the pool is empty.
suppress_credential_source("xai-oauth", "device_code")
assert is_source_suppressed("xai-oauth", "device_code") is True
assert not load_pool("xai-oauth").has_credentials()
new_access = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
monkeypatch.setattr(
"hermes_cli.auth._xai_oauth_device_code_login",
lambda **kwargs: {
"tokens": {
"access_token": new_access,
"refresh_token": "rt-relogin",
"id_token": "",
"token_type": "Bearer",
},
"discovery": {"token_endpoint": "https://auth.x.ai/token"},
"redirect_uri": "",
"base_url": DEFAULT_XAI_OAUTH_BASE_URL,
"last_refresh": "2026-06-30T10:00:00Z",
},
)
# Don't mutate a real config file during the test.
monkeypatch.setattr(
"hermes_cli.auth._update_config_for_provider",
lambda *args, **kwargs: "config.toml",
)
_login_xai_oauth(
SimpleNamespace(no_browser=True, timeout=3),
None, # pconfig is `del`-eted inside the function
force_new_login=True,
)
# The explicit interactive login cleared the suppression marker...
assert is_source_suppressed("xai-oauth", "device_code") is False
# ...so the singleton seed re-creates the canonical pool entry.
pool = load_pool("xai-oauth")
assert pool.has_credentials()
entry = next(e for e in pool.entries() if e.source == "device_code")
assert entry.access_token == new_access
# ---------------------------------------------------------------------------
# Pool sync-back to singleton after refresh
# ---------------------------------------------------------------------------
@ -1599,7 +1543,7 @@ def test_pool_refresh_marks_entry_exhausted_on_failure(tmp_path, monkeypatch):
def test_pool_seeded_entry_sync_back_after_refresh(tmp_path, monkeypatch):
"""When an entry seeded from the singleton (source='loopback_pkce')
"""When an entry seeded from the singleton (source='device_code')
is refreshed by the pool, the new tokens must be written back so a
fresh process load doesn't re-seed the now-consumed refresh token."""
from agent.credential_pool import load_pool
@ -1756,7 +1700,7 @@ def test_pool_exhausted_xai_entry_recovers_after_singleton_refresh(tmp_path, mon
pool = load_pool("xai-oauth")
seeded = pool.entries()[0]
assert seeded.source == "loopback_pkce"
assert seeded.source == "device_code"
# Park the seeded entry as exhausted with a far-future cooldown so
# without resync it would never be selectable.
@ -1796,7 +1740,7 @@ def test_pool_exhausted_xai_entry_recovers_after_singleton_refresh(tmp_path, mon
def test_pool_manual_xai_entry_not_synced_from_singleton(tmp_path, monkeypatch):
"""Sync from the singleton must apply ONLY to the singleton-seeded
entry (source='loopback_pkce'). Manually added entries (e.g. via
entry (source='device_code'). Manually added entries (e.g. via
``hermes auth add xai-oauth``) own their own refresh-token lifecycle
and must not be silently overwritten when the user logs in via
``hermes model``."""

View file

@ -89,6 +89,22 @@ class TestEnsureHermesHome:
ensure_hermes_home()
assert soul_path.read_text(encoding="utf-8") == mixed
def test_existing_named_profile_still_bootstraps_subdirs(self, tmp_path):
profile_home = tmp_path / ".hermes" / "profiles" / "coder"
profile_home.mkdir(parents=True)
with patch.dict(os.environ, {"HERMES_HOME": str(profile_home)}):
ensure_hermes_home()
assert (profile_home / "cron").is_dir()
assert (profile_home / "sessions").is_dir()
assert (profile_home / "memories").is_dir()
def test_missing_named_profile_is_not_recreated(self, tmp_path):
profile_home = tmp_path / ".hermes" / "profiles" / "coder"
with patch.dict(os.environ, {"HERMES_HOME": str(profile_home)}):
with pytest.raises(FileNotFoundError, match="Named profile home does not exist"):
ensure_hermes_home()
assert not profile_home.exists()
class TestLoadConfigDefaults:
def test_returns_defaults_when_no_file(self, tmp_path):

View file

@ -7,13 +7,18 @@ and shell completion generation.
import json
import io
import os
import shutil
import sys
import tarfile
import types
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
import yaml
from hermes_cli import profiles
from hermes_cli.profiles import (
normalize_profile_name,
validate_profile_name,
@ -578,6 +583,7 @@ class TestDeleteProfile:
set_active_profile("coder")
with patch("hermes_cli.profiles._cleanup_gateway_service"), \
patch("hermes_cli.profiles.time.sleep"), \
patch("hermes_cli.profiles.shutil.rmtree", side_effect=PermissionError("locked")):
with pytest.raises(RuntimeError, match="Could not remove profile directory"):
delete_profile("coder", yes=True)
@ -585,6 +591,84 @@ class TestDeleteProfile:
assert profile_dir.is_dir()
assert get_active_profile() == "default"
def test_stops_profile_bound_backends_before_removal(self, profile_env):
"""A Desktop-spawned backend (not in gateway.pid) is stopped first."""
profile_dir = create_profile("coder", no_alias=True)
with patch("hermes_cli.profiles._cleanup_gateway_service"), \
patch("hermes_cli.profiles._profile_bound_backend_pids", return_value=[4242]) as pids, \
patch("gateway.status.terminate_pid") as terminate, \
patch("gateway.status._pid_exists", return_value=False):
delete_profile("coder", yes=True)
pids.assert_called_once()
terminate.assert_any_call(4242)
assert not profile_dir.is_dir()
def test_rmtree_retries_transient_enotempty_then_succeeds(self, profile_env):
"""A live writer racing rmtree (ENOTEMPTY) is absorbed by a retry."""
profile_dir = create_profile("coder", no_alias=True)
real_rmtree = shutil.rmtree
calls = {"n": 0}
def flaky_rmtree(path, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
raise OSError(66, "Directory not empty")
return real_rmtree(path)
with patch("hermes_cli.profiles._cleanup_gateway_service"), \
patch("hermes_cli.profiles._profile_bound_backend_pids", return_value=[]), \
patch("hermes_cli.profiles.time.sleep"), \
patch("hermes_cli.profiles.shutil.rmtree", side_effect=flaky_rmtree):
delete_profile("coder", yes=True)
assert calls["n"] == 2
assert not profile_dir.is_dir()
def test_backend_scan_only_matches_this_profile(self, profile_env, monkeypatch):
"""The backend PID scan binds by --profile selector and skips self."""
create_profile("coder", no_alias=True)
profile_dir = get_profile_dir("coder")
class FakeProc:
def __init__(self, pid, cmdline, username="me"):
self.pid = pid
self.info = {"pid": pid, "name": "python", "username": username, "cmdline": cmdline}
def parent(self):
return None
def username(self):
return "me"
def environ(self):
return {}
self_pid = os.getpid()
procs = [
# Backend bound to coder → matched.
FakeProc(101, ["python", "-m", "hermes_cli.main", "--profile", "coder", "serve"]),
# Interactive chat for coder → NOT a backend subcommand, skipped.
FakeProc(102, ["python", "-m", "hermes_cli.main", "--profile", "coder", "chat"]),
# Backend for a different profile → skipped.
FakeProc(103, ["python", "-m", "hermes_cli.main", "--profile", "other", "serve"]),
# This very process → skipped even if it matched.
FakeProc(self_pid, ["python", "-m", "hermes_cli.main", "--profile", "coder", "serve"]),
]
fake_psutil = types.SimpleNamespace(
process_iter=lambda attrs=None: iter(procs),
Process=lambda pid=None: FakeProc(self_pid, []),
NoSuchProcess=Exception,
AccessDenied=Exception,
ZombieProcess=Exception,
)
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
pids = profiles._profile_bound_backend_pids("coder", profile_dir)
assert pids == [101]
# ===================================================================
# TestListProfiles

View file

@ -460,13 +460,13 @@ def test_anthropic_pkce_branch_still_works():
assert "claude.ai" in body["auth_url"]
def test_xai_oauth_listed_as_loopback_flow():
"""xAI Grok OAuth must surface in the catalog as a first-class loopback flow."""
def test_xai_oauth_listed_as_device_code_flow():
"""xAI Grok OAuth must surface in the catalog as a device-code flow."""
resp = client.get("/api/providers/oauth", headers=HEADERS)
assert resp.status_code == 200, resp.text
providers = {p["id"]: p for p in resp.json()["providers"]}
assert "xai-oauth" in providers
assert providers["xai-oauth"]["flow"] == "loopback"
assert providers["xai-oauth"]["flow"] == "device_code"
assert "grok" in providers["xai-oauth"]["name"].lower()
@ -557,247 +557,117 @@ def test_env_sourced_oauth_status_is_not_disconnectable(monkeypatch):
assert "Settings" in delete_resp.text
def test_xai_loopback_start_returns_authorize_url(monkeypatch):
"""Start MUST bind the loopback listener and hand back an xAI authorize URL."""
def test_xai_oauth_device_code_start_returns_user_code(monkeypatch):
"""Start MUST hand back xAI's verification URL and user code."""
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
class _FakeServer:
def shutdown(self):
pass
def server_close(self):
pass
class _FakeThread:
def join(self, timeout=None):
pass
redirect_uri = (
f"http://{auth_mod.XAI_OAUTH_REDIRECT_HOST}:{auth_mod.XAI_OAUTH_REDIRECT_PORT}"
f"{auth_mod.XAI_OAUTH_REDIRECT_PATH}"
)
monkeypatch.setattr(
auth_mod,
"_xai_oauth_discovery",
"_xai_oauth_request_device_code",
lambda *a, **k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/auth",
"token_endpoint": "https://auth.x.ai/oauth2/token",
"device_code": "device-code",
"user_code": "ABCD-EFGH",
"verification_uri": "https://accounts.x.ai/oauth2/device",
"verification_uri_complete": "https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH",
"expires_in": 1800,
"interval": 5,
},
)
monkeypatch.setattr(
auth_mod,
"_xai_start_callback_server",
lambda *a, **k: (_FakeServer(), _FakeThread(), {"code": None, "error": None}, redirect_uri),
)
# Don't let the background worker run a real callback wait/exchange.
monkeypatch.setattr(ws, "_xai_loopback_worker", lambda sid: None)
# Don't let the background poller hit the real token endpoint.
monkeypatch.setattr(ws, "_xai_device_poller", lambda sid: None)
resp = client.post("/api/providers/oauth/xai-oauth/start", headers=HEADERS)
assert resp.status_code == 200, resp.text
body = resp.json()
try:
assert body["flow"] == "loopback"
assert "user_code" not in body # loopback has nothing to paste/show
assert body["auth_url"].startswith("https://auth.x.ai/oauth2/auth?")
assert "code_challenge" in body["auth_url"]
assert body["flow"] == "device_code"
assert body["user_code"] == "ABCD-EFGH"
assert body["verification_url"].startswith("https://accounts.x.ai/oauth2/device")
sess = ws._oauth_sessions[body["session_id"]]
assert sess["provider"] == "xai-oauth"
assert sess["flow"] == "loopback"
assert sess["flow"] == "device_code"
assert sess["device_code"] == "device-code"
finally:
ws._oauth_sessions.pop(body["session_id"], None)
def test_xai_loopback_worker_persists_tokens_on_success(monkeypatch):
"""The worker exchanges the callback code and marks the session approved."""
def test_xai_dashboard_poller_seeds_single_entry_and_clears_suppression(tmp_path, monkeypatch):
"""The dashboard device-code poller must leave exactly ONE pool entry — the
singleton-seeded ``device_code`` source and must NOT create a parallel
``manual:dashboard_*`` entry.
Dedupe: a parallel dashboard entry would share the singleton's single-use
refresh token, and two entries racing the same rotation ->
``refresh_token_reused`` (on main, the dashboard login inserted exactly
such a duplicate alongside the singleton seed). The poller writes the
singleton only; the seed is the single source of truth.
Suppression: an interactive dashboard login must also clear any
``device_code`` suppression left by a prior ``hermes auth remove
xai-oauth``.
"""
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
from agent.credential_pool import load_pool
saved = {}
session_id = "xai-loopback-success-test"
ws._oauth_sessions[session_id] = {
"session_id": session_id,
"provider": "xai-oauth",
"flow": "loopback",
"created_at": time.time(),
"status": "pending",
"error_message": None,
"server": object(),
"thread": object(),
"callback_result": {"code": "auth-code", "state": "st"},
"redirect_uri": "http://127.0.0.1:56121/callback",
"verifier": "verifier",
"challenge": "challenge",
"state": "st",
"token_endpoint": "https://auth.x.ai/oauth2/token",
"discovery": {"token_endpoint": "https://auth.x.ai/oauth2/token"},
}
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False)
monkeypatch.delenv("XAI_BASE_URL", raising=False)
# Prior `hermes auth remove xai-oauth` left the source suppressed.
auth_mod.suppress_credential_source("xai-oauth", "device_code")
assert auth_mod.is_source_suppressed("xai-oauth", "device_code") is True
monkeypatch.setattr(
auth_mod,
"_xai_wait_for_callback",
lambda *a, **k: {"code": "auth-code", "state": "st"},
"_xai_oauth_discovery",
lambda *a, **k: {"token_endpoint": "https://auth.x.ai/token"},
)
monkeypatch.setattr(
auth_mod,
"_xai_oauth_exchange_code_for_tokens",
lambda **k: {
"access_token": "xai-access",
"refresh_token": "xai-refresh",
"_xai_oauth_poll_device_token",
lambda client, **kwargs: {
"access_token": "xai-dashboard-access",
"refresh_token": "rt-dashboard",
"id_token": "",
"expires_in": 3600,
"token_type": "Bearer",
},
)
monkeypatch.setattr(
auth_mod,
"_save_xai_oauth_tokens",
lambda tokens, **k: saved.update(tokens),
)
monkeypatch.setattr(ws, "_add_xai_oauth_pool_entry", lambda *a, **k: None)
session_id = "xai-dashboard-dedupe-test"
ws._oauth_sessions[session_id] = {
"session_id": session_id,
"provider": "xai-oauth",
"flow": "device_code",
"created_at": time.time(),
"status": "pending",
"error_message": None,
"device_code": "device-code",
"interval": 5,
"expires_at": time.time() + 600,
}
try:
ws._xai_loopback_worker(session_id)
ws._xai_device_poller(session_id)
assert ws._oauth_sessions[session_id]["status"] == "approved"
assert saved["access_token"] == "xai-access"
assert saved["refresh_token"] == "xai-refresh"
finally:
ws._oauth_sessions.pop(session_id, None)
# The interactive dashboard login cleared the suppression marker.
assert auth_mod.is_source_suppressed("xai-oauth", "device_code") is False
def test_xai_loopback_worker_fails_on_state_mismatch(monkeypatch):
"""A mismatched OAuth state must fail the session, not persist tokens."""
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
session_id = "xai-loopback-state-test"
ws._oauth_sessions[session_id] = {
"session_id": session_id,
"provider": "xai-oauth",
"flow": "loopback",
"created_at": time.time(),
"status": "pending",
"error_message": None,
"server": object(),
"thread": object(),
"callback_result": {},
"redirect_uri": "http://127.0.0.1:56121/callback",
"verifier": "verifier",
"challenge": "challenge",
"state": "expected-state",
"token_endpoint": "https://auth.x.ai/oauth2/token",
"discovery": {},
}
monkeypatch.setattr(
auth_mod,
"_xai_wait_for_callback",
lambda *a, **k: {"code": "auth-code", "state": "ATTACKER-state"},
# The credential pool has exactly one entry, seeded from the
# singleton as ``device_code`` — no parallel ``manual:dashboard_*``
# duplicate sharing the single-use refresh token.
entries = load_pool("xai-oauth").entries()
assert len(entries) == 1
assert entries[0].source == "device_code"
assert entries[0].refresh_token == "rt-dashboard"
assert not any(
getattr(e, "source", "").startswith("manual:dashboard") for e in entries
)
def _boom(**kwargs):
raise AssertionError("token exchange must not run on state mismatch")
monkeypatch.setattr(auth_mod, "_xai_oauth_exchange_code_for_tokens", _boom)
try:
ws._xai_loopback_worker(session_id)
sess = ws._oauth_sessions[session_id]
assert sess["status"] == "error"
assert "state mismatch" in sess["error_message"].lower()
finally:
ws._oauth_sessions.pop(session_id, None)
def test_xai_loopback_worker_skips_persist_when_cancelled(monkeypatch):
"""If the session is cancelled while waiting, the worker must not persist."""
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
session_id = "xai-loopback-cancel-test"
ws._oauth_sessions[session_id] = {
"session_id": session_id,
"provider": "xai-oauth",
"flow": "loopback",
"created_at": time.time(),
"status": "pending",
"error_message": None,
"server": object(),
"thread": object(),
"callback_result": {},
"redirect_uri": "http://127.0.0.1:56121/callback",
"verifier": "verifier",
"challenge": "challenge",
"state": "st",
"token_endpoint": "https://auth.x.ai/oauth2/token",
"discovery": {},
}
def _wait_then_cancel(*args, **kwargs):
# Simulate the user cancelling (DELETE /sessions/{id}) while we were
# blocked on the callback: the session vanishes, then a valid code
# arrives. The worker must notice and bail before persisting.
ws._oauth_sessions.pop(session_id, None)
return {"code": "auth-code", "state": "st"}
monkeypatch.setattr(auth_mod, "_xai_wait_for_callback", _wait_then_cancel)
def _must_not_persist(*args, **kwargs):
raise AssertionError("tokens must not be persisted for a cancelled session")
monkeypatch.setattr(auth_mod, "_save_xai_oauth_tokens", _must_not_persist)
monkeypatch.setattr(ws, "_add_xai_oauth_pool_entry", _must_not_persist)
# Should return cleanly without raising and without persisting.
ws._xai_loopback_worker(session_id)
assert session_id not in ws._oauth_sessions
def test_cancel_loopback_session_shuts_down_callback_server():
"""Cancelling a loopback session must free the bound callback port now."""
from hermes_cli import web_server as ws
shutdown_calls = {"shutdown": 0, "close": 0, "join": 0}
class _FakeServer:
def shutdown(self):
shutdown_calls["shutdown"] += 1
def server_close(self):
shutdown_calls["close"] += 1
class _FakeThread:
def join(self, timeout=None):
shutdown_calls["join"] += 1
# callback_result is the dict the worker's _xai_wait_for_callback polls.
callback_result = {"code": None, "error": None}
session_id = "xai-loopback-cancel-shutdown-test"
ws._oauth_sessions[session_id] = {
"session_id": session_id,
"provider": "xai-oauth",
"flow": "loopback",
"created_at": time.time(),
"status": "pending",
"server": _FakeServer(),
"thread": _FakeThread(),
"callback_result": callback_result,
}
try:
resp = client.delete(
f"/api/providers/oauth/sessions/{session_id}", headers=HEADERS
)
assert resp.status_code == 200, resp.text
assert resp.json()["ok"] is True
assert shutdown_calls == {"shutdown": 1, "close": 1, "join": 1}
# The waiting worker must be signalled so it returns promptly instead
# of spinning until the timeout.
assert callback_result["error"] == "cancelled"
assert session_id not in ws._oauth_sessions
finally:
ws._oauth_sessions.pop(session_id, None)
def test_unknown_pkce_provider_rejected_cleanly():
"""A future PKCE provider without an explicit branch must NOT silently route to Anthropic.

View file

@ -33,12 +33,11 @@ def test_xai_model_flow_reauth_uses_standard_radio_prompt(monkeypatch):
main_mod._model_flow_xai_oauth(
{},
current_model="grok-build-0.1",
args=argparse.Namespace(manual_paste=True, no_browser=True, timeout=3),
args=argparse.Namespace(no_browser=True, timeout=3),
)
assert captured["login_calls"] == 1
assert captured["force_new_login"] is True
assert captured["args"].manual_paste is True
assert captured["args"].no_browser is True
assert captured["args"].timeout == 3

View file

@ -1,359 +0,0 @@
"""Regression coverage for xAI OAuth PKCE token exchange (issue #26990).
Issue [#26990] reported that ``hermes auth add xai-oauth`` succeeds at the
browser-side authorize step but fails at the token endpoint with
``code_challenge is required`` the symptom of an OAuth server that
re-validates PKCE at the token step instead of relying purely on
state captured during the authorize redirect.
The fix in ``hermes_cli/auth.py`` extracts the token POST into
:func:`_xai_oauth_exchange_code_for_tokens` and:
* Sends ``code_verifier`` (RFC 7636 §4.5 requirement).
* **Also** echoes ``code_challenge`` and ``code_challenge_method``
in the request body as defense-in-depth strictly compliant
servers ignore extras at the token endpoint, but xAI's server
needs them.
* Refuses to fire the POST locally when ``code_verifier`` is empty
(avoids leaking the auth code to a server that can't redeem it).
* Surfaces the HTTP status code prominently in the error message so
users / maintainers can tell a 400 (bad request) from a 403
(entitlement denied) at a glance.
These tests pin all three behaviors so the fix can't silently regress.
"""
from __future__ import annotations
from typing import Any, Dict, List
from urllib.parse import parse_qs
import httpx
import pytest
from hermes_cli.auth import (
AuthError,
XAI_OAUTH_CLIENT_ID,
_xai_oauth_exchange_code_for_tokens,
)
# ---------------------------------------------------------------------------
# httpx.post recorder
# ---------------------------------------------------------------------------
class _PostRecorder:
"""Capture every ``httpx.post`` call without touching the network."""
def __init__(self, response: httpx.Response) -> None:
self.response = response
self.calls: List[Dict[str, Any]] = []
def __call__(self, url, *, headers=None, data=None, timeout=None, **kw):
self.calls.append(
{"url": url, "headers": headers or {}, "data": data or {},
"timeout": timeout, "extra": kw}
)
return self.response
def _ok_response(payload: dict) -> httpx.Response:
return httpx.Response(200, json=payload)
def _err_response(status: int, body: str) -> httpx.Response:
return httpx.Response(status, text=body)
@pytest.fixture
def post_recorder(monkeypatch):
"""Default: 200 response with a full xAI token payload."""
recorder = _PostRecorder(
_ok_response(
{
"access_token": "AT-fresh",
"refresh_token": "RT-fresh",
"id_token": "ID",
"expires_in": 3600,
"token_type": "Bearer",
}
)
)
monkeypatch.setattr("hermes_cli.auth.httpx.post", recorder)
return recorder
# ---------------------------------------------------------------------------
# Core contract: which fields go on the wire?
# ---------------------------------------------------------------------------
def test_token_exchange_includes_code_verifier(post_recorder):
"""RFC 7636 §4.5 — ``code_verifier`` MUST be sent."""
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="theVerifier_43_to_128_chars_____________________",
code_challenge="aBcDeF",
)
sent = post_recorder.calls[-1]["data"]
assert sent["code_verifier"] == "theVerifier_43_to_128_chars_____________________"
def test_token_exchange_also_echoes_code_challenge_for_xai(post_recorder):
"""Defense-in-depth for #26990 — xAI re-validates the challenge
at the token endpoint, not just at authorize. Without this echo
we get ``code_challenge is required`` even though we send a valid
``code_verifier``."""
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="aBcDeF",
)
sent = post_recorder.calls[-1]["data"]
assert sent["code_challenge"] == "aBcDeF"
assert sent["code_challenge_method"] == "S256"
def test_token_exchange_uses_correct_grant_and_client(post_recorder):
"""Lock the static fields too — a future refactor must not flip
these to ``client_credentials`` or drop ``client_id``."""
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="c" * 43,
)
sent = post_recorder.calls[-1]["data"]
assert sent["grant_type"] == "authorization_code"
assert sent["code"] == "AUTHCODE"
assert sent["redirect_uri"] == "http://127.0.0.1:56121/callback"
assert sent["client_id"] == XAI_OAUTH_CLIENT_ID
def test_token_exchange_uses_form_urlencoded_content_type(post_recorder):
"""xAI's token endpoint expects ``application/x-www-form-urlencoded``."""
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="c" * 43,
)
headers = post_recorder.calls[-1]["headers"]
assert headers["Content-Type"] == "application/x-www-form-urlencoded"
assert headers["Accept"] == "application/json"
def test_token_exchange_targets_the_supplied_endpoint(post_recorder):
"""Some test fixtures sniff the discovered token endpoint dynamically.
We must POST to the URL the caller passed, not a hard-coded constant."""
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/some/other/token/path",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="c" * 43,
)
assert post_recorder.calls[-1]["url"] == "https://auth.x.ai/some/other/token/path"
def test_token_exchange_passes_timeout_through(post_recorder):
"""Operators on slow networks pass a higher ``timeout_seconds``;
the helper must forward it (and bump the floor to 20s)."""
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="c" * 43,
timeout_seconds=45.0,
)
assert post_recorder.calls[-1]["timeout"] == 45.0
def test_token_exchange_floor_timeout_is_20s(post_recorder):
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="c" * 43,
timeout_seconds=2.0,
)
assert post_recorder.calls[-1]["timeout"] == 20.0
# ---------------------------------------------------------------------------
# Sanity guard: refuse to POST with an empty code_verifier
# ---------------------------------------------------------------------------
def test_empty_code_verifier_raises_without_posting(post_recorder):
"""If ``code_verifier`` is somehow lost upstream, we must refuse to
send the request leaking an authorization code to xAI without a
verifier is worse than failing locally with an actionable error."""
with pytest.raises(AuthError) as exc_info:
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="",
code_challenge="c" * 43,
)
assert exc_info.value.code == "xai_pkce_verifier_missing"
assert "26990" in str(exc_info.value)
# And critically: nothing was sent.
assert post_recorder.calls == []
def test_missing_code_challenge_omits_echo_but_still_sends_verifier(post_recorder):
"""``code_challenge`` is defensive — if a caller doesn't have it
handy, we must still send the standards-compliant request rather
than refusing. This keeps RFC-compliant servers happy."""
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="",
)
sent = post_recorder.calls[-1]["data"]
assert sent["code_verifier"] == "v" * 64
assert "code_challenge" not in sent
assert "code_challenge_method" not in sent
# ---------------------------------------------------------------------------
# Error surfacing
# ---------------------------------------------------------------------------
def test_non_200_response_surfaces_status_and_body(monkeypatch):
"""When xAI returns a 4xx, the operator needs both the HTTP status
code (to tell 400 from 401 from 403 at a glance) and the response
body (the actual server-side reason)."""
recorder = _PostRecorder(
_err_response(400, '{"error":"invalid_grant","error_description":"code_challenge is required"}')
)
monkeypatch.setattr("hermes_cli.auth.httpx.post", recorder)
with pytest.raises(AuthError) as exc_info:
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="c" * 43,
)
msg = str(exc_info.value)
assert "HTTP 400" in msg, (
"Status code must be in the error so callers can disambiguate "
"tier-denied (403) from bad-request (400) without inspecting "
"exc.code."
)
assert "code_challenge is required" in msg
assert exc_info.value.code == "xai_token_exchange_failed"
def test_transport_error_wraps_as_auth_error(monkeypatch):
"""A connection failure must come back as ``AuthError`` so the
surrounding ``format_auth_error`` UI mapping fires correctly."""
def _boom(*args, **kwargs):
raise httpx.ConnectError("dns failure")
monkeypatch.setattr("hermes_cli.auth.httpx.post", _boom)
with pytest.raises(AuthError) as exc_info:
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="c" * 43,
)
assert exc_info.value.code == "xai_token_exchange_failed"
assert "dns failure" in str(exc_info.value)
def test_non_dict_payload_raises_invalid_json(monkeypatch):
"""xAI returning ``[]`` or a string at 200 is a server bug — fail
with a precise error rather than crashing later in token storage."""
recorder = _PostRecorder(_ok_response([1, 2, 3])) # type: ignore[arg-type]
monkeypatch.setattr("hermes_cli.auth.httpx.post", recorder)
with pytest.raises(AuthError) as exc_info:
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="c" * 43,
)
assert exc_info.value.code == "xai_token_exchange_invalid"
def test_success_returns_full_payload_dict(post_recorder):
"""200 happy path: the parsed JSON dict comes back verbatim so the
caller can pluck ``access_token`` / ``refresh_token`` etc."""
out = _xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="c" * 43,
)
assert out["access_token"] == "AT-fresh"
assert out["refresh_token"] == "RT-fresh"
# ---------------------------------------------------------------------------
# Wire-format guard: httpx must serialise ``data`` as form-urlencoded
# ---------------------------------------------------------------------------
def test_wire_format_is_form_urlencoded_with_all_pkce_fields(monkeypatch):
"""End-to-end check on the actual bytes httpx puts on the wire.
If anyone ever swaps ``data=`` for ``json=`` or refactors the dict,
xAI will start rejecting again this catches it locally."""
captured: Dict[str, Any] = {}
class _Transport(httpx.BaseTransport):
def handle_request(self, request):
captured["body"] = bytes(request.read())
captured["content_type"] = request.headers.get("content-type", "")
return httpx.Response(
200,
json={"access_token": "AT", "refresh_token": "RT",
"id_token": "", "expires_in": 60, "token_type": "Bearer"},
)
real_post = httpx.post
def _post(*args, **kwargs):
with httpx.Client(transport=_Transport()) as c:
return c.post(*args, **kwargs)
monkeypatch.setattr("hermes_cli.auth.httpx.post", _post)
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="theVerifier_43+",
code_challenge="theChallenge_43+",
)
assert "application/x-www-form-urlencoded" in captured["content_type"]
parsed = parse_qs(captured["body"].decode())
assert parsed["grant_type"] == ["authorization_code"]
assert parsed["code"] == ["AUTHCODE"]
assert parsed["redirect_uri"] == ["http://127.0.0.1:56121/callback"]
assert parsed["client_id"] == [XAI_OAUTH_CLIENT_ID]
assert parsed["code_verifier"] == ["theVerifier_43+"]
assert parsed["code_challenge"] == ["theChallenge_43+"]
assert parsed["code_challenge_method"] == ["S256"]

View file

@ -41,3 +41,17 @@ def test_xai_oauth_token_not_expiring_beyond_one_hour_skew() -> None:
token,
auth.XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
)
def test_xai_proactive_refresh_skew_short_lived_token() -> None:
token = _jwt_with_exp(int(time.time()) + 15 * 60)
skew = auth._xai_proactive_refresh_skew_seconds(token)
assert skew == 120
assert not auth._xai_access_token_is_expiring(token, skew)
def test_xai_proactive_refresh_skew_long_lived_token() -> None:
token = _jwt_with_exp(int(time.time()) + 5 * 60 * 60)
assert auth._xai_proactive_refresh_skew_seconds(token) == auth.XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS

View file

@ -0,0 +1,141 @@
"""Unit tests for the Z.AI / GLM provider profile's thinking-mode wiring.
Z.AI's GLM-4.5-and-later chat models default to thinking-mode ON when the
request omits ``thinking``. Before the profile emitted the parameter,
``reasoning_config = {"enabled": False}`` was a silent no-op on the direct
Z.AI route users who turned thinking off kept burning thinking tokens on
every turn (the desktop "thinking reverts to medium" report).
These tests pin the profile's wire-shape contract so Z.AI requests stay
correctly shaped without going live.
"""
from __future__ import annotations
import pytest
@pytest.fixture
def zai_profile():
"""Resolve the registered Z.AI profile through the real discovery path."""
# ``model_tools`` triggers plugin discovery on import, which is what
# registers the Z.AI profile in the global provider registry.
import model_tools # noqa: F401
import providers
profile = providers.get_provider_profile("zai")
assert profile is not None, "zai provider profile must be registered"
return profile
class TestZaiThinkingWireShape:
"""``build_api_kwargs_extras`` produces Z.AI's exact wire format."""
def test_no_preference_omits_thinking(self, zai_profile):
"""No reasoning_config → omit ``thinking`` so the server default
applies (matches prior behavior for users with no preference)."""
extra_body, top_level = zai_profile.build_api_kwargs_extras(
reasoning_config=None, model="glm-5"
)
assert extra_body == {}
assert top_level == {}
def test_enabled_sends_enabled_marker(self, zai_profile):
extra_body, top_level = zai_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "medium"}, model="glm-5"
)
assert extra_body == {"thinking": {"type": "enabled"}}
assert top_level == {}
def test_explicitly_disabled_sends_disabled_marker(self, zai_profile):
"""``reasoning_config.enabled=False`` → ``thinking.type=disabled``.
The crucial bit is that the parameter is *sent* at all GLM defaults
to thinking-on when ``thinking`` is absent, so an unsent disable
burns thinking tokens forever.
"""
extra_body, top_level = zai_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False}, model="glm-5"
)
assert extra_body == {"thinking": {"type": "disabled"}}
assert top_level == {}
def test_no_effort_levels_leak_to_top_level(self, zai_profile):
"""GLM has no effort knob — never emit ``reasoning_effort``."""
for effort in ("minimal", "low", "medium", "high", "xhigh"):
_, top_level = zai_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort}, model="glm-5.2"
)
assert top_level == {}
class TestZaiModelGating:
"""GLM 4.5+ get thinking; earlier GLM models are left untouched."""
@pytest.mark.parametrize(
"model",
[
"glm-4.5",
"glm-4.5-air",
"glm-4.5-flash",
"glm-4.6",
"glm-5",
"glm-5.2",
"GLM-5", # case-insensitive
],
)
def test_thinking_capable_models_emit_thinking(self, zai_profile, model):
extra_body, _ = zai_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False}, model=model
)
assert extra_body == {"thinking": {"type": "disabled"}}
@pytest.mark.parametrize(
"model",
[
"glm-4-9b", # pre-4.5, no thinking param
"glm-4",
"glm-3-turbo",
"", # bare/unknown
None, # missing
"charglm-3", # non-GLM-versioned id
],
)
def test_non_thinking_models_emit_nothing(self, zai_profile, model):
extra_body, top_level = zai_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False}, model=model
)
assert extra_body == {}
assert top_level == {}
class TestZaiFullKwargsIntegration:
"""End-to-end: the transport's full kwargs carry the thinking marker."""
def test_disabled_reaches_the_wire(self, zai_profile):
from agent.transports.chat_completions import ChatCompletionsTransport
kwargs = ChatCompletionsTransport().build_kwargs(
model="glm-5",
messages=[{"role": "user", "content": "ping"}],
tools=None,
provider_profile=zai_profile,
reasoning_config={"enabled": False},
base_url="https://api.z.ai/api/paas/v4",
provider_name="zai",
)
assert kwargs["extra_body"]["thinking"] == {"type": "disabled"}
def test_no_preference_keeps_wire_clean(self, zai_profile):
from agent.transports.chat_completions import ChatCompletionsTransport
kwargs = ChatCompletionsTransport().build_kwargs(
model="glm-5",
messages=[{"role": "user", "content": "ping"}],
tools=None,
provider_profile=zai_profile,
reasoning_config=None,
base_url="https://api.z.ai/api/paas/v4",
provider_name="zai",
)
assert "thinking" not in kwargs.get("extra_body", {})

View file

@ -958,7 +958,7 @@ def test_try_refresh_codex_client_credentials_handles_xai_oauth(monkeypatch):
def test_try_refresh_codex_client_credentials_skips_xai_oauth_when_singleton_differs(monkeypatch):
"""An xai-oauth agent constructed with a non-singleton credential
(e.g. a manual pool entry whose tokens belong to a different account
than the loopback_pkce singleton, or an explicit ``api_key=`` arg)
than the device_code singleton, or an explicit ``api_key=`` arg)
MUST NOT silently adopt the singleton's tokens on a 401 reactive
refresh. Otherwise a 401 mid-conversation would re-route the rest
of the conversation onto a different account, with no user feedback.

View file

@ -436,6 +436,18 @@ class TestParseReasoningEffort:
"""The literal "none" disables reasoning explicitly."""
assert parse_reasoning_effort("none") == {"enabled": False}
@pytest.mark.parametrize("value", [False, "false", "FALSE", "disabled", " Disabled "])
def test_false_aliases_disable_reasoning(self, value):
"""YAML `reasoning_effort: false`/`off`/`no` reaches loaders as a
boolean; users also hand-write "false"/"disabled". All must mean
disabled not "unset, fall back to the default and keep thinking"."""
assert parse_reasoning_effort(value) == {"enabled": False}
@pytest.mark.parametrize("value", [None, True])
def test_non_string_non_false_returns_none(self, value):
"""None and boolean True fall back to the caller default."""
assert parse_reasoning_effort(value) is None
@pytest.mark.parametrize("level", list(VALID_REASONING_EFFORTS))
def test_each_valid_level(self, level):
"""Every level listed in VALID_REASONING_EFFORTS is accepted as-is."""

View file

@ -69,20 +69,48 @@ def _stub_uvicorn(monkeypatch):
return captured
def test_start_server_enables_ws_ping_for_half_open_detection(monkeypatch):
"""WS ping must be configured so half-open connections (reverse-proxy 524,
dropped tunnels) raise WebSocketDisconnect into the reaping path (#32377).
def test_start_server_disables_ws_ping_on_loopback(monkeypatch):
"""Loopback binds (the Desktop case) MUST disable uvicorn's protocol-level
keepalive ping so an event-loop stall can never trigger a false disconnect.
Loopback binds (the Desktop case) get a longer window to ride out
GIL-pressure event-loop stalls (#48445/#50005). The invariant asserted
here is that ping stays enabled (non-None, positive) and the timeout is
never shorter than the interval not a frozen literal, which churns every
time the window is retuned."""
uvicorn's ws ping runs on the same event loop as agent turns. A single
synchronous GIL-holding call on a worker thread can starve that loop for
minutes, so the loop can't process the pong and uvicorn kills an
otherwise-healthy local connection (#53773 "event loop stalled 226.3s",
#48445/#50005). On loopback there is no network/proxy path where a
half-open connection can occur a dead local client tears the socket down
with a real FIN/RST that surfaces as WebSocketDisconnect regardless so
the ping provides no liveness value and only harms. Assert it is disabled.
"""
captured = _stub_uvicorn(monkeypatch)
# Loopback bind => no auth gate, so this reaches the Config constructor.
web_server.start_server(host="127.0.0.1", port=0, open_browser=False)
assert captured["ws_ping_interval"] is None
assert captured["ws_ping_timeout"] is None
def test_start_server_enables_ws_ping_for_half_open_detection(monkeypatch):
"""Non-loopback (public) binds MUST keep the ws ping enabled so half-open
connections (reverse-proxy 524, dropped Cloudflare Tunnel) raise
WebSocketDisconnect into the reaping path (#32377).
The invariant asserted here is that ping stays enabled (non-None, positive)
and the timeout is never shorter than the interval not a frozen literal,
which churns every time the window is retuned. Loopback disables the ping
(see test_start_server_disables_ws_ping_on_loopback); this covers the
public-bind half-open case, so the auth gate is active here.
"""
captured = _stub_uvicorn(monkeypatch)
# Non-loopback bind so the _is_loopback branch selects the enabled-ping
# window. Neutralize the auth gate so start_server reaches uvicorn.Config
# without requiring a registered provider (a real public bind would raise
# SystemExit here). The ping window keys off the host, not the auth flag.
monkeypatch.setattr(web_server, "should_require_auth", lambda *a, **k: False)
web_server.start_server(host="0.0.0.0", port=0, open_browser=False)
assert captured["ws_ping_interval"] and captured["ws_ping_interval"] > 0
assert captured["ws_ping_timeout"] and captured["ws_ping_timeout"] > 0
assert captured["ws_ping_timeout"] >= captured["ws_ping_interval"]

View file

@ -24,9 +24,12 @@ from unittest.mock import patch
from tools.environments import local as local_mod
from tools.environments.local import (
LocalEnvironment,
_make_run_env,
_msys_to_windows_path,
_resolve_safe_cwd,
_sanitize_subprocess_env,
_windows_to_msys_path,
hermes_subprocess_env,
)
@ -228,6 +231,53 @@ class TestExtractCwdFromOutputWindowsMsys:
assert env.cwd == str(new_dir)
# ---------------------------------------------------------------------------
# MSYS_NO_PATHCONV — native Windows command flags (#56700)
# ---------------------------------------------------------------------------
class TestWindowsMsysPathconvDefaults:
def test_make_run_env_sets_msys_no_pathconv_on_windows(self, monkeypatch):
monkeypatch.setattr(local_mod, "_IS_WINDOWS", True)
run_env = _make_run_env({})
assert run_env.get("MSYS_NO_PATHCONV") == "1"
def test_sanitize_subprocess_env_sets_msys_no_pathconv_on_windows(self, monkeypatch):
monkeypatch.setattr(local_mod, "_IS_WINDOWS", True)
env = _sanitize_subprocess_env({})
assert env.get("MSYS_NO_PATHCONV") == "1"
def test_hermes_subprocess_env_sets_msys_no_pathconv_on_windows(self, monkeypatch):
monkeypatch.setattr(local_mod, "_IS_WINDOWS", True)
env = hermes_subprocess_env()
assert env.get("MSYS_NO_PATHCONV") == "1"
def test_no_pathconv_not_set_on_posix(self, monkeypatch):
monkeypatch.setattr(local_mod, "_IS_WINDOWS", False)
assert "MSYS_NO_PATHCONV" not in _make_run_env({})
def test_respects_user_override(self, monkeypatch):
monkeypatch.setattr(local_mod, "_IS_WINDOWS", True)
run_env = _make_run_env({"MSYS_NO_PATHCONV": "0"})
assert run_env.get("MSYS_NO_PATHCONV") == "0"
def test_msys2_arg_conv_excl_set_on_windows(self, monkeypatch):
# MSYS2-proper / Cygwin bash ignore MSYS_NO_PATHCONV; they honor
# MSYS2_ARG_CONV_EXCL. Both must be set on every env builder.
monkeypatch.setattr(local_mod, "_IS_WINDOWS", True)
assert _make_run_env({}).get("MSYS2_ARG_CONV_EXCL") == "*"
assert _sanitize_subprocess_env({}).get("MSYS2_ARG_CONV_EXCL") == "*"
assert hermes_subprocess_env().get("MSYS2_ARG_CONV_EXCL") == "*"
def test_msys2_arg_conv_excl_not_set_on_posix(self, monkeypatch):
monkeypatch.setattr(local_mod, "_IS_WINDOWS", False)
assert "MSYS2_ARG_CONV_EXCL" not in _make_run_env({})
def test_msys2_arg_conv_excl_respects_user_override(self, monkeypatch):
monkeypatch.setattr(local_mod, "_IS_WINDOWS", True)
run_env = _make_run_env({"MSYS2_ARG_CONV_EXCL": "/custom"})
assert run_env.get("MSYS2_ARG_CONV_EXCL") == "/custom"
# ---------------------------------------------------------------------------
# Command wrapping — native Windows cwd must be Git Bash-friendly for cd
# ---------------------------------------------------------------------------

View file

@ -64,9 +64,11 @@ def capture(server):
# seconds when the GIL is contended by concurrent agent turns.
FRONTEND_POLLED_RPCS = [
"session.list", # loads session list — SQLite query
"pet.info", # petdex poll — file/network read
"process.list", # background process status — process registry scan
"session.list", # loads session list — SQLite query
"pet.info", # petdex poll — file/network read
"process.list", # background process status — process registry scan
"setup.runtime_check", # runtime readiness — resolve_runtime_provider() I/O
"setup.status", # provider configured check — config/credential scan
]

View file

@ -0,0 +1,121 @@
"""Reasoning-effort session scoping in the TUI gateway (desktop backend).
Covers the "desktop reverts thinking to medium after one turn" report:
1. ``_session_info`` must report ``reasoning_effort: "none"`` when reasoning
is disabled reporting ``""`` (indistinguishable from "unset") made the
desktop adopt the empty value after the first turn, wiping its sticky
"thinking off" pick so every later chat reverted to the default effort.
2. ``config.set key=reasoning`` with a live session must be session-scoped:
it must NOT rewrite the global ``agent.reasoning_effort`` in config.yaml
(the desktop model menu applies a per-model preset on every selection,
which was silently clobbering the user's configured value), and it must
land on ``create_reasoning_override`` so lazily-built sessions (agent not
constructed until the first prompt) don't drop the change.
3. ``_load_reasoning_config`` must honor a YAML boolean False
(``reasoning_effort: false`` / ``off`` / ``no``) as thinking-disabled.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import tui_gateway.server as server
from tui_gateway.server import _session_info
def _agent(reasoning_config):
return SimpleNamespace(
reasoning_config=reasoning_config,
service_tier=None,
model="glm-5",
provider="zai",
session_id="sess-key",
)
class TestSessionInfoReasoningEffort:
"""Disabled reasoning must be reported as 'none', never ''."""
def test_disabled_reports_none(self) -> None:
info = _session_info(_agent({"enabled": False}))
assert info["reasoning_effort"] == "none"
def test_enabled_reports_effort(self) -> None:
info = _session_info(_agent({"enabled": True, "effort": "high"}))
assert info["reasoning_effort"] == "high"
def test_unset_reports_empty(self) -> None:
info = _session_info(_agent(None))
assert info["reasoning_effort"] == ""
class TestConfigSetReasoningSessionScope:
"""Session-targeted reasoning changes must not touch global config."""
def _dispatch(self, params: dict) -> dict:
handler = server._methods["config.set"]
return handler("rid-1", params)
def test_session_scoped_set_skips_global_write(self) -> None:
agent = _agent(None)
session = {"session_key": "k1", "agent": agent}
with patch.dict(server._sessions, {"s1": session}, clear=False), \
patch.object(server, "_write_config_key") as write_key, \
patch.object(server, "_persist_live_session_runtime"), \
patch.object(server, "_emit"):
resp = self._dispatch(
{"key": "reasoning", "session_id": "s1", "value": "none"}
)
assert resp["result"]["value"] == "none"
assert agent.reasoning_config == {"enabled": False}
write_key.assert_not_called()
def test_session_scoped_set_updates_create_override_for_lazy_session(self) -> None:
"""A pre-build (agent=None) session must keep the change for the
deferred agent build instead of dropping it."""
session = {"session_key": "k2", "agent": None}
with patch.dict(server._sessions, {"s2": session}, clear=False), \
patch.object(server, "_write_config_key") as write_key:
resp = self._dispatch(
{"key": "reasoning", "session_id": "s2", "value": "high"}
)
assert resp["result"]["value"] == "high"
assert session["create_reasoning_override"] == {
"enabled": True,
"effort": "high",
}
write_key.assert_not_called()
def test_no_session_persists_globally(self) -> None:
with patch.object(server, "_write_config_key") as write_key:
resp = self._dispatch({"key": "reasoning", "value": "low"})
assert resp["result"]["value"] == "low"
write_key.assert_called_once_with("agent.reasoning_effort", "low")
def test_unknown_value_rejected(self) -> None:
resp = self._dispatch({"key": "reasoning", "value": "bogus"})
assert "error" in resp
class TestLoadReasoningConfigYamlBoolean:
"""YAML `reasoning_effort: false` means disabled, not default."""
def test_boolean_false_disables(self) -> None:
with patch.object(
server, "_load_cfg", return_value={"agent": {"reasoning_effort": False}}
):
assert server._load_reasoning_config() == {"enabled": False}
def test_string_false_disables(self) -> None:
with patch.object(
server, "_load_cfg", return_value={"agent": {"reasoning_effort": "false"}}
):
assert server._load_reasoning_config() == {"enabled": False}
def test_unset_returns_default(self) -> None:
with patch.object(server, "_load_cfg", return_value={"agent": {}}):
assert server._load_reasoning_config() is None

View file

@ -1255,8 +1255,11 @@ def _build_child_agent(
parent_reasoning = getattr(parent_agent, "reasoning_config", None)
child_reasoning = parent_reasoning
try:
delegation_effort = str(delegation_cfg.get("reasoning_effort") or "").strip()
if delegation_effort:
# Keep the raw value — ``str(x or "")`` would coerce a YAML boolean
# False (``reasoning_effort: false``) to "" and inherit the parent
# instead of disabling thinking for children.
delegation_effort = delegation_cfg.get("reasoning_effort")
if delegation_effort or delegation_effort is False:
from hermes_constants import parse_reasoning_effort
parsed = parse_reasoning_effort(delegation_effort)

View file

@ -383,6 +383,8 @@ def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = Non
for _marker in _ACTIVE_VENV_MARKER_VARS:
sanitized.pop(_marker, None)
_apply_windows_msys_bash_env_defaults(sanitized)
return sanitized
@ -493,6 +495,8 @@ def hermes_subprocess_env(*, inherit_credentials: bool = False) -> dict[str, str
for _marker in _ACTIVE_VENV_MARKER_VARS:
env.pop(_marker, None)
_apply_windows_msys_bash_env_defaults(env)
# Cross-session leak guard, same as the terminal spawn paths: this helper
# copies os.environ, whose HERMES_SESSION_* mirror is a last-writer-wins
# global under a concurrent multi-session host. A caller that re-binds the
@ -746,6 +750,29 @@ def _append_missing_sane_path_entries(existing_path: str) -> str:
return ":".join(ordered_entries)
def _apply_windows_msys_bash_env_defaults(env: dict) -> None:
"""Disable MSYS argument path conversion for Git Bash subprocesses.
Git Bash rewrites arguments that look like Unix paths (``/FO``, ``/TN``,
``/Create``) into ``C:/.../git/FO``-style paths, which breaks native
Windows commands such as ``tasklist``, ``schtasks``, and ``wmic``. Hermes
runs terminal commands through bash on Windows, so set the standard MSYS
opt-out by default. Users who need conversion can override in their env.
Refs #56700.
``MSYS_NO_PATHCONV`` is honored by Git for Windows bash only. MSYS2-proper
and Cygwin bash (which ``_find_bash`` can still return via the final
``shutil.which`` fallback) ignore it and honor ``MSYS2_ARG_CONV_EXCL``
instead, so set both. ``*`` disables all argv conversion the semantic
equivalent of ``MSYS_NO_PATHCONV=1``. Also fixes ``cmd /c`` mangling
(#56147).
"""
if not _IS_WINDOWS:
return
env.setdefault("MSYS_NO_PATHCONV", "1")
env.setdefault("MSYS2_ARG_CONV_EXCL", "*")
def _path_env_key(run_env: dict) -> str | None:
"""Return the PATH env key to update without altering Windows casing.
@ -804,6 +831,8 @@ def _make_run_env(env: dict) -> dict:
for _marker in _ACTIVE_VENV_MARKER_VARS:
run_env.pop(_marker, None)
_apply_windows_msys_bash_env_defaults(run_env)
return run_env

View file

@ -212,6 +212,16 @@ _LONG_HANDLERS = frozenset(
"projects.for_cwd",
"projects.tree",
"projects.project_sessions",
# Setup readiness RPCs are polled by the Desktop frontend on connect
# and periodically (use-status-snapshot → evaluateRuntimeReadiness).
# setup.runtime_check calls resolve_runtime_provider() which reads
# config, checks auth state, and may probe the provider endpoint;
# setup.status calls _has_any_provider_configured() which scans
# provider config + credential files. Under GIL pressure from
# concurrent agent turns, either can take seconds inline, blocking
# the WS read loop and causing false "needs setup" (#50005 family).
"setup.runtime_check",
"setup.status",
"session.branch",
"session.compress",
"session.list",
@ -2318,10 +2328,12 @@ def _display_mouse_tracking(display: dict) -> str:
def _load_reasoning_config() -> dict | None:
from hermes_constants import parse_reasoning_effort
effort = str(
(_load_cfg().get("agent") or {}).get("reasoning_effort", "") or ""
).strip()
return parse_reasoning_effort(effort)
# Pass the raw value through — ``or ""`` would coerce a YAML boolean
# False (``reasoning_effort: false``/``off``/``no``) to "", silently
# re-enabling thinking for users who explicitly turned it off.
return parse_reasoning_effort(
(_load_cfg().get("agent") or {}).get("reasoning_effort", "")
)
def _load_service_tier() -> str | None:
@ -3095,11 +3107,15 @@ def _session_info(agent, session: dict | None = None) -> dict:
personality = (session or {}).get("personality", cfg_personality)
reasoning_config = getattr(agent, "reasoning_config", None)
reasoning_effort = ""
if (
isinstance(reasoning_config, dict)
and reasoning_config.get("enabled") is not False
):
reasoning_effort = str(reasoning_config.get("effort", "") or "")
if isinstance(reasoning_config, dict):
if reasoning_config.get("enabled") is False:
# Disabled must be distinguishable from unset ("" = provider
# default). Reporting "" here made the desktop adopt the empty
# value after the first turn, wiping its sticky "thinking off"
# pick and re-creating every later chat at the default effort.
reasoning_effort = "none"
else:
reasoning_effort = str(reasoning_config.get("effort", "") or "")
service_tier = getattr(agent, "service_tier", None) or ""
# Effective approval-bypass state — the same three sources that
# check_all_command_guards() ORs together: persistent config
@ -4055,15 +4071,21 @@ def _preview_restart_callbacks(parent: str, task_id: str) -> dict:
def _reset_session_agent(sid: str, session: dict) -> dict:
tokens = _set_session_context(session["session_key"])
try:
# Preserve this session's chosen model AND reasoning across /new so a
# reset doesn't silently revert to global config (or to a model
# another session set). See the cross-session-contamination note in
# _apply_model_switch.
reset_kw = {"model_override": session.get("model_override")}
old_reasoning = getattr(session.get("agent"), "reasoning_config", None)
if old_reasoning is None:
old_reasoning = session.get("create_reasoning_override")
if isinstance(old_reasoning, dict):
reset_kw["reasoning_config_override"] = old_reasoning
new_agent = _make_agent(
sid,
session["session_key"],
session_id=session["session_key"],
# Preserve this session's chosen model across /new so a reset
# doesn't silently revert to global config (or to a model another
# session set). See the cross-session-contamination note in
# _apply_model_switch.
model_override=session.get("model_override"),
**reset_kw,
)
finally:
_clear_session_context(tokens)
@ -10093,15 +10115,23 @@ def _(rid, params: dict) -> dict:
parsed = parse_reasoning_effort(arg)
if parsed is None:
return _err(rid, 4002, f"unknown reasoning value: {value}")
_write_config_key("agent.reasoning_effort", arg)
if session and session.get("agent") is not None:
session["agent"].reasoning_config = parsed
_persist_live_session_runtime(session)
_emit(
"session.info",
params.get("session_id", ""),
_session_info(session["agent"], session),
)
if session is not None:
# Session-scoped, like the messaging gateway's `/reasoning
# <level>` (global persistence is `--global` / Settings →
# Model territory). Writing config.yaml here let every
# desktop model-menu selection rewrite the user's global
# agent.reasoning_effort to the preset default.
session["create_reasoning_override"] = parsed
if session.get("agent") is not None:
session["agent"].reasoning_config = parsed
_persist_live_session_runtime(session)
_emit(
"session.info",
params.get("session_id", ""),
_session_info(session["agent"], session),
)
else:
_write_config_key("agent.reasoning_effort", arg)
return _ok(rid, {"key": key, "value": arg})
except Exception as e:
return _err(rid, 5001, str(e))
@ -10776,9 +10806,26 @@ def _(rid, params: dict) -> dict:
)
if key == "reasoning":
cfg = _load_cfg()
effort = str(
(cfg.get("agent") or {}).get("reasoning_effort", "medium") or "medium"
)
effort = ""
# Prefer the session's live value — `config.set reasoning` is
# session-scoped, so the global key may not reflect this chat.
session = _sessions.get(params.get("session_id", ""))
live = getattr((session or {}).get("agent"), "reasoning_config", None)
if live is None and session is not None:
live = session.get("create_reasoning_override")
if isinstance(live, dict):
if live.get("enabled") is False:
effort = "none"
else:
effort = str(live.get("effort", "") or "")
if not effort:
raw_effort = (cfg.get("agent") or {}).get("reasoning_effort", "")
if raw_effort is False:
# YAML `reasoning_effort: false`/`off`/`no` — thinking
# disabled, not "unset, show the medium default".
effort = "none"
else:
effort = str(raw_effort or "medium")
display = (
"show"
if bool((cfg.get("display") or {}).get("show_reasoning", False))

View file

@ -1,56 +1,41 @@
---
sidebar_position: 17
title: "OAuth over SSH / Remote Hosts"
description: "How to complete browser-based OAuth (xAI, Spotify, MCP servers) when Hermes runs on a remote machine, container, or behind a jump box"
description: "How to complete browser-based OAuth (Spotify, MCP servers) when Hermes runs on a remote machine, container, or behind a jump box"
---
# OAuth over SSH / Remote Hosts
Some Hermes providers — **xAI Grok OAuth**, **Spotify**, and **remote MCP servers** (Linear, Sentry, Atlassian, Asana, Figma, …) — use a *loopback redirect* OAuth flow. The auth server redirects your browser to `http://127.0.0.1:<port>/callback` so a tiny HTTP listener started by Hermes can grab the authorization code.
Some Hermes providers — **Spotify** and **remote MCP servers** (Linear, Sentry, Atlassian, Asana, Figma, …) — use a *loopback redirect* OAuth flow. The auth server redirects your browser to `http://127.0.0.1:<port>/callback` so a tiny HTTP listener started by Hermes can grab the authorization code.
This works perfectly when Hermes and your browser are on the same machine. It breaks the moment they aren't: your laptop's browser tries to reach `127.0.0.1` on **your laptop**, but the listener is bound to `127.0.0.1` on **the remote server**.
The fix is a one-line SSH local-forward — **or**, when you don't have a real SSH client (GCP Cloud Shell, GitHub Codespaces, EC2 Instance Connect, Gitpod, browser-based web IDEs), the new `--manual-paste` flag introduced in [#26923](https://github.com/NousResearch/hermes-agent/issues/26923).
The fix is a one-line SSH local-forward. For MCP servers on an interactive terminal, you can often paste the redirect URL back instead (no tunnel).
**xAI Grok OAuth (`xai-oauth`) uses OAuth device code**, not a loopback callback — open the printed verification URL in any browser and Hermes polls until approval. No SSH tunnel is required. See [xAI Grok OAuth](./xai-grok-oauth.md).
## TL;DR
```bash
# On your local machine (laptop), in a separate terminal:
ssh -N -L 56121:127.0.0.1:56121 user@remote-host
ssh -N -L 43827:127.0.0.1:43827 user@remote-host
# In your existing SSH session on the remote machine:
hermes auth add xai-oauth --no-browser
hermes auth add spotify --no-browser
# → Hermes prints an authorize URL. Open it in a browser on your laptop.
# → Your browser redirects to 127.0.0.1:56121/callback, the tunnel forwards
# → Your browser redirects to 127.0.0.1:43827/callback, the tunnel forwards
# the request to the remote listener, login completes.
```
Port `56121` is what xAI OAuth uses. For Spotify, replace it with `43827`. Hermes prints the exact port it bound to on the `Waiting for callback on ...` line — copy it from there.
## Browser-only remote (Cloud Shell / Codespaces / EC2 Instance Connect)
If you don't have a regular SSH client — for example because you're running Hermes inside GCP Cloud Shell, GitHub Codespaces, AWS EC2 Instance Connect, Gitpod, or another browser-based console — the SSH tunnel above isn't available. Use `--manual-paste` instead:
```bash
hermes auth add xai-oauth --manual-paste
# → Hermes prints an authorize URL. Open it in a browser on your laptop.
# → Approve in the browser. The redirect to 127.0.0.1:56121/callback fails
# to load — that's expected.
# → Copy the FULL URL from the failed page's address bar.
# → Paste it back into the terminal at the "Callback URL:" prompt.
```
The same flag works on `hermes model --manual-paste` for the integrated model picker. Hermes accepts three callback paste forms interchangeably: the full URL, a bare `?code=...&state=...` query fragment, or — when the upstream consent page renders the authorization code in-page instead of redirecting (xAI's current behavior on browser-based consoles) — just the bare code value on its own.
Hermes uses the **same PKCE verifier, state and nonce** for both paths, so the upstream OAuth flow is byte-identical — `--manual-paste` is purely a transport change for the callback hop and is not a security downgrade.
Hermes prints the exact port it bound to on the `Waiting for callback on ...` line — copy it from there. Spotify defaults to port `43827`.
## Which Providers Need This
| Provider | Loopback port | Tunnel needed? |
|----------|---------------|----------------|
| `xai-oauth` (Grok SuperGrok) | `56121` | Yes, when Hermes is remote |
| Spotify | `43827` | Yes, when Hermes is remote |
| MCP servers (`auth: oauth`) | auto-picked per server | Yes, when Hermes is remote |
| Spotify | `43827` (default) | Yes, when Hermes is remote |
| MCP servers (`auth: oauth`) | auto-picked per server | Yes, when Hermes is remote (or paste redirect URL) |
| `xai-oauth` (Grok SuperGrok) | n/a | No — device code flow |
| `anthropic` (Claude Pro/Max) | n/a | No — paste-the-code flow |
| `openai-codex` (ChatGPT Plus/Pro) | n/a | No — device code flow |
| `minimax`, `nous-portal` | n/a | No — device code flow |
@ -78,7 +63,7 @@ You have two ways to complete it from a remote host:
A bare `?code=...&state=...` query string is accepted too. This works for any MCP server with `auth: oauth` and requires no SSH config changes.
**Option 2 — SSH port forward (same as xAI / Spotify).** Hermes prints the exact port it bound to in the SSH-session hint. Open a separate terminal on your laptop:
**Option 2 — SSH port forward (same as Spotify).** Hermes prints the exact port it bound to in the SSH-session hint. Open a separate terminal on your laptop:
```bash
ssh -N -L <port>:127.0.0.1:<port> user@remote-host
@ -90,17 +75,14 @@ Then open the authorize URL in your browser as normal; the redirect tunnels thro
## Why the listener can't just bind 0.0.0.0
xAI and Spotify both validate the `redirect_uri` parameter against an allowlist. Both require the loopback form (`http://127.0.0.1:<exact-port>/callback`). Binding the listener to `0.0.0.0` or a different port would cause the auth server to reject the request as a redirect_uri mismatch. The SSH tunnel keeps the loopback URI intact end-to-end.
Spotify and most MCP OAuth servers validate the `redirect_uri` parameter against an allowlist. Both require the loopback form (`http://127.0.0.1:<exact-port>/callback`). Binding the listener to `0.0.0.0` or a different port would cause the auth server to reject the request as a redirect_uri mismatch. The SSH tunnel keeps the loopback URI intact end-to-end.
## Step-by-step: single SSH hop
### 1. Start the tunnel from your local machine
```bash
# xAI Grok OAuth (port 56121)
ssh -N -L 56121:127.0.0.1:56121 user@remote-host
# Or for Spotify (port 43827)
# Spotify (port 43827)
ssh -N -L 43827:127.0.0.1:43827 user@remote-host
```
@ -110,9 +92,7 @@ ssh -N -L 43827:127.0.0.1:43827 user@remote-host
```bash
ssh user@remote-host
hermes auth add xai-oauth --no-browser
# or for Spotify:
# hermes auth add spotify --no-browser
hermes auth add spotify --no-browser
```
Hermes detects the SSH session, skips the browser auto-open, and prints an authorize URL plus a `Waiting for callback on http://127.0.0.1:<port>/callback` line.
@ -128,17 +108,17 @@ You can tear down the tunnel (Ctrl+C in the first terminal) once you see the suc
If you reach Hermes through a bastion / jump host, use SSH's built-in `-J` (ProxyJump):
```bash
ssh -N -L 56121:127.0.0.1:56121 -J jump-user@jump-host user@final-host
ssh -N -L 43827:127.0.0.1:43827 -J jump-user@jump-host user@final-host
```
This chains a SSH connection through the jump host without putting the loopback port on the jump box itself. The local `127.0.0.1:56121` on your laptop tunnels straight through to `127.0.0.1:56121` on the final remote host.
This chains a SSH connection through the jump host without putting the loopback port on the jump box itself. The local `127.0.0.1:43827` on your laptop tunnels straight through to `127.0.0.1:43827` on the final remote host.
For older OpenSSH that doesn't support `-J`, the long form is:
```bash
ssh -N \
-o "ProxyCommand=ssh -W %h:%p jump-user@jump-host" \
-L 56121:127.0.0.1:56121 \
-L 43827:127.0.0.1:43827 \
user@final-host
```
@ -150,30 +130,26 @@ If you use `ssh -o ControlMaster=auto`, port forwards on a multiplexed connectio
```bash
ssh -O exit user@remote-host
ssh -N -L 56121:127.0.0.1:56121 user@remote-host
ssh -N -L 43827:127.0.0.1:43827 user@remote-host
```
## Troubleshooting
### `bind [127.0.0.1]:56121: Address already in use`
### `bind [127.0.0.1]:43827: Address already in use`
Something on your laptop is already using that port. Either the previous tunnel didn't shut down cleanly, or a local Hermes is also listening on it. Find and kill the offender:
```bash
# macOS / Linux
lsof -iTCP:56121 -sTCP:LISTEN
lsof -iTCP:43827 -sTCP:LISTEN
kill <PID>
```
Then retry the `ssh -L` command.
### "Could not establish connection. We couldn't reach your app." (xAI)
### Authorization timed out waiting for the local callback
xAI's authorize page shows this when its redirect to `127.0.0.1:<port>/callback` doesn't reach a listener. Either the tunnel isn't running, the port is wrong, or you're using the port Hermes printed in a previous run (the port can be auto-bumped if the preferred one is busy — always read the latest `Waiting for callback on ...` line).
### `xAI authorization timed out waiting for the local callback`
Same root cause as above — the redirect never made it back. Check the tunnel is still alive (`ssh -N` doesn't show output, so look at the terminal you started it from), restart it if needed, and re-run `hermes auth add xai-oauth --no-browser`.
The redirect never made it back to the remote listener. Check the tunnel is still alive (`ssh -N` doesn't show output, so look at the terminal you started it from), confirm you used the port from the latest `Waiting for callback on ...` line (Hermes may auto-bump if the preferred port is busy), restart the tunnel if needed, and re-run the auth command.
### Tokens land in the wrong `~/.hermes`
@ -181,7 +157,7 @@ The tokens are written under the Linux user that ran `hermes auth add ...`. If y
## See Also
- [xAI Grok OAuth](./xai-grok-oauth.md)
- [xAI Grok OAuth](./xai-grok-oauth.md) — device code; no SSH tunnel
- [Spotify (`Running over SSH`)](../user-guide/features/spotify.md#running-over-ssh--in-a-headless-environment)
- [Native MCP client (OAuth section)](../user-guide/features/mcp.md#oauth-authenticated-http-servers)
- [SSH `-J` / ProxyJump (man page)](https://man.openbsd.org/ssh#J)

View file

@ -47,8 +47,8 @@ OAuth needs a browser, but the loopback callback runs on the machine where Herme
ssh -N -L 8642:127.0.0.1:8642 user@remote-host # in a local terminal
hermes setup --portal # on the remote, open the printed URL in your local browser
# Option B: manual paste (for Cloud Shell, Codespaces, EC2 Instance Connect)
hermes auth add nous --type oauth --manual-paste
# Option B: device-code login (works from Cloud Shell, Codespaces, EC2 Instance Connect)
hermes auth add nous --type oauth
# Then re-run `hermes setup --portal` to wire the provider + gateway
```
@ -186,7 +186,7 @@ The OAuth flow didn't complete. Re-run it:
hermes portal
```
If your browser doesn't open or the callback fails, you're likely on a remote/headless host — see [OAuth over SSH](/guides/oauth-over-ssh) for the port-forwarding and manual-paste workarounds.
If your browser doesn't open or the callback fails, you're likely on a remote/headless host — see [OAuth over SSH](/guides/oauth-over-ssh) for the port-forwarding workarounds.
### "Model: currently openrouter" (or some other provider) instead of "using Nous as inference provider"

View file

@ -113,7 +113,7 @@ Already set up with another model?
- **Don't see the model in the list?** Make sure you finished the Nous Portal connection and that you're on the **Free** plan. In the CLI, `hermes portal info` confirms you're logged in and routing through Nous.
- **Picked the wrong variant?** Re-select `nvidia/nemotron-3-ultra:free` — the `:free` suffix is required to stay on the no-cost tier.
- **Browser didn't open / you're on a remote host (CLI)?** See [OAuth over SSH / Remote Hosts](/guides/oauth-over-ssh) for port-forwarding and manual-paste workarounds.
- **Browser didn't open / you're on a remote host (CLI)?** See [OAuth over SSH / Remote Hosts](/guides/oauth-over-ssh) for port-forwarding workarounds.
## See also

View file

@ -6,7 +6,7 @@ description: "Sign in with your SuperGrok or X Premium+ subscription to use Grok
# xAI Grok OAuth (SuperGrok / X Premium+)
Hermes Agent supports xAI Grok through a browser-based OAuth login flow against [accounts.x.ai](https://accounts.x.ai), using either a **SuperGrok subscription** ([grok.com](https://x.ai/grok)) or an **X Premium+ subscription** (linked X account). No `XAI_API_KEY` is required — log in once and Hermes automatically refreshes your session in the background.
Hermes Agent supports xAI Grok through a browser-based OAuth device-code login flow against [accounts.x.ai](https://accounts.x.ai), using either a **SuperGrok subscription** ([grok.com](https://x.ai/grok)) or an **X Premium+ subscription** (linked X account). No `XAI_API_KEY` is required — log in once and Hermes automatically refreshes your session in the background.
When you sign in with an X account that has Premium+, xAI automatically links the subscription status to your xAI session, so the OAuth flow works the same as it does for direct SuperGrok subscribers.
@ -20,7 +20,7 @@ The same OAuth bearer token is also reused by every direct-to-xAI surface in Her
|------|-------|
| Provider ID | `xai-oauth` |
| Display name | xAI Grok OAuth (SuperGrok / X Premium+) |
| Auth type | Browser OAuth 2.0 PKCE (loopback callback) |
| Auth type | Browser OAuth 2.0 device code |
| Transport | xAI Responses API (`codex_responses`) |
| Default model | `grok-build-0.1` |
| Endpoint | `https://api.x.ai/v1` |
@ -33,7 +33,7 @@ The same OAuth bearer token is also reused by every direct-to-xAI surface in Her
- Python 3.9+
- Hermes Agent installed
- An active **SuperGrok** subscription on your xAI account, **or** an **X Premium+** subscription on the X account you sign in with (xAI links the subscription automatically)
- A browser available on the local machine (or use `--no-browser` for remote sessions)
- A browser available anywhere you can open the printed verification URL
:::warning xAI may restrict OAuth API access by tier
xAI's backend enforces its own allowlist on the OAuth API surface and has been seen to reject standard SuperGrok subscribers with `HTTP 403` (see issue [#26847](https://github.com/NousResearch/hermes-agent/issues/26847)) even though the in-app subscription is active. If OAuth login succeeds in the browser but inference returns 403, set `XAI_API_KEY` and switch to the API-key path (`provider: xai`) — that surface is not subject to the same gating today.
@ -45,8 +45,8 @@ xAI's backend enforces its own allowlist on the OAuth API surface and has been s
# Launch the provider and model picker
hermes model
# → Select "xAI Grok OAuth (SuperGrok / X Premium+)" from the provider list
# → Hermes opens your browser to accounts.x.ai
# → Approve access in the browser
# → Hermes opens or prints an accounts.x.ai verification URL
# → Enter the displayed code if prompted, then approve access in the browser
# → Pick a model (grok-build-0.1 is at the top)
# → Start chatting
@ -65,42 +65,20 @@ hermes auth add xai-oauth
### Remote / headless sessions
On servers, containers, or SSH sessions where no browser is available, Hermes detects the remote environment and prints the authorization URL instead of opening a browser.
**Important:** the loopback listener still runs on the remote machine at `127.0.0.1:56121`. The xAI redirect needs to reach *that* listener, so opening the URL on your laptop will fail (`Could not establish connection. We couldn't reach your app.`) unless you forward the port:
On servers, containers, browser-only consoles (Cloud Shell, Codespaces, EC2 Instance Connect), or SSH sessions where Hermes cannot open a browser locally, Hermes prints the xAI verification URL and user code. Open the URL in any browser on your laptop or in the cloud console, enter the code if prompted, and Hermes will keep polling until xAI approves the login. No SSH tunnel or local callback listener is required.
```bash
# In a separate terminal on your local machine:
ssh -N -L 56121:127.0.0.1:56121 user@remote-host
# Then in your SSH session on the remote machine:
hermes auth add xai-oauth --no-browser
# Open the printed authorize URL in your local browser.
# Open the printed verification URL in your browser.
```
Through a jump box / bastion: add `-J jump-user@jump-host`.
See [OAuth over SSH / Remote Hosts](./oauth-over-ssh.md) for the full step-by-step, including ProxyJump chains, mosh/tmux, and ControlMaster gotchas.
### Browser-only remotes (Cloud Shell, Codespaces, EC2 Instance Connect)
If you don't have a regular SSH client (e.g. you're running Hermes inside GCP Cloud Shell, GitHub Codespaces, AWS EC2 Instance Connect, Gitpod, or another browser-based console), the `ssh -L` recipe above isn't available. Use `--manual-paste` instead — Hermes skips the loopback listener and lets you paste the failed callback URL straight from your browser:
```bash
hermes auth add xai-oauth --manual-paste
# Or via the model picker:
hermes model --manual-paste
```
See [OAuth over SSH / Remote Hosts](./oauth-over-ssh.md#browser-only-remote-cloud-shell--codespaces--ec2-instance-connect) for the full walkthrough. Regression fix for [#26923](https://github.com/NousResearch/hermes-agent/issues/26923).
If the consent page renders the authorization code directly on the page (xAI's current behavior on browser-based consoles) instead of redirecting to your `127.0.0.1:56121/callback`, paste **just the bare code value** at the `Callback URL:` prompt — Hermes accepts the full URL, a bare `?code=...&state=...` query fragment, or a bare code interchangeably.
The same device-code flow applies when you sign in from the web dashboard or the desktop app: Hermes shows the verification URL and user code, then polls in the background until you approve access.
## How the Login Works
1. Hermes opens your browser to `accounts.x.ai`.
2. You sign in (or confirm your existing session) and approve access.
3. xAI redirects back to Hermes and the tokens are saved to `~/.hermes/auth.json`.
1. Hermes requests a device code from `auth.x.ai`.
2. You open the verification URL, sign in, enter the displayed code if prompted, and approve access.
3. Hermes polls xAI until approval, then saves tokens to `~/.hermes/auth.json`.
4. From then on, Hermes refreshes the access token in the background — you stay signed in until you `hermes auth logout xai-oauth` or revoke access from your xAI account settings.
## Checking Login Status
@ -209,29 +187,19 @@ When the refresh failure is terminal (HTTP 4xx, `invalid_grant`, revoked grant,
### Authorization timed out
The loopback listener has a finite expiry window (default 180 s). If you don't approve the login in time, Hermes raises a timeout error.
Device-code approval has a finite expiry window (xAI sets `expires_in` on the device-code response, typically on the order of tens of minutes). If you do not approve the login in time, Hermes raises a timeout error.
**Fix:** re-run `hermes auth add xai-oauth` (or `hermes model`). The flow starts fresh.
### State mismatch (possible CSRF)
Hermes detected that the `state` value returned by the authorization server doesn't match what it sent.
**Fix:** re-run the login. If it persists, check for a proxy or redirect that is modifying the OAuth response.
### Logging in from a remote server
On SSH or container sessions Hermes prints the authorization URL instead of opening a browser. The loopback callback listener still binds `127.0.0.1:56121` on the remote host — your laptop's browser can't reach it without an SSH local-forward:
On SSH or container sessions Hermes prints the verification URL and user code instead of opening a browser. Open that URL in a browser on your laptop or in a cloud console — no SSH port forward is needed for xAI Grok OAuth.
```bash
# Local machine, separate terminal:
ssh -N -L 56121:127.0.0.1:56121 user@remote-host
# Remote machine:
hermes auth add xai-oauth --no-browser
```
Full walkthrough (jump boxes, mosh/tmux, port conflicts): [OAuth over SSH / Remote Hosts](./oauth-over-ssh.md).
For loopback-redirect providers (Spotify, MCP servers), see [OAuth over SSH / Remote Hosts](./oauth-over-ssh.md).
### HTTP 403 after a successful login (tier / entitlement)
@ -266,7 +234,7 @@ This clears both the singleton OAuth entry in `auth.json` and any credential-poo
## See Also
- [OAuth over SSH / Remote Hosts](./oauth-over-ssh.md) — required reading if Hermes is on a different machine than your browser
- [OAuth over SSH / Remote Hosts](./oauth-over-ssh.md) — SSH tunnels for loopback-redirect providers (Spotify, MCP); xAI uses device code and does not need a tunnel
- [AI Providers reference](../integrations/providers.md)
- [Environment Variables](../reference/environment-variables.md)
- [Configuration](../user-guide/configuration.md)

View file

@ -120,7 +120,7 @@ Your existing providers stay configured. You can switch between them with `/mode
### Headless / SSH / remote setup
OAuth needs a browser, but the loopback callback runs on the machine where Hermes is running. For remote hosts, see [OAuth over SSH / Remote Hosts](/guides/oauth-over-ssh) — the same patterns work for the Portal as for any other OAuth-based provider (`ssh -L` port forwarding, `--manual-paste` for browser-only environments like Cloud Shell / Codespaces).
OAuth needs a browser, but the loopback callback runs on the machine where Hermes is running. For remote hosts, see [OAuth over SSH / Remote Hosts](/guides/oauth-over-ssh) — the same patterns work for the Portal as for any other OAuth-based provider (`ssh -L` port forwarding).
### Profile setup

View file

@ -1,55 +1,40 @@
---
sidebar_position: 17
title: "SSH / 远程主机上的 OAuth"
description: "当 Hermes 运行在远程机器、容器或跳板机后面时,如何完成基于浏览器的 OAuthxAI、Spotify"
description: "当 Hermes 运行在远程机器、容器或跳板机后面时,如何完成基于浏览器的 OAuthSpotify、MCP 服务器"
---
# SSH / 远程主机上的 OAuth
部分 Hermes 提供商——目前是 **xAI Grok OAuth****Spotify**——使用*回环重定向loopback redirect* OAuth 流程。认证服务器xAI、Spotify将浏览器重定向到 `http://127.0.0.1:<port>/callback`,由 `hermes auth ...` 命令启动的一个小型 HTTP 监听器来获取授权码。
部分 Hermes 提供商——**Spotify** 和 **远程 MCP 服务器**Linear、Sentry、Atlassian、Asana、Figma 等)——使用*回环重定向loopback redirect* OAuth 流程。认证服务器将浏览器重定向到 `http://127.0.0.1:<port>/callback`,由 Hermes 启动的小型 HTTP 监听器获取授权码。
当 Hermes 和浏览器在同一台机器上时,这一切运行正常。一旦两者不在同一台机器上就会出问题:你笔记本上的浏览器试图访问**你笔记本**上的 `127.0.0.1`,但监听器绑定的是**远程服务器**上的 `127.0.0.1`
解决方法是一行 SSH 本地端口转发——**或者**,当你没有真正的 SSH 客户端时GCP Cloud Shell、GitHub Codespaces、EC2 Instance Connect、Gitpod、基于浏览器的 Web IDE使用 [#26923](https://github.com/NousResearch/hermes-agent/issues/26923) 中引入的新 `--manual-paste` 标志。
解决方法是一行 SSH 本地端口转发。对于交互式终端上的 MCP 服务器,通常也可以直接粘贴重定向 URL无需隧道
**xAI Grok OAuth`xai-oauth`)使用 OAuth 设备代码**,不是回环回调——在任意浏览器中打开打印的验证 URLHermes 轮询直到批准即可,无需 SSH 隧道。请参阅 [xAI Grok OAuth](./xai-grok-oauth.md)。
## 快速概览
```bash
# 在你的本地机器(笔记本)上,另开一个终端:
ssh -N -L 56121:127.0.0.1:56121 user@remote-host
ssh -N -L 43827:127.0.0.1:43827 user@remote-host
# 在远程机器的现有 SSH 会话中:
hermes auth add xai-oauth --no-browser
# → Hermes 打印一个授权 URL在笔记本的浏览器中打开它。
# → 浏览器重定向到 127.0.0.1:56121/callback隧道将请求转发
# 到远程监听器,登录完成。
hermes auth add spotify --no-browser
# → Hermes 打印授权 URL在笔记本的浏览器中打开。
# → 浏览器重定向到 127.0.0.1:43827/callback隧道转发到远程监听器登录完成。
```
`56121` 是 xAI OAuth 使用的端口。Spotify 请将其替换为 `43827`。Hermes 会在 `Waiting for callback on ...` 这一行打印它实际绑定的端口——从那里复制。
## 仅限浏览器的远程环境Cloud Shell / Codespaces / EC2 Instance Connect
如果你没有常规的 SSH 客户端——例如你在 GCP Cloud Shell、GitHub Codespaces、AWS EC2 Instance Connect、Gitpod 或其他基于浏览器的控制台中运行 Hermes——上述 SSH 隧道不可用。请改用 `--manual-paste`
```bash
hermes auth add xai-oauth --manual-paste
# → Hermes 打印一个授权 URL在笔记本的浏览器中打开它。
# → 在浏览器中批准。重定向到 127.0.0.1:56121/callback 会加载失败
# ——这是预期行为。
# → 从失败页面的地址栏复制完整 URL。
# → 在终端的 "Callback URL:" 提示处粘贴。
```
同样的标志也适用于集成模型选择器的 `hermes model --manual-paste`。如果不想粘贴完整 URL也可以只接受裸的 `?code=...&state=...` 查询片段。
Hermes 对两种路径使用**相同的 PKCE verifier、state 和 nonce**,因此上游 OAuth 流程在字节层面完全一致——`--manual-paste` 纯粹是回调跳转的传输方式变更,不会降低安全性。
Hermes 会在 `Waiting for callback on ...` 一行打印实际绑定的端口——从那里复制。Spotify 默认端口为 `43827`
## 哪些提供商需要此操作
| 提供商 | 回环端口 | 需要隧道? |
|----------|---------------|----------------|
| `xai-oauth`Grok SuperGrok | `56121` | 是,当 Hermes 在远程时 |
| Spotify | `43827` | 是,当 Hermes 在远程时 |
| Spotify | `43827`(默认) | 是,当 Hermes 在远程时 |
| MCP 服务器(`auth: oauth` | 每台服务器自动选择 | 是(或粘贴重定向 URL |
| `xai-oauth`Grok SuperGrok | 不适用 | 否——设备代码流程 |
| `anthropic`Claude Pro/Max | 不适用 | 否——粘贴代码流程 |
| `openai-codex`ChatGPT Plus/Pro | 不适用 | 否——设备码流程 |
| `minimax``nous-portal` | 不适用 | 否——设备码流程 |
@ -58,97 +43,54 @@ Hermes 对两种路径使用**相同的 PKCE verifier、state 和 nonce**,因
## 为什么监听器不能直接绑定 0.0.0.0
xAI 和 Spotify 都会根据白名单验证 `redirect_uri` 参数。两者都要求回环形式(`http://127.0.0.1:<exact-port>/callback`)。将监听器绑定到 `0.0.0.0` 或不同端口会导致认证服务器以 redirect_uri 不匹配为由拒绝请求。SSH 隧道可以端到端保持回环 URI 不变。
Spotify 和大多数 MCP OAuth 服务器会根据白名单验证 `redirect_uri` 参数,并要求回环形式(`http://127.0.0.1:<精确端口>/callback`)。将监听器绑定到 `0.0.0.0`使用不同端口会导致认证服务器以 redirect_uri 不匹配为由拒绝请求。SSH 隧道可以端到端保持回环 URI 不变。
## 分步说明:单跳 SSH
## 分步操作:单次 SSH 跳转
### 1. 从本地机器启动隧道
```bash
# xAI Grok OAuth端口 56121
ssh -N -L 56121:127.0.0.1:56121 user@remote-host
# 或 Spotify端口 43827
# Spotify端口 43827
ssh -N -L 43827:127.0.0.1:43827 user@remote-host
```
`-N` 表示"不打开远程 shell只保持隧道开启"。在登录期间保持此终端运行。
`-N` 表示「不打开远程 shell仅保持隧道」。登录期间保持此终端运行。
### 2. 在另一个 SSH 会话中运行认证命令
```bash
ssh user@remote-host
hermes auth add xai-oauth --no-browser
# 或 Spotify
# hermes auth add spotify --no-browser
hermes auth add spotify --no-browser
```
Hermes 检测到 SSH 会话,跳过自动打开浏览器,打印授权 URL 以及 `Waiting for callback on http://127.0.0.1:<port>/callback` 这一行
Hermes 检测到 SSH 会话,跳过自动打开浏览器,打印授权 URL 以及 `Waiting for callback on http://127.0.0.1:<port>/callback`
### 3. 在本地浏览器中打开 URL
从远程终端复制授权 URL粘贴到笔记本的浏览器中。批准同意页面。认证服务器重定向到 `http://127.0.0.1:<port>/callback`。浏览器访问隧道,请求转发到远程监听器Hermes 打印 `Login successful!`
从远程终端复制授权 URL粘贴到笔记本的浏览器中。批准同意后,认证服务器重定向到 `http://127.0.0.1:<port>/callback`。浏览器经隧道访问请求转发到远程监听器Hermes 打印 `Login successful!`
看到成功提示后,可以关闭隧道(在第一个终端按 Ctrl+C
看到成功提示后即可关闭隧道(在第一个终端按 Ctrl+C
## 分步说明:通过跳板机
## 通过跳板机
如果通过堡垒机 / 跳板机访问 Hermes使用 SSH 内置的 `-J`ProxyJump
如果通过堡垒机 / 跳板机访问 Hermes使用 SSH 内置的 `-J`ProxyJump
```bash
ssh -N -L 56121:127.0.0.1:56121 -J jump-user@jump-host user@final-host
ssh -N -L 43827:127.0.0.1:43827 -J jump-user@jump-host user@final-host
```
这会通过跳板机链式建立 SSH 连接,而不会将回环端口暴露在跳板机上。你笔记本上的本地 `127.0.0.1:56121` 直接隧道到最终远程主机上的 `127.0.0.1:56121`
## 故障排除
对于不支持 `-J` 的旧版 OpenSSH完整写法为
### `bind [127.0.0.1]:43827: Address already in use`
```bash
ssh -N \
-o "ProxyCommand=ssh -W %h:%p jump-user@jump-host" \
-L 56121:127.0.0.1:56121 \
user@final-host
```
笔记本上已有进程占用该端口。结束占用进程后重试 `ssh -L`
## Mosh、tmux、ssh ControlMaster
### 等待本地回调超时
隧道是底层 SSH 连接的属性。如果你在 mosh 会话中的 `tmux` 里运行 Hermesmosh 的漫游不会携带 `-L` 转发。**单独**开一个普通 SSH 会话**仅用于** `-L` 隧道——这个连接必须在整个认证流程期间保持存活。你的交互式 mosh/tmux 会话可以继续正常运行 Hermes。
如果你使用 `ssh -o ControlMaster=auto`,多路复用连接上的端口转发共享主连接的生命周期。如果隧道未能建立,重启主连接:
```bash
ssh -O exit user@remote-host
ssh -N -L 56121:127.0.0.1:56121 user@remote-host
```
## 故障排查
### `bind [127.0.0.1]:56121: Address already in use`
你笔记本上已有某个程序占用了该端口。可能是上一个隧道没有正常关闭,或者本地也有一个 Hermes 在监听。找到并终止占用进程:
```bash
# macOS / Linux
lsof -iTCP:56121 -sTCP:LISTEN
kill <PID>
```
然后重试 `ssh -L` 命令。
### "Could not establish connection. We couldn't reach your app."xAI
当 xAI 重定向到 `127.0.0.1:<port>/callback` 未能到达监听器时xAI 的授权页面会显示此错误。可能是隧道未运行、端口错误,或者你使用的是 Hermes 上一次运行时打印的端口(如果首选端口被占用,端口可能会自动递增——始终以最新的 `Waiting for callback on ...` 行为准)。
### `xAI authorization timed out waiting for the local callback`
与上述原因相同——重定向从未返回。检查隧道是否仍然存活(`ssh -N` 不显示输出,查看启动它的终端),必要时重启,然后重新运行 `hermes auth add xai-oauth --no-browser`
### Token 写入了错误的 `~/.hermes`
Token 写入运行 `hermes auth add ...` 的 Linux 用户目录下。如果你的网关 / systemd 服务以不同用户(如 `root` 或专用的 `hermes` 用户)运行,请以**该**用户身份进行认证,使 token 写入其 `~/.hermes/auth.json`。使用 `sudo -u hermes -i` 或等效命令。
重定向未到达远程监听器。确认隧道仍在运行,并使用最新一次 `Waiting for callback on ...` 中的端口(首选端口被占用时 Hermes 可能自动递增)。
## 另请参阅
- [xAI Grok OAuth](./xai-grok-oauth.md)
- [Spotify`通过 SSH 运行`](../user-guide/features/spotify.md#running-over-ssh--in-a-headless-environment)
- [SSH `-J` / ProxyJumpman 手册)](https://man.openbsd.org/ssh#J)
- [xAI Grok OAuth](./xai-grok-oauth.md)——设备代码;无需 SSH 隧道
- [SpotifySSH 上运行)](../user-guide/features/spotify.md#running-over-ssh--in-a-headless-environment)
- [原生 MCP 客户端OAuth 部分)](../user-guide/features/mcp.md#oauth-authenticated-http-servers)

View file

@ -47,8 +47,8 @@ OAuth 需要浏览器,但 loopback 回调运行在 Hermes 所在的机器上
ssh -N -L 8642:127.0.0.1:8642 user@remote-host # 在本地终端执行
hermes setup --portal # 在远程机器上执行,在本地浏览器中打开打印出的 URL
# 方案 B手动粘贴(适用于 Cloud Shell、Codespaces、EC2 Instance Connect
hermes auth add nous --type oauth --manual-paste
# 方案 B设备码登录(适用于 Cloud Shell、Codespaces、EC2 Instance Connect
hermes auth add nous --type oauth
# 然后重新运行 `hermes setup --portal` 以连接 provider + gateway
```
@ -183,7 +183,7 @@ OAuth 流程未完成。重新运行:
hermes portal
```
如果浏览器未打开或回调失败,你可能在远程/无头主机上——参见 [OAuth over SSH](/guides/oauth-over-ssh) 了解端口转发和手动粘贴的解决方案。
如果浏览器未打开或回调失败,你可能在远程/无头主机上——参见 [OAuth over SSH](/guides/oauth-over-ssh) 了解端口转发的解决方案。
### "Model: currently openrouter"(或其他 provider而非"using Nous as inference provider"

View file

@ -20,7 +20,7 @@ Hermes Agent 通过基于浏览器的 OAuth 登录流程支持 xAI Grok认证
|------|-------|
| Provider ID | `xai-oauth` |
| 显示名称 | xAI Grok OAuth (SuperGrok / X Premium+) |
| 认证类型 | 浏览器 OAuth 2.0 PKCE回环回调 |
| 认证类型 | 浏览器 OAuth 2.0 设备代码 |
| 传输层 | xAI Responses API`codex_responses` |
| 默认模型 | `grok-build-0.1` |
| 端点 | `https://api.x.ai/v1` |
@ -33,7 +33,7 @@ Hermes Agent 通过基于浏览器的 OAuth 登录流程支持 xAI Grok认证
- Python 3.9+
- 已安装 Hermes Agent
- 你的 xAI 账号拥有有效的 **SuperGrok** 订阅,**或**你登录所用的 X 账号拥有 **X Premium+** 订阅xAI 会自动关联订阅)
- 本地机器上有可用的浏览器(远程会话可使用 `--no-browser`
- 任意可打开打印出的验证 URL 的浏览器
:::warning xAI 可能按套餐限制 OAuth API 访问
xAI 的后端对 OAuth API 接口维护自己的白名单,已有记录显示即使应用内订阅处于激活状态,标准 SuperGrok 订阅者也会收到 `HTTP 403`(见 issue [#26847](https://github.com/NousResearch/hermes-agent/issues/26847))。如果浏览器中 OAuth 登录成功但推理返回 403请设置 `XAI_API_KEY` 并切换到 API 密钥路径(`provider: xai`)——该接口目前不受相同限制。
@ -45,8 +45,8 @@ xAI 的后端对 OAuth API 接口维护自己的白名单,已有记录显示
# 启动 provider 和模型选择器
hermes model
# → 从 provider 列表中选择 "xAI Grok OAuth (SuperGrok / X Premium+)"
# → Hermes 在浏览器中打开 accounts.x.ai
# → 在浏览器中批准访问
# → Hermes 打开或打印 accounts.x.ai 验证 URL
# → 如有提示,输入显示的代码,然后在浏览器中批准访问
# → 选择模型grok-build-0.1 在列表顶部)
# → 开始对话
@ -65,40 +65,20 @@ hermes auth add xai-oauth
### 远程 / 无头会话
在没有浏览器的服务器、容器或 SSH 会话中Hermes 会检测到远程环境并打印授权 URL而不是打开浏览器。
**重要:** 回环监听器仍在远程机器的 `127.0.0.1:56121` 上运行。xAI 的重定向需要到达*该*监听器,因此在你的笔记本上打开 URL 会失败(`Could not establish connection. We couldn't reach your app.`),除非你转发端口:
在没有浏览器的服务器、容器、仅限浏览器的远程控制台Cloud Shell、Codespaces、EC2 Instance Connect或 SSH 会话中Hermes 会打印 xAI 验证 URL 和用户代码。在笔记本电脑或云控制台的任意浏览器中打开该 URL如有提示则输入代码Hermes 会持续轮询直到 xAI 批准登录。无需 SSH 隧道或本地回调监听器。
```bash
# 在本地机器的另一个终端中:
ssh -N -L 56121:127.0.0.1:56121 user@remote-host
# 然后在远程机器的 SSH 会话中:
hermes auth add xai-oauth --no-browser
# 在本地浏览器中打开打印出的授权 URL。
# 在浏览器中打开打印出的验证 URL。
```
通过跳板机 / 堡垒机:添加 `-J jump-user@jump-host`
完整步骤(包括 ProxyJump 链、mosh/tmux 和 ControlMaster 注意事项)请参阅 [OAuth over SSH / Remote Hosts](./oauth-over-ssh.md)。
### 仅限浏览器的远程环境Cloud Shell、Codespaces、EC2 Instance Connect
如果你没有常规 SSH 客户端(例如在 GCP Cloud Shell、GitHub Codespaces、AWS EC2 Instance Connect、Gitpod 或其他基于浏览器的控制台中运行 Hermes上述 `ssh -L` 方案不可用。请改用 `--manual-paste`——Hermes 跳过回环监听器,让你直接从浏览器粘贴失败的回调 URL
```bash
hermes auth add xai-oauth --manual-paste
# 或通过模型选择器:
hermes model --manual-paste
```
完整操作说明请参阅 [OAuth over SSH / Remote Hosts](./oauth-over-ssh.md#browser-only-remote-cloud-shell--codespaces--ec2-instance-connect)。此为 [#26923](https://github.com/NousResearch/hermes-agent/issues/26923) 的回归修复。
Web 仪表盘和桌面应用使用相同的设备代码流程:显示验证 URL 和用户代码,并在你批准访问后在后台轮询。
## 登录流程说明
1. Hermes 在浏览器中打开 `accounts.x.ai`
2. 你登录(或确认现有会话)并批准访问。
3. xAI 重定向回 Hermestoken 保存到 `~/.hermes/auth.json`
1. Hermes 向 `auth.x.ai` 请求设备代码。
2. 你打开验证 URL登录如有提示则输入显示的代码并批准访问。
3. Hermes 轮询 xAI 直到批准,然后将 token 保存到 `~/.hermes/auth.json`
4. 此后Hermes 在后台刷新 access token——你将保持登录状态直到执行 `hermes auth logout xai-oauth` 或在 xAI 账号设置中撤销访问。
## 检查登录状态
@ -207,29 +187,19 @@ Hermes 在每次会话前刷新 token并在收到 401 时响应式地再次
### 授权超时
回环监听器有有限的过期窗口(默认 180 秒。如果你未在时限内批准登录Hermes 会抛出超时错误。
设备代码批准有有限的过期窗口xAI 在设备代码响应中设置 `expires_in`,通常为数十分钟量级。如果你未在时限内批准登录Hermes 会抛出超时错误。
**修复方法:** 重新运行 `hermes auth add xai-oauth`(或 `hermes model`)。流程重新开始。
### State 不匹配(可能的 CSRF
Hermes 检测到授权服务器返回的 `state` 值与发送的不匹配。
**修复方法:** 重新运行登录。如果问题持续,检查是否有代理或重定向在修改 OAuth 响应。
### 从远程服务器登录
在 SSH 或容器会话中Hermes 打印授权 URL 而不是打开浏览器。回环回调监听器仍绑定在远程主机的 `127.0.0.1:56121`——你笔记本上的浏览器无法访问它,除非进行 SSH 本地端口转发:
在 SSH 或容器会话中Hermes 打印验证 URL 和用户代码,而不是打开浏览器。在笔记本电脑或云控制台的浏览器中打开该 URL——xAI Grok OAuth 无需 SSH 端口转发。
```bash
# 本地机器,另一个终端:
ssh -N -L 56121:127.0.0.1:56121 user@remote-host
# 远程机器:
hermes auth add xai-oauth --no-browser
```
完整操作说明跳板机、mosh/tmux、端口冲突[OAuth over SSH / Remote Hosts](./oauth-over-ssh.md)。
回环重定向类 providerSpotify、MCP 服务器)请参阅 [OAuth over SSH / Remote Hosts](./oauth-over-ssh.md)。
### 登录成功后 HTTP 403套餐 / 权限问题)

View file

@ -116,7 +116,7 @@ hermes model
### 无头环境 / SSH / 远程配置
OAuth 需要浏览器,但回调的 loopback 运行在 Hermes 所在的机器上。对于远程主机,请参阅 [OAuth over SSH / 远程主机](/guides/oauth-over-ssh)——与其他基于 OAuth 的提供商相同的方式同样适用于 Portal`ssh -L` 端口转发,或在 Cloud Shell / Codespaces 等纯浏览器环境中使用 `--manual-paste`)。
OAuth 需要浏览器,但回调的 loopback 运行在 Hermes 所在的机器上。对于远程主机,请参阅 [OAuth over SSH / 远程主机](/guides/oauth-over-ssh)——与其他基于 OAuth 的提供商相同的方式同样适用于 Portal`ssh -L` 端口转发)。
### Profile 配置