From 2319dbb014c1d04deaf87f4f43beab8e5ede8590 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:24:27 -0700 Subject: [PATCH] fix(desktop): honest browser-backend readiness + explicit backend activation + full OpenAI TTS voice/model options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three GUI Capabilities-tab defects reported on Windows: 1. Browser rows stuck on 'Setup required' after a successful setup run. Root causes, all in the readiness probe (not the installer): - _has_agent_browser() never searched the Hermes-managed Node dir (%LOCALAPPDATA%/hermes/node / $HERMES_HOME/node/bin) where the Windows install lands, and probed node_modules/.bin/agent-browser as the extensionless POSIX shim, which fails exec on Windows (WinError 193) — now resolved via PATHEXT-aware shutil.which against both rungs, mirroring _find_agent_browser(). - Cloud rows (Nous Subscription Browser Use, Browserbase, Browser Use, Firecrawl) declared post_setup: agent_browser, whose readiness gate requires a LOCAL Chromium build the cloud never uses — switched to the cloud-scoped 'browserbase' hook (CLI-only). - _agent_browser_installed() could read browser_tool's stale cached 'Chromium missing' result from before the install ran in the spawned post-setup process — cache now dropped before probing so the pill flips to Ready right after a successful run. 2. No way to tell which backend is active, and clicking a row to read its details silently rewrote config. Row click now only expands/collapses; activation is an explicit 'Use this backend' button, the active row carries an 'Active' pill, and the expanded active row says 'This is your active backend'. 3. OpenAI TTS showed one model and one voice. The options were always defined but rendered through a native , which filters by the field's current value — a field already set to a valid option suggested only itself. Replaced with a real combobox (Input + dropdown) that lists every option, and voice suggestions now track the selected model per the OpenAI TTS docs: tts-1/tts-1-hd = 9 voices, gpt-4o-mini-tts = 13 (adds ballad, verse, marin, cedar). --- .../src/app/settings/combobox-input.tsx | 112 ++++++++++++++++++ .../desktop/src/app/settings/config-field.tsx | 31 ++--- apps/desktop/src/app/settings/constants.ts | 6 +- apps/desktop/src/app/settings/helpers.test.ts | 20 ++++ apps/desktop/src/app/settings/helpers.ts | 16 +++ .../settings/toolset-config-panel.test.tsx | 62 +++++++--- .../src/app/settings/toolset-config-panel.tsx | 64 +++++++--- apps/desktop/src/i18n/en.ts | 5 +- apps/desktop/src/i18n/ja.ts | 3 + apps/desktop/src/i18n/types.ts | 3 + apps/desktop/src/i18n/zh-hant.ts | 3 + apps/desktop/src/i18n/zh.ts | 3 + hermes_cli/nous_subscription.py | 28 ++++- hermes_cli/tools_config.py | 18 ++- plugins/browser/browser_use/provider.py | 4 +- plugins/browser/browserbase/provider.py | 4 +- plugins/browser/firecrawl/provider.py | 4 +- tests/hermes_cli/test_nous_subscription.py | 34 ++++++ tests/hermes_cli/test_tools_config.py | 10 +- .../browser/test_browser_provider_plugins.py | 15 ++- 20 files changed, 371 insertions(+), 74 deletions(-) create mode 100644 apps/desktop/src/app/settings/combobox-input.tsx diff --git a/apps/desktop/src/app/settings/combobox-input.tsx b/apps/desktop/src/app/settings/combobox-input.tsx new file mode 100644 index 000000000000..f8dcd7933817 --- /dev/null +++ b/apps/desktop/src/app/settings/combobox-input.tsx @@ -0,0 +1,112 @@ +import { useRef, useState } from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command' +import { Input } from '@/components/ui/input' +import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' +import { cn } from '@/lib/utils' + +/** + * Free-input combobox for open-world fields (voice/model names): a plain + * Input the user can type anything into, plus a dropdown listing ALL known + * options. + * + * Replaces the old `` + `` rendering for + * FREE_INPUT_KEYS: native datalists filter by the field's current value, so a + * field already holding a valid option (e.g. `gpt-4o-mini-tts`) suggested + * only that one entry — users couldn't discover the other models/voices at + * all (and on some platforms datalists barely render). Suggestions filter by + * substring while typing, but an exact-match value shows the full list so an + * already-configured field still exposes every alternative. + */ +export function ComboboxInput({ + value, + onChange, + options, + optionLabels, + placeholder, + className +}: { + value: string + onChange: (value: string) => void + options: string[] + optionLabels?: Record + placeholder?: string + className?: string +}) { + const [open, setOpen] = useState(false) + const inputRef = useRef(null) + + const query = value.trim().toLowerCase() + const isExact = options.some(option => option.toLowerCase() === query) + + const visible = + query && !isExact ? options.filter(option => option.toLowerCase().includes(query)) : options + + return ( + + +
+ { + onChange(e.target.value) + + if (!open) { + setOpen(true) + } + }} + onFocus={() => setOpen(true)} + onKeyDown={e => { + if (e.key === 'Escape' || e.key === 'Enter' || e.key === 'Tab') { + setOpen(false) + } + }} + placeholder={placeholder} + ref={inputRef} + value={value} + /> + +
+
+ e.preventDefault()} + > + + + {visible.length > 0 && ( + + {visible.map(option => ( + { + onChange(option) + setOpen(false) + }} + value={option} + > + + {optionLabels?.[option] ?? option} + + ))} + + )} + + + +
+ ) +} diff --git a/apps/desktop/src/app/settings/config-field.tsx b/apps/desktop/src/app/settings/config-field.tsx index 7461b4b0c08c..21e46787226c 100644 --- a/apps/desktop/src/app/settings/config-field.tsx +++ b/apps/desktop/src/app/settings/config-field.tsx @@ -9,6 +9,7 @@ import { prettyName } from '@/lib/text' import { cn } from '@/lib/utils' import type { ConfigFieldSchema } from '@/types/hermes' +import { ComboboxInput } from './combobox-input' import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, FREE_INPUT_KEYS } from './constants' import { FallbackModelsField } from './fallback-models-field' import { fieldCopyForSchemaKey } from './field-copy' @@ -113,27 +114,19 @@ export function ConfigField({ // Voice/model name fields are open-world (custom voice IDs, cloned voices, // brand-new model names) — render a free-input combobox where the known - // options are datalist suggestions instead of a closed Select gate. + // options are dropdown suggestions instead of a closed Select gate. The old + // native filtered by the current value, so a field already set + // to a valid option showed only that single suggestion. if (selectOptions && FREE_INPUT_KEYS.has(schemaKey)) { - const datalistId = `config-field-options-${schemaKey.replace(/\./g, '-')}` - return row( - <> - onChange(e.target.value)} - placeholder={c.notSet} - value={String(value ?? '')} - /> - - {selectOptions - .filter(option => option !== '') - .map(option => ( - - + o !== '')} + placeholder={c.notSet} + value={String(value ?? '')} + /> ) } diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index b0e03175bc0c..51f9ab9c2dc6 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -263,8 +263,10 @@ export const ENUM_OPTIONS: Record = { // Speech-to-text backends — kept in sync with the stt block in // hermes_cli/config.py (local/groq/openai/mistral/elevenlabs). 'stt.provider': ['local', 'groq', 'openai', 'mistral', 'xai', 'elevenlabs'], - // gpt-4o-mini-tts voice set (the tts-1 era stopped at shimmer). Free-input - // field — the list is suggestions, not a gate (see FREE_INPUT_KEYS). + // OpenAI TTS voices — the union across models (per the OpenAI TTS API + // docs). Model-specific narrowing happens in enumOptionsFor(): + // tts-1 / tts-1-hd support 9 voices; gpt-4o-mini-tts supports all 13. + // Free-input field — the list is suggestions, not a gate (FREE_INPUT_KEYS). 'tts.openai.voice': [ 'alloy', 'ash', diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index 860c89cb31f0..cd0a455b3cc7 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -209,6 +209,26 @@ describe('settings helpers', () => { expect(opts).toEqual(['local', 'docker', 'singularity', 'modal', 'daytona', 'ssh']) }) + it('narrows OpenAI TTS voice suggestions to what the selected model supports', () => { + // gpt-4o-mini-tts (and unset/unknown models): full 13-voice set. + const full = enumOptionsFor('tts.openai.voice', 'alloy', { tts: { openai: { model: 'gpt-4o-mini-tts' } } }) + expect(full).toContain('marin') + expect(full).toContain('cedar') + expect(full).toContain('ballad') + expect(full).toContain('verse') + expect(full).toHaveLength(13) + + // tts-1 / tts-1-hd: the 9-voice set — no ballad/verse/marin/cedar. + for (const model of ['tts-1', 'tts-1-hd']) { + const narrowed = enumOptionsFor('tts.openai.voice', 'alloy', { tts: { openai: { model } } }) + expect(narrowed).toEqual(['alloy', 'ash', 'coral', 'echo', 'fable', 'nova', 'onyx', 'sage', 'shimmer']) + } + + // A hand-typed custom voice still stays selectable on tts-1. + const custom = enumOptionsFor('tts.openai.voice', 'my-cloned-voice', { tts: { openai: { model: 'tts-1' } } }) + expect(custom).toContain('my-cloned-voice') + }) + it('appends a hand-typed value not in the known list so it stays selected', () => { const opts = enumOptionsFor('tts.provider', 'my-custom-command-tts', config) expect(opts).toContain('my-custom-command-tts') diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts index 13be2c6f0bf7..e3f0fe14e2a2 100644 --- a/apps/desktop/src/app/settings/helpers.ts +++ b/apps/desktop/src/app/settings/helpers.ts @@ -259,6 +259,12 @@ function commandProviderNames(config: HermesConfigRecord, section: 'tts' | 'stt' return [...names] } +// Voice sets per OpenAI speech model, per the OpenAI TTS API docs: tts-1 and +// tts-1-hd support 9 voices; gpt-4o-mini-tts supports those plus ballad, +// verse, marin, and cedar (13 total). Unknown/future models get the full +// union (the field is free-input anyway — this only narrows suggestions). +const OPENAI_TTS1_VOICES = new Set(['alloy', 'ash', 'coral', 'echo', 'fable', 'nova', 'onyx', 'sage', 'shimmer']) + export function enumOptionsFor( key: string, value: unknown, @@ -280,6 +286,16 @@ export function enumOptionsFor( } } + // Narrow OpenAI voice suggestions to what the selected model actually + // accepts — offering `marin` against tts-1 would 400 at the API. + if (!dynamicOptions && opts && key === 'tts.openai.voice') { + const model = asText(getNested(config, 'tts.openai.model')) + + if (model === 'tts-1' || model === 'tts-1-hd') { + opts = opts.filter(voice => OPENAI_TTS1_VOICES.has(voice)) + } + } + if (!opts) { return undefined } diff --git a/apps/desktop/src/app/settings/toolset-config-panel.test.tsx b/apps/desktop/src/app/settings/toolset-config-panel.test.tsx index 6fd5d359087b..fb057d63879d 100644 --- a/apps/desktop/src/app/settings/toolset-config-panel.test.tsx +++ b/apps/desktop/src/app/settings/toolset-config-panel.test.tsx @@ -111,6 +111,16 @@ beforeEach(() => { Element.prototype.scrollIntoView = vi.fn() Element.prototype.hasPointerCapture = vi.fn(() => false) Element.prototype.releasePointerCapture = vi.fn() + // cmdk (used by the free-input voice/model combobox) needs ResizeObserver, + // which jsdom doesn't ship (mirrors searchable-select.test.tsx). + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) getToolsetConfig.mockResolvedValue(config()) getToolsetModels.mockResolvedValue({ @@ -201,12 +211,17 @@ describe('ToolsetConfigPanel', () => { expect(getToolsetConfig).toHaveBeenCalledWith('tts') }) - it('selects a provider when clicked', async () => { + it('expands a provider on row click and activates it via the explicit button', async () => { const { ToolsetConfigPanel } = await import('./toolset-config-panel') render() + // Row click only expands — browsing details must not rewrite config. const elevenlabs = await screen.findByRole('button', { name: /ElevenLabs/ }) fireEvent.click(elevenlabs) + expect(selectToolsetProvider).not.toHaveBeenCalled() + + // The explicit activation button is what persists the backend choice. + fireEvent.click(await screen.findByRole('button', { name: /Use this backend/ })) await waitFor(() => expect(selectToolsetProvider).toHaveBeenCalledWith('tts', 'ElevenLabs')) }) @@ -223,17 +238,22 @@ describe('ToolsetConfigPanel', () => { const { ToolsetConfigPanel } = await import('./toolset-config-panel') render() - const edge = await screen.findByRole('button', { name: /Microsoft Edge TTS/ }) - const elevenlabs = screen.getByRole('button', { name: /ElevenLabs/ }) - fireEvent.click(edge) + // Edge auto-expands (first configured provider); activate it explicitly. + await screen.findByRole('button', { name: /Microsoft Edge TTS/ }) + const useBackend = await screen.findByRole('button', { name: /Use this backend/ }) + fireEvent.click(useBackend) await waitFor(() => expect(selectToolsetProvider).toHaveBeenCalledWith('tts', 'Microsoft Edge TTS')) - expect(elevenlabs.hasAttribute('disabled')).toBe(true) - fireEvent.click(elevenlabs) + // While the first selection is pending, the activation CTA is disabled — + // a second click must not fire another PUT. + expect(useBackend.hasAttribute('disabled')).toBe(true) + fireEvent.click(useBackend) expect(selectToolsetProvider).toHaveBeenCalledTimes(1) resolveSelection({ name: 'tts', ok: true, provider: 'Microsoft Edge TTS' }) - await waitFor(() => expect(elevenlabs.hasAttribute('disabled')).toBe(false)) + // Edge is now the active backend — its expanded panel shows the active + // hint instead of the activation button. + await waitFor(() => expect(screen.getByText('This is your active backend')).toBeTruthy()) }) it('shows a backend model catalog for image_gen and persists a pick', async () => { @@ -564,10 +584,12 @@ describe('ToolsetConfigPanel', () => { render() await screen.findByText('Microsoft Edge TTS') - // Exactly one Ready pill — the genuinely keyless Edge TTS row. - expect(screen.getAllByText('Ready')).toHaveLength(1) + // Edge is the active backend — its row pill reads Active (which + // subsumes Ready); the other rows keep their warn pills. + expect(screen.getAllByText('Active')).toHaveLength(1) + expect(screen.queryByText('Ready')).toBeNull() expect(screen.getByText('Needs sign-in')).toBeTruthy() - expect(screen.getByText('Needs setup')).toBeTruthy() + expect(screen.getByText('Setup required')).toBeTruthy() }) it('shows no Ready pill for a keyed provider the server marks needs_keys', async () => { @@ -603,7 +625,7 @@ describe('ToolsetConfigPanel', () => { expect(screen.queryByText('Ready')).toBeNull() // Missing keys are signalled by the env-var fields, not a warn pill. expect(screen.queryByText('Needs sign-in')).toBeNull() - expect(screen.queryByText('Needs setup')).toBeNull() + expect(screen.queryByText('Setup required')).toBeNull() }) it('falls back to the env-var heuristic when the backend sends no status', async () => { @@ -617,13 +639,13 @@ describe('ToolsetConfigPanel', () => { // Default config(): keyless Edge TTS (ready) + unset ElevenLabs (not). expect(screen.getAllByText('Ready')).toHaveLength(1) expect(screen.queryByText('Needs sign-in')).toBeNull() - expect(screen.queryByText('Needs setup')).toBeNull() + expect(screen.queryByText('Setup required')).toBeNull() }) it('flips a needs_keys provider to Ready locally after its key is saved', async () => { getToolsetConfig.mockResolvedValue( config({ - active_provider: 'ElevenLabs', + active_provider: null, providers: [ { name: 'ElevenLabs', @@ -640,7 +662,7 @@ describe('ToolsetConfigPanel', () => { ], post_setup: null, requires_nous_auth: false, - is_active: true, + is_active: false, status: 'needs_keys' } ] @@ -806,7 +828,9 @@ describe('ToolsetConfigPanel', () => { const { ToolsetConfigPanel } = await import('./toolset-config-panel') render() - fireEvent.click(await screen.findByRole('button', { name: /Nous Subscription/ })) + // The single Nous row auto-expands; activate via the explicit button. + await screen.findByRole('button', { name: /Nous Subscription/ }) + fireEvent.click(await screen.findByRole('button', { name: /Use this backend/ })) await waitFor(() => expect(selectToolsetProvider).toHaveBeenCalledWith('browser', 'Nous Subscription (Browser Use cloud)') @@ -849,7 +873,8 @@ describe('ToolsetConfigPanel', () => { const { ToolsetConfigPanel } = await import('./toolset-config-panel') render() - fireEvent.click(await screen.findByRole('button', { name: /Nous Subscription/ })) + await screen.findByRole('button', { name: /Nous Subscription/ }) + fireEvent.click(await screen.findByRole('button', { name: /Use this backend/ })) // Grab the sign-in action off the warning notification and invoke it — // this is the affordance the toast renders as a button. @@ -891,7 +916,8 @@ describe('ToolsetConfigPanel', () => { const { ToolsetConfigPanel } = await import('./toolset-config-panel') render() - fireEvent.click(await screen.findByRole('button', { name: /Nous Subscription/ })) + await screen.findByRole('button', { name: /Nous Subscription/ }) + fireEvent.click(await screen.findByRole('button', { name: /Use this backend/ })) await waitFor(() => expect(notify).toHaveBeenCalledWith(expect.objectContaining({ kind: 'success' }))) expect(startOAuthLogin).not.toHaveBeenCalled() @@ -947,7 +973,7 @@ describe('ToolsetConfigPanel', () => { // provider initializer, and user intent must win that race. const elevenLabs = await screen.findByRole('button', { name: /ElevenLabs/ }) fireEvent.click(elevenLabs) - await waitFor(() => expect(elevenLabs.getAttribute('aria-pressed')).toBe('true')) + await waitFor(() => expect(elevenLabs.getAttribute('aria-expanded')).toBe('true')) const trigger = await screen.findByRole('button', { name: /^Actions$/ }) fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' }) diff --git a/apps/desktop/src/app/settings/toolset-config-panel.tsx b/apps/desktop/src/app/settings/toolset-config-panel.tsx index 1e824c2cd2c5..d0c92957c6df 100644 --- a/apps/desktop/src/app/settings/toolset-config-panel.tsx +++ b/apps/desktop/src/app/settings/toolset-config-panel.tsx @@ -492,7 +492,9 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi const [cfg, setCfg] = useState(null) const [loading, setLoading] = useState(true) const [selecting, setSelecting] = useState(null) - const [activeProvider, setActiveProvider] = useState(null) + // Which provider row is EXPANDED in the panel (purely presentational — + // distinct from the backend-active provider in cfg.active_provider). + const [expandedProvider, setExpandedProvider] = useState(null) // Live per-key set/unset state, seeded from the endpoint then patched locally. const [envState, setEnvState] = useState>({}) // Default-provider selection and a user click race just after config arrives: @@ -545,7 +547,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi // the user had already selected another (e.g. DuckDuckGo). // eslint-disable-next-line no-restricted-syntax -- one-shot provider-choice claim flag, not an atom mirror useEffect(() => { - if (providerChoiceClaimedRef.current || activeProvider || providers.length === 0) { + if (providerChoiceClaimedRef.current || expandedProvider || providers.length === 0) { return } @@ -556,11 +558,11 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi providers[0] // Claim before enqueueing the state update. Effects can run with a stale - // activeProvider closure after a user click, so state alone is too late to - // protect that choice. + // expandedProvider closure after a user click, so state alone is too late + // to protect that choice. providerChoiceClaimedRef.current = true - setActiveProvider(selected.name) - }, [activeProvider, providers, envState, cfg]) + setExpandedProvider(selected.name) + }, [expandedProvider, providers, envState, cfg]) async function handleSelect(provider: ToolProvider) { if (selecting !== null) { @@ -568,7 +570,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi } providerChoiceClaimedRef.current = true - setActiveProvider(provider.name) + setExpandedProvider(provider.name) setSelecting(provider.name) try { @@ -735,7 +737,8 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi )} {providers.map(provider => { - const isActive = activeProvider === provider.name + const isExpanded = expandedProvider === provider.name + const isBackendActive = provider.is_active || cfg?.active_provider === provider.name const status = providerStatus(provider, envState) const webCaps = toolset === 'web' ? (provider.capabilities ?? []) : [] const isSearchBackend = Boolean(provider.web_backend && cfg.active_search_backend === provider.web_backend) @@ -744,19 +747,30 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi return (
- {isActive && ( + {isExpanded && (
{provider.tag &&

{provider.tag}

} + {(toolset !== 'web' || webCaps.length === 0) && ( + // Explicit activation — the old row-click-selects UX gave no + // signal about which backend was actually in use and made + // reading a row's details indistinguishable from choosing it. +
+ {isBackendActive ? ( + + + {copy.activeBackendHint} + + ) : ( + + )} +
+ )} {webCaps.length > 0 && ( // Per-capability assignment: writes web.search_backend / // web.extract_backend without touching the shared diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 87f4e84914e3..29fa06c439fe 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -910,7 +910,10 @@ export const en: Translations = { noProviders: 'No providers are available for this toolset right now.', ready: 'Ready', needsSignIn: 'Needs sign-in', - needsSetup: 'Needs setup', + needsSetup: 'Setup required', + activeBackend: 'Active', + activeBackendHint: 'This is your active backend', + useBackend: 'Use this backend', nousIncluded: 'Included with a Nous subscription — sign in to Nous Portal to activate.', nousAuthNeededTitle: 'Sign in to Nous Portal', nousAuthNeededMessage: provider => `${provider} is saved but won't activate until you sign in to Nous Portal.`, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 88ba5809cca1..22a11179e084 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -947,6 +947,9 @@ export const ja = defineLocale({ ready: '準備完了', needsSignIn: 'サインインが必要', needsSetup: 'セットアップが必要', + activeBackend: '使用中', + activeBackendHint: 'これが現在アクティブなバックエンドです', + useBackend: 'このバックエンドを使う', nousIncluded: 'Nous サブスクリプションに含まれています。有効にするには Nous Portal にサインインしてください。', nousAuthNeededTitle: 'Nous Portal にサインイン', nousAuthNeededMessage: provider => diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index cbcf9bd1579c..9b986fd90a92 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -786,6 +786,9 @@ export interface Translations { ready: string needsSignIn: string needsSetup: string + activeBackend: string + activeBackendHint: string + useBackend: string nousIncluded: string nousAuthNeededTitle: string nousAuthNeededMessage: (provider: string) => string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index b7ff39699d8c..9f1c2474e40a 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -916,6 +916,9 @@ export const zhHant = defineLocale({ ready: '就緒', needsSignIn: '需要登入', needsSetup: '需要安裝', + activeBackend: '目前後端', + activeBackendHint: '這是你目前使用的後端', + useBackend: '使用此後端', nousIncluded: '包含在 Nous 訂閱中;登入 Nous Portal 即可啟用。', nousAuthNeededTitle: '登入 Nous Portal', nousAuthNeededMessage: provider => `已儲存 ${provider},但在登入 Nous Portal 之前不會啟用。`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 71b55234df61..7a881a763197 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1111,6 +1111,9 @@ export const zh: Translations = { ready: '就绪', needsSignIn: '需要登录', needsSetup: '需要安装', + activeBackend: '当前后端', + activeBackendHint: '这是你当前使用的后端', + useBackend: '使用此后端', nousIncluded: '包含在 Nous 订阅中;登录 Nous Portal 即可激活。', nousAuthNeededTitle: '登录 Nous Portal', nousAuthNeededMessage: provider => `已保存 ${provider},但在登录 Nous Portal 之前不会激活。`, diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index f2775e3bbe83..bfba9b2dd49f 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -159,17 +159,35 @@ def _toolset_enabled(config: Dict[str, object], toolset_key: str) -> bool: def _has_agent_browser() -> bool: import shutil - from hermes_constants import agent_browser_runnable + from hermes_constants import agent_browser_runnable, with_hermes_node_path # Validate the resolved binary actually runs — a dangling global symlink # (issue #48521) is reported by ``which`` but fails at exec. Fall through to # the local node_modules copy, which the validator also checks. if agent_browser_runnable(shutil.which("agent-browser")): return True - local_bin = ( - Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser" - ) - return agent_browser_runnable(str(local_bin)) if local_bin.exists() else False + + # Hermes-managed Node dirs (Windows installer / POSIX $HERMES_HOME/node) + # are prepended to PATH at runtime but usually absent from the *probe* + # process's PATH — the same rung `_find_agent_browser` searches. Without + # it a successful install keeps reporting "needs setup" on Windows. + managed_path = with_hermes_node_path().get("PATH", "") + if managed_path: + managed_hit = shutil.which("agent-browser", path=managed_path) + if managed_hit and agent_browser_runnable(managed_hit): + return True + + # Local node_modules/.bin: resolve via PATHEXT-aware ``shutil.which`` so + # Windows picks the executable ``.cmd`` shim. Probing the extensionless + # POSIX shim directly fails exec (WinError 193) even right after a + # successful ``npm install`` — the bug that pinned every browser row on + # "Setup required" in the desktop GUI. + local_bin_dir = Path(__file__).parent.parent / "node_modules" / ".bin" + if local_bin_dir.is_dir(): + local_which = shutil.which("agent-browser", path=str(local_bin_dir)) + if local_which and agent_browser_runnable(local_which): + return True + return False def _local_browser_runnable() -> bool: diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 1d3f6899fa9a..cdc0243e0bd6 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -540,7 +540,12 @@ TOOL_CATEGORIES = { "requires_nous_auth": True, "managed_nous_feature": "browser", "override_env_vars": ["BROWSER_USE_API_KEY"], - "post_setup": "agent_browser", + # Cloud hook: installs the agent-browser CLI only. Browser Use + # hosts its own Chromium, so the local-Chromium install (and + # the local-Chromium readiness gate) must not apply here — + # with "agent_browser" this row read "needs setup" forever on + # machines without a local Chromium build. + "post_setup": "browserbase", }, { "name": "Camofox", @@ -2872,8 +2877,19 @@ def _agent_browser_installed() -> bool: Lightpanda engine, which needs no Chromium). Mirrors the hook so "Run setup" flips to an installed state only when re-running it would be a no-op.""" + import sys + from hermes_cli.nous_subscription import _local_browser_runnable + # The install hook runs in a spawned ``hermes tools post-setup`` process, + # but this probe runs in the long-lived web-server/CLI process, whose + # browser_tool module may have cached a stale "Chromium missing" result + # from before the install. Drop the cache (when the module is loaded) so + # the readiness pill flips to Ready right after a successful setup run. + bt = sys.modules.get("tools.browser_tool") + if bt is not None: + bt._cached_chromium_installed = None + return _local_browser_runnable() diff --git a/plugins/browser/browser_use/provider.py b/plugins/browser/browser_use/provider.py index 46a220333446..8310862cf7a7 100644 --- a/plugins/browser/browser_use/provider.py +++ b/plugins/browser/browser_use/provider.py @@ -313,5 +313,7 @@ class BrowserUseBrowserProvider(BrowserProvider): "url": "https://browser-use.com", }, ], - "post_setup": "agent_browser", + # Cloud-scoped hook: installs the agent-browser CLI only (no + # local Chromium — Browser Use hosts the browser). + "post_setup": "browserbase", } diff --git a/plugins/browser/browserbase/provider.py b/plugins/browser/browserbase/provider.py index 41ceb9e83d15..c828ae29b2da 100644 --- a/plugins/browser/browserbase/provider.py +++ b/plugins/browser/browserbase/provider.py @@ -293,5 +293,7 @@ class BrowserbaseBrowserProvider(BrowserProvider): "prompt": "Browserbase project ID", }, ], - "post_setup": "agent_browser", + # Cloud-scoped hook: installs the agent-browser CLI only (no + # local Chromium — Browserbase hosts the browser). + "post_setup": "browserbase", } diff --git a/plugins/browser/firecrawl/provider.py b/plugins/browser/firecrawl/provider.py index 50f813f60185..892cd6c1dc8f 100644 --- a/plugins/browser/firecrawl/provider.py +++ b/plugins/browser/firecrawl/provider.py @@ -167,5 +167,7 @@ class FirecrawlBrowserProvider(BrowserProvider): "url": "https://firecrawl.dev", }, ], - "post_setup": "agent_browser", + # Cloud-scoped hook: installs the agent-browser CLI only (no + # local Chromium — Firecrawl hosts the browser). + "post_setup": "browserbase", } diff --git a/tests/hermes_cli/test_nous_subscription.py b/tests/hermes_cli/test_nous_subscription.py index 9faf25965e0e..064c3fa66cbc 100644 --- a/tests/hermes_cli/test_nous_subscription.py +++ b/tests/hermes_cli/test_nous_subscription.py @@ -854,3 +854,37 @@ def test_apply_gateway_defaults_sets_stt_use_gateway(monkeypatch): assert "stt" in changed assert config["stt"]["provider"] == "openai" assert config["stt"]["use_gateway"] is True + + +def test_has_agent_browser_resolves_via_hermes_managed_node_path(monkeypatch, tmp_path): + """The managed-Node rung: a runnable agent-browser under the Hermes Node + dir must count even when it's absent from the probe process's PATH (the + Windows installer shape — install succeeded, GUI still said needs setup).""" + import shutil as _shutil + + managed_dir = tmp_path / "node" + managed_dir.mkdir() + managed_bin = managed_dir / "agent-browser" + managed_bin.write_text("#!/bin/sh\nexit 0\n") + managed_bin.chmod(0o755) + + monkeypatch.setattr(_shutil, "which", lambda cmd, path=None: str(managed_bin) if path else None) + monkeypatch.setattr( + "hermes_constants.with_hermes_node_path", lambda: {"PATH": str(managed_dir)} + ) + monkeypatch.setattr( + "hermes_constants.agent_browser_runnable", + lambda p: bool(p) and str(p) == str(managed_bin), + ) + + assert ns._has_agent_browser() is True + + +def test_has_agent_browser_false_when_nothing_runnable(monkeypatch): + import shutil as _shutil + + monkeypatch.setattr(_shutil, "which", lambda cmd, path=None: None) + monkeypatch.setattr("hermes_constants.with_hermes_node_path", lambda: {"PATH": ""}) + monkeypatch.setattr("hermes_constants.agent_browser_runnable", lambda p: False) + + assert ns._has_agent_browser() is False diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 0b3e84251185..5d298000734e 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -1200,7 +1200,7 @@ def test_computer_use_needs_configuration_when_cua_driver_post_setup_pending(): def test_computer_use_skips_configuration_when_cua_driver_already_installed(): """Installed post_setup dependencies should keep returning-user toggles no-op.""" - def fake_which(name: str): + def fake_which(name: str, path=None): return "/usr/local/bin/cua-driver" if name == "cua-driver" else None with patch("shutil.which", side_effect=fake_which): @@ -1209,7 +1209,7 @@ def test_computer_use_skips_configuration_when_cua_driver_already_installed(): def test_computer_use_respects_custom_cua_driver_command(): """The setup gate should match runtime's HERMES_CUA_DRIVER_CMD override.""" - def fake_which(name: str): + def fake_which(name: str, path=None): return "/opt/bin/custom-cua" if name == "custom-cua" else None with patch.dict("os.environ", {"HERMES_CUA_DRIVER_CMD": "custom-cua"}), \ @@ -1219,7 +1219,7 @@ def test_computer_use_respects_custom_cua_driver_command(): def test_computer_use_blank_custom_driver_command_falls_back_to_default(): """Blank overrides should not make the setup gate look for an empty command.""" - def fake_which(name: str): + def fake_which(name: str, path=None): return "/usr/local/bin/cua-driver" if name == "cua-driver" else None with patch.dict("os.environ", {"HERMES_CUA_DRIVER_CMD": " "}), \ @@ -1229,7 +1229,7 @@ def test_computer_use_blank_custom_driver_command_falls_back_to_default(): def test_computer_use_post_setup_respects_custom_driver_command_when_installed(): """post_setup already-installed checks should version-probe the resolved override.""" - def fake_which(name: str): + def fake_which(name: str, path=None): return "/opt/bin/custom-cua" if name == "custom-cua" else None with patch.dict("os.environ", {"HERMES_CUA_DRIVER_CMD": "custom-cua"}), \ @@ -1250,7 +1250,7 @@ def test_computer_use_post_setup_missing_override_does_not_accept_default_binary """A default cua-driver binary must not satisfy a missing runtime override.""" seen = [] - def fake_which(name: str): + def fake_which(name: str, path=None): seen.append(name) if name == "cua-driver": return "/usr/local/bin/cua-driver" diff --git a/tests/plugins/browser/test_browser_provider_plugins.py b/tests/plugins/browser/test_browser_provider_plugins.py index 986a1d635bfe..c05f92b3b92a 100644 --- a/tests/plugins/browser/test_browser_provider_plugins.py +++ b/tests/plugins/browser/test_browser_provider_plugins.py @@ -115,9 +115,11 @@ class TestBundledPluginsRegister: assert isinstance(schema, dict) assert "name" in schema assert "env_vars" in schema - # Every cloud-browser plugin needs the agent-browser post-setup hook - # so the picker auto-installs the CLI on selection. - assert schema.get("post_setup") == "agent_browser" + # Every cloud-browser plugin needs the cloud-scoped post-setup hook + # ("browserbase" = agent-browser CLI only, no local Chromium install) + # so the picker auto-installs the CLI on selection without gating + # readiness on a local Chromium build the cloud never uses. + assert schema.get("post_setup") == "browserbase" @pytest.mark.parametrize( "plugin_name", @@ -359,13 +361,14 @@ class TestPickerIntegration: assert names == ["browser-use", "browserbase", "firecrawl"] def test_picker_rows_carry_post_setup_hook(self) -> None: - """Every browser plugin row has post_setup='agent_browser' so - selecting it triggers the agent-browser CLI install.""" + """Every browser plugin row has the cloud-scoped post_setup hook + ('browserbase': agent-browser CLI only) so selecting it installs the + CLI without requiring a local Chromium the cloud never uses.""" _ensure_plugins_loaded() from hermes_cli.tools_config import _plugin_browser_providers for row in _plugin_browser_providers(): - assert row.get("post_setup") == "agent_browser", ( + assert row.get("post_setup") == "browserbase", ( f"plugin row {row['browser_provider']!r} missing post_setup hook" )