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 c2b162b8aa4d..693dc86f2e33 100644
--- a/apps/desktop/src/app/settings/toolset-config-panel.test.tsx
+++ b/apps/desktop/src/app/settings/toolset-config-panel.test.tsx
@@ -12,6 +12,8 @@ const deleteEnvVar = vi.fn()
const revealEnvVar = vi.fn()
const runToolsetPostSetup = vi.fn()
const getActionStatus = vi.fn()
+const startOAuthLogin = vi.fn()
+const pollOAuthSession = vi.fn()
vi.mock('@/hermes', () => ({
getToolsetConfig: (name: string) => getToolsetConfig(name),
@@ -22,7 +24,9 @@ vi.mock('@/hermes', () => ({
deleteEnvVar: (key: string) => deleteEnvVar(key),
revealEnvVar: (key: string) => revealEnvVar(key),
runToolsetPostSetup: (name: string, key: string) => runToolsetPostSetup(name, key),
- getActionStatus: (name: string, lines?: number) => getActionStatus(name, lines)
+ getActionStatus: (name: string, lines?: number) => getActionStatus(name, lines),
+ startOAuthLogin: (providerId: string) => startOAuthLogin(providerId),
+ pollOAuthSession: (providerId: string, sessionId: string) => pollOAuthSession(providerId, sessionId)
}))
vi.mock('@/store/notifications', () => ({
@@ -506,4 +510,234 @@ describe('ToolsetConfigPanel', () => {
await waitFor(() => expect(screen.getByText('Ready')).toBeTruthy())
})
})
+
+ describe('post-setup installed state', () => {
+ it('renders Installed + Re-run setup instead of the primary CTA when the server says ready', async () => {
+ // Regression (Windows 11 Capabilities journey): "Run setup" rendered
+ // unconditionally, so an already-installed backend still showed the
+ // primary install CTA and clicking it re-ran the whole npm/Chromium
+ // install. status === 'ready' must flip to a resting Installed state.
+ getToolsetConfig.mockResolvedValue(
+ config({
+ name: 'browser',
+ active_provider: 'Local Browser',
+ providers: [
+ {
+ name: 'Local Browser',
+ badge: 'free',
+ tag: 'Headless Chromium, no API key needed',
+ env_vars: [],
+ post_setup: 'agent_browser',
+ requires_nous_auth: false,
+ is_active: true,
+ status: 'ready'
+ }
+ ]
+ })
+ )
+
+ const { ToolsetConfigPanel } = await import('./toolset-config-panel')
+ render()
+
+ await screen.findByText('Local Browser')
+ expect(screen.getByText('Installed')).toBeTruthy()
+ expect(screen.getByRole('button', { name: /Re-run setup/ })).toBeTruthy()
+ expect(screen.queryByRole('button', { name: /^Run setup$/ })).toBeNull()
+ })
+
+ it('still runs the hook from the Re-run setup affordance', async () => {
+ getToolsetConfig.mockResolvedValue(
+ config({
+ name: 'browser',
+ active_provider: 'Local Browser',
+ providers: [
+ {
+ name: 'Local Browser',
+ badge: 'free',
+ tag: 'Headless Chromium, no API key needed',
+ env_vars: [],
+ post_setup: 'agent_browser',
+ requires_nous_auth: false,
+ is_active: true,
+ status: 'ready'
+ }
+ ]
+ })
+ )
+ runToolsetPostSetup.mockResolvedValue({ ok: true, pid: 4321, name: 'tools-post-setup', key: 'agent_browser' })
+ getActionStatus.mockResolvedValue({
+ exit_code: 0,
+ lines: ['agent-browser already installed, nothing to do'],
+ name: 'tools-post-setup',
+ pid: 4321,
+ running: false
+ })
+
+ const { ToolsetConfigPanel } = await import('./toolset-config-panel')
+ render()
+
+ fireEvent.click(await screen.findByRole('button', { name: /Re-run setup/ }))
+
+ await waitFor(() => expect(runToolsetPostSetup).toHaveBeenCalledWith('browser', 'agent_browser'))
+ })
+
+ it('keeps the primary Run setup CTA when the server says needs_setup', async () => {
+ getToolsetConfig.mockResolvedValue(
+ config({
+ name: 'browser',
+ active_provider: 'Local Browser',
+ providers: [
+ {
+ name: 'Local Browser',
+ badge: 'free',
+ tag: 'Headless Chromium, no API key needed',
+ env_vars: [],
+ post_setup: 'agent_browser',
+ requires_nous_auth: false,
+ is_active: true,
+ status: 'needs_setup'
+ }
+ ]
+ })
+ )
+
+ const { ToolsetConfigPanel } = await import('./toolset-config-panel')
+ render()
+
+ await screen.findByText('Local Browser')
+ expect(screen.getByRole('button', { name: /Run setup/ })).toBeTruthy()
+ expect(screen.queryByText('Installed')).toBeNull()
+ })
+ })
+
+ describe('managed Nous provider activation', () => {
+ const nousBrowserConfig = () =>
+ config({
+ name: 'browser',
+ active_provider: null,
+ providers: [
+ {
+ name: 'Nous Subscription (Browser Use cloud)',
+ badge: 'subscription',
+ tag: 'Managed Browser Use billed to your subscription',
+ env_vars: [],
+ post_setup: 'agent_browser',
+ requires_nous_auth: true,
+ is_active: false,
+ status: 'needs_auth'
+ }
+ ]
+ })
+
+ it('surfaces a sign-in notice when the PUT reports needs_nous_auth', async () => {
+ // Regression (Windows 11 Capabilities journey): the GUI wrote
+ // browser.cloud_provider but skipped the Portal entitlement handshake,
+ // so the managed row silently never activated. The endpoint now
+ // reports needs_nous_auth and the panel must surface a sign-in action
+ // instead of the misleading "provider selected" success toast.
+ const { notify } = await import('@/store/notifications')
+
+ getToolsetConfig.mockResolvedValue(nousBrowserConfig())
+ selectToolsetProvider.mockResolvedValue({
+ ok: true,
+ name: 'browser',
+ provider: 'Nous Subscription (Browser Use cloud)',
+ needs_nous_auth: true,
+ feature: 'browser'
+ })
+
+ const { ToolsetConfigPanel } = await import('./toolset-config-panel')
+ render()
+
+ fireEvent.click(await screen.findByRole('button', { name: /Nous Subscription/ }))
+
+ await waitFor(() =>
+ expect(selectToolsetProvider).toHaveBeenCalledWith('browser', 'Nous Subscription (Browser Use cloud)')
+ )
+ await waitFor(() =>
+ expect(notify).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kind: 'warning',
+ action: expect.objectContaining({ label: expect.any(String) })
+ })
+ )
+ )
+ // No success toast — the row is not active yet.
+ expect(notify).not.toHaveBeenCalledWith(expect.objectContaining({ kind: 'success' }))
+ })
+
+ it('drives the existing Nous OAuth device-code flow from the sign-in action and refetches', async () => {
+ const { notify } = await import('@/store/notifications')
+
+ getToolsetConfig.mockResolvedValue(nousBrowserConfig())
+ selectToolsetProvider.mockResolvedValue({
+ ok: true,
+ name: 'browser',
+ provider: 'Nous Subscription (Browser Use cloud)',
+ needs_nous_auth: true,
+ feature: 'browser'
+ })
+ startOAuthLogin.mockResolvedValue({
+ flow: 'device_code',
+ session_id: 'sess-1',
+ user_code: 'NOUS-1234',
+ verification_url: 'https://portal.nousresearch.com/device?user_code=NOUS-1234',
+ poll_interval: 5,
+ expires_in: 600
+ })
+ pollOAuthSession.mockResolvedValue({ session_id: 'sess-1', status: 'approved' })
+ const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
+
+ try {
+ const { ToolsetConfigPanel } = await import('./toolset-config-panel')
+ render()
+
+ fireEvent.click(await screen.findByRole('button', { name: /Nous Subscription/ }))
+
+ // Grab the sign-in action off the warning notification and invoke it —
+ // this is the affordance the toast renders as a button.
+ await waitFor(() => expect(notify).toHaveBeenCalledWith(expect.objectContaining({ kind: 'warning' })))
+
+ const warning = vi
+ .mocked(notify)
+ .mock.calls.map(call => call[0])
+ .find(input => input.kind === 'warning')
+
+ expect(warning?.action).toBeTruthy()
+ getToolsetConfig.mockClear()
+ warning!.action!.onClick()
+
+ await waitFor(() => expect(startOAuthLogin).toHaveBeenCalledWith('nous'))
+ expect(openSpy).toHaveBeenCalledWith(
+ 'https://portal.nousresearch.com/device?user_code=NOUS-1234',
+ '_blank',
+ 'noopener,noreferrer'
+ )
+ // Approved poll → the panel refetches the config so status flips.
+ await waitFor(() => expect(pollOAuthSession).toHaveBeenCalledWith('nous', 'sess-1'), { timeout: 8000 })
+ await waitFor(() => expect(getToolsetConfig).toHaveBeenCalled(), { timeout: 8000 })
+ } finally {
+ openSpy.mockRestore()
+ }
+ }, 20000)
+
+ it('shows the plain success toast when the managed row is already entitled', async () => {
+ const { notify } = await import('@/store/notifications')
+
+ getToolsetConfig.mockResolvedValue(nousBrowserConfig())
+ selectToolsetProvider.mockResolvedValue({
+ ok: true,
+ name: 'browser',
+ provider: 'Nous Subscription (Browser Use cloud)'
+ })
+
+ const { ToolsetConfigPanel } = await import('./toolset-config-panel')
+ render()
+
+ fireEvent.click(await screen.findByRole('button', { name: /Nous Subscription/ }))
+
+ await waitFor(() => expect(notify).toHaveBeenCalledWith(expect.objectContaining({ kind: 'success' })))
+ expect(startOAuthLogin).not.toHaveBeenCalled()
+ })
+ })
})
diff --git a/apps/desktop/src/app/settings/toolset-config-panel.tsx b/apps/desktop/src/app/settings/toolset-config-panel.tsx
index e8338bf377bb..4ca544926ba3 100644
--- a/apps/desktop/src/app/settings/toolset-config-panel.tsx
+++ b/apps/desktop/src/app/settings/toolset-config-panel.tsx
@@ -7,11 +7,13 @@ import {
getActionStatus,
getToolsetConfig,
getToolsetModels,
+ pollOAuthSession,
revealEnvVar,
runToolsetPostSetup,
selectToolsetModel,
selectToolsetProvider,
- setEnvVar
+ setEnvVar,
+ startOAuthLogin
} from '@/hermes'
import { useI18n } from '@/i18n'
import { Check, Loader2, Save, Terminal } from '@/lib/icons'
@@ -204,6 +206,10 @@ interface PostSetupRunnerProps {
toolset: string
/** The provider's post_setup hook key (e.g. "camofox", "ddgs"). */
postSetupKey: string
+ /** True when the server reports the install side-effect already satisfied
+ * (provider status === 'ready') — renders the resting "Installed" state
+ * with a low-key re-run affordance instead of the primary CTA. */
+ installed?: boolean
/** Refresh the parent config after the install finishes (a backend may now
* report itself configured). */
onComplete?: () => void
@@ -214,8 +220,13 @@ interface PostSetupRunnerProps {
* `/api/tools/toolsets/{name}/post-setup` spawn-action and tails the resulting
* log inline — the GUI equivalent of the install step `hermes tools` runs
* after you pick a backend that needs extra dependencies.
+ *
+ * Idempotent UX: when the backend's readiness status says the install is
+ * already satisfied, the primary "Run setup" CTA is replaced by an
+ * "Installed" pill plus a small "Re-run setup" text button, so clicking
+ * around the panel doesn't look like it keeps reinstalling.
*/
-function PostSetupRunner({ toolset, postSetupKey, onComplete }: PostSetupRunnerProps) {
+function PostSetupRunner({ toolset, postSetupKey, installed = false, onComplete }: PostSetupRunnerProps) {
const { t } = useI18n()
const copy = t.settings.toolsets
const [running, setRunning] = useState(false)
@@ -297,12 +308,27 @@ function PostSetupRunner({ toolset, postSetupKey, onComplete }: PostSetupRunnerP
-
{copy.postSetupHint(postSetupKey)}
+
+ {installed ? copy.postSetupInstalledHint : copy.postSetupHint(postSetupKey)}
+
-
+ {installed ? (
+
+
+
+ {copy.postSetupInstalled}
+
+
+
+ ) : (
+
+ )}
{status && (status.lines.length > 0 || status.running) && (
@@ -455,6 +481,16 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
const [activeProvider, setActiveProvider] = useState
(null)
// Live per-key set/unset state, seeded from the endpoint then patched locally.
const [envState, setEnvState] = useState>({})
+ // Guard the Nous Portal sign-in poll loop against unmount/state updates.
+ const mountedRef = useRef(true)
+
+ useEffect(() => {
+ mountedRef.current = true
+
+ return () => {
+ mountedRef.current = false
+ }
+ }, [])
const refresh = useCallback(async () => {
setLoading(true)
@@ -508,7 +544,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
setSelecting(provider.name)
try {
- await selectToolsetProvider(toolset, provider.name)
+ const result = await selectToolsetProvider(toolset, provider.name)
// Mirror the backend write locally so dependent UI (model catalog
// enablement) tracks the new active backend without a refetch.
setCfg(current =>
@@ -520,6 +556,22 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
}
: current
)
+
+ if (result.needs_nous_auth) {
+ // Managed Nous row selected without Portal entitlement: the config
+ // keys are written but the backend won't activate until the user
+ // signs in (the CLI runs this gate inline; the GUI surfaces it as a
+ // sign-in action). Reuses the existing Nous Portal device-code flow.
+ notify({
+ kind: 'warning',
+ title: copy.nousAuthNeededTitle,
+ message: copy.nousAuthNeededMessage(provider.name),
+ action: { label: copy.nousAuthSignIn, onClick: () => void signInToNousPortal() }
+ })
+
+ return
+ }
+
notify({ kind: 'success', title: copy.selectedTitle, message: copy.selectedMessage(provider.name) })
onConfiguredChange?.()
} catch (err) {
@@ -529,6 +581,62 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
}
}
+ // Drive the existing Nous Portal OAuth device-code flow (the same session
+ // machinery onboarding uses: start → open verification URL → poll), then
+ // refetch the toolset config so is_active / status flip once entitled.
+ async function signInToNousPortal() {
+ try {
+ const start = await startOAuthLogin('nous')
+
+ if (start.flow !== 'device_code') {
+ notifyError(new Error(`unexpected flow: ${start.flow}`), copy.nousAuthFailed)
+
+ return
+ }
+
+ const url = start.verification_url
+
+ if (window.hermesDesktop?.openExternal) {
+ try {
+ await window.hermesDesktop.openExternal(url)
+ } catch {
+ window.open(url, '_blank', 'noopener,noreferrer')
+ }
+ } else {
+ window.open(url, '_blank', 'noopener,noreferrer')
+ }
+
+ // Poll until the device-code session resolves (~5s cadence, bounded).
+ for (let attempt = 0; attempt < 120 && mountedRef.current; attempt += 1) {
+ await new Promise(resolve => window.setTimeout(resolve, 5000))
+
+ if (!mountedRef.current) {
+ return
+ }
+
+ const polled = await pollOAuthSession('nous', start.session_id)
+
+ if (polled.status === 'approved') {
+ notify({ kind: 'success', title: copy.nousAuthDoneTitle, message: copy.nousAuthDoneMessage })
+ await refresh()
+ onConfiguredChange?.()
+
+ return
+ }
+
+ if (polled.status !== 'pending') {
+ notifyError(new Error(polled.error_message || `Sign-in ${polled.status}`), copy.nousAuthFailed)
+
+ return
+ }
+ }
+ } catch (err) {
+ if (mountedRef.current) {
+ notifyError(err, copy.nousAuthFailed)
+ }
+ }
+ }
+
function patchEnv(key: string, isSet: boolean) {
setEnvState(c => ({ ...c, [key]: isSet }))
onConfiguredChange?.()
@@ -610,6 +718,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
)}
{provider.post_setup && (
void refresh()}
postSetupKey={provider.post_setup}
toolset={toolset}
diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts
index 7d462fbb2ef5..9f8de233598d 100644
--- a/apps/desktop/src/hermes.ts
+++ b/apps/desktop/src/hermes.ts
@@ -865,11 +865,20 @@ export function selectToolsetModel(
})
}
-export function selectToolsetProvider(
- name: string,
+export interface SelectToolsetProviderResponse {
+ ok: boolean
+ name: string
provider: string
-): Promise<{ ok: boolean; name: string; provider: string }> {
- return window.hermesDesktop.api<{ ok: boolean; name: string; provider: string }>({
+ /** Present (true) when a managed Nous row was selected but the Portal
+ * entitlement is missing — the row won't activate until the user signs
+ * in to Nous Portal. */
+ needs_nous_auth?: boolean
+ /** The managed feature key (e.g. "browser") when needs_nous_auth is set. */
+ feature?: string
+}
+
+export function selectToolsetProvider(name: string, provider: string): Promise {
+ return window.hermesDesktop.api({
...profileScoped(),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`,
method: 'PUT',
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 00a8f13445a2..4983e1cb8642 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -823,10 +823,19 @@ export const en: Translations = {
needsSignIn: 'Needs sign-in',
needsSetup: 'Needs setup',
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.`,
+ nousAuthSignIn: 'Sign in',
+ nousAuthDoneTitle: 'Nous Portal connected',
+ nousAuthDoneMessage: 'Your subscription backends are now active.',
+ nousAuthFailed: 'Nous Portal sign-in did not complete',
noApiKeyRequired: 'No API key required.',
postSetupHint: step =>
`This backend needs a one-time install (${step}). Runs on this machine — may take a few minutes.`,
+ postSetupInstalledHint: 'This backend is installed on this machine.',
postSetupRun: 'Run setup',
+ postSetupRerun: 'Re-run setup',
+ postSetupInstalled: 'Installed',
postSetupRunning: 'Installing…',
postSetupStarting: 'Starting…',
postSetupCompleteTitle: 'Setup complete',
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index d24e33ecff04..c4dc2aadbf34 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -862,10 +862,19 @@ export const ja = defineLocale({
needsSignIn: 'サインインが必要',
needsSetup: 'セットアップが必要',
nousIncluded: 'Nous サブスクリプションに含まれています。有効にするには Nous Portal にサインインしてください。',
+ nousAuthNeededTitle: 'Nous Portal にサインイン',
+ nousAuthNeededMessage: provider => `${provider} は保存されましたが、Nous Portal にサインインするまで有効になりません。`,
+ nousAuthSignIn: 'サインイン',
+ nousAuthDoneTitle: 'Nous Portal に接続しました',
+ nousAuthDoneMessage: 'サブスクリプションのバックエンドが有効になりました。',
+ nousAuthFailed: 'Nous Portal のサインインが完了しませんでした',
noApiKeyRequired: 'API キーは不要です。',
postSetupHint: step =>
`このバックエンドは一度だけインストールが必要です (${step})。このマシン上で実行され、数分かかる場合があります。`,
+ postSetupInstalledHint: 'このバックエンドはこのマシンにインストール済みです。',
postSetupRun: 'セットアップを実行',
+ postSetupRerun: 'セットアップを再実行',
+ postSetupInstalled: 'インストール済み',
postSetupRunning: 'インストール中…',
postSetupStarting: '開始中…',
postSetupCompleteTitle: 'セットアップ完了',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index e48c37d6e382..91a502cec363 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -712,9 +712,18 @@ export interface Translations {
needsSignIn: string
needsSetup: string
nousIncluded: string
+ nousAuthNeededTitle: string
+ nousAuthNeededMessage: (provider: string) => string
+ nousAuthSignIn: string
+ nousAuthDoneTitle: string
+ nousAuthDoneMessage: string
+ nousAuthFailed: string
noApiKeyRequired: string
postSetupHint: (step: string) => string
+ postSetupInstalledHint: string
postSetupRun: string
+ postSetupRerun: string
+ postSetupInstalled: string
postSetupRunning: string
postSetupStarting: string
postSetupCompleteTitle: string
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index 4439465ad84a..c51c1ceee39d 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -835,9 +835,18 @@ export const zhHant = defineLocale({
needsSignIn: '需要登入',
needsSetup: '需要安裝',
nousIncluded: '包含在 Nous 訂閱中;登入 Nous Portal 即可啟用。',
+ nousAuthNeededTitle: '登入 Nous Portal',
+ nousAuthNeededMessage: provider => `已儲存 ${provider},但在登入 Nous Portal 之前不會啟用。`,
+ nousAuthSignIn: '登入',
+ nousAuthDoneTitle: '已連接 Nous Portal',
+ nousAuthDoneMessage: '訂閱後端現已啟用。',
+ nousAuthFailed: 'Nous Portal 登入未完成',
noApiKeyRequired: '不需要 API 金鑰。',
postSetupHint: step => `此後端需要一次性安裝 (${step})。將在此機器上執行,可能需要幾分鐘。`,
+ postSetupInstalledHint: '此後端已在此機器上安裝。',
postSetupRun: '執行設定',
+ postSetupRerun: '重新執行設定',
+ postSetupInstalled: '已安裝',
postSetupRunning: '安裝中…',
postSetupStarting: '啟動中…',
postSetupCompleteTitle: '設定完成',
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index c682cdae57a7..d5c56ed6017c 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -1013,9 +1013,18 @@ export const zh: Translations = {
needsSignIn: '需要登录',
needsSetup: '需要安装',
nousIncluded: '包含在 Nous 订阅中;登录 Nous Portal 即可激活。',
+ nousAuthNeededTitle: '登录 Nous Portal',
+ nousAuthNeededMessage: provider => `已保存 ${provider},但在登录 Nous Portal 之前不会激活。`,
+ nousAuthSignIn: '登录',
+ nousAuthDoneTitle: '已连接 Nous Portal',
+ nousAuthDoneMessage: '订阅后端现已激活。',
+ nousAuthFailed: 'Nous Portal 登录未完成',
noApiKeyRequired: '不需要 API 密钥。',
postSetupHint: step => `此后端需要一次性安装 (${step})。将在此机器上执行,可能需要几分钟。`,
+ postSetupInstalledHint: '此后端已在此机器上安装。',
postSetupRun: '运行设置',
+ postSetupRerun: '重新运行设置',
+ postSetupInstalled: '已安装',
postSetupRunning: '安装中…',
postSetupStarting: '启动中…',
postSetupCompleteTitle: '设置完成',
diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py
index 54cce4aa4f38..5f8d305b5d20 100644
--- a/hermes_cli/tools_config.py
+++ b/hermes_cli/tools_config.py
@@ -35,6 +35,40 @@ from utils import base_url_hostname, is_truthy_value
logger = logging.getLogger(__name__)
+
+def _post_setup_no_window_flags(*, streams_to_console: bool = False) -> int:
+ """Win32 creationflags that stop post-setup children flashing a console.
+
+ The dashboard/GUI runs post-setup hooks through a detached, console-less
+ ``hermes tools post-setup `` child. On Windows, every console child
+ (npm.cmd, npx, pip, powershell, curl) spawned from that console-less
+ parent materializes a brand-new console window — the "terminal flash"
+ users see when clicking "Run setup". ``CREATE_NO_WINDOW`` (via
+ :func:`hermes_cli._subprocess_compat.windows_hide_flags`) suppresses it
+ without breaking ``capture_output`` — unlike ``DETACHED_PROCESS``, stdio
+ handles stay inheritable. Returns 0 on POSIX, so passing the result
+ unconditionally is safe.
+
+ ``streams_to_console=True`` marks children spawned WITHOUT stdio
+ redirection (live installer output, e.g. the verbose cua-driver install).
+ Hiding those in an interactive console session would silently swallow
+ their output into an invisible console, so the flag is only applied when
+ the current process has no usable console of its own (stdout is a
+ pipe/log file — exactly the GUI-spawn case that flashes).
+ """
+ from hermes_cli._subprocess_compat import windows_hide_flags
+
+ flags = windows_hide_flags()
+ if not flags:
+ return 0
+ if streams_to_console:
+ try:
+ if sys.stdout is not None and sys.stdout.isatty():
+ return 0
+ except Exception:
+ pass
+ return flags
+
# Platforms already warned about an all-invalid platform_toolsets list, so the
# runtime check in _get_platform_tools warns once per platform instead of on
# every tool resolution for a persistently-corrupt config (#38798).
@@ -667,6 +701,9 @@ def _pip_install(
[uv_bin, "pip", "install", *args],
capture_output=capture_output, text=True, timeout=timeout,
env=uv_env,
+ creationflags=_post_setup_no_window_flags(
+ streams_to_console=not capture_output
+ ),
)
if result.returncode == 0:
return result
@@ -681,6 +718,7 @@ def _pip_install(
probe = subprocess.run(
pip_cmd + ["--version"],
capture_output=True, text=True, timeout=15,
+ creationflags=_post_setup_no_window_flags(),
)
if probe.returncode != 0:
raise FileNotFoundError("pip not in venv")
@@ -689,6 +727,7 @@ def _pip_install(
subprocess.run(
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
capture_output=True, text=True, timeout=120, check=True,
+ creationflags=_post_setup_no_window_flags(),
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
# Synthesize a result so callers see a clean failure path.
@@ -700,6 +739,9 @@ def _pip_install(
return subprocess.run(
pip_cmd + ["install", *args],
capture_output=capture_output, text=True, timeout=timeout,
+ creationflags=_post_setup_no_window_flags(
+ streams_to_console=not capture_output
+ ),
)
@@ -809,6 +851,7 @@ def install_cua_driver(upgrade: bool = False) -> bool:
version = subprocess.run(
[driver_cmd, "--version"],
capture_output=True, text=True, timeout=5, env=_cua_driver_env(),
+ creationflags=_post_setup_no_window_flags(),
).stdout.strip()
_print_success(f" {driver_cmd} already installed: {version or 'unknown version'}")
except Exception:
@@ -866,6 +909,7 @@ def install_cua_driver(upgrade: bool = False) -> bool:
before = subprocess.run(
[driver_cmd, "--version"],
capture_output=True, text=True, timeout=5, env=_cua_driver_env(),
+ creationflags=_post_setup_no_window_flags(),
).stdout.strip()
except Exception:
before = ""
@@ -878,6 +922,7 @@ def install_cua_driver(upgrade: bool = False) -> bool:
after = subprocess.run(
[driver_cmd, "--version"],
capture_output=True, text=True, timeout=5, env=_cua_driver_env(),
+ creationflags=_post_setup_no_window_flags(),
).stdout.strip()
if after and after != before:
_print_success(f" {driver_cmd} upgraded: {before} → {after}")
@@ -1080,7 +1125,9 @@ def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) -
# keep streaming live.
if verbose:
proc = subprocess.Popen(
- install_cmd, shell=use_shell, env=_cua_driver_env(), **popen_kwargs
+ install_cmd, shell=use_shell, env=_cua_driver_env(),
+ creationflags=_post_setup_no_window_flags(streams_to_console=True),
+ **popen_kwargs
)
try:
proc.communicate(timeout=_CUA_INSTALLER_TIMEOUT)
@@ -1095,7 +1142,9 @@ def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) -
proc = subprocess.Popen(
install_cmd, shell=use_shell, env=_cua_driver_env(),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
- text=True, encoding="utf-8", errors="replace", **popen_kwargs
+ text=True, encoding="utf-8", errors="replace",
+ creationflags=_post_setup_no_window_flags(),
+ **popen_kwargs
)
try:
out, _ = proc.communicate(timeout=_CUA_INSTALLER_TIMEOUT)
@@ -1185,7 +1234,8 @@ def _run_post_setup(post_setup_key: str):
# only, avoiding the apps/* glob which would pull in
# apps/desktop (Electron + node-pty) unnecessarily. See #38772.
[npm_bin, "install", "--silent", "--workspaces=false"],
- capture_output=True, text=True, cwd=str(PROJECT_ROOT)
+ capture_output=True, text=True, cwd=str(PROJECT_ROOT),
+ creationflags=_post_setup_no_window_flags(),
)
if result.returncode == 0:
_print_success(" Node.js dependencies installed")
@@ -1194,7 +1244,11 @@ def _run_post_setup(post_setup_key: str):
_print_warning(f" npm install failed - run manually: cd {display_hermes_home()}/hermes-agent && npm install --workspaces=false")
if result.stderr:
_print_info(f" {result.stderr.strip()[:200]}")
- elif not node_modules.exists():
+ elif node_modules.exists():
+ # Distinct message for the re-run case so the GUI action log tells
+ # the truth ("nothing to do") instead of implying a fresh install.
+ _print_success(" agent-browser already installed, nothing to do")
+ else:
_print_warning(" Node.js not found - browser tools require: npm install (in hermes-agent directory)")
return
@@ -1221,7 +1275,7 @@ def _run_post_setup(post_setup_key: str):
return
if _chromium_installed():
- _print_success(" Chromium browser already installed")
+ _print_success(" Chromium browser already installed, nothing to do")
return
if _running_in_docker():
@@ -1261,6 +1315,7 @@ def _run_post_setup(post_setup_key: str):
result = subprocess.run(
install_cmd,
capture_output=True, text=True, cwd=str(PROJECT_ROOT), timeout=600,
+ creationflags=_post_setup_no_window_flags(),
)
if result.returncode == 0:
_print_success(" Chromium installed")
@@ -1284,14 +1339,17 @@ def _run_post_setup(post_setup_key: str):
elif post_setup_key == "camofox":
camofox_dir = PROJECT_ROOT / "node_modules" / "@askjo" / "camofox-browser"
_npm_bin = shutil.which("npm")
- if not camofox_dir.exists() and _npm_bin:
+ if camofox_dir.exists():
+ _print_success(" Camofox already installed, nothing to do")
+ elif _npm_bin:
_print_info(" Installing Camofox browser server...")
import subprocess
# Absolute npm path so .cmd shim executes on Windows.
result = subprocess.run(
# --workspaces=false avoids resolving apps/desktop. See #38772.
[_npm_bin, "install", "--silent", "--workspaces=false"],
- capture_output=True, text=True, cwd=str(PROJECT_ROOT)
+ capture_output=True, text=True, cwd=str(PROJECT_ROOT),
+ creationflags=_post_setup_no_window_flags(),
)
if result.returncode == 0:
_print_success(" Camofox installed")
@@ -2615,9 +2673,20 @@ def _module_installed(module_name: str) -> bool:
def _agent_browser_installed() -> bool:
- from hermes_cli.nous_subscription import _has_agent_browser
+ """True when everything ``_run_post_setup("agent_browser")`` installs is
+ present: the agent-browser CLI *and* the Chromium build it drives (or the
+ 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."""
+ from hermes_cli.nous_subscription import _local_browser_runnable
- return _has_agent_browser()
+ return _local_browser_runnable()
+
+
+def _camofox_installed() -> bool:
+ """True when the Camofox npm package ``_run_post_setup("camofox")``
+ installs is already in node_modules."""
+ return (PROJECT_ROOT / "node_modules" / "@askjo" / "camofox-browser").exists()
# post_setup_key -> predicate(): True when the install side-effect is already
@@ -2631,11 +2700,23 @@ _POST_SETUP_READY: dict = {
"piper": lambda: _module_installed("piper"),
"ddgs": lambda: _module_installed("ddgs"),
"langfuse": lambda: _module_installed("langfuse"),
- "agent_browser": _agent_browser_installed,
+ "agent_browser": lambda: _agent_browser_installed(),
+ "browserbase": lambda: _cloud_agent_browser_installed(),
+ "camofox": lambda: _camofox_installed(),
"cua_driver": lambda: bool(shutil.which(_cua_driver_cmd())),
}
+def _cloud_agent_browser_installed() -> bool:
+ """Installed-check for the ``browserbase`` hook (cloud provider rows).
+
+ Cloud providers host their own Chromium, so their hook only installs the
+ agent-browser npm package — presence of the CLI is the whole contract."""
+ from hermes_cli.nous_subscription import _has_agent_browser
+
+ return _has_agent_browser()
+
+
def provider_readiness_status(
provider: dict,
config: dict,
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index c8d75926d4bb..bd77c55b51df 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -14763,10 +14763,26 @@ async def select_toolset_provider(
write identical config keys (``web.backend``, ``tts.provider``, etc.).
API keys and post-setup flows are handled by separate endpoints. Returns
400 for unknown toolset or provider names.
+
+ Managed Nous rows (``managed_nous_feature``) additionally report the
+ Portal entitlement state: the CLI flow gates these selections on
+ ``ensure_nous_portal_access`` (inline login), but the GUI has no inline
+ prompt, so selecting one while logged out / unentitled used to write the
+ config keys and then never activate (``_is_provider_active`` requires
+ ``managed_by_nous``). The response now carries an additive
+ ``needs_nous_auth: true`` + ``feature`` so the client can drive the
+ existing Nous Portal OAuth flow (``POST /api/providers/oauth/nous/start``)
+ and refetch.
"""
from hermes_cli.tools_config import (
+ TOOL_CATEGORIES,
apply_provider_selection,
_get_effective_configurable_toolsets,
+ _visible_providers,
+ )
+ from hermes_cli.nous_subscription import (
+ MANAGED_FEATURE_COVERAGE_CATEGORY,
+ get_nous_subscription_features,
)
valid = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
@@ -14780,7 +14796,40 @@ async def select_toolset_provider(
except KeyError as exc:
raise HTTPException(status_code=400, detail=str(exc).strip('"'))
save_config(config)
- return {"ok": True, "name": name, "provider": body.provider}
+
+ response: Dict[str, Any] = {"ok": True, "name": name, "provider": body.provider}
+
+ # Entitlement check for managed Nous rows — mirrors the gate the CLI
+ # applies via ensure_nous_portal_access at selection time.
+ cat = TOOL_CATEGORIES.get(name)
+ row = None
+ if cat:
+ row = next(
+ (
+ p
+ for p in _visible_providers(cat, config, force_fresh=True)
+ if p.get("name") == body.provider
+ ),
+ None,
+ )
+ managed_feature = (row or {}).get("managed_nous_feature")
+ if managed_feature:
+ features = get_nous_subscription_features(config, force_fresh=True)
+ acct = features.account_info
+ category = MANAGED_FEATURE_COVERAGE_CATEGORY.get(managed_feature)
+ entitled = bool(
+ acct
+ and acct.logged_in
+ and (
+ acct.tool_gateway_entitled_for(category)
+ if category
+ else acct.tool_gateway_entitled
+ )
+ )
+ if not entitled:
+ response["needs_nous_auth"] = True
+ response["feature"] = managed_feature
+ return response
class ToolsetEnvUpdate(BaseModel):
diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py
index 09a501f33d30..23d4a0956a52 100644
--- a/tests/hermes_cli/test_tools_config.py
+++ b/tests/hermes_cli/test_tools_config.py
@@ -2038,3 +2038,107 @@ def test_provider_readiness_unknown_post_setup_falls_back_to_is_active():
provider = {"name": "Mystery", "env_vars": [], "post_setup": "mystery_hook"}
assert provider_readiness_status(provider, {}, is_active=True) == "ready"
assert provider_readiness_status(provider, {}, is_active=False) == "needs_setup"
+
+
+# ── Windows console-flash guard for post-setup subprocess spawns ──────────────
+#
+# The desktop GUI runs post-setup hooks through a detached, console-less
+# `hermes tools post-setup ` child. On Windows each console child (npm,
+# npx, pip, powershell) spawned without CREATE_NO_WINDOW materializes a brand
+# new console window — the "terminal flash" reported on the Capabilities
+# browser-setup journey. `_post_setup_no_window_flags` is the single wrapper
+# every hook spawn passes as `creationflags`.
+
+
+def test_post_setup_no_window_flags_zero_on_posix(monkeypatch):
+ from hermes_cli import _subprocess_compat
+ from hermes_cli.tools_config import _post_setup_no_window_flags
+
+ monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", False)
+ assert _post_setup_no_window_flags() == 0
+ assert _post_setup_no_window_flags(streams_to_console=True) == 0
+
+
+def test_post_setup_no_window_flags_hides_window_on_windows(monkeypatch):
+ from hermes_cli import _subprocess_compat
+ from hermes_cli.tools_config import _post_setup_no_window_flags
+
+ monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True)
+ # CREATE_NO_WINDOW only — DETACHED_PROCESS would sever stdio and break
+ # capture_output in the hooks.
+ assert _post_setup_no_window_flags() == 0x08000000
+
+
+def test_post_setup_no_window_flags_streaming_keeps_interactive_console(monkeypatch):
+ """A hook that streams live output to a real console must stay visible."""
+ import sys as _sys
+
+ from hermes_cli import _subprocess_compat
+ from hermes_cli.tools_config import _post_setup_no_window_flags
+
+ monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True)
+
+ class _Tty:
+ def isatty(self):
+ return True
+
+ class _Pipe:
+ def isatty(self):
+ return False
+
+ monkeypatch.setattr(_sys, "stdout", _Tty())
+ assert _post_setup_no_window_flags(streams_to_console=True) == 0
+
+ # GUI-spawn case: stdout is a log pipe, no console to stream to — hide.
+ monkeypatch.setattr(_sys, "stdout", _Pipe())
+ assert _post_setup_no_window_flags(streams_to_console=True) == 0x08000000
+
+
+# ── Post-setup readiness predicates for the browser rows ─────────────────────
+#
+# The GUI's "Run setup" idempotence rides on provider_readiness_status
+# reporting ready/needs_setup honestly. agent_browser (local browser) must
+# track the FULL local install (CLI + Chromium), the cloud-provider hook
+# ("browserbase") only the CLI, and camofox its npm package.
+
+
+def test_provider_readiness_agent_browser_tracks_local_install(monkeypatch):
+ provider = {"name": "Local Browser", "env_vars": [], "post_setup": "agent_browser"}
+
+ monkeypatch.setattr(
+ "hermes_cli.nous_subscription._local_browser_runnable", lambda: False
+ )
+ assert provider_readiness_status(provider, {}) == "needs_setup"
+
+ monkeypatch.setattr(
+ "hermes_cli.nous_subscription._local_browser_runnable", lambda: True
+ )
+ assert provider_readiness_status(provider, {}) == "ready"
+
+
+def test_provider_readiness_cloud_browser_hook_tracks_cli_only(monkeypatch):
+ # Cloud rows (post_setup: "browserbase") host their own Chromium — the
+ # agent-browser CLI being present is the whole install contract.
+ provider = {"name": "Browserbase", "env_vars": [], "post_setup": "browserbase"}
+
+ monkeypatch.setattr(
+ "hermes_cli.nous_subscription._has_agent_browser", lambda: False
+ )
+ assert provider_readiness_status(provider, {}) == "needs_setup"
+
+ monkeypatch.setattr(
+ "hermes_cli.nous_subscription._has_agent_browser", lambda: True
+ )
+ assert provider_readiness_status(provider, {}) == "ready"
+
+
+def test_provider_readiness_camofox_tracks_node_modules(monkeypatch, tmp_path):
+ from hermes_cli import tools_config
+
+ provider = {"name": "Camofox", "env_vars": [], "post_setup": "camofox"}
+
+ monkeypatch.setattr(tools_config, "PROJECT_ROOT", tmp_path)
+ assert provider_readiness_status(provider, {}) == "needs_setup"
+
+ (tmp_path / "node_modules" / "@askjo" / "camofox-browser").mkdir(parents=True)
+ assert provider_readiness_status(provider, {}) == "ready"
diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py
index 579f660d6a17..be9193bcb0ba 100644
--- a/tests/hermes_cli/test_web_server.py
+++ b/tests/hermes_cli/test_web_server.py
@@ -5449,6 +5449,70 @@ class TestNewEndpoints:
)
assert resp.status_code == 400
+ def test_select_managed_nous_provider_reports_needs_nous_auth(self, monkeypatch):
+ """Selecting a managed Nous row while logged out flags needs_nous_auth.
+
+ Regression: the GUI PUT wrote browser.cloud_provider + use_gateway
+ but skipped the Portal entitlement handshake the CLI runs inline
+ (ensure_nous_portal_access) — so the row never activated and nothing
+ told the user to sign in. The endpoint now reports the entitlement
+ gap so the client can drive the existing Nous OAuth flow.
+ """
+ from hermes_cli.nous_account import NousPortalAccountInfo
+
+ monkeypatch.setattr(
+ "hermes_cli.nous_subscription.get_nous_portal_account_info",
+ lambda *a, **k: NousPortalAccountInfo(
+ logged_in=False, source="none", fresh=False, paid_service_access=None
+ ),
+ )
+
+ resp = self.client.put(
+ "/api/tools/toolsets/browser/provider",
+ json={"provider": "Nous Subscription (Browser Use cloud)"},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["ok"] is True
+ assert data["needs_nous_auth"] is True
+ assert data["feature"] == "browser"
+ # The selection is still persisted — activation is what's gated.
+ from hermes_cli.config import load_config
+ cfg = load_config()
+ assert cfg["browser"]["cloud_provider"] == "browser-use"
+
+ def test_select_managed_nous_provider_entitled_no_auth_flag(self, monkeypatch):
+ """A signed-in, entitled subscriber gets no needs_nous_auth field."""
+ from hermes_cli.nous_account import NousPortalAccountInfo
+
+ monkeypatch.setattr(
+ "hermes_cli.nous_subscription.get_nous_portal_account_info",
+ lambda *a, **k: NousPortalAccountInfo(
+ logged_in=True, source="jwt", fresh=True, paid_service_access=True
+ ),
+ )
+
+ resp = self.client.put(
+ "/api/tools/toolsets/browser/provider",
+ json={"provider": "Nous Subscription (Browser Use cloud)"},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["ok"] is True
+ assert "needs_nous_auth" not in data
+
+ def test_select_unmanaged_provider_has_no_nous_auth_field(self):
+ """Non-managed rows never carry the entitlement fields."""
+ resp = self.client.put(
+ "/api/tools/toolsets/web/provider",
+ json={"provider": "Firecrawl Self-Hosted"},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["ok"] is True
+ assert "needs_nous_auth" not in data
+ assert "feature" not in data
+
def test_select_toolset_provider_unknown_toolset_returns_400(self):
resp = self.client.put(
"/api/tools/toolsets/not_a_real_toolset/provider",