diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 996826adf..a5c5e3833 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -13,6 +13,7 @@ import { useLocation } from 'react-router-dom' import { Thread } from '@/components/assistant-ui/thread' import { Backdrop } from '@/components/Backdrop' import { NotificationStack } from '@/components/notifications' +import { PromptOverlays } from '@/components/prompt-overlays' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { getGlobalModelOptions, type HermesGateway } from '@/hermes' @@ -315,6 +316,7 @@ export function ChatView({ /> +
{ + cleanup() + $approvalRequest.set(null) + $gateway.set(null) +}) + +describe('PendingToolApproval', () => { + it('renders nothing when there is no pending approval', () => { + const { container } = render() + + expect(container.innerHTML).toBe('') + }) + + it('renders nothing for tools that never raise approval', () => { + setRequest() + const { container } = render() + + expect(container.innerHTML).toBe('') + }) + + it('renders the inline run/reject controls on the pending terminal row', () => { + setRequest('chmod -R 777 /tmp/x') + render() + + expect(screen.getByRole('button', { name: /Run/ })).toBeTruthy() + expect(screen.getByRole('button', { name: /Reject/ })).toBeTruthy() + }) + + it('sends approval.respond {choice: "once"} and clears the request on Run', async () => { + const request = mockGateway() + setRequest() + render() + + fireEvent.click(screen.getByRole('button', { name: /Run/ })) + + await waitFor(() => { + expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'once', session_id: 'sess-1' }) + }) + expect($approvalRequest.get()).toBeNull() + }) + + it('sends choice "deny" on Reject', async () => { + const request = mockGateway() + setRequest() + render() + + fireEvent.click(screen.getByRole('button', { name: /Reject/ })) + + await waitFor(() => { + expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'sess-1' }) + }) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/tool-approval.tsx b/apps/desktop/src/components/assistant-ui/tool-approval.tsx new file mode 100644 index 000000000..8e6dfdaaf --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool-approval.tsx @@ -0,0 +1,213 @@ +'use client' + +import { useStore } from '@nanostores/react' +import { type FC, useCallback, useEffect, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { triggerHaptic } from '@/lib/haptics' +import { ChevronDown, Loader2 } from '@/lib/icons' +import { $gateway } from '@/store/gateway' +import { notifyError } from '@/store/notifications' +import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts' + +import type { ToolPart } from './tool-fallback-model' + +// Inline approval control. Rendered as a compact button strip +// under the pending tool row that raised the approval (the row already shows +// the command, so the strip deliberately doesn't repeat it) instead of as a +// modal overlay. +// +// Binding is POSITIONAL, not command-matched: the desktop `tool.start` payload +// carries no structured args (only tool_id/name/context — see +// tui_gateway/server.py::_on_tool_start), so we cannot join the approval to the +// row by command string. But `approval.request` only ever fires from the +// `terminal` / `execute_code` guards and the agent thread blocks on exactly one +// approval at a time, so the single pending row of those tools IS the row that +// raised it. The command/description text comes from `$approvalRequest` (the +// event payload), which is the only place that data reliably exists. +const APPROVAL_TOOLS = new Set(['terminal', 'execute_code']) + +// Canonical gateway choices (ui-tui/src/components/prompts.tsx). +type ApprovalChoice = 'once' | 'session' | 'always' | 'deny' + +export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => { + const request = useStore($approvalRequest) + + if (!request || !APPROVAL_TOOLS.has(part.toolName)) { + return null + } + + return +} + +const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform) + +const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => { + const gateway = useStore($gateway) + const [submitting, setSubmitting] = useState(null) + // "Always allow" persists the pattern to ~/.hermes/config.yaml permanently, so + // it goes through a confirm step rather than firing straight from the menu. + const [confirmAlways, setConfirmAlways] = useState(false) + const busy = submitting !== null + + const respond = useCallback( + async (choice: ApprovalChoice) => { + // Another bar (or the keyboard path) may have already resolved this + // approval; the atom is the single source of truth, so bail if it's gone. + if (busy || !$approvalRequest.get()) { + return + } + + if (!gateway) { + notifyError(new Error('Hermes gateway is not connected'), 'Could not send approval response') + + return + } + + setSubmitting(choice) + + try { + await gateway.request<{ resolved?: boolean }>('approval.respond', { + choice, + session_id: request.sessionId ?? undefined + }) + triggerHaptic(choice === 'deny' ? 'cancel' : 'submit') + clearApprovalRequest() + } catch (error) { + notifyError(error, 'Could not send approval response') + setSubmitting(null) + } + }, + [busy, gateway, request.sessionId] + ) + + // ⌘/Ctrl+Enter → Run, Esc → Reject. + // While the confirm dialog is open it owns the keyboard (Esc closes it), so + // the strip-level shortcuts stand down to avoid denying the whole approval. + useEffect(() => { + if (confirmAlways) { + return + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + void respond('once') + } else if (event.key === 'Escape') { + event.preventDefault() + void respond('deny') + } + } + + window.addEventListener('keydown', onKeyDown, true) + + return () => window.removeEventListener('keydown', onKeyDown, true) + }, [confirmAlways, respond]) + + return ( +
+
+ + + + + + + + void respond('session')}>Allow this session + { + // Defer one tick so the menu fully unmounts before the dialog + // mounts — otherwise Radix's focus-return races the dialog and + // dismisses it via onInteractOutside. + setTimeout(() => setConfirmAlways(true), 0) + }} + > + Always allow… + + void respond('deny')} variant="destructive"> + Reject + + + +
+ + + + + + + Always allow this command? + + This adds the “{request.description}” pattern to your permanent allowlist ( + ~/.hermes/config.yaml). Hermes won’t ask again for commands + like this — in this session or any future one. + + + + {request.command.trim() && ( +
+              {request.command.trim()}
+            
+ )} + + + + + +
+
+
+ ) +} diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index cb8cd2194..32498bca4 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -24,6 +24,7 @@ import { cn } from '@/lib/utils' import { $toolInlineDiffs } from '@/store/tool-diffs' import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view' +import { PendingToolApproval } from './tool-approval' import { groupCopyText as buildGroupCopyText, buildToolView, @@ -309,6 +310,7 @@ function ToolEntry({ part }: ToolEntryProps) {
+ {isPending && } {open && (
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && ( diff --git a/apps/desktop/src/components/prompt-overlays.tsx b/apps/desktop/src/components/prompt-overlays.tsx new file mode 100644 index 000000000..1483deae5 --- /dev/null +++ b/apps/desktop/src/components/prompt-overlays.tsx @@ -0,0 +1,230 @@ +'use client' + +import { useStore } from '@nanostores/react' +import { type FormEvent, useCallback, useEffect, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { triggerHaptic } from '@/lib/haptics' +import { KeyRound, Loader2, Lock } from '@/lib/icons' +import { $gateway } from '@/store/gateway' +import { notifyError } from '@/store/notifications' +import { $secretRequest, $sudoRequest, clearSecretRequest, clearSudoRequest } from '@/store/prompts' + +// Renders the modal mid-turn prompts the gateway raises and waits on: sudo +// password and skill secret capture. (Dangerous-command / execute_code approval +// is rendered INLINE on the pending tool row instead — see +// components/assistant-ui/tool-approval.tsx — so it reads like an inline "Run" +// affordance rather than a blocking modal.) Each Python-side caller blocks the +// agent thread until the matching `*.respond` RPC lands; without a renderer the +// agent stalls until its timeout and the tool is BLOCKED (the bug this fixes — +// desktop handled clarify.request but not these). Any close path (Esc, backdrop +// click) funnels through Radix's single `onOpenChange(false)` and maps to a +// refusal, so silence is never mistaken for consent, matching the TUI. We +// deliberately do NOT add onEscapeKeyDown / onInteractOutside handlers — they'd +// fire a second `*.respond` alongside onOpenChange (double-send) or block the +// backdrop-dismiss path. + +function SudoDialog() { + const request = useStore($sudoRequest) + const gateway = useStore($gateway) + const [password, setPassword] = useState('') + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + setPassword('') + setSubmitting(false) + }, [request?.requestId]) + + const send = useCallback( + async (value: string) => { + if (!request) { + return + } + + if (!gateway) { + notifyError(new Error('Hermes gateway is not connected'), 'Could not send sudo password') + + return + } + + setSubmitting(true) + + try { + await gateway.request<{ status?: string }>('sudo.respond', { + password: value, + request_id: request.requestId + }) + triggerHaptic('submit') + clearSudoRequest(request.requestId) + } catch (error) { + notifyError(error, 'Could not send sudo password') + setSubmitting(false) + } + }, + [gateway, request] + ) + + // Cancel → empty password. The backend treats an empty sudo response as a + // failed sudo (no command runs), so closing the dialog is a safe refusal. + const onOpenChange = useCallback( + (open: boolean) => { + if (!open && !submitting && request) { + void send('') + } + }, + [request, send, submitting] + ) + + const onSubmit = useCallback( + (event: FormEvent) => { + event.preventDefault() + void send(password) + }, + [password, send] + ) + + if (!request) { + return null + } + + return ( + + + + + + Administrator password + + + Hermes needs your sudo password to run a privileged command. It is sent only to your local agent. + + + +
+ setPassword(event.target.value)} + placeholder="sudo password" + type="password" + value={password} + /> + + + + +
+
+
+ ) +} + +function SecretDialog() { + const request = useStore($secretRequest) + const gateway = useStore($gateway) + const [value, setValue] = useState('') + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + setValue('') + setSubmitting(false) + }, [request?.requestId]) + + const send = useCallback( + async (secret: string) => { + if (!request) { + return + } + + if (!gateway) { + notifyError(new Error('Hermes gateway is not connected'), 'Could not send secret') + + return + } + + setSubmitting(true) + + try { + await gateway.request<{ status?: string }>('secret.respond', { + request_id: request.requestId, + value: secret + }) + triggerHaptic('submit') + clearSecretRequest(request.requestId) + } catch (error) { + notifyError(error, 'Could not send secret') + setSubmitting(false) + } + }, + [gateway, request] + ) + + const onOpenChange = useCallback( + (open: boolean) => { + if (!open && !submitting && request) { + void send('') + } + }, + [request, send, submitting] + ) + + const onSubmit = useCallback( + (event: FormEvent) => { + event.preventDefault() + void send(value) + }, + [send, value] + ) + + if (!request) { + return null + } + + return ( + + + + + + {request.envVar || 'Secret required'} + + {request.prompt || 'Hermes needs a credential to continue.'} + + +
+ setValue(event.target.value)} + placeholder={request.envVar || 'secret value'} + type="password" + value={value} + /> + + + + +
+
+
+ ) +} + +export function PromptOverlays() { + return ( + <> + + + + ) +} diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 20be83c75..c6c9cee48 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -55,6 +55,12 @@ export type GatewayEventPayload = { request_id?: string question?: string choices?: string[] | null + // approval.request (dangerous command / execute_code) — session-keyed + command?: string + description?: string + // secret.request (skill credential capture) + env_var?: string + prompt?: string } export function textPart(text: string): ChatMessagePart { diff --git a/apps/desktop/src/store/prompts.test.ts b/apps/desktop/src/store/prompts.test.ts new file mode 100644 index 000000000..3ed8ade8a --- /dev/null +++ b/apps/desktop/src/store/prompts.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { + $approvalRequest, + $secretRequest, + $sudoRequest, + clearAllPrompts, + clearApprovalRequest, + clearSecretRequest, + clearSudoRequest, + setApprovalRequest, + setSecretRequest, + setSudoRequest +} from './prompts' + +afterEach(() => { + clearAllPrompts() +}) + +describe('approval prompt store', () => { + it('holds the most recent session-keyed approval request', () => { + setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'recursive delete', sessionId: 's1' }) + + expect($approvalRequest.get()).toEqual({ + command: 'rm -rf /tmp/x', + description: 'recursive delete', + sessionId: 's1' + }) + }) + + it('clears unconditionally (approval is session-keyed, no request id)', () => { + setApprovalRequest({ command: 'x', description: 'd', sessionId: 's1' }) + clearApprovalRequest() + + expect($approvalRequest.get()).toBeNull() + }) +}) + +describe('sudo prompt store', () => { + it('clears only when the request id matches the in-flight prompt', () => { + setSudoRequest({ requestId: 'abc' }) + + // A stale clear for a different request must NOT drop the live prompt — + // otherwise a late response to a prior sudo ask would dismiss the current + // one and leave the agent blocked. + clearSudoRequest('stale') + expect($sudoRequest.get()).toEqual({ requestId: 'abc' }) + + clearSudoRequest('abc') + expect($sudoRequest.get()).toBeNull() + }) + + it('clears unconditionally when no request id is given', () => { + setSudoRequest({ requestId: 'abc' }) + clearSudoRequest() + + expect($sudoRequest.get()).toBeNull() + }) +}) + +describe('secret prompt store', () => { + it('carries env var and prompt, and clears on id match', () => { + setSecretRequest({ requestId: 'r1', envVar: 'OPENAI_API_KEY', prompt: 'Paste your key' }) + + expect($secretRequest.get()).toEqual({ + requestId: 'r1', + envVar: 'OPENAI_API_KEY', + prompt: 'Paste your key' + }) + + clearSecretRequest('mismatch') + expect($secretRequest.get()).not.toBeNull() + + clearSecretRequest('r1') + expect($secretRequest.get()).toBeNull() + }) +}) + +describe('clearAllPrompts', () => { + it('drops every in-flight prompt at once (turn end / interrupt)', () => { + setApprovalRequest({ command: 'x', description: 'd', sessionId: 's1' }) + setSudoRequest({ requestId: 'abc' }) + setSecretRequest({ requestId: 'r1', envVar: 'E', prompt: 'p' }) + + clearAllPrompts() + + expect($approvalRequest.get()).toBeNull() + expect($sudoRequest.get()).toBeNull() + expect($secretRequest.get()).toBeNull() + }) +}) diff --git a/apps/desktop/src/store/prompts.ts b/apps/desktop/src/store/prompts.ts new file mode 100644 index 000000000..11ba3a0bb --- /dev/null +++ b/apps/desktop/src/store/prompts.ts @@ -0,0 +1,86 @@ +import { atom } from 'nanostores' + +// Blocking interactive prompts the gateway raises mid-turn. Each maps to a +// `*.request` event the Python side emits while it blocks the agent thread +// waiting for a `*.respond` RPC. Without a renderer for these, the agent +// silently stalls until its timeout (default 5 min) and the tool is BLOCKED +// — the desktop app previously handled clarify.request but not these three, +// so dangerous-command approval, sudo, and secret prompts never surfaced. + +export interface ApprovalRequest { + command: string + description: string + sessionId: string | null +} + +// Approval is session-keyed on the backend (one in-flight approval per +// session, resolved via approval.respond {choice, session_id}). It carries +// no request_id, unlike sudo/secret which are _block()-style request/response. +export const $approvalRequest = atom(null) + +export function setApprovalRequest(request: ApprovalRequest): void { + $approvalRequest.set(request) +} + +export function clearApprovalRequest(): void { + $approvalRequest.set(null) +} + +export interface SudoRequest { + requestId: string +} + +export const $sudoRequest = atom(null) + +export function setSudoRequest(request: SudoRequest): void { + $sudoRequest.set(request) +} + +export function clearSudoRequest(requestId?: string): void { + const current = $sudoRequest.get() + + if (!current) { + return + } + + if (requestId && current.requestId !== requestId) { + return + } + + $sudoRequest.set(null) +} + +export interface SecretRequest { + requestId: string + envVar: string + prompt: string +} + +export const $secretRequest = atom(null) + +export function setSecretRequest(request: SecretRequest): void { + $secretRequest.set(request) +} + +export function clearSecretRequest(requestId?: string): void { + const current = $secretRequest.get() + + if (!current) { + return + } + + if (requestId && current.requestId !== requestId) { + return + } + + $secretRequest.set(null) +} + +// Drop every in-flight prompt. Called when a turn ends (message.complete / +// error) so a stale overlay can't linger past the turn that raised it — e.g. +// if the agent was interrupted while a prompt was open. +export function clearAllPrompts(): void { + $approvalRequest.set(null) + $sudoRequest.set(null) + $secretRequest.set(null) +} diff --git a/tools/skills_tool.py b/tools/skills_tool.py index bc19ff8b5..d771226f9 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -305,7 +305,13 @@ def _capture_required_environment_variables( } missing_names = [entry["name"] for entry in missing_entries] - if _is_gateway_surface(): + # Most gateway surfaces (messaging platforms) can't prompt for a secret, so + # they short-circuit to the "unsupported" hint. Interactive gateway surfaces + # — the desktop app / TUI — set HERMES_INTERACTIVE and register a + # secret-capture callback that routes to a secure secret.request overlay, so + # they fall through and actually prompt. (HERMES_INTERACTIVE is the same flag + # tools/approval.py uses to tell an interactive surface from a messaging one.) + if _is_gateway_surface() and not env_var_enabled("HERMES_INTERACTIVE"): return { "missing_names": missing_names, "setup_skipped": False, diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 68b64e8ec..338218cd8 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4154,6 +4154,13 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: approval_token = set_current_session_key(session["session_key"]) session_tokens = _set_session_context(session["session_key"]) + # The sudo password callback is thread-local (tools.terminal_tool + # _callback_tls), so wiring it on the build thread doesn't reach this + # turn thread — terminal sudo prompts would fall through to /dev/tty + # and hang the headless gateway. Re-wire here so the prompt routes to + # the sudo.request overlay. (secret capture is a module global, so + # re-running is a harmless no-op.) + _wire_callbacks(sid) cwd = _session_cwd(session) _register_session_cwd(session) cols = session.get("cols", 80)