fix(desktop): Windows browser-setup journey — console flash, idempotent setup, Nous Portal activation (#67473)

* 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 <key>' 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.
This commit is contained in:
Teknium 2026-07-19 05:22:10 -07:00 committed by GitHub
parent 09109fec98
commit aa1ad32191
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 719 additions and 24 deletions

View file

@ -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(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
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(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
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(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
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(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
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(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
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(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
fireEvent.click(await screen.findByRole('button', { name: /Nous Subscription/ }))
await waitFor(() => expect(notify).toHaveBeenCalledWith(expect.objectContaining({ kind: 'success' })))
expect(startOAuthLogin).not.toHaveBeenCalled()
})
})
})

View file

@ -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
<div className="grid gap-2 rounded-lg bg-background/55 p-2.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-[0.72rem] text-muted-foreground">{copy.postSetupHint(postSetupKey)}</p>
<p className="text-[0.72rem] text-muted-foreground">
{installed ? copy.postSetupInstalledHint : copy.postSetupHint(postSetupKey)}
</p>
</div>
<Button disabled={running} onClick={() => void run()} size="sm">
{running ? <Loader2 className="size-3.5 animate-spin" /> : <Terminal className="size-3.5" />}
{running ? copy.postSetupRunning : copy.postSetupRun}
</Button>
{installed ? (
<span className="flex items-center gap-2">
<Pill tone="primary">
<Check className="size-3" />
{copy.postSetupInstalled}
</Pill>
<Button disabled={running} onClick={() => void run()} size="sm" variant="text">
{running ? <Loader2 className="size-3.5 animate-spin" /> : <Terminal className="size-3.5" />}
{running ? copy.postSetupRunning : copy.postSetupRerun}
</Button>
</span>
) : (
<Button disabled={running} onClick={() => void run()} size="sm">
{running ? <Loader2 className="size-3.5 animate-spin" /> : <Terminal className="size-3.5" />}
{running ? copy.postSetupRunning : copy.postSetupRun}
</Button>
)}
</div>
{status && (status.lines.length > 0 || status.running) && (
@ -455,6 +481,16 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
const [activeProvider, setActiveProvider] = useState<string | null>(null)
// Live per-key set/unset state, seeded from the endpoint then patched locally.
const [envState, setEnvState] = useState<Record<string, boolean>>({})
// 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 && (
<PostSetupRunner
installed={provider.status === 'ready'}
onComplete={() => void refresh()}
postSetupKey={provider.post_setup}
toolset={toolset}

View file

@ -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<SelectToolsetProviderResponse> {
return window.hermesDesktop.api<SelectToolsetProviderResponse>({
...profileScoped(),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`,
method: 'PUT',

View file

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

View file

@ -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: 'セットアップ完了',

View file

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

View file

@ -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: '設定完成',

View file

@ -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: '设置完成',

View file

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

View file

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

View file

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

View file

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