feat(desktop): run tool backend post-setup installs from the GUI (#40559)

Complete the desktop app's tool-backend configuration so it fully
mirrors `hermes tools`. The toolset config panel already did
enable/disable, provider selection, and API-key save/reveal/clear — the
one remaining gap was post-setup install hooks, which previously just
told the user to run the CLI.

Now a provider that declares a post_setup hook (browser Chromium,
Camofox, cua-driver, KittenTTS/Piper, ddgs, Spotify, Langfuse, xAI)
renders a 'Run setup' button that spawns the install via the
`POST /api/tools/toolsets/{name}/post-setup` endpoint and tails the
log inline, feeding the desktop activity rail — mirroring
command-center's runSystemAction poll loop. On completion the panel
refreshes so a now-installed backend reports itself ready.

- hermes.ts: runToolsetPostSetup(name, key) -> profile-scoped POST.
- toolset-config-panel.tsx: PostSetupRunner sub-component (Run setup
  button + inline live log + activity-rail upsert + unmount guard),
  replacing the CLI-only placeholder.
- i18n: replace the orphaned `toolsets.postSetup` (CLI redirect) string
  with proper post-setup UI keys (hint / run / running / starting /
  complete / error / failed) across en, ja, zh, zh-hant + types.
- test: post-setup run+poll+log-tail coverage; mock additions for
  runToolsetPostSetup/getActionStatus/activity store.

Works against local AND remote backends: all calls route through the
desktop's single `hermes:api` IPC handler to connection.baseUrl, so a
connected remote configures the remote host's tools (keys -> remote
.env, install runs on the remote). Relies on the post-setup endpoint +
'hermes tools post-setup' CLI shipped in #40418.

Verification: tsc -b clean (all 5 locales), eslint clean (the lone
exhaustive-deps warning is pre-existing on origin/main), vitest 4/5
(new post-setup test passes; the failing 'saves an API key' test fails
identically on origin/main — pre-existing EnvVarActionsMenu drift).
This commit is contained in:
Teknium 2026-06-06 18:35:02 -07:00 committed by GitHub
parent 8f7567c325
commit 97524344ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 323 additions and 15 deletions

View file

