fix(desktop): honest browser-backend readiness + explicit backend activation + full OpenAI TTS voice/model options

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 <datalist>, 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).
This commit is contained in:
Teknium 2026-07-28 23:24:27 -07:00
parent 9bf1f7376f
commit 2319dbb014
20 changed files with 371 additions and 74 deletions

View file

@ -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 `<Input list="…">` + `<datalist>` 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<string, string>
placeholder?: string
className?: string
}) {
const [open, setOpen] = useState(false)
const inputRef = useRef<HTMLInputElement>(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 (
<Popover onOpenChange={setOpen} open={open}>
<PopoverAnchor asChild>
<div className={cn('relative', className)}>
<Input
className="w-full pr-7"
onChange={e => {
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}
/>
<button
aria-label="Show options"
className="absolute inset-y-0 right-1.5 flex items-center text-muted-foreground"
onClick={() => {
setOpen(current => !current)
inputRef.current?.focus()
}}
tabIndex={-1}
type="button"
>
<Codicon name={open ? 'chevron-up' : 'chevron-down'} size="1rem" />
</button>
</div>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
onOpenAutoFocus={e => e.preventDefault()}
>
<Command shouldFilter={false}>
<CommandList>
{visible.length > 0 && (
<CommandGroup>
{visible.map(option => (
<CommandItem
key={option}
onSelect={() => {
onChange(option)
setOpen(false)
}}
value={option}
>
<Codicon className={cn('mr-2 size-4', option === value ? 'opacity-100' : 'opacity-0')} name="check" />
<span className="truncate">{optionLabels?.[option] ?? option}</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View file

@ -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 <datalist> 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(
<>
<Input
className={CONTROL_TEXT}
list={datalistId}
onChange={e => onChange(e.target.value)}
placeholder={c.notSet}
value={String(value ?? '')}
/>
<datalist id={datalistId}>
{selectOptions
.filter(option => option !== '')
.map(option => (
<option key={option} label={optionLabels?.[option]} value={option} />
))}
</datalist>
</>
<ComboboxInput
className={CONTROL_TEXT}
onChange={onChange}
optionLabels={optionLabels}
options={selectOptions.filter(o => o !== '')}
placeholder={c.notSet}
value={String(value ?? '')}
/>
)
}

View file

@ -263,8 +263,10 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
// 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',

View file

@ -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')

View file

@ -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
}

View file

@ -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(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
// 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(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
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(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
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(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
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(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
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(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
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' })

View file

@ -492,7 +492,9 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
const [cfg, setCfg] = useState<ToolsetConfig | null>(null)
const [loading, setLoading] = useState(true)
const [selecting, setSelecting] = useState<string | null>(null)
const [activeProvider, setActiveProvider] = useState<string | null>(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<string | null>(null)
// Live per-key set/unset state, seeded from the endpoint then patched locally.
const [envState, setEnvState] = useState<Record<string, boolean>>({})
// 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
</div>
)}
{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 (
<div className="overflow-hidden rounded-xl bg-background/60" key={provider.name}>
<button
aria-pressed={isActive}
aria-expanded={isExpanded}
className={cn(
'flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left transition hover:bg-accent/50',
isActive && 'bg-accent/40'
isExpanded && 'bg-accent/40'
)}
disabled={selecting !== null}
onClick={() => void handleSelect(provider)}
onClick={() => {
// Row click only expands/collapses — activating a backend is
// the explicit "Use this backend" button below, so browsing
// provider details never silently rewrites config.
providerChoiceClaimedRef.current = true
setExpandedProvider(current => (current === provider.name ? null : provider.name))
}}
type="button"
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{provider.name}</span>
{provider.badge && <Pill>{provider.badge}</Pill>}
{status === 'ready' && (
{isBackendActive && (
<Pill tone="primary">
<Check className="size-3" />
{copy.activeBackend}
</Pill>
)}
{status === 'ready' && !isBackendActive && (
<Pill tone="primary">
<Check className="size-3" />
{copy.ready}
@ -770,9 +784,31 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
{selecting === provider.name && <Loader2 className="size-3.5 shrink-0 animate-spin" />}
</button>
{isActive && (
{isExpanded && (
<div className="grid gap-2 bg-muted/20 p-3">
{provider.tag && <p className="text-[0.72rem] text-muted-foreground">{provider.tag}</p>}
{(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.
<div className="flex flex-wrap items-center gap-2">
{isBackendActive ? (
<Pill tone="primary">
<Check className="size-3" />
{copy.activeBackendHint}
</Pill>
) : (
<Button
disabled={selecting !== null}
onClick={() => void handleSelect(provider)}
size="sm"
>
{selecting === provider.name ? <Loader2 className="size-3.5 animate-spin" /> : <Check />}
{copy.useBackend}
</Button>
)}
</div>
)}
{webCaps.length > 0 && (
// Per-capability assignment: writes web.search_backend /
// web.extract_backend without touching the shared

View file

@ -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.`,

View file

@ -947,6 +947,9 @@ export const ja = defineLocale({
ready: '準備完了',
needsSignIn: 'サインインが必要',
needsSetup: 'セットアップが必要',
activeBackend: '使用中',
activeBackendHint: 'これが現在アクティブなバックエンドです',
useBackend: 'このバックエンドを使う',
nousIncluded: 'Nous サブスクリプションに含まれています。有効にするには Nous Portal にサインインしてください。',
nousAuthNeededTitle: 'Nous Portal にサインイン',
nousAuthNeededMessage: provider =>

View file

@ -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

View file

@ -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 之前不會啟用。`,

View file

@ -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 之前不会激活。`,

View file

@ -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:

View file

@ -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()

View file

@ -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",
}

View file

@ -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",
}

View file

@ -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",
}

View file

@ -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

View file

@ -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"

View file

@ -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"
)