fix(desktop): render approval/sudo/secret prompts so tools stop silently timing out (#38578)

* fix(desktop): render approval/sudo/secret prompts so tools stop silently timing out

The desktop app's gateway event handler (use-message-stream.ts) handled
clarify.request but had no case for approval.request, sudo.request, or
secret.request. When a tool needed approval, the gateway emitted
approval.request and blocked the agent thread in _await_gateway_decision()
for up to 5 min (approvals.gateway_timeout); the desktop dropped the unknown
event, never showed a dialog, then the agent returned BLOCKED. No prompt,
just a stall then a block.

The Ink TUI already handles all three (createGatewayEventHandler.ts); this
brings the Electron app to parity.

- store/prompts.ts: approval/sudo/secret atoms (+ request-id-guarded clears)
- components/prompt-overlays.tsx: Radix dialogs; close/Esc maps to refusal so
  silence is never mistaken for consent (parity with TUI Esc->deny)
- use-message-stream.ts: wire the three *.request cases; clearAllPrompts on
  message.complete so an overlay can't outlive its turn
- chat-messages.ts: GatewayEventPayload gains command/description/env_var/prompt
- mount PromptOverlays in the chat shell

* feat(desktop): inline tool-call approval bar (Cursor-style "Run")

Render dangerous-command / execute_code approval inline on the pending
tool row instead of as a modal. Binding is positional: the desktop
tool.start payload carries no structured args, but approval.request only
fires from the terminal/execute_code guards and the agent blocks on one
approval at a time, so the single pending row of those tools is the one
that raised it. Command/description text comes from $approvalRequest.

Drops ApprovalDialog from PromptOverlays (sudo/secret stay modal).

* style(desktop): make inline approval bar match Cursor's command card

Drop the amber alert styling for a neutral elevated card: command on a
terminal-prefixed row up top, a divided footer with the muted description
on the left and right-aligned controls — a ghost "Reject" (Esc) plus a
split primary "Run" (⌘⏎) whose chevron opens "Allow this session" /
"Always allow" / "Reject". Wire ⌘/Ctrl+Enter → Run and Esc → Reject to
match Cursor's accept/skip bindings, guarded against double-send via the
$approvalRequest atom.

* style(desktop): shrink inline approval to a tiny Cursor-style button strip

The running tool row already shows the command, so drop the whole card +
command echo + description band. What's left is a compact strip under the
row: a small split "Run ⌘⏎" button (chevron → Allow this session / Always
allow / Reject) and a ghost "Reject Esc", indented to sit under the row's
title text.

* style(desktop): drop the loud blue Run button for a quiet outlined control

Swap the primary (blue) Run for a subtle outlined split control — neutral
border, transparent fill, hover-accent — so the approval strip reads as
quiet inline affordance rather than a big CTA. Reject stays ghost.

* style(desktop): make Run a soft primary badge

Tint the Run split control with the primary color as a badge (bg-primary/10,
primary text, primary/25 border, rounded-md, hover primary/15) instead of a
solid CTA or a neutral outline.

* style(desktop): slim the approval chevron and space out Reject

The chevron button had ballooned because dropping the size prop fell back
to the big default size (h-9 + has-svg px-3). Pin size=xs everywhere and
give the chevron a tight w-5/px-0. Bump the gap between the Run badge and
Reject (gap-2.5) and loosen Reject's internal spacing.

* feat(desktop): confirm before "Always allow" persists an approval

"Always allow" writes the matched pattern to ~/.hermes/config.yaml and
suppresses the prompt in every future session — too consequential to fire
straight from a menu click. Route it through a confirm dialog that names
the pattern + command and the file it touches. The dialog owns the
keyboard while open so Esc closes it instead of denying the approval.

* fix(gateway): make sudo + secret prompts actually fire in the desktop

Tek's PR added the sudo/secret overlays and callback wiring, but neither
reached the live path:

- Sudo: the sudo password callback is thread-local (terminal_tool
  _callback_tls), and _wire_callbacks runs on the agent-build thread, not
  the turn thread that executes tools. At command time the callback was
  missing, so terminal sudo fell through to /dev/tty and hung the headless
  gateway. Re-wire callbacks at the top of the prompt-submit turn thread.

- Secret: skills_tool short-circuited to the "secret entry unsupported"
  hint for any gateway surface, before invoking the callback. Interactive
  surfaces (desktop/TUI) register a secret-capture callback that routes to
  the secret.request overlay; only short-circuit when no callback exists,
  so messaging still gets the hint but the desktop prompts.

* docs(desktop): drop Cursor references from approval comments

* docs(desktop): drop Cursor reference from prompt-overlays comment

* fix(skills): gate in-band secret capture on HERMES_INTERACTIVE, not callback presence

The desktop/sudo PR switched the gateway secret-capture short-circuit from
"any gateway surface" to "gateway surface with no callback registered". That
made a messaging gateway (telegram/discord/...) attempt interactive in-band
secret capture whenever any callback happened to be registered, instead of
returning the safe "setup unsupported" hint — and broke
test_gateway_still_loads_skill_but_returns_setup_guidance.

Discriminate on HERMES_INTERACTIVE instead: the desktop app / TUI set it in
_enable_gateway_prompts (alongside registering the secret.request callback),
while messaging platforms never do. This is the same flag tools/approval.py
uses to tell an interactive surface from a messaging one, so messaging keeps
the hint and desktop/TUI still prompt.

---------

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
This commit is contained in:
Teknium
2026-06-04 01:53:51 +00:00
committed by GitHub
co-authored by Brooklyn Nicholson
parent 04d620d91f
commit f66a929a6b
11 changed files with 780 additions and 1 deletions
@@ -0,0 +1,78 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HermesGateway } from '@/hermes'
import { $gateway } from '@/store/gateway'
import { $approvalRequest } from '@/store/prompts'
import { PendingToolApproval } from './tool-approval'
import type { ToolPart } from './tool-fallback-model'
function part(toolName: string): ToolPart {
return { toolName, type: `tool-${toolName}` } as unknown as ToolPart
}
function setRequest(command = 'rm -rf /tmp/x') {
$approvalRequest.set({ command, description: 'dangerous command', sessionId: 'sess-1' })
}
function mockGateway() {
const request = vi.fn().mockResolvedValue({ resolved: true })
$gateway.set({ request } as unknown as HermesGateway)
return request
}
afterEach(() => {
cleanup()
$approvalRequest.set(null)
$gateway.set(null)
})
describe('PendingToolApproval', () => {
it('renders nothing when there is no pending approval', () => {
const { container } = render(<PendingToolApproval part={part('terminal')} />)
expect(container.innerHTML).toBe('')
})
it('renders nothing for tools that never raise approval', () => {
setRequest()
const { container } = render(<PendingToolApproval part={part('read_file')} />)
expect(container.innerHTML).toBe('')
})
it('renders the inline run/reject controls on the pending terminal row', () => {
setRequest('chmod -R 777 /tmp/x')
render(<PendingToolApproval part={part('terminal')} />)
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(<PendingToolApproval part={part('terminal')} />)
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(<PendingToolApproval part={part('terminal')} />)
fireEvent.click(screen.getByRole('button', { name: /Reject/ }))
await waitFor(() => {
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'sess-1' })
})
})
})
@@ -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 <ApprovalBar request={request} />
}
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<ApprovalChoice | null>(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 (
<div className="mt-1 flex items-center gap-2.5 ps-5" data-slot="tool-approval-inline">
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
<Button
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
onClick={() => void respond('once')}
size="xs"
variant="ghost"
>
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : 'Run'}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
</Button>
<span aria-hidden className="w-px self-stretch bg-primary/20" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="More approval options"
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
size="xs"
variant="ghost"
>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-44">
<DropdownMenuItem onSelect={() => void respond('session')}>Allow this session</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
// 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
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
Reject
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Button
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
disabled={busy}
onClick={() => void respond('deny')}
size="xs"
variant="ghost"
>
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : 'Reject'}
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
</Button>
<Dialog onOpenChange={setConfirmAlways} open={confirmAlways}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Always allow this command?</DialogTitle>
<DialogDescription>
This adds the {request.description} pattern to your permanent allowlist (
<code className="font-mono text-xs">~/.hermes/config.yaml</code>). Hermes wont ask again for commands
like this in this session or any future one.
</DialogDescription>
</DialogHeader>
{request.command.trim() && (
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-words rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-2.5 py-1.5 font-mono text-xs leading-snug text-foreground">
{request.command.trim()}
</pre>
)}
<DialogFooter>
<Button onClick={() => setConfirmAlways(false)} size="sm" variant="ghost">
Cancel
</Button>
<Button
onClick={() => {
setConfirmAlways(false)
void respond('always')
}}
size="sm"
variant="destructive"
>
Always allow
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -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) {
</span>
</DisclosureRow>
</div>
{isPending && <PendingToolApproval part={part} />}
{open && (
<div className="grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
@@ -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<HTMLFormElement>) => {
event.preventDefault()
void send(password)
},
[password, send]
)
if (!request) {
return null
}
return (
<Dialog onOpenChange={onOpenChange} open>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Lock className="size-4 text-primary" />
Administrator password
</DialogTitle>
<DialogDescription>
Hermes needs your sudo password to run a privileged command. It is sent only to your local agent.
</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={onSubmit}>
<Input
autoFocus
disabled={submitting}
onChange={event => setPassword(event.target.value)}
placeholder="sudo password"
type="password"
value={password}
/>
<DialogFooter>
<Button disabled={submitting} onClick={() => void send('')} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={submitting} type="submit">
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : 'Send'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
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<HTMLFormElement>) => {
event.preventDefault()
void send(value)
},
[send, value]
)
if (!request) {
return null
}
return (
<Dialog onOpenChange={onOpenChange} open>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<KeyRound className="size-4 text-primary" />
{request.envVar || 'Secret required'}
</DialogTitle>
<DialogDescription>{request.prompt || 'Hermes needs a credential to continue.'}</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={onSubmit}>
<Input
autoFocus
disabled={submitting}
onChange={event => setValue(event.target.value)}
placeholder={request.envVar || 'secret value'}
type="password"
value={value}
/>
<DialogFooter>
<Button disabled={submitting} onClick={() => void send('')} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={submitting || !value} type="submit">
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : 'Send'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export function PromptOverlays() {
return (
<>
<SudoDialog />
<SecretDialog />
</>
)
}