@ -8,13 +8,17 @@ const selectToolsetProvider = vi.fn()
const setEnvVar = vi.fn()
const deleteEnvVar = vi.fn()
const revealEnvVar = vi.fn()
const runToolsetPostSetup = vi.fn()
const getActionStatus = vi.fn()
vi.mock('@/hermes', () => ({
getToolsetConfig: (name: string) => getToolsetConfig(name),
selectToolsetProvider: (name: string, provider: string) => selectToolsetProvider(name, provider),
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
deleteEnvVar: (key: string) => deleteEnvVar(key),
revealEnvVar: (key: string) => revealEnvVar(key)
revealEnvVar: (key: string) => revealEnvVar(key),
runToolsetPostSetup: (name: string, key: string) => runToolsetPostSetup(name, key),
getActionStatus: (name: string, lines?: number) => getActionStatus(name, lines)
}))
vi.mock('@/store/notifications', () => ({
@ -22,6 +26,10 @@ vi.mock('@/store/notifications', () => ({
notifyError: vi.fn()
}))
vi.mock('@/store/activity', () => ({
upsertDesktopActionTask: vi.fn()
}))
function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
return {
name: 'tts',
@ -152,4 +160,130 @@ describe('ToolsetConfigPanel', () => {
// No provider selection was triggered — this is purely reflecting state.
expect(selectToolsetProvider).not.toHaveBeenCalled()
})
it('runs a provider post-setup install hook and tails its log', async () => {
// A browser-style toolset whose active provider declares a post_setup hook.
getToolsetConfig.mockResolvedValue(
config({
name: 'browser',
active_provider: 'Camofox',
providers: [
{
name: 'Camofox',
badge: 'local',
tag: 'Stealth local browser',
env_vars: [],
post_setup: 'camofox',
requires_nous_auth: false,
is_active: true
}
]
})
)
runToolsetPostSetup.mockResolvedValue({ ok: true, pid: 4321, name: 'tools-post-setup', key: 'camofox' })
// First poll: still running; second poll: finished cleanly.
getActionStatus
.mockResolvedValueOnce({
exit_code: null,
lines: ['Installing Camofox browser server...'],
name: 'tools-post-setup',
pid: 4321,
running: true
})
.mockResolvedValue({
exit_code: 0,
lines: ['Installing Camofox browser server...', "Post-setup 'camofox' complete"],
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: /Run setup/ }))
await waitFor(() => expect(runToolsetPostSetup).toHaveBeenCalledWith('browser', 'camofox'))
// The install log is tailed inline. The first poll fires after a 1200ms
// delay (mirrors command-center's poll cadence), so allow >1200ms here.
await waitFor(() => expect(getActionStatus).toHaveBeenCalledWith('tools-post-setup', 300), {
timeout: 4000
})
})
it('does not poll when the spawn endpoint reports ok:false', async () => {
getToolsetConfig.mockResolvedValue(
config({
name: 'browser',
active_provider: 'Camofox',
providers: [
{
name: 'Camofox',
badge: 'local',
tag: 'Stealth local browser',
env_vars: [],
post_setup: 'camofox',
requires_nous_auth: false,
is_active: true
}
]
})
)
// Spawn failed server-side — must NOT proceed to poll a non-existent action.
runToolsetPostSetup.mockResolvedValue({ ok: false, pid: 0, name: 'tools-post-setup' })
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
fireEvent.click(await screen.findByRole('button', { name: /Run setup/ }))
await waitFor(() => expect(runToolsetPostSetup).toHaveBeenCalledWith('browser', 'camofox'))
// Give the would-be first poll delay (1200ms) time to NOT fire.
await new Promise(resolve => setTimeout(resolve, 1500))
expect(getActionStatus).not.toHaveBeenCalled()
})
it('surfaces a non-zero exit code from the setup process', async () => {
getToolsetConfig.mockResolvedValue(
config({
name: 'browser',
active_provider: 'Camofox',
providers: [
{
name: 'Camofox',
badge: 'local',
tag: 'Stealth local browser',
env_vars: [],
post_setup: 'camofox',
requires_nous_auth: false,
is_active: true
}
]
})
)
runToolsetPostSetup.mockResolvedValue({ ok: true, pid: 4321, name: 'tools-post-setup', key: 'camofox' })
// Action finished but failed (non-zero exit).
getActionStatus.mockResolvedValue({
exit_code: 1,
lines: ['Installing...', 'npm ERR! install failed'],
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: /Run setup/ }))
// The failing install log is still tailed and shown; exit_code:1 routes to
// the error notify branch (asserted via the poll completing on a non-zero
// status without throwing).
await waitFor(() => expect(getActionStatus).toHaveBeenCalledWith('tools-post-setup', 300), {
timeout: 4000
})
await waitFor(() => expect(screen.getByText(/npm ERR! install failed/)).toBeTruthy(), {
timeout: 4000
})
})
})

View file

@ -1,14 +1,23 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes'
import {
deleteEnvVar,
getActionStatus,
getToolsetConfig,
revealEnvVar,
runToolsetPostSetup,
selectToolsetProvider,
setEnvVar
} from '@/hermes'
import { useI18n } from '@/i18n'
import { Check, Loader2, Save } from '@/lib/icons'
import { Check, Loader2, Save, Terminal } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { upsertDesktopActionTask } from '@/store/activity'
import { notify, notifyError } from '@/store/notifications'
import type { ToolEnvVar, ToolProvider, ToolsetConfig } from '@/types/hermes'
import type { ActionStatusResponse, ToolEnvVar, ToolProvider, ToolsetConfig } from '@/types/hermes'
import { EnvVarActionsMenu, EnvVarActionsTrigger } from './env-var-actions-menu'
import { Pill } from './primitives'
@ -157,6 +166,120 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
)
}
interface PostSetupRunnerProps {
toolset: string
/** The provider's post_setup hook key (e.g. "camofox", "ddgs"). */
postSetupKey: string
/** Refresh the parent config after the install finishes (a backend may now
* report itself configured). */
onComplete?: () => void
}
/**
* Runs a provider's post-setup install hook (npm / pip / binary) via the
* `/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.
*/
function PostSetupRunner({ toolset, postSetupKey, onComplete }: PostSetupRunnerProps) {
const { t } = useI18n()
const copy = t.settings.toolsets
const [running, setRunning] = useState(false)
const [status, setStatus] = useState<ActionStatusResponse | null>(null)
// Guard against overlapping polls / state updates after unmount.
const activeRef = useRef(false)
useEffect(() => {
return () => {
activeRef.current = false
}
}, [])
const run = useCallback(async () => {
setRunning(true)
setStatus(null)
activeRef.current = true
try {
const started = await runToolsetPostSetup(toolset, postSetupKey)
// The spawn endpoint reports ok:false if it couldn't launch the action
// (e.g. unknown key, server-side spawn failure). Don't poll a status
// that will never exist — surface the failure and stop.
if (!started.ok) {
notifyError(new Error('spawn failed'), copy.postSetupFailed(postSetupKey))
return
}
let last: ActionStatusResponse | null = null
// Mirror command-center's runSystemAction poll loop: poll the action log
// until it exits (or we hit the attempt ceiling), feeding the global
// activity rail as we go.
for (let attempt = 0; attempt < 150 && activeRef.current; attempt += 1) {
await new Promise(resolve => window.setTimeout(resolve, 1200))
if (!activeRef.current) {
break
}
const polled = await getActionStatus(started.name, 300)
last = polled
setStatus(polled)
upsertDesktopActionTask(polled)
if (!polled.running) {
break
}
}
if (activeRef.current) {
const ok = last?.exit_code === 0
notify(
ok
? {
kind: 'success',
title: copy.postSetupCompleteTitle,
message: copy.postSetupCompleteMessage(postSetupKey)
}
: { kind: 'error', title: copy.postSetupErrorTitle, message: copy.postSetupErrorMessage(postSetupKey) }
)
onComplete?.()
}
} catch (err) {
if (activeRef.current) {
notifyError(err, copy.postSetupFailed(postSetupKey))
}
} finally {
if (activeRef.current) {
setRunning(false)
}
}
}, [toolset, postSetupKey, onComplete, copy])
return (
<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>
</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>
</div>
{status && (status.lines.length > 0 || status.running) && (
<pre className="max-h-48 overflow-y-auto rounded-md bg-background px-2.5 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground whitespace-pre-wrap">
{status.lines.length > 0 ? status.lines.join('\n') : copy.postSetupStarting}
</pre>
)}
</div>
)
}
export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfigPanelProps) {
const { t } = useI18n()
const copy = t.settings.toolsets
@ -310,9 +433,11 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
))
)}
{provider.post_setup && (
<p className="text-[0.72rem] text-muted-foreground">
{copy.postSetup(provider.post_setup)}
</p>
<PostSetupRunner
onComplete={() => void refresh()}
postSetupKey={provider.post_setup}
toolset={toolset}
/>
)}
</div>
)}

View file

