From fb44b519d712e7c8452112dbfcaa5071d56c66e4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 2 Jul 2026 11:36:21 -0500 Subject: [PATCH 01/34] fix(desktop): parse multiline slash commands + hand degenerate payloads back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseSlashCommand used /^(\S+)\s*(.*)$/ where `.` can't cross a newline and `$` anchors end-of-string, so any slash command whose arg contained a newline (/goal , a skill command with a long pasted context) failed the whole match, parsed as an empty name, and rendered "empty slash command" while the payload vanished — cleared from the composer and absent from the Up-arrow history ring, which only derives from sent user messages. - name now splits on any whitespace ([\s\S]* arg), matching the CLI and the gateway's split(maxsplit=1); multiline args flow to slash.exec intact - the residual empty-name branch (bare "/", "/ text") restores the submitted text to the composer draft instead of eating it Fixes #41323. Fixes #55510. --- .../hooks/use-prompt-actions/index.test.tsx | 66 ++++++++++++++++++- .../session/hooks/use-prompt-actions/slash.ts | 8 +++ apps/desktop/src/lib/chat-runtime.test.ts | 41 +++++++++++- apps/desktop/src/lib/chat-runtime.ts | 7 +- 4 files changed, 119 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index d0290aa27fc..c824e6e3cb6 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -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' @@ -272,6 +272,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 }[] = [] + const states: Record[] = [] + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + 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( + (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( (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', () => { diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts index 4d887f4ef64..3c918c7ed40 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts @@ -561,6 +561,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) { diff --git a/apps/desktop/src/lib/chat-runtime.test.ts b/apps/desktop/src/lib/chat-runtime.test.ts index 9d30dfb1c38..b3d4e638537 100644 --- a/apps/desktop/src/lib/chat-runtime.test.ts +++ b/apps/desktop/src/lib/chat-runtime.test.ts @@ -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: '' }) + }) +}) diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 9cd0c923d1d..2ae7dd262e1 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -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 ` + // 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: '' } } From 9738870489d04ee168f87263d85922ae2a858d42 Mon Sep 17 00:00:00 2001 From: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:05:42 -0700 Subject: [PATCH 02/34] fix(desktop): call checkUpdates() in startUpdatePoller so version pill auto-populates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startUpdatePoller() only called checkBackendUpdates() — never checkUpdates(). The statusbar version pill reads $updateStatus (set by checkUpdates()), so the commit-behind counter stayed null after restart. It only appeared when the user manually clicked the pill, which triggered checkUpdates() via openUpdateOverlayFor. Added void checkUpdates() in three places alongside the existing checkBackendUpdates() calls: - On startup in startUpdatePoller() - In the 30-minute setInterval callback - In the onFocus handler checkUpdates() uses the Electron IPC bridge (local git check), not the gateway, so no mode gating is needed. The existing $updateChecking atom guard prevents double-fire on overlap. Fixes #53079 --- apps/desktop/src/store/updates.test.ts | 74 +++++++++++++++++++++++++- apps/desktop/src/store/updates.ts | 3 ++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/store/updates.test.ts b/apps/desktop/src/store/updates.test.ts index 494d65319cc..5ecb9c52c8e 100644 --- a/apps/desktop/src/store/updates.test.ts +++ b/apps/desktop/src/store/updates.test.ts @@ -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 = {} + + 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() + }) +}) diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index eb70afcb342..5efe6477131 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -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() } From 03406ae2553e802f11399129c3f376a096bbec4f Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:21:06 -0600 Subject: [PATCH 03/34] fix(desktop): restore remote artifact rendering --- .../src/app/artifacts/artifact-utils.ts | 282 +++++++++++++++++ apps/desktop/src/app/artifacts/index.test.ts | 33 +- apps/desktop/src/app/artifacts/index.tsx | 299 ++---------------- .../components/assistant-ui/markdown-text.tsx | 80 +++-- apps/desktop/src/lib/media.remote.test.ts | 71 ++++- apps/desktop/src/lib/media.ts | 35 +- 6 files changed, 497 insertions(+), 303 deletions(-) create mode 100644 apps/desktop/src/app/artifacts/artifact-utils.ts diff --git a/apps/desktop/src/app/artifacts/artifact-utils.ts b/apps/desktop/src/app/artifacts/artifact-utils.ts new file mode 100644 index 00000000000..730ec0e0016 --- /dev/null +++ b/apps/desktop/src/app/artifacts/artifact-utils.ts @@ -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 { + 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)) { + 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() + 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()) +} diff --git a/apps/desktop/src/app/artifacts/index.test.ts b/apps/desktop/src/app/artifacts/index.test.ts index ebca956a2c9..cd98db3243a 100644 --- a/apps/desktop/src/app/artifacts/index.test.ts +++ b/apps/desktop/src/app/artifacts/index.test.ts @@ -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 { return { @@ -24,6 +25,12 @@ function makeSession(overrides: Partial = {}): 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' + }) + }) }) diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index f7d9e3238e3..fea5b7f58c9 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -21,14 +21,18 @@ 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 { + ARTIFACT_FILTERS, + artifactImageSrc, + collectArtifactsForSession, + type ArtifactFilter, + type ArtifactRecord +} from './artifact-utils' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { useRouteEnumParam } from '../hooks/use-route-enum-param' import { PAGE_INSET_NEG_X, PAGE_INSET_X } from '../layout-constants' @@ -36,29 +40,6 @@ 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 - const ARTIFACT_TIME_FMT = new Intl.DateTimeFormat(undefined, { day: 'numeric', hour: 'numeric', @@ -66,246 +47,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)) { - 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() - 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 +425,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 (
@@ -693,7 +456,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: failedImage && 'cursor-default' )} > - {!failedImage && ( + {!failedImage && src && ( onImageError(artifact.id)} slot="artifact-media" - src={artifact.href} + src={src} /> )} diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index beabcbf8cc2..bceadb8e6ca 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -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 { 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 ( - + + Couldn't fetch {name} from the gateway (missing, unreadable, or too large). + + ) +} + +function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) { + const { open, openFailed } = useOpenMediaFile(path) + + return ( + + + {openFailed && } + ) } 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 ( - { - event.preventDefault() - openExternalLink(mediaExternalUrl(path)) - }} - > - {failed ? `Open ${name}` : `Loading ${name}...`} - + + { + event.preventDefault() + open() + }} + > + {failed ? `Open ${name}` : `Loading ${name}...`} + + {openFailed && } + ) } diff --git a/apps/desktop/src/lib/media.remote.test.ts b/apps/desktop/src/lib/media.remote.test.ts index 53e5c2212c3..074d11ec42a 100644 --- a/apps/desktop/src/lib/media.remote.test.ts +++ b/apps/desktop/src/lib/media.remote.test.ts @@ -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,78 @@ 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 + + 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() + }) +}) diff --git a/apps/desktop/src/lib/media.ts b/apps/desktop/src/lib/media.ts index 9c50ce6c757..24988d97f15 100644 --- a/apps/desktop/src/lib/media.ts +++ b/apps/desktop/src/lib/media.ts @@ -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 { - 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 { + 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 { From 42ca4381316c54394f7e3e078dfc75119fcde9ae Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 2 Jul 2026 12:20:29 -0500 Subject: [PATCH 04/34] style(desktop): fix import ordering + padding lint in remote-artifact files --- apps/desktop/src/app/artifacts/index.tsx | 15 ++++++++------- apps/desktop/src/lib/media.remote.test.ts | 1 + 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index fea5b7f58c9..77e87b038b7 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -26,13 +26,6 @@ import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons' import { cn } from '@/lib/utils' import { notifyError } from '@/store/notifications' -import { - ARTIFACT_FILTERS, - artifactImageSrc, - collectArtifactsForSession, - type ArtifactFilter, - type ArtifactRecord -} from './artifact-utils' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { useRouteEnumParam } from '../hooks/use-route-enum-param' import { PAGE_INSET_NEG_X, PAGE_INSET_X } from '../layout-constants' @@ -40,6 +33,14 @@ import { PageSearchShell } from '../page-search-shell' import { sessionRoute } from '../routes' import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' +import { + ARTIFACT_FILTERS, + type ArtifactFilter, + artifactImageSrc, + type ArtifactRecord, + collectArtifactsForSession +} from './artifact-utils' + const ARTIFACT_TIME_FMT = new Intl.DateTimeFormat(undefined, { day: 'numeric', hour: 'numeric', diff --git a/apps/desktop/src/lib/media.remote.test.ts b/apps/desktop/src/lib/media.remote.test.ts index 074d11ec42a..9bb47ce6808 100644 --- a/apps/desktop/src/lib/media.remote.test.ts +++ b/apps/desktop/src/lib/media.remote.test.ts @@ -111,6 +111,7 @@ describe('downloadGatewayMediaFile', () => { throw new Error(`unexpected path ${path}`) }) + let clickSpy: ReturnType beforeEach(() => { From 931e2356af9bdc7ee0996e25ff9afc51216763ad Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 2 Jul 2026 12:27:51 -0500 Subject: [PATCH 05/34] feat(desktop): /journey opens the memory graph overlay instead of printing text --- apps/desktop/src/app/desktop-controller.tsx | 2 ++ .../hooks/use-prompt-actions/index.test.tsx | 26 +++++++++++++++++++ .../session/hooks/use-prompt-actions/index.ts | 3 +++ .../session/hooks/use-prompt-actions/slash.ts | 10 +++++++ .../src/lib/desktop-slash-commands.test.ts | 12 +++++++++ .../desktop/src/lib/desktop-slash-commands.ts | 7 +++++ 6 files changed, 60 insertions(+) diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 29f34b95890..b4a1c863e8e 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -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, diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index c824e6e3cb6..fd50574eaeb 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -54,6 +54,7 @@ function Harness({ busyRef, onReady, onSeedState, + openMemoryGraph, refreshSessions, requestGateway, resumeStoredSession, @@ -63,6 +64,7 @@ function Harness({ busyRef?: MutableRefObject onReady: (handle: HarnessHandle) => void onSeedState?: (state: Record) => void + openMemoryGraph?: () => void refreshSessions: () => Promise requestGateway: (method: string, params?: Record) => Promise resumeStoredSession?: (storedSessionId: string) => Promise | void @@ -91,6 +93,7 @@ function Harness({ busyRef: localBusyRef, createBackendSessionForSend: async () => RUNTIME_SESSION_ID, handleSkinCommand: () => '', + openMemoryGraph: openMemoryGraph ?? (() => undefined), refreshSessions, requestGateway, resumeStoredSession: resumeStoredSession ?? (() => undefined), @@ -369,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( + (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 }[] = [] diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 01d25c95d1a..8da0f2d1279 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -158,6 +158,7 @@ interface PromptActionsOptions { branchCurrentSession: () => Promise createBackendSessionForSend: (preview?: string | null) => Promise handleSkinCommand: (arg: string) => string + openMemoryGraph: () => void refreshSessions: () => Promise requestGateway: (method: string, params?: Record, timeoutMs?: number) => Promise resumeStoredSession: (storedSessionId: string) => Promise | 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, diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts index 3c918c7ed40..def08fe6eb4 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts @@ -54,6 +54,7 @@ interface SlashCommandDeps { platform: string, options?: { onProgress?: (state: string) => void; sessionId?: string } ) => Promise<{ ok: boolean; error?: string }> + openMemoryGraph: () => void refreshSessions: () => Promise requestGateway: GatewayRequest resumeStoredSession: (storedSessionId: string) => Promise | 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. @@ -612,6 +621,7 @@ export function useSlashCommand(deps: SlashCommandDeps) { createBackendSessionForSend, handleSkinCommand, handoffSession, + openMemoryGraph, refreshSessions, requestGateway, resumeStoredSession, diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index 8e30e5bfcfb..0a108e77ba8 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -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) diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index c5e28819557..20d5416f8db 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -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 }, From 8da0a56ba86a713a49b6fd61c79004aad7580a19 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 2 Jul 2026 12:28:30 -0500 Subject: [PATCH 06/34] style(desktop): fix pre-existing import-order lint in use-prompt-actions --- apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts | 2 +- apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 8da0f2d1279..66b4667b23d 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -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' diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index fba7eac8231..5127b534f1c 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -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' From c19bfb50ad6fc6368635cb0df8ec81bb820160bb Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:29:02 -0600 Subject: [PATCH 07/34] fix(desktop): restore remote file picker attachments --- apps/desktop/src/lib/desktop-fs.test.ts | 12 +++++++++++- apps/desktop/src/lib/desktop-fs.ts | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/desktop-fs.test.ts b/apps/desktop/src/lib/desktop-fs.test.ts index 9dcada40adb..f4dc4fac376 100644 --- a/apps/desktop/src/lib/desktop-fs.test.ts +++ b/apps/desktop/src/lib/desktop-fs.test.ts @@ -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 }) diff --git a/apps/desktop/src/lib/desktop-fs.ts b/apps/desktop/src/lib/desktop-fs.ts index d66e02230e2..3b05031bac1 100644 --- a/apps/desktop/src/lib/desktop-fs.ts +++ b/apps/desktop/src/lib/desktop-fs.ts @@ -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 }) : [] From fe82b3a774d97db0c4e948217a89f2b055ce73bc Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 2 Jul 2026 12:34:08 -0500 Subject: [PATCH 08/34] fix(desktop): read attachment previews local-first in remote mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit attachImagePath fetched its thumbnail through readDesktopFileDataUrl, which in remote mode routes every read to the gateway fs bridge. Paperclip picks, clipboard saves, and OS drops always produce paths on the LOCAL machine, so the gateway read 404s — toasting "image preview failed" and dropping the thumbnail even though the attach itself works (upload reads local bytes via the Electron bridge). Read the local bridge first and fall back to the remote facade, which still serves in-app drags from the remote project tree. Local mode is unchanged (the facade already reads locally there). Follow-up to #56572, which restored the remote paperclip picker and made this path reachable from the picker as well. --- .../chat/hooks/use-composer-actions.test.ts | 68 ++++++++++++++++++- .../app/chat/hooks/use-composer-actions.ts | 25 ++++++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts index 5f3f91b0402..9ecf4faa669 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index 5facd58f42a..1ebd2420f2f 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -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 { + 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 }) From a9b5598909585b851b1ed65f05034033676d1a86 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:30:06 -0600 Subject: [PATCH 09/34] fix(desktop): load remote model options before session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both Desktop picker surfaces (status-bar model menu, settings/onboarding dialog) only asked the connected gateway's model.options once a session existed; before that they fell back to the Desktop REST/global options, which can't see virtual providers a remote gateway exposes — including the MoA presets from #53817. Centralize the fetch rule in requestModelOptions(): prefer the connected gateway whenever one exists (no session_id needed — the RPC resolves disk config), REST only when no gateway is connected. The status-bar MoA preset section now renders from the same model.options payload (the virtual `moa` provider row) instead of the local /api/model/moa REST config, so remote presets appear correctly; the row is filtered out of the main provider groups so presets don't list twice. Preset selection keeps the persistent switchTo path from #56417 and drops the vestigial session gate — like regular model rows, a pre-session pick ships on the next session.create. Fixes #53817. Rebased and reconciled with #56417 (persistent MoA selection), which landed after this PR was opened and covered its one-shot-/moa half. --- .../src/app/shell/model-menu-panel.test.tsx | 48 ++++++++------ .../src/app/shell/model-menu-panel.tsx | 64 +++++++++---------- apps/desktop/src/components/model-picker.tsx | 14 +--- apps/desktop/src/lib/model-options.test.ts | 49 ++++++++++++++ apps/desktop/src/lib/model-options.ts | 25 ++++++++ 5 files changed, 138 insertions(+), 62 deletions(-) create mode 100644 apps/desktop/src/lib/model-options.test.ts create mode 100644 apps/desktop/src/lib/model-options.ts diff --git a/apps/desktop/src/app/shell/model-menu-panel.test.tsx b/apps/desktop/src/app/shell/model-menu-panel.test.tsx index 9cfb0998340..57125de35da 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.test.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.test.tsx @@ -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' }) + }) }) diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index 1d1d620a067..ae93c2179b2 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -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 => { - if (gateway && activeSessionId) { - return gateway.request('model.options', { session_id: activeSessionId }) - } - - return getGlobalModelOptions() - } - }) - - const moaOptions = useQuery({ - queryKey: ['moa-presets'], - queryFn: (): Promise => 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 => 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('model.options', { - session_id: activeSessionId, - refresh: true - }) - : await getGlobalModelOptions({ refresh: true }) + const next = await requestModelOptions({ gateway, refresh: true, sessionId: activeSessionId }) queryClient.setQueryData(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 {error} - ) : groups.length === 0 ? ( + ) : groups.length === 0 && moaPresets.length === 0 ? ( {copy.noModels} @@ -322,16 +323,15 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model - {moaOptions.data && Object.keys(moaOptions.data.presets ?? {}).length > 0 ? ( + {moaPresets.length > 0 ? ( <> MoA presets - {Object.keys(moaOptions.data.presets).map(preset => { - const isCurrentMoa = currentProvider === 'moa' && currentModel === preset + {moaPresets.map(preset => { + const isCurrentMoa = optionsProvider === 'moa' && optionsModel === preset return ( { event.preventDefault() diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx index a4e77a6d9ed..e2ca2908355 100644 --- a/apps/desktop/src/components/model-picker.tsx +++ b/apps/desktop/src/components/model-picker.tsx @@ -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('model.options', { - session_id: sessionId - }) - } - - return getGlobalModelOptions() - }, + queryFn: () => requestModelOptions({ gateway: gw, sessionId }), enabled: open }) diff --git a/apps/desktop/src/lib/model-options.test.ts b/apps/desktop/src/lib/model-options.test.ts new file mode 100644 index 00000000000..a1f6c057ed9 --- /dev/null +++ b/apps/desktop/src/lib/model-options.test.ts @@ -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 }) + }) +}) diff --git a/apps/desktop/src/lib/model-options.ts b/apps/desktop/src/lib/model-options.ts new file mode 100644 index 00000000000..f441b0dfdd3 --- /dev/null +++ b/apps/desktop/src/lib/model-options.ts @@ -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 { + if (gateway) { + const params: Record = {} + + if (sessionId) { + params.session_id = sessionId + } + + if (refresh) { + params.refresh = true + } + + return gateway.request('model.options', params) + } + + return getGlobalModelOptions(refresh ? { refresh: true } : undefined) +} From cc2abd570b8c5a842ba5ff77256ee047dd18659c Mon Sep 17 00:00:00 2001 From: xxxigm Date: Thu, 2 Jul 2026 08:14:28 +0700 Subject: [PATCH 10/34] fix(terminal): set MSYS_NO_PATHCONV for Windows Git Bash subprocesses Git Bash mangles native Windows command flags (/FO, /TN, /Create) into bogus paths. Hermes terminal and background spawns now opt out by default so tasklist, schtasks, and wmic work without manual prefixes. Fixes #56700. --- tools/environments/local.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tools/environments/local.py b/tools/environments/local.py index 6c518b54b64..a3908e3d617 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -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,21 @@ 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. + """ + if not _IS_WINDOWS: + return + env.setdefault("MSYS_NO_PATHCONV", "1") + + def _path_env_key(run_env: dict) -> str | None: """Return the PATH env key to update without altering Windows casing. @@ -804,6 +823,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 From 51c01062d4a2e3cdccc1fb1fdf712dd44fd18e2a Mon Sep 17 00:00:00 2001 From: xxxigm Date: Thu, 2 Jul 2026 08:14:28 +0700 Subject: [PATCH 11/34] test(terminal): cover MSYS_NO_PATHCONV defaults on Windows env builders --- tests/tools/test_local_env_windows_msys.py | 33 ++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/tools/test_local_env_windows_msys.py b/tests/tools/test_local_env_windows_msys.py index 1b05aaaeace..09bd09e2765 100644 --- a/tests/tools/test_local_env_windows_msys.py +++ b/tests/tools/test_local_env_windows_msys.py @@ -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,36 @@ 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" + + # --------------------------------------------------------------------------- # Command wrapping — native Windows cwd must be Git Bash-friendly for cd # --------------------------------------------------------------------------- From a2d49de80156cc0e20c1ff5a30c552585f780413 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:22:52 -0700 Subject: [PATCH 12/34] fix(terminal): also set MSYS2_ARG_CONV_EXCL for MSYS2/Cygwin bash fallback MSYS_NO_PATHCONV is honored by Git for Windows bash only. _find_bash's final shutil.which fallback can return MSYS2-proper or Cygwin bash, which ignore it and honor MSYS2_ARG_CONV_EXCL instead. Set both so argv path conversion stays disabled regardless of which bash flavor spawns. Also subsumes the cmd /c mangling in #56147. --- tests/tools/test_local_env_windows_msys.py | 17 +++++++++++++++++ tools/environments/local.py | 8 ++++++++ 2 files changed, 25 insertions(+) diff --git a/tests/tools/test_local_env_windows_msys.py b/tests/tools/test_local_env_windows_msys.py index 09bd09e2765..59f01ac56b6 100644 --- a/tests/tools/test_local_env_windows_msys.py +++ b/tests/tools/test_local_env_windows_msys.py @@ -260,6 +260,23 @@ class TestWindowsMsysPathconvDefaults: 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 diff --git a/tools/environments/local.py b/tools/environments/local.py index a3908e3d617..191ff4d4b2d 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -759,10 +759,18 @@ def _apply_windows_msys_bash_env_defaults(env: dict) -> None: 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: From 6cffc37b5ac4467aa41fbdddbba29e0f04876378 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Thu, 2 Jul 2026 14:15:07 -0500 Subject: [PATCH 13/34] feat(desktop): collapse profile rail to a select past 13 profiles (#57306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The colored-square rail stops scaling once a user racks up many profiles: tiny drag targets and an endless horizontal scroll strip. Past a threshold (13) the rail swaps the squares for a compact select dropdown — same active tint + initial glyph, minus the drag-reorder / long-press-recolor / per-row context menu that only make sense at small counts. Two render paths behind one flag; the left default↔all toggle, the "+" create button, and Manage stay put in both. Rename/delete/color remain reachable via Manage. --- .../src/app/chat/sidebar/profile-switcher.tsx | 172 +++++++++++++----- 1 file changed, 127 insertions(+), 45 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 100ad8001e4..4c919b14456 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -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) const scrollRef = useRef(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() { /> )} -
- {multiProfile && ( - - 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 +). */} -
- {named.map(profile => ( - setPendingDelete(profile)} - onRecolor={color => setProfileColor(profile.name, color)} - onRename={() => setPendingRename(profile)} - onSelect={() => selectProfile(profile.name)} - /> - ))} -
-
-
- )} + {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. +
+ + setCreateOpen(true)} /> +
+ ) : ( +
+ {multiProfile && ( + + 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 +). */} +
+ {named.map(profile => ( + setPendingDelete(profile)} + onRecolor={color => setProfileColor(profile.name, color)} + onRename={() => setPendingRename(profile)} + onSelect={() => selectProfile(profile.name)} + /> + ))} +
+
+
+ )} - - - -
+ setCreateOpen(true)} /> +
+ )} {/* 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 ( + + + + ) +} + +// 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 + 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 ( + + ) +} + interface ProfilePillProps { active: boolean // home / All / Manage are glyph action buttons (navigation, not identity). From 472d75193f295e509e9f25e962c59655fb26998a Mon Sep 17 00:00:00 2001 From: LeonSGP43 Date: Sat, 20 Jun 2026 12:33:20 +0800 Subject: [PATCH 14/34] Prevent deleted profile skeleton revival --- hermes_cli/config.py | 9 +++++++++ tests/hermes_cli/test_config.py | 16 ++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 8748db2cb13..7bbec92cf62 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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: diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 41eef04a1ba..37740c2ea75 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -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): From 5ef0b8acb0fa3b0bb9d65ae04313b9b64970d7fd Mon Sep 17 00:00:00 2001 From: Jaaneek Date: Thu, 2 Jul 2026 15:35:06 +0000 Subject: [PATCH 15/34] feat(auth): make xAI Grok OAuth device-code-only, drop loopback login Replace the loopback/PKCE-callback server and manual-paste fallback with the RFC 8628 device-code flow as the only xAI Grok OAuth login path. The flow works in headless/SSH/container sessions with no 127.0.0.1 listener, shrinking the local attack surface. - Poll the token endpoint with server-provided interval, honoring slow_down and expires_in; store tokens with auth_mode oauth_device_code. - Adaptive proactive refresh skew for short-lived device-code JWTs; rotated tokens sync back to auth.json, the global root store, and the credential pool (no refresh-token replay). - Clear source suppression on successful re-login (CLI + dashboard) and drop the duplicate dashboard pool entry so exactly one seeded device_code entry exists. - Use the shared device_code source name for consistency with the nous/codex device-code providers. - Desktop: remove the loopback OAuth flow states and dead type variants; pkce providers' sign-in URL selection is unchanged. - Docs (EN + zh-Hans) rewritten for device-code login; drop the deleted --manual-paste flag from documented commands. --- agent/auxiliary_client.py | 2 +- agent/credential_persistence.py | 2 +- agent/credential_pool.py | 45 +- agent/credential_sources.py | 11 +- .../src/components/onboarding/flow.tsx | 15 - apps/desktop/src/i18n/en.ts | 1 - apps/desktop/src/i18n/ja.ts | 1 - apps/desktop/src/i18n/zh-hant.ts | 1 - apps/desktop/src/i18n/zh.ts | 1 - apps/desktop/src/store/onboarding.ts | 20 +- apps/desktop/src/types/hermes.ts | 8 +- hermes_cli/auth.py | 858 +++++------------- hermes_cli/auth_commands.py | 6 +- hermes_cli/model_setup_flows.py | 6 - hermes_cli/setup.py | 7 +- hermes_cli/subcommands/auth.py | 11 - hermes_cli/subcommands/model.py | 10 - hermes_cli/web_server.py | 372 +++----- run_agent.py | 2 +- tests/agent/test_credential_pool.py | 4 +- tests/hermes_cli/test_auth_commands.py | 10 +- .../hermes_cli/test_auth_loopback_ssh_hint.py | 148 --- tests/hermes_cli/test_auth_manual_paste.py | 684 -------------- .../test_auth_xai_oauth_provider.py | 426 ++++----- tests/hermes_cli/test_web_oauth_dispatch.py | 276 ++---- tests/hermes_cli/test_xai_model_flow.py | 3 +- .../test_xai_oauth_pkce_token_exchange.py | 359 -------- tests/hermes_cli/test_xai_oauth_refresh.py | 14 + .../test_run_agent_codex_responses.py | 2 +- website/docs/guides/oauth-over-ssh.md | 74 +- .../guides/run-hermes-with-nous-portal.md | 6 +- .../docs/guides/run-nemotron-3-ultra-free.md | 2 +- website/docs/guides/xai-grok-oauth.md | 62 +- website/docs/integrations/nous-portal.md | 2 +- .../current/guides/oauth-over-ssh.md | 122 +-- .../guides/run-hermes-with-nous-portal.md | 6 +- .../current/guides/xai-grok-oauth.md | 56 +- .../current/integrations/nous-portal.md | 2 +- 38 files changed, 733 insertions(+), 2904 deletions(-) delete mode 100644 tests/hermes_cli/test_auth_loopback_ssh_hint.py delete mode 100644 tests/hermes_cli/test_auth_manual_paste.py delete mode 100644 tests/hermes_cli/test_xai_oauth_pkce_token_exchange.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index d3a0bfb52c0..d92253a8c72 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -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, diff --git a/agent/credential_persistence.py b/agent/credential_persistence.py index 069384e7ce6..9217f9535ec 100644 --- a/agent/credential_persistence.py +++ b/agent/credential_persistence.py @@ -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({ diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 39de2520271..1de7390ea19 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -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" @@ -1174,7 +1175,7 @@ class CredentialPool: ] 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), }, ) diff --git a/agent/credential_sources.py b/agent/credential_sources.py index f99a7586257..18f0823ba84 100644 --- a/agent/credential_sources.py +++ b/agent/credential_sources.py @@ -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 `` 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( diff --git a/apps/desktop/src/components/onboarding/flow.tsx b/apps/desktop/src/components/onboarding/flow.tsx index 11cb3073a17..6e9fb1ef51f 100644 --- a/apps/desktop/src/components/onboarding/flow.tsx +++ b/apps/desktop/src/components/onboarding/flow.tsx @@ -96,21 +96,6 @@ export function FlowPanel({ ) } - if (flow.status === 'awaiting_browser') { - return ( - -

{t.onboarding.autoBrowser(title)}

- {t.onboarding.reopenSignInPage}}> - - - {t.onboarding.waitingAuthorize} - - - -
- ) - } - if (flow.status === 'external_pending') { return ( diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 351702deda3..d9c9de2f063 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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}...`, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 37655844e29..7c23fb65601 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1852,7 +1852,6 @@ export const ja = defineLocale({ flowSubtitles: { pkce: 'ブラウザーを開いてサインインし、ここに戻ります', device_code: 'ブラウザーで確認ページを開きます — Hermes が自動接続します', - loopback: 'サインインのためブラウザーを開きます — Hermes が自動接続します', external: 'ターミナルで一度サインインして、チャットに戻ります' }, startingSignIn: provider => `${provider} のサインインを開始中...`, diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 03c1d244911..5859569a2ae 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1793,7 +1793,6 @@ export const zhHant = defineLocale({ flowSubtitles: { pkce: '開啟瀏覽器登入,然後回到這裡繼續', device_code: '在瀏覽器中開啟驗證頁面 — Hermes 會自動連線', - loopback: '開啟瀏覽器登入 — Hermes 會自動連線', external: '先在終端機登入一次,然後回來繼續聊天' }, startingSignIn: provider => `正在為 ${provider} 啟動登入...`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 3cd51e03df6..14a96cee199 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1917,7 +1917,6 @@ export const zh: Translations = { flowSubtitles: { pkce: '打开浏览器登录,然后回到这里继续', device_code: '在浏览器中打开验证页面 — Hermes 会自动连接', - loopback: '打开浏览器登录 — Hermes 会自动连接', external: '先在终端登录一次,然后回来继续对话' }, startingSignIn: provider => `正在为 ${provider} 启动登录...`, diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index 9ef3754be7b..c9c9606f349 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -18,7 +18,6 @@ import type { ModelOptionProvider, OAuthProvider, OAuthStartResponse } from '@/t type PkceStart = Extract type DeviceStart = Extract -type LoopbackStart = Extract 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) diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 4892bebff19..d5506415caf 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -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 diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index fdd099bfa46..3547bb87323 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -106,9 +106,7 @@ XAI_OAUTH_ISSUER = "https://auth.x.ai" XAI_OAUTH_DISCOVERY_URL = f"{XAI_OAUTH_ISSUER}/.well-known/openid-configuration" XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828" XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access" -XAI_OAUTH_REDIRECT_HOST = "127.0.0.1" -XAI_OAUTH_REDIRECT_PORT = 56121 -XAI_OAUTH_REDIRECT_PATH = "/callback" +XAI_OAUTH_DEVICE_CODE_URL = f"{XAI_OAUTH_ISSUER}/oauth2/device/code" # xAI/Grok OAuth access tokens are intentionally short-lived (about 6h in # current SuperGrok flows). A two-minute refresh window is too narrow for # gateway/cron workloads that may only touch the provider every 30 minutes, @@ -125,7 +123,6 @@ SPOTIFY_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/featur SPOTIFY_DASHBOARD_URL = "https://developer.spotify.com/dashboard" SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 -XAI_OAUTH_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth" OAUTH_OVER_SSH_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/guides/oauth-over-ssh" DEFAULT_SPOTIFY_SCOPE = " ".join(( "user-modify-playback-state", @@ -2597,256 +2594,6 @@ def _spotify_wait_for_callback( ) -def _xai_validate_loopback_redirect_uri(redirect_uri: str) -> tuple[str, int, str]: - parsed = urlparse(redirect_uri) - if parsed.scheme != "http": - raise AuthError( - "xAI OAuth redirect_uri must use http://127.0.0.1.", - provider="xai-oauth", - code="xai_redirect_invalid", - ) - host = parsed.hostname or "" - if host != XAI_OAUTH_REDIRECT_HOST: - raise AuthError( - "xAI OAuth redirect_uri must point to 127.0.0.1.", - provider="xai-oauth", - code="xai_redirect_invalid", - ) - if not parsed.port: - raise AuthError( - "xAI OAuth redirect_uri must include an explicit localhost port.", - provider="xai-oauth", - code="xai_redirect_invalid", - ) - return host, parsed.port, parsed.path or "/" - - -def _xai_callback_cors_origin(origin: Optional[str]) -> str: - # CORS allowlist for the loopback callback. Only xAI's own auth origins - # are accepted; the redirect_uri itself is bound to 127.0.0.1 and gated by - # PKCE+state, so additional dev/3p origins are not needed here. - allowed = { - "https://accounts.x.ai", - "https://auth.x.ai", - } - return origin if origin in allowed else "" - - -def _make_xai_callback_handler(expected_path: str) -> tuple[type[BaseHTTPRequestHandler], dict[str, Any]]: - result: dict[str, Any] = { - "code": None, - "state": None, - "error": None, - "error_description": None, - } - result_lock = threading.Lock() - - class _XAICallbackHandler(BaseHTTPRequestHandler): - def _maybe_write_cors_headers(self) -> None: - origin = self.headers.get("Origin") - allow_origin = _xai_callback_cors_origin(origin) - if allow_origin: - self.send_header("Access-Control-Allow-Origin", allow_origin) - self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") - self.send_header("Access-Control-Allow-Headers", "Content-Type") - self.send_header("Access-Control-Allow-Private-Network", "true") - self.send_header("Vary", "Origin") - - def do_OPTIONS(self) -> None: # noqa: N802 - self.send_response(204) - self._maybe_write_cors_headers() - self.end_headers() - - def do_GET(self) -> None: # noqa: N802 - parsed = urlparse(self.path) - if parsed.path != expected_path: - self.send_response(404) - self.end_headers() - self.wfile.write(b"Not found.") - return - - params = parse_qs(parsed.query) - incoming = { - "code": params.get("code", [None])[0], - "state": params.get("state", [None])[0], - "error": params.get("error", [None])[0], - "error_description": params.get("error_description", [None])[0], - } - - # Diagnostic logging — emits at INFO so reporters of loopback bugs - # (#27385 — "callback received but Hermes times out") can produce - # actionable evidence without a code change. Logged values are - # fingerprints / booleans only; no actual code/state strings leak - # into the log file. Run with ``HERMES_LOG_LEVEL=INFO`` (or check - # ``~/.hermes/logs/agent.log`` which captures INFO+ unconditionally). - try: - logger.info( - "xAI loopback callback received: path=%s has_code=%s has_state=%s has_error=%s " - "ua=%s", - parsed.path, - incoming["code"] is not None, - incoming["state"] is not None, - incoming["error"] is not None, - (self.headers.get("User-Agent") or "")[:80], - ) - if incoming["error"]: - logger.info( - "xAI loopback callback carries error=%s error_description=%s", - incoming["error"], - (incoming["error_description"] or "")[:200], - ) - except Exception: - # Logging must never break the OAuth flow. - pass - - # Treat a hit on the callback path with neither `code` nor `error` - # as a missing OAuth callback (e.g. xAI's auth backend failed to - # redirect and the user navigated to the bare loopback URL by hand). - # Show an explicit "not received" page rather than the success page — - # otherwise the browser claims authorization succeeded while the CLI - # is still waiting for a real callback and eventually times out. - if incoming["code"] is None and incoming["error"] is None: - self.send_response(400) - self._maybe_write_cors_headers() - self.send_header("Content-Type", "text/html; charset=utf-8") - self.end_headers() - body = ( - "" - "

xAI authorization not received.

" - "

No authorization code was present in this callback URL. " - "Return to the terminal and re-run " - "hermes auth add xai-oauth to retry.

" - "" - ) - self.wfile.write(body.encode("utf-8")) - return - - # ThreadingHTTPServer allows a fallback/manual callback to complete - # while a browser connection is stuck. Once we have a terminal - # OAuth result (code or error), keep the first one so a later - # concurrent/invalid callback cannot overwrite state before - # validation in _xai_oauth_loopback_login(). - with result_lock: - if not (result["code"] or result["error"]): - result.update(incoming) - - self.send_response(200) - self._maybe_write_cors_headers() - self.send_header("Content-Type", "text/html; charset=utf-8") - self.end_headers() - if incoming["error"]: - body = "

xAI authorization failed.

You can close this tab." - else: - body = "

xAI authorization received.

You can close this tab." - self.wfile.write(body.encode("utf-8")) - - def log_message(self, format: str, *args: Any) -> None: # noqa: A003 - return - - return _XAICallbackHandler, result - - -def _xai_start_callback_server( - preferred_port: int = XAI_OAUTH_REDIRECT_PORT, -) -> tuple[HTTPServer, threading.Thread, dict[str, Any], str]: - host = XAI_OAUTH_REDIRECT_HOST - expected_path = XAI_OAUTH_REDIRECT_PATH - handler_cls, result = _make_xai_callback_handler(expected_path) - - class _ReuseHTTPServer(ThreadingHTTPServer): - allow_reuse_address = True - daemon_threads = True - - ports_to_try = [preferred_port] - if preferred_port != 0: - ports_to_try.append(0) - server = None - last_error: Optional[OSError] = None - for port in ports_to_try: - try: - server = _ReuseHTTPServer((host, port), handler_cls) - break - except OSError as exc: - last_error = exc - if server is None: - raise AuthError( - f"Could not bind xAI callback server on {host}:{preferred_port}: {last_error}", - provider="xai-oauth", - code="xai_callback_bind_failed", - ) from last_error - - actual_port = int(server.server_address[1]) - redirect_uri = f"http://{host}:{actual_port}{expected_path}" - thread = threading.Thread( - target=server.serve_forever, - kwargs={"poll_interval": 0.1}, - daemon=True, - ) - thread.start() - return server, thread, result, redirect_uri - - -def _xai_wait_for_callback( - server: HTTPServer, - thread: threading.Thread, - result: dict[str, Any], - *, - timeout_seconds: float = 180.0, - manual_paste_redirect_uri: Optional[str] = None, -) -> dict[str, Any]: - deadline = time.monotonic() + max(5.0, timeout_seconds) - if manual_paste_redirect_uri and sys.stdin.isatty(): - print() - print("If xAI shows a Grok Build code instead of redirecting,") - print("paste that code here and press Enter.") - try: - while time.monotonic() < deadline: - if result["code"] or result["error"]: - return result - if manual_paste_redirect_uri: - raw_paste = _read_ready_stdin_line() - if raw_paste and raw_paste.strip(): - pasted = _parse_pasted_callback(raw_paste) - pasted["_manual_paste"] = True - return pasted - time.sleep(0.1) - finally: - server.shutdown() - server.server_close() - thread.join(timeout=1.0) - # Diagnostic: distinguish "no callback ever arrived" from "callback - # arrived but result wasn't populated" (#27385). The per-hit handler - # also logs at INFO; if neither line appears, xAI's IDP never reached - # the loopback at all (firewall, port-binding, IPv6/IPv4 mismatch). - logger.info( - "xAI loopback wait timed out after %.0fs with no usable callback " - "(result.code=%s result.error=%s)", - max(5.0, timeout_seconds), - result["code"] is not None, - result["error"] is not None, - ) - raise AuthError( - "xAI authorization timed out waiting for the local callback.", - provider="xai-oauth", - code="xai_callback_timeout", - ) - - -def _read_ready_stdin_line() -> Optional[str]: - """Return one pending stdin line without blocking, if the terminal has one.""" - try: - if not sys.stdin.isatty(): - return None - import select - - ready, _, _ = select.select([sys.stdin], [], [], 0) - if not ready: - return None - return sys.stdin.readline() - except Exception: - return None - - def _spotify_token_payload_to_state( token_payload: Dict[str, Any], *, @@ -3277,8 +3024,8 @@ def _is_remote_session() -> bool: # it hijacks the user's TTY with an unusable text browser (the xAI OAuth # "Account Management" page rendered in w3m, reported May 2026) instead of # letting them copy the URL to a real browser. When the resolved browser is -# one of these we refuse to auto-open and fall back to the print-the-URL / -# manual-paste path, same as a remote session. +# one of these we refuse to auto-open and fall back to the print-the-URL +# path, same as a remote session. _CONSOLE_BROWSER_NAMES: FrozenSet[str] = frozenset( { "w3m", @@ -3349,83 +3096,6 @@ def _can_open_graphical_browser() -> bool: return True -def _parse_pasted_callback(raw: str) -> dict: - """Parse a pasted callback URL / query string into the loopback shape. - - Accepts any of: - - * full URL: ``http://127.0.0.1:56121/callback?code=abc&state=xyz`` - * bare query string: ``?code=abc&state=xyz`` or ``code=abc&state=xyz`` - * bare code (no state, only used when the upstream omits state): - ``abc-the-code-value`` - - Returns ``{"code", "state", "error", "error_description"}`` with - missing keys set to ``None`` so the loopback callsites can keep - using the same validation path (state check, error check, etc.) - they already use for the HTTP server output. Regression for - #26923 — formalises the curl-the-callback-URL workaround the - reporter used while waiting for upstream support. - """ - stripped = raw.strip() - result: dict = { - "code": None, - "state": None, - "error": None, - "error_description": None, - } - if not stripped: - return result - query = "" - if stripped.startswith(("http://", "https://")): - try: - parsed = urlparse(stripped) - except Exception: - return result - query = parsed.query or "" - elif stripped.startswith("?"): - query = stripped[1:] - elif "=" in stripped: - # Looks like a bare query fragment (``code=...&state=...``). - query = stripped - else: - # Treat as a bare opaque code value with no state. - result["code"] = stripped - return result - params = parse_qs(query, keep_blank_values=False) - for key in ("code", "state", "error", "error_description"): - values = params.get(key) - if values: - result[key] = values[0] - return result - - -def _prompt_manual_callback_paste(redirect_uri: str) -> dict: - """Read a callback URL from stdin as a fallback for browser-only remotes. - - Used when ``--manual-paste`` is set or when the loopback listener - cannot bind. Returns the parsed callback dict (same shape as the - HTTP handler output) so the existing state / error validation in - the caller works unchanged. See #26923. - """ - print() - print("─── Manual callback paste ─────────────────────────────────────") - print("After approving in your browser, your browser will try to load") - print(f" {redirect_uri}") - print("which fails (the loopback listener is on this remote machine,") - print("not on your laptop) — that is expected. Copy the FULL URL") - print("from your browser's address bar of that failed page and paste") - print("it below. A bare '?code=...&state=...' fragment also works.") - print("If the consent page shows the authorization code in-page") - print("(xAI's current behavior) rather than redirecting, paste the") - print("bare code value on its own.") - print("───────────────────────────────────────────────────────────────") - try: - raw = input("Callback URL: ") - except (EOFError, KeyboardInterrupt): - raw = "" - return _parse_pasted_callback(raw) - - def _ssh_user_at_host() -> str: """Return best-effort 'user@hostname' for the SSH tunnel hint command. @@ -3443,16 +3113,16 @@ def _ssh_user_at_host() -> str: def _print_loopback_ssh_hint(redirect_uri: str, *, docs_url: str | None = None) -> None: """Print an SSH tunnel hint when running a loopback-redirect OAuth flow on a - remote host. The auth server (xAI, Spotify, ...) will redirect the user's - browser to ``127.0.0.1:/callback``. If the browser is on a different - machine than the loopback listener (the usual SSH case), the redirect can't - reach the listener without a local port forward. + remote host. The auth server (Spotify, MCP servers, ...) will redirect the + user's browser to ``127.0.0.1:/callback``. If the browser is on a + different machine than the loopback listener (the usual SSH case), the + redirect can't reach the listener without a local port forward. The hint is best-effort: silent if we don't think we're remote, or if we can't parse a host/port out of the redirect URI. - Pass ``docs_url`` for a provider-specific guide (e.g. the xAI Grok OAuth - page); the generic OAuth-over-SSH guide is always shown after it. + Pass ``docs_url`` for a provider-specific guide; the generic OAuth-over-SSH + guide is always shown after it. """ if not _is_remote_session(): return @@ -3476,10 +3146,6 @@ def _print_loopback_ssh_hint(redirect_uri: str, *, docs_url: str | None = None) print(f" ssh -N -L {port}:127.0.0.1:{port} {_ssh_user_at_host()}") print() print("Then open the authorize URL above in your local browser.") - print() - print("No SSH client (Cloud Shell / Codespaces / web IDE)? Re-run with") - print("`--manual-paste` to skip the loopback listener and paste the failed") - print("callback URL directly.") if docs_url: print(f"Provider docs: {docs_url}") print(f"SSH/jump-box guide: {OAUTH_OVER_SSH_DOCS_URL}") @@ -4293,6 +3959,7 @@ def _save_xai_oauth_tokens( discovery: Optional[Dict[str, Any]] = None, redirect_uri: str = "", last_refresh: Optional[str] = None, + auth_mode: str = "oauth_device_code", ) -> None: if last_refresh is None: last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") @@ -4306,7 +3973,7 @@ def _save_xai_oauth_tokens( state = _load_provider_state(auth_store, "xai-oauth") or {} state["tokens"] = tokens state["last_refresh"] = last_refresh - state["auth_mode"] = "oauth_pkce" + state["auth_mode"] = auth_mode if discovery: state["discovery"] = discovery if redirect_uri: @@ -4335,6 +4002,40 @@ def _xai_access_token_is_expiring(access_token: str, skew_seconds: int = 0) -> b return False +def _xai_proactive_refresh_skew_seconds(access_token: str) -> int: + """How far before JWT ``exp`` to proactively refresh xAI OAuth tokens. + + SuperGrok sessions can still ship multi-hour access tokens, where the + gateway-oriented :data:`XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS` window + makes sense. Device-code logins often return ~15-minute JWTs; applying + the full hour-long skew to those forces a refresh on *every* credential + resolution (chat turn, Imagine tool call, ``hermes auth status``, …), + which burns single-use refresh tokens and races concurrent callers into + ``invalid_grant`` quarantine. + """ + max_skew = XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS + if not isinstance(access_token, str) or "." not in access_token: + return max_skew + try: + parts = access_token.split(".") + if len(parts) < 2: + return max_skew + payload_b64 = parts[1] + payload_b64 += "=" * (-len(payload_b64) % 4) + payload = json.loads(base64.urlsafe_b64decode(payload_b64.encode("ascii")).decode("utf-8")) + exp = payload.get("exp") + if not isinstance(exp, (int, float)): + return max_skew + remaining = float(exp) - time.time() + if remaining <= 0: + return max_skew + if remaining <= 45 * 60: + return min(120, max_skew) + return max_skew + except Exception: + return max_skew + + def _xai_validate_oauth_endpoint(url: str, *, field: str) -> str: """Refuse any OIDC discovery endpoint that isn't HTTPS on the xAI origin. @@ -4586,6 +4287,14 @@ def _refresh_xai_oauth_tokens( redirect_uri: str = "", timeout_seconds: float, ) -> Dict[str, Any]: + # Re-persist whatever auth_mode is already stored (legacy pre-device-code + # logins may still carry ``oauth_pkce``): the refresh hot path must not + # relabel how the grant was originally obtained. + try: + state = _load_provider_state(_load_auth_store(), "xai-oauth") or {} + auth_mode = str(state.get("auth_mode") or "oauth_device_code") + except Exception: + auth_mode = "oauth_device_code" refreshed = refresh_xai_oauth_pure( str(tokens.get("access_token", "") or ""), str(tokens.get("refresh_token", "") or ""), @@ -4606,6 +4315,7 @@ def _refresh_xai_oauth_tokens( discovery={"token_endpoint": token_endpoint}, redirect_uri=redirect_uri, last_refresh=refreshed["last_refresh"], + auth_mode=auth_mode, ) return updated_tokens @@ -4614,7 +4324,7 @@ def resolve_xai_oauth_runtime_credentials( *, force_refresh: bool = False, refresh_if_expiring: bool = True, - refresh_skew_seconds: int = XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, + refresh_skew_seconds: Optional[int] = None, ) -> Dict[str, Any]: data = _read_xai_oauth_tokens() tokens = dict(data["tokens"]) @@ -4624,9 +4334,14 @@ def resolve_xai_oauth_runtime_credentials( token_endpoint = str(discovery.get("token_endpoint", "") or "").strip() redirect_uri = str(data.get("redirect_uri", "") or "").strip() + effective_skew = ( + int(refresh_skew_seconds) + if refresh_skew_seconds is not None + else _xai_proactive_refresh_skew_seconds(access_token) + ) should_refresh = bool(force_refresh) if (not should_refresh) and refresh_if_expiring: - should_refresh = _xai_access_token_is_expiring(access_token, refresh_skew_seconds) + should_refresh = _xai_access_token_is_expiring(access_token, effective_skew) if should_refresh: with _auth_store_lock(timeout_seconds=max(float(AUTH_LOCK_TIMEOUT_SECONDS), refresh_timeout_seconds + 5.0)): data = _read_xai_oauth_tokens(_lock=False) @@ -4635,9 +4350,14 @@ def resolve_xai_oauth_runtime_credentials( discovery = dict(data.get("discovery") or {}) token_endpoint = str(discovery.get("token_endpoint", "") or "").strip() redirect_uri = str(data.get("redirect_uri", "") or "").strip() + effective_skew = ( + int(refresh_skew_seconds) + if refresh_skew_seconds is not None + else _xai_proactive_refresh_skew_seconds(access_token) + ) should_refresh = bool(force_refresh) if (not should_refresh) and refresh_if_expiring: - should_refresh = _xai_access_token_is_expiring(access_token, refresh_skew_seconds) + should_refresh = _xai_access_token_is_expiring(access_token, effective_skew) if should_refresh: if not token_endpoint: token_endpoint = _xai_oauth_discovery(refresh_timeout_seconds)["token_endpoint"] @@ -4688,7 +4408,10 @@ def resolve_xai_oauth_runtime_credentials( "api_key": access_token, "source": "hermes-auth-store", "last_refresh": data.get("last_refresh"), - "auth_mode": "oauth_pkce", + # Display/telemetry only. Device-code is the only supported xAI OAuth + # flow, so report it unconditionally — auth.json may still carry a + # legacy ``oauth_pkce`` label, which the refresh path preserves as-is. + "auth_mode": "oauth_device_code", } @@ -6270,7 +5993,10 @@ def get_xai_oauth_auth_status() -> Dict[str, Any]: "logged_in": True, "auth_store": str(_auth_file_path()), "last_refresh": getattr(entry, "last_refresh", None), - "auth_mode": "oauth_pkce", + # Display/telemetry only. Device-code is the only xAI + # OAuth flow, so report it unconditionally (auth.json + # may still carry a legacy ``oauth_pkce`` label). + "auth_mode": "oauth_device_code", "source": f"pool:{getattr(entry, 'label', 'unknown')}", "api_key": api_key, } @@ -7088,19 +6814,27 @@ def _login_xai_oauth( open_browser = not getattr(args, "no_browser", False) if _is_remote_session(): open_browser = False - manual_paste = bool(getattr(args, "manual_paste", False)) - creds = _xai_oauth_loopback_login( + creds = _xai_oauth_device_code_login( timeout_seconds=timeout_seconds, open_browser=open_browser, - manual_paste=manual_paste, ) _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", ) + # An explicit interactive re-login is a strong signal the user wants the + # xAI credential re-enabled. ``hermes auth remove xai-oauth`` leaves a + # ``device_code`` suppression marker that otherwise stops the singleton + # seed from re-creating the pool entry, so ``hermes auth list`` would show + # nothing even though the agent still works via the singleton fallback. + # Clear it here (same helper ``auth_add_command`` uses). This is kept OUT + # of ``_save_xai_oauth_tokens`` on purpose — that helper is shared with the + # refresh hot path, which must never mutate suppression state. + unsuppress_credential_source("xai-oauth", "device_code") config_path = _update_config_for_provider("xai-oauth", creds.get("base_url", DEFAULT_XAI_OAUTH_BASE_URL)) print() print("Login successful!") @@ -7109,338 +6843,170 @@ def _login_xai_oauth( print(f" Config updated: {config_path} (model.provider=xai-oauth)") -def _xai_oauth_build_authorize_url( +def _xai_oauth_request_device_code( + client: httpx.Client, *, - authorization_endpoint: str, - redirect_uri: str, - code_challenge: str, - state: str, - nonce: str, -) -> str: - # `plan=generic` opts the consent screen into xAI's generic OAuth plan - # tier instead of falling back to the per-account default. Without it, - # accounts.x.ai rejects loopback OAuth from non-allowlisted clients. - # `referrer=hermes-agent` lets xAI attribute Hermes-originated logins - # in their OAuth server logs (we still impersonate the upstream Grok-CLI - # client_id; this is best-effort attribution until xAI mints us our own). - authorize_params = { - "response_type": "code", - "client_id": XAI_OAUTH_CLIENT_ID, - "redirect_uri": redirect_uri, - "scope": XAI_OAUTH_SCOPE, - "code_challenge": code_challenge, - "code_challenge_method": "S256", - "state": state, - "nonce": nonce, - "plan": "generic", - "referrer": "hermes-agent", - } - return f"{authorization_endpoint}?{urlencode(authorize_params)}" + scope: str = XAI_OAUTH_SCOPE, +) -> Dict[str, Any]: + response = client.post( + XAI_OAUTH_DEVICE_CODE_URL, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, + data={ + "client_id": XAI_OAUTH_CLIENT_ID, + "scope": scope, + }, + ) + if response.status_code != 200: + raise AuthError( + f"xAI device-code request failed (HTTP {response.status_code})." + + (f" Response: {response.text.strip()}" if response.text else ""), + provider="xai-oauth", + code="device_code_request_failed", + ) + payload = response.json() + required = ( + "device_code", + "user_code", + "verification_uri", + "verification_uri_complete", + "expires_in", + "interval", + ) + missing = [key for key in required if key not in payload] + if missing: + raise AuthError( + f"xAI device-code response missing fields: {', '.join(missing)}", + provider="xai-oauth", + code="device_code_invalid", + ) + return payload -def _xai_oauth_exchange_code_for_tokens( +def _xai_oauth_poll_device_token( + client: httpx.Client, *, token_endpoint: str, - code: str, - redirect_uri: str, - code_verifier: str, - code_challenge: str, - timeout_seconds: float = 20.0, + device_code: str, + expires_in: int, + poll_interval: int, ) -> Dict[str, Any]: - """POST the authorization code to xAI's token endpoint and return - the parsed JSON payload. - - Sends ``code_verifier`` as required by RFC 7636 §4.5. Also echoes - ``code_challenge`` + ``code_challenge_method`` in the request body - as a defense-in-depth measure for OAuth servers (xAI's among them, - per #26990) that re-validate the challenge at the token step - instead of relying solely on server-side session state captured - during the authorize step. Echoing the challenge is harmless for - strict RFC-compliant servers — RFC 7636 doesn't forbid additional - parameters at the token endpoint — and decisively fixes the - ``code_challenge is required`` failure mode users hit on the - loopback flow. - - Raises :class:`AuthError` on any non-2xx response or transport - failure; the error message embeds the HTTP status code and the - full response body so users can disambiguate cause at a glance. - """ - # Paranoia: if upstream call sites ever drop ``code_verifier`` we - # want to surface a precise, local error rather than send a - # missing-PKCE request to xAI and receive their generic "code - # challenge required" message back. - if not code_verifier: - raise AuthError( - "xAI token exchange refused locally: PKCE code_verifier is empty. " - "This is a bug in Hermes — please report at " - "https://github.com/NousResearch/hermes-agent/issues/26990.", - provider="xai-oauth", - code="xai_pkce_verifier_missing", - ) - - data = { - "grant_type": "authorization_code", - "code": code, - "redirect_uri": redirect_uri, - "client_id": XAI_OAUTH_CLIENT_ID, - "code_verifier": code_verifier, - } - # Defense-in-depth: include the original ``code_challenge`` and - # ``code_challenge_method``. Some OAuth servers (including xAI's - # auth.x.ai implementation, per the symptom reported in #26990) - # validate these at the token endpoint instead of relying purely on - # state captured during the authorize step — without them, xAI - # rejects the exchange with ``code_challenge is required`` even - # though we sent a valid ``code_verifier``. - if code_challenge: - data["code_challenge"] = code_challenge - data["code_challenge_method"] = "S256" - - try: - response = httpx.post( + deadline = time.monotonic() + max(1, int(expires_in)) + current_interval = max(1, int(poll_interval)) + while time.monotonic() < deadline: + response = client.post( token_endpoint, headers={ "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", }, - data=data, - timeout=max(20.0, timeout_seconds), + data={ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "client_id": XAI_OAUTH_CLIENT_ID, + "device_code": device_code, + }, ) - except Exception as exc: - raise AuthError( - f"xAI token exchange failed: {exc}", - provider="xai-oauth", - code="xai_token_exchange_failed", - ) from exc + if response.status_code == 200: + payload = response.json() + if not payload.get("access_token"): + raise AuthError( + "xAI device-code token response did not include an access_token.", + provider="xai-oauth", + code="xai_device_token_invalid", + ) + if not payload.get("refresh_token"): + raise AuthError( + "xAI device-code token response did not include a refresh_token.", + provider="xai-oauth", + code="xai_device_token_invalid", + ) + return payload - if response.status_code != 200: - body = response.text.strip() - # See ``refresh_xai_oauth_pure`` — token-exchange 403 also - # surfaces tier/entitlement gating from xAI's backend. Avoid - # the misleading "re-authenticate" hint and point at the API - # key fallback. See #26847. - if response.status_code == 403: + try: + error_payload = response.json() + except Exception: + response.raise_for_status() raise AuthError( - f"xAI token exchange failed (HTTP 403)." - + (f" Response: {body}" if body else "") - + " This OAuth account is not authorized for xAI API" - " access — xAI may be restricting API/OAuth use to" - " specific SuperGrok tiers despite the in-app" - " subscription being active. Set ``XAI_API_KEY``" - " and switch to ``provider: xai`` (API-key path) if" - " available, or upgrade your subscription at" - " https://x.ai/grok.", + "xAI device-code token polling returned a non-JSON error response.", provider="xai-oauth", - code="xai_oauth_tier_denied", - relogin_required=False, + code="xai_device_token_failed", ) - raise AuthError( - f"xAI token exchange failed (HTTP {response.status_code})." - + (f" Response: {body}" if body else ""), - provider="xai-oauth", - code="xai_token_exchange_failed", + error_code = str(error_payload.get("error") or "") + if error_code == "authorization_pending": + time.sleep(current_interval) + continue + if error_code == "slow_down": + current_interval = min(current_interval + 1, 30) + time.sleep(current_interval) + continue + description = ( + error_payload.get("error_description") + or error_payload.get("error") + or response.text ) - - try: - payload = response.json() - except Exception as exc: raise AuthError( - f"xAI token exchange returned invalid JSON: {exc}", + f"xAI device-code token polling failed: {description}", provider="xai-oauth", - code="xai_token_exchange_invalid", - ) from exc - if not isinstance(payload, dict): - raise AuthError( - "xAI token exchange response was not a JSON object.", - provider="xai-oauth", - code="xai_token_exchange_invalid", + code="xai_device_token_failed", ) - return payload + raise AuthError( + "Timed out waiting for xAI device authorization.", + provider="xai-oauth", + code="device_code_timeout", + ) -def _xai_oauth_loopback_login( +def _xai_oauth_device_code_login( *, timeout_seconds: float = 20.0, open_browser: bool = True, - manual_paste: bool = False, ) -> Dict[str, Any]: - """Run the xAI OAuth PKCE flow. - - When ``manual_paste=True`` the loopback HTTP listener is skipped - entirely and the user is prompted to paste the failed callback - URL into stdin (regression fix for #26923 — browser-only remote - consoles like GCP Cloud Shell / GitHub Codespaces / EC2 Instance - Connect, where the laptop's browser can't reach 127.0.0.1 on the - remote VM). The same PKCE verifier, ``state``, and ``nonce`` are - used for both paths so the upstream-side OAuth flow is identical. - """ - def _stdin_supports_manual_paste() -> bool: - try: - return bool(getattr(sys.stdin, "isatty", lambda: False)()) - except Exception: - return False - discovery = _xai_oauth_discovery(timeout_seconds) - authorization_endpoint = discovery["authorization_endpoint"] token_endpoint = discovery["token_endpoint"] - - allow_missing_state = False - if manual_paste: - # No HTTP listener — synthesize a redirect_uri matching what - # the server would have bound to so the authorize URL the user - # opens (and the redirect_uri sent in the token exchange) stay - # byte-identical to the loopback path. xAI's token endpoint - # cross-checks redirect_uri against the authorize request. - redirect_uri = ( - f"http://{XAI_OAUTH_REDIRECT_HOST}:{XAI_OAUTH_REDIRECT_PORT}" - f"{XAI_OAUTH_REDIRECT_PATH}" - ) - _xai_validate_loopback_redirect_uri(redirect_uri) - code_verifier = _oauth_pkce_code_verifier() - code_challenge = _oauth_pkce_code_challenge(code_verifier) - state = uuid.uuid4().hex - nonce = uuid.uuid4().hex - authorize_url = _xai_oauth_build_authorize_url( - authorization_endpoint=authorization_endpoint, - redirect_uri=redirect_uri, - code_challenge=code_challenge, - state=state, - nonce=nonce, + timeout = httpx.Timeout(max(20.0, timeout_seconds)) + with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}) as client: + device_data = _xai_oauth_request_device_code(client) + verification_url = str( + device_data.get("verification_uri_complete") + or device_data["verification_uri"] ) + user_code = str(device_data["user_code"]) + expires_in = int(device_data["expires_in"]) + interval = int(device_data["interval"]) - print("Open this URL to authorize Hermes with xAI:") - print(authorize_url) - callback = _prompt_manual_callback_paste(redirect_uri) - allow_missing_state = True - else: - server, thread, callback_result, redirect_uri = _xai_start_callback_server() - try: - _xai_validate_loopback_redirect_uri(redirect_uri) - code_verifier = _oauth_pkce_code_verifier() - code_challenge = _oauth_pkce_code_challenge(code_verifier) - state = uuid.uuid4().hex - nonce = uuid.uuid4().hex - authorize_url = _xai_oauth_build_authorize_url( - authorization_endpoint=authorization_endpoint, - redirect_uri=redirect_uri, - code_challenge=code_challenge, - state=state, - nonce=nonce, - ) - - print("Open this URL to authorize Hermes with xAI:") - print(authorize_url) - print() - print(f"Waiting for callback on {redirect_uri}") - - _print_loopback_ssh_hint(redirect_uri, docs_url=XAI_OAUTH_DOCS_URL) - - if open_browser and not _is_remote_session() and _can_open_graphical_browser(): - try: - opened = webbrowser.open(authorize_url) - except Exception: - opened = False - if opened: - print("Browser opened for xAI authorization.") - else: - print("Could not open the browser automatically; use the URL above.") - + print() + print("To continue:") + print(f" 1. Open: {verification_url}") + print(f" 2. If prompted, enter code: {user_code}") + if open_browser and not _is_remote_session() and _can_open_graphical_browser(): try: - callback = _xai_wait_for_callback( - server, - thread, - callback_result, - timeout_seconds=max(30.0, timeout_seconds * 9), - manual_paste_redirect_uri=redirect_uri, - ) - except AuthError as exc: - if ( - getattr(exc, "code", "") != "xai_callback_timeout" - or not _stdin_supports_manual_paste() - ): - raise - print() - print("xAI loopback callback timed out.") - print("If your browser reached a failed 127.0.0.1 callback page,") - print("paste that FULL callback URL below to continue this login.") - print("You can also re-run with `--manual-paste` to skip the") - print("loopback listener from the start.") - callback = _prompt_manual_callback_paste(redirect_uri) - if callback.get("code") is None and callback.get("error") is None: - raise exc - allow_missing_state = True - except Exception: - try: - server.shutdown() - server.server_close() + opened = webbrowser.open(verification_url) except Exception: - pass - try: - thread.join(timeout=1.0) - except Exception: - pass - raise + opened = False + if opened: + print(" (Opened browser for verification)") + else: + print(" Could not open browser automatically -- use the URL above.") + print(f"Waiting for approval (polling every {max(1, interval)}s)...") - if callback.get("error"): - detail = callback.get("error_description") or callback["error"] - raise AuthError( - f"xAI authorization failed: {detail}", - provider="xai-oauth", - code="xai_authorization_failed", - ) - callback_state = callback.get("state") - # Manual bare-code paths: when a user pastes only the opaque - # authorization code (no ``code=``/``state=`` query parameters), - # ``_parse_pasted_callback`` returns ``state=None``. xAI's consent - # page renders the code in-page rather than redirecting through the - # 127.0.0.1 callback, so on many remote setups (Cloud Shell, headless - # VPS, container consoles) the bare code is the only thing the user - # can obtain. PKCE (code_verifier) still binds the exchange to this - # client, so the local state-equality check is redundant on the - # bare-code paths — we substitute the locally generated state to keep - # the rest of the validation chain (and the token exchange) unchanged. - # See #26923 (AccursedGalaxy comment, 2026-05-20). - if callback.get("_manual_paste"): - allow_missing_state = True - if callback_state is None and (manual_paste or allow_missing_state): - callback_state = state - if callback_state != state: - raise AuthError( - "xAI authorization failed: state mismatch.", - provider="xai-oauth", - code="xai_state_mismatch", - ) - code = str(callback.get("code") or "").strip() - if not code: - raise AuthError( - "xAI authorization failed: missing authorization code.", - provider="xai-oauth", - code="xai_code_missing", + payload = _xai_oauth_poll_device_token( + client, + token_endpoint=token_endpoint, + device_code=str(device_data["device_code"]), + expires_in=expires_in, + poll_interval=interval, ) - payload = _xai_oauth_exchange_code_for_tokens( - token_endpoint=token_endpoint, - code=code, - redirect_uri=redirect_uri, - code_verifier=code_verifier, - code_challenge=code_challenge, - timeout_seconds=timeout_seconds, - ) access_token = str(payload.get("access_token", "") or "").strip() refresh_token = str(payload.get("refresh_token", "") or "").strip() - if not access_token: + if not access_token or not refresh_token: raise AuthError( - "xAI token exchange did not return an access_token.", + "xAI device-code token response was missing required tokens.", provider="xai-oauth", - code="xai_token_exchange_invalid", + code="xai_device_token_invalid", ) - if not refresh_token: - raise AuthError( - "xAI token exchange did not return a refresh_token.", - provider="xai-oauth", - code="xai_token_exchange_invalid", - ) - base_url = _xai_validate_inference_base_url( os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/") or os.getenv("XAI_BASE_URL", "").strip().rstrip("/"), @@ -7455,10 +7021,10 @@ def _xai_oauth_loopback_login( "token_type": str(payload.get("token_type") or "Bearer").strip() or "Bearer", }, "discovery": discovery, - "redirect_uri": redirect_uri, + "redirect_uri": "", "base_url": base_url, "last_refresh": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), - "source": "oauth-loopback", + "source": "oauth-device-code", } diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index decf30dea0f..70a346e3e6e 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -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) ) diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py index 46ec15ffb6e..312677dabc4 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -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), ) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index e09a410fb3b..9c3c7c1dcd3 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -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) diff --git a/hermes_cli/subcommands/auth.py b/hermes_cli/subcommands/auth.py index a087937cb93..e81fcea8c10 100644 --- a/hermes_cli/subcommands/auth.py +++ b/hermes_cli/subcommands/auth.py @@ -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" ) diff --git a/hermes_cli/subcommands/model.py b/hermes_cli/subcommands/model.py index 37567e39533..11b9676f91a 100644 --- a/hermes_cli/subcommands/model.py +++ b/hermes_cli/subcommands/model.py @@ -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, diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index ae53511d41b..6a6f026c749 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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} diff --git a/run_agent.py b/run_agent.py index 7d4afad9aa4..aaafd469a80 100644 --- a/run_agent.py +++ b/run_agent.py @@ -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 diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index 461fd243a45..d9252a7829c 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -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 diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index 9289da8db63..f2e65dd6cd4 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -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" diff --git a/tests/hermes_cli/test_auth_loopback_ssh_hint.py b/tests/hermes_cli/test_auth_loopback_ssh_hint.py deleted file mode 100644 index 4525e89fcfc..00000000000 --- a/tests/hermes_cli/test_auth_loopback_ssh_hint.py +++ /dev/null @@ -1,148 +0,0 @@ -"""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. -""" - -from __future__ import annotations - -import io -import contextlib -import socket - - -from hermes_cli import auth as auth_mod - - -def _cap(fn): - buf = io.StringIO() - with contextlib.redirect_stdout(buf): - fn() - return buf.getvalue() - - -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 - )) - assert out == "" - - -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 - )) - # 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 - # Must include the provider-specific docs URL - assert auth_mod.XAI_OAUTH_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.""" - 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 - )) - assert "ssh -N -L 51234:127.0.0.1:51234" in out - assert "56121" not in out - - -def test_loopback_ssh_hint_silent_for_non_loopback_uri(monkeypatch): - """Defense in depth: if a future caller passes a non-loopback redirect URI - 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 - )) - assert out == "" - - -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 - )) - assert out == "" - - -def test_loopback_ssh_hint_works_without_provider_docs_url(monkeypatch): - monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) - out = _cap(lambda: auth_mod._print_loopback_ssh_hint( - "http://127.0.0.1:43827/spotify/callback" - )) - assert "ssh -N -L 43827:127.0.0.1:43827" in out - # Generic SSH guide is always present even without a provider-specific URL - assert auth_mod.OAUTH_OVER_SSH_DOCS_URL in out - # Should not falsely show "Provider docs:" when no docs_url was passed - assert "Provider docs:" not in out - - -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.""" - monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) - out = _cap(lambda: auth_mod._print_loopback_ssh_hint( - "http://localhost:56121/callback" - )) - assert "ssh -N -L 56121:127.0.0.1:56121" in out - - -def test_loopback_ssh_hint_includes_user_at_host(monkeypatch): - """The SSH command should include a detected user@host so the user can - copy-paste it without manually substituting placeholders.""" - 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" - )) - assert "ssh -N -L 56121:127.0.0.1:56121 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" - )) - assert "Remote session detected" in out - assert "---" in out # divider is present - - -class TestSshUserAtHost: - def test_resolves_user_and_hostname(self, monkeypatch): - monkeypatch.setenv("USER", "alice") - monkeypatch.delenv("LOGNAME", raising=False) - monkeypatch.setattr(socket, "gethostname", lambda: "myserver") - assert auth_mod._ssh_user_at_host() == "alice@myserver" - - def test_falls_back_to_logname(self, monkeypatch): - monkeypatch.delenv("USER", raising=False) - monkeypatch.setenv("LOGNAME", "bob") - monkeypatch.setattr(socket, "gethostname", lambda: "host1") - assert auth_mod._ssh_user_at_host() == "bob@host1" - - def test_placeholder_when_no_env_vars(self, monkeypatch): - monkeypatch.delenv("USER", raising=False) - monkeypatch.delenv("LOGNAME", raising=False) - monkeypatch.setattr(socket, "gethostname", lambda: "host1") - assert auth_mod._ssh_user_at_host() == "@host1" - - def test_placeholder_when_socket_raises(self, monkeypatch): - monkeypatch.setenv("USER", "charlie") - def _raise(): - raise OSError("no network") - monkeypatch.setattr(socket, "gethostname", _raise) - assert auth_mod._ssh_user_at_host() == "charlie@" - - def test_placeholder_when_empty_hostname(self, monkeypatch): - monkeypatch.setenv("USER", "dave") - monkeypatch.setattr(socket, "gethostname", lambda: "") - assert auth_mod._ssh_user_at_host() == "dave@" diff --git a/tests/hermes_cli/test_auth_manual_paste.py b/tests/hermes_cli/test_auth_manual_paste.py deleted file mode 100644 index 81d1c6ce17a..00000000000 --- a/tests/hermes_cli/test_auth_manual_paste.py +++ /dev/null @@ -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 diff --git a/tests/hermes_cli/test_auth_xai_oauth_provider.py b/tests/hermes_cli/test_auth_xai_oauth_provider.py index 32c9437337f..eae404c28c0 100644 --- a/tests/hermes_cli/test_auth_xai_oauth_provider.py +++ b/tests/hermes_cli/test_auth_xai_oauth_provider.py @@ -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:/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 ` 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``.""" diff --git a/tests/hermes_cli/test_web_oauth_dispatch.py b/tests/hermes_cli/test_web_oauth_dispatch.py index f478a5b5967..f8ee073b138 100644 --- a/tests/hermes_cli/test_web_oauth_dispatch.py +++ b/tests/hermes_cli/test_web_oauth_dispatch.py @@ -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. diff --git a/tests/hermes_cli/test_xai_model_flow.py b/tests/hermes_cli/test_xai_model_flow.py index 51d12909f17..9cb3aba5968 100644 --- a/tests/hermes_cli/test_xai_model_flow.py +++ b/tests/hermes_cli/test_xai_model_flow.py @@ -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 diff --git a/tests/hermes_cli/test_xai_oauth_pkce_token_exchange.py b/tests/hermes_cli/test_xai_oauth_pkce_token_exchange.py deleted file mode 100644 index 98b81ff140e..00000000000 --- a/tests/hermes_cli/test_xai_oauth_pkce_token_exchange.py +++ /dev/null @@ -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"] diff --git a/tests/hermes_cli/test_xai_oauth_refresh.py b/tests/hermes_cli/test_xai_oauth_refresh.py index e954778a5ac..10625d8de3c 100644 --- a/tests/hermes_cli/test_xai_oauth_refresh.py +++ b/tests/hermes_cli/test_xai_oauth_refresh.py @@ -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 diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index 825150b9c02..1d355c65543 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -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. diff --git a/website/docs/guides/oauth-over-ssh.md b/website/docs/guides/oauth-over-ssh.md index 22ee2f5f6d4..c904524f528 100644 --- a/website/docs/guides/oauth-over-ssh.md +++ b/website/docs/guides/oauth-over-ssh.md @@ -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:/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:/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 :127.0.0.1: 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:/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:/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:/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 ``` 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:/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) diff --git a/website/docs/guides/run-hermes-with-nous-portal.md b/website/docs/guides/run-hermes-with-nous-portal.md index c81e9bfa52e..d20295f169a 100644 --- a/website/docs/guides/run-hermes-with-nous-portal.md +++ b/website/docs/guides/run-hermes-with-nous-portal.md @@ -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" diff --git a/website/docs/guides/run-nemotron-3-ultra-free.md b/website/docs/guides/run-nemotron-3-ultra-free.md index f50ec0f594e..db613e79e99 100644 --- a/website/docs/guides/run-nemotron-3-ultra-free.md +++ b/website/docs/guides/run-nemotron-3-ultra-free.md @@ -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 diff --git a/website/docs/guides/xai-grok-oauth.md b/website/docs/guides/xai-grok-oauth.md index b1635fbac18..12f4e738eb6 100644 --- a/website/docs/guides/xai-grok-oauth.md +++ b/website/docs/guides/xai-grok-oauth.md @@ -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) diff --git a/website/docs/integrations/nous-portal.md b/website/docs/integrations/nous-portal.md index 1be857b350e..46a61d75936 100644 --- a/website/docs/integrations/nous-portal.md +++ b/website/docs/integrations/nous-portal.md @@ -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 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/oauth-over-ssh.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/oauth-over-ssh.md index 2ab6efb49ca..63c2fd3a6ef 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/oauth-over-ssh.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/oauth-over-ssh.md @@ -1,55 +1,40 @@ --- sidebar_position: 17 title: "SSH / 远程主机上的 OAuth" -description: "当 Hermes 运行在远程机器、容器或跳板机后面时,如何完成基于浏览器的 OAuth(xAI、Spotify)" +description: "当 Hermes 运行在远程机器、容器或跳板机后面时,如何完成基于浏览器的 OAuth(Spotify、MCP 服务器)" --- # SSH / 远程主机上的 OAuth -部分 Hermes 提供商——目前是 **xAI Grok OAuth** 和 **Spotify**——使用*回环重定向(loopback redirect)* OAuth 流程。认证服务器(xAI、Spotify)将浏览器重定向到 `http://127.0.0.1:/callback`,由 `hermes auth ...` 命令启动的一个小型 HTTP 监听器来获取授权码。 +部分 Hermes 提供商——**Spotify** 和 **远程 MCP 服务器**(Linear、Sentry、Atlassian、Asana、Figma 等)——使用*回环重定向(loopback redirect)* OAuth 流程。认证服务器将浏览器重定向到 `http://127.0.0.1:/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 设备代码**,不是回环回调——在任意浏览器中打开打印的验证 URL,Hermes 轮询直到批准即可,无需 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:/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:/callback` 这一行。 +Hermes 检测到 SSH 会话,跳过自动打开浏览器,并打印授权 URL 以及 `Waiting for callback on http://127.0.0.1:/callback`。 ### 3. 在本地浏览器中打开 URL -从远程终端复制授权 URL,粘贴到笔记本的浏览器中。批准同意页面。认证服务器重定向到 `http://127.0.0.1:/callback`。浏览器访问隧道,请求被转发到远程监听器,Hermes 打印 `Login successful!`。 +从远程终端复制授权 URL,粘贴到笔记本的浏览器中。批准同意后,认证服务器重定向到 `http://127.0.0.1:/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` 里运行 Hermes,mosh 的漫游不会携带 `-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 -``` - -然后重试 `ssh -L` 命令。 - -### "Could not establish connection. We couldn't reach your app."(xAI) - -当 xAI 重定向到 `127.0.0.1:/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` / ProxyJump(man 手册)](https://man.openbsd.org/ssh#J) \ No newline at end of file +- [xAI Grok OAuth](./xai-grok-oauth.md)——设备代码;无需 SSH 隧道 +- [Spotify(SSH 上运行)](../user-guide/features/spotify.md#running-over-ssh--in-a-headless-environment) +- [原生 MCP 客户端(OAuth 部分)](../user-guide/features/mcp.md#oauth-authenticated-http-servers) diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/run-hermes-with-nous-portal.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/run-hermes-with-nous-portal.md index e5625b4326c..8739d0fa3fb 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/run-hermes-with-nous-portal.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/run-hermes-with-nous-portal.md @@ -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" diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/xai-grok-oauth.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/xai-grok-oauth.md index 8cc02ce1fcb..c205c23ff8a 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/xai-grok-oauth.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/xai-grok-oauth.md @@ -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 重定向回 Hermes,token 保存到 `~/.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)。 +回环重定向类 provider(Spotify、MCP 服务器)请参阅 [OAuth over SSH / Remote Hosts](./oauth-over-ssh.md)。 ### 登录成功后 HTTP 403(套餐 / 权限问题) diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/nous-portal.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/nous-portal.md index 8e66915a026..265abb4aed1 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/nous-portal.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/nous-portal.md @@ -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 配置 From 254328bf56d0f5c249a0b756bc1ff3b66aab7071 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:19:56 -0700 Subject: [PATCH 16/34] fix(auth): remove stale loopback_pkce reference in xAI quarantine removal list The terminal-refresh quarantine filtered in-memory entries on source == "device_code" but built removed_ids from the deleted "loopback_pkce" source name, so the revoked device-code entry was never pruned from the persisted pool in auth.json. Also restores the _print_loopback_ssh_hint test suite scoped to Spotify (the helper's remaining caller) instead of deleting it wholesale. --- agent/credential_pool.py | 2 +- .../hermes_cli/test_auth_loopback_ssh_hint.py | 150 ++++++++++++++++++ 2 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_auth_loopback_ssh_hint.py diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 1de7390ea19..2c7a4825e8d 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -1171,7 +1171,7 @@ 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 diff --git a/tests/hermes_cli/test_auth_loopback_ssh_hint.py b/tests/hermes_cli/test_auth_loopback_ssh_hint.py new file mode 100644 index 00000000000..a072c679a55 --- /dev/null +++ b/tests/hermes_cli/test_auth_loopback_ssh_hint.py @@ -0,0 +1,150 @@ +"""Unit tests for _print_loopback_ssh_hint() in hermes_cli/auth.py. + +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 + +import io +import contextlib +import socket + + +from hermes_cli import auth as auth_mod + + +def _cap(fn): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + fn() + return buf.getvalue() + + +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:43827/spotify/callback", docs_url=auth_mod.SPOTIFY_DOCS_URL + )) + assert out == "" + + +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:43827/spotify/callback", docs_url=auth_mod.SPOTIFY_DOCS_URL + )) + assert "ssh -N -L 43827:127.0.0.1:43827" in out + # Must include the provider-specific docs URL + 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, 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.SPOTIFY_DOCS_URL + )) + assert "ssh -N -L 51234:127.0.0.1:51234" in out + assert "43827" not in out + + +def test_loopback_ssh_hint_silent_for_non_loopback_uri(monkeypatch): + """Defense in depth: if a future caller passes a non-loopback redirect URI + 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.SPOTIFY_DOCS_URL + )) + assert out == "" + + +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.SPOTIFY_DOCS_URL + )) + assert out == "" + + +def test_loopback_ssh_hint_works_without_provider_docs_url(monkeypatch): + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) + out = _cap(lambda: auth_mod._print_loopback_ssh_hint( + "http://127.0.0.1:43827/spotify/callback" + )) + assert "ssh -N -L 43827:127.0.0.1:43827" in out + # Generic SSH guide is always present even without a provider-specific URL + assert auth_mod.OAUTH_OVER_SSH_DOCS_URL in out + # Should not falsely show "Provider docs:" when no docs_url was passed + assert "Provider docs:" not in out + + +def test_loopback_ssh_hint_accepts_localhost_hostname(monkeypatch): + """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:43827/callback" + )) + assert "ssh -N -L 43827:127.0.0.1:43827" in out + + +def test_loopback_ssh_hint_includes_user_at_host(monkeypatch): + """The SSH command should include a detected user@host so the user can + copy-paste it without manually substituting placeholders.""" + 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:43827/callback" + )) + 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:43827/callback" + )) + assert "Remote session detected" in out + assert "---" in out # divider is present + + +class TestSshUserAtHost: + def test_resolves_user_and_hostname(self, monkeypatch): + monkeypatch.setenv("USER", "alice") + monkeypatch.delenv("LOGNAME", raising=False) + monkeypatch.setattr(socket, "gethostname", lambda: "myserver") + assert auth_mod._ssh_user_at_host() == "alice@myserver" + + def test_falls_back_to_logname(self, monkeypatch): + monkeypatch.delenv("USER", raising=False) + monkeypatch.setenv("LOGNAME", "bob") + monkeypatch.setattr(socket, "gethostname", lambda: "host1") + assert auth_mod._ssh_user_at_host() == "bob@host1" + + def test_placeholder_when_no_env_vars(self, monkeypatch): + monkeypatch.delenv("USER", raising=False) + monkeypatch.delenv("LOGNAME", raising=False) + monkeypatch.setattr(socket, "gethostname", lambda: "host1") + assert auth_mod._ssh_user_at_host() == "@host1" + + def test_placeholder_when_socket_raises(self, monkeypatch): + monkeypatch.setenv("USER", "charlie") + def _raise(): + raise OSError("no network") + monkeypatch.setattr(socket, "gethostname", _raise) + assert auth_mod._ssh_user_at_host() == "charlie@" + + def test_placeholder_when_empty_hostname(self, monkeypatch): + monkeypatch.setenv("USER", "dave") + monkeypatch.setattr(socket, "gethostname", lambda: "") + assert auth_mod._ssh_user_at_host() == "dave@" From c5e8a60b0aeeb8125ffe2dcd4c4cdf6e782b5ba9 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Thu, 25 Jun 2026 11:54:23 +0800 Subject: [PATCH 17/34] fix(desktop): skip ensureBackend after profile-delete teardown to prevent respawn loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the renderer sends a DELETE /api/profiles/{name} request, the IPC handler tears down the profile's pool backend (or primary backend) via prepareProfileDeleteRequest. However, the very next line calls ensureBackend(profile), which spawns a fresh pool backend for the just- deleted profile. The new backend's startup path calls ensure_hermes_home(), which recreates the profile directory — defeating the deletion and leaving the process as a zombie. On the next Desktop restart the cycle repeats: the profile directory exists, the Desktop spawns a backend, the backend recreates the directory after deletion, and PIDs accumulate indefinitely. Fix: make prepareProfileDeleteRequest return the torn-down profile name. The IPC handler uses this to route the DELETE to the primary backend instead of spawning a new pool backend for the deleted profile. Fixes #52279 --- apps/desktop/electron/main.cjs | 18 +++-- .../electron/profile-delete-respawn.test.cjs | 66 +++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/electron/profile-delete-respawn.test.cjs diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index a6b70872632..a20c85387b8 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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(), diff --git a/apps/desktop/electron/profile-delete-respawn.test.cjs b/apps/desktop/electron/profile-delete-respawn.test.cjs new file mode 100644 index 00000000000..a982072bd57 --- /dev/null +++ b/apps/desktop/electron/profile-delete-respawn.test.cjs @@ -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' + ) +}) From c3f06a8fda6051cea05c0a5301b353a65db7cd29 Mon Sep 17 00:00:00 2001 From: Tranquil-Flow <66773372+Tranquil-Flow@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:08:37 +0200 Subject: [PATCH 18/34] fix(desktop): refresh profile rail after deletion (#49289) --- apps/desktop/src/app/profiles/index.tsx | 5 ++-- apps/desktop/src/store/profile.test.ts | 35 ++++++++++++++++++++++++- apps/desktop/src/store/profile.ts | 10 +++++-- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/profiles/index.tsx b/apps/desktop/src/app/profiles/index.tsx index 3e44f7fd912..df5b58751a8 100644 --- a/apps/desktop/src/app/profiles/index.tsx +++ b/apps/desktop/src/app/profiles/index.tsx @@ -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)) { diff --git a/apps/desktop/src/store/profile.test.ts b/apps/desktop/src/store/profile.test.ts index 14edeb5c050..1306139151f 100644 --- a/apps/desktop/src/store/profile.test.ts +++ b/apps/desktop/src/store/profile.test.ts @@ -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 => ({ 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']) + }) +}) diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index 2ff6987c0dc..8c13c10669d 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -38,6 +38,13 @@ export function setActiveProfile(name: string): void { $activeProfile.set(name || 'default') } +export async function refreshProfiles(): Promise { + 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 { } try { - const { profiles } = await getProfiles() - $profiles.set(profiles) + await refreshProfiles() } catch { // Leave the cached list in place. } From 5a6720b884eb9ab373da8986a0b7ddb571e312e7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 2 Jul 2026 15:18:05 -0500 Subject: [PATCH 19/34] fix(desktop,tui-gateway,zai): stop thinking-off from reverting to medium MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Z.ai desktop user reported thinking reverting to medium after one turn, burning ~200% of a week's credits in 4 days despite reasoning_effort: false in config.yaml. Four compounding bugs: - _session_info reported reasoning_effort "" for disabled reasoning, indistinguishable from unset — the desktop adopted it after the first turn, wiping its sticky "thinking off" pick so every later chat reverted to the default effort. - config.set key=reasoning always wrote agent.reasoning_effort to global config.yaml, so every desktop model-menu selection (preset.effort ?? 'medium') clobbered the user's configured value. Now session-scoped like the messaging gateway's /reasoning, landing on create_reasoning_override so lazily-built sessions keep it too. - YAML `reasoning_effort: false`/`off`/`no` (boolean False) was coerced to "" by every loader's `str(x or "")`, silently re-enabling thinking. parse_reasoning_effort now treats False/"false"/"disabled" as {"enabled": False}; loaders (tui gateway, gateway, cli, cron, delegate) pass the raw value through. The desktop config reader also crashed on the boolean (false.trim()), aborting voice/STT settings. - The zai provider profile never sent thinking on the wire, and GLM-4.5+ defaults to thinking ON server-side — so disabling reasoning was a silent no-op on direct Z.ai, the actual token burner. The profile now emits extra_body.thinking {"type": "enabled"|"disabled"} for thinking-capable GLM models, mirroring the DeepSeek profile. Also: /new (session reset) now carries reasoning_config across the rebuild like model_override; config.get reasoning prefers the session's live value and maps a config False to "none"; Settings shows "Off" instead of a blank select for hand-written false. --- .../app/session/hooks/use-hermes-config.ts | 19 ++- .../src/app/settings/model-settings.tsx | 10 +- cli.py | 10 +- cron/scheduler.py | 8 +- gateway/run.py | 7 +- hermes_constants.py | 16 +- plugins/model-providers/zai/__init__.py | 62 +++++++- .../model_providers/test_zai_profile.py | 141 ++++++++++++++++++ tests/test_hermes_constants.py | 12 ++ .../test_reasoning_session_scope.py | 121 +++++++++++++++ tools/delegate_tool.py | 7 +- tui_gateway/server.py | 89 +++++++---- 12 files changed, 455 insertions(+), 47 deletions(-) create mode 100644 tests/plugins/model_providers/test_zai_profile.py create mode 100644 tests/tui_gateway/test_reasoning_session_scope.py diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.ts index 16242ba71c5..fe2a42d4603 100644 --- a/apps/desktop/src/app/session/hooks/use-hermes-config.ts +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.ts @@ -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 refreshProjectBranch: (cwd: string) => Promise @@ -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)) diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index 8230519f414..6459370dc76 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -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')) diff --git a/cli.py b/cli.py index c3f438690a2..d2dbbbb0195 100644 --- a/cli.py +++ b/cli.py @@ -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 diff --git a/cron/scheduler.py b/cron/scheduler.py index 4c764bd13a4..e072fce7fd1 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -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 diff --git a/gateway/run.py b/gateway/run.py index ed257607fe8..cf6dae7d81f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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 diff --git a/hermes_constants.py b/hermes_constants.py index 526bb0ed473..c0f4d48e172 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -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": } 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} diff --git a/plugins/model-providers/zai/__init__.py b/plugins/model-providers/zai/__init__.py index 9fcdb2bec7d..7a53ec166c1 100644 --- a/plugins/model-providers/zai/__init__.py +++ b/plugins/model-providers/zai/__init__.py @@ -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"), diff --git a/tests/plugins/model_providers/test_zai_profile.py b/tests/plugins/model_providers/test_zai_profile.py new file mode 100644 index 00000000000..feb209c88dd --- /dev/null +++ b/tests/plugins/model_providers/test_zai_profile.py @@ -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", {}) diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index 8635c6827c8..f23bed43ab8 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -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.""" diff --git a/tests/tui_gateway/test_reasoning_session_scope.py b/tests/tui_gateway/test_reasoning_session_scope.py new file mode 100644 index 00000000000..0c560cd80fe --- /dev/null +++ b/tests/tui_gateway/test_reasoning_session_scope.py @@ -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 diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 2895733abe1..b3172e51acd 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -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) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 6bd1ed13ace..faa09ff18c6 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2318,10 +2318,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 +3097,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 +4061,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 +10105,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 + # ` (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 +10796,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)) From 1501a338c3f1e017f092ecd84da4d2dd49f759aa Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 2 Jul 2026 15:26:17 -0500 Subject: [PATCH 20/34] fix(cli): stop profile-bound backends before deleting so rmtree converges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit delete_profile stopped only the process named in gateway.pid, but a Desktop app spawns a headless `serve`/`dashboard` backend per profile that holds the profile's SQLite connection open and keeps writing sessions/WAL/sandbox files. That backend is never in gateway.pid, so a CLI `hermes profile delete` run while the Desktop app is up left it writing into the tree — rmtree's final rmdir then failed with ENOTEMPTY (#47368 "Bug 2"), and pre-guard it also resurrected the directory. - _profile_bound_backend_pids(): find running Hermes backends bound to this profile via a `--profile ` selector or a HERMES_HOME env resolving to the profile dir. Tightly scoped — current-user only, backend subcommands (serve/dashboard/gateway) only so an interactive chat is never killed, and never this process or its ancestors. - _stop_profile_backends(): terminate them (graceful, then force), best-effort so it can never make delete worse. - _rmtree_with_retry(): a few spaced retries absorb the ENOTEMPTY / Windows file-lock race from a just-terminated writer's in-flight -wal/-shm/sandbox writes instead of failing the whole delete on a race the next attempt wins. Complements the recreation guard (deleted profiles no longer reappear) and the Desktop teardown-before-delete flow; this is the CLI-side convergence fix for a delete run while a Desktop-managed backend is live. Part of #47368. --- hermes_cli/profiles.py | 196 +++++++++++++++++++++++++++++- tests/hermes_cli/test_profiles.py | 84 +++++++++++++ 2 files changed, 275 insertions(+), 5 deletions(-) diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 7f7f3b262e5..5e64e768bbb 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -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 `` / ``-p `` 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 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}") diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index 608a10eabbf..91a13dd761a 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -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 From 67472fbaa459e360291ee991dd4cb4496b7d34ba Mon Sep 17 00:00:00 2001 From: Yingliang Zhang Date: Wed, 1 Jul 2026 13:41:31 +0800 Subject: [PATCH 21/34] fix(tui_gateway): route setup.runtime_check and setup.status to RPC pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup.runtime_check and setup.status are polled by the Desktop frontend on connect and periodically (use-status-snapshot → evaluateRuntimeReadiness), but neither was in _LONG_HANDLERS — so dispatch() ran both inline on the WS reader thread. Under GIL pressure from concurrent agent turns (terminal I/O, large output, background-process completions) either can block for seconds: - setup.runtime_check → resolve_runtime_provider() (config read, auth check, may probe the provider endpoint) - setup.status → _has_any_provider_configured() (provider config + credential scan) While either blocks the reader thread the WS read loop can't service later requests; the frontend RPC timeout fires, the client drops the socket, and the lost setup.runtime_check response reads as ready=false — a false "needs setup" / "Settings failed to load" even though the provider is configured. Route both to the RPC pool (same precedent as #55545's session.list/pet.info/ process.list). The handlers are read-only and pool writes go through the lock-guarded write_json, so there's no ordering or safety concern. Test asserts all 5 frontend-polled RPCs are pool-routed. Co-authored-by: izumi0uu --- tests/tui_gateway/test_inline_rpc_gil_starvation.py | 8 +++++--- tui_gateway/server.py | 10 ++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/tui_gateway/test_inline_rpc_gil_starvation.py b/tests/tui_gateway/test_inline_rpc_gil_starvation.py index e9a01a76c58..80244b71a73 100644 --- a/tests/tui_gateway/test_inline_rpc_gil_starvation.py +++ b/tests/tui_gateway/test_inline_rpc_gil_starvation.py @@ -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 ] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index faa09ff18c6..944d5273fd9 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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", From ab942330fc627e931577bc7c68ef0ec086e810e4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 2 Jul 2026 15:39:01 -0500 Subject: [PATCH 22/34] chore(release): map yingliang-zhang in AUTHOR_MAP for #57335 --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 5f676add6aa..9f585ea8bff 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -175,6 +175,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", From 3a122ba4acaabec5768ceddb46da82e43c382d7c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:52:42 -0700 Subject: [PATCH 23/34] fix(usage): capture reasoning_tokens from completion_tokens_details on chat_completions (#57340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalize_usage only read output_tokens_details.reasoning_tokens (the Responses API shape). Chat Completions providers — OpenAI, OpenRouter, DeepSeek, and every OpenAI-compatible proxy — report it under completion_tokens_details.reasoning_tokens, so reasoning_tokens was 0 for every chat_completions reasoning model: hidden thinking was invisible in session accounting, MoA traces, and the eval's per-task token columns. Measured impact (HermesBench MoA run on deepseek-v4-flash, 4,828 advisor calls): reasoning_tokens showed 0 everywhere while individual calls burned up to 21.5K hidden thinking tokens to emit ~500 visible tokens. Verified live against OpenRouter: deepseek-v4-flash returns completion_tokens_details.reasoning_tokens=61 for a 74-completion-token call; the field was simply never read. Responses-shape reads are unchanged; the new read only fires when the Responses shape yielded nothing. --- agent/usage_pricing.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index 32338caff80..d7b56a9fac4 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -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, From d3c8a155cbfd265fd50d0fe126dfd368ea0ba5f6 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Thu, 2 Jul 2026 21:48:53 +0800 Subject: [PATCH 24/34] fix(slack): keep blank-line-separated ordered items in one rich_text_list When a Markdown ordered list has blank lines between items (common in LLM-authored content), the list run loop breaks on each blank line. Slack numbers each rich_text_list independently, so N items produce N lists each starting at 1. Skip blank lines inside the list run as soft separators instead of breaking, so ordered items stay in one rich_text_list and Slack renders the correct numbering. Fixes #57076 --- plugins/platforms/slack/block_kit.py | 4 ++++ tests/gateway/test_slack_block_kit.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/plugins/platforms/slack/block_kit.py b/plugins/platforms/slack/block_kit.py index 01b048f93a5..ac105520097 100644 --- a/plugins/platforms/slack/block_kit.py +++ b/plugins/platforms/slack/block_kit.py @@ -451,6 +451,10 @@ def render_blocks( indent, ordered, txt = items[-1] items[-1] = (indent, ordered, txt + " " + lines[i].strip()) i += 1 + elif not lines[i].strip(): + # blank line — soft separator within a list run; + # skip so that ordered items stay in one rich_text_list. + i += 1 else: break blocks.append(_list_block(items)) diff --git a/tests/gateway/test_slack_block_kit.py b/tests/gateway/test_slack_block_kit.py index 6dc0d74c778..6d1bfad4c2f 100644 --- a/tests/gateway/test_slack_block_kit.py +++ b/tests/gateway/test_slack_block_kit.py @@ -90,6 +90,22 @@ 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 + class TestTables: def test_pipe_table_renders_native_table_block(self): From 033d7bf259c300472110424a1dd4486f51fe5290 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:39:04 +0530 Subject: [PATCH 25/34] fix(slack): guard blank-line list continuation on next-item lookahead Refine the blank-line handling so a blank line only continues a list run when the next non-blank line is another list item. This keeps a list -> paragraph -> list sequence as three separate blocks and matches the contiguous-list layout for mixed/nested lists (one rich_text block, split into sub-lists by (indent, ordered)), rather than emitting a separate block per item. Adds regression tests for the mixed blank-separated layout and the list->paragraph->list boundary. --- plugins/platforms/slack/block_kit.py | 20 ++++++++++++++++---- tests/gateway/test_slack_block_kit.py | 25 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/plugins/platforms/slack/block_kit.py b/plugins/platforms/slack/block_kit.py index ac105520097..f3bcf1b9f89 100644 --- a/plugins/platforms/slack/block_kit.py +++ b/plugins/platforms/slack/block_kit.py @@ -451,10 +451,22 @@ def render_blocks( indent, ordered, txt = items[-1] items[-1] = (indent, ordered, txt + " " + lines[i].strip()) i += 1 - elif not lines[i].strip(): - # blank line — soft separator within a list run; - # skip so that ordered items stay in one rich_text_list. - 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 (_BULLET_RE.match(lines[j]) or _ORDERED_RE.match(lines[j])): + i = j + else: + break else: break blocks.append(_list_block(items)) diff --git a/tests/gateway/test_slack_block_kit.py b/tests/gateway/test_slack_block_kit.py index 6d1bfad4c2f..2685606664a 100644 --- a/tests/gateway/test_slack_block_kit.py +++ b/tests/gateway/test_slack_block_kit.py @@ -106,6 +106,31 @@ class TestInlineFormatting: 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): From 9f60467426d71419e767786c28bdd7fe86013289 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:50:24 +0530 Subject: [PATCH 26/34] refactor(slack): extract _is_list_line helper for list-marker checks Deduplicate the '_BULLET_RE.match or _ORDERED_RE.match' idiom used at the list-run entry guard and the blank-line lookahead into a single helper, so adding future marker types is a one-point change. Pure refactor, no behavior change (22 block_kit tests still pass). --- plugins/platforms/slack/block_kit.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/plugins/platforms/slack/block_kit.py b/plugins/platforms/slack/block_kit.py index f3bcf1b9f89..dc33aacbf11 100644 --- a/plugins/platforms/slack/block_kit.py +++ b/plugins/platforms/slack/block_kit.py @@ -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: @@ -463,7 +468,7 @@ def render_blocks( j = i + 1 while j < n and not lines[j].strip(): j += 1 - if j < n and (_BULLET_RE.match(lines[j]) or _ORDERED_RE.match(lines[j])): + if j < n and _is_list_line(lines[j]): i = j else: break From 048270fa069ff6aa41c01b403ac1eeab34b29628 Mon Sep 17 00:00:00 2001 From: kchantharuan Date: Thu, 2 Jul 2026 13:56:21 -0700 Subject: [PATCH 27/34] fix: refresh NVIDIA featured models --- hermes_cli/models.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index e61cd455a90..f93ad967ae3 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -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", From 14882bab7e9ae0b89a3065beb226c89acac80a79 Mon Sep 17 00:00:00 2001 From: Gumclaw Date: Thu, 2 Jul 2026 15:48:59 -0400 Subject: [PATCH 28/34] fix(gateway): close webhook sessions on delivery completion so prune can reap them Webhook deliveries created a unique one-shot session (delivery_id baked into the session key at gateway/platforms/webhook.py:668) but the adapter fired handle_message via asyncio.create_task WITHOUT ever ending the session (webhook.py:713, pre-fix). Nothing else closes it: the gateway caches/expires the agent per session_key but never calls end_session for the webhook path, and _end_session_on_close teardown doesn't run for these fire-and-forget tasks. SessionDB.prune_sessions (hermes_state.py:4965) only deletes rows WHERE ended_at IS NOT NULL. So every webhook session stayed with ended_at NULL -> unprunable -> unbounded state.db growth. This was the primary driver of the SQLite lock-contention gateway outage. Fix: wrap the delivery in _run_delivery_and_close, which awaits handle_message and then (in finally, so failures still reap) calls _end_webhook_session -> SessionDB.end_session(session_id, 'webhook_complete'). This mirrors how cron closes its session with 'cron_complete' (cron/scheduler.py:3065). end_session is first-reason-wins and no-ops on an already-ended row, so it never clobbers a compression/agent_close reason. Adds tests/gateway/test_webhook_session_close.py asserting the invariant (a completed webhook session has ended_at set + is prunable), including the error-path case, against a real SessionStore + SessionDB. --- gateway/platforms/webhook.py | 90 +++++++++- tests/gateway/test_webhook_session_close.py | 178 ++++++++++++++++++++ 2 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 tests/gateway/test_webhook_session_close.py diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 51e93c8a7a9..bb1fefb673e 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -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,81 @@ 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) + 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 # ------------------------------------------------------------------ diff --git a/tests/gateway/test_webhook_session_close.py b/tests/gateway/test_webhook_session_close.py new file mode 100644 index 00000000000..e91f02f73d7 --- /dev/null +++ b/tests/gateway/test_webhook_session_close.py @@ -0,0 +1,178 @@ +"""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() From de67f430b23dbc02e3aa943d17385adf01f7c6de Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:09:50 +0530 Subject: [PATCH 29/34] chore: map gumclaw@gumroad.com in AUTHOR_MAP for PR #57322 salvage --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 9f585ea8bff..1e4db7ed96f 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -59,6 +59,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) From 65cb70b8d09c2ae2a87dea754c429687d69f2252 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:17:07 +0530 Subject: [PATCH 30/34] refactor(gateway): add SessionStore.peek_session_id public accessor for webhook close Replace the webhook delivery-close path's direct reach into private SessionStore._entries (which also bypassed the store lock) with a public, lock-held peek_session_id(session_key) accessor. Mirrors the existing lookup_by_session_id inverse helper. Keeps a getattr fallback for older stores / test doubles. Adds a unit test for the accessor. --- gateway/platforms/webhook.py | 25 +++++++++----- gateway/session.py | 16 +++++++++ tests/gateway/test_webhook_session_close.py | 38 +++++++++++++++++++++ 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index bb1fefb673e..8327213a056 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -776,14 +776,23 @@ class WebhookAdapter(BasePlatformAdapter): if key_fn is None: return session_key = key_fn(event.source) - 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 + # 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)", diff --git a/gateway/session.py b/gateway/session.py index fd2fae87f38..2a75aa16d7f 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -1887,6 +1887,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 key→session_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). diff --git a/tests/gateway/test_webhook_session_close.py b/tests/gateway/test_webhook_session_close.py index e91f02f73d7..804bed07bf6 100644 --- a/tests/gateway/test_webhook_session_close.py +++ b/tests/gateway/test_webhook_session_close.py @@ -176,3 +176,41 @@ async def test_webhook_session_closed_even_when_agent_run_raises(tmp_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() + From 26edfab004b6a4e09828745badcd46f04e219987 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:30:54 +0530 Subject: [PATCH 31/34] chore: add trismegistus-wanderer to AUTHOR_MAP for PR #31856 salvage --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 1e4db7ed96f..2218f3fb7d7 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "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) From e73adb50437a591979e6d63eb1d63b79dbfd267c Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:15:50 +0530 Subject: [PATCH 32/34] fix(dashboard): disable ws keepalive ping on loopback to survive event-loop stalls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop/dashboard WebSocket connections drop during long agent operations (delegate_task subagents, large model outputs) when the uvicorn event loop is GIL-starved for minutes. Root cause: uvicorn's ws keepalive ping runs on the SAME event loop as agent turns. A single synchronous GIL-holding call on a worker thread (a regex/scrub over a large output, or a long subagent turn) freezes the loop, so it cannot process the incoming pong within ws_ping_timeout and uvicorn closes an otherwise-healthy connection (#53773: 'event loop stalled 226.3s'; #48445/#50005). Loosening the 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 (ws_ping_interval/timeout=None). Non-loopback (public) binds sit behind a Cloudflare Tunnel where half-open IS a real failure mode, so the ping stays at 20/20 to detect it. Empirically verified (real uvicorn + websockets peer): with ws_ping=None the server never closes a silent peer during an 8s window; with the pre-fix 2s/2s window uvicorn closes it. A genuinely-dead client still fires the WebSocketDisconnect reap path regardless of the ping. Note: this fixes the local Desktop case (the OP's scenario). A remote Desktop over an authenticated public dashboard route (McCalebTheSecond's comment) keeps the ping and needs the deeper GIL-hotspot fix — tracked separately. Closes #53773 --- hermes_cli/web_server.py | 37 +++++++++++++++++++++------------ tests/test_web_server.py | 44 ++++++++++++++++++++++++++++++++-------- 2 files changed, 60 insertions(+), 21 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 6a6f026c749..4c7687f5281 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -14119,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", @@ -14137,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) diff --git a/tests/test_web_server.py b/tests/test_web_server.py index a525b6f828a..ee795542d85 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -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"] From 90b618f48a68d5384b9e5e5753a71e313dd1c123 Mon Sep 17 00:00:00 2001 From: Hermes Trismegistus Date: Sun, 24 May 2026 20:49:51 -0700 Subject: [PATCH 33/34] fix(gateway): keep idle cached agents alive until session actually expires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The idle-TTL sweep (_sweep_idle_cached_agents) was evicting agents as soon as they passed _AGENT_CACHE_IDLE_TTL_SECS, even when the session hadn'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 no agent in cache to call on_session_end() with, so memory providers miss the live transcript. Now the sweep checks the session store before evicting: if the session still exists and hasn't expired, the agent stays in cache so the expiry watcher can tear it down properly later. When the session store is unavailable or throws, falls back to the original eviction behavior (safe default). Fixes: #11205 --- gateway/run.py | 21 +++++++++++++ tests/gateway/test_agent_cache.py | 52 +++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index cf6dae7d81f..3826b383ec7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -15727,6 +15727,27 @@ 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) + session_entry = None + try: + _store = getattr(self, "session_store", None) + if _store is not None: + _store._ensure_loaded() + session_entry = _store._entries.get(key) + except Exception: + pass + if session_entry is not None: + if not _store._is_session_expired(session_entry): + continue # keep agent — session hasn't expired to_evict.append((key, agent)) for key, _ in to_evict: _cache.pop(key, None) diff --git a/tests/gateway/test_agent_cache.py b/tests/gateway/test_agent_cache.py index 2750cd004ac..6efec75d24a 100644 --- a/tests/gateway/test_agent_cache.py +++ b/tests/gateway/test_agent_cache.py @@ -708,6 +708,58 @@ 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. + session_entry = MagicMock() + runner.session_store = MagicMock() + runner.session_store._entries = {"stale-session": session_entry} + 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_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_plain_dict_cache_is_tolerated(self): """Test fixtures using plain {} don't crash _enforce_agent_cache_cap.""" from gateway.run import GatewayRunner From 201b646d672733f75fc8d213f7b5b6c6efbc97de Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:31:27 +0530 Subject: [PATCH 34/34] fix(gateway): complete on_session_end coverage across all eviction paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the cherry-picked #31856 fix. The contributor's guard defers idle-TTL eviction until the session store reports the session expired, so the expiry watcher can tear the agent down and fire MemoryProvider.on_session_end() with the live transcript. Two gaps remained: 1. Memory-leak regression for mode='none' sessions. _is_session_expired() returns False forever for the 'none' reset policy, so the naive guard would never idle-evict those agents — reopening the unbounded-cache leak the idle sweep (#11565) exists to relieve. Added SessionStore.is_session_finalizable() (a public predicate: will the expiry watcher EVER finalize this session?) and gate the deferral on it. mode='none' agents fall through to soft eviction as before. 2. on_session_end still dropped on the LRU-cap path. Both cache-pressure paths (_enforce_agent_cache_cap and _sweep_idle_cached_agents) soft-evict via _release_evicted_agent_soft, which by design does NOT fire on_session_end. If cache pressure evicts a finalizable-but-not-yet-expired agent before it expires, the watcher later finds no cached agent and the hook is skipped. Added _commit_memory_before_soft_evict(): at LRU eviction, if the session is finalizable and not yet expired, commit end-of-session extraction via the live agent's own (fully-scoped) memory manager using commit_memory_session() — extraction WITHOUT provider teardown, so the eviction stays soft and a resumed turn keeps working. Skipped for mode='none' (no missed boundary to compensate) and expired sessions (the watcher tears those down directly). This closes #11205 for ALL eviction paths and reset policies, not just the idle-sweep + finite-policy case, while preserving the soft-eviction resumability contract (never calls close() on a live session). Tests: 5 new cases in test_agent_cache.py (mode='none' still reaped, LRU-cap commits for finalizable / skips for none, real is_session_finalizable predicate); all mutation-checked. Contributor's original 2 tests updated to assert the finalizable path explicitly. --- gateway/run.py | 104 +++++++++++++++++-- gateway/session.py | 31 ++++++ tests/gateway/test_agent_cache.py | 165 +++++++++++++++++++++++++++++- 3 files changed, 292 insertions(+), 8 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 3826b383ec7..d3992a760e8 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -15589,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. @@ -15687,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() @@ -15737,17 +15809,35 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # 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: - _store = getattr(self, "session_store", None) if _store is not None: _store._ensure_loaded() session_entry = _store._entries.get(key) except Exception: - pass - if session_entry is not None: - if not _store._is_session_expired(session_entry): - continue # keep agent — session hasn't expired + 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) diff --git a/gateway/session.py b/gateway/session.py index 2a75aa16d7f..67bd84aaa16 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -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. diff --git a/tests/gateway/test_agent_cache.py b/tests/gateway/test_agent_cache.py index 6efec75d24a..73a4941db3f 100644 --- a/tests/gateway/test_agent_cache.py +++ b/tests/gateway/test_agent_cache.py @@ -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 @@ -725,10 +808,13 @@ class TestAgentCacheBoundedGrowth: import time as _t stale = self._fake_agent(last_activity=_t.time() - 10.0) - # Session store says the session is still alive. + # 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") @@ -752,6 +838,7 @@ class TestAgentCacheBoundedGrowth: 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") @@ -760,6 +847,82 @@ class TestAgentCacheBoundedGrowth: 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