Files
hermes-agent/apps/desktop/src/store/prompts.ts
T
Teknium f98cb00a8b fix(vault): 2FA review follow-ups — per-digit fill only for an unmistakable maxlength=1 widget; honour otpauth digits/period/algorithm
Reviewer findings on #107585:
- build_otp_fills split any >=4 code-like controls into digits. A page with
  promo/zip/referral 'code' inputs next to the real OTP box would have had a
  digit sprayed across unrelated fields. Split now requires exactly len(code)
  controls that are all maxlength=1, same form, adjacent in DOM order
  (inspection JS exports maxLength); anything else fills ONE field, the
  best-scoring one. Verified on the real Browser Use stack: 6-box widget gets
  one digit each; scattered page fills only the one-time-code input.
- normalize_otp_secret dropped digits/period/algorithm from otpauth:// URIs,
  so an 8-digit or SHA-256 authenticator would get wrong codes. Non-default
  parameters are now stored as seed|digits|period|algo and honoured (RFC 6238
  SHA-256 8-digit vector added); hotp:// is rejected explicitly.
- rebase on main (prompts.ts conflict) + prettier.
2026-09-10 11:48:01 -07:00

358 lines
13 KiB
TypeScript

import { atom, computed, type ReadableAtom } from 'nanostores'
import { $clarifyRequest, $clarifyRequests } from './clarify'
import { isSessionGone, isSessionGoneForBackgroundPolling, markSessionGone } from './runtime-gone'
import { $activeSessionId } from './session'
import { ambientRequestFor } from './session-gone-latch'
import { requestForOwnedSession } from './session-states'
// 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.
//
// Like clarify, every prompt is parked under the runtime session id that raised
// it (not one shared slot), so a *background* session running concurrently can
// raise an approval/sudo/secret prompt and have it wait — surfaced via the
// sidebar "needs input" badge — until the user switches to that chat. The
// exported $*Request view is scoped to the active session, so a background
// prompt never hijacks the foreground.
const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
interface KeyedPrompt {
sessionId: string | null
}
interface PromptStore<T extends KeyedPrompt> {
$active: ReadableAtom<null | T>
$all: ReadableAtom<Record<string, T>>
clear: (sessionId?: string | null, requestId?: string) => void
reset: () => void
set: (request: T) => void
}
// One per-session prompt kind: a map keyed by session, plus an active-session
// view for the overlays. `clear` drops one session's entry (a request-id
// mismatch is a no-op so a stale resolve can't wipe a newer prompt); with no
// session hint it drops every entry, optionally filtered by request id.
function keyedPromptStore<T extends KeyedPrompt>(): PromptStore<T> {
const $all = atom<Record<string, T>>({})
const idOf = (value: T): string | undefined => (value as { requestId?: string }).requestId
return {
$active: computed([$all, $activeSessionId], (all, activeId) => all[keyFor(activeId)] ?? null),
$all,
reset: () => $all.set({}),
set: request => $all.set({ ...$all.get(), [keyFor(request.sessionId)]: request }),
clear(sessionId, requestId) {
const all = $all.get()
if (sessionId !== undefined) {
const key = keyFor(sessionId)
const current = all[key]
if (current && !(requestId && idOf(current) !== requestId)) {
const next = { ...all }
delete next[key]
$all.set(next)
}
return
}
const next = Object.fromEntries(Object.entries(all).filter(([, v]) => requestId && idOf(v) !== requestId))
if (Object.keys(next).length !== Object.keys(all).length) {
$all.set(next as Record<string, T>)
}
}
}
}
// Approval is session-keyed on the backend and correlated by `request_id` when
// available (legacy ID-free responses remain FIFO-compatible). Resolved via
// approval.respond {choice, request_id, session_id}.
export interface ApprovalRequest extends KeyedPrompt {
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
allowPermanent?: boolean
choices?: string[]
command: string
description: string
requestId?: string
smartDenied?: boolean
}
interface ApprovalGateway {
request: (method: string, params: Record<string, unknown>) => Promise<unknown>
}
interface PendingApprovalPayload {
allow_permanent?: boolean
choices?: unknown
command?: unknown
description?: unknown
request_id?: unknown
smart_denied?: boolean
}
export interface SudoRequest extends KeyedPrompt {
requestId: string
}
export interface SecretRequest extends KeyedPrompt {
envVar: string
prompt: string
requestId: string
}
// External password-manager unlock (agent/vault_backends). Resolved via
// vault.unlock.respond {request_id, password}; "" keeps the manager locked.
export interface VaultUnlockRequest extends KeyedPrompt {
backend: string
displayName: string
requestId: string
}
const approval = keyedPromptStore<ApprovalRequest>()
const sudo = keyedPromptStore<SudoRequest>()
const secret = keyedPromptStore<SecretRequest>()
const vaultUnlock = keyedPromptStore<VaultUnlockRequest>()
// "Save this login" for the page the agent is on (tools/browser_vault_tool). Resolved via
// vault.save_login.respond {request_id, login: JSON {identifier, password}}; "" declines.
export interface VaultSaveLoginRequest extends KeyedPrompt {
origin: string
site: string
requestId: string
}
const vaultSave = keyedPromptStore<VaultSaveLoginRequest>()
// Second-factor code for the page the agent is on. Resolved via vault.code.respond
// {request_id, code}; "" skips.
export interface VaultCodeRequest extends KeyedPrompt {
site: string
hint: string
requestId: string
}
const vaultCode = keyedPromptStore<VaultCodeRequest>()
// Inline approval anchors, keyed by session: a tile's inline bar mounting must
// not suppress the PRIMARY session's floating fallback (and vice versa).
const $approvalInlineAnchors = atom<Record<string, number>>({})
export const $approvalRequest = approval.$active
export const setApprovalRequest = approval.set
export const clearApprovalRequest = approval.clear
export async function receiveApprovalRequest(gateway: ApprovalGateway | null, request: ApprovalRequest): Promise<void> {
setApprovalRequest(request)
if (gateway && request.requestId && request.sessionId) {
try {
await requestForOwnedSession(request.sessionId, ambientRequestFor(gateway), 'approval.received', {
request_id: request.requestId,
session_id: request.sessionId
})
} catch (error) {
if (isSessionGoneForBackgroundPolling(error)) {
markSessionGone(request.sessionId)
return
}
throw error
}
}
}
export async function replayPendingApproval(gateway: ApprovalGateway | null, sessionId: string | null): Promise<void> {
if (!gateway || !sessionId || isSessionGone(sessionId)) {
return
}
let rawResult: unknown
try {
rawResult = await requestForOwnedSession(sessionId, ambientRequestFor(gateway), 'approval.pending', {
session_id: sessionId
})
} catch (error) {
if (isSessionGoneForBackgroundPolling(error)) {
markSessionGone(sessionId)
return
}
throw error
}
const result =
rawResult && typeof rawResult === 'object' ? (rawResult as { approvals?: PendingApprovalPayload[] }) : {}
const pending = Array.isArray(result?.approvals) ? result.approvals[0] : undefined
if (!pending || typeof pending.request_id !== 'string') {
return
}
await receiveApprovalRequest(gateway, {
allowPermanent: pending.allow_permanent !== false,
choices: Array.isArray(pending.choices) ? pending.choices.filter(choice => typeof choice === 'string') : undefined,
command: typeof pending.command === 'string' ? pending.command : '',
description: typeof pending.description === 'string' ? pending.description : 'dangerous command',
requestId: pending.request_id,
sessionId,
smartDenied: pending.smart_denied === true
})
}
/** The prompt request for one specific session — the tile counterpart of the
* active-session `$*Request` views (same map, fixed key). */
export const sessionApprovalRequest = (sessionId: string | null) =>
computed(approval.$all, all => all[keyFor(sessionId)] ?? null)
export const sessionSudoRequest = (sessionId: string | null) =>
computed(sudo.$all, all => all[keyFor(sessionId)] ?? null)
export const sessionSecretRequest = (sessionId: string | null) =>
computed(secret.$all, all => all[keyFor(sessionId)] ?? null)
export function registerApprovalInlineAnchor(sessionId: string | null): () => void {
const key = keyFor(sessionId)
const bump = (delta: number) => {
const all = $approvalInlineAnchors.get()
const next = Math.max(0, (all[key] ?? 0) + delta)
$approvalInlineAnchors.set({ ...all, [key]: next })
}
bump(1)
return () => bump(-1)
}
/** True when session `sessionId` has an inline approval bar mounted, so its
* floating fallback should stand down. Per-session (not global). */
export const sessionApprovalInlineVisible = (sessionId: string | null) =>
computed($approvalInlineAnchors, anchors => (anchors[keyFor(sessionId)] ?? 0) > 0)
export const $sudoRequest = sudo.$active
export const setSudoRequest = sudo.set
export const clearSudoRequest = sudo.clear
export const $secretRequest = secret.$active
export const setSecretRequest = secret.set
export const clearSecretRequest = secret.clear
export const $vaultUnlockRequest = vaultUnlock.$active
export const setVaultUnlockRequest = vaultUnlock.set
export const clearVaultUnlockRequest = vaultUnlock.clear
export const $vaultUnlockRequests = vaultUnlock.$all
export const sessionVaultUnlockRequest = (sessionId: string | null) =>
computed(vaultUnlock.$all, all => all[keyFor(sessionId)] ?? null)
export const $vaultSaveLoginRequest = vaultSave.$active
export const setVaultSaveLoginRequest = vaultSave.set
export const clearVaultSaveLoginRequest = vaultSave.clear
export const $vaultSaveLoginRequests = vaultSave.$all
export const sessionVaultSaveLoginRequest = (sessionId: string | null) =>
computed(vaultSave.$all, all => all[keyFor(sessionId)] ?? null)
export const $vaultCodeRequest = vaultCode.$active
export const setVaultCodeRequest = vaultCode.set
export const clearVaultCodeRequest = vaultCode.clear
export const $vaultCodeRequests = vaultCode.$all
export const sessionVaultCodeRequest = (sessionId: string | null) =>
computed(vaultCode.$all, all => all[keyFor(sessionId)] ?? null)
// True when the active session is blocked on the user (clarify question or an
// approval / sudo / secret prompt). Mirrors the pet's `awaitingInput` concept
// (agent/pet/state.py): the turn is paused on you, not working — so callers can
// suppress "thinking" indicators and the Esc-to-interrupt shortcut while you
// decide, instead of treating the wait as an in-flight turn.
export const $activeSessionAwaitingInput = computed(
[
$clarifyRequest,
$approvalRequest,
$sudoRequest,
$secretRequest,
$vaultUnlockRequest,
$vaultSaveLoginRequest,
$vaultCodeRequest
],
(clarify, approval, sudo, secret, vault, save, code) =>
Boolean(clarify || approval || sudo || secret || vault || save || code)
)
/** True when `sessionId` is parked on a blocking prompt that typing cannot
* answer (approval / sudo / secret). Clarify is deliberately excluded: typing
* a real message IS an answer to a clarify ("none of these" — the composer
* skips it and routes the words), but no message text can approve a command
* or supply a password. Imperative read — the composer checks this on Enter,
* not on every render. */
export const hasBlockingPromptRequest = (sessionId: string | null | undefined): boolean => {
const key = keyFor(sessionId)
return Boolean(
approval.$all.get()[key] ||
sudo.$all.get()[key] ||
secret.$all.get()[key] ||
vaultUnlock.$all.get()[key] ||
vaultSave.$all.get()[key] ||
vaultCode.$all.get()[key]
)
}
/** Reactive twin of `hasBlockingPromptRequest`, for the composer's busy-action
* affordance (the primary button must advertise queue, not steer, while the
* turn is parked on a prompt Enter can't answer). */
export const sessionBlockingPrompt = (sessionId: string | null) =>
computed(
[approval.$all, sudo.$all, secret.$all, vaultUnlock.$all, vaultSave.$all, vaultCode.$all],
(approvals, sudos, secrets, vaults, saves, codes) => {
const key = keyFor(sessionId)
return Boolean(approvals[key] || sudos[key] || secrets[key] || vaults[key] || saves[key] || codes[key])
}
)
/** Per-session `awaitingInput` — the tile composer's counterpart of
* `$activeSessionAwaitingInput` (same sources, fixed session instead of the
* active one). */
export function sessionAwaitingInput(sessionId: string | null) {
return computed(
[$clarifyRequests, approval.$all, sudo.$all, secret.$all, vaultUnlock.$all, vaultSave.$all, vaultCode.$all],
(clarify, approvals, sudos, secrets, vaults, saves, codes) => {
const key = keyFor(sessionId)
return Boolean(
clarify[key] || approvals[key] || sudos[key] || secrets[key] || vaults[key] || saves[key] || codes[key]
)
}
)
}
// Drop in-flight prompts for `sessionId` (a turn ended) across all three kinds —
// or every parked prompt when no session is given (global reset / tests).
export function clearAllPrompts(sessionId?: string | null): void {
if (sessionId === undefined) {
approval.reset()
sudo.reset()
secret.reset()
vaultUnlock.reset()
vaultSave.reset()
vaultCode.reset()
$approvalInlineAnchors.set({})
return
}
approval.clear(sessionId)
sudo.clear(sessionId)
secret.clear(sessionId)
vaultUnlock.clear(sessionId)
vaultSave.clear(sessionId)
vaultCode.clear(sessionId)
}