@ -443,6 +443,15 @@ export function selectToolsetProvider(
})
}
export function runToolsetPostSetup(name: string, key: string): Promise<ActionResponse & { key: string }> {
return window.hermesDesktop.api<ActionResponse & { key: string }>({
...profileScoped(),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`,
method: 'POST',
body: { key }
})
}
export function getMessagingPlatforms(): Promise<MessagingPlatformsResponse> {
return window.hermesDesktop.api<MessagingPlatformsResponse>({
path: '/api/messaging/platforms'

View file

@ -542,8 +542,16 @@ export const en: Translations = {
ready: 'Ready',
nousIncluded: 'Included with a Nous subscription — sign in to Nous Portal to activate.',
noApiKeyRequired: 'No API key required.',
postSetup: step =>
`This provider needs an extra setup step (${step}). Run it from the CLI with hermes tools for now.`
postSetupHint: step =>
`This backend needs a one-time install (${step}). Runs on this machine — may take a few minutes.`,
postSetupRun: 'Run setup',
postSetupRunning: 'Installing…',
postSetupStarting: 'Starting…',
postSetupCompleteTitle: 'Setup complete',
postSetupCompleteMessage: step => `${step} installed.`,
postSetupErrorTitle: 'Setup finished with errors',
postSetupErrorMessage: step => `Check the ${step} log.`,
postSetupFailed: step => `Failed to run ${step} setup`
}
},

View file

@ -637,8 +637,16 @@ export const ja = defineLocale({
ready: '準備完了',
nousIncluded: 'Nous サブスクリプションに含まれています。有効にするには Nous Portal にサインインしてください。',
noApiKeyRequired: 'API キーは不要です。',
postSetup: step =>
`このプロバイダーは追加のセットアップ手順 (${step}) が必要です。今は CLI で hermes tools を実行してください。`
postSetupHint: step =>
`このバックエンドは一度だけインストールが必要です (${step})。このマシン上で実行され、数分かかる場合があります。`,
postSetupRun: 'セットアップを実行',
postSetupRunning: 'インストール中…',
postSetupStarting: '開始中…',
postSetupCompleteTitle: 'セットアップ完了',
postSetupCompleteMessage: step => `${step} をインストールしました。`,
postSetupErrorTitle: 'セットアップはエラーで終了しました',
postSetupErrorMessage: step => `${step} のログを確認してください。`,
postSetupFailed: step => `${step} のセットアップの実行に失敗しました`
}
},

View file

@ -450,7 +450,15 @@ export interface Translations {
ready: string
nousIncluded: string
noApiKeyRequired: string
postSetup: (step: string) => string
postSetupHint: (step: string) => string
postSetupRun: string
postSetupRunning: string
postSetupStarting: string
postSetupCompleteTitle: string
postSetupCompleteMessage: (step: string) => string
postSetupErrorTitle: string
postSetupErrorMessage: (step: string) => string
postSetupFailed: (step: string) => string
}
}

View file

@ -621,7 +621,15 @@ export const zhHant = defineLocale({
ready: '就緒',
nousIncluded: '包含在 Nous 訂閱中;登入 Nous Portal 即可啟用。',
noApiKeyRequired: '不需要 API 金鑰。',
postSetup: step => `此提供方需要額外設定步驟 (${step})。暫時請在 CLI 中執行 hermes tools。`
postSetupHint: step => `此後端需要一次性安裝 (${step})。將在此機器上執行,可能需要幾分鐘。`,
postSetupRun: '執行設定',
postSetupRunning: '安裝中…',
postSetupStarting: '啟動中…',
postSetupCompleteTitle: '設定完成',
postSetupCompleteMessage: step => `已安裝 ${step}`,
postSetupErrorTitle: '設定完成但有錯誤',
postSetupErrorMessage: step => `請檢查 ${step} 日誌。`,
postSetupFailed: step => `執行 ${step} 設定失敗`
}
},

View file

@ -696,7 +696,15 @@ export const zh: Translations = {
ready: '就绪',
nousIncluded: '包含在 Nous 订阅中;登录 Nous Portal 即可激活。',
noApiKeyRequired: '不需要 API 密钥。',
postSetup: step => `此提供方需要额外设置步骤 (${step})。暂时请在 CLI 中运行 hermes tools。`
postSetupHint: step => `此后端需要一次性安装 (${step})。将在此机器上执行,可能需要几分钟。`,
postSetupRun: '运行设置',
postSetupRunning: '安装中…',
postSetupStarting: '启动中…',
postSetupCompleteTitle: '设置完成',
postSetupCompleteMessage: step => `已安装 ${step}`,
postSetupErrorTitle: '设置完成但有错误',
postSetupErrorMessage: step => `请检查 ${step} 日志。`,
postSetupFailed: step => `运行 ${step} 设置失败`
}
},