From aa1ad32191c4c8b6f15fd7fb213dbf8bfee28652 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 19 Jul 2026 05:22:10 -0700 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20Windows=20browser-setup=20journ?= =?UTF-8?q?ey=20=E2=80=94=20console=20flash,=20idempotent=20setup,=20Nous?= =?UTF-8?q?=20Portal=20activation=20(#67473)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(windows): suppress console-window flash in tools post-setup subprocess spawns The desktop GUI runs post-setup hooks via a detached, console-less 'hermes tools post-setup ' child (spawned with windows_detach_flags). But the hook implementations in tools_config.py ran their inner installers (npm install, agent-browser install, uv/pip installs, ensurepip, cua-driver version probes and installer) without Windows creationflags — and on Windows a console-less parent spawning a console/.cmd child materializes a brand-new console window, the 'terminal flash' reported on the Capabilities > Browser Automation setup journey. Add _post_setup_no_window_flags(), a local wrapper around windows_hide_flags() (CREATE_NO_WINDOW only — DETACHED_PROCESS would sever stdio and break capture_output), and pass it at every post-setup subprocess call site. Spawns that stream live output to the user's console (verbose cua-driver install) only hide when stdout is not a tty, so interactive CLI installs keep their output. POSIX behavior is unchanged (the helper returns 0 off-Windows). * fix(desktop): make Capabilities post-setup idempotent — Installed state instead of unconditional Run setup The GUI panel rendered the primary 'Run setup' CTA whenever a provider declared post_setup, ignoring the server-computed readiness status the config endpoint already serves. Users on Windows clicked 'Run setup' on an already-installed Local Browser and watched it 'install' again. Frontend: PostSetupRunner now takes installed (provider.status === 'ready') and renders an 'Installed' pill + small 'Re-run setup' text button in that state; onComplete still refetches the toolset config, so a fresh install flips the row to Installed once the endpoint reports ready. Backend: - _POST_SETUP_READY extended: agent_browser now tracks the FULL local install (_local_browser_runnable: CLI + Chromium-or-Lightpanda) instead of the bare CLI check; new entries for the cloud 'browserbase' hook (CLI only — cloud rows host their own Chromium) and camofox (npm package present). - _run_post_setup prints distinct 'already installed, nothing to do' messages for the agent-browser/Chromium/Camofox early-exits so the GUI action log tells the truth on re-runs vs fresh installs. i18n: new postSetupInstalled/postSetupRerun/postSetupInstalledHint strings in en, ja, zh, zh-hant + types. * fix(desktop): let managed Nous Subscription rows activate from the GUI via the Portal sign-in flow PUT /api/tools/toolsets/{name}/provider intentionally skips the Nous Portal auth gate the CLI runs inline (ensure_nous_portal_access) — but no desktop surface handled it. Selecting 'Nous Subscription (Browser Use cloud)' from Capabilities wrote browser.cloud_provider=browser-use + use_gateway=true and then silently never activated: _is_provider_active requires feature.managed_by_nous, which stays false without the entitlement, and the credential was never used. Backend: after apply_provider_selection, the endpoint now checks the managed row's entitlement (get_nous_subscription_features force_fresh + the same per-category coverage gate the CLI applies) and reports the gap with additive response fields {needs_nous_auth: true, feature}. The selection is still persisted — activation is what's gated. Frontend: handleSelect surfaces a 'Sign in to Nous Portal' warning toast with a Sign-in action instead of the misleading success toast. The action drives the EXISTING Nous Portal OAuth device-code flow (provider id 'nous' in _OAUTH_PROVIDER_CATALOG): POST /api/providers/oauth/nous/start, open verification_url, poll /poll/{session}; on approval the panel refetches the toolset config so is_active/status flip. i18n: nousAuthNeeded*/nousAuthSignIn/nousAuthDone*/nousAuthFailed strings in en, ja, zh, zh-hant + types. --- .../settings/toolset-config-panel.test.tsx | 236 +++++++++++++++++- .../src/app/settings/toolset-config-panel.tsx | 125 +++++++++- apps/desktop/src/hermes.ts | 17 +- apps/desktop/src/i18n/en.ts | 9 + apps/desktop/src/i18n/ja.ts | 9 + apps/desktop/src/i18n/types.ts | 9 + apps/desktop/src/i18n/zh-hant.ts | 9 + apps/desktop/src/i18n/zh.ts | 9 + hermes_cli/tools_config.py | 101 +++++++- hermes_cli/web_server.py | 51 +++- tests/hermes_cli/test_tools_config.py | 104 ++++++++ tests/hermes_cli/test_web_server.py | 64 +++++ 12 files changed, 719 insertions(+), 24 deletions(-) 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",