From 97524344adbd0617207e83b0dd2c9155deb01c7c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:35:02 -0700 Subject: [PATCH] feat(desktop): run tool backend post-setup installs from the GUI (#40559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../settings/toolset-config-panel.test.tsx | 136 ++++++++++++++++- .../src/app/settings/toolset-config-panel.tsx | 139 +++++++++++++++++- apps/desktop/src/hermes.ts | 9 ++ apps/desktop/src/i18n/en.ts | 12 +- apps/desktop/src/i18n/ja.ts | 12 +- apps/desktop/src/i18n/types.ts | 10 +- apps/desktop/src/i18n/zh-hant.ts | 10 +- apps/desktop/src/i18n/zh.ts | 10 +- 8 files changed, 323 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/app/settings/toolset-config-panel.test.tsx b/apps/desktop/src/app/settings/toolset-config-panel.test.tsx index a6dd99ae679..379f2580f45 100644 --- a/apps/desktop/src/app/settings/toolset-config-panel.test.tsx +++ b/apps/desktop/src/app/settings/toolset-config-panel.test.tsx @@ -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 { 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() + + 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() + + 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() + + 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 + }) + }) }) diff --git a/apps/desktop/src/app/settings/toolset-config-panel.tsx b/apps/desktop/src/app/settings/toolset-config-panel.tsx index aeff184e39d..a321096f183 100644 --- a/apps/desktop/src/app/settings/toolset-config-panel.tsx +++ b/apps/desktop/src/app/settings/toolset-config-panel.tsx @@ -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(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 ( +
+
+
+

{copy.postSetupHint(postSetupKey)}

+
+ +
+ + {status && (status.lines.length > 0 || status.running) && ( +
+          {status.lines.length > 0 ? status.lines.join('\n') : copy.postSetupStarting}
+        
+ )} +
+ ) +} + 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 && ( -

- {copy.postSetup(provider.post_setup)} -

+ void refresh()} + postSetupKey={provider.post_setup} + toolset={toolset} + /> )} )} diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index c4621ba8d83..aac7c0acd2b 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -443,6 +443,15 @@ export function selectToolsetProvider( }) } +export function runToolsetPostSetup(name: string, key: string): Promise { + return window.hermesDesktop.api({ + ...profileScoped(), + path: `/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`, + method: 'POST', + body: { key } + }) +} + export function getMessagingPlatforms(): Promise { return window.hermesDesktop.api({ path: '/api/messaging/platforms' diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 1ae5b1e6b27..4dedd88cc52 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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` } }, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 3987eec905d..9d6e752f058 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -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} のセットアップの実行に失敗しました` } }, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index cc2281c366e..11425046844 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 } } diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index aa58b00da9e..6d17cada126 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -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} 設定失敗` } }, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 46caac06445..4b228e7ab60 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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} 设置失败` } },