feat(chat): handle gateway sudo.request / secret.request via hardened modal#606
feat(chat): handle gateway sudo.request / secret.request via hardened modal#6060xr00tf3rr3t wants to merge 3 commits into
Conversation
The stock build dead-ends a mid-turn clarify.request — it interrupts the session
and finishes with "Hermes One does not yet expose that gateway dialog," so the
agent's clarifying questions can never be answered from the desktop.
Render clarify inline in the chat transcript instead, following the existing
streaming-event pattern (tool_event / reasoning):
- main/hermes.ts: a request_id-keyed pending-clarify registry; the clarify.request
handler registers a resolver (closure over the gateway client) and surfaces the
question via a new onClarify callback instead of interrupting. The resolver
forwards the user's answer to clarify.respond. Stale resolvers are cleared on
turn end. sudo.request/secret.request are left unchanged (deliberate follow-up).
- main/index.ts: bridge onClarify -> safeSend("chat-clarify-request") and a
clarify-respond IPC handler that resolves the pending request.
- preload: onClarifyRequest (main->renderer) + respondClarify (renderer->main).
- renderer: a ClarifyMessage variant in the ChatMessage union; useChatIPC appends
it (idempotent on request_id); MessageList dispatches a new ClarifyCard that
renders choice buttons / open-ended textarea / skip, answers via respondClarify,
and flips to a read-only resolved state. Skip sends an empty answer so the agent
proceeds autonomously.
- i18n (en), themed CSS, and ClarifyCard.test.tsx (5 cases).
Tests: 1044 pass (+5). typecheck + lint + production build all green.
…ery failures Addresses review feedback on PR fathah#604: 1. Transcript ordering (sessionHistory.ts): a clarify card is renderer-only and has no reconciliationKey, so reconcileStreamedWithDb flushed it to the suffix — rendering it BELOW any agent content streamed after the user answered, the reverse of what they saw live. Add repositionClarifyCards(): a pure post-pass that re-anchors each card immediately after its streamed predecessor, with a safety net so a card is never dropped if its predecessor was deduped. 2. Failed delivery (ClarifyCard.tsx): submit() previously called onResolved in a finally block, marking the card answered even when respondClarify rejected or returned false (no pending request — e.g. the turn already ended). Now resolve only on confirmed delivery; on failure surface an inline error and keep the controls live for a retry. Tests: +2 reconcile ordering cases (mid-stream + leading card), +2 ClarifyCard failure-path cases (returns-false, rejects). Suite 1048 pass. typecheck + build green. Diffs kept surgical (no formatting sweep of existing lines).
… modal
The stock build dead-ends sudo.request / secret.request the same way clarify
used to — it interrupts the turn with "Hermes One does not yet expose that
gateway dialog," so any agent command needing sudo or a secret value can't
proceed from the desktop.
Unlike clarify (an inline transcript card), a sudo password / secret value is
sensitive and must never land in chat scrollback. So this reuses the installer's
existing hardened askpass modal (showPasswordDialog): CSP-locked
default-src 'none', sandboxed, contextIsolation, ephemeral data-URL, value never
persisted. showPasswordDialog is parameterized with title/heading (defaults
unchanged for the installer caller) and exported.
- gatewayPrompt.ts: promptSudoPassword() / promptSecretValue(envVar, prompt)
wrap the modal; a new setGatewayPromptParent() lets index.ts hand in the main
window. Cancel maps to "" — a safe skip the gateway already handles
(secret.request -> {skipped:true}; sudo lets terminal sudo fail cleanly).
- hermes.ts: the sudo/secret stream handler collects via the modal and forwards
with the gateway's exact shapes — sudo.respond {request_id, password} and
secret.respond {request_id, value} (verified against tui_gateway/server.py).
- askpass-security.test.ts: +4 cases pinning modal reuse, no-log/no-persist,
the exact respond shapes, and cancel->skip.
Stacks on the inline-clarify PR (fathah#604). Suite 1052 pass (+4); typecheck, lint
(--quiet), build all green.
Greptile SummaryThis PR wires up the gateway's
Confidence Score: 3/5Safe to merge after adding a The sudo/secret collect-and-forward chain fires unconditionally once the modal resolves, even if the turn was cancelled or errored while the modal was open. The src/main/hermes.ts — the sudo/secret event handler around the Important Files Changed
Reviews (1): Last reviewed commit: "feat(chat): handle gateway sudo.request ..." | Re-trigger Greptile |
| void collect | ||
| .then((answer) => { | ||
| const method = isSudo ? "sudo.respond" : "secret.respond"; | ||
| const params = isSudo | ||
| ? { request_id: requestId, password: answer } | ||
| : { request_id: requestId, value: answer }; | ||
| return client.request(method, params, 300_000); | ||
| }) |
There was a problem hiding this comment.
Missing
finished guard before forwarding credentials to the gateway. If cancel() or finish() is called while the askpass modal is still open (e.g. the user aborts the chat turn), finished becomes true but the modal stays open with no way to dismiss it programmatically. When the user eventually submits the modal, collect resolves and the .then() fires unconditionally — forwarding the sudo password or secret value to the gateway on a session that has already been torn down. The clarify path avoids this by clearing pendingClarify in finish()/cancel(), making stale responses a no-op; the same protection is missing here.
| void collect | |
| .then((answer) => { | |
| const method = isSudo ? "sudo.respond" : "secret.respond"; | |
| const params = isSudo | |
| ? { request_id: requestId, password: answer } | |
| : { request_id: requestId, value: answer }; | |
| return client.request(method, params, 300_000); | |
| }) | |
| void collect | |
| .then((answer) => { | |
| if (finished) return; | |
| const method = isSudo ? "sudo.respond" : "secret.respond"; | |
| const params = isSudo | |
| ? { request_id: requestId, password: answer } | |
| : { request_id: requestId, value: answer }; | |
| return client.request(method, params, 300_000); | |
| }) |
| export async function promptSecretValue( | ||
| envVar: string, | ||
| prompt: string, | ||
| ): Promise<string> { | ||
| const parent = parentWindowGetter(); | ||
| const detail = | ||
| (prompt && prompt.trim()) || | ||
| `The agent is requesting a value for ${envVar || "a secret"}.`; | ||
| const value = await showPasswordDialog(parent, detail, { | ||
| title: "Secret Required", | ||
| heading: envVar | ||
| ? `Hermes needs a value for ${envVar}` | ||
| : "Hermes needs a secret value", | ||
| }); | ||
| return value ?? ""; | ||
| } |
There was a problem hiding this comment.
envVar used verbatim in the modal heading without sanitisation. envVar comes directly from event.payload.env_var in the gateway event stream — an untrusted, agent-controlled string. It flows into the heading option and is rendered as <div class="title">${safeHeading}</div> in buildDialogHtml, which does HTML-escape it, so the XSS risk in the rendered DOM is mitigated. However, the raw value also appears in the title option, which is used as the OS-level window title (win.title), where HTML escaping is irrelevant. A length cap and simple printable-chars validation on envVar would close the spoofing surface entirely.
Summary
sudo.request/secret.requestprompts instead of dead-ending the turn with "Hermes One does not yet expose that gateway dialog" (the same dead-end feat(chat): render gateway clarify.request as an inline card #604 removed forclarify.request)default-src 'none',script-src 'none', sandboxed,contextIsolation, ephemeral data-URL) and never rendered into the chat transcript or persistedshowPasswordDialogis parameterized withtitle/heading(defaults unchanged, so the installer's existing call is untouched) and exported;gatewayPrompt.tswraps it aspromptSudoPassword()/promptSecretValue(envVar, prompt)sudo.respond { request_id, password }andsecret.respond { request_id, value }— and Cancel maps to an empty answer, a safe skip the gateway already handles (secret.request→{ skipped: true }; sudo -S -p '' lets the terminal prompt fail cleanly), so the turn never wedgesWhy a modal, not an inline card
clarify.request(#604) is a non-sensitive question → inline transcript card. A sudo password or secret must never land in scrollback, so this reuses the installer's already-hardened askpass dialog rather than introducing a new surface. Same trust boundary the app already uses to collect the installer's sudo password.Testing
npm run typechecknpm run buildnpm run lint -- --quiet(no new problems from this change; one pre-existing error insrc/main/ssh-remote.tsis untouched here)git diff --checknpm test— 1052 passing (+4 newaskpass-security.test.tscases: modal reuse, no-log/no-persist, exact respond shapes, cancel→skip)Note
Stacked on #604 (inline clarify card) — that branch is its base, so the diff shows the clarify commits too until #604 merges. The only new commit here is the top one (
feat(chat): handle gateway sudo.request / secret.request via hardened modal). Happy to rebase ontomainonce #604 lands.