mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
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:
parent
09109fec98
commit
aa1ad32191
12 changed files with 719 additions and 24 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: 'セットアップ完了',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '設定完成',
|
||||
|
|
|
|||
|
|
@ -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: '设置完成',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue